Skip to content

Commit 8036c89

Browse files
author
a3s-release
committed
release: v2.6.0 — containerd RuntimeClass shim + VMM shim session isolation
- Add containerd-shim-a3s-box-v2: route runtimeClassName=a3s-box pods to the a3s-box MicroVM runtime via a containerd runtime handler (deploy/shim/ manifests). - Fix: setsid the libkrun VMM shim so it survives launcher teardown (keeps exec.sock). - Make a3s-libkrun-sys libkrunfw download resilient (curl retry + abort-on-stall). - Bump workspace version 2.5.2 -> 2.6.0; CHANGELOG.
1 parent b3319c4 commit 8036c89

12 files changed

Lines changed: 1293 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,36 @@ All notable changes to A3S Box will be documented in this file.
44

55
## [Unreleased]
66

7+
## [2.6.0] — 2026-06-26
8+
9+
### Added
10+
11+
- **`containerd-shim-a3s-box-v2` — Kubernetes RuntimeClass integration.** A new
12+
containerd runtime-v2 shim (standalone `containerd-shim/` crate) that lets a vanilla
13+
Kubernetes cluster route `runtimeClassName: a3s-box` pods to the a3s-box MicroVM
14+
runtime via a containerd runtime handler, without replacing the node CRI. It maps the
15+
containerd Task API onto the `a3s-box` CLI (pod sandbox → placeholder; workload →
16+
detached MicroVM; `kubectl exec``a3s-box exec`). Deploy manifests under
17+
`deploy/shim/` (RuntimeClass, additive containerd config, example pod). Validated on a
18+
real `/dev/kvm` Kubernetes node: a `runtimeClassName: a3s-box` pod reaches Running on
19+
a real libkrun MicroVM. Still experimental — `kubectl exec`/log streaming depend on the
20+
guest exec control channel and are not yet fully validated; single-container,
21+
TSI-networked pods are the supported shape.
22+
23+
### Fixed
24+
25+
- **VMM shim now survives teardown of its launcher's session.** `VmController` puts the
26+
libkrun shim in its own session (`setsid` via `pre_exec`) so a process-group/cgroup
27+
kill of a foreground launcher (e.g. a containerd-shim `a3s-box run`) no longer reaps
28+
the shim and removes the box's `exec.sock`, which previously caused `a3s-box exec` to
29+
fail with "exec socket missing".
30+
31+
### Changed
32+
33+
- **`a3s-libkrun-sys` build downloads are resilient.** The libkrunfw fetch now retries and
34+
aborts stalled transfers (`curl --retry --speed-limit/--speed-time`) instead of a bare,
35+
unbounded `curl` that could hang forever on a flaky network.
36+
737
## [2.5.2] — 2026-06-22
838

939
### Changed

containerd-shim/Cargo.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Standalone crate: NOT part of the box workspace (own [workspace] table below).
2+
# It links no a3s-box crate — it drives the installed `a3s-box` CLI — so it builds
3+
# fast and in isolation, and never perturbs the runtime's Cargo.lock.
4+
[package]
5+
name = "a3s-box-containerd-shim"
6+
version = "0.1.0"
7+
edition = "2021"
8+
license = "MIT"
9+
description = "containerd runtime shim v2 that routes RuntimeClass=a3s-box pods to the a3s-box MicroVM runtime"
10+
11+
[[bin]]
12+
name = "containerd-shim-a3s-box-v2"
13+
path = "src/main.rs"
14+
15+
[dependencies]
16+
# ttrpc + protobuf come transitively via containerd-shim-protos; reference them
17+
# through its re-exports (containerd_shim_protos::{ttrpc, protobuf}) so versions
18+
# always match the generated Task service.
19+
containerd-shim = { version = "0.11", features = ["async"] }
20+
containerd-shim-protos = { version = "0.11", features = ["async"] }
21+
async-trait = "0.1"
22+
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "sync", "io-util", "fs", "time"] }
23+
log = "0.4"
24+
serde = { version = "1", features = ["derive"] }
25+
serde_json = "1"
26+
27+
[workspace]

containerd-shim/src/logic.rs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
//! Pure, side-effect-free logic for the a3s-box containerd shim.
2+
//!
3+
//! Everything here is free of process spawning, ttrpc, and filesystem state so it
4+
//! can be unit-tested directly. `service.rs` is the thin async layer that wires
5+
//! these into the containerd Task API and spawns the `a3s-box` CLI.
6+
7+
use serde_json::Value;
8+
9+
/// Annotations the containerd CRI plugin sets on the OCI spec.
10+
pub const ANN_CONTAINER_TYPE: &str = "io.kubernetes.cri.container-type";
11+
pub const ANN_IMAGE_NAME: &str = "io.kubernetes.cri.image-name";
12+
13+
/// Fields extracted from an OCI runtime spec (config.json) the shim needs.
14+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
15+
pub struct ParsedSpec {
16+
pub is_sandbox: bool,
17+
pub image: Option<String>,
18+
pub args: Vec<String>,
19+
pub env: Vec<String>,
20+
pub cpus: Option<u32>,
21+
pub memory_mb: Option<u64>,
22+
}
23+
24+
/// Read a string annotation from an OCI spec JSON value.
25+
pub fn annotation<'a>(spec: &'a Value, key: &str) -> Option<&'a str> {
26+
spec.get("annotations")?.get(key)?.as_str()
27+
}
28+
29+
/// Extract the shim-relevant fields from a parsed config.json value.
30+
pub fn parse_spec(spec: &Value) -> ParsedSpec {
31+
let is_sandbox = annotation(spec, ANN_CONTAINER_TYPE)
32+
.map(|t| t == "sandbox")
33+
.unwrap_or(false);
34+
let image = annotation(spec, ANN_IMAGE_NAME).map(|s| s.to_string());
35+
let args = string_array(spec.pointer("/process/args"));
36+
let env = string_array(spec.pointer("/process/env"));
37+
let memory_mb = spec
38+
.pointer("/linux/resources/memory/limit")
39+
.and_then(|v| v.as_u64())
40+
.map(|b| (b / (1024 * 1024)).max(1));
41+
let cpus = match (
42+
spec.pointer("/linux/resources/cpu/quota").and_then(|v| v.as_i64()),
43+
spec.pointer("/linux/resources/cpu/period").and_then(|v| v.as_u64()),
44+
) {
45+
(Some(q), Some(p)) if q > 0 && p > 0 => Some(((q as f64 / p as f64).ceil()) as u32),
46+
_ => None,
47+
};
48+
ParsedSpec { is_sandbox, image, args, env, cpus, memory_mb }
49+
}
50+
51+
fn string_array(v: Option<&Value>) -> Vec<String> {
52+
v.and_then(|a| a.as_array())
53+
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
54+
.unwrap_or_default()
55+
}
56+
57+
/// Build the `a3s-box` argv (after the binary) for a detached workload run.
58+
pub fn run_args(
59+
id: &str,
60+
image: &str,
61+
cpus: Option<u32>,
62+
memory_mb: Option<u64>,
63+
env: &[String],
64+
cmd: &[String],
65+
) -> Vec<String> {
66+
let mut v = vec![
67+
"run".to_string(),
68+
"-d".to_string(),
69+
"--name".to_string(),
70+
id.to_string(),
71+
];
72+
if let Some(c) = cpus {
73+
v.push("--cpus".to_string());
74+
v.push(c.to_string());
75+
}
76+
if let Some(m) = memory_mb {
77+
v.push("--memory".to_string());
78+
v.push(format!("{m}m"));
79+
}
80+
for e in env {
81+
v.push("-e".to_string());
82+
v.push(e.clone());
83+
}
84+
v.push(image.to_string());
85+
if !cmd.is_empty() {
86+
v.push("--".to_string());
87+
v.extend(cmd.iter().cloned());
88+
}
89+
v
90+
}
91+
92+
/// Build the `a3s-box exec` argv (after the binary) for an exec process.
93+
pub fn exec_args(container_id: &str, cmd: &[String]) -> Vec<String> {
94+
let mut v = vec![
95+
"exec".to_string(),
96+
container_id.to_string(),
97+
"--".to_string(),
98+
];
99+
v.extend(cmd.iter().cloned());
100+
v
101+
}
102+
103+
/// Parse the OCI Process command from the JSON-encoded protobuf Any value that
104+
/// containerd sends with an Exec request.
105+
pub fn parse_exec_command(spec_value: &[u8]) -> Vec<String> {
106+
serde_json::from_slice::<Value>(spec_value)
107+
.ok()
108+
.map(|v| string_array(v.get("args")))
109+
.unwrap_or_default()
110+
}
111+
112+
/// Whether `a3s-box inspect` stdout reports the box as running.
113+
pub fn is_running(inspect_stdout: &str) -> bool {
114+
inspect_stdout.contains("\"running\"")
115+
}
116+
117+
/// The stable A3S_HOME used so `run`/`exec`/`wait`/`stop`/`rm` share one
118+
/// boxes.json regardless of the (often empty) env containerd gives the shim.
119+
pub fn a3s_home() -> String {
120+
std::env::var("A3S_HOME").unwrap_or_else(|_| "/var/lib/a3s-box".to_string())
121+
}
122+
123+
/// Numeric box status -> name (mirrors the protobuf Status enum we report).
124+
pub fn status_name(s: i32) -> &'static str {
125+
match s {
126+
1 => "created",
127+
2 => "running",
128+
3 => "stopped",
129+
_ => "unknown",
130+
}
131+
}
132+
133+
/// Parse an exit code printed by `a3s-box wait` (its stdout is the integer code).
134+
pub fn parse_wait_exit_code(stdout: &str) -> u32 {
135+
stdout.trim().parse::<i32>().unwrap_or(0) as u32
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
use super::*;
141+
use serde_json::json;
142+
143+
#[test]
144+
fn annotation_present_and_absent() {
145+
let s = json!({"annotations": {"k": "v"}});
146+
assert_eq!(annotation(&s, "k"), Some("v"));
147+
assert_eq!(annotation(&s, "missing"), None);
148+
assert_eq!(annotation(&json!({}), "k"), None);
149+
assert_eq!(annotation(&json!(null), "k"), None);
150+
}
151+
152+
#[test]
153+
fn parse_spec_sandbox() {
154+
let s = json!({"annotations": {ANN_CONTAINER_TYPE: "sandbox"}});
155+
let p = parse_spec(&s);
156+
assert!(p.is_sandbox);
157+
assert_eq!(p.image, None);
158+
assert!(p.args.is_empty());
159+
}
160+
161+
#[test]
162+
fn parse_spec_workload_full() {
163+
let s = json!({
164+
"annotations": {
165+
ANN_CONTAINER_TYPE: "container",
166+
ANN_IMAGE_NAME: "docker.io/library/redis:7"
167+
},
168+
"process": {
169+
"args": ["redis-server", "--port", "0"],
170+
"env": ["A=1", "B=2"]
171+
},
172+
"linux": {"resources": {
173+
"memory": {"limit": 536870912u64}, // 512 MiB
174+
"cpu": {"quota": 150000, "period": 100000} // 1.5 -> ceil 2
175+
}}
176+
});
177+
let p = parse_spec(&s);
178+
assert!(!p.is_sandbox);
179+
assert_eq!(p.image.as_deref(), Some("docker.io/library/redis:7"));
180+
assert_eq!(p.args, vec!["redis-server", "--port", "0"]);
181+
assert_eq!(p.env, vec!["A=1", "B=2"]);
182+
assert_eq!(p.memory_mb, Some(512));
183+
assert_eq!(p.cpus, Some(2));
184+
}
185+
186+
#[test]
187+
fn parse_spec_defaults_when_missing() {
188+
let p = parse_spec(&json!({}));
189+
assert_eq!(p, ParsedSpec::default());
190+
// zero/invalid cpu quota -> None
191+
let s = json!({"linux": {"resources": {"cpu": {"quota": -1, "period": 100000}}}});
192+
assert_eq!(parse_spec(&s).cpus, None);
193+
// sub-MiB memory clamps to at least 1
194+
let s = json!({"linux": {"resources": {"memory": {"limit": 1024}}}});
195+
assert_eq!(parse_spec(&s).memory_mb, Some(1));
196+
}
197+
198+
#[test]
199+
fn run_args_minimal() {
200+
assert_eq!(
201+
run_args("box1", "alpine", None, None, &[], &[]),
202+
vec!["run", "-d", "--name", "box1", "alpine"]
203+
);
204+
}
205+
206+
#[test]
207+
fn run_args_full() {
208+
let v = run_args(
209+
"box1",
210+
"redis:7",
211+
Some(2),
212+
Some(256),
213+
&["A=1".into()],
214+
&["redis-server".into(), "--port".into(), "0".into()],
215+
);
216+
assert_eq!(
217+
v,
218+
vec![
219+
"run", "-d", "--name", "box1", "--cpus", "2", "--memory", "256m", "-e", "A=1",
220+
"redis:7", "--", "redis-server", "--port", "0"
221+
]
222+
);
223+
}
224+
225+
#[test]
226+
fn exec_args_builds_separator() {
227+
assert_eq!(
228+
exec_args("box1", &["sh".into(), "-c".into(), "echo hi".into()]),
229+
vec!["exec", "box1", "--", "sh", "-c", "echo hi"]
230+
);
231+
assert_eq!(exec_args("box1", &[]), vec!["exec", "box1", "--"]);
232+
}
233+
234+
#[test]
235+
fn parse_exec_command_from_any() {
236+
let any = br#"{"args":["ls","-la"],"cwd":"/"}"#;
237+
assert_eq!(parse_exec_command(any), vec!["ls", "-la"]);
238+
assert!(parse_exec_command(b"not json").is_empty());
239+
assert!(parse_exec_command(br#"{"no_args":true}"#).is_empty());
240+
}
241+
242+
#[test]
243+
fn is_running_detects_status() {
244+
assert!(is_running(r#"{"status": "running"}"#));
245+
assert!(is_running(r#"{"status":"running","pid":1}"#));
246+
assert!(!is_running(r#"{"status": "created"}"#));
247+
assert!(!is_running(""));
248+
}
249+
250+
#[test]
251+
fn status_name_maps() {
252+
assert_eq!(status_name(1), "created");
253+
assert_eq!(status_name(2), "running");
254+
assert_eq!(status_name(3), "stopped");
255+
assert_eq!(status_name(0), "unknown");
256+
assert_eq!(status_name(99), "unknown");
257+
}
258+
259+
#[test]
260+
fn parse_wait_exit_code_handles_garbage() {
261+
assert_eq!(parse_wait_exit_code("0\n"), 0);
262+
assert_eq!(parse_wait_exit_code(" 137 "), 137);
263+
assert_eq!(parse_wait_exit_code("notanumber"), 0);
264+
assert_eq!(parse_wait_exit_code(""), 0);
265+
}
266+
267+
#[test]
268+
fn a3s_home_default() {
269+
// Default applies when unset (don't mutate global env in parallel tests).
270+
if std::env::var("A3S_HOME").is_err() {
271+
assert_eq!(a3s_home(), "/var/lib/a3s-box");
272+
}
273+
}
274+
}

containerd-shim/src/main.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//! containerd-shim-a3s-box-v2 — a containerd runtime shim v2 that routes
2+
//! `RuntimeClass=a3s-box` pods to the a3s-box MicroVM runtime.
3+
//!
4+
//! Model: containerd maps the runtime handler `a3s-box` (runtime_type
5+
//! `io.containerd.a3s-box.v2`) to this binary. It is a THIN shim: it drives the
6+
//! installed `a3s-box` CLI rather than linking the runtime, so it builds fast
7+
//! and never perturbs the runtime's build. Each workload container becomes one
8+
//! `a3s-box run` MicroVM; the CRI pod-sandbox (pause) container is a lightweight
9+
//! placeholder (a3s-box has no pause container — the VM itself is the sandbox).
10+
//!
11+
//! Known MVP limitation: a3s-box manages its own image rootfs, so the shim
12+
//! resolves the image from the CRI annotation `io.kubernetes.cri.image-name`
13+
//! and lets a3s-box pull it, rather than consuming containerd's prepared
14+
//! rootfs. This works for the RuntimeClass pod path; raw `ctr run` (no image
15+
//! annotation) is not supported.
16+
17+
mod logic;
18+
mod service;
19+
20+
use service::Service;
21+
22+
#[tokio::main]
23+
async fn main() {
24+
containerd_shim::asynchronous::run::<Service>("io.containerd.a3s-box.v2", None).await;
25+
}

0 commit comments

Comments
 (0)