1use 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#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct InstanceId(pub String);
32
33#[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#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum InstanceState {
46 Running,
47 Exited { exit_code: i32 },
48}
49
50#[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 async fn run(&self, spec: &RunSpec) -> Result<InstanceId, RuntimeError>;
63 async fn stop(&self, uid: &str) -> Result<(), RuntimeError>;
65 async fn remove(&self, uid: &str) -> Result<(), RuntimeError>;
67 async fn list(&self) -> Result<Vec<Instance>, RuntimeError>;
69 async fn version(&self) -> Result<String, RuntimeError>;
71}
72
73#[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 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
139const SUBCMD_RUN: &str = "run";
160const SUBCMD_STOP: &str = "stop";
161const SUBCMD_REMOVE: &str = "delete";
162const SUBCMD_LIST: &str = "list";
163const NAME_PREFIX: &str = "velos-";
165
166fn instance_name(uid: &str) -> String {
167 format!("{NAME_PREFIX}{uid}")
168}
169
170pub 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 pub fn with_binary(bin: impl Into<String>) -> Self {
190 Self { bin: bin.into() }
191 }
192
193 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 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
273fn 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
290fn 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 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}