draupnir 0.1.3

Draupnir — the nordisk boot/provisioning library: fire up a runtime from one BootSpec across three backends (KVM via tunnr · OCI container · Redfish bare-metal virtual-media) and drive its power lifecycle. Odin's ring that drips eight identical copies → boot a fleet of identical machines from one ISO.
Documentation
//! **Container backend** — fire up an OCI container instance (e.g. a redis
//! service) over a container runtime.
//!
//! Draupnir does not reimplement a runtime. This backend is the thin adapter that
//! maps a Draupnir [`BootSpec`] onto **`bollard`** — the async podman/Docker REST
//! client `jera` already drives for its zero-shell container path — and runs the
//! container lifecycle over it. Reusing bollard keeps one container engine across
//! the constellation rather than a second bespoke one.
//!
//! It sits behind the `backend-oci` feature so the default build stays pure-std;
//! the trait wiring compiles unconditionally. **Zero-shell**: every operation is a
//! Rust API call over the podman/Docker socket — never a `podman`/`docker`
//! subprocess. If no daemon is reachable the backend **degrades with a clear
//! error** (there is deliberately no CLI fallback), it never fakes a boot.

use crate::{Boot, BootSpec, Error, ImageSource, Lifecycle, Machine, PowerState, Result};

#[cfg(feature = "backend-oci")]
use std::sync::{Arc, Mutex};

/// The OCI container boot backend.
#[derive(Default, Clone)]
pub struct ContainerBoot {
    /// The connected engine (a bollard `Docker` + its own tokio runtime), built
    /// lazily on first use and shared across [`Boot`]/[`Lifecycle`] calls.
    #[cfg(feature = "backend-oci")]
    engine: Arc<Mutex<Option<Arc<Engine>>>>,
}

impl std::fmt::Debug for ContainerBoot {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ContainerBoot").finish_non_exhaustive()
    }
}

impl ContainerBoot {
    /// Construct the container backend.
    pub fn new() -> Self {
        Self::default()
    }

    /// The OCI image reference this spec will pull + run.
    pub fn image_ref<'a>(&self, spec: &'a BootSpec) -> Result<&'a str> {
        match &spec.image {
            ImageSource::OciImage(r) => Ok(r.as_str()),
            other => Err(Error::Spec(format!(
                "container backend needs an OCI image, got {other:?}"
            ))),
        }
    }

    /// The per-instance container name derived from the spec name.
    #[cfg_attr(not(feature = "backend-oci"), allow(dead_code))]
    fn container_name(spec: &BootSpec) -> String {
        format!("draupnir-{}", spec.name)
    }

    /// The connected engine, built (and cached) on first use. `Err` when no
    /// podman/Docker socket is reachable — the honest degrade, no shell fallback.
    #[cfg(feature = "backend-oci")]
    fn engine(&self) -> Result<Arc<Engine>> {
        let mut guard = self.engine.lock().unwrap();
        if let Some(e) = guard.as_ref() {
            return Ok(Arc::clone(e));
        }
        let e = Arc::new(Engine::connect()?);
        *guard = Some(Arc::clone(&e));
        Ok(e)
    }
}

impl Boot for ContainerBoot {
    fn boot(&self, spec: &BootSpec) -> Result<Machine> {
        spec.validate()?;
        let image = self.image_ref(spec)?;
        #[cfg(feature = "backend-oci")]
        {
            let name = Self::container_name(spec);
            let env: Vec<String> = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
            self.engine()?.create_and_start(image, &name, &env)?;
            Ok(Machine::started(name, spec))
        }
        #[cfg(not(feature = "backend-oci"))]
        {
            let _ = image;
            Err(Error::Unsupported(
                "container backend needs the `backend-oci` feature (drives the podman/Docker REST API via bollard)"
                    .into(),
            ))
        }
    }
}

impl Lifecycle for ContainerBoot {
    fn power_on(&self, machine: &Machine) -> Result<()> {
        #[cfg(feature = "backend-oci")]
        {
            self.engine()?.start(&machine.id)
        }
        #[cfg(not(feature = "backend-oci"))]
        {
            let _ = machine;
            Err(Error::Unsupported("container backend needs the `backend-oci` feature".into()))
        }
    }

    fn power_off(&self, machine: &Machine) -> Result<()> {
        #[cfg(feature = "backend-oci")]
        {
            self.engine()?.stop(&machine.id);
            Ok(())
        }
        #[cfg(not(feature = "backend-oci"))]
        {
            let _ = machine;
            Err(Error::Unsupported("container backend needs the `backend-oci` feature".into()))
        }
    }

    fn status(&self, machine: &Machine) -> Result<PowerState> {
        #[cfg(feature = "backend-oci")]
        {
            Ok(self.engine()?.power_state(&machine.id))
        }
        #[cfg(not(feature = "backend-oci"))]
        {
            let _ = machine;
            Err(Error::Unsupported("container backend needs the `backend-oci` feature".into()))
        }
    }
}

// ---------------------------------------------------------------------------
// Engine — the real podman/Docker REST engine (feature `backend-oci`).
// ---------------------------------------------------------------------------

/// The live bollard engine: a `Docker` handle + a dedicated tokio runtime that
/// drives its async API from draupnir's synchronous [`Boot`]/[`Lifecycle`] seam.
#[cfg(feature = "backend-oci")]
struct Engine {
    docker: bollard::Docker,
    rt: tokio::runtime::Runtime,
}

#[cfg(feature = "backend-oci")]
impl Engine {
    /// Resolve the podman/Docker API socket URL: honour `DOCKER_HOST`, else the
    /// rootless user socket under `XDG_RUNTIME_DIR` (parity with jera).
    fn socket_url() -> String {
        if let Ok(h) = std::env::var("DOCKER_HOST") {
            return h;
        }
        let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
        format!("unix://{xdg}/podman/podman.sock")
    }

    /// Connect over the API socket. Does NOT try to start a daemon (zero-shell) —
    /// returns a clear [`Error::Backend`] if the socket is absent/unreachable.
    fn connect() -> Result<Self> {
        let url = Self::socket_url();
        let path = url.strip_prefix("unix://").unwrap_or(&url);
        if url.starts_with("unix://") && !std::path::Path::new(path).exists() {
            return Err(Error::Backend(format!(
                "podman/Docker API socket not found at {path} (enable with \
                 `systemctl --user enable --now podman.socket`, or point DOCKER_HOST at a running socket)"
            )));
        }
        let docker = bollard::Docker::connect_with_unix(&url, 120, bollard::API_DEFAULT_VERSION)
            .map_err(|e| Error::Backend(format!("connect container socket {url}: {e}")))?;
        let rt = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .enable_all()
            .build()
            .map_err(|e| Error::Backend(format!("build tokio runtime for bollard: {e}")))?;
        Ok(Engine { docker, rt })
    }

    /// Pull `image` if not present, then create + start it as a detached container
    /// named `name` with `env` (`KEY=VALUE`).
    fn create_and_start(&self, image: &str, name: &str, env: &[String]) -> Result<()> {
        use bollard::models::ContainerCreateBody;
        use bollard::query_parameters::{
            CreateContainerOptionsBuilder, CreateImageOptionsBuilder, RemoveContainerOptionsBuilder,
            StartContainerOptions,
        };
        use futures::StreamExt;

        let docker = &self.docker;
        self.rt.block_on(async {
            // Drop any stale container of the same name (ignore "not found").
            let _ = docker
                .remove_container(name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
                .await;
            // Pull the image if it is not already local.
            if docker.inspect_image(image).await.is_err() {
                let (repo, tag) = image.rsplit_once(':').unwrap_or((image, "latest"));
                let opts = CreateImageOptionsBuilder::new().from_image(repo).tag(tag).build();
                let mut pull = docker.create_image(Some(opts), None, None);
                while let Some(item) = pull.next().await {
                    item.map_err(|e| Error::Backend(format!("pull image {image}: {e}")))?;
                }
            }
            let body = ContainerCreateBody {
                image: Some(image.to_string()),
                env: if env.is_empty() { None } else { Some(env.to_vec()) },
                ..Default::default()
            };
            docker
                .create_container(Some(CreateContainerOptionsBuilder::new().name(name).build()), body)
                .await
                .map_err(|e| Error::Backend(format!("create container {name}: {e}")))?;
            docker
                .start_container(name, None::<StartContainerOptions>)
                .await
                .map_err(|e| Error::Backend(format!("start container {name}: {e}")))?;
            Ok::<(), Error>(())
        })
    }

    /// Start a previously-created (stopped) container.
    fn start(&self, name: &str) -> Result<()> {
        use bollard::query_parameters::StartContainerOptions;
        self.rt.block_on(async {
            self.docker
                .start_container(name, None::<StartContainerOptions>)
                .await
                .map_err(|e| Error::Backend(format!("start container {name}: {e}")))
        })
    }

    /// Stop + remove the container (idempotent, best-effort).
    fn stop(&self, name: &str) {
        use bollard::query_parameters::{RemoveContainerOptionsBuilder, StopContainerOptions};
        self.rt.block_on(async {
            let _ = self
                .docker
                .stop_container(name, None::<StopContainerOptions>)
                .await;
            let _ = self
                .docker
                .remove_container(name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
                .await;
        });
    }

    /// The container's power state: `On` while running, else `Off` (a gone/unknown
    /// container reads `Off`).
    fn power_state(&self, name: &str) -> PowerState {
        use bollard::query_parameters::InspectContainerOptions;
        self.rt.block_on(async {
            match self
                .docker
                .inspect_container(name, None::<InspectContainerOptions>)
                .await
            {
                Ok(info) => {
                    let running = info.state.as_ref().and_then(|s| s.running).unwrap_or(false);
                    if running {
                        PowerState::On
                    } else {
                        PowerState::Off
                    }
                }
                Err(_) => PowerState::Off,
            }
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn image_ref_extracts_the_oci_reference() {
        let spec = BootSpec::container("cache", "docker.io/library/redis:7");
        assert_eq!(ContainerBoot::new().image_ref(&spec).unwrap(), "docker.io/library/redis:7");
    }

    #[test]
    fn image_ref_rejects_a_non_oci_image() {
        let mut spec = BootSpec::container("bad", "redis:7");
        spec.image = ImageSource::Iso("/boot.iso".into());
        assert!(matches!(ContainerBoot::new().image_ref(&spec), Err(Error::Spec(_))));
    }

    #[test]
    fn container_name_is_derived_from_the_spec_name() {
        let spec = BootSpec::container("cache", "redis:7");
        assert_eq!(ContainerBoot::container_name(&spec), "draupnir-cache");
    }
}