1use std::path::Path;
8use std::time::Duration;
9
10use async_trait::async_trait;
11
12use super::evidence::RunEvidence;
13use super::types::RuntimeError;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Engine {
18 Apple,
20 Docker,
22 Podman,
24}
25
26impl Engine {
27 pub fn cli(self) -> &'static str {
29 match self {
30 Engine::Apple => "container",
31 Engine::Docker => "docker",
32 Engine::Podman => "podman",
33 }
34 }
35
36 pub fn runtime_id(self) -> &'static str {
38 match self {
39 Engine::Apple => "container.apple",
40 Engine::Docker => "container.docker",
41 Engine::Podman => "container.podman",
42 }
43 }
44
45 pub fn capability_id(self) -> &'static str {
47 match self {
48 Engine::Apple => "runtime.container.apple",
49 Engine::Docker => "runtime.container.docker",
50 Engine::Podman => "runtime.container.podman",
51 }
52 }
53
54 pub fn platform_args(self, arch: &str) -> Result<Vec<String>, RuntimeError> {
56 let (docker, apple) = match arch {
57 "aarch64" => ("linux/arm64", "arm64"),
58 "armv7" => ("linux/arm/v7", "arm"),
59 "x86_64" => ("linux/amd64", "amd64"),
60 _ => {
61 return Err(RuntimeError::InvalidConfig(format!(
62 "no platform for {arch}"
63 )));
64 }
65 };
66 Ok(match self {
67 Engine::Apple => vec!["--arch".into(), apple.into()],
68 _ => vec!["--platform".into(), docker.into()],
69 })
70 }
71}
72
73pub const CONTAINER_USER: &str = "65534:65534";
75pub const PIDS_LIMIT: u32 = 64;
77pub const COG_PATH: &str = "/cog";
79pub const DATA_DIR: &str = "/data";
81
82pub fn validate_base_image(r: &str) -> Result<(), RuntimeError> {
84 let ok = r.split_once("@sha256:").is_some_and(|(name, digest)| {
85 !name.is_empty()
86 && name.len() <= 200
87 && name
88 .bytes()
89 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"./:_-".contains(&b))
90 && crate::workload_pkg::codec::is_lower_hex(digest, 64)
91 });
92 if ok {
93 Ok(())
94 } else {
95 Err(RuntimeError::InvalidConfig(
96 "base image must be pinned by digest (name@sha256:<64 hex>)".into(),
97 ))
98 }
99}
100
101pub fn dockerfile(base_image: &str) -> String {
103 format!(
104 "FROM {base_image}\nCOPY cog {COG_PATH}\nUSER {CONTAINER_USER}\nWORKDIR /\nENTRYPOINT [\"{COG_PATH}\"]\n"
105 )
106}
107
108pub fn image_tag(cog_id: &str, blake3: &str) -> String {
110 format!(
111 "{}{cog_id}:{}",
112 crate::container::COG_IMAGE_PREFIX,
113 &blake3[..16.min(blake3.len())]
114 )
115}
116
117pub fn build_cmd(
119 engine: Engine,
120 arch: &str,
121 tag: &str,
122 ctx: &Path,
123) -> Result<Vec<String>, RuntimeError> {
124 let mut v = vec!["build".to_string()];
125 v.extend(engine.platform_args(arch)?);
126 v.extend(["-t".into(), tag.into(), ctx.to_string_lossy().into_owned()]);
127 Ok(v)
128}
129
130#[derive(Debug, Clone)]
132pub struct RunLimits {
133 pub ram_mb: u32,
135 pub cpu_pct: u32,
137}
138
139#[derive(Debug, Clone)]
141pub struct RunSpec<'a> {
142 pub name: &'a str,
144 pub tag: &'a str,
146 pub arch: &'a str,
148 pub limits: RunLimits,
150 pub env_file: &'a Path,
152 pub feed_publish: Option<(std::net::IpAddr, u16, u16)>,
154 pub network: Option<&'a str>,
156 pub detach: bool,
158 pub ingest_relay: bool,
162 pub args: &'a [String],
164}
165
166pub fn run_cmd(engine: Engine, s: &RunSpec<'_>) -> Result<Vec<String>, RuntimeError> {
168 let mut v = vec!["run".to_string()];
169 v.push(if s.detach { "-d" } else { "--rm" }.into());
170 v.extend(["--name".into(), s.name.into()]);
171 v.extend(engine.platform_args(s.arch)?);
172 v.extend([
173 "--memory".into(),
174 format!("{}M", s.limits.ram_mb),
175 "--cpus".into(),
176 match engine {
177 Engine::Apple => s.limits.cpu_pct.div_ceil(100).max(1).to_string(),
179 _ => format!("{:.2}", f64::from(s.limits.cpu_pct) / 100.0),
180 },
181 "--read-only".into(),
182 "--cap-drop".into(),
183 "ALL".into(),
184 ]);
185 if s.ingest_relay {
186 for c in super::container_relay::RELAY_CAPS {
187 v.extend(["--cap-add".into(), c.into()]);
188 }
189 }
190 v.extend([
191 "--tmpfs".into(),
192 DATA_DIR.into(),
193 "--env-file".into(),
194 s.env_file.to_string_lossy().into_owned(),
195 "--label".into(),
196 "weftos.workload=cog".into(),
197 ]);
198 match engine {
199 Engine::Apple => v.extend([
206 "--ulimit".into(),
207 format!("nproc={PIDS_LIMIT}:{PIDS_LIMIT}"),
208 ]),
209 _ => v.extend([
210 "--pids-limit".into(),
211 PIDS_LIMIT.to_string(),
212 "--security-opt".into(),
213 "no-new-privileges".into(),
214 ]),
215 }
216 if let Some((ip, host, inner)) = s.feed_publish {
217 let addr = match ip {
218 std::net::IpAddr::V4(a) => a.to_string(),
219 std::net::IpAddr::V6(a) => format!("[{a}]"),
220 };
221 v.extend(["-p".into(), format!("{addr}:{host}:{inner}/udp")]);
222 }
223 if let Some(n) = s.network {
224 v.extend(["--network".into(), n.into()]);
225 }
226 v.push(s.tag.into());
227 v.extend(s.args.iter().cloned());
228 Ok(v)
229}
230
231pub fn stop_cmd(name: &str, grace: Duration) -> Vec<String> {
233 vec![
234 "stop".into(),
235 "-t".into(),
236 grace.as_secs().max(1).to_string(),
237 name.into(),
238 ]
239}
240
241pub fn rm_cmd(engine: Engine, name: &str) -> Vec<String> {
243 match engine {
244 Engine::Apple => vec!["delete".into(), "--force".into(), name.into()],
245 _ => vec!["rm".into(), "-f".into(), name.into()],
246 }
247}
248
249pub fn rmi_cmd(engine: Engine, tag: &str) -> Vec<String> {
251 match engine {
252 Engine::Apple => vec!["image".into(), "delete".into(), tag.into()],
253 _ => vec!["rmi".into(), tag.into()],
254 }
255}
256
257pub fn parse_inspect(engine: Engine, out: &str) -> Option<(String, Option<i32>)> {
259 let v: serde_json::Value = serde_json::from_str(out.trim()).ok()?;
260 let first = v.as_array().and_then(|a| a.first()).unwrap_or(&v);
261 match engine {
262 Engine::Apple => {
263 let st = first.get("status")?;
266 let status = st.get("state").unwrap_or(st).as_str()?.to_string();
267 Some((status, None))
268 }
269 _ => {
270 let st = first.get("State")?;
271 let status = st.get("Status")?.as_str()?.to_string();
272 let code = st
273 .get("ExitCode")
274 .and_then(|c| c.as_i64())
275 .map(|c| c as i32);
276 Some((status, code))
277 }
278 }
279}
280
281#[derive(Debug, Clone, Default)]
283pub struct CmdOutput {
284 pub status: Option<i32>,
286 pub stdout: String,
288 pub stderr: String,
290 pub stdout_bytes: u64,
292 pub stderr_bytes: u64,
294 pub timed_out: bool,
296}
297
298#[async_trait]
300pub trait CommandRunner: Send + Sync {
301 async fn run(
303 &self,
304 program: &str,
305 args: &[String],
306 timeout: Duration,
307 limit: usize,
308 ) -> Result<CmdOutput, RuntimeError>;
309}
310
311pub struct SystemRunner;
313
314#[async_trait]
315impl CommandRunner for SystemRunner {
316 async fn run(
317 &self,
318 program: &str,
319 args: &[String],
320 timeout: Duration,
321 limit: usize,
322 ) -> Result<CmdOutput, RuntimeError> {
323 let mut child = tokio::process::Command::new(program)
324 .args(args)
325 .stdin(std::process::Stdio::null())
326 .stdout(std::process::Stdio::piped())
327 .stderr(std::process::Stdio::piped())
328 .kill_on_drop(true)
329 .spawn()
330 .map_err(|e| RuntimeError::Backend(format!("{program}: {e}")))?;
331 let (o, e) = (child.stdout.take(), child.stderr.take());
332 let work = async { tokio::join!(capture(o, limit), capture(e, limit), child.wait()) };
333 match tokio::time::timeout(timeout, work).await {
334 Ok((out, err, st)) => Ok(CmdOutput {
335 status: st.ok().and_then(|s| s.code()),
336 stdout: out.text(),
337 stderr: err.text(),
338 stdout_bytes: out.total(),
339 stderr_bytes: err.total(),
340 timed_out: false,
341 }),
342 Err(_) => Ok(CmdOutput {
343 timed_out: true,
344 ..CmdOutput::default()
345 }),
346 }
347 }
348}
349
350async fn capture<R: tokio::io::AsyncRead + Unpin>(
351 s: Option<R>,
352 limit: usize,
353) -> super::evidence::Capture {
354 use tokio::io::AsyncReadExt;
355 let mut cap = super::evidence::Capture::new(limit);
356 if let Some(mut s) = s {
357 let mut buf = [0u8; 8192];
358 while let Ok(n) = s.read(&mut buf).await {
359 if n == 0 {
360 break;
361 }
362 cap.push(&buf[..n]);
363 }
364 }
365 cap
366}
367
368pub fn container_name(iid: &str) -> String {
370 let s: String = iid
371 .chars()
372 .map(|c| {
373 if c.is_ascii_alphanumeric() || c == '-' {
374 c.to_ascii_lowercase()
375 } else {
376 '-'
377 }
378 })
379 .collect();
380 format!("weftos-{s}")
381}
382
383pub const ENGINE_STARTUP_SECS: u64 = 20;
385
386pub(super) fn evidence_from(
387 out: &CmdOutput,
388 args: &[String],
389 exit: Option<i32>,
390 t0: std::time::Instant,
391) -> RunEvidence {
392 let limit_hit =
393 out.stdout_bytes > out.stdout.len() as u64 || out.stderr_bytes > out.stderr.len() as u64;
394 RunEvidence {
395 args: args.to_vec(),
396 exit_code: exit,
397 elapsed_ms: t0.elapsed().as_millis() as u64,
398 stdout: out.stdout.clone(),
399 stderr: out.stderr.clone(),
400 stdout_bytes: out.stdout_bytes,
401 stderr_bytes: out.stderr_bytes,
402 truncated: limit_hit,
403 ..RunEvidence::default()
404 }
405}
406
407pub(super) fn write_context(
408 ctx: &std::path::Path,
409 env_file: &std::path::Path,
410 bytes: &[u8],
411 dockerfile: &str,
412 env: &[(String, String)],
413) -> Result<(), RuntimeError> {
414 use std::io::Write;
415 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
416 let io = |e: std::io::Error| RuntimeError::Backend(format!("build context: {e}"));
417 std::fs::create_dir_all(ctx).map_err(io)?;
418 std::fs::write(ctx.join("Dockerfile"), dockerfile).map_err(io)?;
419 let bin = ctx.join("cog");
420 std::fs::write(&bin, bytes).map_err(io)?;
421 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o555)).map_err(io)?;
422 let mut f = std::fs::OpenOptions::new()
423 .create(true)
424 .truncate(true)
425 .write(true)
426 .mode(0o600)
427 .open(env_file)
428 .map_err(io)?;
429 for (k, v) in env {
430 if v.contains('\n') {
431 return Err(RuntimeError::InvalidConfig(format!(
432 "{k} contains a newline"
433 )));
434 }
435 writeln!(f, "{k}={v}").map_err(io)?;
436 }
437 Ok(())
438}