diff --git a/Cargo.lock b/Cargo.lock index 9d44e32e1..1e5ff0814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5390,7 +5390,6 @@ dependencies = [ "async-compression", "async-trait", "base64 0.21.7", - "bollard", "bytesize", "cargo_metadata", "chrono", @@ -5814,7 +5813,6 @@ name = "stellar-ledger" version = "27.0.0" dependencies = [ "async-trait", - "bollard", "byteorder 1.5.0", "ed25519-dalek", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 90664eb5d..676da683f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,6 @@ escape-bytes = "0.1.1" hex = "0.4.3" itertools = "0.10.0" async-trait = "0.1.76" -bollard = "0.20.2" serde-aux = "4.1.2" serde_json = "1.0.82" serde = "1.0.82" diff --git a/cmd/crates/stellar-ledger/Cargo.toml b/cmd/crates/stellar-ledger/Cargo.toml index 1d7a584bf..6930b3094 100644 --- a/cmd/crates/stellar-ledger/Cargo.toml +++ b/cmd/crates/stellar-ledger/Cargo.toml @@ -23,7 +23,6 @@ ledger-transport = "0.10.0" tracing = { workspace = true } hex.workspace = true byteorder = "1.5.0" -bollard = { workspace = true } home = "0.5.9" tokio = { version = "1", features = ["full"] } reqwest = { workspace = true, features = ["json"] } diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 634cdc342..aa68b6ef1 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -107,7 +107,6 @@ shell-escape = "0.1.5" tempfile = "3.8.1" toml_edit = { workspace = true } rust-embed = { version = "8.2.0", features = ["debug-embed"] } -bollard = { workspace = true } futures-util = "0.3.30" futures = "0.3.30" home = "0.5.9" diff --git a/cmd/soroban-cli/src/commands/container/logs.rs b/cmd/soroban-cli/src/commands/container/logs.rs index 3d029cf74..fa538362d 100644 --- a/cmd/soroban-cli/src/commands/container/logs.rs +++ b/cmd/soroban-cli/src/commands/container/logs.rs @@ -1,20 +1,14 @@ -use bollard::query_parameters::LogsOptions; -use futures_util::TryStreamExt; - -use crate::{ - commands::{container::shared::Error as ConnectionError, global}, - print, -}; +use crate::commands::{container::shared::Error as ConnectionError, global}; use super::shared::{Args, Name}; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] - ConnectionError(#[from] ConnectionError), + Docker(#[from] ConnectionError), - #[error("⛔ ️Failed to tail container: {0}")] - TailContainerError(#[from] bollard::errors::Error), + #[error("failed to tail container logs")] + TailContainerError, } #[derive(Debug, clap::Parser, Clone)] @@ -28,24 +22,22 @@ pub struct Cmd { } impl Cmd { - pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { - let print = print::Print::new(global_args.quiet); + pub async fn run(&self, _global_args: &global::Args) -> Result<(), Error> { let container_name = Name(self.name.clone()).get_internal_container_name(); - let docker = self.container_args.connect_to_docker(&print).await?; - let logs_stream = &mut docker.logs( - &container_name, - Some(LogsOptions { - follow: true, - stdout: true, - stderr: true, - tail: "all".to_owned(), - ..Default::default() - }), - ); - - while let Some(log) = logs_stream.try_next().await? { - print!("{log}"); + + // Stream logs straight to the terminal by inheriting stdio. + let status = self + .container_args + .docker_command() + .args(["logs", "-f", "--tail", "all", &container_name]) + .status() + .await + .map_err(ConnectionError::from)?; + + if !status.success() { + return Err(Error::TailContainerError); } + Ok(()) } } diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index 78ac2c5e3..449db3946 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -1,36 +1,27 @@ use core::fmt; -use bollard::{ClientVersion, Docker}; use clap::ValueEnum; -#[allow(unused_imports)] -// Need to add this for windows, since we are only using this crate for the unix fn try_docker_desktop_socket -use home::home_dir; - -use crate::print; +use tokio::process::Command; 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"; -// DEFAULT_DOCKER_HOST is from the bollard crate on the main branch, which has not been released yet: https://github.com/fussybeaver/bollard/blob/0972b1aac0ad5c08798e100319ddd0d2ee010365/src/docker.rs#L64 -#[cfg(unix)] -pub const DEFAULT_DOCKER_HOST: &str = "unix:///var/run/docker.sock"; - -#[cfg(windows)] -pub const DEFAULT_DOCKER_HOST: &str = "npipe:////./pipe/docker_engine"; - -// DEFAULT_TIMEOUT and API_DEFAULT_VERSION are from the bollard crate -const DEFAULT_TIMEOUT: u64 = 120; -const API_DEFAULT_VERSION: &ClientVersion = &ClientVersion { - major_version: 1, - minor_version: 40, -}; - #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("⛔ ️Failed to start container: {0}")] - BollardErr(#[from] bollard::errors::Error), + #[error("failed to run docker: {0}; is docker installed and on your PATH?")] + DockerNotFound(std::io::Error), - #[error("URI scheme is not supported: {uri}")] - UnsupportedURISchemeError { uri: String }, + #[error("failed to run docker: {0}")] + DockerCommand(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) + } + } } #[derive(Debug, clap::Parser, Clone)] @@ -48,61 +39,16 @@ impl Args { .unwrap_or_default() } - #[allow(unused_variables)] - pub(crate) async fn connect_to_docker(&self, print: &print::Print) -> Result { - // if no docker_host is provided, use the default docker host: - // "unix:///var/run/docker.sock" on unix machines - // "npipe:////./pipe/docker_engine" on windows machines - let host = self.docker_host.as_ref().map_or_else( - || DEFAULT_DOCKER_HOST.to_string(), - std::string::ToString::to_string, - ); - - // this is based on the `connect_with_defaults` method which has not yet been released in the bollard crate - // https://github.com/fussybeaver/bollard/blob/0972b1aac0ad5c08798e100319ddd0d2ee010365/src/docker.rs#L660 - let connection = match host.clone() { - // if tcp or http, connect to the specified host directly - // if unix and host starts with "unix://" use connect_with_unix - // if windows and host starts with "npipe://", use connect_with_named_pipe - // else default to connect_with_unix - h if h.starts_with("tcp://") || h.starts_with("http://") => { - Docker::connect_with_http(&h, DEFAULT_TIMEOUT, API_DEFAULT_VERSION) - } - #[cfg(unix)] - h if h.starts_with("unix://") => { - Docker::connect_with_unix(&h, DEFAULT_TIMEOUT, API_DEFAULT_VERSION) - } - #[cfg(windows)] - h if h.starts_with("npipe://") => { - Docker::connect_with_named_pipe(&h, DEFAULT_TIMEOUT, API_DEFAULT_VERSION) - } - _ => { - return Err(Error::UnsupportedURISchemeError { uri: host.clone() }); - } - }?; - - match check_docker_connection(&connection).await { - Ok(()) => Ok(connection), - // If we aren't able to connect with the defaults, or with the provided docker_host - // try to connect with the default docker desktop socket since that is a common use case for devs - #[allow(unused_variables)] - Err(e) => { - // if on unix, try to connect to the default docker desktop socket - #[cfg(unix)] - { - let docker_desktop_connection = try_docker_desktop_socket(&host, print)?; - match check_docker_connection(&docker_desktop_connection).await { - Ok(()) => Ok(docker_desktop_connection), - Err(err) => Err(err)?, - } - } - - #[cfg(windows)] - { - Err(e)? - } - } + /// 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]); } + cmd } } @@ -137,42 +83,3 @@ impl Name { self.0.clone() } } - -#[cfg(unix)] -fn try_docker_desktop_socket( - host: &str, - print: &print::Print, -) -> Result { - let default_docker_desktop_host = - format!("{}/.docker/run/docker.sock", home_dir().unwrap().display()); - print.warnln(format!("Failed to connect to Docker daemon at {host}.")); - - print.infoln(format!( - "Attempting to connect to the default Docker Desktop socket at {default_docker_desktop_host} instead." - )); - - Docker::connect_with_unix( - &default_docker_desktop_host, - DEFAULT_TIMEOUT, - API_DEFAULT_VERSION, - ).inspect_err(|_| { - print.errorln(format!( - "Failed to connect to the Docker daemon at {host:?}. Is the docker daemon running?" - )); - print.infoln( - "Running a local Stellar network requires a Docker-compatible container runtime." - ); - print.infoln( - "Please note that if you are using Docker Desktop, you may need to utilize the `--docker-host` flag to pass in the location of the docker socket on your machine." - ); - }) -} - -// When bollard is not able to connect to the docker daemon, it returns a generic ConnectionRefused error -// This method attempts to connect to the docker daemon and returns a more specific error message -async fn check_docker_connection(docker: &Docker) -> Result<(), bollard::errors::Error> { - match docker.version().await { - Ok(_version) => Ok(()), - Err(err) => Err(err), - } -} diff --git a/cmd/soroban-cli/src/commands/container/start.rs b/cmd/soroban-cli/src/commands/container/start.rs index fe0e834bd..738a5f2bf 100644 --- a/cmd/soroban-cli/src/commands/container/start.rs +++ b/cmd/soroban-cli/src/commands/container/start.rs @@ -1,12 +1,7 @@ -use std::collections::HashMap; use std::env; +use std::process::Stdio; -use bollard::{ - models::ContainerCreateBody, - query_parameters::{CreateContainerOptions, CreateImageOptions, StartContainerOptions}, - service::{HostConfig, PortBinding}, -}; -use futures_util::TryStreamExt; +use tokio::io::{AsyncBufReadExt, BufReader}; use crate::{ commands::{ @@ -23,13 +18,13 @@ const DOCKER_IMAGE: &str = "docker.io/stellar/quickstart"; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("⛔ ️Failed to connect to docker: {0}")] - DockerConnectionFailed(#[from] ConnectionError), + #[error(transparent)] + Docker(#[from] ConnectionError), - #[error("⛔ ️Failed to create container: {0}")] - CreateContainerFailed(#[from] bollard::errors::Error), + #[error("failed to create container: {0}")] + CreateContainerFailed(String), - #[error("⛔ ️ a container named {0:?} already running")] + #[error("a container named {0:?} already running")] ContainerAlreadyRunning(String), } @@ -85,83 +80,73 @@ impl Runner { self.print .infoln(format!("Starting {} network", &self.network)); - let docker = self - .args - .container_args - .connect_to_docker(&self.print) - .await?; - let image = self.get_image_name(); - let mut stream = docker.create_image( - Some(CreateImageOptions { - from_image: Some(image.clone()), - ..Default::default() - }), - None, - None, - ); - - while let Some(result) = stream.try_next().await.transpose() { - if let Ok(item) = result { - if let Some(status) = item.status { - if status.contains("Pulling from") - || status.contains("Digest") - || status.contains("Status") - { - self.print.infoln(status); - } - } - } else { - self.print - .warnln(format!("Failed to fetch image: {image}.")); - self.print.warnln( - "Attempting to start local quickstart image. The image may be out-of-date.", - ); - break; - } + 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]); + } + 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). + for arg in self.get_container_args() { + cmd.arg(arg); } - let config = ContainerCreateBody { - image: Some(image), - cmd: Some(self.get_container_args()), - attach_stdout: Some(true), - attach_stderr: Some(true), - host_config: Some(HostConfig { - auto_remove: Some(true), - port_bindings: Some(self.get_port_mapping()), - ..Default::default() - }), - ..Default::default() - }; - let create_container_response = docker - .create_container( - Some(CreateContainerOptions { - name: Some(self.container_name().get_internal_container_name()), - ..Default::default() - }), - config, - ) - .await - .map_err(|e| match &e { - bollard::errors::Error::DockerResponseServerError { status_code, .. } => { - if *status_code == 409 { - return Error::ContainerAlreadyRunning( - self.container_name().get_internal_container_name(), - ); - } - Error::CreateContainerFailed(e) - } - _ => Error::CreateContainerFailed(e), - })?; + let output = cmd.output().await.map_err(ConnectionError::from)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("already in use") { + return Err(Error::ContainerAlreadyRunning(container_name)); + } + return Err(Error::CreateContainerFailed(stderr.trim().to_string())); + } - docker - .start_container(&create_container_response.id, None::) - .await?; self.print.checkln("Started container"); self.print_instructions(); Ok(()) } + 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 child = match cmd.spawn() { + Ok(child) => child, + // Let the main `docker run` invocation surface the "is docker installed?" hint + // rather than warning about a failed image fetch. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(_) => return self.warn_image_fetch(image), + }; + + if let Some(stdout) = child.stdout.take() { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.contains("Pulling from") + || line.contains("Digest") + || line.contains("Status") + { + self.print.infoln(line); + } + } + } + + match child.wait().await { + Ok(status) if status.success() => {} + _ => self.warn_image_fetch(image), + } + } + + fn warn_image_fetch(&self, image: &str) { + self.print + .warnln(format!("Failed to fetch image: {image}.")); + self.print + .warnln("Attempting to start local quickstart image. The image may be out-of-date."); + } + fn get_image_name(&self) -> String { // this can be overriden with the `-t` flag let mut image_tag = match &self.network { @@ -195,26 +180,6 @@ impl Runner { .collect() } - // The port mapping in the bollard crate is formatted differently than the docker CLI. In the docker CLI, we usually specify exposed ports as `-p HOST_PORT:CONTAINER_PORT`. But with the bollard crate, it is expecting the port mapping to be a map of the container port (with the protocol) to the host port. - fn get_port_mapping(&self) -> HashMap>> { - let mut port_mapping_hash = HashMap::new(); - for port_mapping in &self.args.ports_mapping { - let ports_vec: Vec<&str> = port_mapping.split(':').collect(); - let from_port = ports_vec[0]; - let to_port = ports_vec[1]; - - port_mapping_hash.insert( - format!("{to_port}/tcp"), - Some(vec![PortBinding { - host_ip: None, - host_port: Some(from_port.to_string()), - }]), - ); - } - - port_mapping_hash - } - fn container_name(&self) -> Name { Name(self.args.name.clone().unwrap_or(self.network.to_string())) } diff --git a/cmd/soroban-cli/src/commands/container/stop.rs b/cmd/soroban-cli/src/commands/container/stop.rs index dffeca2ff..04600eb7f 100644 --- a/cmd/soroban-cli/src/commands/container/stop.rs +++ b/cmd/soroban-cli/src/commands/container/stop.rs @@ -1,25 +1,20 @@ use crate::{ - commands::{container::shared::Error as BollardConnectionError, global}, + commands::{container::shared::Error as ConnectionError, global}, print, }; -use bollard::query_parameters::StopContainerOptions; use super::shared::{Args, Name}; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("⛔ Failed to connect to docker: {0}")] - DockerConnectionFailed(#[from] BollardConnectionError), + #[error(transparent)] + Docker(#[from] ConnectionError), - #[error("⛔ Container {container_name} not found")] - ContainerNotFound { - container_name: String, - #[source] - source: bollard::errors::Error, - }, + #[error("container {container_name} not found")] + ContainerNotFound { container_name: String }, - #[error("⛔ Failed to stop container: {0}")] - ContainerStopFailed(#[from] bollard::errors::Error), + #[error("failed to stop container: {0}")] + ContainerStopFailed(String), } #[derive(Debug, clap::Parser, Clone)] @@ -36,31 +31,29 @@ impl Cmd { 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()); - let docker = self.container_args.connect_to_docker(&print).await?; print.infoln(format!( "Stopping {} container", container_name.get_external_container_name() )); - docker - .stop_container( - &container_name.get_internal_container_name(), - None::, - ) + let output = self + .container_args + .docker_command() + .args(["stop", &container_name.get_internal_container_name()]) + .output() .await - .map_err(|e| { - let msg = e.to_string(); - - if msg.contains("No such container") { - Error::ContainerNotFound { - container_name: container_name.get_external_container_name(), - source: e, - } - } else { - Error::ContainerStopFailed(e) - } - })?; + .map_err(ConnectionError::from)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("No such container") { + return Err(Error::ContainerNotFound { + container_name: container_name.get_external_container_name(), + }); + } + return Err(Error::ContainerStopFailed(stderr.trim().to_string())); + } print.checkln("Container stopped");