diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 76e0fb8c4..fec0668df 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -1676,6 +1676,11 @@ Get logs from a running network container ###### **Options:** - `-d`, `--docker-host ` — Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) ## `stellar container start` @@ -1698,6 +1703,12 @@ By default, when starting a testnet container, without any optional arguments, i ###### **Options:** - `-d`, `--docker-host ` — Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) + - `--name ` — Optional argument to specify the container name - `-l`, `--limits ` — Optional argument to specify the limits for the local network only - `-p`, `--ports-mapping ` — Argument to specify the `HOST_PORT:CONTAINER_PORT` mapping @@ -1722,6 +1733,11 @@ Stop a network container started with `stellar container start` ###### **Options:** - `-d`, `--docker-host ` — Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock +- `--engine ` — Container engine to use [default: docker] + + Possible values: + - `docker`: Docker, or any Docker-compatible CLI + - `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon) ## `stellar config` diff --git a/cmd/crates/soroban-test/tests/it/container.rs b/cmd/crates/soroban-test/tests/it/container.rs new file mode 100644 index 000000000..9949df569 --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/container.rs @@ -0,0 +1,281 @@ +//! End-to-end tests for `stellar container start|stop|logs`. +//! +//! These never touch a real container runtime. Instead they drop a fake `docker` +//! / `container` executable into a temp dir, point `PATH` at it, and assert on +//! both the CLI's behavior and the exact argv the fake was invoked with. This +//! exercises the real spawn path and stderr classification that the unit tests in +//! `shared.rs` can't reach. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +use assert_cmd::Command; +use predicates::prelude::predicate; +use soroban_test::TestEnv; + +/// A sandbox with an isolated `PATH` that only contains the fake engines we +/// explicitly install, so the real `docker`/`container` on the host is never hit. +struct EngineSandbox { + env: TestEnv, + bin_dir: PathBuf, + log: PathBuf, +} + +impl EngineSandbox { + fn new() -> Self { + let env = TestEnv::default(); + let bin_dir = env.dir().join("fake-bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let log = env.dir().join("engine-invocations.log"); + Self { env, bin_dir, log } + } + + /// Installs a fake engine binary named `name` (e.g. `docker` or `container`). + /// Each invocation appends its argv to the shared log; behavior is driven by + /// the `FAKE_ENGINE_MODE` env var (`ok`, `already_running`, `not_found`). The + /// stderr wording matches the real engine the binary is impersonating so the + /// CLI's per-engine classifiers are exercised end-to-end. + fn install_engine(&self, name: &str) { + // Real-world stderr strings the CLI classifies, per engine. + let (already_running, not_found) = if name == "container" { + ( + "Error: container with id stellar-local already exists", + r#"Error: internalError: "failed to stop container" (cause: "notFound: "container with ID stellar-local not found"")"#, + ) + } else { + ( + r#"docker: Error response from daemon: Conflict. The container name "/stellar-local" is already in use."#, + "Error response from daemon: No such container: stellar-local", + ) + }; + let script = format!( + r#"#!/bin/sh +echo "{name} $@" >> "{log}" +case " $* " in + *" pull "*) exit 0 ;; + *" run "*) + case "$FAKE_ENGINE_MODE" in + already_running) + echo '{already_running}' >&2 + exit 1 ;; + *) exit 0 ;; + esac ;; + *" stop "*) + case "$FAKE_ENGINE_MODE" in + not_found) + echo '{not_found}' >&2 + exit 1 ;; + *) exit 0 ;; + esac ;; + *) exit 0 ;; +esac +"#, + name = name, + log = self.log.display(), + ); + let path = self.bin_dir.join(name); + fs::write(&path, script).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + } + + /// Builds a `container` subcommand whose `PATH` is only the fake bin dir. + fn cmd(&self, mode: &str) -> Command { + let mut cmd = self.env.new_assert_cmd("container"); + cmd.env("PATH", &self.bin_dir) + .env("FAKE_ENGINE_MODE", mode) + // Don't inherit a real DOCKER_HOST/engine from the developer's shell. + .env_remove("DOCKER_HOST") + .env_remove("STELLAR_CONTAINER_ENGINE"); + cmd + } + + fn invocations(&self) -> String { + fs::read_to_string(&self.log).unwrap_or_default() + } +} + +fn line_for(log: &str, needle: &str) -> String { + log.lines() + .find(|l| l.contains(needle)) + .unwrap_or_else(|| panic!("no invocation containing {needle:?} in:\n{log}")) + .to_string() +} + +#[test] +fn start_defaults_to_docker_and_passes_expected_args() { + let s = EngineSandbox::new(); + s.install_engine("docker"); + + s.cmd("ok") + .args(["start", "local"]) + .assert() + .success() + .stderr(predicate::str::contains("Started container")); + + let log = s.invocations(); + // Pulled and ran via docker (not the apple `image pull` form). + assert!(line_for(&log, "pull").starts_with("docker pull ")); + let run = line_for(&log, " run "); + assert!(run.starts_with("docker "), "expected docker binary: {run}"); + assert!(run.contains("--name stellar-local"), "run line: {run}"); + assert!(run.contains("-p 8000:8000"), "run line: {run}"); + // No host override unless requested. + assert!(!run.contains("-H "), "unexpected -H: {run}"); +} + +#[test] +fn start_passes_docker_host_as_h_flag() { + let s = EngineSandbox::new(); + s.install_engine("docker"); + + s.cmd("ok") + .args(["start", "local", "--docker-host", "ssh://me@host"]) + .assert() + .success(); + + let run = line_for(&s.invocations(), " run "); + assert!(run.contains("-H ssh://me@host"), "run line: {run}"); +} + +#[test] +fn start_reports_already_running_from_stderr() { + let s = EngineSandbox::new(); + s.install_engine("docker"); + + s.cmd("already_running") + .args(["start", "local"]) + .assert() + .failure() + .stderr(predicate::str::contains("already running")); +} + +#[test] +fn stop_success() { + let s = EngineSandbox::new(); + s.install_engine("docker"); + + s.cmd("ok") + .args(["stop", "local"]) + .assert() + .success() + .stderr(predicate::str::contains("Container stopped")); + + assert!(line_for(&s.invocations(), " stop ").contains("stop stellar-local")); +} + +#[test] +fn stop_reports_not_found_from_stderr() { + let s = EngineSandbox::new(); + s.install_engine("docker"); + + s.cmd("not_found") + .args(["stop", "local"]) + .assert() + .failure() + .stderr(predicate::str::contains("container local not found")); +} + +#[test] +fn apple_engine_uses_container_binary_and_image_pull() { + let s = EngineSandbox::new(); + // Only the apple engine exists on PATH; if the CLI shelled out to docker it + // would fail with a not-found error instead. + s.install_engine("container"); + + s.cmd("ok") + .args(["start", "local", "--engine", "apple-container"]) + .assert() + .success(); + + let log = s.invocations(); + // Apple groups image ops under `image` (singular) and takes no `--tail`. + assert!(line_for(&log, "pull").starts_with("container image pull ")); + let run = line_for(&log, " run "); + assert!( + run.starts_with("container "), + "expected container binary: {run}" + ); +} + +#[test] +fn apple_engine_reports_already_running_from_stderr() { + let s = EngineSandbox::new(); + s.install_engine("container"); + + // The fake `container` emits Apple's `already exists` wording, so this only + // passes if the CLI classifies stderr with the apple-container matcher. + s.cmd("already_running") + .args(["start", "local", "--engine", "apple-container"]) + .assert() + .failure() + .stderr(predicate::str::contains("already running")); +} + +#[test] +fn apple_engine_reports_not_found_from_stderr() { + let s = EngineSandbox::new(); + s.install_engine("container"); + + // The fake `container` emits Apple's `not found` wording here. + s.cmd("not_found") + .args(["stop", "local", "--engine", "apple-container"]) + .assert() + .failure() + .stderr(predicate::str::contains("container local not found")); +} + +#[test] +fn apple_engine_selectable_via_env_var() { + let s = EngineSandbox::new(); + s.install_engine("container"); + + s.cmd("ok") + .env("STELLAR_CONTAINER_ENGINE", "apple-container") + .args(["start", "local"]) + .assert() + .success(); + + assert!(line_for(&s.invocations(), " run ").starts_with("container ")); +} + +#[test] +fn warns_and_drops_docker_host_for_apple_engine() { + let s = EngineSandbox::new(); + s.install_engine("container"); + + s.cmd("ok") + .args([ + "start", + "local", + "--engine", + "apple-container", + "--docker-host", + "ssh://me@host", + ]) + .assert() + .success() + .stderr(predicate::str::contains( + "is ignored because the `apple-container` engine", + )); + + // The ignored host must not reach the container binary. + assert!( + !s.invocations().contains("-H "), + "host leaked to container CLI" + ); +} + +#[test] +fn errors_when_engine_binary_is_missing() { + // No engine installed on the isolated PATH. + let s = EngineSandbox::new(); + + s.cmd("ok") + .args(["stop", "local"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "is docker installed and on your PATH?", + )); +} diff --git a/cmd/crates/soroban-test/tests/it/main.rs b/cmd/crates/soroban-test/tests/it/main.rs index e96558af5..9a54b41d3 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -1,5 +1,7 @@ mod build; mod config; +#[cfg(unix)] +mod container; #[cfg(feature = "emulator-tests")] mod emulator; mod help; diff --git a/cmd/soroban-cli/src/commands/container/logs.rs b/cmd/soroban-cli/src/commands/container/logs.rs index fa538362d..800efd455 100644 --- a/cmd/soroban-cli/src/commands/container/logs.rs +++ b/cmd/soroban-cli/src/commands/container/logs.rs @@ -1,4 +1,5 @@ use crate::commands::{container::shared::Error as ConnectionError, global}; +use crate::print; use super::shared::{Args, Name}; @@ -22,17 +23,19 @@ pub struct Cmd { } impl Cmd { - pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = print::Print::new(global_args.quiet); let container_name = Name(self.name.clone()).get_internal_container_name(); + self.container_args.warn_if_host_ignored(&print); + // Stream logs straight to the terminal by inheriting stdio. let status = self .container_args - .docker_command() - .args(["logs", "-f", "--tail", "all", &container_name]) + .logs_command(&container_name) .status() .await - .map_err(ConnectionError::from)?; + .map_err(|e| self.container_args.io_error(e))?; if !status.success() { return Err(Error::TailContainerError); diff --git a/cmd/soroban-cli/src/commands/container/mod.rs b/cmd/soroban-cli/src/commands/container/mod.rs index 08203095d..6d025cac4 100644 --- a/cmd/soroban-cli/src/commands/container/mod.rs +++ b/cmd/soroban-cli/src/commands/container/mod.rs @@ -1,7 +1,7 @@ use crate::commands::global; pub(crate) mod logs; -mod shared; +pub(crate) mod shared; pub(crate) mod start; pub(crate) mod stop; diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index 449db3946..f6290249f 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -3,53 +3,219 @@ use core::fmt; use clap::ValueEnum; use tokio::process::Command; +use crate::print::Print; + pub const DOCKER_HOST_HELP: &str = "Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock"; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("failed to run docker: {0}; is docker installed and on your PATH?")] - DockerNotFound(std::io::Error), + #[error("failed to run {program}: {source}; is {program} installed and on your PATH?")] + NotFound { + program: String, + source: std::io::Error, + }, - #[error("failed to run docker: {0}")] - DockerCommand(std::io::Error), + #[error("failed to run {program}: {source}")] + Command { + program: String, + source: std::io::Error, + }, } -impl From for Error { - fn from(err: std::io::Error) -> Self { - if err.kind() == std::io::ErrorKind::NotFound { - Error::DockerNotFound(err) - } else { - Error::DockerCommand(err) +/// Container runtime to shell out to. +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Default)] +pub enum Engine { + /// Docker, or any Docker-compatible CLI. + #[default] + Docker, + /// Apple's `container` CLI (macOS 26+, Apple silicon). + AppleContainer, +} + +impl Engine { + /// The engine used when no explicit `--engine` flag is passed: honors the + /// `STELLAR_CONTAINER_ENGINE` env var, otherwise docker. An explicit + /// `--engine` still overrides this (clap injects it into `Args::engine`); + /// this is for callers without CLI args, e.g. `stellar doctor`. + pub(crate) fn resolved_default() -> Engine { + std::env::var("STELLAR_CONTAINER_ENGINE") + .ok() + .and_then(|value| Engine::from_str(&value, true).ok()) + .unwrap_or_default() + } + + /// The executable name to invoke on `PATH`. + pub(crate) fn program(self) -> &'static str { + match self { + Engine::Docker => "docker", + Engine::AppleContainer => "container", + } + } + + /// Only docker honors `--docker-host`/`DOCKER_HOST`. + fn supports_docker_host(self) -> bool { + matches!(self, Engine::Docker) + } + + // The stderr-classification helpers below are the single place per-engine + // wording lives. + fn is_container_already_running(self, stderr: &str) -> bool { + match self { + Engine::Docker => stderr.contains("already in use"), + // Apple emits e.g. `Error: container with id stellar-local already exists`. + Engine::AppleContainer => stderr.contains("already exists"), + } + } + + fn is_container_not_found(self, stderr: &str) -> bool { + match self { + Engine::Docker => stderr.contains("No such container"), + // Apple emits e.g. `... notFound: "container with ID stellar-local not found"`. + Engine::AppleContainer => stderr.contains("not found"), } } } +impl fmt::Display for Engine { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Emit the flag value, which differs from the binary name (`program()`). + let variant_str = match self { + Engine::Docker => "docker", + Engine::AppleContainer => "apple-container", + }; + + write!(f, "{variant_str}") + } +} + #[derive(Debug, clap::Parser, Clone)] pub struct Args { /// Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock #[arg(short = 'd', long, help = DOCKER_HOST_HELP, env = "DOCKER_HOST")] pub docker_host: Option, + + /// Container engine to use [default: docker]. + #[arg(long, value_enum, env = "STELLAR_CONTAINER_ENGINE")] + pub engine: Option, } impl Args { + /// Resolves the effective engine. This is the single seam where a future + /// `stellar container use ` config default (flag/env > config > docker) + /// can slot in. + fn engine(&self) -> Engine { + self.engine.unwrap_or_default() + } + pub(crate) fn get_additional_flags(&self) -> String { - self.docker_host - .as_ref() - .map(|docker_host| format!("--docker-host {docker_host}")) - .unwrap_or_default() + let engine = self.engine(); + let mut parts = Vec::new(); + + if engine != Engine::default() { + parts.push(format!("--engine {engine}")); + } + + if engine.supports_docker_host() { + if let Some(docker_host) = &self.docker_host { + parts.push(format!("--docker-host {docker_host}")); + } + } + + parts.join(" ") + } + + /// Warns when `--docker-host`/`DOCKER_HOST` was provided but the selected + /// engine ignores it. + pub(crate) fn warn_if_host_ignored(&self, print: &Print) { + if let Some(message) = self.host_ignored_warning() { + print.warnln(message); + } + } + + fn host_ignored_warning(&self) -> Option { + if self.docker_host.is_some() && !self.engine().supports_docker_host() { + Some(format!( + "`--docker-host`/`DOCKER_HOST` is ignored because the `{}` engine does not support it", + self.engine() + )) + } else { + None + } + } + + /// Maps a spawn/IO error to an engine-aware error carrying the binary name. + pub(crate) fn io_error(&self, err: std::io::Error) -> Error { + let program = self.engine().program().to_string(); + if err.kind() == std::io::ErrorKind::NotFound { + Error::NotFound { + program, + source: err, + } + } else { + Error::Command { + program, + source: err, + } + } + } + + pub(crate) fn is_container_already_running(&self, stderr: &str) -> bool { + self.engine().is_container_already_running(stderr) + } + + pub(crate) fn is_container_not_found(&self, stderr: &str) -> bool { + self.engine().is_container_not_found(stderr) + } + + /// Builds the base command for the selected engine. For docker, a + /// `--docker-host` (or `DOCKER_HOST` env) value is passed as `-H `; the + /// `-H` flag outranks `DOCKER_CONTEXT`, so the override is honored even when a + /// docker context is active. Host resolution is otherwise left to the CLI. + fn base_command(&self) -> Command { + let engine = self.engine(); + let mut cmd = Command::new(engine.program()); + if engine.supports_docker_host() { + if let Some(host) = &self.docker_host { + cmd.args(["-H", host]); + } + } + cmd + } + + pub(crate) fn pull_command(&self, image: &str) -> Command { + let mut cmd = self.base_command(); + match self.engine() { + Engine::Docker => cmd.args(["pull", image]), + // Apple's CLI groups image operations under the `image` subcommand. + Engine::AppleContainer => cmd.args(["image", "pull", image]), + }; + cmd } - /// Builds a `docker` command, passing a `-H ` override when a `--docker-host` (or - /// `DOCKER_HOST` env) value is provided. The `-H` flag outranks `DOCKER_CONTEXT`, so the - /// override is honored even when a docker context is active. Host resolution is otherwise - /// left to the docker CLI itself. - pub(crate) fn docker_command(&self) -> Command { - let mut cmd = Command::new("docker"); - if let Some(host) = &self.docker_host { - cmd.args(["-H", host]); + pub(crate) fn run_command(&self, name: &str, ports: &[String]) -> Command { + let mut cmd = self.base_command(); + cmd.args(["run", "-d", "--rm", "--name", name]); + for port in ports { + cmd.args(["-p", port]); } cmd } + + pub(crate) fn stop_command(&self, name: &str) -> Command { + let mut cmd = self.base_command(); + cmd.args(["stop", name]); + cmd + } + + pub(crate) fn logs_command(&self, name: &str) -> Command { + let mut cmd = self.base_command(); + match self.engine() { + Engine::Docker => cmd.args(["logs", "-f", "--tail", "all", name]), + // `--tail all` is docker-specific; Apple's `container logs` omits it. + Engine::AppleContainer => cmd.args(["logs", "-f", name]), + }; + cmd + } } #[derive(ValueEnum, Debug, Copy, Clone, PartialEq)] @@ -83,3 +249,158 @@ impl Name { self.0.clone() } } + +#[cfg(test)] +mod test { + use super::*; + + fn args(docker_host: Option<&str>, engine: Option) -> Args { + Args { + docker_host: docker_host.map(String::from), + engine, + } + } + + fn program_of(cmd: &Command) -> String { + cmd.as_std().get_program().to_string_lossy().into_owned() + } + + fn args_of(cmd: &Command) -> Vec { + cmd.as_std() + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn engine_defaults_to_docker() { + assert_eq!(args(None, None).engine(), Engine::Docker); + assert_eq!( + args(None, Some(Engine::AppleContainer)).engine(), + Engine::AppleContainer + ); + } + + #[test] + fn docker_pull_uses_bare_pull() { + let cmd = args(None, None).pull_command("img:tag"); + assert_eq!(program_of(&cmd), "docker"); + assert_eq!(args_of(&cmd), ["pull", "img:tag"]); + } + + #[test] + fn apple_pull_uses_image_pull_and_ignores_host() { + let cmd = args(Some("ssh://host"), Some(Engine::AppleContainer)).pull_command("img:tag"); + assert_eq!(program_of(&cmd), "container"); + assert_eq!(args_of(&cmd), ["image", "pull", "img:tag"]); + } + + #[test] + fn docker_run_passes_host_as_h_flag() { + let cmd = + args(Some("ssh://host"), None).run_command("stellar-local", &["8000:8000".to_string()]); + assert_eq!(program_of(&cmd), "docker"); + assert_eq!( + args_of(&cmd), + [ + "-H", + "ssh://host", + "run", + "-d", + "--rm", + "--name", + "stellar-local", + "-p", + "8000:8000" + ] + ); + } + + #[test] + fn apple_run_omits_host() { + let cmd = args(Some("ssh://host"), Some(Engine::AppleContainer)) + .run_command("stellar-local", &[]); + assert_eq!(program_of(&cmd), "container"); + assert_eq!( + args_of(&cmd), + ["run", "-d", "--rm", "--name", "stellar-local"] + ); + } + + #[test] + fn docker_logs_include_tail_all_apple_omits_it() { + assert_eq!( + args_of(&args(None, None).logs_command("stellar-local")), + ["logs", "-f", "--tail", "all", "stellar-local"] + ); + assert_eq!( + args_of(&args(None, Some(Engine::AppleContainer)).logs_command("stellar-local")), + ["logs", "-f", "stellar-local"] + ); + } + + #[test] + fn additional_flags_carry_engine_and_host() { + assert_eq!(args(None, None).get_additional_flags(), ""); + assert_eq!( + args(None, Some(Engine::AppleContainer)).get_additional_flags(), + "--engine apple-container" + ); + assert_eq!( + args(Some("ssh://host"), None).get_additional_flags(), + "--docker-host ssh://host" + ); + // Apple ignores the host, so it must not be suggested for follow-up commands. + assert_eq!( + args(Some("ssh://host"), Some(Engine::AppleContainer)).get_additional_flags(), + "--engine apple-container" + ); + } + + #[test] + fn host_ignored_warning_only_for_non_docker_engines() { + assert!(args(Some("ssh://host"), Some(Engine::AppleContainer)) + .host_ignored_warning() + .is_some()); + assert!(args(Some("ssh://host"), None) + .host_ignored_warning() + .is_none()); + assert!(args(None, Some(Engine::AppleContainer)) + .host_ignored_warning() + .is_none()); + } + + #[test] + fn io_error_is_engine_aware() { + let not_found = std::io::Error::from(std::io::ErrorKind::NotFound); + match args(None, Some(Engine::AppleContainer)).io_error(not_found) { + Error::NotFound { program, .. } => assert_eq!(program, "container"), + Error::Command { .. } => panic!("expected NotFound, got Command"), + } + } + + #[test] + fn docker_stderr_classifiers_match_expected_strings() { + let docker = args(None, None); + assert!(docker.is_container_already_running( + r#"docker: Error response from daemon: Conflict. The container name "/stellar-local" is already in use"# + )); + assert!(!docker.is_container_already_running("some unrelated failure")); + assert!(docker.is_container_not_found( + "Error response from daemon: No such container: stellar-local" + )); + assert!(!docker.is_container_not_found("some unrelated failure")); + } + + #[test] + fn apple_stderr_classifiers_match_expected_strings() { + let apple = args(None, Some(Engine::AppleContainer)); + assert!(apple + .is_container_already_running("Error: container with id stellar-local already exists")); + assert!(!apple.is_container_already_running("some unrelated failure")); + assert!(apple.is_container_not_found( + r#"Error: internalError: "failed to stop container" (cause: "notFound: "container with ID stellar-local not found"")"# + )); + assert!(!apple.is_container_not_found("some unrelated failure")); + } +} diff --git a/cmd/soroban-cli/src/commands/container/start.rs b/cmd/soroban-cli/src/commands/container/start.rs index 0a4885dd6..66eeca6ff 100644 --- a/cmd/soroban-cli/src/commands/container/start.rs +++ b/cmd/soroban-cli/src/commands/container/start.rs @@ -80,15 +80,16 @@ impl Runner { self.print .infoln(format!("Starting {} network", self.network)); + self.args.container_args.warn_if_host_ignored(&self.print); + let image = self.get_image_name(); self.pull_image(&image).await; let container_name = self.container_name().get_internal_container_name(); - let mut cmd = self.args.container_args.docker_command(); - cmd.args(["run", "-d", "--rm", "--name", &container_name]); - for port_mapping in &self.args.ports_mapping { - cmd.args(["-p", port_mapping]); - } + let mut cmd = self + .args + .container_args + .run_command(&container_name, &self.args.ports_mapping); cmd.arg(&image); // Each element of `get_container_args` is passed as a single argv token (some elements, // such as "--enable rpc,horizon,lab", intentionally contain spaces). @@ -96,10 +97,17 @@ impl Runner { cmd.arg(arg); } - let output = cmd.output().await.map_err(ConnectionError::from)?; + let output = cmd + .output() + .await + .map_err(|e| self.args.container_args.io_error(e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("already in use") { + if self + .args + .container_args + .is_container_already_running(&stderr) + { return Err(Error::ContainerAlreadyRunning(container_name)); } return Err(Error::CreateContainerFailed(stderr.trim().to_string())); @@ -111,8 +119,8 @@ impl Runner { } async fn pull_image(&self, image: &str) { - let mut cmd = self.args.container_args.docker_command(); - cmd.args(["pull", image]).stdout(Stdio::piped()); + let mut cmd = self.args.container_args.pull_command(image); + cmd.stdout(Stdio::piped()); // `docker pull` writes its progress to stderr; honor `--quiet` by discarding it. if self.print.quiet { diff --git a/cmd/soroban-cli/src/commands/container/stop.rs b/cmd/soroban-cli/src/commands/container/stop.rs index 04600eb7f..d655892b2 100644 --- a/cmd/soroban-cli/src/commands/container/stop.rs +++ b/cmd/soroban-cli/src/commands/container/stop.rs @@ -37,17 +37,18 @@ impl Cmd { container_name.get_external_container_name() )); + self.container_args.warn_if_host_ignored(&print); + let output = self .container_args - .docker_command() - .args(["stop", &container_name.get_internal_container_name()]) + .stop_command(&container_name.get_internal_container_name()) .output() .await - .map_err(ConnectionError::from)?; + .map_err(|e| self.container_args.io_error(e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("No such container") { + if self.container_args.is_container_not_found(&stderr) { return Err(Error::ContainerNotFound { container_name: container_name.get_external_container_name(), }); diff --git a/cmd/soroban-cli/src/commands/doctor.rs b/cmd/soroban-cli/src/commands/doctor.rs index e6c9c3e87..bc477fc1d 100644 --- a/cmd/soroban-cli/src/commands/doctor.rs +++ b/cmd/soroban-cli/src/commands/doctor.rs @@ -5,7 +5,7 @@ use std::fmt::Debug; use std::process::Command; use crate::{ - commands::global, + commands::{container::shared::Engine, global}, config::{ self, data, locator::{self, KeyType}, @@ -50,6 +50,7 @@ impl Cmd { check_rust_version(&print); check_wasm_target(&print); check_optional_features(&print); + check_container_engine(&print); show_config_path(&print, &self.config_locator)?; show_data_path(&print)?; show_xdr_version(&print); @@ -205,6 +206,22 @@ fn check_wasm_target(print: &Print) { } } +fn check_container_engine(print: &Print) { + let engine = Engine::resolved_default(); + + // `output()` succeeds as long as the binary spawned, regardless of exit + // status, so it tells us whether the engine is installed on `PATH`. + if Command::new(engine.program()) + .arg("--version") + .output() + .is_ok() + { + print.checkln(format!("Container engine `{engine}` is available")); + } else { + print.warnln(format!("Container engine `{engine}` is not installed")); + } +} + fn check_optional_features(print: &Print) { #[cfg(feature = "additional-libs")] { diff --git a/cmd/soroban-cli/src/env_vars.rs b/cmd/soroban-cli/src/env_vars.rs index faf732438..f20da44c3 100644 --- a/cmd/soroban-cli/src/env_vars.rs +++ b/cmd/soroban-cli/src/env_vars.rs @@ -6,6 +6,7 @@ pub fn unprefixed() -> Vec<&'static str> { "ACCOUNT", "ARCHIVE_URL", "CONFIG_HOME", + "CONTAINER_ENGINE", "CONTRACT_ID", "DATA_HOME", "FEE", @@ -30,6 +31,7 @@ pub fn unprefixed() -> Vec<&'static str> { const VISIBLE: &[&str] = &[ "ACCOUNT", "CONFIG_HOME", + "CONTAINER_ENGINE", "CONTRACT_ID", "DATA_HOME", "FEE",