diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs new file mode 100644 index 000000000..ebd4049f8 --- /dev/null +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -0,0 +1,318 @@ +//! Docker Desktop: opened when it is installed, stopped, and safe to open. +//! +//! **OpenBot never puts a person in front of Docker's terms, and never waits on them.** A Docker +//! Desktop that was installed and never opened shows its subscription terms on first open, and +//! from outside it nothing tells that window apart from a password prompt, a required sign-in or a +//! slow boot. So a stopped Docker Desktop is opened only when it is known to start by itself: +//! +//! - it has run before on this machine (`used_before`: its VM disk exists), or +//! - this installation was set up on Docker, so it answered for OpenBot before. +//! +//! Anything else 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. + +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use crate::engine::{self, Engine}; +use crate::problem::Problem; + +/// How long a Docker Desktop that is safe to open gets to start answering: a cold start on a slow +/// machine, with room to spare. One still not answering by then is most likely stopped on a screen +/// of its own (updated terms, a required sign-in), and the person is told to finish it. +const START_PATIENCE: Duration = Duration::from_secs(3 * 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() +} + +pub(crate) 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 this machine's Docker Desktop has run before: its terms were accepted and its first-run +/// screens are done, so opening it starts Docker and shows nothing that needs the person. +/// +/// The evidence is Docker Desktop's VM disk, which holds every image and container. Docker creates +/// it when its engine first starts, and the engine starts only after the terms are accepted. +/// Docker documents these places in "How to back up and restore your Docker Desktop data". A disk +/// moved elsewhere in Docker's settings is not found, and that machine gets Podman: the safe side. +/// +/// METADATA ONLY. On macOS the disk is inside Docker's app container, and reading or listing +/// anything there raises "would like to access data from other apps". A `stat` of a path inside it +/// raises nothing: checked on macOS 27.0.1 against the TCC log, where listing does show up. Never +/// open, read or list here, and never run `docker` to find out. +pub fn used_before() -> bool { + any_disk(&vm_disks()) +} + +fn any_disk(disks: &[PathBuf]) -> bool { + disks.iter().any(|disk| disk.is_file()) +} + +fn vm_disks() -> Vec { + #[cfg(target_os = "macos")] + { + std::env::var_os("HOME") + .map(|home| { + PathBuf::from(home) + .join("Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw") + }) + .into_iter() + .collect() + } + #[cfg(target_os = "windows")] + { + // WSL 2 now, then WSL 2 as earlier releases laid it out, then Docker's own VM manager and + // Hyper-V. + let local = std::env::var_os("LOCALAPPDATA").map(PathBuf::from); + let data = std::env::var_os("ProgramData").map(PathBuf::from); + [ + local + .as_ref() + .map(|l| l.join("Docker\\wsl\\disk\\docker_data.vhdx")), + local + .as_ref() + .map(|l| l.join("Docker\\wsl\\data\\docker_data.vhdx")), + local + .as_ref() + .map(|l| l.join("Docker\\wsl\\data\\ext4.vhdx")), + local + .as_ref() + .map(|l| l.join("Docker\\vm-data\\DockerDesktop.vhdx")), + data.map(|d| d.join("DockerDesktop\\vm-data\\DockerDesktop.vhdx")), + ] + .into_iter() + .flatten() + .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. Only for a Docker Desktop that is safe to open: +/// 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.".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 didn't start. Open it and finish anything it shows, then try again.", + format!( + "Docker did not answer within {}s of opening Docker Desktop", + START_PATIENCE.as_secs() + ), + )) +} + +/// Before Start, for an installation set up on Docker: a stopped Docker Desktop is opened. It +/// answered for this installation before, so its first-run screens are done. Without a Docker +/// Desktop to open, nothing is done here and Start reports that Docker is not answering. +pub fn start_saved(progress: &mut dyn FnMut(String)) -> Result<(), Problem> { + if cfg!(any(target_os = "macos", target_os = "windows")) && installed_app().is_some() { + start(progress)?; + } + Ok(()) +} + +/// 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."]); + } + + #[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 didn't start.")); + assert!(problem.detail.unwrap().contains("180s")); + } + + #[test] + fn a_docker_desktop_is_used_before_only_once_its_vm_disk_exists() { + let root = crate::test_support::temp_root("docker-desktop-vm-disk"); + let disk = root.join("vms/0/data/Docker.raw"); + // Installed and never opened: no disk, and the folder alone is not a disk. + assert!(!any_disk(std::slice::from_ref(&disk))); + std::fs::create_dir_all(&disk).unwrap(); + assert!(!any_disk(std::slice::from_ref(&disk))); + std::fs::remove_dir(&disk).unwrap(); + std::fs::write(&disk, "").unwrap(); + assert!(any_disk(&[root.join("elsewhere.vhdx"), disk])); + } + + #[test] + #[cfg(target_os = "macos")] + fn the_mac_vm_disk_is_where_docker_documents_it() { + let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()); + assert_eq!( + vm_disks(), + [home.join("Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw")] + ); + } + + #[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/engine.rs b/desktop/src-tauri/src/engine.rs index 88431a9e7..2108ce191 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Engine { - /// Docker Desktop, OrbStack, Colima, or a Docker daemon by any other name. + /// Any Docker daemon, whatever product runs it. Docker, Podman, } @@ -413,7 +413,13 @@ fn on_path(binary: &str) -> Option { /// The fixed places each platform's installers use. fn where_installers_put(engine: Engine) -> Option { - let places: Vec = match engine { + installer_places(engine) + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn installer_places(engine: Engine) -> Vec { + match engine { #[cfg(target_os = "windows")] Engine::Podman => { // The MSI uses Programs\Podman per user and Program Files\Podman per machine. @@ -444,15 +450,19 @@ fn where_installers_put(engine: Engine) -> Option { .iter() .map(PathBuf::from) .collect(), + // Docker Desktop's own copy is looked for in every place `docker_desktop` looks for the + // app, `~/Applications` included: an app that setup starts has to have a CLI that setup + // can find, or Docker answering is never seen and the start times out. #[cfg(target_os = "macos")] - Engine::Docker => [ - "/usr/local/bin/docker", - "/opt/homebrew/bin/docker", - "/Applications/Docker.app/Contents/Resources/bin/docker", - ] - .iter() - .map(PathBuf::from) - .collect(), + Engine::Docker => ["/usr/local/bin/docker", "/opt/homebrew/bin/docker"] + .iter() + .map(PathBuf::from) + .chain( + crate::docker_desktop::app_places() + .into_iter() + .map(|app| app.join("Contents/Resources/bin/docker")), + ) + .collect(), #[cfg(target_os = "linux")] Engine::Podman => ["/usr/bin/podman", "/usr/local/bin/podman"] .iter() @@ -463,8 +473,7 @@ fn where_installers_put(engine: Engine) -> Option { .iter() .map(PathBuf::from) .collect(), - }; - places.into_iter().find(|candidate| candidate.is_file()) + } } /// The rootless socket on Linux, which is the one Compose must mount. @@ -493,7 +502,23 @@ pub fn rootless_socket() -> Option { /// What is here, before anything is installed. pub fn detect() -> EngineStatus { - for engine in [Engine::Docker, Engine::Podman] { + detect_among(&[Engine::Docker, Engine::Podman]) +} + +/// What is here of the engine an installation was set up on, or of either when it saved none. +/// +/// The other engine is never looked at: an installation on Podman that found an answering Docker +/// would start on it with none of its images and an empty database. See +/// `preparation::saved_engine`. +pub fn detect_saved(saved: Option) -> EngineStatus { + match saved { + Some(engine) => detect_among(&[engine]), + None => detect(), + } +} + +fn detect_among(engines: &[Engine]) -> EngineStatus { + for &engine in engines { let address = Address::new(engine, None); if address.responds() { return answering(address); @@ -502,14 +527,16 @@ pub fn detect() -> EngineStatus { // Podman's default connection can name a machine that is not running while another one is. That // is not "no engine", and creating a second machine in answer to it is the wrong repair. - if let Some(machine) = running_machine(crate::acquire::MACHINE) { - let address = Address::new(Engine::Podman, Some(machine)); - if address.responds() { - return answering(address); + if engines.contains(&Engine::Podman) { + if let Some(machine) = running_machine(crate::acquire::MACHINE) { + let address = Address::new(Engine::Podman, Some(machine)); + if address.responds() { + return answering(address); + } } } - for engine in [Engine::Docker, Engine::Podman] { + for &engine in engines { if program(engine).is_some() { return EngineStatus { engine: Some(engine), @@ -787,6 +814,16 @@ fn main() { } } + #[test] + #[cfg(target_os = "macos")] + fn every_docker_desktop_setup_can_start_has_a_cli_setup_can_find() { + let places = installer_places(Engine::Docker); + for app in crate::docker_desktop::app_places() { + let cli = app.join("Contents/Resources/bin/docker"); + assert!(places.contains(&cli), "{} is not looked for", cli.display()); + } + } + /// The tools live under the downloads they came from, so one install of the app has one place /// for both and a retry finds what it already fetched. #[test] diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 2900d3a57..a63b5c6b4 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,5 +1,15 @@ //! Installing an engine, instead of telling somebody to go and get one. //! +//! **Which engine.** A new installation uses, in this order: +//! +//! 1. a Docker that answers; +//! 2. on macOS and Windows, a stopped Docker Desktop that has run before, which is opened (see +//! `docker_desktop`); +//! 3. Podman: the one somebody already has, or one installed here. +//! +//! The engine chosen is saved with the installation (`preparation::saved_engine`), and every later +//! install and start uses it and only it. +//! //! 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. @@ -367,37 +377,152 @@ pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { /// /// Both halves, in that order, because the second is invisible until the first has succeeded and /// 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, |_| {}) +/// registers. `saved` is the engine the installation was set up on, if it saved one. +pub fn install_engine(cache: &Path, saved: Option) -> Result { + install_engine_observed(cache, saved, |_, _| {}, &mut |_| {}).map(|chosen| chosen.said) +} + +/// The engine the install step settled on, and the sentence for its step row. +#[derive(Debug, PartialEq, Eq)] +pub struct EngineChosen { + pub engine: Engine, + pub said: String, } -/// 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), -) -> Result { - install_engine_with(cache, installed, install_podman, place_compose) + saved: Option, + installed: impl FnMut(Engine, bool), + progress: &mut dyn FnMut(String), +) -> Result { + install_engine_with( + cache, + Found::here(saved), + saved, + 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. +/// +/// Nothing here asks what product is behind a `docker` command. A Docker that answers is used, a +/// Docker Desktop that is safe to open is opened, and anything else is a machine without Docker. +#[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 Desktop has run on this machine before, so it opens without its terms. See + /// `docker_desktop::used_before`. + docker_desktop_used_before: bool, + podman_installed: bool, +} + +impl Found { + fn here(saved: Option) -> Self { + // An installation on Podman never looks at Docker, and so never runs `docker` either. + let docker = saved != Some(Engine::Podman); + let docker_app_installed = docker && crate::docker_desktop::installed_app().is_some(); + Self { + docker_desktop_platform: cfg!(any(target_os = "macos", target_os = "windows")), + docker_answers: docker && engine::Address::new(Engine::Docker, None).responds(), + docker_app_installed, + docker_desktop_used_before: docker_app_installed + && crate::docker_desktop::used_before(), + podman_installed: engine::program(Engine::Podman).is_some(), + } + } +} + +/// What the install step does to have an engine answering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Choice { + /// Docker answers now. + UseDocker, + /// Docker Desktop is installed and stopped, and opening it shows nothing that needs the person. + OpenDockerDesktop, + /// This installation is on Docker, Docker does not answer, and there is no Docker Desktop to + /// open. Only the person can start it. + DockerNotRunning, + UsePodman, + InstallPodman, +} + +/* + * DOCKER WHEN IT IS READY, PODMAN OTHERWISE, AND NEVER A MOVE BETWEEN THEM. + * + * A new installation uses a Docker that answers, then a stopped Docker Desktop that has run before, + * then Podman. A Docker Desktop that has never run is not opened: its first window is Docker's + * terms, and OpenBot must never put a person in front of them or wait on them. A `docker` command + * that does not answer is not a reason to stop either. That machine gets Podman too. + * + * An installation that saved its engine keeps it. Its images and its database are there, and + * the other engine would start it empty. A saved Docker Desktop that is stopped is opened: it + * answered for this installation before. + * + * Linux has no Docker Desktop. An answering Docker is used there, and anything else gets Podman. + */ +fn choose(found: Found, saved: Option) -> Choice { + let can_open_docker_desktop = found.docker_desktop_platform && found.docker_app_installed; + let podman = if found.podman_installed { + Choice::UsePodman + } else { + Choice::InstallPodman + }; + match saved { + Some(Engine::Podman) => podman, + Some(Engine::Docker) if found.docker_answers => Choice::UseDocker, + Some(Engine::Docker) if can_open_docker_desktop => Choice::OpenDockerDesktop, + Some(Engine::Docker) => Choice::DockerNotRunning, + None if found.docker_answers => Choice::UseDocker, + None if can_open_docker_desktop && found.docker_desktop_used_before => { + Choice::OpenDockerDesktop + } + None => podman, + } } fn install_engine_with( cache: &Path, - mut installed: impl FnMut(bool), + found: Found, + saved: Option, + 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 { +) -> 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() - { - return place_compose(&into); + let chosen = |engine, said| EngineChosen { engine, said }; + + match choose(found, saved) { + Choice::UseDocker => return place_compose(&into).map(|said| chosen(Engine::Docker, said)), + // Nothing is placed: Docker Desktop ships Compose as a CLI plugin, and `docker compose` + // never looks on PATH, where a placed provider goes. `place_compose` asks Podman, which a + // Docker-only machine does not have, so it downloaded a provider nothing used. The caller + // checks Docker's own Compose once Docker answers. + Choice::OpenDockerDesktop => { + return start_docker().map(|said| chosen(Engine::Docker, said)) + } + Choice::DockerNotRunning => { + return Err(Problem::with( + "OpenBot was installed with Docker, and Docker isn't running. Start Docker, then \ + try again.", + "this installation saved Docker as its engine; docker does not answer and there \ + is no Docker Desktop to open", + )) + } + Choice::UsePodman => return place_compose(&into).map(|said| chosen(Engine::Podman, said)), + Choice::InstallPodman => {} } 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 @@ -416,7 +541,10 @@ fn install_engine_with( } place_compose(&into)?; - Ok(format!("Podman {PODMAN} and Compose {COMPOSE} installed.")) + Ok(chosen( + Engine::Podman, + format!("Podman {PODMAN} and Compose {COMPOSE} installed."), + )) } /// Place the Compose provider where the engine will find it, unless something already provides one. @@ -665,65 +793,306 @@ mod tests { use super::*; use crate::test_support::temp_root; + /// A Mac or a PC. `docker_app` is a Docker Desktop that is installed; `used` is one that has + /// run before. + fn found(docker_answers: bool, docker_app: bool, used: bool, podman: bool) -> Found { + Found { + docker_desktop_platform: true, + docker_answers, + docker_app_installed: docker_app, + docker_desktop_used_before: used, + podman_installed: podman, + } + } + + fn linux(found: Found) -> Found { + Found { + docker_desktop_platform: false, + ..found + } + } + + const NOTHING: Found = Found { + docker_desktop_platform: true, + docker_answers: false, + docker_app_installed: false, + docker_desktop_used_before: false, + podman_installed: false, + }; + + /// The whole table, for a new installation and for one that saved its engine. #[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 the_engine_is_chosen_by_this_table() { + use Choice::*; + let docker = Some(Engine::Docker); + let podman = Some(Engine::Podman); + let rows: [(&str, Found, Option, Choice); 18] = [ + // A new installation. + ( + "answering Docker", + found(true, false, false, false), + None, + UseDocker, + ), + ( + "answering Docker Desktop beside Podman", + found(true, true, true, true), + None, + UseDocker, + ), + ( + "stopped Docker Desktop, used before", + found(false, true, true, false), + None, + OpenDockerDesktop, + ), + ( + "stopped Docker Desktop, used before, Podman too", + found(false, true, true, true), + None, + OpenDockerDesktop, + ), + ( + "Docker Desktop never used", + found(false, true, false, false), + None, + InstallPodman, + ), + ( + "Docker Desktop never used, Podman here", + found(false, true, false, true), + None, + UsePodman, + ), + // A `docker` command that does not answer, with no Docker Desktop behind it, is no + // Docker at all: nothing in `Found` even records that the command is there. + ( + "stopped docker without Docker Desktop", + NOTHING, + None, + InstallPodman, + ), + ( + "existing Podman", + found(false, false, false, true), + None, + UsePodman, + ), + ("nothing", NOTHING, None, InstallPodman), + // An installation saved on Docker. + ( + "saved Docker, answering", + found(true, true, true, false), + docker, + UseDocker, + ), + ( + "saved Docker, Docker Desktop stopped", + found(false, true, true, true), + docker, + OpenDockerDesktop, + ), + // It answered for this installation, so it opens even with no disk where Docker + // documents it (moved in Docker's settings, say). + ( + "saved Docker, disk moved", + found(false, true, false, true), + docker, + OpenDockerDesktop, + ), + ( + "saved Docker, none to open", + found(false, false, false, true), + docker, + DockerNotRunning, + ), + // An installation saved on Podman never moves to Docker. + ( + "saved Podman, Docker answering", + found(true, true, true, true), + podman, + UsePodman, + ), + ( + "saved Podman, Docker Desktop used before", + found(false, true, true, true), + podman, + UsePodman, + ), + ( + "saved Podman, Podman removed", + found(true, true, true, false), + podman, + InstallPodman, + ), + // Linux: unchanged, and no Docker Desktop to open. + ( + "Linux, answering Docker", + linux(found(true, false, false, false)), + None, + UseDocker, + ), + ( + "Linux, saved Docker stopped", + linux(found(false, true, true, true)), + docker, + DockerNotRunning, + ), + ]; + for (row, found, saved, expected) in rows { + assert_eq!(choose(found, saved), expected, "{row}"); } - 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); + } + + #[test] + fn a_stopped_docker_desktop_that_has_run_before_is_opened_and_podman_is_never_installed_beside_it( + ) { + let root = temp_root("stopped-docker-desktop"); + let mut observed = Vec::new(); + // Used before, or saved by this installation: Docker Desktop is opened. + for (found, saved) in [ + (found(false, true, true, false), None), + (found(false, true, true, true), None), + (found(false, true, false, false), Some(Engine::Docker)), + ] { + let chosen = install_engine_with( + &root, + found, + saved, + |engine, ok| observed.push((engine, ok)), + || Ok("started".into()), + |_| panic!("Podman is never installed beside Docker"), + |_| panic!("Docker Desktop ships its own Compose"), + ) + .unwrap(); + assert_eq!( + chosen, + EngineChosen { + engine: Engine::Docker, + said: "started".into() } - 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), + None, + |_, _| 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 podman_is_installed(found: Found, saved: Option) { + 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, + saved, + |engine, ok| observed.push((engine, ok)), + || panic!("no Docker Desktop is opened"), + |_| 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() { + podman_is_installed(NOTHING, None); + } + + #[test] + fn a_docker_desktop_that_has_never_run_is_not_opened_and_podman_is_installed() { + podman_is_installed(found(false, true, false, false), None); + } + + #[test] + fn linux_keeps_installing_podman() { + podman_is_installed(linux(NOTHING), None); + // Linux has no Docker Desktop to open, whatever the flags say. + podman_is_installed(linux(found(false, true, true, false)), None); + } + + #[test] + fn an_installation_saved_on_podman_never_moves_to_an_answering_docker() { + let root = temp_root("saved-podman"); + let chosen = install_engine_with( + &root, + found(true, true, true, true), + Some(Engine::Podman), + |_, _| panic!("nothing is installed"), + || panic!("Docker Desktop is not opened for a Podman installation"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(chosen.engine, Engine::Podman); + // And a Podman that has gone is installed again, not swapped for Docker. + podman_is_installed(found(true, true, true, false), Some(Engine::Podman)); + } + + #[test] + fn an_installation_saved_on_docker_with_none_to_open_asks_for_docker() { + let root = temp_root("saved-docker-stopped"); + let problem = install_engine_with( + &root, + found(false, false, false, true), + Some(Engine::Docker), + |_, _| panic!("nothing is installed"), + || panic!("there is no Docker Desktop to open"), + |_| panic!("no Podman in Docker's place"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert!(problem.said.contains("Start Docker"), "{}", problem.said); + } + + #[test] + fn a_podman_somebody_already_has_is_theirs_and_is_used() { + let root = temp_root("existing-podman"); + let chosen = install_engine_with( &root, - |_| panic!("no Podman installation should be observed"), - |_| panic!("responding Docker must be reused"), + found(false, false, false, true), + None, + |_, _| panic!("nothing is installed"), + || panic!("no Docker Desktop to open"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!( + chosen, + EngineChosen { + engine: Engine::Podman, + said: "Compose placed".into() + } ); - assert_eq!(result.unwrap(), "Compose placed"); - std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let root = temp_root("answering-docker"); + let chosen = install_engine_with( + &root, + found(true, false, false, true), + None, + |_, _| panic!("nothing is installed"), + || panic!("an answering Docker is not started again"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(chosen.engine, Engine::Docker); } /// 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 f0459213a..df554af8f 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 bcafefee8..b7cc830b8 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -255,12 +255,69 @@ struct AlreadyConfigured { auto_start: bool, } +/// The engine the install step left answering, if it left one. +/// +/// Starting Docker Desktop leaves Docker answering, and the machine steps after the install step +/// are Podman's: on a Mac or a PC without Podman, the first of them fails at `podman machine list` +/// with Docker already running. A Podman the step installed or found answers only once its +/// machine is up, so it is `None` here and the machine steps run. +fn answering_after_install( + ready: &engine::EngineStatus, + composes: impl FnOnce(&engine::Address) -> bool, +) -> Result, Problem> { + let Some(address) = ready.address.clone().filter(|_| ready.responding) else { + return Ok(None); + }; + if !composes(&address) { + return Err(Problem::plain(acquire::missing_compose( + address.engine.binary(), + ))); + } + Ok(Some(address)) +} + struct ReadyRespondingEngine { address: engine::Address, detail: String, installed: Option, } +/// What Start does first, so the engine this installation runs on is answering. +/// +/// Install brings the engine up; Start used to only look for it. So after a restart a Podman +/// installation's machine was stopped and Start failed with "podman is installed but not +/// answering", which nobody this app is for can act on. Now the saved engine is brought up: +/// +/// - Docker: a stopped Docker Desktop is opened. It answered for this installation before. +/// - Podman, or an installation from before engines were saved: OpenBot's own machine is +/// started, when it exists. A machine is never created here, and nobody else's is started. +/// +/// An engine that still does not answer is reported by the check after this one. An older +/// installation that was on Docker cannot land on an empty machine: Start requires the +/// installation's images on the engine it uses before it runs or saves anything. +fn wake_saved_engine( + saved: Option, + engine_in_a_machine: bool, + answers: impl FnOnce() -> bool, + open_docker_desktop: impl FnOnce() -> Result<(), Problem>, + our_machine_exists: impl FnOnce() -> bool, + start_our_machine: impl FnOnce() -> Result<(), Problem>, +) -> Result<(), Problem> { + if answers() { + return Ok(()); + } + match saved { + Some(engine::Engine::Docker) => open_docker_desktop(), + Some(engine::Engine::Podman) | None => { + if engine_in_a_machine && our_machine_exists() { + start_our_machine() + } else { + Ok(()) + } + } + } +} + /// Return a responding engine only after Compose is present too. fn ready_responding_engine_after_compose_repair( found: engine::EngineStatus, @@ -390,7 +447,12 @@ fn cleanup_root(shell: &Shell, fallback_root: &Path) -> PathBuf { #[tauri::command] fn detect_engine(app: tauri::AppHandle) -> engine::EngineStatus { - let status = engine::detect(); + let root = app + .try_state::() + .and_then(|shell| shell.selected_root.lock().unwrap().clone()); + let status = engine::detect_saved(preparation::saved_engine( + &root.unwrap_or_else(stack::default_root), + )); desktop_telemetry::observe_engine(&app, &status); status } @@ -442,8 +504,13 @@ fn windows_blocker_instruction(blocker: win::Blocker) -> String { /// nothing moving in it reads as a hang. #[tauri::command] async fn prepare_engine(app: tauri::AppHandle) -> Result { - engine_ready(&app).await?; - Ok(engine::detect()) + let saved = { + let shell = app.state::(); + let root = shell.selected_root.lock().unwrap().clone(); + preparation::saved_engine(&root.unwrap_or_else(stack::default_root)) + }; + let address = engine_ready(&app, saved).await?; + Ok(engine::detect_saved(Some(address.engine))) } /// Complete all local software acquisition before either authentication screen is available. @@ -467,9 +534,15 @@ async fn prepare_installation( } remember_selected_root(&shell, &root); } - let address = tauri::async_runtime::block_on(engine_ready(&app))?.pin()?; + let saved = preparation::saved_engine(&root); + let address = tauri::async_runtime::block_on(engine_ready(&app, saved))?.pin()?; attempt.require_current()?; tauri::async_runtime::block_on(deployment_ready(&app, &root))?; + // Saved once the folder is there, before any image is pulled into the engine. Every later + // install and start uses this engine: see `preparation::saved_engine`. + if saved.is_none() { + preparation::save_engine(&root, address.engine)?; + } let picked = harness::picked(harness.as_ref(), &root).map_err(Problem::from)?; let handle = app.clone(); let _startup = attempt.lock_current()?; @@ -610,17 +683,22 @@ async fn prepare_installation( /// /// Reported step by step rather than as one result, because these take minutes and a window with /// nothing moving in it reads as a hang. -async fn engine_ready(app: &tauri::AppHandle) -> Result { +/// `saved` is the engine the installation was set up on: only that one is looked at, started or +/// installed. With none saved, `install::install_engine_with` chooses. +async fn engine_ready( + app: &tauri::AppHandle, + saved: Option, +) -> Result { report_running(app, "engine", "Checking the software OpenBot needs.", None); - let found = engine::detect(); + let found = engine::detect_saved(saved); desktop_telemetry::observe_engine(app, &found); let root = stack::default_root(); let existing = tauri::async_runtime::spawn_blocking(move || { ready_responding_engine_after_compose_repair( found, cfg!(target_os = "linux"), - || install::install_engine(&root), - engine::detect, + || install::install_engine(&root, saved), + || engine::detect_saved(saved), engine::Address::composes, ) }) @@ -676,20 +754,30 @@ 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| { @@ -698,14 +786,24 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result { - report(app, "install-engine", true, said); + let chosen = match installed { + Ok(chosen) => { + report(app, "install-engine", true, chosen.said); + chosen.engine } Err(problem) => { report(app, "install-engine", false, problem.said.clone()); return Err(problem); } + }; + + // Only the engine the install step settled on: a Docker that started answering meanwhile is + // not the one a Podman installation's machine steps are for. + let after = engine::detect_saved(Some(chosen)); + if let Some(address) = answering_after_install(&after, engine::Address::composes)? { + desktop_telemetry::observe_engine(app, &after); + report(app, "engine", true, after.detail); + return Ok(address); } // One at a time, and each only if the last one worked. Written as a loop over an array once, @@ -737,7 +835,7 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result( /// /// Require completed local installation before starting an authentication container. fn prepared_sign_in(root: &Path, published: &str) -> Result<(engine::Address, String), Problem> { - let status = engine::detect(); + let status = engine::detect_saved(preparation::saved_engine(root)); let address = status .address .filter(|_| status.responding) @@ -1484,6 +1582,55 @@ async fn start_stack_inner( ) })?; + // The installation's own engine, brought up when it is stopped: here, before startup is held, + // because it can take minutes and Quit must not wait on it. See `wake_saved_engine`. + let saved = preparation::saved_engine(&root); + let owned = shell.containers.lock().unwrap().is_some(); + if !owned { + let progress_app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + wake_saved_engine( + saved, + cfg!(any(target_os = "macos", target_os = "windows")), + || engine::detect_saved(saved).responding, + || { + openbot_desktop_lib::docker_desktop::start_saved(&mut |said| { + report_running(&progress_app, "engine", said, None) + }) + }, + || acquire::machine_exists().unwrap_or(false), + || { + report_running( + &progress_app, + "start-machine", + "Starting the engine machine.", + None, + ); + let started = acquire::start_machine(); + report( + &progress_app, + "start-machine", + started.ok, + started.said.clone(), + ); + if started.ok { + Ok(()) + } else { + Err(started.problem()) + } + }, + ) + }) + .await + .map_err(|error| { + Problem::with( + "OpenBot could not start the software it runs on. Try again.", + error.to_string(), + ) + })??; + attempt.require_current()?; + } + let (logs, bun, mut secrets, ports, local_browser) = { let _startup = attempt.lock_current()?; @@ -1502,7 +1649,7 @@ async fn start_stack_inner( .map(|owned| owned.address.clone()); let status = match owned_address { Some(address) => address.status(), - None => engine::detect(), + None => engine::detect_saved(saved), }; let Some(found) = status.address.clone().filter(|_| status.responding) else { return Err(status.detail.into()); @@ -1514,6 +1661,11 @@ async fn start_stack_inner( let status = found.status(); acquire::prepare_for_compose(&found)?; let bun = preparation::require(&root, Some(&requested_harness), &found)?; + // An installation from before engines were saved is pinned to the one its images were + // just found on. Best effort: Start does not fail over a hint for next time. + if saved.is_none() { + let _ = preparation::save_engine(&root, found.engine); + } // Checked here as well as in the health gate, because the gate only runs when an engine had to // be installed. A machine that already had Podman skips all of that and arrives at Compose, @@ -3098,7 +3250,7 @@ async fn ask_the_bot_with_settings( Err(None) => { let log = log_service .and_then(|service| { - engine::detect() + engine::detect_saved(preparation::saved_engine(&root)) .address .map(|found| stack::service_log(&found, &root, service, 40)) }) @@ -6679,6 +6831,102 @@ mod tests { assert_eq!(*trace.borrow(), ["install", "detect", "compose"]); } + #[test] + fn a_docker_desktop_started_by_the_install_step_skips_the_podman_machine() { + let docker = engine::EngineStatus { + engine: Some(engine::Engine::Docker), + address: Some(engine::Address::new(engine::Engine::Docker, None)), + responding: true, + engine_socket: None, + detail: "docker is answering.".into(), + }; + let address = answering_after_install(&docker, |_| true) + .unwrap() + .expect("an answering Docker needs no Podman machine"); + assert_eq!(address.engine, engine::Engine::Docker); + + let problem = answering_after_install(&docker, |_| false).unwrap_err(); + assert_eq!( + problem.said, + acquire::missing_compose(engine::Engine::Docker.binary()) + ); + } + + #[test] + fn a_podman_without_its_machine_still_gets_the_machine_steps() { + let podman = engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: None, + responding: false, + engine_socket: None, + detail: "podman is installed but not answering. Start it and try again.".into(), + }; + assert!( + answering_after_install(&podman, |_| panic!("nothing answers to run Compose")) + .unwrap() + .is_none() + ); + } + + #[test] + fn start_brings_up_the_engine_this_installation_runs_on() { + use engine::Engine::{Docker, Podman}; + use std::cell::RefCell; + // (saved, in a machine, answers, our machine exists) -> what was done + let run = |saved, in_machine, answers, exists| { + let done = RefCell::new(Vec::new()); + wake_saved_engine( + saved, + in_machine, + || answers, + || { + done.borrow_mut().push("open Docker Desktop"); + Ok(()) + }, + || exists, + || { + done.borrow_mut().push("start our machine"); + Ok(()) + }, + ) + .unwrap(); + done.into_inner() + }; + let none: [&str; 0] = []; + // Answering: nothing is done, whatever was saved. + for saved in [Some(Docker), Some(Podman), None] { + assert_eq!(run(saved, true, true, true), none, "{saved:?}"); + } + // A saved Docker opens Docker Desktop, never a Podman machine. + assert_eq!( + run(Some(Docker), true, false, true), + ["open Docker Desktop"] + ); + // A saved Podman after a restart: OpenBot's machine is started. + assert_eq!(run(Some(Podman), true, false, true), ["start our machine"]); + // An installation from before engines were saved, with nothing answering: the same. + assert_eq!(run(None, true, false, true), ["start our machine"]); + // No machine of ours: none is created and nobody else's is started. + assert_eq!(run(Some(Podman), true, false, false), none); + assert_eq!(run(None, true, false, false), none); + // Linux runs Podman natively: there is no machine to start. + assert_eq!(run(Some(Podman), false, false, true), none); + } + + #[test] + fn a_machine_that_fails_to_start_stops_start_with_its_own_words() { + let problem = wake_saved_engine( + Some(engine::Engine::Podman), + true, + || false, + || panic!("a Podman installation never opens Docker Desktop"), + || true, + || Err(Problem::plain("machine did not start")), + ) + .unwrap_err(); + assert_eq!(problem.said, "machine did not start"); + } + #[test] fn linux_failed_native_engine_is_an_error_instead_of_a_request_for_qemu() { let stopped = engine::EngineStatus { diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 9b0fa3224..364699140 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -6,13 +6,21 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - deployment, engine::Address, harness::HarnessChoice, local_browser, problem::Problem, stack, + deployment, + engine::{Address, Engine}, + harness::HarnessChoice, + local_browser, + problem::Problem, + stack, }; pub const REQUIRED: &str = "Finish installing OpenBot's local software before signing in or starting. Return to Install and try again."; pub const FILE: &str = ".openbot-prepared.json"; const DEPENDENCIES: &str = ".openbot-dependencies.json"; const LAUNCH: &str = ".openbot-launched.json"; +/// The engine this installation was set up on. Never invalidated with the preparation record: a +/// new release is prepared again on the same engine, where its database already is. +pub const ENGINE: &str = ".openbot-engine.json"; const PACKAGES: [&str; 7] = [ "", "server", @@ -96,6 +104,28 @@ pub fn installation(root: &Path) -> Option { }) } +#[derive(Serialize, Deserialize)] +struct SavedEngine { + engine: Engine, +} + +/// The engine this installation was set up on, or `None` when it saved none (a new installation, +/// or one from before this record existed). +/// +/// Every later install and start uses this engine and never the other one. The installation's +/// images and its database are in the engine it was set up on, so moving to the other one, say +/// because Docker happens to be answering, starts OpenBot with an empty database. +pub fn saved_engine(root: &Path) -> Option { + let record: SavedEngine = + serde_json::from_slice(&std::fs::read(root.join(ENGINE)).ok()?).ok()?; + Some(record.engine) +} + +/// Save the engine this installation runs on. Called once, when it has none saved. +pub fn save_engine(root: &Path, engine: Engine) -> Result<(), Problem> { + write_record(root, ENGINE, &SavedEngine { engine }) +} + pub fn save_selected_root(config: &Path, root: &Path) -> Result<(), Problem> { let root = std::fs::canonicalize(root).map_err(|e| { Problem::with( @@ -494,6 +524,26 @@ pub fn image_settings( mod tests { use super::*; + #[test] + fn the_saved_engine_outlives_a_new_preparation() { + let root = crate::test_support::temp_root("preparation-saved-engine"); + std::fs::create_dir_all(&root).unwrap(); + assert_eq!(saved_engine(&root), None, "nothing saved yet"); + save_engine(&root, Engine::Podman).unwrap(); + assert_eq!(saved_engine(&root), Some(Engine::Podman)); + // A new release is prepared again on the engine its database is in. + invalidate(&root).unwrap(); + assert_eq!(saved_engine(&root), Some(Engine::Podman)); + save_engine(&root, Engine::Docker).unwrap(); + assert_eq!(saved_engine(&root), Some(Engine::Docker)); + std::fs::write(root.join(ENGINE), "not json").unwrap(); + assert_eq!( + saved_engine(&root), + None, + "an unreadable record is no record" + ); + } + #[test] fn a_release_from_before_the_toolkit_needs_no_shared_or_toolkit_manifest() { let root = crate::test_support::temp_root("preparation-v020-layout");