Gunnar

mikronova/draupnir

src/youki.rs

//! **The daemon-less OCI runtime backend — youki-core (`libcontainer`).**
//!
//! Where [`crate::container`] drives a container over the podman/Docker REST socket
//! (`bollard` → a *running daemon*), this backend runs a container **directly**: it
//! links youki's **`libcontainer`** crate — the pure-Rust low-level OCI runtime, a
//! `runc` alternative — and creates the Linux namespaces + cgroups-v2 and execs the
//! entrypoint *in-process*. **No daemon. No CLI.** This is the in-appliance engine
//! from Gemini's revised korp design: korp-server (static PID-1) links this and, at
//! boot, unpacks an OCI image's layers into a bundle rootfs, generates a
//! `config.json`, and drives the create/start/kill/delete lifecycle over cgroups-v2.
//!
//! **Zero-shell holds by construction.** The only place a runc-style runtime would
//! normally shell out is the rootless uid/gid map: youki execs `newuidmap`/`newgidmap`
//! *only* for a multi-line / non-self mapping. This backend authors a **single-line**
//! map that sends container-root straight to the caller's own uid
//! ([`Linux::rootless`]), which youki writes directly to `/proc/self/{uid,gid}_map` —
//! no helper binary. And any boot-time shell-out from inside a `scratch` bundle is an
//! instant ENOENT (there is no `/bin/sh`), the distroless correctness proof.
//!
//! Rootless caveat, stated honestly: cgroup delegation for a rootless container comes
//! from the caller's **systemd user session** — libcontainer forces the systemd cgroup
//! driver whenever a user namespace is present, and creates a transient scope under
//! `user@UID.service` over the **session D-Bus** (a pure-Rust dbus client, still no
//! CLI). So a rootless youki run needs: a kernel with unprivileged user namespaces, a
//! cgroups-v2 hierarchy with a delegated `user@UID.service` subtree, and `subuid`/`subgid`
//! entries. On a box without those, [`YoukiRuntime::create`] fails with a clear error
//! and the caller (test) loud-skips — it never fakes a start.

use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use crate::{Error, Result};

use libcontainer::container::builder::ContainerBuilder;
use libcontainer::container::{Container, ContainerStatus};
use libcontainer::oci_spec::runtime::{Linux, ProcessBuilder, RootBuilder, Spec};
use libcontainer::signal::Signal;
use libcontainer::syscall::syscall::SyscallType;

/// The lifecycle state of a youki-run container, projected off libcontainer's
/// [`ContainerStatus`] into the same shape draupnir's other backends speak.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum YoukiState {
    /// Created but not yet started (init process parked at the start barrier).
    Created,
    /// The entrypoint is running.
    Running,
    /// The container has exited / been stopped.
    Stopped,
    /// Paused (cgroup-frozen) — surfaced for completeness; this backend does not pause.
    Paused,
    /// Transient creating state.
    Creating,
}

impl From<ContainerStatus> for YoukiState {
    fn from(s: ContainerStatus) -> Self {
        match s {
            ContainerStatus::Creating => YoukiState::Creating,
            ContainerStatus::Created => YoukiState::Created,
            ContainerStatus::Running => YoukiState::Running,
            ContainerStatus::Stopped => YoukiState::Stopped,
            ContainerStatus::Paused => YoukiState::Paused,
        }
    }
}

/// A host **bind mount** into the container (`source` on the host → `destination`
/// inside the rootfs). The daemon-less analogue of a `-v host:container` — used, among
/// other things, to hand the container a directory it drops a **readiness sentinel**
/// into, which the host reads back (the youki equivalent of the korp-kvm `BOOT-OK`).
#[derive(Debug, Clone)]
pub struct BindMount {
    /// Absolute host path to expose.
    pub source: PathBuf,
    /// Absolute path the mount appears at inside the container rootfs.
    pub destination: String,
    /// `true` → mount read-only; `false` → read-write.
    pub read_only: bool,
}

/// A minimal container definition this backend turns into an OCI **bundle**
/// (`rootfs/` + `config.json`) and runs. Deliberately small — the fields youki needs
/// to create namespaces and exec an entrypoint rootless — not a re-modelling of the
/// whole OCI runtime spec.
#[derive(Debug, Clone)]
pub struct YoukiSpec {
    /// The entrypoint argv, absolute-path first element (`["/bin/busybox","sh","-c",…]`).
    pub args: Vec<String>,
    /// Process environment as `KEY=VALUE`.
    pub env: Vec<String>,
    /// Working directory inside the container (defaults to `/`).
    pub cwd: String,
    /// Extra host bind mounts (e.g. a sentinel/readiness dir).
    pub binds: Vec<BindMount>,
}

impl YoukiSpec {
    /// A spec that runs `args` with a default `/`-cwd, a sane `PATH`, and no binds.
    pub fn new(args: impl IntoIterator<Item = impl Into<String>>) -> Self {
        Self {
            args: args.into_iter().map(Into::into).collect(),
            env: vec!["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()],
            cwd: "/".into(),
            binds: Vec::new(),
        }
    }

    /// Attach a host bind mount (builder style).
    pub fn with_bind(
        mut self,
        source: impl Into<PathBuf>,
        destination: impl Into<String>,
        read_only: bool,
    ) -> Self {
        self.binds.push(BindMount {
            source: source.into(),
            destination: destination.into(),
            read_only,
        });
        self
    }
}

/// The daemon-less OCI runtime. Holds the youki **state root** (where libcontainer
/// keeps each container's `state.json` + notify socket) — one per process is fine.
#[derive(Debug, Clone)]
pub struct YoukiRuntime {
    root: PathBuf,
}

impl YoukiRuntime {
    /// A runtime rooted at `root` (created on demand). Each container's state lands in
    /// `root/<id>/`.
    pub fn with_root(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    /// A runtime rooted under `$XDG_RUNTIME_DIR/draupnir-youki` (the natural rootless
    /// home — a tmpfs the caller owns), falling back to `/tmp` when unset.
    pub fn new() -> Self {
        let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
        Self::with_root(PathBuf::from(base).join("draupnir-youki"))
    }

    /// The caller's effective uid — the host side of the single-line rootless id-map
    /// (container-root → this uid, so youki writes `uid_map` directly, no `newuidmap`).
    fn euid() -> u32 {
        rustix::process::geteuid().as_raw()
    }

    /// The caller's effective gid — the host side of the single-line rootless gid-map.
    fn egid() -> u32 {
        rustix::process::getegid().as_raw()
    }

    /// **Unpack an OCI image's layer tarballs into a bundle rootfs** — the
    /// image → bundle step of the daemon-less path. Each entry in `layers` is a
    /// layer blob (`tar`, optionally gzip-compressed — auto-detected by the gzip magic)
    /// applied in order over `rootfs`, exactly as an OCI image is assembled. Pure-Rust
    /// (`tar` + `flate2`); no `podman pull`/`umoci`/`skopeo`. (OCI whiteouts are not yet
    /// honoured — additive layers only; enough for a scratch/single-layer base.)
    pub fn unpack_oci_layers(rootfs: &Path, layers: &[PathBuf]) -> Result<()> {
        std::fs::create_dir_all(rootfs)
            .map_err(|e| Error::Backend(format!("create rootfs {}: {e}", rootfs.display())))?;
        for layer in layers {
            let bytes = std::fs::read(layer)
                .map_err(|e| Error::Backend(format!("read layer {}: {e}", layer.display())))?;
            // gzip magic `1f 8b` → decompress; else treat as a plain tar.
            let is_gzip = bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b;
            let unpack = |reader: Box<dyn std::io::Read>| -> Result<()> {
                let mut ar = tar::Archive::new(reader);
                ar.set_preserve_permissions(true);
                ar.unpack(rootfs).map_err(|e| {
                    Error::Backend(format!("unpack layer {} into rootfs: {e}", layer.display()))
                })
            };
            if is_gzip {
                unpack(Box::new(flate2::read::GzDecoder::new(
                    std::io::Cursor::new(bytes),
                )))?;
            } else {
                unpack(Box::new(std::io::Cursor::new(bytes)))?;
            }
        }
        Ok(())
    }

    /// **Author the bundle's `config.json`** for a **rootless** run of `spec` over the
    /// rootfs at `bundle/rootfs`. Builds an OCI runtime [`Spec`] with the default mount
    /// set (proc/dev/sys/…), the process argv/env/cwd, any host bind mounts, and the
    /// single-line rootless uid/gid map + a fresh user namespace ([`Linux::rootless`]).
    /// Writes it to `bundle/config.json`. Pure — no privilege, no runtime — so the
    /// bundle can be inspected/tested without starting anything.
    pub fn write_bundle_config(bundle: &Path, spec: &YoukiSpec) -> Result<()> {
        use libcontainer::oci_spec::runtime::{Mount, MountBuilder};

        if spec.args.is_empty() {
            return Err(Error::Spec("youki spec needs a non-empty argv".into()));
        }

        let mut oci = Spec::default();

        let process = ProcessBuilder::default()
            .args(spec.args.clone())
            .env(spec.env.clone())
            .cwd(spec.cwd.clone())
            .build()
            .map_err(|e| Error::Spec(format!("build process spec: {e}")))?;

        let root = RootBuilder::default()
            .path("rootfs")
            .readonly(false)
            .build()
            .map_err(|e| Error::Spec(format!("build root spec: {e}")))?;

        // Rootless linux config: single-line id maps (container-root → caller uid/gid,
        // size 1) + a fresh user namespace, network namespace dropped (share the host
        // net so no bridge/netns setup is needed). This is the zero-shell map youki
        // writes directly to /proc/self/{uid,gid}_map.
        let linux = Linux::rootless(Self::euid(), Self::egid());

        // Sanitize the default mount set for a **single-line rootless** map: the OCI
        // default `/dev/pts` mount carries `gid=5` (the host `tty` group) and `mode=…`
        // options, but only container-uid/gid 0 is mapped here, so libcontainer rejects
        // any `uid=`/`gid=` option naming an id that isn't in the map ("invalid spec for
        // new user namespace container"). Drop exactly those unmapped `uid=`/`gid=`
        // options (id != 0) — the same adjustment youki's own rootless spec makes —
        // leaving every other mount and option byte-identical.
        let mut mounts: Vec<Mount> = oci.mounts().clone().unwrap_or_default();
        for m in &mut mounts {
            if let Some(opts) = m.options().clone() {
                let kept: Vec<String> = opts
                    .into_iter()
                    .filter(|opt| {
                        let unmapped_id =
                            |v: &str| v.parse::<u32>().map(|n| n != 0).unwrap_or(false);
                        if let Some(v) = opt.strip_prefix("uid=") {
                            return !unmapped_id(v);
                        }
                        if let Some(v) = opt.strip_prefix("gid=") {
                            return !unmapped_id(v);
                        }
                        true
                    })
                    .collect();
                m.set_options(Some(kept));
            }
        }

        // Append the caller's bind mounts onto the default mount set.
        for b in &spec.binds {
            let opts = if b.read_only {
                vec!["rbind".to_string(), "ro".to_string()]
            } else {
                vec!["rbind".to_string(), "rw".to_string()]
            };
            let m = MountBuilder::default()
                .destination(PathBuf::from(&b.destination))
                .typ("bind")
                .source(b.source.clone())
                .options(opts)
                .build()
                .map_err(|e| Error::Spec(format!("build bind mount {}: {e}", b.destination)))?;
            mounts.push(m);
        }

        oci.set_process(Some(process))
            .set_root(Some(root))
            .set_linux(Some(linux))
            .set_hostname(Some("draupnir-youki".to_string()))
            .set_mounts(Some(mounts));

        std::fs::create_dir_all(bundle)
            .map_err(|e| Error::Backend(format!("create bundle {}: {e}", bundle.display())))?;
        oci.save(bundle.join("config.json")).map_err(|e| {
            Error::Backend(format!("write config.json into {}: {e}", bundle.display()))
        })?;
        Ok(())
    }

    /// **Create the container** from an already-authored `bundle` (`rootfs/` +
    /// `config.json`) under id `id`, leaving it in the *created* state (init process
    /// parked at the start barrier). This is where the namespaces + cgroups are set up;
    /// a missing kernel capability (no unprivileged userns, no cgroup delegation) fails
    /// here with a clear [`Error::Backend`] — never a fake create.
    pub fn create(&self, id: &str, bundle: &Path) -> Result<Container> {
        std::fs::create_dir_all(&self.root).map_err(|e| {
            Error::Backend(format!(
                "create youki state root {}: {e}",
                self.root.display()
            ))
        })?;
        // **Idempotent boot** (owner): force-remove any stale container of the same id
        // BEFORE creating a fresh one — a crashed/leftover prior run leaves a state dir
        // that makes `build()` fail with "container already exists", the daemon-less youki
        // twin of the OCI engine's rm-f-before-create. A still-running init is killed +
        // reaped, then the state dir is swept. Best-effort: never blocks a fresh create.
        self.force_cleanup(id);
        ContainerBuilder::new(id.to_string(), SyscallType::default())
            .with_root_path(self.root.clone())
            .map_err(|e| Error::Backend(format!("youki root path {}: {e}", self.root.display())))?
            .as_init(bundle)
            .with_systemd(false)
            .with_detach(true)
            .build()
            .map_err(|e| Error::Backend(format!("youki create `{id}`: {e}")))
    }

    /// Force-remove any stale container of `id` in the state root — the idempotent-boot
    /// sweep [`create`](Self::create) runs first. Loads the existing container (if any) and
    /// `delete(force=true)`s it (kills a still-running init + reaps its state), then removes
    /// the leftover state dir as a belt-and-braces. Every step is ignore-on-error so a
    /// fresh create is never blocked by teardown trouble (matches the OCI engine, which
    /// `remove_container(force)`s and ignores "not found").
    fn force_cleanup(&self, id: &str) {
        let dir = self.root.join(id);
        if !dir.exists() {
            return;
        }
        if let Ok(mut existing) = Container::load(dir.clone()) {
            let _ = existing.delete(true);
        }
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// **Start** a created container — release the init barrier so the entrypoint execs.
    pub fn start(container: &mut Container) -> Result<()> {
        container
            .start()
            .map_err(|e| Error::Backend(format!("youki start `{}`: {e}", container.id())))
    }

    /// The container's current [`YoukiState`] (refreshes libcontainer's cached status).
    pub fn state(container: &mut Container) -> YoukiState {
        let _ = container.refresh_status();
        container.status().into()
    }

    /// **Kill** the container's init process with `signal` (e.g. `SIGKILL`/`SIGTERM`).
    /// A no-op-ish `Ok(())` when it is already stopped.
    pub fn kill(container: &mut Container, signal: Signal) -> Result<()> {
        container
            .kill(signal, true)
            .map_err(|e| Error::Backend(format!("youki kill `{}`: {e}", container.id())))
    }

    /// **Delete** the container, force-removing it (kills a still-running init first)
    /// and reaping its state dir. Idempotent-ish: best-effort teardown.
    pub fn delete(container: &mut Container) -> Result<()> {
        container
            .delete(true)
            .map_err(|e| Error::Backend(format!("youki delete `{}`: {e}", container.id())))
    }

    /// **Create + start + wait-for-readiness + tear down — the youki `BOOT-OK` e2e in
    /// one call.** Authors nothing (the caller owns the bundle); it drives the real
    /// lifecycle: [`create`](Self::create) → [`start`](Self::start) → poll `ready` until
    /// it returns `true` or `timeout` elapses → [`delete`](Self::delete). `ready` is the
    /// host-side readiness probe (e.g. "the sentinel file the container wrote now says
    /// `CONTAINER-READY`" or "the TCP port is open") — the container equivalent of
    /// reading `KORP-APPLIANCE BOOT-OK` off a serial console. Returns the observed state
    /// at the moment readiness held (or the last state before timeout).
    ///
    /// Always tears the container down before returning (even on a readiness timeout),
    /// so a youki proof leaves nothing behind. A create/start failure surfaces as `Err`.
    pub fn run_until_ready(
        &self,
        id: &str,
        bundle: &Path,
        timeout: Duration,
        mut ready: impl FnMut() -> bool,
    ) -> Result<YoukiState> {
        let mut container = self.create(id, bundle)?;
        if let Err(e) = Self::start(&mut container) {
            let _ = Self::delete(&mut container);
            return Err(e);
        }

        let deadline = Instant::now() + timeout;
        let mut last;
        let mut became_ready;
        loop {
            became_ready = ready();
            last = Self::state(&mut container);
            // A short-lived entrypoint may already have exited cleanly; if it wrote its
            // readiness signal before exiting, the `ready()` check above still caught it.
            if became_ready || Instant::now() >= deadline {
                break;
            }
            std::thread::sleep(Duration::from_millis(100));
        }

        let _ = Self::delete(&mut container);

        crate::functional_status(
            "draupnir/youki",
            "run_until_ready",
            became_ready,
            &if became_ready {
                format!("youki container `{id}` reached readiness (daemon-less, rootless)")
            } else {
                format!("youki container `{id}` never signalled readiness within {timeout:?}")
            },
        );

        if became_ready {
            Ok(last)
        } else {
            Err(Error::Backend(format!(
                "youki container `{id}` did not signal readiness within {timeout:?} (last state {last:?})"
            )))
        }
    }
}

impl Default for YoukiRuntime {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn spec_new_defaults_are_sane() {
        let s = YoukiSpec::new(["/bin/busybox", "true"]);
        assert_eq!(s.args, vec!["/bin/busybox".to_string(), "true".to_string()]);
        assert_eq!(s.cwd, "/");
        assert!(s.env.iter().any(|e| e.starts_with("PATH=")));
        assert!(s.binds.is_empty());
    }

    #[test]
    fn empty_argv_is_rejected_before_any_bundle_write() {
        let dir = std::env::temp_dir().join(format!("youki-cfg-test-{}", std::process::id()));
        let spec = YoukiSpec {
            args: vec![],
            env: vec![],
            cwd: "/".into(),
            binds: vec![],
        };
        let err = YoukiRuntime::write_bundle_config(&dir, &spec).unwrap_err();
        assert!(matches!(err, Error::Spec(_)));
    }

    #[test]
    fn write_bundle_config_emits_a_rootless_config_json() {
        // Pure bundle authoring — no container is created, so this runs anywhere.
        let bundle = std::env::temp_dir().join(format!("youki-bundle-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&bundle);
        let spec = YoukiSpec::new(["/bin/busybox", "sh", "-c", "true"])
            .with_bind("/tmp", "/signal", false);
        YoukiRuntime::write_bundle_config(&bundle, &spec).expect("author config.json");

        let text = std::fs::read_to_string(bundle.join("config.json")).expect("config.json exists");
        // The rootless proofs: a user namespace + a uid map back to our own uid, and the
        // bind-mounted signal dir. (String-level assertions keep this dep-light.)
        assert!(
            text.contains("\"user\""),
            "config.json declares a user namespace"
        );
        assert!(
            text.contains("uidMappings"),
            "config.json carries a rootless uid map"
        );
        assert!(
            text.contains("/signal"),
            "config.json carries the bind-mounted signal dir"
        );
        let _ = std::fs::remove_dir_all(&bundle);
    }

    #[test]
    fn force_cleanup_sweeps_a_stale_state_dir_so_a_fresh_create_is_idempotent() {
        // Daemon-free: a leftover state dir (a crashed prior run) must be swept before a
        // fresh create — otherwise libcontainer's `build()` fails with "already exists".
        // Container::load on a bogus dir returns Err; the belt-and-braces remove_dir_all is
        // what actually reaps it → this goes RED if that removal is reverted.
        let root = std::env::temp_dir().join(format!("youki-idem-root-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        let rt = YoukiRuntime::with_root(root.clone());
        let id = "korp-falkordb";
        let stale = root.join(id);
        std::fs::create_dir_all(&stale).unwrap();
        std::fs::write(stale.join("state.json"), b"{stale}").unwrap();
        assert!(stale.exists(), "the stale state dir exists before cleanup");

        rt.force_cleanup(id);
        assert!(
            !stale.exists(),
            "force_cleanup swept the stale state dir (idempotent boot)"
        );
        // Idempotent on a clean root too — a no-op, never an error.
        rt.force_cleanup(id);
        let _ = std::fs::remove_dir_all(&root);
    }
}