draupnir 0.1.9

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! **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** (Rickard): 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);
    }
}