Skip to main content

clawft_kernel/workload_runtime/
container.rs

1//! Container adapters: Apple `container`, Docker / OrbStack and Podman
2//! (ADR-100 section 3). This is the real start path for cog workloads; the
3//! simulated start in [`crate::container::ContainerManager`] remains for
4//! service-registry tests only.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use clawft_types::placement::{AttrValue, Capability, CapabilityId, Provenance};
13use tokio::sync::Mutex;
14
15use super::cog_spec::CogSpec;
16use super::container_cmd::{
17    self as cmd, CommandRunner, DATA_DIR, Engine, RunLimits, RunSpec, evidence_from, write_context,
18};
19pub use super::container_cmd::{ENGINE_STARTUP_SECS, container_name};
20use super::container_relay as relay;
21use super::evidence::RunEvidence;
22use super::native::{PayloadKind, classify, elf_machine, instance_id};
23use super::types::{
24    Admission, ControlMode, InstanceHandle, InstanceState, InstanceStatus, RuntimeError,
25    VerifiedWorkload, WorkloadConfig, WorkloadRuntime,
26};
27use crate::workload_governance::NetworkPolicy;
28
29/// Container adapter configuration.
30#[derive(Debug, Clone)]
31pub struct ContainerRuntimeConfig {
32    /// Engine.
33    pub engine: Engine,
34    /// Operator-pinned base image (`name@sha256:...`), present locally.
35    pub base_image: String,
36    /// Directory for build contexts and env files.
37    pub work_root: PathBuf,
38    /// Arches the engine runs natively (`aarch64` on Apple silicon).
39    pub arches_native: Vec<String>,
40    /// Arches it can run under emulation (`armv7` on OrbStack).
41    pub arches_emulated: Vec<String>,
42    /// Operator opt-in to emulated placement (never automatic).
43    pub allow_emulated: bool,
44    /// Host UDP port to publish to the cog's feed port, if any.
45    pub feed_host_port: Option<u16>,
46    /// Host address the feed port is published on. Loopback by default;
47    /// set a LAN address (or `0.0.0.0`) so an ESP32 elsewhere on the LAN
48    /// can reach a containerized cog.
49    pub feed_publish_ip: std::net::IpAddr,
50    /// Network to attach, if not the engine default.
51    pub network: Option<String>,
52    /// Engine variant (`orbstack`, `engine`, `desktop`) for `provides()`.
53    pub variant: Option<String>,
54    /// Engine version for `provides()`.
55    pub version: Option<String>,
56    /// Timeout for build / run / stop CLI calls.
57    pub cli_timeout: Duration,
58}
59
60impl ContainerRuntimeConfig {
61    /// Defaults for an Apple-silicon Mac.
62    pub fn new(
63        engine: Engine,
64        base_image: impl Into<String>,
65        work_root: impl Into<PathBuf>,
66    ) -> Self {
67        Self {
68            engine,
69            base_image: base_image.into(),
70            work_root: work_root.into(),
71            arches_native: vec!["aarch64".into()],
72            arches_emulated: Vec::new(),
73            allow_emulated: false,
74            feed_host_port: None,
75            feed_publish_ip: std::net::IpAddr::from([127, 0, 0, 1]),
76            network: None,
77            variant: None,
78            version: None,
79            cli_timeout: Duration::from_secs(300),
80        }
81    }
82}
83
84struct Instance {
85    name: String,
86    tag: String,
87    arch: String,
88    dir: PathBuf,
89    env_file: PathBuf,
90    spec: CogSpec,
91    args: Vec<String>,
92    csi_port: u16,
93    relay: bool,
94    started: bool,
95    last: Option<RunEvidence>,
96}
97
98/// A container-engine runtime.
99pub struct ContainerRuntime {
100    cfg: ContainerRuntimeConfig,
101    runner: Arc<dyn CommandRunner>,
102    instances: Mutex<HashMap<String, Instance>>,
103}
104
105impl ContainerRuntime {
106    /// New adapter.
107    pub fn new(cfg: ContainerRuntimeConfig, runner: Arc<dyn CommandRunner>) -> Self {
108        Self {
109            cfg,
110            runner,
111            instances: Mutex::new(HashMap::new()),
112        }
113    }
114
115    async fn call(&self, args: Vec<String>, limit: usize) -> Result<cmd::CmdOutput, RuntimeError> {
116        self.runner
117            .run(self.cfg.engine.cli(), &args, self.cfg.cli_timeout, limit)
118            .await
119    }
120
121    async fn call_ok(&self, args: Vec<String>, what: &str) -> Result<cmd::CmdOutput, RuntimeError> {
122        let out = self.call(args, 64 * 1024).await?;
123        if out.status != Some(0) {
124            let tail: String = out
125                .stderr
126                .chars()
127                .rev()
128                .take(400)
129                .collect::<Vec<_>>()
130                .into_iter()
131                .rev()
132                .collect();
133            return Err(RuntimeError::Backend(format!(
134                "{} {what} failed ({:?}): {tail}",
135                self.cfg.engine.cli(),
136                out.status
137            )));
138        }
139        Ok(out)
140    }
141
142    fn stamp(&self, iid: &str, mut ev: RunEvidence) -> RunEvidence {
143        ev.runtime = self.cfg.engine.runtime_id().into();
144        ev.instance_id = iid.into();
145        ev
146    }
147
148    fn run_spec<'a>(
149        &'a self,
150        inst: &'a Instance,
151        name: &'a str,
152        detach: bool,
153        args: &'a [String],
154    ) -> RunSpec<'a> {
155        RunSpec {
156            name,
157            tag: &inst.tag,
158            arch: &inst.arch,
159            limits: RunLimits {
160                ram_mb: inst.spec.resources.ram_mb,
161                cpu_pct: inst.spec.resources.cpu_pct,
162            },
163            env_file: &inst.env_file,
164            feed_publish: self
165                .cfg
166                .feed_host_port
167                .map(|h| (self.cfg.feed_publish_ip, h, inst.csi_port)),
168            network: self.cfg.network.as_deref(),
169            detach,
170            ingest_relay: inst.relay,
171            args,
172        }
173    }
174
175    async fn inspect(&self, name: &str) -> Option<(String, Option<i32>)> {
176        let out = self
177            .call(vec!["inspect".into(), name.into()], 256 * 1024)
178            .await
179            .ok()?;
180        if out.status != Some(0) {
181            return None;
182        }
183        cmd::parse_inspect(self.cfg.engine, &out.stdout)
184    }
185}
186
187#[async_trait]
188impl WorkloadRuntime for ContainerRuntime {
189    fn id(&self) -> &str {
190        self.cfg.engine.runtime_id()
191    }
192
193    fn provides(&self) -> Vec<Capability> {
194        let list =
195            |v: &[String]| AttrValue::List(v.iter().map(|a| AttrValue::from(a.as_str())).collect());
196        let Ok(id) = CapabilityId::new(self.cfg.engine.capability_id()) else {
197            return Vec::new();
198        };
199        let mut cap = Capability::new(id, Provenance::Probed)
200            .with_attr("arches_native", list(&self.cfg.arches_native))
201            .with_attr("arches_emulated", list(&self.cfg.arches_emulated));
202        if let Some(v) = &self.cfg.variant {
203            cap = cap.with_attr("variant", v.as_str());
204        }
205        if let Some(v) = &self.cfg.version {
206            cap = cap.with_attr("version", v.as_str());
207        }
208        vec![cap]
209    }
210
211    async fn admit(&self, w: &VerifiedWorkload) -> Result<Admission, RuntimeError> {
212        let rid = self.cfg.engine.runtime_id();
213        let p = w.signed(rid)?;
214        cmd::validate_base_image(&self.cfg.base_image)?;
215        let native = self
216            .cfg
217            .arches_native
218            .iter()
219            .find(|a| p.binaries.contains_key(*a));
220        let emulated = self
221            .cfg
222            .arches_emulated
223            .iter()
224            .find(|a| p.binaries.contains_key(*a));
225        let (arch, emu) = match (native, emulated) {
226            (Some(a), _) => (a.clone(), false),
227            (None, Some(a)) if self.cfg.allow_emulated => (a.clone(), true),
228            (None, Some(a)) => {
229                return Err(RuntimeError::AdmissionRefused(format!(
230                    "{a} runs only emulated here and emulation is operator opt-in"
231                )));
232            }
233            (None, None) => {
234                return Err(RuntimeError::AdmissionRefused(format!(
235                    "{rid} runs none of the package arches"
236                )));
237            }
238        };
239        match classify(&p.binaries[&arch].bytes) {
240            PayloadKind::Elf(m) if Some(m) == elf_machine(&arch) => {}
241            other => {
242                return Err(RuntimeError::AdmissionRefused(format!(
243                    "{arch} payload is not a matching Linux ELF ({other:?})"
244                )));
245            }
246        }
247        Ok(Admission {
248            runtime: rid.into(),
249            arch,
250            emulated: emu,
251            notes: vec![format!("base {}", self.cfg.base_image)],
252        })
253    }
254
255    async fn load(
256        &self,
257        w: &VerifiedWorkload,
258        cfg: &WorkloadConfig,
259    ) -> Result<InstanceHandle, RuntimeError> {
260        let adm = self.admit(w).await?;
261        let p = w.signed(self.id())?;
262        cfg.mode.validate()?;
263        cfg.host.validate()?;
264        p.spec.validate_args(&cfg.args)?;
265        let iid = instance_id(w, cfg)?;
266        if self.instances.lock().await.contains_key(&iid) {
267            return Err(RuntimeError::InvalidState(format!(
268                "{iid} is already loaded"
269            )));
270        }
271        let bin = &p.binaries[&adm.arch];
272        let dir = self.cfg.work_root.join(&iid);
273        let ctx = dir.join("ctx");
274        let env_file = dir.join("env");
275        let env = cfg.host.env(DATA_DIR);
276        let upstream = relay::relay_upstream(&cfg.host, self.cfg.network.as_deref())?;
277        let df = match upstream {
278            Some(up) => relay::dockerfile(&self.cfg.base_image, up),
279            None => cmd::dockerfile(&self.cfg.base_image),
280        };
281        let (bytes, c2, e2) = (bin.bytes.clone(), ctx.clone(), env_file.clone());
282        tokio::task::spawn_blocking(move || {
283            write_context(&c2, &e2, &bytes, &df, &env)?;
284            match upstream {
285                Some(_) => std::fs::write(c2.join(relay::RELAY_FILE), relay::RELAY_SCRIPT)
286                    .map_err(|e| RuntimeError::Backend(format!("build context: {e}"))),
287                None => Ok(()),
288            }
289        })
290        .await
291        .map_err(|e| RuntimeError::Backend(e.to_string()))??;
292        let tag = cmd::image_tag(&w.id, &bin.blake3);
293        let built = self
294            .call_ok(
295                cmd::build_cmd(self.cfg.engine, &adm.arch, &tag, &ctx)?,
296                "build",
297            )
298            .await;
299        if let Err(e) = built {
300            let _ = tokio::fs::remove_dir_all(&dir).await;
301            return Err(e);
302        }
303        let mut args = cfg.mode.args();
304        args.extend(cfg.args.iter().cloned());
305        self.instances.lock().await.insert(
306            iid.clone(),
307            Instance {
308                name: container_name(&iid),
309                tag,
310                arch: adm.arch,
311                dir,
312                env_file,
313                spec: p.spec.clone(),
314                args,
315                csi_port: cfg.host.csi_bind.port(),
316                relay: upstream.is_some(),
317                started: false,
318                last: None,
319            },
320        );
321        Ok(InstanceHandle {
322            runtime: self.id().into(),
323            instance_id: iid,
324            workload_id: w.id.clone(),
325            store_installed: false,
326        })
327    }
328
329    async fn start(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
330        let mut map = self.instances.lock().await;
331        let inst = map
332            .get_mut(&h.instance_id)
333            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
334        if inst.started {
335            return Err(RuntimeError::InvalidState("already started".into()));
336        }
337        let name = inst.name.clone();
338        let args = inst.args.clone();
339        let _ = self.call(cmd::rm_cmd(self.cfg.engine, &name), 4096).await;
340        self.call_ok(
341            cmd::run_cmd(self.cfg.engine, &self.run_spec(inst, &name, true, &args))?,
342            "run",
343        )
344        .await?;
345        inst.started = true;
346        Ok(())
347    }
348
349    async fn stop(&self, h: &InstanceHandle, grace: Duration) -> Result<RunEvidence, RuntimeError> {
350        let mut map = self.instances.lock().await;
351        let inst = map
352            .get_mut(&h.instance_id)
353            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
354        if !inst.started {
355            return inst
356                .last
357                .clone()
358                .ok_or_else(|| RuntimeError::InvalidState("not started".into()));
359        }
360        let t0 = std::time::Instant::now();
361        let _ = self.call(cmd::stop_cmd(&inst.name, grace), 4096).await;
362        let exit = self.inspect(&inst.name).await.and_then(|(_, c)| c);
363        let logs = self
364            .call(
365                vec!["logs".into(), inst.name.clone()],
366                inst.spec.console.output_limit_bytes,
367            )
368            .await?;
369        let _ = self
370            .call(cmd::rm_cmd(self.cfg.engine, &inst.name), 4096)
371            .await;
372        inst.started = false;
373        let ev = self.stamp(&h.instance_id, evidence_from(&logs, &inst.args, exit, t0));
374        inst.last = Some(ev.clone());
375        Ok(ev)
376    }
377
378    async fn unload(&self, h: InstanceHandle) -> Result<(), RuntimeError> {
379        let inst = self
380            .instances
381            .lock()
382            .await
383            .remove(&h.instance_id)
384            .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
385        let _ = self
386            .call(cmd::rm_cmd(self.cfg.engine, &inst.name), 4096)
387            .await;
388        let _ = self
389            .call(cmd::rmi_cmd(self.cfg.engine, &inst.tag), 4096)
390            .await;
391        tokio::fs::remove_dir_all(&inst.dir)
392            .await
393            .map_err(|e| RuntimeError::Backend(format!("remove work dir: {e}")))
394    }
395
396    async fn status(&self, h: &InstanceHandle) -> InstanceStatus {
397        let (name, started, last) = {
398            let map = self.instances.lock().await;
399            let Some(inst) = map.get(&h.instance_id) else {
400                return InstanceStatus::of(InstanceState::Unknown);
401            };
402            (inst.name.clone(), inst.started, inst.last.clone())
403        };
404        if !started {
405            return match last {
406                Some(ev) => InstanceStatus {
407                    state: InstanceState::Exited,
408                    exit_code: ev.exit_code,
409                    detail: None,
410                },
411                None => InstanceStatus::of(InstanceState::Loaded),
412            };
413        }
414        match self.inspect(&name).await {
415            Some((s, code)) if s == "running" => InstanceStatus {
416                state: InstanceState::Running,
417                exit_code: code,
418                detail: Some(s),
419            },
420            Some((s, code)) if s == "exited" || s == "stopped" => InstanceStatus {
421                state: InstanceState::Exited,
422                exit_code: code,
423                detail: Some(s),
424            },
425            Some((s, code)) => InstanceStatus {
426                state: InstanceState::Degraded,
427                exit_code: code,
428                detail: Some(s),
429            },
430            None => InstanceStatus {
431                state: InstanceState::Degraded,
432                exit_code: None,
433                detail: Some("inspect failed".into()),
434            },
435        }
436    }
437
438    fn control_mode(&self) -> ControlMode {
439        ControlMode::Managed
440    }
441
442    /// `none` has no network. Every other network (the engine's default
443    /// bridge, `host`, or a named one) can reach the internet as far as
444    /// this adapter knows, so the gate is told `egress`.
445    fn network_exposure(&self) -> NetworkPolicy {
446        match self.cfg.network.as_deref() {
447            Some("none") => NetworkPolicy::None,
448            _ => NetworkPolicy::Egress,
449        }
450    }
451
452    async fn console(
453        &self,
454        h: &InstanceHandle,
455        command: &str,
456    ) -> Result<RunEvidence, RuntimeError> {
457        let (inst_args, spec_line, name, max, limit) = {
458            let map = self.instances.lock().await;
459            let inst = map
460                .get(&h.instance_id)
461                .ok_or_else(|| RuntimeError::UnknownInstance(h.instance_id.clone()))?;
462            if inst.started {
463                return Err(RuntimeError::InvalidState(
464                    "stop the running instance before a console run (sensor feed contention)"
465                        .into(),
466                ));
467            }
468            let args = inst.spec.console_args(command)?;
469            let name = format!("{}-console", inst.name);
470            let line = cmd::run_cmd(self.cfg.engine, &self.run_spec(inst, &name, false, &args))?;
471            (
472                args,
473                line,
474                name,
475                inst.spec.console.max_runtime_secs,
476                inst.spec.console.output_limit_bytes,
477            )
478        };
479        // The cap is the cog's wall clock plus a fixed allowance for the
480        // engine to create (and, for Apple, boot) the container.
481        let cap = Duration::from_secs(max + ENGINE_STARTUP_SECS);
482        let t0 = std::time::Instant::now();
483        let out = self
484            .runner
485            .run(self.cfg.engine.cli(), &spec_line, cap, limit)
486            .await?;
487        let mut ev = evidence_from(&out, &inst_args, out.status, t0);
488        if out.timed_out {
489            let _ = self.call(cmd::rm_cmd(self.cfg.engine, &name), 4096).await;
490            ev.killed_for_timeout = true;
491            ev.exit_code = None;
492        }
493        Ok(self.stamp(&h.instance_id, ev))
494    }
495}