diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1e6a8afc..74401d3efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +### A reinstall no longer picks up the previous installation's Bot files and sign-ins + +A desktop installation set up again after its folder was deleted reused whatever the previous one +left in the container engine: Bot workspace files, and each Bot's browser profile with its website +sign-ins, under the same Bot ids. Only the database was checked. Start now lists every volume and +stopped container the previous installation left, and offers one confirmed reset that removes them; +nothing is reused without asking. A container from it that is still running stops the reset until it +is stopped. An installation with its saved key keeps its data as before. + ### A new desktop install starts its server again A desktop install of v0.2.0 stopped at start with `server stopped straight away (exit status: 1)` and diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3877d8b906..2e32e1402a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -13,8 +13,9 @@ mod test_support; use openbot_desktop_lib::{ acquire, deployment, deployment_release, engine, env as openbot_env, harness, host_access, - install, preparation, problem::Problem, provider, pull_metrics, quiet, stack, supervise, - telemetry, tray, windows as win, + install, preparation, + problem::{LeftoverReset, Problem}, + provider, pull_metrics, quiet, stack, supervise, telemetry, tray, windows as win, }; const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice"; @@ -57,7 +58,7 @@ struct Shell { last_failure: Mutex>, /// Reading the notification must not make a partially running deployment adoptable again. recovery_required: Mutex>, - /// An explicit reset remains bound to the engine that found the leftover database. + /// An explicit reset remains bound to the engine and the exact volumes it offered. leftover_database: Mutex>, selected_root: Mutex>, root: Mutex>, @@ -102,7 +103,10 @@ struct ContainerDeployment { struct LeftoverDatabase { root: PathBuf, - volume: String, + /// Sorted, exactly as offered: the only set a confirmation may remove. + volumes: Vec, + /// Sorted names of the stopped containers removed before those volumes. + containers: Vec, address: engine::Address, } @@ -1094,46 +1098,113 @@ fn require_existing_encryption_key( Ok(()) } +/** +Refuse a Start that would mint a key for, or reattach, a previous installation's data. + +A deleted installation folder takes `.env` and the vault with it, but the engine keeps every +volume. Without this, a new installation reattaches the previous one's Bots' workspace files and +browser profiles by name, and only the database was ever checked. An installation with its usable +key is a returning one and is never probed: it keeps its data. +*/ fn require_existing_encryption_key_with_recovery( root: &Path, secrets: &stack::Secrets, existing_postgres_volume: impl FnOnce() -> Result, - resettable_volume: impl FnOnce() -> Result, Problem>, + leftovers: impl FnOnce() -> Result, ) -> Result<(), Problem> { + if secrets + .get("KEY_ENCRYPTION_KEY") + .is_some_and(|value| openbot_env::usable_encryption_key(value)) + { + return Ok(()); + } let mut volume_exists = false; - require_existing_encryption_key(root, secrets, || { + let guarded = require_existing_encryption_key(root, secrets, || { volume_exists = existing_postgres_volume()?; Ok(volume_exists) - }) - .map_err(|mut problem| { + }); + let mut problem = match guarded { // A failed probe is not proof of an existing volume. Configured roots never reach here. - if volume_exists { - let recovery = fresh_root_without_encryption_key(root, secrets).and_then(|fresh| { - if fresh { - resettable_volume() - } else { - Ok(None) + Err(problem) if !volume_exists => return Err(problem), + Err(problem) => problem, + Ok(()) => { + // No database, so a key would be minted. Anything else left behind would be reused. + let found = leftovers()?; + if found.volumes.is_empty() { + return Ok(()); + } + let mut problem = Problem::plain("OpenBot found data from a previous installation, such as Bots' files and browser sign-ins. OpenBot will not reuse it in a new installation. Restore the previous installation's folder to keep it, or reset the leftover data to start fresh."); + match fresh_root_without_encryption_key(root, secrets) { + Ok(true) if found.resettable() => { + problem.leftover = Some(Box::new(LeftoverReset { + containers: found.container_names(), + volumes: found.volumes, + })); } - }); - match recovery { - Ok(volume) => { - if volume.is_some() { - problem.said = "OpenBot found a database from a previous installation, but its encryption key is unavailable. Restore the original key to keep its saved data, or reset the leftover database to start fresh.".into(); + Ok(true) => { + if let Some(running) = found.running_said() { + problem.said = format!("{} {running}", problem.said); } - problem.database_reset = volume; - } - Err(verification) => { - problem.detail = Some(match verification.detail { - Some(detail) => format!("{}\n{detail}", verification.said), - None => verification.said, - }); } + Ok(false) => {} + Err(verification) => problem.detail = Some(verification_detail(verification)), } + return Err(problem); } - problem + }; + let recovery = fresh_root_without_encryption_key(root, secrets).and_then(|fresh| { + if fresh { + leftovers().map(Some) + } else { + Ok(None) + } + }); + match recovery { + Ok(Some(found)) if found.database.is_some() && found.resettable() => { + problem.said = if found.volumes.len() > 1 { + "OpenBot found a database and other data from a previous installation, but its encryption key is unavailable. Restore the original key to keep its saved data, or reset the leftover data to start fresh." + } else { + "OpenBot found a database from a previous installation, but its encryption key is unavailable. Restore the original key to keep its saved data, or reset the leftover database to start fresh." + } + .into(); + problem.leftover = Some(Box::new(LeftoverReset { + containers: found.container_names(), + volumes: found.volumes, + })); + problem.database_reset = found.database; + } + Ok(Some(found)) if found.database.is_some() => { + if let Some(running) = found.running_said() { + problem.said = format!("{} {running}", problem.said); + } + } + Ok(_) => {} + Err(verification) => problem.detail = Some(verification_detail(verification)), + } + Err(problem) +} + +/// The reset a refused Start offered, bound to the exact containers and volumes it named. +fn leftover_offer( + problem: &Problem, + root: &Path, + address: &engine::Address, +) -> Option { + (!problem.leftover_volumes().is_empty()).then(|| LeftoverDatabase { + root: root.to_path_buf(), + volumes: problem.leftover_volumes().to_vec(), + containers: problem.leftover_containers().to_vec(), + address: address.clone(), }) } +fn verification_detail(verification: Problem) -> String { + match verification.detail { + Some(detail) => format!("{}\n{detail}", verification.said), + None => verification.said, + } +} + /// Destructive recovery needs positive evidence that root metadata is readable and unconfigured. /// The ordinary startup guard keeps its existing behavior when metadata is unknown. fn fresh_root_without_encryption_key( @@ -1147,7 +1218,7 @@ fn fresh_root_without_encryption_key( return Ok(false); } let unknown = || { - Problem::plain("OpenBot could not verify that this is an unconfigured installation. Its leftover database cannot be reset here.") + Problem::plain("OpenBot could not verify that this is an unconfigured installation. Its leftover data cannot be reset here.") }; let settings = openbot_env::read_already_set(&root.join(".env"), &["DATABASE_URL"]) .map_err(|_| unknown())?; @@ -1173,13 +1244,16 @@ fn fresh_root_without_encryption_key( fn reset_leftover_database_with( shell: &Shell, root: &Path, - volume: &str, + volumes: &[String], + containers: &[String], confirmed: bool, read_secrets: impl FnOnce() -> Result, reset: impl FnOnce(&engine::Address, &stack::Secrets) -> Result<(), Problem>, ) -> Result<(), Problem> { if !confirmed { - return Err(Problem::plain("Confirm that you want to permanently delete the leftover database before resetting it.")); + return Err(Problem::plain( + "Confirm that you want to permanently delete the leftover data before resetting it.", + )); } let attempt = StartAttempt::begin(shell)?; let _startup = attempt.lock_current()?; @@ -1187,7 +1261,7 @@ fn reset_leftover_database_with( || shell.root.lock().unwrap().is_some() || !shell.children.lock().unwrap().is_empty() { - return Err(Problem::plain("OpenBot still owns running services. Choose Stop OpenBot before resetting a leftover database.")); + return Err(Problem::plain("OpenBot still owns running services. Choose Stop OpenBot before resetting leftover data.")); } if shell .selected_root @@ -1196,15 +1270,26 @@ fn reset_leftover_database_with( .as_ref() .is_some_and(|selected| selected != root) { - return Err(Problem::plain("The selected installation changed. Try Start again before resetting its leftover database.")); + return Err(Problem::plain("The selected installation changed. Try Start again before resetting its leftover data.")); } + let sorted = |names: &[String]| { + let mut names = names.to_vec(); + names.sort(); + names + }; + let (volumes, containers) = (sorted(volumes), sorted(containers)); let address = shell.leftover_database.lock().unwrap().as_ref() - .filter(|offer| offer.root == root && offer.volume == volume) + .filter(|offer| { + offer.root == root + && !volumes.is_empty() + && offer.volumes == volumes + && offer.containers == containers + }) .map(|offer| offer.address.clone()) - .ok_or_else(|| Problem::plain("The leftover database reset offer is no longer current. Try Start again before confirming a reset."))?; + .ok_or_else(|| Problem::plain("The leftover data reset offer is no longer current. Try Start again before confirming a reset."))?; let secrets = read_secrets()?; if !fresh_root_without_encryption_key(root, &secrets)? { - return Err(Problem::plain("This installation is already configured or has its original encryption key. Its database cannot be reset here.")); + return Err(Problem::plain("This installation is already configured or has its original encryption key. Its data cannot be reset here.")); } reset(&address, &secrets)?; *shell.leftover_database.lock().unwrap() = None; @@ -1214,7 +1299,8 @@ fn reset_leftover_database_with( #[tauri::command] async fn reset_leftover_database( root: String, - volume: String, + volumes: Vec, + containers: Vec, confirmed: bool, app: tauri::AppHandle, ) -> Result<(), Problem> { @@ -1223,7 +1309,8 @@ async fn reset_leftover_database( reset_leftover_database_with( &shell, &root, - &volume, + &volumes, + &[], confirmed, || { openbot_desktop_lib::vault::already_given_no_ui( @@ -1232,7 +1319,9 @@ async fn reset_leftover_database( &openbot_env::MINTED[..], ) }, - |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume), + |address, secrets| { + stack::reset_leftover_volumes(address, &root, secrets, &volumes, &containers) + }, ) } @@ -1421,18 +1510,10 @@ async fn start_stack_inner( &root, &existing_secrets, || stack::postgres_volume_exists(&found, &root, &existing_secrets), - || stack::leftover_database_volume(&found, &root, &existing_secrets), + || stack::leftover_volumes(&found, &root, &existing_secrets), ) .inspect_err(|problem| { - *shell.leftover_database.lock().unwrap() = - problem - .database_reset - .as_ref() - .map(|volume| LeftoverDatabase { - root: root.clone(), - volume: volume.clone(), - address: found.clone(), - }); + *shell.leftover_database.lock().unwrap() = leftover_offer(problem, &root, &found); })?; // Only this deployment's recorded hosts are reclaimed; its existing containers are reusable. @@ -5061,12 +5142,14 @@ mod tests { &root, &stack::Secrets::new(), || Ok(true), - || Ok(Some("openbot_postgres-data".into())), + || Ok(database_leftovers(&[])), ) .unwrap_err(); + let offered = serde_json::to_value(error).unwrap(); + assert_eq!(offered["database_reset"], "openbot_postgres-data"); assert_eq!( - serde_json::to_value(error).unwrap()["database_reset"], - "openbot_postgres-data" + offered["leftover_reset"], + serde_json::json!(["openbot_postgres-data"]) ); for (file, content) in [ (".env", "DATABASE_URL=postgres://fixture\n"), @@ -5091,10 +5174,9 @@ mod tests { || panic!("unknown or configured roots must not offer deletion"), ) .unwrap_err(); - assert!(serde_json::to_value(error) - .unwrap() - .get("database_reset") - .is_none()); + let refused = serde_json::to_value(error).unwrap(); + assert!(refused.get("database_reset").is_none()); + assert!(refused.get("leftover_reset").is_none()); std::fs::remove_file(root.join(file)).unwrap(); } let secrets = stack::Secrets::from([( @@ -5111,6 +5193,245 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + fn database_leftovers(others: &[&str]) -> stack::LeftoverVolumes { + let mut volumes: Vec = others.iter().map(|name| name.to_string()).collect(); + volumes.push("openbot_postgres-data".into()); + volumes.sort(); + stack::LeftoverVolumes { + database: Some("openbot_postgres-data".into()), + database_exists: true, + volumes, + ..Default::default() + } + } + + fn leftovers_without_database(volumes: &[&str]) -> stack::LeftoverVolumes { + stack::LeftoverVolumes { + database: None, + database_exists: false, + volumes: volumes.iter().map(|name| name.to_string()).collect(), + ..Default::default() + } + } + + const BOT_LEFTOVERS: [&str; 3] = [ + "openbot-profile-general-assistant", + "openbot-workspace-general-assistant", + "openbot_agent-profiles", + ]; + + /// Deleting the folder leaves Bots' files and sign-ins in the engine even with no database. + #[test] + fn a_fresh_root_refuses_to_reattach_any_leftover_volume() { + let root = temp_root("leftover-volumes-offer"); + std::fs::create_dir_all(&root).unwrap(); + + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(leftovers_without_database(&BOT_LEFTOVERS)), + ) + .unwrap_err(); + assert!( + error.said.contains("previous installation"), + "{}", + error.said + ); + let offered = serde_json::to_value(error).unwrap(); + assert!(offered.get("database_reset").is_none()); + assert_eq!(offered["leftover_reset"], serde_json::json!(BOT_LEFTOVERS)); + + // Nothing left behind starts exactly as before. + assert!(require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(stack::LeftoverVolumes::default()), + ) + .is_ok()); + + // A probe that fails is not permission to reattach whatever is there. + assert!(require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Err(Problem::plain("inventory failed")), + ) + .is_err()); + + // A database joins the same confirmation as everything else it would be reset with. + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || Ok(database_leftovers(&BOT_LEFTOVERS)), + ) + .unwrap_err(); + assert_eq!( + error.database_reset.as_deref(), + Some("openbot_postgres-data") + ); + assert_eq!( + error.leftover_volumes(), + database_leftovers(&BOT_LEFTOVERS).volumes + ); + + // A database OpenBot cannot verify keeps the existing refusal, with nothing offered. + let mut unverified = leftovers_without_database(&BOT_LEFTOVERS); + unverified.database_exists = true; + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || Ok(unverified), + ) + .unwrap_err(); + assert_eq!(error.database_reset, None); + assert!(error.leftover_volumes().is_empty()); + assert!(error + .said + .contains("OpenBot will not replace the key automatically")); + + // Stopped containers that hold the volumes join the confirmation; running ones block it. + let mut held = leftovers_without_database(&BOT_LEFTOVERS); + held.containers = vec![stack::LeftoverContainer { + name: "openbot-computer-general-assistant".into(), + id: "c-computer".into(), + }]; + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(held.clone()), + ) + .unwrap_err(); + assert_eq!(error.leftover_volumes(), BOT_LEFTOVERS); + assert_eq!( + error.leftover_containers(), + ["openbot-computer-general-assistant"] + ); + assert_eq!( + serde_json::to_value(&error).unwrap()["leftover_containers"], + serde_json::json!(["openbot-computer-general-assistant"]) + ); + held.running = vec!["openbot-agent-computer-1".into()]; + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(held), + ) + .unwrap_err(); + assert!(error.leftover_volumes().is_empty() && error.leftover_containers().is_empty()); + assert!( + error.said.contains("openbot-agent-computer-1") && error.said.contains("still running"), + "{}", + error.said + ); + + // Unknown root metadata refuses Start but never offers deletion. + std::fs::write( + root.join(openbot_desktop_lib::saved_intent::FILE), + "invalid-settings-secret", + ) + .unwrap(); + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(leftovers_without_database(&BOT_LEFTOVERS)), + ) + .unwrap_err(); + assert!(error.leftover_volumes().is_empty()); + assert!(!format!("{error:?}").contains("invalid-settings-secret")); + std::fs::remove_file(root.join(openbot_desktop_lib::saved_intent::FILE)).unwrap(); + + // A returning installation with its key keeps its data and is never probed. + let secrets = stack::Secrets::from([( + "KEY_ENCRYPTION_KEY".into(), + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + )]); + std::fs::write(root.join(".env"), "DATABASE_URL=postgres://fixture\n").unwrap(); + assert!(require_existing_encryption_key_with_recovery( + &root, + &secrets, + || panic!("original key needs no probe"), + || panic!("original key keeps its volumes") + ) + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_refused_start_offers_exactly_the_containers_and_volumes_it_named() { + let root = temp_root("leftover-offer"); + let address = engine::Address::new(engine::Engine::Docker, None); + assert!(leftover_offer(&Problem::plain("refused"), &root, &address).is_none()); + let mut problem = Problem::plain("refused"); + problem.leftover = Some(Box::new(LeftoverReset { + volumes: BOT_LEFTOVERS.map(String::from).to_vec(), + containers: vec!["openbot-computer-general-assistant".into()], + })); + let offer = leftover_offer(&problem, &root, &address).unwrap(); + assert_eq!(offer.root, root); + assert_eq!(offer.volumes, BOT_LEFTOVERS); + assert_eq!(offer.containers, ["openbot-computer-general-assistant"]); + assert_eq!(offer.address, address); + } + + #[test] + fn leftover_reset_is_bound_to_the_exact_offered_set() { + let root = temp_root("leftover-volumes-command"); + std::fs::create_dir_all(&root).unwrap(); + let shell = Shell::default(); + let offered: Vec = BOT_LEFTOVERS.map(String::from).to_vec(); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volumes: offered.clone(), + containers: vec!["openbot-computer-general-assistant".into()], + address: engine::Address::new(engine::Engine::Docker, None), + }); + let mut superset = offered.clone(); + superset.push("other-profile-knowledge".into()); + let containers = ["openbot-computer-general-assistant".to_string()]; + for (stale, stale_containers) in [ + (offered[..2].to_vec(), &containers[..]), + (superset, &containers[..]), + (Vec::new(), &containers[..]), + (offered.clone(), &[][..]), + ( + offered.clone(), + &[containers[0].clone(), "other-computer-knowledge".into()][..], + ), + ] { + assert!(reset_leftover_database_with( + &shell, + &root, + &stale, + stale_containers, + true, + || panic!("a changed set must refuse before reading credentials"), + |_, _| panic!("a changed set must not remove anything") + ) + .is_err()); + } + let mut reordered = offered.clone(); + reordered.reverse(); + reset_leftover_database_with( + &shell, + &root, + &reordered, + &containers, + true, + || Ok(stack::Secrets::new()), + |_, _| Ok(()), + ) + .unwrap(); + assert!(shell.leftover_database.lock().unwrap().is_none()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn leftover_database_reset_rechecks_confirmation_configuration_key_and_ownership() { let root = temp_root("leftover-database-command"); @@ -5119,7 +5440,8 @@ mod tests { let offer = || { *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { root: root.clone(), - volume: "openbot_postgres-data".into(), + volumes: vec!["openbot_postgres-data".into()], + containers: Vec::new(), address: engine::Address::new( engine::Engine::Podman, Some("original-machine".into()), @@ -5130,7 +5452,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], false, || panic!("unconfirmed must not access credentials"), |_, _| panic!("unconfirmed must not delete") @@ -5139,7 +5462,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || Ok(stack::Secrets::new()), |_, _| Ok(()) @@ -5161,7 +5485,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || Ok(stack::Secrets::new()), |_, _| panic!("configured or unknown root must not delete") @@ -5173,7 +5498,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || Ok(stack::Secrets::new()), |_, _| panic!("unreadable settings must not delete") @@ -5187,7 +5513,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || Ok(secrets), |_, _| panic!("restored key must prevent deletion") @@ -5197,7 +5524,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || panic!("owned hosts must refuse before credential access"), |_, _| panic!("owned hosts must prevent deletion") @@ -5211,7 +5539,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || panic!("owned containers must refuse"), |_, _| panic!("owned containers must prevent deletion") @@ -5222,7 +5551,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || panic!("concurrent startup must refuse"), |_, _| panic!("concurrent startup must prevent deletion") @@ -5232,7 +5562,8 @@ mod tests { assert!(reset_leftover_database_with( &shell, &root, - "openbot_postgres-data", + &["openbot_postgres-data".to_string()], + &[], true, || Ok(stack::Secrets::new()), |_, _| { @@ -5263,11 +5594,12 @@ mod tests { let shell = Shell::default(); let original = engine::Address::new(engine::Engine::Podman, Some("original-machine".into())); - let volume = "openbot_postgres-data"; + let volume = &["openbot_postgres-data".to_string()][..]; assert!(reset_leftover_database_with( &shell, &root, volume, + &[], true, || panic!("a missing offer must refuse before reading credentials"), |_, _| panic!("a missing offer must not remove anything") @@ -5275,17 +5607,19 @@ mod tests { .is_err()); *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { root: root.clone(), - volume: volume.into(), + volumes: volume.to_vec(), + containers: Vec::new(), address: original.clone(), }); for (selected_root, selected_volume) in [ - (&root, "other-volume"), + (&root, &["other-volume".to_string()][..]), (&root.join("another-root"), volume), ] { assert!(reset_leftover_database_with( &shell, selected_root, selected_volume, + &[], true, || panic!("a mismatched offer must refuse before reading credentials"), |_, _| panic!("a mismatched offer must not remove anything") @@ -5302,6 +5636,7 @@ mod tests { &shell, &root, volume, + &[], true, || Ok(stack::Secrets::new()), |address, _| { @@ -5315,6 +5650,7 @@ mod tests { &shell, &root, volume, + &[], true, || Ok(stack::Secrets::new()), |address, _| { @@ -5388,23 +5724,33 @@ mod tests { &root, &secrets, || stack::postgres_volume_exists(&address, &root, &secrets), - || stack::leftover_database_volume(&address, &root, &secrets), + || stack::leftover_volumes(&address, &root, &secrets), ) .unwrap_err(); assert_eq!(guarded.database_reset.as_deref(), Some(volume.as_str())); + let volumes = [volume.clone()]; + assert_eq!(guarded.leftover_volumes(), volumes); let shell = Shell::default(); *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { root: root.clone(), - volume: volume.clone(), + volumes: volumes.to_vec(), + containers: Vec::new(), address: address.clone(), }); assert!(reset_leftover_database_with( &shell, &root, - &volume, + &volumes, + &[], false, || Ok(secrets.clone()), - |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume) + |address, secrets| stack::reset_leftover_volumes( + address, + &root, + secrets, + &volumes, + &[] + ) ) .is_err()); assert!(stack::postgres_volume_exists(&address, &root, &secrets).unwrap()); @@ -5413,10 +5759,13 @@ mod tests { reset_leftover_database_with( &shell, &root, - &volume, + &volumes, + &[], true, || Ok(secrets.clone()), - |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume), + |address, secrets| { + stack::reset_leftover_volumes(address, &root, secrets, &volumes, &[]) + }, ) .unwrap(); require_existing_encryption_key(&root, &secrets, || { @@ -5443,7 +5792,7 @@ mod tests { String::from_utf8_lossy(&started.stderr) ); assert!( - stack::reset_leftover_database(&address, &root, &secrets, &volume).is_err(), + stack::reset_leftover_volumes(&address, &root, &secrets, &volumes, &[]).is_err(), "the real engine must refuse an attached database volume" ); let ready = address diff --git a/desktop/src-tauri/src/problem.rs b/desktop/src-tauri/src/problem.rs index 285418fd84..16dfa06b4f 100644 --- a/desktop/src-tauri/src/problem.rs +++ b/desktop/src-tauri/src/problem.rs @@ -36,6 +36,21 @@ pub struct Problem { /// The full, verified Compose volume name offered for an explicit fresh-install reset. #[serde(skip_serializing_if = "Option::is_none")] pub database_reset: Option, + /// A verified fresh-install reset of everything a previous installation left behind. + /// Boxed and flattened: the window reads `leftover_reset` and `leftover_containers`. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub leftover: Option>, +} + +/// Exactly what one confirmed reset removes: the containers first, then the volumes. +#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)] +pub struct LeftoverReset { + /// Every verified volume, the database included. + #[serde(rename = "leftover_reset", skip_serializing_if = "Vec::is_empty")] + pub volumes: Vec, + /// Stopped containers that still hold those volumes. + #[serde(rename = "leftover_containers", skip_serializing_if = "Vec::is_empty")] + pub containers: Vec, } impl Problem { @@ -46,6 +61,7 @@ impl Problem { detail: None, connection: None, database_reset: None, + leftover: None, } } @@ -57,9 +73,22 @@ impl Problem { detail: (!detail.trim().is_empty()).then_some(detail), connection: None, database_reset: None, + leftover: None, } } + /// The volumes a confirmed reset removes, or none when no reset is offered. + pub fn leftover_volumes(&self) -> &[String] { + self.leftover.as_deref().map_or(&[], |reset| &reset.volumes) + } + + /// The stopped containers a confirmed reset removes before those volumes. + pub fn leftover_containers(&self) -> &[String] { + self.leftover + .as_deref() + .map_or(&[], |reset| &reset.containers) + } + pub fn connection(mut self, connection: Connection) -> Self { self.connection = Some(connection); self diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 22e01c8455..79d5a7df1f 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -213,6 +213,12 @@ fn postgres_configuration( } fn named_volume_exists(engine: &Address, root: &Path, volume: &str) -> Result { + Ok(volume_inventory(engine, root)? + .iter() + .any(|name| name == volume)) +} + +fn volume_inventory(engine: &Address, root: &Path) -> Result, Problem> { let inventory = engine .command() .current_dir(root) @@ -227,7 +233,12 @@ fn named_volume_exists(engine: &Address, root: &Path, volume: &str) -> Result) -> Problem { @@ -243,65 +254,120 @@ pub fn leftover_database_volume( root: &Path, secrets: &Secrets, ) -> Result, Problem> { + let config = leftover_configuration(engine, root, secrets)?; + let inventory = volume_inventory(engine, root)?; + owned_database_volume(engine, root, &config, &inventory) +} + +fn leftover_configuration( + engine: &Address, + root: &Path, + secrets: &Secrets, +) -> Result { let configuration = postgres_configuration(engine, root, secrets)?; - let config: serde_json::Value = serde_json::from_slice(&configuration) - .map_err(|_| postgres_volume_problem("Compose config did not return valid JSON."))?; - let Some((source, definition)) = postgres_data_volume(&config) else { + serde_json::from_slice(&configuration) + .map_err(|_| postgres_volume_problem("Compose config did not return valid JSON.")) +} + +fn owned_database_volume( + engine: &Address, + root: &Path, + config: &serde_json::Value, + inventory: &[String], +) -> Result, Problem> { + let Some((source, definition)) = postgres_data_volume(config) else { return Ok(None); }; let Some(project) = config["name"].as_str().filter(|name| !name.is_empty()) else { return Ok(None); }; - let Some(name) = definition["name"].as_str() else { + let Some(name) = ordinary_project_volume(project, source, definition) else { return Ok(None); }; - // Compose's explicit names and external volumes can refer to somebody else's database. - // Permit only ordinary project-scoped, local storage, even if a custom volume has labels. - if name != format!("{project}_{source}") - || !name.bytes().enumerate().all(|(index, byte)| { + // Another service mounting the database volume, under any alias, makes it shared storage. + if config["services"].as_object().is_some_and(|services| { + services.iter().any(|(service, configuration)| { + service != "postgres" + && configuration["volumes"].as_array().is_some_and(|mounts| { + mounts.iter().any(|mount| { + mount["source"].as_str().is_some_and(|other_source| { + other_source == source + || config["volumes"][other_source]["name"].as_str() == Some(name) + }) + }) + }) + }) + }) { + return Ok(None); + } + owned_compose_volume(engine, root, project, source, name, inventory) +} + +/// Compose's explicit names and external volumes can refer to somebody else's data. +/// Permit only ordinary project-scoped, local storage, even if a custom volume has labels. +fn ordinary_project_volume<'a>( + project: &str, + source: &str, + definition: &'a serde_json::Value, +) -> Option<&'a str> { + let name = definition["name"].as_str()?; + (name == format!("{project}_{source}") + && name.bytes().enumerate().all(|(index, byte)| { byte.is_ascii_alphanumeric() || (index > 0 && b"_.-".contains(&byte)) }) - || !matches!( + && matches!( definition.get("external"), None | Some(serde_json::Value::Bool(false)) ) - || definition["driver"] + && !definition["driver"] .as_str() .is_some_and(|driver| driver != "local") - || !empty_volume_options(&definition["driver_opts"]) - || config["services"].as_object().is_some_and(|services| { - services.iter().any(|(service, configuration)| { - service != "postgres" - && configuration["volumes"].as_array().is_some_and(|mounts| { - mounts.iter().any(|mount| { - mount["source"].as_str().is_some_and(|other_source| { - other_source == source - || config["volumes"][other_source]["name"].as_str() - == Some(name) - }) - }) - }) - }) - }) - { + && empty_volume_options(&definition["driver_opts"])) + .then_some(name) +} + +fn owned_compose_volume( + engine: &Address, + root: &Path, + project: &str, + source: &str, + name: &str, + inventory: &[String], +) -> Result, Problem> { + if !inventory.iter().any(|listed| listed == name) { return Ok(None); } - if !named_volume_exists(engine, root, name)? { + let Some(labels) = inspect_local_volume(engine, root, name)? else { return Ok(None); - } + }; + Ok( + (labels["com.docker.compose.project"].as_str() == Some(project) + && labels["com.docker.compose.volume"].as_str() == Some(source)) + .then(|| name.to_owned()), + ) +} + +/// The labels of exactly this local volume, or `None` when the engine describes anything else. +fn inspect_local_volume( + engine: &Address, + root: &Path, + name: &str, +) -> Result, Problem> { let inspect = engine .command() .current_dir(root) .args(["volume", "inspect", name]) .output() .map_err(|error| { - postgres_volume_problem(format!("Could not inspect the database volume: {error}")) + postgres_volume_problem(format!("Could not inspect volume {name}: {error}")) })?; if !inspect.status.success() { - return Err(postgres_volume_problem(format!("Database volume inspection exited with {}. Output omitted because it can contain credentials.", inspect.status))); + return Err(postgres_volume_problem(format!("Inspection of volume {name} exited with {}. Output omitted because it can contain credentials.", inspect.status))); } let inspected: serde_json::Value = serde_json::from_slice(&inspect.stdout).map_err(|_| { - postgres_volume_problem("Database volume inspection did not return valid JSON.") + postgres_volume_problem(format!( + "Inspection of volume {name} did not return valid JSON." + )) })?; let Some(volumes) = inspected.as_array().filter(|volumes| volumes.len() == 1) else { return Ok(None); @@ -311,36 +377,402 @@ pub fn leftover_database_volume( // tied to the full name the person confirmed, never a similarly named backup. Ok((volume["Name"].as_str() == Some(name) && volume["Driver"].as_str() == Some("local") - && empty_volume_options(&volume["Options"]) - && volume["Labels"]["com.docker.compose.project"].as_str() == Some(project) - && volume["Labels"]["com.docker.compose.volume"].as_str() == Some(source)) + && empty_volume_options(&volume["Options"])) + .then(|| volume["Labels"].clone())) +} + +/// A Bot's profile or workspace volume, created by the supervisor in this namespace. +/// Matches `supervisor/src/names.ts` names and `labelsFor` in `supervisor/src/docker.ts`; +/// all three labels are required, so an older or foreign volume is never claimed. +fn owned_supervisor_volume( + engine: &Address, + root: &Path, + namespace: &str, + name: &str, +) -> Result, Problem> { + let Some(bot) = ["profile", "workspace"] + .iter() + .find_map(|kind| name.strip_prefix(&format!("{namespace}-{kind}-"))) + .filter(|bot| supervisor_identifier(bot)) + else { + return Ok(None); + }; + let Some(labels) = inspect_local_volume(engine, root, name)? else { + return Ok(None); + }; + Ok((labels["openbot.supervisor"].as_str() == Some("true") + && labels["openbot.bot-id"].as_str() == Some(bot) + && labels["openbot.namespace"].as_str() == Some(namespace)) .then(|| name.to_owned())) } -pub fn reset_leftover_database( +/// The namespace the selected supervisor will run with, as `supervisor/src/names.ts` reads it. +/// No supervisor, or a namespace it would refuse, owns no volumes. +fn supervisor_namespace(config: &serde_json::Value) -> Option { + let supervisor = config["services"]["supervisor"].as_object()?; + let configured = match supervisor + .get("environment") + .and_then(|environment| environment.get("COMPUTER_NAMESPACE")) + { + None | Some(serde_json::Value::Null) => "", + Some(value) => value.as_str()?, + }; + resolve_namespace(configured).map(str::to_owned) +} + +/// Match supervisor/src/names.ts: trim, default only an empty value, then the same 64-character +/// ASCII identifier grammar. +fn resolve_namespace(configured: &str) -> Option<&str> { + let namespace = match configured.trim() { + "" => "openbot", + value => value, + }; + supervisor_identifier(namespace).then_some(namespace) +} + +fn supervisor_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') +} + +/// What a previous installation of this deployment left on the selected engine. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LeftoverVolumes { + /// The Postgres volume, when Compose and the engine agree this project owns it. + pub database: Option, + /// Whether the volume Compose selects for Postgres exists at all, owned or not. + pub database_exists: bool, + /// Every verified, owned volume, sorted. This is exactly what a reset removes. + pub volumes: Vec, + /// Stopped containers this deployment provably owns, sorted by name. They still hold the + /// volumes, so a reset removes them first. + pub containers: Vec, + /// Owned containers that are not stopped, by name. Any one of them blocks a reset. + pub running: Vec, +} + +/// A stopped container a reset may remove, named for the person and removed by its exact id. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LeftoverContainer { + pub name: String, + pub id: String, +} + +impl LeftoverVolumes { + /// A reset must not leave a database it cannot verify for the new installation to reuse, + /// and never touches a container that is still running. + pub fn resettable(&self) -> bool { + !self.volumes.is_empty() + && (self.database.is_some() || !self.database_exists) + && self.running.is_empty() + } + + pub fn container_names(&self) -> Vec { + self.containers + .iter() + .map(|container| container.name.clone()) + .collect() + } + + /// The sentence for a reset that a running container blocks, or `None` when none is running. + pub fn running_said(&self) -> Option { + (!self.running.is_empty()).then(|| { + format!( + "A container from the previous installation is still running: {}. Stop it in Docker or Podman, then try Start again.", + self.running.join(", ") + ) + }) + } +} + +fn leftover_container_problem(detail: impl Into) -> Problem { + Problem::with( + "OpenBot could not verify which containers a previous installation left. Nothing was changed. Check the selected container engine, then try again.", + detail, + ) +} + +/// The ids carrying every one of these labels. Only a hint: each one is inspected before use. +fn labelled_containers( engine: &Address, root: &Path, - secrets: &Secrets, - confirmed_volume: &str, -) -> Result<(), Problem> { - if leftover_database_volume(engine, root, secrets)?.as_deref() != Some(confirmed_volume) { - return Err(Problem::plain("The leftover database no longer matches the volume you confirmed, or OpenBot could not verify that it owns the volume. Nothing was removed. Try Start again.")); + labels: &[(&str, &str)], +) -> Result, Problem> { + let mut command = engine.command(); + command + .current_dir(root) + .args(["ps", "--all", "--quiet", "--no-trunc"]); + for (key, value) in labels { + command.args(["--filter", &format!("label={key}={value}")]); } - // Never force this operation: the engine must refuse any container attachment, including a - // stopped container or one created after inspection. Do not stop containers to make it pass. - let removed = engine + let listed = command.output().map_err(|error| { + leftover_container_problem(format!("Could not list containers: {error}")) + })?; + if !listed.status.success() { + return Err(leftover_container_problem(format!( + "Container listing exited with {}. Output omitted because it can contain credentials.", + listed.status + ))); + } + let listed = std::str::from_utf8(&listed.stdout) + .map_err(|_| leftover_container_problem("Container listing was not valid UTF-8."))?; + Ok(listed.split_whitespace().map(str::to_owned).collect()) +} + +enum ContainerState { + Stopped, + Running, +} + +/// The name and state of a container whose labels prove this deployment owns it, or `None`. +fn owned_container( + engine: &Address, + root: &Path, + id: &str, + project: Option<&str>, + namespace: Option<&str>, +) -> Result, Problem> { + let inspect = engine .command() .current_dir(root) - .args(["volume", "rm", confirmed_volume]) + .args(["container", "inspect", id]) .output() .map_err(|error| { - Problem::with( - "OpenBot could not reset the leftover database. Nothing else was removed.", - format!("Could not run volume removal: {error}"), - ) + leftover_container_problem(format!("Could not inspect container {id}: {error}")) })?; - if !removed.status.success() { - return Err(Problem::with("OpenBot could not reset the leftover database. It may still be attached to a container. Nothing else was removed; stop the installation using it before trying again.", format!("Volume removal exited with {}. Output omitted because it can contain credentials.", removed.status))); + if !inspect.status.success() { + return Err(leftover_container_problem(format!( + "Inspection of container {id} exited with {}. Output omitted because it can contain credentials.", + inspect.status + ))); + } + let inspected: serde_json::Value = serde_json::from_slice(&inspect.stdout).map_err(|_| { + leftover_container_problem(format!( + "Inspection of container {id} did not return valid JSON." + )) + })?; + let Some(containers) = inspected + .as_array() + .filter(|containers| containers.len() == 1) + else { + return Ok(None); + }; + let container = &containers[0]; + // Exact ids only: an engine resolving a prefix to something else is not this container. + if container["Id"].as_str() != Some(id) { + return Ok(None); + } + // Docker reports `/name`; Podman reports `name`. + let Some(name) = container["Name"] + .as_str() + .map(|name| name.strip_prefix('/').unwrap_or(name)) + .filter(|name| !name.is_empty()) + else { + return Ok(None); + }; + let labels = &container["Config"]["Labels"]; + let compose_owned = project + .is_some_and(|project| labels["com.docker.compose.project"].as_str() == Some(project)); + // Matches `namesFor` in supervisor/src/names.ts and `labelsFor` in supervisor/src/docker.ts. + let supervisor_owned = namespace.is_some_and(|namespace| { + labels["openbot.supervisor"].as_str() == Some("true") + && labels["openbot.namespace"].as_str() == Some(namespace) + && labels["openbot.bot-id"].as_str().is_some_and(|bot| { + supervisor_identifier(bot) && name == format!("{namespace}-computer-{bot}") + }) + }); + if !compose_owned && !supervisor_owned { + return Ok(None); + } + let state = &container["State"]; + // Only states a plain removal accepts count as stopped. Anything else, or anything unknown, + // is treated as running so it is never touched. + let stopped = state["Running"].as_bool() == Some(false) + && state["Status"].as_str().is_some_and(|status| { + matches!(status, "created" | "exited" | "stopped" | "configured") + }); + Ok(Some(( + name.to_owned(), + if stopped { + ContainerState::Stopped + } else { + ContainerState::Running + }, + ))) +} + +fn leftover_containers( + engine: &Address, + root: &Path, + project: Option<&str>, + namespace: Option<&str>, +) -> Result<(Vec, Vec), Problem> { + let mut ids = Vec::new(); + if let Some(project) = project { + ids.extend(labelled_containers( + engine, + root, + &[("com.docker.compose.project", project)], + )?); + } + if let Some(namespace) = namespace { + ids.extend(labelled_containers( + engine, + root, + &[ + ("openbot.supervisor", "true"), + ("openbot.namespace", namespace), + ], + )?); + } + ids.sort(); + ids.dedup(); + let mut stopped = Vec::new(); + let mut running = Vec::new(); + for id in ids { + match owned_container(engine, root, &id, project, namespace)? { + Some((name, ContainerState::Stopped)) => stopped.push(LeftoverContainer { name, id }), + Some((name, ContainerState::Running)) => running.push(name), + None => {} + } + } + stopped.sort_by(|a, b| a.name.cmp(&b.name)); + running.sort(); + Ok((stopped, running)) +} + +/** +Every volume a previous installation of this deployment left behind, and that it provably owns. + +A deleted installation folder removes `.env` and the vault, but not the engine's volumes. Compose +and the supervisor reattach them by name, so a new installation silently inherits the old one's +Bots' workspace files and browser sign-ins. Only three kinds are claimed: the verified Postgres +volume, the other ordinary project-scoped Compose volumes labelled for this project, and the +per-Bot volumes the supervisor labelled for this namespace. Never include config output in +diagnostics: interpolation may have put credentials in it. +*/ +pub fn leftover_volumes( + engine: &Address, + root: &Path, + secrets: &Secrets, +) -> Result { + let config = leftover_configuration(engine, root, secrets)?; + let inventory = volume_inventory(engine, root)?; + let postgres = postgres_data_volume(&config); + let database_exists = postgres + .and_then(|(_, definition)| definition["name"].as_str()) + .is_some_and(|name| inventory.iter().any(|listed| listed == name)); + let database = owned_database_volume(engine, root, &config, &inventory)?; + let mut volumes: Vec = database.iter().cloned().collect(); + let project = config["name"].as_str().filter(|name| !name.is_empty()); + let namespace = supervisor_namespace(&config); + if let (Some(project), Some(definitions)) = (project, config["volumes"].as_object()) { + for (source, definition) in definitions { + // The database has its own, stricter test above. + if postgres.is_some_and(|(postgres_source, _)| postgres_source == source) { + continue; + } + let Some(name) = ordinary_project_volume(project, source, definition) else { + continue; + }; + volumes.extend(owned_compose_volume( + engine, root, project, source, name, &inventory, + )?); + } + } + if let Some(namespace) = &namespace { + for name in &inventory { + volumes.extend(owned_supervisor_volume(engine, root, namespace, name)?); + } + } + volumes.sort(); + volumes.dedup(); + // Stopped containers keep their volumes attached, so the engine refuses to remove them. Only + // worth listing when there is data to reset. + let (containers, running) = if volumes.is_empty() { + Default::default() + } else { + leftover_containers(engine, root, project, namespace.as_deref())? + }; + Ok(LeftoverVolumes { + database, + database_exists, + volumes, + containers, + running, + }) +} + +/// Remove exactly the leftover containers and volumes the person confirmed, containers first, +/// or nothing when either set changed or an owned container is running. +pub fn reset_leftover_volumes( + engine: &Address, + root: &Path, + secrets: &Secrets, + confirmed: &[String], + confirmed_containers: &[String], +) -> Result<(), Problem> { + let leftovers = leftover_volumes(engine, root, secrets)?; + if let Some(said) = leftovers.running_said() { + return Err(Problem::plain(format!("{said} Nothing was removed."))); + } + let sorted = |names: &[String]| { + let mut names = names.to_vec(); + names.sort(); + names + }; + if !leftovers.resettable() + || leftovers.volumes != sorted(confirmed) + || leftovers.container_names() != sorted(confirmed_containers) + { + return Err(Problem::plain("The leftover data no longer matches the containers and volumes you confirmed, or OpenBot could not verify that it owns them. Nothing was removed. Try Start again.")); + } + let mut removed: Vec<&str> = Vec::new(); + let removals = leftovers + .containers + .iter() + .map(|container| ("container", container.id.as_str(), container.name.as_str())) + .chain( + leftovers + .volumes + .iter() + .map(|volume| ("volume", volume.as_str(), volume.as_str())), + ); + for (kind, target, name) in removals { + let so_far = || match removed.as_slice() { + [] => "Nothing was removed.".to_owned(), + removed => format!("Already removed: {}.", removed.join(", ")), + }; + // Never force this operation: the engine must refuse a running container and any + // container attachment, including one created after inspection. Do not stop containers + // to make it pass. + let output = engine + .command() + .current_dir(root) + .args([kind, "rm", target]) + .output() + .map_err(|error| { + Problem::with( + format!("OpenBot could not remove {name}. {}", so_far()), + format!("Could not run {kind} removal: {error}"), + ) + })?; + if !output.status.success() { + let why = if kind == "container" { + "It may have started again." + } else { + "It may still be attached to a container." + }; + return Err(Problem::with( + format!("OpenBot could not remove {name}. {why} {} Stop the installation using it, then try Start again.", so_far()), + format!("{kind} removal exited with {}. Output omitted because it can contain credentials.", output.status), + )); + } + removed.push(name); } Ok(()) } @@ -732,20 +1164,9 @@ fn computer_namespace(engine: &Address, root: &Path) -> Result, S .pointer("/services/supervisor/environment/COMPUTER_NAMESPACE") .and_then(serde_json::Value::as_str) .ok_or("could not resolve computer namespace: supervisor configuration has no namespace")?; - // Match supervisor/src/names.ts: trim, default only an empty value, then the same 64-character - // ASCII identifier grammar. A malformed/missing response never becomes an unscoped filter. - let namespace = match configured.trim() { - "" => "openbot", - value => value, - }; - if namespace.len() > 64 - || !namespace.as_bytes()[0].is_ascii_alphanumeric() - || !namespace - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err("could not resolve computer namespace: supervisor namespace is invalid".into()); - } + // A malformed/missing response never becomes an unscoped filter. + let namespace = resolve_namespace(configured) + .ok_or("could not resolve computer namespace: supervisor namespace is invalid")?; Ok(Some(namespace.to_string())) } @@ -3209,14 +3630,25 @@ mod tests { leftover_database_volume(&address, &root, &Secrets::new()).unwrap(), Some("openbot_postgres-data".into()) ); - assert!( - reset_leftover_database(&address, &root, &Secrets::new(), "unrelated").is_err() - ); + assert!(reset_leftover_volumes( + &address, + &root, + &Secrets::new(), + &["unrelated".into()], + &[] + ) + .is_err()); assert!(!std::fs::read_to_string(&record) .unwrap() .contains("volume rm")); - reset_leftover_database(&address, &root, &Secrets::new(), "openbot_postgres-data") - .unwrap(); + reset_leftover_volumes( + &address, + &root, + &Secrets::new(), + &["openbot_postgres-data".into()], + &[], + ) + .unwrap(); assert_eq!( std::fs::read_to_string(root.join(".fixture-volumes")).unwrap(), "unrelated\n" @@ -3329,9 +3761,14 @@ mod tests { if let Some(marker) = marker { std::fs::write(root.join(marker), "").unwrap(); } - let error = - reset_leftover_database(&address, &root, &Secrets::new(), "openbot_postgres-data") - .expect_err(scenario); + let error = reset_leftover_volumes( + &address, + &root, + &Secrets::new(), + &["openbot_postgres-data".into()], + &[], + ) + .expect_err(scenario); assert!( !format!("{error:?}").contains("synthetic-secret"), "{scenario}" @@ -3357,6 +3794,462 @@ mod tests { } } + /// A reinstall's Compose project and supervisor volumes, beside look-alikes it does not own. + fn leftover_volumes_fixture(root: &Path, namespace: &str) { + let config = serde_json::json!({ + "name": "openbot", + "services": { + "postgres": {"volumes": [{ + "type": "volume", "source": "postgres-data", "target": "/var/lib/postgresql/data" + }]}, + "supervisor": {"environment": {"COMPUTER_NAMESPACE": namespace}}, + "agent-computer": {"volumes": [ + {"type": "volume", "source": "agent-profiles", "target": "/profiles"}, + {"type": "volume", "source": "agent-workspace", "target": "/workspace"} + ]} + }, + "volumes": { + "postgres-data": {"name": "openbot_postgres-data"}, + "agent-profiles": {"name": "openbot_agent-profiles"}, + "agent-workspace": {"name": "openbot_agent-workspace"}, + "spire-server-data": {"name": "openbot_spire-server-data"}, + "unlabeled": {"name": "openbot_unlabeled"}, + "foreign": {"name": "openbot_foreign"}, + "shared-cache": {"name": "shared-cache", "external": true}, + "explicit": {"name": "explicitly-named"}, + "remote": {"name": "openbot_remote", "driver_opts": {"device": "/shared"}} + } + }); + std::fs::write(root.join(".fixture-config"), config.to_string()).unwrap(); + std::fs::write( + root.join(".fixture-volumes"), + [ + "unrelated", + "openbot_agent-profiles", + "openbot_agent-workspace", + "openbot_unlabeled", + "openbot_foreign", + "shared-cache", + "explicitly-named", + "openbot_remote", + "openbot-profile-general-assistant", + "openbot-workspace-general-assistant", + "openbot-profile-unlabeled", + "openbot-workspace-elsewhere", + "openbot-profile-legacy", + "other-profile-knowledge", + ] + .map(|name| format!("{name}\n")) + .concat(), + ) + .unwrap(); + let inspect = |name: &str, labels: serde_json::Value| { + std::fs::write( + root.join(format!(".fixture-inspect-{name}")), + serde_json::json!([{ + "Name": name, "Driver": "local", "Options": {}, "Labels": labels + }]) + .to_string(), + ) + .unwrap(); + }; + let compose = |project: &str, source: &str| { + serde_json::json!({ + "com.docker.compose.project": project, "com.docker.compose.volume": source + }) + }; + let supervisor = |bot: &str, namespace: &str| { + serde_json::json!({ + "openbot.supervisor": "true", "openbot.bot-id": bot, "openbot.namespace": namespace + }) + }; + inspect( + "openbot_agent-profiles", + compose("openbot", "agent-profiles"), + ); + inspect( + "openbot_agent-workspace", + compose("openbot", "agent-workspace"), + ); + inspect("openbot_unlabeled", serde_json::json!({})); + inspect("openbot_foreign", compose("another-project", "foreign")); + inspect( + "openbot-profile-general-assistant", + supervisor("general-assistant", "openbot"), + ); + inspect( + "openbot-workspace-general-assistant", + supervisor("general-assistant", "openbot"), + ); + inspect("openbot-profile-unlabeled", serde_json::json!({})); + inspect( + "openbot-workspace-elsewhere", + supervisor("elsewhere", "other"), + ); + inspect( + "openbot-profile-legacy", + serde_json::json!({"openbot.supervisor": "true", "openbot.bot-id": "legacy"}), + ); + inspect("other-profile-knowledge", supervisor("knowledge", "other")); + } + + const OWNED_LEFTOVERS: [&str; 4] = [ + "openbot-profile-general-assistant", + "openbot-workspace-general-assistant", + "openbot_agent-profiles", + "openbot_agent-workspace", + ]; + + #[test] + fn leftover_volumes_are_only_volumes_this_project_or_namespace_provably_owns() { + if crate::test_support::isolated_process( + "stack::tests::leftover_volumes_are_only_volumes_this_project_or_namespace_provably_owns", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let address = computer_stop_addresses(&path)[0].clone(); + let root = path.bin.join("leftover-volumes"); + std::fs::create_dir(&root).unwrap(); + let record = root.join("commands.log"); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + + leftover_volumes_fixture(&root, "openbot"); + let found = leftover_volumes(&address, &root, &Secrets::new()).unwrap(); + assert_eq!(found.volumes, OWNED_LEFTOVERS.map(String::from)); + assert_eq!(found.database, None); + assert!(!found.database_exists); + assert!(found.resettable()); + let log = std::fs::read_to_string(&record).unwrap(); + for never_inspected in ["shared-cache", "explicitly-named", "openbot_remote"] { + assert!( + !log.contains(&format!("volume inspect {never_inspected}")), + "{never_inspected}: {log}" + ); + } + assert!(!log.contains("volume rm")); + + // Another deployment's namespace selects only that namespace's Bots. + leftover_volumes_fixture(&root, "other"); + assert_eq!( + leftover_volumes(&address, &root, &Secrets::new()) + .unwrap() + .volumes, + [ + "openbot_agent-profiles", + "openbot_agent-workspace", + "other-profile-knowledge" + ] + ); + + // The database joins the set when it is verified, and blocks the offer when it is not. + leftover_volumes_fixture(&root, "openbot"); + let mut inventory = std::fs::read_to_string(root.join(".fixture-volumes")).unwrap(); + inventory.push_str("openbot_postgres-data\n"); + std::fs::write(root.join(".fixture-volumes"), inventory).unwrap(); + let database = serde_json::json!([{ + "Name": "openbot_postgres-data", "Driver": "local", "Options": {}, + "Labels": {"com.docker.compose.project":"openbot", "com.docker.compose.volume":"postgres-data"} + }]); + std::fs::write(root.join(".fixture-inspect"), database.to_string()).unwrap(); + let found = leftover_volumes(&address, &root, &Secrets::new()).unwrap(); + assert_eq!(found.database.as_deref(), Some("openbot_postgres-data")); + assert!(found.database_exists && found.resettable()); + let mut expected = OWNED_LEFTOVERS.map(String::from).to_vec(); + expected.push("openbot_postgres-data".into()); + expected.sort(); + assert_eq!(found.volumes, expected); + + let mut foreign = database.clone(); + foreign[0]["Labels"]["com.docker.compose.project"] = "another-project".into(); + std::fs::write(root.join(".fixture-inspect"), foreign.to_string()).unwrap(); + let found = leftover_volumes(&address, &root, &Secrets::new()).unwrap(); + assert_eq!(found.database, None); + assert!(found.database_exists); + assert_eq!(found.volumes, OWNED_LEFTOVERS.map(String::from)); + assert!( + !found.resettable(), + "an unverified database must not be left behind by a reset" + ); + std::fs::write(record, "").unwrap(); + assert!(reset_leftover_volumes( + &address, + &root, + &Secrets::new(), + &OWNED_LEFTOVERS.map(String::from), + &[] + ) + .is_err()); + assert!(!std::fs::read_to_string(root.join("commands.log")) + .unwrap() + .contains("volume rm")); + } + + #[test] + fn leftover_volume_reset_removes_exactly_the_confirmed_owned_set() { + if crate::test_support::isolated_process( + "stack::tests::leftover_volume_reset_removes_exactly_the_confirmed_owned_set", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + for address in computer_stop_addresses(&path) { + let root = path + .bin + .join(format!("{}-leftover-reset", address.engine.binary())); + std::fs::create_dir(&root).unwrap(); + let record = root.join("commands.log"); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + leftover_volumes_fixture(&root, "openbot"); + let owned = OWNED_LEFTOVERS.map(String::from).to_vec(); + let mut duplicated = owned.clone(); + duplicated.push(owned[0].clone()); + let mut superset = owned.clone(); + superset.push("other-profile-knowledge".into()); + for stale in [ + owned[..3].to_vec(), + superset, + duplicated, + vec!["unrelated".into()], + Vec::new(), + ] { + let error = reset_leftover_volumes(&address, &root, &Secrets::new(), &stale, &[]) + .expect_err("a changed set must not be removed"); + assert!(error.said.contains("Nothing was removed"), "{}", error.said); + assert!(!std::fs::read_to_string(&record) + .unwrap_or_default() + .contains("volume rm")); + } + + // The engine refuses a volume a container still uses; nothing is forced. + std::fs::write( + root.join(".fixture-attached-openbot-profile-general-assistant"), + "", + ) + .unwrap(); + let error = reset_leftover_volumes(&address, &root, &Secrets::new(), &owned, &[]) + .expect_err("an attached volume is refused"); + assert!( + error.said.contains("openbot-profile-general-assistant"), + "{}", + error.said + ); + assert!(error.said.contains("Nothing was removed"), "{}", error.said); + assert!(!format!("{error:?}").contains("synthetic-secret")); + std::fs::remove_file(root.join(".fixture-attached-openbot-profile-general-assistant")) + .unwrap(); + + let mut reordered = owned.clone(); + reordered.reverse(); + reset_leftover_volumes(&address, &root, &Secrets::new(), &reordered, &[]).unwrap(); + let remaining = std::fs::read_to_string(root.join(".fixture-volumes")).unwrap(); + for name in &owned { + assert!(!remaining.lines().any(|line| line == name), "{name}"); + } + for kept in [ + "unrelated", + "openbot_unlabeled", + "openbot_foreign", + "shared-cache", + "explicitly-named", + "openbot_remote", + "openbot-profile-unlabeled", + "openbot-workspace-elsewhere", + "openbot-profile-legacy", + "other-profile-knowledge", + ] { + assert!(remaining.lines().any(|line| line == kept), "{kept}"); + } + let log = std::fs::read_to_string(&record).unwrap(); + let removals: Vec<_> = log + .lines() + .filter_map(|line| line.split_once("volume rm ")) + .map(|(_, name)| name) + .collect(); + // One refused attempt, then each confirmed volume once. + assert_eq!(removals[0], "openbot-profile-general-assistant"); + assert_eq!(removals[1..], OWNED_LEFTOVERS); + assert!(!log.contains("--force") && !log.contains("prune") && !log.contains("down")); + if address.engine == crate::engine::Engine::Podman { + assert!(log.lines().all(|line| line + .split_once('\t') + .unwrap() + .1 + .starts_with("--connection fixture-machine "))); + } + } + } + + /// The previous installation's stopped computers and Compose containers, beside ones it + /// does not own. Each still mounts the volumes it used, as a real engine reports it. + fn leftover_containers_fixture(root: &Path, computer_status: &str) { + let supervisor = |bot: &str, namespace: &str| { + format!("openbot.supervisor=true,openbot.bot-id={bot},openbot.namespace={namespace}") + }; + let lines = [ + format!( + "c-computer\topenbot-computer-general-assistant\t{computer_status}\t{}\topenbot-profile-general-assistant,openbot-workspace-general-assistant", + supervisor("general-assistant", "openbot") + ), + "c-compose\topenbot-agent-computer-1\texited\tcom.docker.compose.project=openbot,com.docker.compose.service=agent-computer\topenbot_agent-profiles,openbot_agent-workspace".into(), + format!("c-other-namespace\tother-computer-knowledge\texited\t{}\tother-profile-knowledge", supervisor("knowledge", "other")), + "c-unlabeled\topenbot-computer-unlabeled\texited\t\topenbot-profile-unlabeled".into(), + "c-other-project\tanother-project-postgres-1\texited\tcom.docker.compose.project=another-project\t".into(), + format!("c-renamed\topenbot-computer-not-this-bot\texited\t{}\t", supervisor("general-assistant", "openbot")), + "c-legacy\topenbot-computer-legacy\texited\topenbot.supervisor=true,openbot.bot-id=legacy\topenbot-profile-legacy".into(), + ]; + std::fs::write( + root.join(".fixture-containers"), + lines.map(|line| format!("{line}\n")).concat(), + ) + .unwrap(); + } + + const OWNED_CONTAINERS: [&str; 2] = [ + "openbot-agent-computer-1", + "openbot-computer-general-assistant", + ]; + + #[test] + fn leftover_reset_removes_stopped_owned_containers_before_their_volumes() { + if crate::test_support::isolated_process( + "stack::tests::leftover_reset_removes_stopped_owned_containers_before_their_volumes", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + for address in computer_stop_addresses(&path) { + let root = path + .bin + .join(format!("{}-leftover-containers", address.engine.binary())); + std::fs::create_dir(&root).unwrap(); + let record = root.join("commands.log"); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + leftover_volumes_fixture(&root, "openbot"); + leftover_containers_fixture(&root, "exited"); + + let found = leftover_volumes(&address, &root, &Secrets::new()).unwrap(); + assert_eq!(found.volumes, OWNED_LEFTOVERS.map(String::from)); + assert_eq!( + found + .containers + .iter() + .map(|container| container.name.as_str()) + .collect::>(), + OWNED_CONTAINERS + ); + assert!(found.running.is_empty() && found.resettable()); + + let volumes = OWNED_LEFTOVERS.map(String::from).to_vec(); + let containers = OWNED_CONTAINERS.map(String::from).to_vec(); + for (stale_volumes, stale_containers) in [ + (volumes.clone(), Vec::new()), + (volumes.clone(), containers[..1].to_vec()), + ( + volumes.clone(), + [containers.clone(), vec!["other-computer-knowledge".into()]].concat(), + ), + (Vec::new(), containers.clone()), + ] { + let error = reset_leftover_volumes( + &address, + &root, + &Secrets::new(), + &stale_volumes, + &stale_containers, + ) + .expect_err("a changed set must not be removed"); + assert!(error.said.contains("Nothing was removed"), "{}", error.said); + assert!(!std::fs::read_to_string(&record).unwrap().contains(" rm ")); + } + + reset_leftover_volumes(&address, &root, &Secrets::new(), &volumes, &containers) + .unwrap(); + let log = std::fs::read_to_string(&record).unwrap(); + let removals: Vec<_> = log + .lines() + .filter_map(|line| line.split_once('\t')) + .map(|(_, command)| { + command + .strip_prefix("--connection fixture-machine ") + .unwrap_or(command) + }) + .filter(|command| command.contains(" rm ")) + .collect(); + assert_eq!( + removals, + [ + "container rm c-compose", + "container rm c-computer", + "volume rm openbot-profile-general-assistant", + "volume rm openbot-workspace-general-assistant", + "volume rm openbot_agent-profiles", + "volume rm openbot_agent-workspace", + ] + ); + assert!(!log.contains("--force") && !log.contains("prune") && !log.contains("down")); + let kept = std::fs::read_to_string(root.join(".fixture-containers")).unwrap(); + let kept: Vec<_> = kept + .lines() + .map(|line| line.split('\t').next().unwrap()) + .collect(); + assert_eq!( + kept, + [ + "c-other-namespace", + "c-unlabeled", + "c-other-project", + "c-renamed", + "c-legacy" + ] + ); + } + } + + #[test] + fn a_running_owned_container_blocks_the_reset_and_removes_nothing() { + if crate::test_support::isolated_process( + "stack::tests::a_running_owned_container_blocks_the_reset_and_removes_nothing", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let address = computer_stop_addresses(&path)[0].clone(); + let root = path.bin.join("leftover-running"); + std::fs::create_dir(&root).unwrap(); + let record = root.join("commands.log"); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + leftover_volumes_fixture(&root, "openbot"); + for status in ["running", "paused", "restarting"] { + leftover_containers_fixture(&root, status); + let found = leftover_volumes(&address, &root, &Secrets::new()).unwrap(); + assert_eq!(found.running, ["openbot-computer-general-assistant"]); + assert!(!found.resettable(), "{status}"); + let error = reset_leftover_volumes( + &address, + &root, + &Secrets::new(), + &OWNED_LEFTOVERS.map(String::from), + &OWNED_CONTAINERS.map(String::from), + ) + .expect_err("a running container blocks the reset"); + assert!( + error.said.contains("openbot-computer-general-assistant") + && error.said.contains("still running") + && error.said.contains("Nothing was removed"), + "{}", + error.said + ); + assert!(!format!("{error:?}").contains("synthetic-secret")); + assert!(!std::fs::read_to_string(&record).unwrap().contains(" rm ")); + } + // A container listing that fails is not proof that nothing is running. + leftover_containers_fixture(&root, "exited"); + std::fs::write(root.join(".fixture-ps-failure"), "").unwrap(); + let error = leftover_volumes(&address, &root, &Secrets::new()).unwrap_err(); + assert!(!format!("{error:?}").contains("synthetic-secret")); + } + #[test] fn desktop_approval_transport_credential_reaches_only_the_server() { let secrets = Secrets::from([ @@ -4726,6 +5619,24 @@ fn main() { const FAKE_ENGINE_SOURCE: &str = r#" use std::io::Write; +/// `.fixture-containers`: one container per line, `idnamestatusk=v,k=vvolume,volume`. +struct FixtureContainer { id: String, name: String, status: String, labels: Vec<(String, String)>, mounts: Vec } + +fn fixture_containers() -> Vec { + let Ok(listing) = std::fs::read_to_string(".fixture-containers") else { return Vec::new() }; + listing.lines().filter(|line| !line.is_empty()).map(|line| { + let fields: Vec<&str> = line.split('\t').collect(); + let list = |field: &str| field.split(',').filter(|item| !item.is_empty()).map(str::to_owned).collect::>(); + FixtureContainer { + id: fields[0].into(), + name: fields[1].into(), + status: fields[2].into(), + labels: list(fields[3]).into_iter().map(|pair| { let (k, v) = pair.split_once('=').unwrap(); (k.to_owned(), v.to_owned()) }).collect(), + mounts: list(fields.get(4).copied().unwrap_or("")), + } + }).collect() +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let joined = args.join(" "); @@ -4747,15 +5658,71 @@ fn main() { (".fixture-volumes", ".fixture-volume-failure") } else if actual == ["volume", "inspect", "openbot_postgres-data"] { (".fixture-inspect", ".fixture-inspect-failure") - } else if actual == ["volume", "rm", "openbot_postgres-data"] { - if std::path::Path::new(".fixture-attached").exists() { + } else if actual.len() == 3 && actual[0] == "volume" && actual[1] == "inspect" { + let file = format!(".fixture-inspect-{}", actual[2]); + if !std::path::Path::new(&file).exists() { + eprintln!("no such volume: {}", actual[2]); + std::process::exit(1); + } + print!("{}", std::fs::read_to_string(file).unwrap()); + return; + } else if actual.len() == 3 && actual[0] == "volume" && actual[1] == "rm" { + let volume = actual[2].as_str(); + if (volume == "openbot_postgres-data" && std::path::Path::new(".fixture-attached").exists()) + || std::path::Path::new(&format!(".fixture-attached-{volume}")).exists() + || fixture_containers().iter().any(|container| container.mounts.iter().any(|mount| mount == volume)) + { eprintln!("synthetic-secret attached container"); std::process::exit(2); } let inventory = std::fs::read_to_string(".fixture-volumes").unwrap(); - let remaining: String = inventory.lines().filter(|name| *name != "openbot_postgres-data").map(|name| format!("{name}\n")).collect(); + let remaining: String = inventory.lines().filter(|name| *name != volume).map(|name| format!("{name}\n")).collect(); std::fs::write(".fixture-volumes", remaining).unwrap(); return; + } else if actual.first().map(String::as_str) == Some("ps") { + assert_eq!(actual[1..4], ["--all", "--quiet", "--no-trunc"], "{actual:?}"); + let wanted: Vec<(&str, &str)> = actual[4..].chunks(2).map(|pair| { + assert_eq!(pair[0], "--filter"); + pair[1].strip_prefix("label=").unwrap().split_once('=').unwrap() + }).collect(); + if std::path::Path::new(".fixture-ps-failure").exists() { + eprintln!("synthetic-secret-must-not-appear-in-diagnostics"); + std::process::exit(17); + } + for container in fixture_containers() { + if wanted.iter().all(|(key, value)| container.labels.iter().any(|(k, v)| k == key && v == value)) { + println!("{}", container.id); + } + } + return; + } else if actual.len() == 3 && actual[0] == "container" && actual[1] == "inspect" { + let Some(container) = fixture_containers().into_iter().find(|container| container.id == actual[2]) else { + eprintln!("no such container: {}", actual[2]); + std::process::exit(1); + }; + // Docker reports names with a leading slash; Podman does not. + let slash = if args.first().map(String::as_str) == Some("--connection") { "" } else { "/" }; + let labels: Vec = container.labels.iter().map(|(k, v)| format!("\"{k}\":\"{v}\"")).collect(); + let mounts: Vec = container.mounts.iter().map(|m| format!("{{\"Type\":\"volume\",\"Name\":\"{m}\"}}")).collect(); + print!( + "[{{\"Id\":\"{}\",\"Name\":\"{slash}{}\",\"State\":{{\"Status\":\"{}\",\"Running\":{}}},\"Config\":{{\"Labels\":{{{}}}}},\"Mounts\":[{}]}}]", + container.id, container.name, container.status, container.status == "running", labels.join(","), mounts.join(",") + ); + return; + } else if actual.len() == 3 && actual[0] == "container" && actual[1] == "rm" { + let containers = fixture_containers(); + let Some(container) = containers.iter().find(|container| container.id == actual[2]) else { + eprintln!("no such container: {}", actual[2]); + std::process::exit(1); + }; + if container.status == "running" { + eprintln!("synthetic-secret cannot remove a running container"); + std::process::exit(2); + } + let inventory = std::fs::read_to_string(".fixture-containers").unwrap(); + let remaining: String = inventory.lines().filter(|line| line.split('\t').next() != Some(actual[2].as_str())).map(|line| format!("{line}\n")).collect(); + std::fs::write(".fixture-containers", remaining).unwrap(); + return; } else { panic!("unexpected volume probe command: {actual:?}"); }; if std::path::Path::new(failure).exists() { eprintln!("synthetic-secret-must-not-appear-in-diagnostics"); diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 98e42fe0ef..4f14f30f6d 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -1237,6 +1237,7 @@ test("leftover database recovery requires confirmation and keeps setup ready to throw { said: "A previous OpenBot database remains without its encryption key.", database_reset: "fixture_postgres-data", + leftover_reset: ["fixture_postgres-data"], }; if (command === "reset_leftover_database") return reset.promise; return previous(command, args); @@ -1253,6 +1254,7 @@ test("leftover database recovery requires confirmation and keeps setup ready to await view.findByRole("button", { name: "Reset leftover database" }), ); expect(view.getByText(/permanently deletes/)).toBeTruthy(); + expect(view.getByText("fixture_postgres-data")).toBeTruthy(); expect(resets()).toHaveLength(0); await userEvent.click(view.getByRole("button", { name: "Keep saved data" })); expect(resets()).toHaveLength(0); @@ -1268,7 +1270,8 @@ test("leftover database recovery requires confirmation and keeps setup ready to command: "reset_leftover_database", args: { root: "/tmp/openbot-app-test", - volume: "fixture_postgres-data", + volumes: ["fixture_postgres-data"], + containers: [], confirmed: true, }, }, @@ -1291,6 +1294,61 @@ test("leftover database recovery requires confirmation and keeps setup ready to expect(installationCalls()).toHaveLength(1); }); +test("leftover data recovery names every container and volume and removes only that list", async () => { + useCompatibleEndpointSetup({}); + const previous = invokeHandler; + const volumes = [ + "openbot-profile-general-assistant", + "openbot-workspace-general-assistant", + "openbot_agent-profiles", + ]; + const containers = [ + "openbot-agent-computer-1", + "openbot-computer-general-assistant", + ]; + invokeHandler = async (command, args) => { + if (command === "start_stack") + throw { + said: "OpenBot found data from a previous installation.", + leftover_reset: volumes, + leftover_containers: containers, + }; + if (command === "reset_leftover_database") return null; + return previous(command, args); + }; + const view = await enterCompatibleEndpoint( + "https://model.example/v1", + "synthetic-key", + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await userEvent.click( + await view.findByRole("button", { name: "Reset leftover data" }), + ); + expect(view.queryByText(/local OpenBot database/)).toBeNull(); + expect(view.getByText(/browser sign-ins/)).toBeTruthy(); + for (const volume of volumes) expect(view.getByText(volume)).toBeTruthy(); + expect(view.getByText(/stopped containers/)).toBeTruthy(); + for (const container of containers) + expect(view.getByText(container)).toBeTruthy(); + await userEvent.click( + view.getByRole("button", { name: "Delete saved data" }), + ); + expect( + invokeCalls.filter((call) => call.command === "reset_leftover_database"), + ).toEqual([ + { + command: "reset_leftover_database", + args: { + root: "/tmp/openbot-app-test", + volumes, + containers, + confirmed: true, + }, + }, + ]); +}); + test("database recovery failure is shown without restarting services", async () => { useCompatibleEndpointSetup({}); const previous = invokeHandler; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index ad54068c62..61794c2bc1 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -296,6 +296,13 @@ export function App() { if (problem.connection === "organization") setRefreshOrganization(true); }, []); const displayedFailure = failure ?? recoveryFailure; + // An older shell offered only the database. The list is exactly what a reset may remove. + const leftoverVolumes = displayedFailure?.leftover_reset?.length + ? displayedFailure.leftover_reset + : displayedFailure?.database_reset + ? [displayedFailure.database_reset] + : []; + const leftoverContainers = displayedFailure?.leftover_containers ?? []; const credentialContext = useRef([ root, model, @@ -602,12 +609,13 @@ export function App() { } } - async function resetLeftoverDatabase(volume: string) { + async function resetLeftoverData(volumes: string[], containers: string[]) { setBusy(true); try { await invoke("reset_leftover_database", { root: root.trim(), - volume, + volumes, + containers, confirmed: true, }); setFailure(null); @@ -982,12 +990,14 @@ export function App() { {/* A failure outranks `running`. The supervisor gives up on a process and sends the window back here, and a heading that still says everything is running while the box underneath names the process that stopped is a screen arguing with itself. */} - {!running && displayedFailure?.database_reset && ( + {!running && leftoverVolumes.length > 0 && ( )} diff --git a/desktop/src/DatabaseReset.tsx b/desktop/src/DatabaseReset.tsx index 8d3dcd46a7..c528a179d1 100644 --- a/desktop/src/DatabaseReset.tsx +++ b/desktop/src/DatabaseReset.tsx @@ -1,14 +1,21 @@ import { useState } from "react"; -/** Only rendered when native setup has identified a recoverable leftover database. */ +/** Only rendered when native setup has identified leftover data it can reset. */ export function DatabaseReset({ busy, + containers, + database, onReset, - volume, + volumes, }: { busy: boolean; - volume: string; - onReset: (volume: string) => Promise; + /** Stopped containers that still hold the volumes. The reset removes them first. */ + containers: string[]; + /** Whether the local OpenBot database is one of the volumes. */ + database: boolean; + /** Exactly the volumes native setup verified, and the only ones a reset removes. */ + volumes: string[]; + onReset: (volumes: string[], containers: string[]) => Promise; }) { const [confirming, setConfirming] = useState(false); if (!confirming) { @@ -19,20 +26,55 @@ export function DatabaseReset({ disabled={busy} onClick={() => setConfirming(true)} > - Reset leftover database + {database ? "Reset leftover database" : "Reset leftover data"} ); } + const others = volumes.length > (database ? 1 : 0); return (

Delete the previous installation’s data?

+ {database && ( +

+ This permanently deletes the local OpenBot database, including saved + chats, Bots, and settings. +

+ )} + {others && ( +

+ {database + ? "It also permanently deletes other data it left, including Bot files and browser sign-ins." + : "This permanently deletes data the previous installation left, including Bot files and browser sign-ins."} +

+ )}

- This permanently deletes the local OpenBot database, including saved - chats, Bots, and settings. Only continue if you want a fresh - installation and do not need that data. + Only continue if you want a fresh installation and do not need that + data. OpenBot deletes these volumes:

+
    + {volumes.map((volume) => ( +
  • + {volume} +
  • + ))} +
+ {containers.length > 0 && ( + <> +

+ It first removes these stopped containers from the previous + installation, which still use those volumes: +

+
    + {containers.map((container) => ( +
  • + {container} +
  • + ))} +
+ + )}

To keep your data, restore its original encryption key instead.

-
diff --git a/desktop/src/Problem.tsx b/desktop/src/Problem.tsx index 723f93edee..72a10b1b9b 100644 --- a/desktop/src/Problem.tsx +++ b/desktop/src/Problem.tsx @@ -13,6 +13,10 @@ export type Problem = { detail?: string | null; connection?: "model" | "intelligence" | "organization" | null; database_reset?: string | null; + /** Every volume a confirmed fresh-install reset removes, the database included. */ + leftover_reset?: string[] | null; + /** Stopped containers that still hold those volumes, removed first by the same reset. */ + leftover_containers?: string[] | null; }; /** Anything thrown, as a problem. A bare string keeps working and reads as it always did. */