Skip to main content

velos_runtime/
lib.rs

1//! The container runtime seam (Principle #3, deep module).
2//!
3//! `veloslet` drives micro-VMs only through the [`ContainerRuntime`] trait, so the
4//! Apple Containerization `container` CLI can be swapped for Tart, Linux, or a
5//! fake without touching the worker's reconcile logic. Every instance is keyed by
6//! its Velos container **uid**, which makes actuation idempotent: reconcile after a
7//! crash matches existing instances by uid before launching.
8//!
9//! Backends today: [`AppleContainer`] (real) and [`FakeRuntime`] (tests). A Linux
10//! backend (e.g. via `podman`/`runc` or a `libkrun` micro-VM) is the planned next
11//! addition behind this same trait — tracked separately, not in this change.
12
13use std::collections::HashMap;
14use std::sync::Mutex;
15
16use async_trait::async_trait;
17use thiserror::Error;
18
19#[derive(Debug, Error)]
20pub enum RuntimeError {
21    #[error("runtime command failed: {0}")]
22    Command(String),
23    #[error("io error: {0}")]
24    Io(String),
25    #[error("lock poisoned")]
26    Lock,
27}
28
29/// The runtime-local identifier of a launched instance.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct InstanceId(pub String);
32
33/// What `veloslet` asks the runtime to launch.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RunSpec {
36    pub uid: String,
37    pub image: String,
38    pub command: Vec<String>,
39    pub env: Vec<(String, String)>,
40}
41
42/// Observed liveness of an instance. There is no "assumed running": an instance
43/// the runtime cannot account for simply isn't in `list`.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum InstanceState {
46    Running,
47    Exited { exit_code: i32 },
48}
49
50/// One instance the runtime is tracking, tagged with its Velos uid.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Instance {
53    pub uid: String,
54    pub id: InstanceId,
55    pub state: InstanceState,
56}
57
58#[async_trait]
59pub trait ContainerRuntime: Send + Sync {
60    /// Launch an instance tagged with `spec.uid`. Idempotent callers check
61    /// [`list`](ContainerRuntime::list) first.
62    async fn run(&self, spec: &RunSpec) -> Result<InstanceId, RuntimeError>;
63    /// Stop the instance tagged with `uid` (no-op if already gone).
64    async fn stop(&self, uid: &str) -> Result<(), RuntimeError>;
65    /// Remove the instance tagged with `uid` (no-op if already gone).
66    async fn remove(&self, uid: &str) -> Result<(), RuntimeError>;
67    /// All instances the runtime knows about, by uid.
68    async fn list(&self) -> Result<Vec<Instance>, RuntimeError>;
69    /// Reported runtime version string (for `WorkerStatus`).
70    async fn version(&self) -> Result<String, RuntimeError>;
71}
72
73// ---------------------------------------------------------------------------
74// FakeRuntime — in-memory, for tests and the e2e harness.
75// ---------------------------------------------------------------------------
76
77/// An in-memory runtime used by tests and `velos-tests`. Exit can be simulated
78/// with [`FakeRuntime::set_exited`].
79#[derive(Default)]
80pub struct FakeRuntime {
81    instances: Mutex<HashMap<String, Instance>>,
82}
83
84impl FakeRuntime {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Simulate the instance for `uid` exiting with `exit_code`.
90    pub fn set_exited(&self, uid: &str, exit_code: i32) -> Result<(), RuntimeError> {
91        let mut g = self.instances.lock().map_err(|_| RuntimeError::Lock)?;
92        if let Some(inst) = g.get_mut(uid) {
93            inst.state = InstanceState::Exited { exit_code };
94        }
95        Ok(())
96    }
97}
98
99#[async_trait]
100impl ContainerRuntime for FakeRuntime {
101    async fn run(&self, spec: &RunSpec) -> Result<InstanceId, RuntimeError> {
102        let id = InstanceId(format!("fake-{}", spec.uid));
103        let mut g = self.instances.lock().map_err(|_| RuntimeError::Lock)?;
104        g.insert(
105            spec.uid.clone(),
106            Instance {
107                uid: spec.uid.clone(),
108                id: id.clone(),
109                state: InstanceState::Running,
110            },
111        );
112        Ok(id)
113    }
114
115    async fn stop(&self, uid: &str) -> Result<(), RuntimeError> {
116        let mut g = self.instances.lock().map_err(|_| RuntimeError::Lock)?;
117        if let Some(inst) = g.get_mut(uid) {
118            inst.state = InstanceState::Exited { exit_code: 0 };
119        }
120        Ok(())
121    }
122
123    async fn remove(&self, uid: &str) -> Result<(), RuntimeError> {
124        let mut g = self.instances.lock().map_err(|_| RuntimeError::Lock)?;
125        g.remove(uid);
126        Ok(())
127    }
128
129    async fn list(&self) -> Result<Vec<Instance>, RuntimeError> {
130        let g = self.instances.lock().map_err(|_| RuntimeError::Lock)?;
131        Ok(g.values().cloned().collect())
132    }
133
134    async fn version(&self) -> Result<String, RuntimeError> {
135        Ok("fake-runtime/1.0".to_string())
136    }
137}
138
139// ---------------------------------------------------------------------------
140// AppleContainer — wraps the `container` CLI (Apple Containerization).
141// ---------------------------------------------------------------------------
142//
143// Every instance is addressed by a derived **name** `velos-<uid>` (Apple's
144// `container` supports `--name` and name-based addressing universally, so this
145// avoids depending on label support). All `container` CLI assumptions are
146// gathered in the constants below so they can be matched to the installed
147// version in one place:
148//
149//   run     : `container run --detach --name velos-<uid> [--env K=V ...] <image> [cmd...]`
150//   stop    : `container stop velos-<uid>`
151//   remove  : `container delete --force velos-<uid>`
152//   list    : `container list --all --format json`
153//   version : `container --version`
154//
155// These match the apple/container 1.0 command reference (`delete` has alias
156// `rm`, `list` has alias `ls`). If your installed version differs, this is the
157// one place to adjust.
158
159const SUBCMD_RUN: &str = "run";
160const SUBCMD_STOP: &str = "stop";
161const SUBCMD_REMOVE: &str = "delete";
162const SUBCMD_LIST: &str = "list";
163/// Prefix applied to a uid to form the runtime instance name.
164const NAME_PREFIX: &str = "velos-";
165
166fn instance_name(uid: &str) -> String {
167    format!("{NAME_PREFIX}{uid}")
168}
169
170/// Real backend: shells out to the `container` CLI via `tokio::process`.
171pub struct AppleContainer {
172    bin: String,
173}
174
175impl Default for AppleContainer {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl AppleContainer {
182    pub fn new() -> Self {
183        Self {
184            bin: "container".to_string(),
185        }
186    }
187
188    /// Override the CLI binary path (e.g. for an alternate install location).
189    pub fn with_binary(bin: impl Into<String>) -> Self {
190        Self { bin: bin.into() }
191    }
192
193    /// Whether the configured `container` binary is callable. Used by tests and
194    /// callers to skip gracefully when Apple Containerization isn't installed.
195    pub async fn available(&self) -> bool {
196        self.output(&["--version".to_string()]).await.is_ok()
197    }
198
199    async fn output(&self, args: &[String]) -> Result<String, RuntimeError> {
200        let out = tokio::process::Command::new(&self.bin)
201            .args(args)
202            .output()
203            .await
204            .map_err(|e| RuntimeError::Io(e.to_string()))?;
205        if !out.status.success() {
206            return Err(RuntimeError::Command(
207                String::from_utf8_lossy(&out.stderr).trim().to_string(),
208            ));
209        }
210        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
211    }
212
213    /// Run a command, swallowing failures (used for idempotent stop/remove where
214    /// "no such container" is an acceptable outcome).
215    async fn output_best_effort(&self, args: &[String]) {
216        let _ = self.output(args).await;
217    }
218}
219
220#[async_trait]
221impl ContainerRuntime for AppleContainer {
222    async fn run(&self, spec: &RunSpec) -> Result<InstanceId, RuntimeError> {
223        let name = instance_name(&spec.uid);
224        let mut args = vec![
225            SUBCMD_RUN.to_string(),
226            "--detach".to_string(),
227            "--name".to_string(),
228            name.clone(),
229        ];
230        for (k, v) in &spec.env {
231            args.push("--env".to_string());
232            args.push(format!("{k}={v}"));
233        }
234        args.push(spec.image.clone());
235        args.extend(spec.command.iter().cloned());
236        self.output(&args).await?;
237        Ok(InstanceId(name))
238    }
239
240    async fn stop(&self, uid: &str) -> Result<(), RuntimeError> {
241        self.output_best_effort(&[SUBCMD_STOP.to_string(), instance_name(uid)])
242            .await;
243        Ok(())
244    }
245
246    async fn remove(&self, uid: &str) -> Result<(), RuntimeError> {
247        self.output_best_effort(&[
248            SUBCMD_REMOVE.to_string(),
249            "--force".to_string(),
250            instance_name(uid),
251        ])
252        .await;
253        Ok(())
254    }
255
256    async fn list(&self) -> Result<Vec<Instance>, RuntimeError> {
257        let raw = self
258            .output(&[
259                SUBCMD_LIST.to_string(),
260                "--all".to_string(),
261                "--format".to_string(),
262                "json".to_string(),
263            ])
264            .await?;
265        parse_list(&raw)
266    }
267
268    async fn version(&self) -> Result<String, RuntimeError> {
269        self.output(&["--version".to_string()]).await
270    }
271}
272
273/// Read the first present string field among `keys`, descending one level into
274/// an array's first element if the field is an array (e.g. `names: [..]`).
275fn field_str<'a>(entry: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
276    for k in keys {
277        match entry.get(k) {
278            Some(serde_json::Value::String(s)) => return Some(s),
279            Some(serde_json::Value::Array(a)) => {
280                if let Some(serde_json::Value::String(s)) = a.first() {
281                    return Some(s);
282                }
283            }
284            _ => {}
285        }
286    }
287    None
288}
289
290/// Parse `container list --format json` into our uid-keyed instances. Entries
291/// whose name lacks the `velos-` prefix are ignored (not ours). Field names are
292/// matched tolerantly to survive minor CLI schema differences.
293fn parse_list(raw: &str) -> Result<Vec<Instance>, RuntimeError> {
294    if raw.is_empty() {
295        return Ok(Vec::new());
296    }
297    let value: serde_json::Value =
298        serde_json::from_str(raw).map_err(|e| RuntimeError::Command(e.to_string()))?;
299    let arr = value.as_array().cloned().unwrap_or_default();
300    let mut out = Vec::new();
301    for entry in arr {
302        let Some(name) = field_str(&entry, &["name", "names", "id"]) else {
303            continue;
304        };
305        let Some(uid) = name.strip_prefix(NAME_PREFIX) else {
306            continue;
307        };
308        let status = field_str(&entry, &["status", "state"]).unwrap_or("unknown");
309        let running = status.eq_ignore_ascii_case("running");
310        let state = if running {
311            InstanceState::Running
312        } else {
313            let exit_code = entry
314                .get("exitCode")
315                .or_else(|| entry.get("exit_code"))
316                .and_then(|v| v.as_i64())
317                .unwrap_or(0) as i32;
318            InstanceState::Exited { exit_code }
319        };
320        out.push(Instance {
321            uid: uid.to_string(),
322            id: InstanceId(name.to_string()),
323            state,
324        });
325    }
326    Ok(out)
327}
328
329#[cfg(test)]
330#[allow(clippy::unwrap_used)]
331mod tests {
332    use super::*;
333
334    fn spec(uid: &str) -> RunSpec {
335        RunSpec {
336            uid: uid.to_string(),
337            image: "alpine".to_string(),
338            command: vec![],
339            env: vec![],
340        }
341    }
342
343    #[tokio::test]
344    async fn fake_runtime_run_list_exit_remove() {
345        let rt = FakeRuntime::new();
346        rt.run(&spec("u1")).await.unwrap();
347        let list = rt.list().await.unwrap();
348        assert_eq!(list.len(), 1);
349        assert_eq!(list[0].state, InstanceState::Running);
350
351        rt.set_exited("u1", 3).unwrap();
352        let list = rt.list().await.unwrap();
353        assert_eq!(list[0].state, InstanceState::Exited { exit_code: 3 });
354
355        rt.remove("u1").await.unwrap();
356        assert!(rt.list().await.unwrap().is_empty());
357    }
358
359    #[test]
360    fn parse_list_filters_to_velos_instances_by_name_prefix() {
361        // Mixed schema shapes: `name` vs `names[]`, `status` vs `state`.
362        let raw = r#"[
363            {"name":"velos-u1","status":"running"},
364            {"names":["velos-u2"],"state":"stopped","exitCode":2},
365            {"name":"someone-elses","status":"running"}
366        ]"#;
367        let mut got = parse_list(raw).unwrap();
368        got.sort_by(|a, b| a.uid.cmp(&b.uid));
369        assert_eq!(got.len(), 2);
370        assert_eq!(got[0].uid, "u1");
371        assert_eq!(got[0].state, InstanceState::Running);
372        assert_eq!(got[1].uid, "u2");
373        assert_eq!(got[1].state, InstanceState::Exited { exit_code: 2 });
374    }
375}