1use cellos_core::ExecutionCellDocument;
16use serde::{Deserialize, Serialize};
17
18#[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#[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 pub path: String,
74 pub readonly: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct LinuxSection {
79 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
90pub 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 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 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}