diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 9bcbb94b4..37c623fc9 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -9,6 +9,14 @@ //! needs elevation. So the two halves run in different contexts, and the elevated half is the only //! part that may be handed to a helper. See `windows.rs`. Fetching and installing Podman itself is //! `install.rs`. +//! +//! **The machine is rootful.** Local Intelligence runs a k3d cluster, and the Intelligence CLI +//! accepts Podman only in rootful mode, because k3d's node needs privileges a rootless engine +//! cannot grant. A rootful machine is the same VM with three things changed: its default +//! connection is `-root`, `/var/run/docker.sock` inside it is the rootful socket, and the API +//! socket it forwards to this computer is the rootful one. OpenBot's own containers are addressed +//! through `-root` as well (see `connection_for`), so OpenBot and Intelligence share one +//! engine and the supervisor's mount of `/var/run/docker.sock` is the engine it was started by. use crate::quiet::said as command_said; use std::net::Ipv4Addr; @@ -80,7 +88,135 @@ impl StepOutcome { pub const MACHINE: &str = "openbot"; const USER_MODE_NETWORKING_FLAG: &str = "--user-mode-networking=true"; +const ROOTFUL_FLAG: &str = "--rootful"; const HOST_GATEWAY_CONFIG: &str = ".config/containers/containers.conf.d/90-openbot-host.conf"; +/// Where a rootful engine reads the same setting. Root's own `~/.config` is not read by rootful +/// Podman, and the API service a rootful machine runs is the system one, not a user unit. +const ROOTFUL_HOST_GATEWAY_CONFIG: &str = "/etc/containers/containers.conf.d/90-openbot-host.conf"; + +/// The connection that reaches a machine's engine in the mode the machine runs in. +/// +/// `podman machine init` makes two connections for every machine, whatever its mode: ``, +/// which logs in as the machine's user and reaches the rootless engine, and `-root`, which +/// reaches the rootful one. The machine's own mode decides only which of the two is the default, +/// which socket `/var/run/docker.sock` is inside it, and which API it forwards to this computer. +/// Addressing a rootful machine by its bare name would put OpenBot's containers in the rootless +/// engine beside a rootful one, with the supervisor mounting a socket it cannot open. The one +/// exception is an OpenBot that already lives there (see `connection_keeping_openbot`). +pub fn connection_for(machine: &str, rootful: bool) -> String { + if rootful { + format!("{machine}-root") + } else { + machine.to_string() + } +} + +/// The connection to address a running machine through: the one for its mode, unless OpenBot's +/// database is only in the other one's storage. +/// +/// The two engines in one machine keep separate containers and volumes. Releases up to 0.2.0 +/// reached every machine by its bare name, so an OpenBot that adopted somebody's rootful machine, +/// or whose own machine was switched to rootful since, has its database in the rootless engine's +/// storage. Moving it to `-root` would open it on an empty database with the old one +/// unreachable beside it, so it stays where it is. Only a rootful machine is asked: a rootless one +/// was always reached by its bare name. +fn connection_keeping_openbot( + machine: &str, + rootful: bool, + holds_openbot: impl Fn(&str) -> bool, +) -> String { + let own = connection_for(machine, rootful); + if rootful && !holds_openbot(&own) && holds_openbot(machine) { + return machine.to_string(); + } + own +} + +/// Whether OpenBot's database volume is in the engine behind `connection`. +/// +/// By Compose's own label for the volume rather than by its full name, which carries the +/// deployment folder's name. Any project's `postgres-data` counts: a false yes keeps the +/// connection a release up to 0.2.0 would have used, and a false no is the one that loses data. +fn holds_openbot(connection: &str) -> bool { + podman(&[ + "--connection", + connection, + "volume", + "ls", + "--quiet", + "--filter", + "label=com.docker.compose.volume=postgres-data", + ]) + .is_ok_and(|listing| !listing.is_empty()) +} + +/// The machine a connection belongs to, by the naming `connection_for` describes. +fn machine_of(connection: &str) -> &str { + connection.strip_suffix("-root").unwrap_or(connection) +} + +/// What `podman machine inspect` says about one machine that matters here. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MachineFacts { + /// `None` when inspect did not say, which no supported Podman does. + pub rootful: Option, + /// The engine API this machine forwards to this computer: a Unix socket on macOS, a named pipe + /// on Windows, as a `unix://` or `npipe://` address. + pub api: Option, +} + +fn machine_facts(listing: &str, machine: &str) -> Result { + let machines: Vec = serde_json::from_str(listing) + .map_err(|error| format!("could not read podman machine inspect JSON: {error}"))?; + let found = machines + .iter() + .find(|entry| entry.get("Name").and_then(Value::as_str) == Some(machine)) + .ok_or_else(|| format!("podman machine inspect did not return {machine}"))?; + let path = |key: &str| { + found + .pointer(&format!("/ConnectionInfo/{key}/Path")) + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + }; + // The pipe first: Windows reports a pipe, and a socket path beside it is not one this + // computer can open. + let api = path("PodmanPipe") + .map(pipe_address) + .or_else(|| path("PodmanSocket").map(|socket| format!("unix://{socket}"))); + Ok(MachineFacts { + rootful: found.get("Rootful").and_then(Value::as_bool), + api, + }) +} + +/// `\\.\pipe\podman-openbot` as the `npipe:////./pipe/podman-openbot` that Docker clients, k3d and +/// the Intelligence CLI read. +fn pipe_address(pipe: &str) -> String { + format!("npipe://{}", pipe.replace('\\', "/")) +} + +/// What the machine behind a Podman connection is, or why that could not be found. +/// +/// A connection that is not a machine's (a remote host somebody added) answers with an error, and +/// the caller then leaves the engine to find its own way. +pub fn machine_behind(connection: &str) -> Result { + let machine = machine_of(connection); + machine_facts(&podman(&["machine", "inspect", machine])?, machine) +} + +/// The connection for a machine found by name, in the mode it runs in, or where its OpenBot +/// already is (see `connection_keeping_openbot`). +/// +/// A machine that cannot be inspected keeps its bare name, which is how it was addressed before +/// machines were rootful. +pub fn connection_of_machine(machine: &str) -> String { + let rootful = podman(&["machine", "inspect", machine]) + .and_then(|listing| machine_facts(&listing, machine)) + .ok() + .and_then(|facts| facts.rootful) + .unwrap_or(false); + connection_keeping_openbot(machine, rootful, holds_openbot) +} fn podman(args: &[&str]) -> Result { podman_with(args, || { @@ -148,6 +284,18 @@ pub fn machine_exists() -> Result { /// No `--provider`: `applehv` has been the default on Apple silicon since Podman 6.1, and pinning it /// asks for what you already get. The libkrun bind-mount trouble that the pin was written for /// belonged to 5.7, where libkrun was the default. +/// +/// `--rootful`, for local Intelligence: see the module comment. +/// +/// AN EXISTING MACHINE IS NEVER SWITCHED TO ROOTFUL, OURS INCLUDED. `podman machine set --rootful` +/// would do it, on a stopped machine, which is the person's to stop. A rootless machine keeps its +/// containers and volumes in the rootless engine's storage, and the rootful engine in the same VM +/// does not see them. Releases up to 0.2.0 created this machine rootless, so "ours" is not +/// "empty". A rootless machine is left as it is and OpenBot keeps running on it; local +/// Intelligence then declines to start there with a sentence that names the switch (see +/// `local_eval::Failure::RootlessPodman`). After the switch OpenBot stays in the rootless storage +/// its database is in (see `connection_keeping_openbot`), and Intelligence runs on the rootful +/// engine of the same machine. pub fn create_machine(cpus: u32, memory_mib: u32, disk_gib: u32) -> StepOutcome { create_machine_with(cpus, memory_mib, disk_gib, machine_exists, podman) } @@ -190,6 +338,7 @@ fn create_machine_args( memory_mib.to_string(), "--disk-size".to_string(), disk_gib.to_string(), + ROOTFUL_FLAG.to_string(), ]; if target_is_windows { args.push(USER_MODE_NETWORKING_FLAG.to_string()); @@ -201,14 +350,19 @@ fn create_machine_args( struct MachineNetworking { state: String, user_mode: bool, + /// Decides where the host-gateway setting is written. Absent reads as rootless, which is what + /// every machine was before this app created them rootful. + rootful: bool, } +/// Answers with the machine's networking on Windows, where the gateway is configured after start, +/// and with nothing elsewhere. fn prepare_user_mode_networking_before_start( run: &mut impl FnMut(&[&str]) -> Result, target_is_windows: bool, -) -> Result<(), String> { +) -> Result, String> { if !target_is_windows { - return Ok(()); + return Ok(None); } let listing = run(&["machine", "inspect", MACHINE])?; @@ -221,7 +375,7 @@ fn prepare_user_mode_networking_before_start( if networking.state.eq_ignore_ascii_case("stopped") && !networking.user_mode { run(&["machine", "set", USER_MODE_NETWORKING_FLAG, MACHINE])?; } - Ok(()) + Ok(Some(networking)) } fn owned_machine_networking(listing: &str) -> Result { @@ -242,12 +396,21 @@ fn owned_machine_networking(listing: &str) -> Result .ok_or_else(|| { format!("podman machine inspect did not include {MACHINE} user-mode networking") })?; - Ok(MachineNetworking { state, user_mode }) + let rootful = machine + .get("Rootful") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(MachineNetworking { + state, + user_mode, + rootful, + }) } fn configure_host_gateway_after_start( run: &mut impl FnMut(&[&str]) -> Result, target_is_windows: bool, + rootful: bool, ) -> Result<(), String> { if !target_is_windows { return Ok(()); @@ -262,7 +425,11 @@ fn configure_host_gateway_after_start( "host.containers.internal", ])?; let ip = first_valid_host_gateway_ip(&resolved)?; - let script = host_gateway_config_script(ip); + let script = if rootful { + rootful_host_gateway_config_script(ip) + } else { + host_gateway_config_script(ip) + }; run(&["machine", "ssh", MACHINE, &script]).map(|_| ()) } @@ -281,7 +448,7 @@ fn configure_owned_windows_podman_for_compose( "{MACHINE} is running without Podman user-mode networking. Stop the OpenBot engine machine and start OpenBot again so host callbacks can be configured." )); } - configure_host_gateway_after_start(&mut run, true) + configure_host_gateway_after_start(&mut run, true, networking.rootful) } /// Run before Compose even when an existing engine skipped the install/start steps. @@ -292,7 +459,10 @@ pub fn prepare_for_compose(address: &Address) -> Result<(), String> { fn owned_windows_podman_address(address: &Address, target_is_windows: bool) -> bool { target_is_windows && address.engine == Engine::Podman - && address.connection.as_deref() == Some(MACHINE) + && address + .connection + .as_deref() + .is_some_and(|connection| machine_of(connection) == MACHINE) } fn first_valid_host_gateway_ip(raw: &str) -> Result { @@ -331,13 +501,36 @@ fn host_gateway_config_script(ip: Ipv4Addr) -> String { ) } +/// The same setting for a rootful machine: in the system's configuration, and the system's API +/// service restarted. +/// +/// Through `sudo`, which is a no-op for root and passwordless for the machine's own user, so the +/// script does not depend on which of the two `podman machine ssh` logs in as. +fn rootful_host_gateway_config_script(ip: Ipv4Addr) -> String { + format!( + "set -e; \ + target=\"{ROOTFUL_HOST_GATEWAY_CONFIG}\"; \ + tmp=\"$(mktemp)\"; \ + cat > \"$tmp\" <<'EOF'\n[containers]\nhost_containers_internal_ip=\"{ip}\"\nEOF\n\ + if sudo test -f \"$target\" && sudo cmp -s \"$tmp\" \"$target\"; then \ + rm \"$tmp\"; \ + else \ + sudo mkdir -p \"$(dirname \"$target\")\"; \ + sudo install -m 0644 \"$tmp\" \"$target\"; \ + rm \"$tmp\"; \ + sudo systemctl try-restart podman.service; \ + fi" + ) +} + fn start_machine_with( mut run: impl FnMut(&[&str]) -> Result, target_is_windows: bool, ) -> StepOutcome { - if let Err(error) = prepare_user_mode_networking_before_start(&mut run, target_is_windows) { - return StepOutcome::stopped(Step::StartMachine, &error); - } + let rootful = match prepare_user_mode_networking_before_start(&mut run, target_is_windows) { + Ok(networking) => networking.is_some_and(|networking| networking.rootful), + Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), + }; let started = match run(&["machine", "start", MACHINE]) { Ok(_) => format!("{MACHINE} started."), Err(error) if error.contains("already running") => { @@ -345,7 +538,7 @@ fn start_machine_with( } Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), }; - if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows) { + if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows, rootful) { return StepOutcome::stopped(Step::StartMachine, &error); } StepOutcome::went(Step::StartMachine, started) @@ -380,9 +573,28 @@ fn explain_machine_error(error: &str) -> String { /// How to reach the machine this app just started. /// /// Always by name. The default connection belongs to whoever set it, and after `machine init` it is -/// usually still pointing somewhere else. +/// usually still pointing somewhere else. The rootful connection for the rootful machine this app +/// creates; the bare name for one an earlier release created rootless (see `create_machine`), and +/// for one switched to rootful since whose database is still in the rootless storage. pub fn address() -> Address { - Address::new(Engine::Podman, Some(MACHINE.to_string())) + address_with(|| podman(&["machine", "inspect", MACHINE]), holds_openbot) +} + +fn address_with( + inspect: impl FnOnce() -> Result, + holds_openbot: impl Fn(&str) -> bool, +) -> Address { + // Unknown reads as rootful: that is how this app creates the machine, and a machine that cannot + // be inspected is not going to answer on either connection. + let rootful = inspect() + .and_then(|listing| machine_facts(&listing, MACHINE)) + .ok() + .and_then(|facts| facts.rootful) + .unwrap_or(true); + Address::new( + Engine::Podman, + Some(connection_keeping_openbot(MACHINE, rootful, holds_openbot)), + ) } /// The gate before Compose is touched. @@ -679,11 +891,129 @@ mod tests { "8192", "--disk-size", "64", + "--rootful", "--user-mode-networking=true" ] ); } + /// Local Intelligence's k3d needs a rootful engine, on macOS as on Windows. + #[test] + fn the_machine_is_created_rootful_on_every_platform() { + for windows in [false, true] { + let args = create_machine_args(4, 6144, 60, windows); + assert!(args.iter().any(|arg| arg == "--rootful"), "{args:?}"); + assert_eq!(&args[..3], ["machine", "init", "openbot"]); + } + assert_eq!( + create_machine_args(4, 6144, 60, false), + [ + "machine", + "init", + "openbot", + "--cpus", + "4", + "--memory", + "6144", + "--disk-size", + "60", + "--rootful" + ] + ); + } + + /// Nothing switches a machine that already exists, ours included: its OpenBot lives in the + /// rootless engine's storage. Creating is skipped, and nothing else is run. + #[test] + fn an_existing_rootless_machine_is_not_switched_to_rootful() { + let listing = r#"[{"Name":"openbot","Default":true,"Running":false}]"#; + let result = create_machine_with( + 4, + 6144, + 60, + || machine_exists_with(|| Ok(listing.into())), + |args| panic!("nothing runs against an existing machine: {args:?}"), + ); + assert!(result.ok, "{result:?}"); + + let mut calls = Vec::>::new(); + let inspect = serde_json::json!([{ + "Name": "openbot", + "State": "stopped", + "UserModeNetworking": true, + "Rootful": false + }]) + .to_string(); + let started = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", ..] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", _] => Ok(String::new()), + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + assert!(started.ok, "{started:?}"); + assert!( + !calls + .iter() + .any(|call| call.iter().any(|arg| arg == "--rootful")), + "{calls:?}" + ); + // Its gateway goes where its rootless engine reads it. + assert!(calls + .last() + .unwrap() + .last() + .unwrap() + .contains(HOST_GATEWAY_CONFIG)); + } + + #[test] + fn a_rootful_windows_machine_gets_the_gateway_in_the_system_configuration() { + let mut calls = Vec::>::new(); + let inspect = serde_json::json!([{ + "Name": "openbot", + "State": "stopped", + "UserModeNetworking": true, + "Rootful": true + }]) + .to_string(); + let started = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", ..] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", _] => Ok(String::new()), + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + assert!(started.ok, "{started:?}"); + let script = calls.last().unwrap().last().unwrap().clone(); + assert_eq!( + script, + rootful_host_gateway_config_script("192.168.127.254".parse().unwrap()) + ); + assert!(script.contains(ROOTFUL_HOST_GATEWAY_CONFIG), "{script}"); + assert!( + script.contains("sudo systemctl try-restart podman.service"), + "{script}" + ); + assert!(!script.contains("--user"), "{script}"); + } + #[test] fn existing_stopped_windows_machine_is_configured_before_start() { let mut calls = Vec::>::new(); @@ -974,9 +1304,124 @@ mod tests { #[test] fn the_machine_this_app_starts_is_addressed_by_name_not_by_the_default_connection() { - let addressed = address(); - assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + let inspect = |rootful: bool| { + serde_json::json!([{ "Name": "openbot", "Rootful": rootful }]).to_string() + }; + let nowhere = |_: &str| false; + // Rootful, as this app creates it: its root connection, where the rootful engine is. + let addressed = address_with(|| Ok(inspect(true)), nowhere); + assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); assert_eq!(addressed.engine, Engine::Podman); + // Rootless, as releases up to 0.2.0 created it: its bare name, where its OpenBot is. + let addressed = address_with(|| Ok(inspect(false)), nowhere); + assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + // Not inspectable: as this app creates it. + let addressed = address_with(|| Err("no such machine".into()), nowhere); + assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); + // Switched to rootful since, with its database still in the rootless storage: kept there. + let addressed = address_with(|| Ok(inspect(true)), |connection| connection == MACHINE); + assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + } + + /// An OpenBot is never moved away from its database by the machine's mode. + #[test] + fn a_rootful_machine_keeps_an_openbot_whose_database_is_in_rootless_storage() { + let in_rootless: fn(&str) -> bool = |connection| connection == "podman-machine-default"; + let in_rootful: fn(&str) -> bool = |connection| connection == "podman-machine-default-root"; + let in_both: fn(&str) -> bool = |_| true; + let in_neither: fn(&str) -> bool = |_| false; + let machine = "podman-machine-default"; + assert_eq!( + connection_keeping_openbot(machine, true, in_rootless), + "podman-machine-default" + ); + // Wherever else it is, the machine's own mode wins. + for holds in [in_rootful, in_both, in_neither] { + assert_eq!( + connection_keeping_openbot(machine, true, holds), + "podman-machine-default-root" + ); + } + // A rootless machine was always reached by its bare name, and nothing is asked. + assert_eq!( + connection_keeping_openbot(machine, false, |_: &str| panic!("not asked")), + "podman-machine-default" + ); + } + + #[test] + fn a_connection_names_its_machine_and_its_mode() { + assert_eq!(connection_for("openbot", true), "openbot-root"); + assert_eq!(connection_for("openbot", false), "openbot"); + assert_eq!(machine_of("openbot-root"), "openbot"); + assert_eq!(machine_of("openbot"), "openbot"); + assert_eq!( + machine_of("podman-machine-default-root"), + "podman-machine-default" + ); + } + + #[test] + fn machine_facts_give_the_mode_and_the_api_this_computer_can_open() { + let mac = serde_json::json!([{ + "Name": "openbot", + "Rootful": true, + "ConnectionInfo": { + "PodmanSocket": { "Path": "/var/folders/xy/T/podman/openbot-api.sock" }, + "PodmanPipe": null + } + }]) + .to_string(); + assert_eq!( + machine_facts(&mac, "openbot").unwrap(), + MachineFacts { + rootful: Some(true), + api: Some("unix:///var/folders/xy/T/podman/openbot-api.sock".into()), + } + ); + let windows = serde_json::json!([{ + "Name": "openbot", + "Rootful": false, + "ConnectionInfo": { + "PodmanSocket": { "Path": "C:\\Users\\me\\podman.sock" }, + "PodmanPipe": { "Path": "\\\\.\\pipe\\podman-openbot" } + } + }]) + .to_string(); + assert_eq!( + machine_facts(&windows, "openbot").unwrap(), + MachineFacts { + rootful: Some(false), + api: Some("npipe:////./pipe/podman-openbot".into()), + } + ); + let bare = serde_json::json!([{ "Name": "openbot" }]).to_string(); + assert_eq!( + machine_facts(&bare, "openbot").unwrap(), + MachineFacts::default() + ); + assert!(machine_facts(&bare, "somebody-else").is_err()); + assert!(machine_facts("not json", "openbot").is_err()); + } + + #[test] + fn the_root_connection_of_our_windows_machine_is_still_ours() { + assert!(owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot-root".into())), + true + )); + assert!(owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot".into())), + true + )); + assert!(!owned_windows_podman_address( + &Address::new(Engine::Podman, Some("somebody-else-root".into())), + true + )); + assert!(!owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot-root".into())), + false + )); } #[test] diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index d94810a6e..4e985b16c 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -4,8 +4,9 @@ //! documentation, and the three differ more than they look: //! //! - **macOS.** `podman machine` puts a Linux VM behind a socket. Inside that VM -//! `/var/run/docker.sock` is already a symlink to the rootless socket, so Compose's mount needs no -//! help and `ENGINE_SOCKET` stays unset. `applehv` is the default on Apple silicon as of Podman +//! `/var/run/docker.sock` is already a symlink to the socket of the mode the machine runs in +//! (rootful for the machine OpenBot creates, see `acquire`), so Compose's mount needs no help and +//! `ENGINE_SOCKET` stays unset. `applehv` is the default on Apple silicon as of Podman //! 6.1, so no `--provider` is passed. //! - **Linux.** Podman is native and rootless and there is no VM. `/var/run/docker.sock` is either //! absent or, with `podman-docker` installed, a symlink to the *rootful* socket, which is not the @@ -279,6 +280,9 @@ pub struct EngineStatus { /// Somebody who already has a machine up is handed it rather than made to wait while a duplicate /// boots beside it. Ours is preferred among running machines only so that repeat launches settle on /// the same one. +/// +/// Answers with the connection for the machine's own mode (see `acquire::connection_for`): a rootful +/// machine is reached through `-root`, where its rootful engine is. fn running_machine(preferred: &str) -> Option { let output = tool(Engine::Podman) .args(["machine", "list", "--format", "json"]) @@ -292,7 +296,7 @@ fn running_machine(preferred: &str) -> Option { running() .find(|machine| machine.name == preferred) .or_else(|| running().next()) - .map(|machine| machine.name.clone()) + .map(|machine| crate::acquire::connection_of_machine(&machine.name)) } #[derive(Deserialize)] @@ -381,17 +385,33 @@ does not reach Docker Desktop's `/usr/local/bin`. Appended, so a `docker` the pe their PATH stays first; both the link and the directory it resolves to, as `command_at` does. */ pub fn path_with_docker() -> Option { - let binary = program(Engine::Docker)?; + path_with_engines(&[Engine::Docker]) +} + +/// `path_with_docker`, for each of `engines` that is here, or None when none is. +/// +/// The Intelligence CLI runs `podman` by name as it runs `docker`, and a Podman OpenBot installed +/// in this session is not on this process's PATH (see the PATH rule above). +pub fn path_with_engines(engines: &[Engine]) -> Option { + let binaries: Vec = engines + .iter() + .filter_map(|engine| program(*engine)) + .collect(); + if binaries.is_empty() { + return None; + } let mut entries: Vec = std::env::var_os("PATH") .map(|path| std::env::split_paths(&path).collect()) .unwrap_or_default(); - let resolved = std::fs::canonicalize(&binary).ok(); - for directory in [binary.parent(), resolved.as_deref().and_then(Path::parent)] - .into_iter() - .flatten() - { - if !entries.iter().any(|entry| entry == directory) { - entries.push(directory.to_path_buf()); + for binary in &binaries { + let resolved = std::fs::canonicalize(binary).ok(); + for directory in [binary.parent(), resolved.as_deref().and_then(Path::parent)] + .into_iter() + .flatten() + { + if !entries.iter().any(|entry| entry == directory) { + entries.push(directory.to_path_buf()); + } } } std::env::join_paths(entries).ok() @@ -529,7 +549,8 @@ pub fn rootless_socket() -> Option { #[cfg(not(target_os = "linux"))] pub fn rootless_socket() -> Option { // macOS and Windows run the engine in a virtual machine, and inside it `/var/run/docker.sock` - // is already the rootless socket. Compose mounts that path, so there is nothing to override. + // is already the socket of the machine's own mode. Compose mounts that path, so there is + // nothing to override. None } diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 1ad052112..596789fec 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -397,17 +397,34 @@ pub fn install_engine_observed( installed: impl FnMut(Engine, bool), progress: &mut dyn FnMut(String), ) -> Result { + // Only one of the two runs, so they share the step row's lines one at a time. + let progress = std::cell::RefCell::new(progress); install_engine_with( cache, Found::here(saved), saved, installed, - || crate::docker_desktop::start(progress), - install_podman, + || crate::docker_desktop::start(&mut **progress.borrow_mut()), + |into| { + (progress.borrow_mut())(podman_install_said(std::env::consts::OS).into()); + install_podman(into) + }, place_compose, ) } +/// The step row's line while Podman installs, naming the prompt the platform raises for it. +/// +/// macOS asks for the person's password, because the package writes to `/opt/podman`. Windows +/// asks for nothing here: the MSI installs for this account alone. Linux asks through `pkexec`. +fn podman_install_said(os: &str) -> &'static str { + match os { + "macos" => "Installing Podman. Enter your Mac password when macOS asks for it.", + "linux" => "Installing Podman. Enter your password when your system asks for it.", + _ => "Installing Podman.", + } +} + /// 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 @@ -1719,4 +1736,12 @@ esac assert!(quoted.contains("Application Support"), "{quoted}"); assert!(!quoted.contains("\\\""), "{quoted}"); } + + /// The prompt is named where there is one, and none is promised where there is not. + #[test] + fn the_podman_install_line_names_the_prompt_each_platform_raises() { + assert!(podman_install_said("macos").contains("password")); + assert!(podman_install_said("linux").contains("password")); + assert_eq!(podman_install_said("windows"), "Installing Podman."); + } } diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index a5ba1337a..cee298daf 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -108,6 +108,11 @@ pub enum Failure { UnexpectedExit, /// An accepted agreement version that is not one `terms` could have reported. InvalidAcceptance, + /// OpenBot runs on a rootless Podman machine, and local Intelligence needs rootful Podman. + /// Refused here, before `setup` or `start`, so the window can name the switch; the CLI would + /// refuse it too, as an engine it does not support. See `acquire::create_machine` for why the + /// switch is the person's to make. + RootlessPodman, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] @@ -1178,6 +1183,174 @@ const INHERITED: &[&str] = &[ "TMP", ]; +/* + * ONE ENGINE FOR OPENBOT AND INTELLIGENCE. + * + * Left to itself the CLI picks Docker when Docker answers and Podman otherwise. That is not always + * the engine OpenBot is running on: Docker Desktop started after OpenBot settled on Podman, or a + * Podman machine of the person's own that is running beside ours. Two engines on one computer is + * two VMs' worth of memory, and an Intelligence that OpenBot's containers reach through a host + * port of a different VM. So every CLI process is told the engine OpenBot uses, by the variable + * the CLI reads for exactly this (`COPILOTKIT_LOCAL_ENGINE`), and for Podman the API of the + * machine OpenBot uses: the CLI would otherwise take the first running machine, and it looks + * `podman` up on a PATH that, in the session OpenBot installed Podman, does not have it. + * + * Recorded by the app when it knows its engine (`use_engine`), and read for every call, because + * the machine's mode can change between calls and asking costs one `podman machine inspect`. + */ +static ENGINE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Run the CLI on the engine OpenBot runs on. +pub fn use_engine(address: &crate::engine::Address) { + *ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(address.clone()); +} + +/// Whether the app has said which engine OpenBot runs on. +pub fn engine_known() -> bool { + ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some() +} + +/// What the CLI is told about its engine. Addresses and a name; nothing here is a secret. +#[derive(Clone, Debug, PartialEq, Eq)] +struct CliEngine { + engine: crate::engine::Engine, + /// The Podman API OpenBot's machine forwards to this computer, when it could be found. + api: Option, + /// A Podman machine running rootless, which local Intelligence does not run on. + rootless: bool, +} + +impl CliEngine { + fn of(address: &crate::engine::Address) -> Self { + Self::of_with(address, crate::acquire::machine_behind) + } + + /// The machine's mode and API, not the connection's. + /// + /// A rootful machine whose OpenBot is still in its rootless storage keeps OpenBot on the + /// machine's bare connection (see `acquire::connection_keeping_openbot`). Intelligence then + /// runs on that same machine's rootful engine, the one its forwarded API reaches: one VM, so + /// none of the cost of two engines above, and the only engine there Intelligence runs on. + /// Only a rootless machine has no rootful engine to offer, and is declined. + fn of_with( + address: &crate::engine::Address, + machine_behind: impl FnOnce(&str) -> Result, + ) -> Self { + use crate::engine::Engine; + let named_only = || Self { + engine: address.engine, + api: None, + rootless: false, + }; + // Linux runs OpenBot's containers without local Intelligence for now, and its Podman has + // no machine: the CLI finds the rootful socket itself, or declines a rootless one. + if address.engine == Engine::Docker || cfg!(target_os = "linux") { + return named_only(); + } + let connection = address + .connection + .clone() + .or_else(|| address.pin().ok().and_then(|pinned| pinned.connection)); + match connection.map(|connection| machine_behind(&connection)) { + Some(Ok(facts)) => Self { + engine: Engine::Podman, + api: facts.api, + rootless: facts.rootful == Some(false), + }, + // Not a machine this computer can inspect: the CLI finds its own way, and judges it. + _ => named_only(), + } + } +} + +fn recorded_engine() -> Option { + let address = ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone()?; + Some(CliEngine::of(&address)) +} + +/// The variables that name the engine, and those the CLI must not inherit beside them. +/// +/// For Podman, `CONTAINER_HOST` is what the CLI and the Podman CLI read and `DOCKER_HOST` is what +/// k3d reads, so both name the machine's API. An inherited `DOCKER_HOST` or `DOCKER_CONTEXT` names +/// a Docker, and is dropped rather than handed to k3d beside a Podman. For Docker nothing else is +/// set: the CLI finds Docker as Docker's own CLI does, through what it inherits. +fn engine_environment(engine: Option<&CliEngine>) -> Vec<(&'static str, Option)> { + use crate::engine::Engine; + let Some(engine) = engine else { + return Vec::new(); + }; + let mut environment = vec![( + "COPILOTKIT_LOCAL_ENGINE", + Some(engine.engine.binary().to_string()), + )]; + if engine.engine == Engine::Podman { + environment.push(("CONTAINER_HOST", engine.api.clone())); + environment.push(("DOCKER_HOST", engine.api.clone())); + environment.push(("DOCKER_CONTEXT", None)); + } + environment +} + +/// Decline the calls that create or start Intelligence's cluster on a rootless Podman machine. +/// Everything else runs: `status`, `stop` and the account calls work on any engine. +fn refuse_rootless(arguments: &[&str], engine: Option<&CliEngine>) -> Result<(), Failure> { + let runs_workloads = matches!(arguments, ["local", "setup", ..] | ["local", "start", ..]); + if runs_workloads && engine.is_some_and(|engine| engine.rootless) { + return Err(Failure::RootlessPodman); + } + Ok(()) +} + +/// The CLI process for one call: its program, its environment and its folder, not yet started. +fn command_for( + cli: &PinnedCli, + arguments: &[&str], + folder: Option<&std::path::Path>, + engine: Option<&CliEngine>, +) -> std::process::Command { + let mut command = crate::quiet::command(&cli.program); + if let Some(entry) = &cli.entry { + command.arg(entry); + } + command.env_clear(); + for name in INHERITED { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + // The CLI looks `docker` and `podman` up by name. Opened from Finder, this app has the system's + // minimal PATH, which reaches neither Docker Desktop's `/usr/local/bin` nor `/opt/podman/bin`; + // the CLI gets the engine's own directory added. + let engines: &[crate::engine::Engine] = match engine.map(|engine| engine.engine) { + Some(crate::engine::Engine::Podman) => { + &[crate::engine::Engine::Docker, crate::engine::Engine::Podman] + } + _ => &[crate::engine::Engine::Docker], + }; + if let Some(path) = crate::engine::path_with_engines(engines) { + command.env("PATH", path); + } + for (name, value) in engine_environment(engine) { + match value { + Some(value) => command.env(name, value), + None => command.env_remove(name), + }; + } + if let Some(folder) = folder { + command.current_dir(folder); + } + command.args(arguments); + command +} + /** Run one CLI call to completion, within its limits. @@ -1271,28 +1444,11 @@ fn execute( limits: Limits, cancel: &Cancel, ) -> Result<(i32, Vec), Failure> { + let engine = recorded_engine(); + refuse_rootless(arguments, engine.as_ref())?; let output = Output::create()?; - let mut command = crate::quiet::command(&cli.program); - if let Some(entry) = &cli.entry { - command.arg(entry); - } - command.env_clear(); - for name in INHERITED { - if let Some(value) = std::env::var_os(name) { - command.env(name, value); - } - } - // The CLI looks `docker` up by name. Opened from Finder, this app has the system's minimal - // PATH, which does not reach Docker Desktop's `/usr/local/bin`; the CLI gets Docker's own - // directory added. - if let Some(path) = crate::engine::path_with_docker() { - command.env("PATH", path); - } - if let Some(folder) = folder { - command.current_dir(folder); - } + let mut command = command_for(cli, arguments, folder, engine.as_ref()); command - .args(arguments) .stdin(Stdio::null()) .stdout(output.file.try_clone().map_err(|_| Failure::Spawn)?) // stderr can carry anything a dependency prints. It is never read, so it is not kept. @@ -3043,6 +3199,204 @@ mod tests { assert!(status(&input).is_ok()); } + fn engine_variables( + engine: Option<&CliEngine>, + ) -> std::collections::BTreeMap> { + let cli = PinnedCli { + program: PathBuf::from("copilotkit"), + entry: None, + sha256: String::new(), + }; + command_for(&cli, &["local", "status", "--json"], None, engine) + .get_envs() + .map(|(name, value)| { + ( + name.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + const ENGINE_VARIABLES: [&str; 4] = [ + "COPILOTKIT_LOCAL_ENGINE", + "CONTAINER_HOST", + "DOCKER_HOST", + "DOCKER_CONTEXT", + ]; + + #[test] + fn the_cli_is_told_the_engine_openbot_runs_on() { + use crate::engine::Engine; + let docker = CliEngine { + engine: Engine::Docker, + api: None, + rootless: false, + }; + let variables = engine_variables(Some(&docker)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("docker".to_string())) + ); + // Docker is found as Docker's own CLI finds it: nothing is set or taken away for it. + for name in ["CONTAINER_HOST", "DOCKER_HOST", "DOCKER_CONTEXT"] { + assert!(!variables.contains_key(name), "{name} in {variables:?}"); + } + + let api = "unix:///var/folders/xy/T/podman/openbot-api.sock"; + let podman = CliEngine { + engine: Engine::Podman, + api: Some(api.into()), + rootless: false, + }; + let variables = engine_variables(Some(&podman)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("podman".to_string())) + ); + assert_eq!( + variables.get("CONTAINER_HOST"), + Some(&Some(api.to_string())) + ); + assert_eq!(variables.get("DOCKER_HOST"), Some(&Some(api.to_string()))); + // An inherited Docker context names a Docker, and is not handed to k3d beside a Podman. + assert!( + engine_environment(Some(&podman)).contains(&("DOCKER_CONTEXT", None)), + "a Podman call must drop DOCKER_CONTEXT" + ); + assert!(!matches!(variables.get("DOCKER_CONTEXT"), Some(Some(_)))); + + // A Podman whose API could not be found is still named, and inherits no Docker address. + let unfound = CliEngine { + api: None, + ..podman + }; + let variables = engine_variables(Some(&unfound)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("podman".to_string())) + ); + assert_eq!( + engine_environment(Some(&unfound))[1..], + [ + ("CONTAINER_HOST", None), + ("DOCKER_HOST", None), + ("DOCKER_CONTEXT", None) + ] + ); + for name in ["CONTAINER_HOST", "DOCKER_HOST", "DOCKER_CONTEXT"] { + assert!( + !matches!(variables.get(name), Some(Some(_))), + "{name} in {variables:?}" + ); + } + + // Before the app knows its engine the CLI chooses for itself, as it did before. + let variables = engine_variables(None); + for name in ENGINE_VARIABLES { + assert!(!variables.contains_key(name), "{name} in {variables:?}"); + } + } + + /// The engine adds names and addresses, never a secret: what the CLI receives is the inherited + /// allowlist and the engine's own variables, and nothing else. + #[test] + fn naming_the_engine_adds_nothing_secret() { + use crate::engine::Engine; + for engine in [ + None, + Some(CliEngine { + engine: Engine::Docker, + api: None, + rootless: false, + }), + Some(CliEngine { + engine: Engine::Podman, + api: Some("npipe:////./pipe/podman-openbot".into()), + rootless: false, + }), + ] { + for name in engine_variables(engine.as_ref()).keys() { + assert!( + INHERITED.contains(&name.as_str()) || ENGINE_VARIABLES.contains(&name.as_str()), + "{name} is neither inherited nor the engine's" + ); + } + } + } + + /// OpenBot on the bare connection of a rootful machine: Intelligence goes to the same machine's + /// rootful engine. OpenBot on a rootless machine, by either connection: declined. + #[cfg(not(target_os = "linux"))] + #[test] + fn intelligence_runs_on_the_rootful_engine_of_the_machine_openbot_is_on() { + use crate::acquire::MachineFacts; + use crate::engine::{Address, Engine}; + let api = "unix:///tmp/podman/openbot-api.sock"; + let machine = |rootful| { + move |name: &str| { + assert_eq!(name.strip_suffix("-root").unwrap_or(name), "openbot"); + Ok(MachineFacts { + rootful: Some(rootful), + api: Some(api.into()), + }) + } + }; + for connection in ["openbot", "openbot-root"] { + let address = Address::new(Engine::Podman, Some(connection.into())); + assert_eq!( + CliEngine::of_with(&address, machine(true)), + CliEngine { + engine: Engine::Podman, + api: Some(api.into()), + rootless: false, + }, + "{connection}" + ); + assert!( + CliEngine::of_with(&address, machine(false)).rootless, + "{connection}" + ); + } + } + + #[test] + fn a_rootless_podman_declines_setup_and_start_and_nothing_else() { + use crate::engine::Engine; + let rootless = CliEngine { + engine: Engine::Podman, + api: Some("unix:///tmp/podman.sock".into()), + rootless: true, + }; + let rootful = CliEngine { + rootless: false, + ..rootless.clone() + }; + let stack = StackId::parse("local-abc123").unwrap(); + for arguments in [ + SETUP_ARGUMENTS.to_vec(), + arguments(Action::Start, &stack).to_vec(), + ] { + assert_eq!( + refuse_rootless(&arguments, Some(&rootless)), + Err(Failure::RootlessPodman) + ); + assert_eq!(refuse_rootless(&arguments, Some(&rootful)), Ok(())); + assert_eq!(refuse_rootless(&arguments, None), Ok(())); + } + for arguments in [ + arguments(Action::Status, &stack).to_vec(), + arguments(Action::Stop, &stack).to_vec(), + LIST_ARGUMENTS.to_vec(), + CONNECT_ARGUMENTS.to_vec(), + login_arguments(&stack).to_vec(), + ACCOUNT_SIGN_IN_ARGUMENTS.to_vec(), + TERMS_ARGUMENTS.to_vec(), + ] { + assert_eq!(refuse_rootless(&arguments, Some(&rootless)), Ok(())); + } + } + #[test] fn every_failure_category_is_known_to_the_window() { let window = include_str!("../../src/localEvalCli.ts"); @@ -3063,6 +3417,7 @@ mod tests { Failure::ContractMismatch, Failure::UnexpectedExit, Failure::InvalidAcceptance, + Failure::RootlessPodman, ]; // Adding a variant without listing it above stops compiling here. for failure in all { @@ -3082,7 +3437,8 @@ mod tests { | Failure::UnsafeEndpoint | Failure::ContractMismatch | Failure::UnexpectedExit - | Failure::InvalidAcceptance => {} + | Failure::InvalidAcceptance + | Failure::RootlessPodman => {} } let name = serde_json::to_string(&failure).unwrap(); assert!(window.contains(&name), "localEvalCli.ts is missing {name}"); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 274de76c0..08c907478 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -799,6 +799,7 @@ async fn engine_ready( ) })??; } + local_eval::use_engine(&ready.address); report(app, "engine", true, ready.detail); return Ok(ready.address); } @@ -843,7 +844,7 @@ async fn engine_ready( }, ); }, - // Starting Docker Desktop can take minutes; each line says where it is. + // Installing Podman or starting Docker Desktop can take minutes; each line says where it is. &mut |said| report_running(&progress_app, "install-engine", said, None), ) }) @@ -870,6 +871,7 @@ async fn engine_ready( let after = engine::detect_saved(Some(chosen)); if let Some(address) = answering_after_install(&after, engine::Address::composes)? { desktop_telemetry::observe_engine(app, &after); + local_eval::use_engine(&address); report(app, "engine", true, after.detail); return Ok(address); } @@ -877,7 +879,19 @@ async fn engine_ready( // One at a time, and each only if the last one worked. Written as a loop over an array once, // which ran all three before the first was checked: a failed `machine init` was still followed // by `machine start`. - report_running(app, "create-machine", "Preparing the engine machine.", None); + report_running( + app, + "create-machine", + if cfg!(target_os = "windows") { + // Podman's own installer for WSL, when WSL is missing: its dialog, then Windows' own + // administrator prompt, then a restart. Said before it happens rather than after. + "Preparing the engine machine. If Windows Subsystem for Linux is missing, Podman \ + offers to install it. Windows then asks for administrator approval and a restart." + } else { + "Preparing the engine machine." + }, + None, + ); let created = acquire::create_machine(4, 6144, 60); report(app, "create-machine", created.ok, created.said.clone()); if !created.ok { @@ -915,6 +929,7 @@ async fn engine_ready( ready.detail, ) })?; + local_eval::use_engine(&address); report(app, "engine", true, "The container engine is answering."); Ok(address) } @@ -1763,6 +1778,8 @@ async fn start_stack_inner( return Err(status.detail.into()); }; let found = found.pin()?; + // Intelligence runs on the engine OpenBot is about to run on. See `local_eval::use_engine`. + local_eval::use_engine(&found); #[cfg(target_os = "linux")] ensure_linux_podman_api(&shell, &found)?; #[cfg(target_os = "linux")] @@ -4791,7 +4808,13 @@ async fn run_local_eval( stack: String, ) -> Result { let stack = local_eval::StackId::parse(&stack)?; + let root = { + let shell = app.state::(); + let selected = shell.selected_root.lock().unwrap().clone(); + selected.unwrap_or_else(stack::default_root) + }; let report = tauri::async_runtime::spawn_blocking(move || { + know_cli_engine(&root); local_eval::run_installed(&attempt, action, &stack) }) .await @@ -4800,6 +4823,24 @@ async fn run_local_eval( Ok(report) } +/** +The engine for the Intelligence CLI when nothing this run has named one yet. + +A relaunched OpenBot can ask Intelligence for its status before Start has pinned an engine. The +answer is the one Start would reach with no containers of its own: the engine `root` was set up on +if it saved one (see `preparation::saved_engine`), otherwise whatever `engine::detect` finds +answering. Blocking, so only from a blocking thread. +*/ +fn know_cli_engine(root: &Path) { + if local_eval::engine_known() { + return; + } + let found = engine::detect_saved(preparation::saved_engine(root)); + if let Some(address) = found.address.filter(|_| found.responding) { + local_eval::use_engine(&address); + } +} + /// Keep the dashboard address of every stack a CLI report described, for `Dashboards`. fn learn_dashboards<'a>( app: &tauri::AppHandle, @@ -4848,6 +4889,7 @@ async fn set_up_local_eval( // may not exist yet; the CLI runs in it. std::fs::create_dir_all(&root) .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))?; + know_cli_engine(&root); set_up_local_eval_at(&root, |folder| { local_eval::setup_installed(&attempt, folder, accepted.as_ref()) }) diff --git a/desktop/src/localEvalCli.ts b/desktop/src/localEvalCli.ts index c43895cd2..60623d904 100644 --- a/desktop/src/localEvalCli.ts +++ b/desktop/src/localEvalCli.ts @@ -29,6 +29,7 @@ export const localFailures = [ "contract-mismatch", "unexpected-exit", "invalid-acceptance", + "rootless-podman", ] as const; export type LocalFailure = (typeof localFailures)[number]; diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts index c1bdc15a3..5c465ae0b 100644 --- a/desktop/src/localReadiness.test.ts +++ b/desktop/src/localReadiness.test.ts @@ -84,7 +84,7 @@ test("every failure gives a plain, non-empty sentence without its own name", () test("the reason and failure lists in this test match the source", () => { expect(new Set(reasons).size).toBe(18); - expect(localFailures.length).toBe(16); + expect(localFailures.length).toBe(17); }); test("Docker reasons block the runtime when the probe has no answer", () => { @@ -95,7 +95,7 @@ test("Docker reasons block the runtime when the probe has no answer", () => { }); expect(rows.runtime).toEqual({ state: "blocked", - said: "Docker Desktop is not running.", + said: "The container engine is not running.", action: "retry", }); expect(rows.intelligence.state).toBe("blocked"); @@ -104,7 +104,9 @@ test("Docker reasons block the runtime when the probe has no answer", () => { runtime: "unknown", intelligence: report({ outcome: "blocked", reason: "unsupported-engine" }), }); - expect(engine.runtime.said).toBe("This Docker setup is not supported."); + expect(engine.runtime.said).toBe( + "This container engine setup is not supported.", + ); expect(engine.runtime.action).toBeUndefined(); }); @@ -122,7 +124,7 @@ test("runtime probe maps directly", () => { ); expect(localReadiness({ ...base, runtime: "not-running" }).runtime).toEqual({ state: "blocked", - said: "Docker Desktop is not running.", + said: "The container engine is not running.", action: "retry", }); expect(localReadiness({ ...base, runtime: "unknown" }).runtime.state).toBe( diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts index 62897c8bd..3f62cbdd5 100644 --- a/desktop/src/localReadiness.ts +++ b/desktop/src/localReadiness.ts @@ -33,7 +33,7 @@ export type ReadinessRow = { }; export type ReadinessInput = { - /** Docker Desktop, as probed by the window. */ + /** The container engine, Docker or Podman, as probed by the window. */ runtime: "running" | "not-running" | "unknown"; /** The latest status call: a report, a failure category, or nothing yet. */ intelligence: ChatGateInput["intelligence"]; @@ -151,6 +151,13 @@ const failureRows: Record = { "The evaluation agreement could not be accepted.", "retry", ), + // OpenBot runs on a Podman machine without root, and Intelligence needs root. Switching it is + // the person's call. OpenBot stays in the storage its database is in, and Intelligence runs on + // the machine's rootful engine. + "rootless-podman": blocked( + "Intelligence needs Podman in rootful mode. Stop your Podman machine, run podman machine set --rootful, then start it again.", + "retry", + ), }; function intelligenceRow( @@ -209,9 +216,9 @@ function runtimeRow( intelligence: ReadinessInput["intelligence"], ): ReadinessRow { if (runtime === "running") { - return { state: "ready", said: "Docker Desktop is running." }; + return { state: "ready", said: "The container engine is running." }; } - const notRunning = blocked("Docker Desktop is not running.", "retry"); + const notRunning = blocked("The container engine is not running.", "retry"); if (runtime === "not-running") return notRunning; const reason = intelligence !== null && typeof intelligence !== "string" @@ -219,9 +226,12 @@ function runtimeRow( : null; if (reason === "docker-unavailable") return notRunning; if (reason === "unsupported-engine") { - return blocked("This Docker setup is not supported."); + return blocked("This container engine setup is not supported."); } - return { state: "unknown", said: "Docker Desktop has not been checked." }; + return { + state: "unknown", + said: "The container engine has not been checked.", + }; } export function localReadiness(input: ReadinessInput): Readiness {