use cellos_core::ExecutionCellDocument;
use serde::{Deserialize, Serialize};
#[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 {}
#[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 {
pub path: String,
pub readonly: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinuxSection {
pub namespaces: Vec<Namespace>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Namespace {
#[serde(rename = "type")]
pub kind: String,
}
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,
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();
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"],);
}
}