diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs new file mode 100644 index 0000000000..e46caa52cd --- /dev/null +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -0,0 +1,221 @@ +//! Docker Desktop: started when it is installed and not running. +//! +//! **Docker first, Podman when there is no Docker.** A Mac or a Windows machine with Docker Desktop +//! installed but not running is not a machine without an engine: starting it is the repair, not +//! installing a second engine beside it. A machine with no Docker at all gets Podman (see +//! `install::install_engine_with`). OpenBot never installs Docker Desktop itself. +//! +//! Linux is not handled here: Docker there is the distribution's Docker Engine, not Docker Desktop. + +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use crate::engine::{self, Engine}; +use crate::problem::Problem; + +/// How long an installed Docker Desktop gets to start answering. A first start shows Docker's +/// terms and a helper prompt in Docker's own window, so this allows for somebody reading them. +const START_PATIENCE: Duration = Duration::from_secs(5 * 60); +const POLL: Duration = Duration::from_secs(2); + +/// Where Docker Desktop is installed on this machine, if it is. +pub fn installed_app() -> Option { + installed_app_in(&app_places()) +} + +fn installed_app_in(places: &[PathBuf]) -> Option { + places.iter().find(|place| place.exists()).cloned() +} + +fn app_places() -> Vec { + #[cfg(target_os = "macos")] + { + let mut places = vec![PathBuf::from("/Applications/Docker.app")]; + if let Some(home) = std::env::var_os("HOME") { + places.push(PathBuf::from(home).join("Applications/Docker.app")); + } + places + } + #[cfg(target_os = "windows")] + { + std::env::var_os("ProgramFiles") + .map(|files| PathBuf::from(files).join("Docker\\Docker\\Docker Desktop.exe")) + .into_iter() + .collect() + } + #[cfg(target_os = "linux")] + { + Vec::new() + } +} + +/// Whether Docker answers now. +pub fn answers() -> bool { + engine::Address::new(Engine::Docker, None).responds() +} + +/// What `start_with` needs from the machine, so its decisions can be tested without one. +pub(crate) struct Machine<'a> { + pub answers: &'a dyn Fn() -> bool, + pub open: &'a dyn Fn() -> Result<(), Problem>, + pub sleep: &'a dyn Fn(Duration), + pub now: &'a dyn Fn() -> Instant, +} + +/// Docker answering, by opening the installed Docker Desktop. Answers with the sentence for the +/// step row; `progress` is each line before that. See the module comment. +pub fn start(progress: &mut dyn FnMut(String)) -> Result { + start_with( + &Machine { + answers: &answers, + open: &open, + sleep: &std::thread::sleep, + now: &Instant::now, + }, + progress, + ) +} + +pub(crate) fn start_with( + machine: &Machine, + progress: &mut dyn FnMut(String), +) -> Result { + if (machine.answers)() { + return Ok("Docker Desktop is running.".into()); + } + progress("Starting Docker Desktop. Accept anything it asks in its own window.".into()); + (machine.open)()?; + + let started = (machine.now)(); + while (machine.now)().duration_since(started) < START_PATIENCE { + if (machine.answers)() { + return Ok("Docker Desktop is running.".into()); + } + (machine.sleep)(POLL); + } + Err(Problem::with( + "Docker Desktop did not start. Open Docker Desktop, finish anything it asks for, then try \ + again.", + format!( + "Docker did not answer within {}s of opening Docker Desktop", + START_PATIENCE.as_secs() + ), + )) +} + +/// Open Docker Desktop the way the person would. +fn open() -> Result<(), Problem> { + let Some(app) = installed_app() else { + return Err(Problem::plain( + "OpenBot cannot find Docker Desktop. Install it, then try again.", + )); + }; + #[cfg(target_os = "macos")] + let spawned = crate::quiet::command("open").arg("-a").arg(&app).status(); + // Starting Docker Desktop's own executable is the whole job, so a started process counts as + // success and its exit is not waited for. The status is named: `Default::default()` here left + // the type to inference, which had nothing to go on, and failed to compile off macOS. + #[cfg(not(target_os = "macos"))] + let spawned = crate::quiet::command(&app) + .spawn() + .map(|_| std::process::ExitStatus::default()); + match spawned { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {} ended with {status}", app.display()), + )), + Err(error) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {}: {error}", app.display()), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + /// A machine whose Docker answers after `answers_after` polls, counting what was done to it. + struct Fake { + polls: Cell, + answers_after: Option, + opened: Cell, + clock: Cell, + } + + impl Fake { + fn new(answers_after: Option) -> Self { + Self { + polls: Cell::new(0), + answers_after, + opened: Cell::new(0), + clock: Cell::new(Instant::now()), + } + } + + fn run(&self) -> (Result, Vec) { + let said = RefCell::new(Vec::new()); + let answers = || { + let polls = self.polls.get(); + self.polls.set(polls + 1); + self.opened.get() > 0 && self.answers_after.is_some_and(|after| polls >= after) + }; + let result = start_with( + &Machine { + answers: &answers, + open: &|| { + self.opened.set(self.opened.get() + 1); + Ok(()) + }, + sleep: &|step| self.clock.set(self.clock.get() + step), + now: &|| self.clock.get(), + }, + &mut |line| said.borrow_mut().push(line), + ); + (result, said.into_inner()) + } + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let fake = Fake::new(Some(0)); + fake.opened.set(1); + let (result, said) = fake.run(); + assert_eq!(result.unwrap(), "Docker Desktop is running."); + assert_eq!(fake.opened.get(), 1, "nothing was opened again"); + assert!(said.is_empty()); + } + + #[test] + fn an_installed_docker_desktop_that_is_stopped_is_started() { + let fake = Fake::new(Some(3)); + let (result, said) = fake.run(); + assert_eq!(result.unwrap(), "Docker Desktop is running."); + assert_eq!(fake.opened.get(), 1); + assert_eq!( + said, + ["Starting Docker Desktop. Accept anything it asks in its own window."] + ); + } + + #[test] + fn a_docker_desktop_that_never_answers_says_so_after_its_patience() { + let fake = Fake::new(None); + let (result, _) = fake.run(); + let problem = result.unwrap_err(); + assert!(problem.said.starts_with("Docker Desktop did not start.")); + assert!(problem.detail.unwrap().contains("300s")); + } + + #[test] + fn an_installed_app_is_found_in_any_of_its_places() { + let root = crate::test_support::temp_root("docker-desktop-places"); + let first = root.join("Applications/Docker.app"); + let second = root.join("home/Applications/Docker.app"); + assert_eq!(installed_app_in(&[first.clone(), second.clone()]), None); + std::fs::create_dir_all(&second).unwrap(); + assert_eq!(installed_app_in(&[first, second.clone()]), Some(second)); + } +} diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 2900d3a579..4c8a188ec2 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,5 +1,10 @@ //! Installing an engine, instead of telling somebody to go and get one. //! +//! **Docker first, Podman when there is no Docker.** A Docker that answers is used. On macOS and +//! Windows an installed Docker Desktop that is not running is started (see `docker_desktop`), and +//! any other stopped Docker is left for its owner to start. Only a machine with no Docker gets +//! Podman, and a Podman somebody already has is used as it is. +//! //! The person this app is for does not have a package manager, has never heard of Podman, and is //! not going to read a release page. Every sentence that begins "install ..." is a step where an //! install stops for good, so the engine is fetched and installed here. @@ -369,35 +374,93 @@ pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { /// somebody presses Start. Answers with the sentence for the step row, or a failure in both /// registers. pub fn install_engine(cache: &Path) -> Result { - install_engine_observed(cache, |_| {}) + install_engine_observed(cache, |_, _| {}, &mut |_| {}) } -/// Observe an actual Podman installer invocation, excluding existing engines and Compose repair. +/// Observe an actual engine install, excluding engines that were already here, starting Docker +/// Desktop and Compose repair; `progress` is each line for the step row. pub fn install_engine_observed( cache: &Path, - installed: impl FnMut(bool), + installed: impl FnMut(Engine, bool), + progress: &mut dyn FnMut(String), ) -> Result { - install_engine_with(cache, installed, install_podman, place_compose) + install_engine_with( + cache, + Found::here(), + installed, + || crate::docker_desktop::start(progress), + install_podman, + place_compose, + ) +} + +/// What engines this machine has, read once so the choice below can be tested on any machine. +#[derive(Debug, Clone, Copy)] +struct Found { + /// Docker Desktop is how this platform gets Docker: macOS and Windows, not Linux. + docker_desktop_platform: bool, + docker_answers: bool, + docker_app_installed: bool, + docker_cli_installed: bool, + podman_installed: bool, +} + +impl Found { + fn here() -> Self { + Self { + docker_desktop_platform: cfg!(any(target_os = "macos", target_os = "windows")), + docker_answers: engine::Address::new(Engine::Docker, None).responds(), + docker_app_installed: crate::docker_desktop::installed_app().is_some(), + docker_cli_installed: engine::program(Engine::Docker).is_some(), + podman_installed: engine::program(Engine::Podman).is_some(), + } + } } fn install_engine_with( cache: &Path, - mut installed: impl FnMut(bool), + found: Found, + mut installed: impl FnMut(Engine, bool), + start_docker: impl FnOnce() -> Result, install_podman: impl FnOnce(&Path) -> Result<(), Problem>, place_compose: impl FnOnce(&Path) -> Result, ) -> Result { let into = crate::acquire::download_dir(cache); - // engine_ready can start an installed Podman machine, but cannot start Docker. A stopped - // Docker CLI must not skip installing the Podman that preparation will then try to run. - if engine::program(Engine::Podman).is_some() - || engine::Address::new(Engine::Docker, None).responds() - { + if found.docker_answers { + return place_compose(&into); + } + + /* + * DOCKER FIRST, PODMAN WHEN THERE IS NO DOCKER. + * + * An installed Docker Desktop that is not running is started, never replaced: installing + * Podman beside it put a second engine on a machine that already had Docker, behind an + * administrator prompt. A `docker` with no Docker Desktop behind it is OrbStack, Colima or + * similar, stopped; starting it is the repair, and only its owner can. Linux keeps its old + * behaviour: a stopped Docker Engine there needs root to start, and Podman is installed. + */ + if found.docker_desktop_platform { + if found.docker_app_installed { + let said = start_docker()?; + place_compose(&into)?; + return Ok(said); + } + if found.docker_cli_installed && !found.podman_installed { + return Err(Problem::with( + "Docker is installed but not running. Start it, then try again.", + "a docker command is installed, Docker Desktop is not, and docker does not \ + answer", + )); + } + } + + if found.podman_installed { return place_compose(&into); } let result = install_podman(&into); - installed(result.is_ok()); + installed(Engine::Podman, result.is_ok()); result?; // Installed is not found. The MSI extends the *user's* PATH and this process was started with @@ -665,65 +728,128 @@ mod tests { use super::*; use crate::test_support::temp_root; + fn found(docker_answers: bool, docker_app: bool, docker_cli: bool, podman: bool) -> Found { + Found { + docker_desktop_platform: true, + docker_answers, + docker_app_installed: docker_app, + docker_cli_installed: docker_cli, + podman_installed: podman, + } + } + #[test] - #[cfg(windows)] - fn a_stopped_docker_does_not_skip_the_podman_needed_for_setup() { - if crate::test_support::isolated_process( - "install::tests::a_stopped_docker_does_not_skip_the_podman_needed_for_setup", - ) { - return; + fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_beside_it() { + let root = temp_root("stopped-docker-desktop"); + let mut observed = Vec::new(); + // Installed and stopped, with or without a Podman beside it: Docker is started. + for podman in [false, true] { + let said = install_engine_with( + &root, + found(false, true, true, podman), + |engine, ok| observed.push((engine, ok)), + || Ok("started".into()), + |_| panic!("Podman is never installed beside Docker"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "started"); } - let root = temp_root("stopped-docker-install"); - let bin = root.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let source = root.join("engine.rs"); - std::fs::write( - &source, - r#"fn main() { - if std::env::var_os("OPENBOT_TEST_DOCKER_RUNNING").is_none() { - std::process::exit(1); - } - println!("1.44"); - }"#, + assert!(observed.is_empty(), "starting is not an install"); + // A Docker Desktop that does not start is reported as that, and Podman is not tried. + let problem = install_engine_with( + &root, + found(false, true, true, false), + |_, _| panic!("nothing is installed"), + || Err(Problem::plain("no")), + |_| panic!("no Podman in Docker's place"), + |_| panic!("no Compose without an engine"), ) - .unwrap(); - crate::test_support::compile_fixture(&source, &bin.join("docker.exe")); - std::env::set_var("PATH", &bin); - std::env::set_var("LOCALAPPDATA", root.join("Local")); - std::env::set_var("ProgramFiles", root.join("Program Files")); - std::env::remove_var("OPENBOT_TEST_DOCKER_RUNNING"); - - let found = engine::detect(); - assert_eq!(found.engine, Some(Engine::Docker)); - assert!(!found.responding); - assert!(engine::program(Engine::Podman).is_none()); + .unwrap_err(); + assert_eq!(problem.said, "no"); + } + + /// Run on every desktop platform, and on Linux, where the old behaviour is kept. + fn no_engine_installs_podman(found: Found) { + let root = temp_root("no-engine"); let mut observed = Vec::new(); - let result = install_engine_with( + // Whether it then finds a podman program depends on the machine running the test: hosted + // Ubuntu runners ship one. What matters is that the engine installed was Podman. + let _ = install_engine_with( &root, - |success| observed.push(success), - |_| { - // Installation supplies the engine that engine_ready will create/start. - std::fs::copy(bin.join("docker.exe"), bin.join("podman.exe")).unwrap(); - Ok(()) - }, + found, + |engine, ok| observed.push((engine, ok)), + || panic!("no Docker Desktop to start"), + |_| Ok(()), |_| Ok("Compose placed".into()), ); - result.expect("setup should install its missing Podman"); - assert_eq!(observed, [true], "the Podman installer must run"); - assert!(engine::program(Engine::Podman).is_some()); - - // A responding Docker still supplies the engine; never install a replacement. - std::fs::remove_file(bin.join("podman.exe")).unwrap(); - std::env::set_var("OPENBOT_TEST_DOCKER_RUNNING", "1"); - assert!(engine::detect().responding); - let result = install_engine_with( + assert_eq!(observed, [(Engine::Podman, true)]); + } + + #[test] + fn a_machine_with_no_docker_gets_podman() { + no_engine_installs_podman(found(false, false, false, false)); + } + + #[test] + fn linux_keeps_installing_podman() { + let linux = |docker_cli| Found { + docker_desktop_platform: false, + ..found(false, false, docker_cli, false) + }; + no_engine_installs_podman(linux(false)); + // A stopped Docker Engine needs root to start, so Linux installs Podman as it always has. + no_engine_installs_podman(linux(true)); + } + + #[test] + fn a_podman_somebody_already_has_is_theirs_and_is_used() { + let root = temp_root("existing-podman"); + // With or without a stopped `docker` beside it (Podman's own compatibility command). + for docker_cli in [false, true] { + let said = install_engine_with( + &root, + found(false, false, docker_cli, true), + |_, _| panic!("nothing is installed"), + || panic!("no Docker Desktop to start"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + } + + #[test] + fn a_stopped_docker_without_docker_desktop_is_left_for_its_owner_to_start() { + let root = temp_root("stopped-orbstack"); + let problem = install_engine_with( + &root, + found(false, false, true, false), + |_, _| panic!("nothing is installed"), + || panic!("there is no Docker Desktop to start"), + |_| panic!("no Podman beside another Docker"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert!(problem + .said + .starts_with("Docker is installed but not running.")); + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let root = temp_root("answering-docker"); + let said = install_engine_with( &root, - |_| panic!("no Podman installation should be observed"), - |_| panic!("responding Docker must be reused"), + found(true, false, true, true), + |_, _| panic!("nothing is installed"), + || panic!("an answering Docker is not started again"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), - ); - assert_eq!(result.unwrap(), "Compose placed"); - std::fs::remove_dir_all(root).unwrap(); + ) + .unwrap(); + assert_eq!(said, "Compose placed"); } /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1a0a49877b..2204fdf5fe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod acquire; pub mod ask; pub mod deployment; pub mod deployment_release; +pub mod docker_desktop; pub mod engine; pub mod env; pub mod harness; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3877d8b906..3bd12bc1b7 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -658,20 +658,29 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result telemetry::Engine::Docker, + engine::Engine::Podman => telemetry::Engine::Podman, + }, + outcome: if success { + telemetry::EngineInstallOutcome::Success + } else { + telemetry::EngineInstallOutcome::Failure + }, }, - }, - ); - }) + ); + }, + // Starting Docker Desktop can take minutes; each line says where it is. + &mut |said| report_running(&progress_app, "install-engine", said, None), + ) }) .await .map_err(|error| {