cellos-host-gvisor 0.5.0

gVisor runsc backend for CellOS — runs cells in user-space syscall-emulated sandboxes for environments without KVM.
Documentation
//! Pure bundle-generation logic for the gVisor backend.
//!
//! Translates an [`ExecutionCellDocument`] into the JSON shape `runsc` reads
//! when invoked as `runsc run --bundle <dir> <cell-id>`. Kept platform-
//! independent (no `unix`-only types, no `runsc` shell-out) so it can be
//! exercised by unit tests on macOS dev hosts.
//!
//! The resulting [`BundleConfig`] is a *subset* of the OCI runtime spec —
//! only the fields that `runsc` actually consumes for our use case
//! (`process.args`, `process.cwd`, `root.path`, `linux.namespaces`). We do
//! not attempt to be a general-purpose OCI generator; a runtime spec
//! upgrade is owned by the L2-06 follow-up that wires real `runsc`
//! invocation.

use cellos_core::ExecutionCellDocument;
use serde::{Deserialize, Serialize};

/// Generator error. Kept simple and self-describing; surfaced verbatim
/// in the supervisor's `CellosError::Backend` wrapping at the call site
/// (`backend::GVisorCellBackend::create`).
///
/// We hand-roll `Display` / `Error` (no `thiserror` dep) to keep the
/// gVisor skeleton crate's transitive dependency surface minimal — the
/// backend has only two failure modes at the bundle layer, both static.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BundleConfigError {
    MissingCellId,
    MissingArgv,
}

impl std::fmt::Display for BundleConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::MissingCellId => {
                f.write_str("spec.id must be non-empty for gVisor bundle generation")
            }
            Self::MissingArgv => {
                f.write_str("spec.run.argv must be non-empty for gVisor bundle generation")
            }
        }
    }
}

impl std::error::Error for BundleConfigError {}

/// Minimal OCI bundle config — what we hand to `runsc` via `config.json`.
///
/// This intentionally mirrors only the keys our `runsc run` invocation
/// reads. We never round-trip back through this struct after generation;
/// `runsc` is the authoritative consumer.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BundleConfig {
    #[serde(rename = "ociVersion")]
    pub oci_version: String,
    pub process: Process,
    pub root: Root,
    pub hostname: String,
    pub linux: LinuxSection,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Process {
    pub terminal: bool,
    pub args: Vec<String>,
    pub cwd: String,
    pub env: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Root {
    /// Relative path to the bundle's `rootfs/` directory. `runsc` resolves
    /// this against the bundle dir passed via `--bundle`.
    pub path: String,
    pub readonly: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinuxSection {
    /// Which Linux namespaces `runsc` should unshare for this cell. We
    /// always request the full set — gVisor's threat model assumes them.
    pub namespaces: Vec<Namespace>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Namespace {
    #[serde(rename = "type")]
    pub kind: String,
}

/// Translate an [`ExecutionCellDocument`] into a [`BundleConfig`].
///
/// Required:
/// - `spec.id` non-empty (becomes the container id `runsc` tracks),
/// - `spec.run.argv` non-empty (becomes `process.args`).
///
/// Optional / defaulted:
/// - `spec.run.working_directory` → `process.cwd` (defaults to `/`),
/// - hostname is derived from `spec.id` so guest output is identifiable.
///
/// Network namespace is always declared so the cell starts off the host
/// network; the supervisor's nftables layer (when present) attaches to
/// the unshared netns separately.
pub fn generate_bundle_config(
    spec: &ExecutionCellDocument,
) -> Result<BundleConfig, BundleConfigError> {
    let cell_id = spec.spec.id.trim();
    if cell_id.is_empty() {
        return Err(BundleConfigError::MissingCellId);
    }

    let run = spec
        .spec
        .run
        .as_ref()
        .ok_or(BundleConfigError::MissingArgv)?;

    if run.argv.is_empty() {
        return Err(BundleConfigError::MissingArgv);
    }

    let cwd = run
        .working_directory
        .clone()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "/".to_string());

    Ok(BundleConfig {
        oci_version: "1.0.2".to_string(),
        process: Process {
            terminal: false,
            args: run.argv.clone(),
            cwd,
            // Empty by design — secrets and env are layered by the
            // supervisor's broker plumbing, not by the bundle generator.
            env: Vec::new(),
        },
        root: Root {
            path: "rootfs".to_string(),
            readonly: true,
        },
        hostname: cell_id.to_string(),
        linux: LinuxSection {
            namespaces: vec![
                Namespace {
                    kind: "pid".to_string(),
                },
                Namespace {
                    kind: "network".to_string(),
                },
                Namespace {
                    kind: "ipc".to_string(),
                },
                Namespace {
                    kind: "uts".to_string(),
                },
                Namespace {
                    kind: "mount".to_string(),
                },
            ],
        },
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use cellos_core::types::{AuthorityBundle, ExecutionCellSpec, Lifetime, RunSpec};
    use cellos_core::ExecutionCellDocument;

    fn doc_with(id: &str, argv: Vec<String>, cwd: Option<String>) -> ExecutionCellDocument {
        let spec = ExecutionCellSpec {
            id: id.to_string(),
            authority: AuthorityBundle::default(),
            lifetime: Lifetime::default(),
            run: Some(RunSpec {
                argv,
                working_directory: cwd,
                timeout_ms: None,
                limits: None,
                secret_delivery: Default::default(),
            }),
            ..ExecutionCellSpec::default()
        };
        ExecutionCellDocument {
            api_version: "cellos.dev/v1".to_string(),
            kind: "ExecutionCell".to_string(),
            spec,
        }
    }

    #[test]
    fn happy_path_populates_args_cwd_hostname() {
        let doc = doc_with(
            "cell-alpha",
            vec!["/bin/true".to_string()],
            Some("/work".to_string()),
        );
        let cfg = generate_bundle_config(&doc).expect("bundle generation succeeds");

        assert_eq!(cfg.process.args, vec!["/bin/true"]);
        assert_eq!(cfg.process.cwd, "/work");
        assert_eq!(cfg.hostname, "cell-alpha");
        assert!(cfg.root.readonly);
        assert_eq!(cfg.root.path, "rootfs");
    }

    #[test]
    fn cwd_defaults_to_root_when_absent() {
        let doc = doc_with("cell-beta", vec!["/bin/sh".to_string()], None);
        let cfg = generate_bundle_config(&doc).unwrap();
        assert_eq!(cfg.process.cwd, "/");
    }

    #[test]
    fn cwd_defaults_to_root_when_empty_string() {
        let doc = doc_with(
            "cell-gamma",
            vec!["/bin/sh".to_string()],
            Some(String::new()),
        );
        let cfg = generate_bundle_config(&doc).unwrap();
        assert_eq!(cfg.process.cwd, "/");
    }

    #[test]
    fn empty_cell_id_is_rejected() {
        let doc = doc_with("", vec!["/bin/true".to_string()], None);
        let err = generate_bundle_config(&doc).unwrap_err();
        assert!(matches!(err, BundleConfigError::MissingCellId));
    }

    #[test]
    fn whitespace_only_cell_id_is_rejected() {
        let doc = doc_with("   ", vec!["/bin/true".to_string()], None);
        let err = generate_bundle_config(&doc).unwrap_err();
        assert!(matches!(err, BundleConfigError::MissingCellId));
    }

    #[test]
    fn missing_run_is_rejected() {
        let spec = ExecutionCellSpec {
            id: "cell".to_string(),
            authority: AuthorityBundle::default(),
            lifetime: Lifetime::default(),
            run: None,
            ..ExecutionCellSpec::default()
        };
        let doc = ExecutionCellDocument {
            api_version: "cellos.dev/v1".to_string(),
            kind: "ExecutionCell".to_string(),
            spec,
        };
        let err = generate_bundle_config(&doc).unwrap_err();
        assert!(matches!(err, BundleConfigError::MissingArgv));
    }

    #[test]
    fn empty_argv_is_rejected() {
        let doc = doc_with("cell-delta", vec![], None);
        let err = generate_bundle_config(&doc).unwrap_err();
        assert!(matches!(err, BundleConfigError::MissingArgv));
    }

    #[test]
    fn namespaces_include_expected_set() {
        let doc = doc_with("cell-eps", vec!["/bin/true".to_string()], None);
        let cfg = generate_bundle_config(&doc).unwrap();
        let kinds: Vec<&str> = cfg
            .linux
            .namespaces
            .iter()
            .map(|n| n.kind.as_str())
            .collect();
        for required in &["pid", "network", "ipc", "uts", "mount"] {
            assert!(
                kinds.contains(required),
                "expected namespace {required} in {kinds:?}",
            );
        }
    }

    #[test]
    fn config_serializes_to_json_with_oci_keys() {
        let doc = doc_with(
            "cell-json",
            vec!["/bin/echo".to_string(), "hi".to_string()],
            None,
        );
        let cfg = generate_bundle_config(&doc).unwrap();
        let json = serde_json::to_value(&cfg).unwrap();
        // OCI runtime spec expects camelCase / specific key names — pin them.
        assert!(
            json.get("ociVersion").is_some(),
            "ociVersion missing: {json}"
        );
        assert!(json.get("process").is_some());
        assert!(json.get("root").is_some());
        assert_eq!(
            json["linux"]["namespaces"][0]["type"], "pid",
            "namespaces must use OCI 'type' key, got: {json}",
        );
    }

    #[test]
    fn multi_arg_argv_round_trips() {
        let doc = doc_with(
            "cell-multi",
            vec![
                "/usr/bin/env".to_string(),
                "PATH=/bin".to_string(),
                "sh".to_string(),
            ],
            None,
        );
        let cfg = generate_bundle_config(&doc).unwrap();
        assert_eq!(cfg.process.args, vec!["/usr/bin/env", "PATH=/bin", "sh"],);
    }
}