1use 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#[derive(Debug, Clone)]
31pub struct ContainerRuntimeConfig {
32 pub engine: Engine,
34 pub base_image: String,
36 pub work_root: PathBuf,
38 pub arches_native: Vec<String>,
40 pub arches_emulated: Vec<String>,
42 pub allow_emulated: bool,
44 pub feed_host_port: Option<u16>,
46 pub feed_publish_ip: std::net::IpAddr,
50 pub network: Option<String>,
52 pub variant: Option<String>,
54 pub version: Option<String>,
56 pub cli_timeout: Duration,
58}
59
60impl ContainerRuntimeConfig {
61 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
98pub struct ContainerRuntime {
100 cfg: ContainerRuntimeConfig,
101 runner: Arc<dyn CommandRunner>,
102 instances: Mutex<HashMap<String, Instance>>,
103}
104
105impl ContainerRuntime {
106 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 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 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}