jobs/runner/src/container.rs
//! **`ContainerRunner`: the interim runner** over Holger's own sandbox
//! (`holger_sandbox`): one fresh container per job (podman, its API socket, or crun
//! over the verified rootfs), or one fresh rlimited subprocess in the scan sidecar
//! (`engine: remote`). No network, a read-only root, every capability dropped,
//! no-new-privileges, a non-root user, the engine's default seccomp (none in the
//! sidecar), the job's input read-only at `/in/i` and its one writable `/out`.
//!
//! It is NOT Heimdall: no generated syscall profile, no Landlock, no broker, no
//! wasm. [`ContainerRunner::features`] says so, and in strict CDS mode
//! (`boundary: OneWay`) Holger warns at startup.
use std::path::Path;
use heimdall_core::{
ExitInfo, Features, Job, JobKind, JobResult, JobRunner, KillReason, Limits, Protection,
RunError, SignedResult, Verdict, VerifyingKey,
};
use holger_sandbox::{EngineKind, Mount, Refusal, Request, Sandbox};
use crate::collect::{collect, empty, refused};
use crate::key::ResultKey;
use crate::wire::{read_mount, Answer, WireJob, INPUT_MOUNT, JOB_SCHEMA};
/// The interim runner.
pub struct ContainerRunner {
sandbox: Sandbox,
key: ResultKey,
}
impl std::fmt::Debug for ContainerRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ContainerRunner")
.field("engine", &self.sandbox.engine_words())
.field("key", &self.key)
.finish()
}
}
impl ContainerRunner {
/// The runner over `sandbox`, with a fresh per-boot key.
pub fn new(sandbox: Sandbox) -> Self {
Self::with_key(sandbox, ResultKey::per_boot())
}
/// The runner over `sandbox` with `key` (tests).
pub fn with_key(sandbox: Sandbox, key: ResultKey) -> Self {
Self { sandbox, key }
}
pub fn sandbox(&self) -> &Sandbox {
&self.sandbox
}
/// What protects a job under `engine`, stated truthfully.
pub fn features_of(engine: EngineKind) -> Features {
let (runner, container, seccomp) = match engine {
EngineKind::Podman => ("holger-podman", true, Protection::Default),
EngineKind::Crun => ("holger-crun", true, Protection::Default),
// A fresh rlimited subprocess in the scan sidecar's container: no container
// of its own per job, and no seccomp filter the sidecar installs.
EngineKind::Remote => ("holger-sidecar", false, Protection::None),
// Never a sandbox of this runner (`Sandbox::from_config` refuses it); said
// plainly should one be built by hand.
EngineKind::Heimdall => ("holger-not-heimdall", false, Protection::None),
};
Features {
runner: runner.into(),
container,
seccomp,
landlock: false,
broker: false,
wasm: false,
image_verified_each_start: false,
}
}
/// The sandbox's limits for one job: the job's own, with the config's scratch
/// space (below the memory) and report cap.
pub fn limits_for(&self, l: &Limits) -> holger_sandbox::Limits {
let cfg = &self.sandbox.config().limits;
let memory_mib = (l.memory_bytes >> 20).max(64);
holger_sandbox::Limits {
cpu_millis: ((f64::from(l.cpus) * 1000.0).round() as u64).max(1),
memory_mib,
pids: u64::from(l.pids).max(8),
scratch_mib: cfg.scratch_mib.min(memory_mib / 2).max(1),
timeout_secs: l.wall.as_secs().max(1),
report_max_bytes: cfg.report_max_bytes,
}
}
}
impl JobRunner for ContainerRunner {
fn run(&self, job: Job) -> Result<SignedResult, RunError> {
let argv = native_argv(&job)?;
if job.policy.net.is_some() {
return Err(RunError::Unsupported { what: "a network allowance: the interim container runner gives a job no network at all".into() });
}
if !job.policy.write.is_empty() {
return Err(RunError::Unsupported {
what: "a writable path besides the output directory".into(),
});
}
check_dirs(&job)?;
make_readable(&job.input_dir);
let wire = WireJob {
schema: JOB_SCHEMA.into(),
job_id: job.id.to_hex(),
argv,
params: job.params.clone(),
reads: job.policy.read.len(),
answer: None,
};
let doc = serde_json::to_vec(&wire).map_err(|e| RunError::Io {
reason: format!("the job document: {e}"),
})?;
let mut mounts = vec![Mount::new(&job.input_dir, INPUT_MOUNT)];
for (n, p) in job.policy.read.iter().enumerate() {
if !p.is_absolute() {
return Err(RunError::InvalidJob {
reason: format!("the read path {} is not absolute", p.display()),
});
}
mounts.push(Mount::new(p, read_mount(n)));
}
let name = job.id.to_hex();
let req = Request {
artifact: b"",
job: &doc,
mounts,
sha256: &name,
command: Vec::new(),
};
let started = now_unix();
let (verdict, outputs, exit) =
match self
.sandbox
.run_job(&req, &job.output_dir, &self.limits_for(&job.policy.limits))
{
Ok(o) => match Answer::parse(&o.report) {
Ok(a) => {
let (v, outs) = collect(&a, &job.output_dir);
(v, outs, ExitInfo::exited(0))
}
Err(why) => {
empty(&job.output_dir);
(
refused(&holger_errcode::SANDBOX_JOB_ANSWER_INVALID, why),
Vec::new(),
ExitInfo::exited(0),
)
}
},
Err(r) => {
empty(&job.output_dir);
let exit = match &r {
Refusal::Timeout { .. } => ExitInfo {
killed: Some(KillReason::Wall),
..Default::default()
},
Refusal::Killed { exit, oom, .. } => ExitInfo {
code: *exit,
killed: oom.then_some(KillReason::Memory),
..Default::default()
},
Refusal::WorkerFailed { exit, .. } => ExitInfo {
code: *exit,
..Default::default()
},
Refusal::ReportTooLarge { .. } | Refusal::ReportInvalid(_) => {
ExitInfo::default()
}
Refusal::Config(_) => {
return Err(RunError::InvalidJob {
reason: r.to_string(),
})
}
Refusal::Io(_) => {
return Err(RunError::Io {
reason: r.to_string(),
})
}
Refusal::ImageMissing(_) => {
return Err(RunError::Image {
reason: r.to_string(),
})
}
_ => {
return Err(RunError::Setup {
reason: r.to_string(),
})
}
};
(
Verdict::Refused {
code: r.code().code.into(),
reason: r.to_string(),
},
Vec::new(),
exit,
)
}
};
let result = JobResult {
job_id: job.id,
nonce: job.nonce,
verdict,
outputs,
exit,
started_unix: started,
finished_unix: now_unix().max(started),
runner: self.features(),
events: Vec::new(),
// This runner probes no kernel (Heimdall 0.2's `JobResult.kernel`).
kernel: None,
};
self.key.sign(result)
}
fn verifying_key(&self) -> VerifyingKey {
self.key.verifying_key()
}
fn features(&self) -> Features {
Self::features_of(self.sandbox.config().engine)
}
}
/// The argv of a native job of Holger's own worker; anything else is refused.
pub(crate) fn native_argv(job: &Job) -> Result<Vec<String>, RunError> {
match &job.kind {
JobKind::Wasm { .. } => Err(RunError::Unsupported {
what: "a wasm guest: this runner runs native workers only".into(),
}),
JobKind::Native { image, .. } if image.name != crate::WORKER => {
Err(RunError::Unsupported {
what: format!(
"the image {:?}: this runner runs Holger's own worker ({}) only",
image.name,
crate::WORKER
),
})
}
JobKind::Native { argv, .. } if argv.is_empty() => Err(RunError::InvalidJob {
reason: "the job names no program".into(),
}),
JobKind::Native { argv, .. } => Ok(argv.clone()),
}
}
/// The input is an absolute directory, the output an absolute, empty one.
pub(crate) fn check_dirs(job: &Job) -> Result<(), RunError> {
for (what, d) in [("input", &job.input_dir), ("output", &job.output_dir)] {
if !d.is_absolute() || !d.is_dir() {
return Err(RunError::InvalidJob {
reason: format!(
"the job's {what} directory {} is not an absolute directory",
d.display()
),
});
}
}
let mut there = std::fs::read_dir(&job.output_dir).map_err(|e| RunError::Io {
reason: format!("{}: {e}", job.output_dir.display()),
})?;
if there.next().is_some() {
return Err(RunError::InvalidJob {
reason: format!(
"the job's output directory {} is not empty",
job.output_dir.display()
),
});
}
Ok(())
}
/// The input tree readable by the job's (mapped, non-root) user: directories 0755,
/// regular files 0444. Links are not followed. Modes only: nothing is opened.
fn make_readable(dir: &Path) {
use std::os::unix::fs::PermissionsExt as _;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755));
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
match e.file_type() {
Ok(t) if t.is_dir() => make_readable(&e.path()),
Ok(t) if t.is_file() => {
let _ = std::fs::set_permissions(e.path(), std::fs::Permissions::from_mode(0o444));
}
_ => {}
}
}
}
pub(crate) fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}