Skip to main content

cellos_host_gvisor/
bundle.rs

1//! Pure bundle-generation logic for the gVisor backend.
2//!
3//! Translates an [`ExecutionCellDocument`] into the JSON shape `runsc` reads
4//! when invoked as `runsc run --bundle <dir> <cell-id>`. Kept platform-
5//! independent (no `unix`-only types, no `runsc` shell-out) so it can be
6//! exercised by unit tests on macOS dev hosts.
7//!
8//! The resulting [`BundleConfig`] is a *subset* of the OCI runtime spec —
9//! only the fields that `runsc` actually consumes for our use case
10//! (`process.args`, `process.cwd`, `root.path`, `linux.namespaces`). We do
11//! not attempt to be a general-purpose OCI generator; a runtime spec
12//! upgrade is owned by the L2-06 follow-up that wires real `runsc`
13//! invocation.
14
15use cellos_core::ExecutionCellDocument;
16use serde::{Deserialize, Serialize};
17
18/// Generator error. Kept simple and self-describing; surfaced verbatim
19/// in the supervisor's `CellosError::Backend` wrapping at the call site
20/// (`backend::GVisorCellBackend::create`).
21///
22/// We hand-roll `Display` / `Error` (no `thiserror` dep) to keep the
23/// gVisor skeleton crate's transitive dependency surface minimal — the
24/// backend has only two failure modes at the bundle layer, both static.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum BundleConfigError {
27    MissingCellId,
28    MissingArgv,
29}
30
31impl std::fmt::Display for BundleConfigError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::MissingCellId => {
35                f.write_str("spec.id must be non-empty for gVisor bundle generation")
36            }
37            Self::MissingArgv => {
38                f.write_str("spec.run.argv must be non-empty for gVisor bundle generation")
39            }
40        }
41    }
42}
43
44impl std::error::Error for BundleConfigError {}
45
46/// Minimal OCI bundle config — what we hand to `runsc` via `config.json`.
47///
48/// This intentionally mirrors only the keys our `runsc run` invocation
49/// reads. We never round-trip back through this struct after generation;
50/// `runsc` is the authoritative consumer.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub struct BundleConfig {
53    #[serde(rename = "ociVersion")]
54    pub oci_version: String,
55    pub process: Process,
56    pub root: Root,
57    pub hostname: String,
58    pub linux: LinuxSection,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct Process {
63    pub terminal: bool,
64    pub args: Vec<String>,
65    pub cwd: String,
66    pub env: Vec<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct Root {
71    /// Relative path to the bundle's `rootfs/` directory. `runsc` resolves
72    /// this against the bundle dir passed via `--bundle`.
73    pub path: String,
74    pub readonly: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct LinuxSection {
79    /// Which Linux namespaces `runsc` should unshare for this cell. We
80    /// always request the full set — gVisor's threat model assumes them.
81    pub namespaces: Vec<Namespace>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct Namespace {
86    #[serde(rename = "type")]
87    pub kind: String,
88}
89
90/// Translate an [`ExecutionCellDocument`] into a [`BundleConfig`].
91///
92/// Required:
93/// - `spec.id` non-empty (becomes the container id `runsc` tracks),
94/// - `spec.run.argv` non-empty (becomes `process.args`).
95///
96/// Optional / defaulted:
97/// - `spec.run.working_directory` → `process.cwd` (defaults to `/`),
98/// - hostname is derived from `spec.id` so guest output is identifiable.
99///
100/// Network namespace is always declared so the cell starts off the host
101/// network; the supervisor's nftables layer (when present) attaches to
102/// the unshared netns separately.
103pub fn generate_bundle_config(
104    spec: &ExecutionCellDocument,
105) -> Result<BundleConfig, BundleConfigError> {
106    let cell_id = spec.spec.id.trim();
107    if cell_id.is_empty() {
108        return Err(BundleConfigError::MissingCellId);
109    }
110
111    let run = spec
112        .spec
113        .run
114        .as_ref()
115        .ok_or(BundleConfigError::MissingArgv)?;
116
117    if run.argv.is_empty() {
118        return Err(BundleConfigError::MissingArgv);
119    }
120
121    let cwd = run
122        .working_directory
123        .clone()
124        .filter(|s| !s.is_empty())
125        .unwrap_or_else(|| "/".to_string());
126
127    Ok(BundleConfig {
128        oci_version: "1.0.2".to_string(),
129        process: Process {
130            terminal: false,
131            args: run.argv.clone(),
132            cwd,
133            // Empty by design — secrets and env are layered by the
134            // supervisor's broker plumbing, not by the bundle generator.
135            env: Vec::new(),
136        },
137        root: Root {
138            path: "rootfs".to_string(),
139            readonly: true,
140        },
141        hostname: cell_id.to_string(),
142        linux: LinuxSection {
143            namespaces: vec![
144                Namespace {
145                    kind: "pid".to_string(),
146                },
147                Namespace {
148                    kind: "network".to_string(),
149                },
150                Namespace {
151                    kind: "ipc".to_string(),
152                },
153                Namespace {
154                    kind: "uts".to_string(),
155                },
156                Namespace {
157                    kind: "mount".to_string(),
158                },
159            ],
160        },
161    })
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use cellos_core::types::{AuthorityBundle, ExecutionCellSpec, Lifetime, RunSpec};
168    use cellos_core::ExecutionCellDocument;
169
170    fn doc_with(id: &str, argv: Vec<String>, cwd: Option<String>) -> ExecutionCellDocument {
171        let spec = ExecutionCellSpec {
172            id: id.to_string(),
173            authority: AuthorityBundle::default(),
174            lifetime: Lifetime::default(),
175            run: Some(RunSpec {
176                argv,
177                working_directory: cwd,
178                timeout_ms: None,
179                limits: None,
180                secret_delivery: Default::default(),
181            }),
182            ..ExecutionCellSpec::default()
183        };
184        ExecutionCellDocument {
185            api_version: "cellos.dev/v1".to_string(),
186            kind: "ExecutionCell".to_string(),
187            spec,
188        }
189    }
190
191    #[test]
192    fn happy_path_populates_args_cwd_hostname() {
193        let doc = doc_with(
194            "cell-alpha",
195            vec!["/bin/true".to_string()],
196            Some("/work".to_string()),
197        );
198        let cfg = generate_bundle_config(&doc).expect("bundle generation succeeds");
199
200        assert_eq!(cfg.process.args, vec!["/bin/true"]);
201        assert_eq!(cfg.process.cwd, "/work");
202        assert_eq!(cfg.hostname, "cell-alpha");
203        assert!(cfg.root.readonly);
204        assert_eq!(cfg.root.path, "rootfs");
205    }
206
207    #[test]
208    fn cwd_defaults_to_root_when_absent() {
209        let doc = doc_with("cell-beta", vec!["/bin/sh".to_string()], None);
210        let cfg = generate_bundle_config(&doc).unwrap();
211        assert_eq!(cfg.process.cwd, "/");
212    }
213
214    #[test]
215    fn cwd_defaults_to_root_when_empty_string() {
216        let doc = doc_with(
217            "cell-gamma",
218            vec!["/bin/sh".to_string()],
219            Some(String::new()),
220        );
221        let cfg = generate_bundle_config(&doc).unwrap();
222        assert_eq!(cfg.process.cwd, "/");
223    }
224
225    #[test]
226    fn empty_cell_id_is_rejected() {
227        let doc = doc_with("", vec!["/bin/true".to_string()], None);
228        let err = generate_bundle_config(&doc).unwrap_err();
229        assert!(matches!(err, BundleConfigError::MissingCellId));
230    }
231
232    #[test]
233    fn whitespace_only_cell_id_is_rejected() {
234        let doc = doc_with("   ", vec!["/bin/true".to_string()], None);
235        let err = generate_bundle_config(&doc).unwrap_err();
236        assert!(matches!(err, BundleConfigError::MissingCellId));
237    }
238
239    #[test]
240    fn missing_run_is_rejected() {
241        let spec = ExecutionCellSpec {
242            id: "cell".to_string(),
243            authority: AuthorityBundle::default(),
244            lifetime: Lifetime::default(),
245            run: None,
246            ..ExecutionCellSpec::default()
247        };
248        let doc = ExecutionCellDocument {
249            api_version: "cellos.dev/v1".to_string(),
250            kind: "ExecutionCell".to_string(),
251            spec,
252        };
253        let err = generate_bundle_config(&doc).unwrap_err();
254        assert!(matches!(err, BundleConfigError::MissingArgv));
255    }
256
257    #[test]
258    fn empty_argv_is_rejected() {
259        let doc = doc_with("cell-delta", vec![], None);
260        let err = generate_bundle_config(&doc).unwrap_err();
261        assert!(matches!(err, BundleConfigError::MissingArgv));
262    }
263
264    #[test]
265    fn namespaces_include_expected_set() {
266        let doc = doc_with("cell-eps", vec!["/bin/true".to_string()], None);
267        let cfg = generate_bundle_config(&doc).unwrap();
268        let kinds: Vec<&str> = cfg
269            .linux
270            .namespaces
271            .iter()
272            .map(|n| n.kind.as_str())
273            .collect();
274        for required in &["pid", "network", "ipc", "uts", "mount"] {
275            assert!(
276                kinds.contains(required),
277                "expected namespace {required} in {kinds:?}",
278            );
279        }
280    }
281
282    #[test]
283    fn config_serializes_to_json_with_oci_keys() {
284        let doc = doc_with(
285            "cell-json",
286            vec!["/bin/echo".to_string(), "hi".to_string()],
287            None,
288        );
289        let cfg = generate_bundle_config(&doc).unwrap();
290        let json = serde_json::to_value(&cfg).unwrap();
291        // OCI runtime spec expects camelCase / specific key names — pin them.
292        assert!(
293            json.get("ociVersion").is_some(),
294            "ociVersion missing: {json}"
295        );
296        assert!(json.get("process").is_some());
297        assert!(json.get("root").is_some());
298        assert_eq!(
299            json["linux"]["namespaces"][0]["type"], "pid",
300            "namespaces must use OCI 'type' key, got: {json}",
301        );
302    }
303
304    #[test]
305    fn multi_arg_argv_round_trips() {
306        let doc = doc_with(
307            "cell-multi",
308            vec![
309                "/usr/bin/env".to_string(),
310                "PATH=/bin".to_string(),
311                "sh".to_string(),
312            ],
313            None,
314        );
315        let cfg = generate_bundle_config(&doc).unwrap();
316        assert_eq!(cfg.process.args, vec!["/usr/bin/env", "PATH=/bin", "sh"],);
317    }
318}