nordisk/heimdall
crates/heimdall-plain/src/spec.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The OCI runtime config (`config.json`) of one plain job.
//!
//! Heimdall is a defensive sandbox. This is an ORDINARY container, as the
//! milestone 0 demand says: user, mount, pid, ipc, uts, cgroup and network
//! namespaces (the network namespace is empty: only a down loopback), a
//! read-only root from the pinned image plus a tmpfs `/tmp`, `input_dir`
//! bound read-only at `/in`, `output_dir` bound read-write at `/out`, every
//! capability dropped, no-new-privs, the fixed default seccomp profile, and
//! cgroup v2 limits from [`heimdall_core::Limits`]. The wall clock is
//! enforced by the shim, not here.
use std::path::Path;
use heimdall_core::Limits;
use serde_json::{json, Value};
use crate::default_profile as p;
/// Where `input_dir` appears inside the container.
pub const IN_MOUNT: &str = "/in";
/// Where `output_dir` appears inside the container.
pub const OUT_MOUNT: &str = "/out";
/// Size of the `/tmp` tmpfs (also counted against the memory limit).
pub const TMP_SIZE: &str = "64m";
/// The cgroup v2 CPU period the `cpus` quota is expressed in (microseconds).
pub const CPU_PERIOD_US: u64 = 100_000;
/// What one job's config needs from the runner.
pub struct SpecInput<'a> {
pub argv: &'a [String],
/// `KEY=value` pairs (the image's `Env`).
pub env: &'a [String],
pub input_dir: &'a Path,
pub output_dir: &'a Path,
/// Extra read-only paths (bound at the same path inside).
pub read: &'a [std::path::PathBuf],
/// Extra writable paths (bound at the same path inside).
pub write: &'a [std::path::PathBuf],
pub limits: &'a Limits,
/// The host uid / gid that container root maps to (one id each).
pub uid: u32,
pub gid: u32,
/// The cgroup path below the cgroup v2 root, e.g. `/heimdall/job-<id>`.
pub cgroup_path: &'a str,
}
/// The default seccomp profile as an OCI `linux.seccomp` object.
pub fn seccomp_json() -> Value {
let mut rules = vec![json!({
"names": p::ALLOW,
"action": "SCMP_ACT_ALLOW",
})];
rules.push(json!({
"names": ["socket"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": p::SOCKET_DOMAIN_BELOW, "op": "SCMP_CMP_LT"}],
}));
for d in p::SOCKET_DOMAINS {
rules.push(json!({
"names": ["socket"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": d, "op": "SCMP_CMP_EQ"}],
}));
}
for v in p::PERSONALITIES {
rules.push(json!({
"names": ["personality"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": v, "op": "SCMP_CMP_EQ"}],
}));
}
rules.push(json!({
"names": ["clone"],
"action": "SCMP_ACT_ALLOW",
"args": [{"index": 0, "value": p::CLONE_NS_MASK, "valueTwo": 0, "op": "SCMP_CMP_MASKED_EQ"}],
}));
rules.push(json!({
"names": ["clone3"],
"action": "SCMP_ACT_ERRNO",
"errnoRet": p::CLONE3_ERRNO,
}));
json!({
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": p::DEFAULT_ERRNO,
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": rules,
})
}
fn bind(src: &Path, dst: &str, rw: bool) -> Value {
json!({
"destination": dst,
"type": "bind",
"source": src,
"options": ["rbind", if rw { "rw" } else { "ro" }, "nosuid", "nodev"],
})
}
/// The cgroup v2 CPU quota for `cpus` CPUs (at least 1 ms per period).
pub fn cpu_quota(cpus: f32) -> i64 {
let q = (f64::from(cpus) * CPU_PERIOD_US as f64).round();
(q as i64).max(1000)
}
/// The full `config.json` for one job. `rootfs` is relative to the bundle.
pub fn build(s: &SpecInput<'_>) -> Value {
let mut mounts = vec![
json!({"destination": "/proc", "type": "proc", "source": "proc", "options": ["nosuid", "noexec", "nodev"]}),
json!({"destination": "/dev", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]}),
json!({"destination": "/dev/pts", "type": "devpts", "source": "devpts", "options": ["nosuid", "noexec", "newinstance", "ptmxmode=0666", "mode=0620"]}),
json!({"destination": "/dev/shm", "type": "tmpfs", "source": "shm", "options": ["nosuid", "noexec", "nodev", "mode=1777", "size=65536k"]}),
json!({"destination": "/dev/mqueue", "type": "mqueue", "source": "mqueue", "options": ["nosuid", "noexec", "nodev"]}),
json!({"destination": "/tmp", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid", "nodev", "mode=1777", format!("size={TMP_SIZE}")]}),
bind(s.input_dir, IN_MOUNT, false),
bind(s.output_dir, OUT_MOUNT, true),
];
for r in s.read {
mounts.push(bind(r, &r.to_string_lossy(), false));
}
for w in s.write {
mounts.push(bind(w, &w.to_string_lossy(), true));
}
let l = s.limits;
let mem = i64::try_from(l.memory_bytes).unwrap_or(i64::MAX);
json!({
"ociVersion": "1.0.2",
"root": {"path": "rootfs", "readonly": true},
"hostname": "heimdall-job",
"process": {
"terminal": false,
"user": {"uid": 0, "gid": 0},
"args": s.argv,
"env": s.env,
"cwd": "/",
"noNewPrivileges": true,
"capabilities": {
"bounding": [], "effective": [], "inheritable": [], "permitted": [], "ambient": []
},
"rlimits": [
{"type": "RLIMIT_NOFILE", "hard": 1024u64, "soft": 1024u64},
{"type": "RLIMIT_CORE", "hard": 0u64, "soft": 0u64}
]
},
"mounts": mounts,
"linux": {
"uidMappings": [{"containerID": 0, "hostID": s.uid, "size": 1}],
"gidMappings": [{"containerID": 0, "hostID": s.gid, "size": 1}],
"namespaces": [
{"type": "user"}, {"type": "mount"}, {"type": "pid"}, {"type": "ipc"},
{"type": "uts"}, {"type": "network"}, {"type": "cgroup"}
],
"cgroupsPath": s.cgroup_path,
"resources": {
"memory": {"limit": mem, "swap": mem},
"pids": {"limit": i64::from(l.pids)},
"cpu": {"quota": cpu_quota(l.cpus), "period": CPU_PERIOD_US}
},
"seccomp": seccomp_json(),
"maskedPaths": [
"/proc/acpi", "/proc/asound", "/proc/kcore", "/proc/keys", "/proc/latency_stats",
"/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi"
],
"readonlyPaths": [
"/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger"
]
}
})
}