Repository: tangle-network/gadget (language: rust)

Issue:
[TASK] Add Docker utils to the SDK
# Overview

The Obol blueprint at https://github.com/tangle-network/obol-dvt-blueprint heavily relies on spinning up a bunch of Docker containers with different commands. This is likely not the only service of its kind that we'd want to support.

The current utils in `src/docker.rs` are likely general enough to include in the SDK, will need to build more blueprints to see.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index bf01cdc98..a6307dd78 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4778,6 +4778,7 @@ dependencies = [
  "auto_impl",
  "backon",
  "bincode",
+ "bollard",
  "ed25519-zebra 4.0.3",
  "eigensdk",
  "elliptic-curve",
diff --git a/Cargo.toml b/Cargo.toml
index 2a92a23dc..f38f5ce10 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -87,6 +87,7 @@ auto_impl = "1.2.0"
 backon = { version = "1.2.0", default-features = false }
 bincode = "1.3.3"
 bip39 = "2.1.0"
+bollard = "0.16.1"
 cargo-generate = { version = "0.21.3", default-features = false }
 cfg-if = "1.0.0"
 clap = "4.5.16"
diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml
index e39755de2..087183c6a 100644
--- a/sdk/Cargo.toml
+++ b/sdk/Cargo.toml
@@ -11,6 +11,7 @@ license.workspace = true
 
 [dependencies]
 auto_impl = { workspace = true }
+bollard = { workspace = true }
 elliptic-curve = { workspace = true, features = ["alloc", "sec1"] }
 getrandom = { workspace = true, optional = true }
 hex = { workspace = true, features = ["alloc"] }
diff --git a/sdk/src/docker.rs b/sdk/src/docker.rs
new file mode 100644
index 000000000..0be025cdb
--- /dev/null
+++ b/sdk/src/docker.rs
@@ -0,0 +1,490 @@
+//! Utilities for spinning up and managing Docker containers
+//!
+//! This module provides wrappers around [`bollard`] to simplify Docker interactions within blueprints.
+
+pub use bollard;
+use bollard::container::{
+    Config, CreateContainerOptions, StartContainerOptions, StopContainerOptions,
+    WaitContainerOptions,
+};
+use bollard::models::{ContainerCreateResponse, HostConfig};
+use bollard::{Docker, API_DEFAULT_VERSION};
+use std::sync::Arc;
+use subxt::ext::futures::{Stream, StreamExt};
+
+/// A [Docker](https://en.wikipedia.org/wiki/Docker_(software)) container
+#[derive(Debug)]
+pub struct Container<'a> {
+    id: Option<String>,
+    image: String,
+    connection: &'a Docker,
+    options: ContainerOptions,
+}
+
+#[derive(Debug, Default, Clone)]
+struct ContainerOptions {
+    env: Option<Vec<String>>,
+    cmd: Option<Vec<String>>,
+    binds: Option<Vec<String>>,
+}
+
+impl<'a> Container<'a> {
+    /// Create a new `Container`
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// // We can now start our container
+    /// container.start(true).await?;
+    /// # Ok(()) }
+    /// ```
+    pub fn new<T>(connection: &'a Docker, image: T) -> Self
+    where
+        T: Into<String>,
+    {
+        Self {
+            id: None,
+            image: image.into(),
+            connection,
+            options: ContainerOptions::default(),
+        }
+    }
+
+    /// Set the environment variables for the container
+    ///
+    /// NOTE: This will override any existing variables.
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// container.env(["FOO=BAR", "BAZ=QUX"]);
+    ///
+    /// // We can now start our container, and the "FOO" and "BAZ" env vars will be set
+    /// container.start(true).await?;
+    /// # Ok(()) }
+    /// ```
+    pub fn env(&mut self, env: impl IntoIterator<Item = impl Into<String>>) -> &mut Self {
+        self.options.env = Some(env.into_iter().map(Into::into).collect());
+        self
+    }
+
+    /// Set the command to run
+    ///
+    /// The command is provided as a list of strings.
+    ///
+    /// NOTE: This will override any existing command
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// container.cmd(["echo", "Hello!"]);
+    ///
+    /// // We can now start our container, and the command "echo Hello!" will run
+    /// container.start(true).await?;
+    /// # Ok(()) }
+    /// ```
+    pub fn cmd(&mut self, cmd: impl IntoIterator<Item = impl Into<String>>) -> &mut Self {
+        self.options.cmd = Some(cmd.into_iter().map(Into::into).collect());
+        self
+    }
+
+    /// Set a list of volume binds
+    ///
+    /// These binds are in the standard `host:dest[:options]` format. For more information, see
+    /// the [Docker documentation](https://docs.docker.com/engine/storage/bind-mounts/).
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// // Mount './my-host-dir' at '/some/container/dir' and make it read-only
+    /// container.binds(["./my-host-dir:/some/container/dir:ro"]);
+    ///
+    /// // We can now start our container
+    /// container.start(true).await?;
+    /// # Ok(()) }
+    /// ```
+    pub fn binds(&mut self, binds: impl IntoIterator<Item = impl Into<String>>) -> &mut Self {
+        self.options.binds = Some(binds.into_iter().map(Into::into).collect());
+        self
+    }
+
+    /// Get the container ID if it has been created
+    ///
+    /// This will only have a value if [`Container::create`] or [`Container::start`] has been
+    /// called prior.
+    pub fn id(&self) -> Option<&str> {
+        self.id.as_deref()
+    }
+
+    /// Attempt to create the container
+    ///
+    /// This will take the following into account:
+    ///
+    /// * [`Container::env`]
+    /// * [`Container::cmd`]
+    /// * [`Container::binds`]
+    ///
+    /// Be sure to set these before calling this!
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// container.env(["FOO=BAR", "BAZ=QUX"]);
+    /// container.cmd(["echo", "Hello!"]);
+    /// container.binds(["./host-data:/container-data"]);
+    ///
+    /// // The container is created using the above settings
+    /// container.create().await?;
+    ///
+    /// // Now it can be started
+    /// container.start(true).await?;
+    /// # Ok(()) }
+    /// ```
+    #[tracing::instrument]
+    pub async fn create(&mut self) -> Result<(), bollard::errors::Error> {
+        crate::debug!("Creating container");
+
+        let config = Config {
+            image: Some(self.image.clone()),
+            cmd: self.options.cmd.clone(),
+            attach_stdout: Some(true),
+            host_config: Some(HostConfig {
+                binds: self.options.binds.clone(),
+                ..Default::default()
+            }),
+            ..Default::default()
+        };
+
+        let ContainerCreateResponse { id, warnings } = self
+            .connection
+            .create_container(None::<CreateContainerOptions<String>>, config)
+            .await?;
+        for warning in warnings {
+            crate::warn!("{}", warning);
+        }
+
+        self.id = Some(id);
+        Ok(())
+    }
+
+    /// Attempt to start the container
+    ///
+    /// NOTE: If the container has not yet been created, this will attempt to call [`Container::create`] first.
+    ///
+    /// `wait_for_exit` will wait for the container to exit before returning.
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// container.cmd(["echo", "Hello!"]);
+    ///
+    /// // We can now start our container, and the command "echo Hello!" will run.
+    /// let wait_for_exit = true;
+    /// container.start(wait_for_exit).await?;
+    ///
+    /// // Since we waited for the container to exit, we don't have to stop it.
+    /// // It can now just be removed.
+    /// container.remove(None).await?;
+    /// # Ok(()) }
+    /// ```
+    #[tracing::instrument]
+    pub async fn start(&mut self, wait_for_exit: bool) -> Result<(), bollard::errors::Error> {
+        if self.id.is_none() {
+            self.create().await?;
+        }
+
+        crate::debug!("Starting container");
+        let id = self.id.as_ref().unwrap();
+        self.connection
+            .start_container(id, None::<StartContainerOptions<String>>)
+            .await?;
+
+        if wait_for_exit {
+            self.wait().await?;
+        }
+
+        Ok(())
+    }
+
+    /// Stop a running container
+    ///
+    /// NOTE: It is not an error to call this on a container that has not been started,
+    ///       it will simply do nothing.
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    ///
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// // Does nothing, the container isn't started
+    /// container.stop().await?;
+    ///
+    /// // Stops the running container
+    /// container.start(false).await?;
+    /// container.stop().await?;
+    /// # Ok(()) }
+    /// ```
+    #[tracing::instrument]
+    pub async fn stop(&mut self) -> Result<(), bollard::errors::Error> {
+        let Some(id) = &self.id else {
+            crate::warn!("Container not started");
+            return Ok(());
+        };
+
+        self.connection
+            .stop_container(id, None::<StopContainerOptions>)
+            .await?;
+
+        Ok(())
+    }
+
+    /// Remove a container
+    ///
+    /// NOTE: To remove a running container, a [`RemoveContainerOptions`] must be provided
+    ///       with the `force` flag set.
+    ///
+    /// See also: [`bollard::container::RemoveContainerOptions`]
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{bollard, connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    ///
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// // Start our container
+    /// container.start(false).await?;
+    ///
+    /// let remove_container_options = bollard::container::RemoveContainerOptions {
+    ///     force: true,
+    ///     ..Default::default()
+    /// };
+    ///
+    /// // Kills the container and removes it
+    /// container.remove(Some(remove_container_options)).await?;
+    /// # Ok(()) }
+    /// ```
+    ///
+    /// [`RemoveContainerOptions::force`]: bollard::container::RemoveContainerOptions::force
+    #[tracing::instrument]
+    pub async fn remove(
+        mut self,
+        options: Option<bollard::container::RemoveContainerOptions>,
+    ) -> Result<(), bollard::errors::Error> {
+        let Some(id) = self.id.take() else {
+            crate::warn!("Container not started");
+            return Ok(());
+        };
+
+        self.connection.remove_container(&id, options).await?;
+        Ok(())
+    }
+
+    /// Wait for a container to exit
+    ///
+    /// NOTE: It is not an error to call this on a container that has not been started,
+    ///       it will simply do nothing.
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use gadget_sdk::docker::{bollard, connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    ///
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// // Start our container
+    /// container.start(false).await?;
+    ///
+    /// // Once this returns, we know that the container has exited.
+    /// container.wait().await?;
+    /// # Ok(()) }
+    /// ```
+    #[tracing::instrument]
+    pub async fn wait(&self) -> Result<(), bollard::errors::Error> {
+        let Some(id) = &self.id else {
+            crate::warn!("Container not created");
+            return Ok(());
+        };
+
+        wait_for_container(self.connection, id).await?;
+        Ok(())
+    }
+
+    /// Fetch the container log stream
+    ///
+    /// NOTE: It is not an error to call this on a container that has not been started,
+    ///       it will simply do nothing and return `None`.
+    ///
+    /// See also:
+    ///
+    /// * [`bollard::container::LogsOptions`]
+    /// * [`bollard::container::LogOutput`]
+    ///
+    /// # Examples
+    ///
+    /// ```rust,no_run
+    /// use futures::StreamExt;
+    /// use gadget_sdk::docker::{bollard, connect_to_docker, Container};
+    ///
+    /// # async fn main() -> Result<(), gadget_sdk::Error> {
+    /// let connection = connect_to_docker(None).await?;
+    ///
+    /// let mut container = Container::new(&connection, "rustlang/rust");
+    ///
+    /// // Start our container and wait for it to exit
+    /// container.start(true).await?;
+    ///
+    /// // We want to collect logs from stderr
+    /// let logs_options = bollard::container::LogsOptions {
+    ///     stderr: true,
+    ///     follow: true,
+    ///     ..Default::default()
+    /// };
+    ///
+    /// // Get our log stream
+    /// let mut logs = container
+    ///     .logs(Some(logs_options))
+    ///     .await
+    ///     .expect("logs should be present");
+    ///
+    /// // Now we want to print anything from stderr
+    /// while let Some(Ok(out)) = logs.next().await {
+    ///     if let bollard::container::LogOutput::StdErr { message } = out {
+    ///         eprintln!("Uh oh! Something was wirtten to stderr: {message}");
+    ///     }
+    /// }
+    /// # Ok(()) }
+    /// ```
+    #[tracing::instrument]
+    pub async fn logs(
+        &self,
+        logs_options: Option<bollard::container::LogsOptions<String>>,
+    ) -> Option<impl Stream<Item = Result<bollard::container::LogOutput, bollard::errors::Error>>>
+    {
+        let Some(id) = &self.id else {
+            crate::warn!("Container not created");
+            return None;
+        };
+
+        Some(self.connection.logs(id, logs_options))
+    }
+}
+
+/// Connect to a local [Docker] socket
+///
+/// NOTE: If `socket` is `None`, this will attempt to connect to `/var/run/docker.sock`.
+///
+/// # Examples
+///
+/// ```rust,no_run
+/// use gadget_sdk::docker::connect_to_docker;
+///
+/// # async fn main() -> Result<(), gadget_sdk::Error> {
+/// let connection = connect_to_docker(None).await?;
+///
+/// // I now have a handle to my local Docker server!
+/// # Ok(()) }
+/// ```
+///
+/// [Docker]: https://en.wikipedia.org/wiki/Docker_(software)
+pub async fn connect_to_docker(
+    socket: Option<&str>,
+) -> Result<Arc<Docker>, bollard::errors::Error> {
+    crate::info!("Connecting to local docker server...");
+    let docker = Docker::connect_with_socket(
+        socket.unwrap_or("/var/run/docker.sock"),
+        120,
+        API_DEFAULT_VERSION,
+    )?;
+    if let Err(e) = docker.ping().await {
+        crate::error!("Failed to ping docker server: {}", e);
+        return Err(e);
+    }
+
+    Ok(Arc::new(docker))
+}
+
+async fn wait_for_container(docker: &Docker, id: &str) -> Result<(), bollard::errors::Error> {
+    let options = WaitContainerOptions {
+        condition: "not-running",
+    };
+
+    let mut wait_stream = docker.wait_container(id, Some(options));
+
+    while let Some(msg) = wait_stream.next().await {
+        match msg {
+            Ok(msg) => {
+                if msg.status_code == 0 {
+                    break;
+                }
+
+                if let Some(err) = msg.error {
+                    crate::error!("Failed to wait for container: {:?}", err.message);
+                    // TODO: These aren't the same error type, is this correct?
+                    return Err(bollard::errors::Error::DockerContainerWaitError {
+                        error: err.message.unwrap_or_default(),
+                        code: msg.status_code,
+                    });
+                }
+            }
+            Err(e) => {
+                match &e {
+                    bollard::errors::Error::DockerContainerWaitError { error, code } => {
+                        crate::error!("Container failed with status code `{}`: {error}", code);
+                    }
+                    _ => crate::error!("Container failed with error: {:?}", e),
+                }
+                return Err(e);
+            }
+        }
+    }
+
+    Ok(())
+}
diff --git a/sdk/src/error.rs b/sdk/src/error.rs
index cb5b92c46..84529840f 100644
--- a/sdk/src/error.rs
+++ b/sdk/src/error.rs
@@ -26,6 +26,9 @@ pub enum Error {
     #[error("Job runner error: {0}")]
     Runner(#[from] crate::job_runner::Error),
 
+    #[error("Docker error: {0}")]
+    Docker(#[from] bollard::errors::Error),
+
     #[error("Missing network ID")]
     MissingNetworkId,
 
diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs
index 0964ed97a..9d6db2440 100644
--- a/sdk/src/lib.rs
+++ b/sdk/src/lib.rs
@@ -65,6 +65,7 @@ pub mod job_runner;
 /// Gadget Context and context extensions
 pub mod ctx;
 
+pub mod docker;
 pub mod utils;
 
 // Re-exports


Test patch: