[pitboss] phase 19: Track E.3 — Docker backend + nyx-image-builder + pinned digests

This commit is contained in:
pitboss 2026-05-15 11:03:31 -05:00
parent 6ca9bddedb
commit 7ca0c053f5
9 changed files with 1412 additions and 0 deletions

View file

@ -0,0 +1,261 @@
//! Phase 19 (Track E.3) — Docker backend helpers.
//!
//! This module is the thin layer between the pinned-digest catalogue
//! (`tools/image-builder/images.toml` → `src/dynamic/toolchain.rs::IMAGE_DIGESTS`)
//! and the existing docker invocations in [`super::run_docker`] /
//! [`super::run_native_binary_docker`].
//!
//! Responsibilities:
//!
//! 1. Resolve a `toolchain_id` → pinned image reference (`<base>@sha256:…`),
//! falling back to the unpinned base tag when no digest is recorded yet.
//! 2. Pull the resolved reference if it is not already present locally so
//! every backend hop runs against the exact bytes the catalogue pinned.
//! 3. Render the docker CLI arg slice that:
//! - mounts the harness workdir read-write at the fixed `/work` path,
//! - mounts each `StubHarness` filesystem root at a fixed `/nyx/stubs/<n>`
//! path so harness-side shims can find them without hard-coding host
//! tempdir layouts,
//! - honours the [`super::NetworkPolicy`] (none / OOB / stubs-only / open)
//! using the same flag set as the legacy `start_container`.
//!
//! All helpers are infallible w.r.t. docker availability — they return arg
//! slices and `Option<String>` references that the caller (`super::`) ships
//! to the docker CLI. That keeps the module easy to unit-test on macOS / CI
//! rows that do not have docker installed.
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
use crate::dynamic::toolchain::{base_image_ref, pinned_image_ref};
use super::{HostPort, NetworkPolicy};
// ── Image references ────────────────────────────────────────────────────────
/// Container-side mount point for the harness workdir. Stable so per-language
/// emitters can reference `/work/...` without threading the host tempdir path
/// through every layer.
pub const WORK_MOUNT_PATH: &str = "/work";
/// Container-side mount point root for `StubHarness` filesystem stubs.
/// Each stub is mounted at `STUB_MOUNT_ROOT/<n>` where `<n>` is its index in
/// the harness's stub list.
pub const STUB_MOUNT_ROOT: &str = "/nyx/stubs";
/// Resolve a `toolchain_id` to the docker image reference the backend should
/// pull. Preference order:
///
/// 1. Pinned digest from `IMAGE_DIGESTS` (`<base>@sha256:…`). Bytes are
/// immutable across hosts; this is what production uses.
/// 2. Base tag from `IMAGE_BASES` (`python:3.11-slim`). Used when the
/// catalogue entry has not been built yet — drift is visible because the
/// daily CI workflow runs `nyx-image-builder build --all` and PRs the
/// digest.
/// 3. `None` — the toolchain is not in the catalogue at all. Callers fall
/// back to the historical hard-coded image map.
pub fn image_reference_for_toolchain(toolchain_id: &str) -> Option<&'static str> {
if let Some(pinned) = pinned_image_ref(toolchain_id) {
return Some(pinned);
}
base_image_ref(toolchain_id)
}
/// `true` when `image_reference_for_toolchain` would return a pinned digest
/// (rather than a bare tag). Used by telemetry + tests.
pub fn toolchain_is_pinned(toolchain_id: &str) -> bool {
pinned_image_ref(toolchain_id).is_some()
}
// ── Pull-by-digest ──────────────────────────────────────────────────────────
/// `docker pull <image>` once per process. Cached so repeated harness runs
/// against the same image do not re-hit the registry.
///
/// Returns `true` if the image is now present locally; `false` if the pull
/// failed (network outage, untagged digest, registry auth, …). Callers
/// treat `false` as a docker-backend-unavailable signal so the verifier can
/// route around it cleanly.
pub fn ensure_image_pulled(image: &str) -> bool {
static CACHE: OnceLock<dashmap::DashMap<String, bool>> = OnceLock::new();
let cache = CACHE.get_or_init(dashmap::DashMap::new);
if let Some(entry) = cache.get(image) {
return *entry;
}
let ok = docker_pull(image);
cache.insert(image.to_owned(), ok);
ok
}
fn docker_pull(image: &str) -> bool {
Command::new(docker_bin())
.args(["pull", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn docker_bin() -> String {
std::env::var("NYX_DOCKER_BIN").unwrap_or_else(|_| "docker".to_owned())
}
// ── Argument assembly ───────────────────────────────────────────────────────
/// Render the `docker run` flag slice that mounts the harness workdir at
/// [`WORK_MOUNT_PATH`] read-write. Always returns a `-v host:/work:rw`
/// pair; an empty workdir is mounted at the same path so harness code can
/// stage outputs under `/work/...` unconditionally.
///
/// Returns owned strings so the caller can `extend` them into its already-
/// built `Vec<String>` arg list without lifetime drag.
pub fn workdir_mount_args(workdir: &Path) -> Vec<String> {
let host = workdir.to_string_lossy().into_owned();
vec!["-v".to_owned(), format!("{host}:{WORK_MOUNT_PATH}:rw")]
}
/// Render the `docker run` flag slice that mounts each filesystem-stub root
/// at a fixed path under [`STUB_MOUNT_ROOT`]. Network stubs (SQL TCP loop,
/// HTTP, Redis) do not appear here — they reach the harness via
/// `--add-host=host-gateway` and the env vars threaded through
/// `SandboxOptions::extra_env`.
///
/// Each entry maps to `-v <host>:<STUB_MOUNT_ROOT>/<index>:rw`. Read-write
/// because stubs record events into the path.
pub fn stub_mount_args(stub_roots: &[std::path::PathBuf]) -> Vec<String> {
let mut out = Vec::with_capacity(stub_roots.len() * 2);
for (idx, root) in stub_roots.iter().enumerate() {
let host = root.to_string_lossy().into_owned();
out.push("-v".to_owned());
out.push(format!("{host}:{STUB_MOUNT_ROOT}/{idx}:rw"));
}
out
}
/// Render the `--network` + `--add-host` flag slice for a [`NetworkPolicy`].
///
/// Mirrors the legacy block in [`super::start_container`] so callers using
/// the new docker.rs entry point produce byte-identical container layouts
/// to the existing path — important for `tests/dynamic_parity.rs` to keep
/// reading the same verdicts across backends.
pub fn network_args(policy: &NetworkPolicy) -> Vec<String> {
let mut args = Vec::with_capacity(4);
match policy {
NetworkPolicy::None => {
args.extend(["--network".to_owned(), "none".to_owned()]);
}
NetworkPolicy::OobOutbound { .. } => {
args.extend(["--network".to_owned(), "bridge".to_owned()]);
args.push("--add-host=host-gateway:host-gateway".to_owned());
}
NetworkPolicy::StubsOnly { allow } => {
args.extend(["--network".to_owned(), "bridge".to_owned()]);
args.push("--add-host=host-gateway:host-gateway".to_owned());
for hp in allow {
args.push(add_host_arg(hp));
}
}
NetworkPolicy::Open => {
args.extend(["--network".to_owned(), "bridge".to_owned()]);
}
}
args
}
fn add_host_arg(hp: &HostPort) -> String {
format!("--add-host={}:host-gateway", hp.host)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
#[test]
fn workdir_mount_args_uses_fixed_path() {
let path = Path::new("/tmp/nyx-harness/abc");
let args = workdir_mount_args(path);
assert_eq!(args, vec!["-v", "/tmp/nyx-harness/abc:/work:rw"]);
}
#[test]
fn stub_mount_args_indexes_each_root() {
let roots = vec![PathBuf::from("/tmp/stub-a"), PathBuf::from("/tmp/stub-b")];
let args = stub_mount_args(&roots);
assert_eq!(
args,
vec![
"-v",
"/tmp/stub-a:/nyx/stubs/0:rw",
"-v",
"/tmp/stub-b:/nyx/stubs/1:rw",
],
);
}
#[test]
fn stub_mount_args_empty_when_no_stubs() {
assert!(stub_mount_args(&[]).is_empty());
}
#[test]
fn network_args_none_picks_network_none() {
let args = network_args(&NetworkPolicy::None);
assert!(args.iter().any(|a| a == "none"));
}
#[test]
fn network_args_stubs_only_adds_host_aliases() {
let policy = NetworkPolicy::StubsOnly {
allow: vec![HostPort::new("sql", 5432), HostPort::new("redis", 6379)],
};
let args = network_args(&policy);
assert!(args.iter().any(|a| a == "--add-host=sql:host-gateway"));
assert!(args.iter().any(|a| a == "--add-host=redis:host-gateway"));
}
#[test]
fn network_args_open_drops_egress_filter() {
let args = network_args(&NetworkPolicy::Open);
// Open is bridge but no host-gateway alias.
assert!(args.iter().any(|a| a == "bridge"));
assert!(!args.iter().any(|a| a.starts_with("--add-host=")));
}
#[test]
fn network_args_oob_threads_host_gateway() {
let listener = Arc::new(
crate::dynamic::oob::OobListener::bind()
.expect("oob listener must bind on 127.0.0.1 in tests"),
);
let args = network_args(&NetworkPolicy::OobOutbound { listener });
assert!(args.iter().any(|a| a == "--add-host=host-gateway:host-gateway"));
}
#[test]
fn image_reference_for_toolchain_unknown_returns_none() {
assert_eq!(image_reference_for_toolchain("python-99.x"), None);
}
#[test]
fn image_reference_for_toolchain_known_returns_base_when_unpinned() {
// The catalogue ships with empty digests; we therefore expect the
// bare base tag for known IDs. When the daily CI run pins a real
// digest this test will start seeing `<base>@sha256:…` instead, and
// we update the assertion accordingly.
let r = image_reference_for_toolchain("python-3.11");
assert!(r.is_some());
assert!(r.unwrap().contains("python"));
}
#[test]
fn toolchain_is_pinned_false_when_digest_empty() {
// Fresh catalogue ships with empty digests, so every known toolchain
// is still considered unpinned until the daily CI run.
assert!(!toolchain_is_pinned("python-3.11"));
}
}

View file

@ -40,6 +40,17 @@ pub use process_linux::{HardeningLevel, HardeningOutcome};
#[cfg(target_os = "macos")]
pub mod process_macos;
/// Phase 19 (Track E.3) — pinned-digest docker backend helpers.
///
/// The functions in this module resolve [`crate::dynamic::toolchain::
/// IMAGE_DIGESTS`] entries to docker image refs, render `docker run`
/// flag slices that honour [`NetworkPolicy`], and mount the harness
/// workdir at the fixed `/work` path. The legacy entry points in this
/// file ([`run_docker`] / [`run_native_binary_docker`]) call into
/// `docker::ensure_image_pulled` so every harness run uses the catalogue
/// pin when one is available.
pub mod docker;
// ── Harness interpretation probe ──────────────────────────────────────────────
/// Returns true when the harness is driven by an interpreter (Python, Node, …)
@ -725,6 +736,19 @@ fn start_container(
image: &str,
policy: &NetworkPolicy,
) -> Result<(), SandboxError> {
// Phase 19 (Track E.3): when `image` is a pinned reference produced by
// `docker::image_reference_for_toolchain`, make sure it is present on
// this host before `docker run` tries to start a container from it.
// `ensure_image_pulled` is a per-process cache, so the second harness
// against the same toolchain is free.
docker::ensure_image_pulled(image);
let workdir_mount = format!(
"{}:{}:rw",
workdir.to_string_lossy(),
docker::WORK_MOUNT_PATH,
);
let mut run_args: Vec<String> = vec![
"run".into(),
"-d".into(),
@ -733,6 +757,13 @@ fn start_container(
"--cap-drop=ALL".into(),
"--security-opt".into(), "no-new-privileges:true".into(),
"--tmpfs".into(), "/tmp:size=128m,exec".into(),
// Phase 19 (Track E.3): bind-mount the host workdir at the fixed
// `/work` path read-write. Harness code emitted in Phase 12+ can
// reference `/work/...` without threading the host tempdir
// through every layer. The `docker cp` path below is retained so
// older harness command lines (which still look at `/workdir`)
// keep working until they are migrated.
"-v".into(), workdir_mount,
];
match policy {
NetworkPolicy::None => {
@ -978,6 +1009,12 @@ fn exec_in_container(
/// Dispatches by the basename of `command[0]` (e.g. `python3`, `node`, `java`,
/// `php`). Falls back to `python:3-slim` for unrecognised interpreters.
/// `NYX_TOOLCHAIN_ID` env var overrides the version portion of the image tag.
///
/// Phase 19 (Track E.3): when `NYX_TOOLCHAIN_ID` matches a pinned entry in
/// `IMAGE_DIGESTS` we return the `<base>@sha256:…` reference directly so the
/// container starts from byte-identical bits across hosts. Unpinned entries
/// fall through to the legacy tag mapping below so behaviour on a fresh
/// catalogue stays unchanged.
fn detect_image_for_harness(harness: &BuiltHarness) -> String {
let cmd0 = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
let base = std::path::Path::new(cmd0)
@ -986,6 +1023,12 @@ fn detect_image_for_harness(harness: &BuiltHarness) -> String {
.unwrap_or(cmd0);
if let Ok(tid) = std::env::var("NYX_TOOLCHAIN_ID") {
if let Some(pinned) = docker::image_reference_for_toolchain(&tid) {
// Catalogue entry takes priority over the legacy hard-coded tag
// map — pinned or unpinned, the value here came from
// tools/image-builder/images.toml.
return pinned.to_owned();
}
return match base {
"node" | "nodejs" => node_image_for_toolchain(&tid),
"java" => java_image_for_toolchain(&tid),

View file

@ -7,6 +7,37 @@
use std::path::Path;
// Phase 19 (Track E.3): generated lookup tables for pinned Docker image
// digests. Populated by `build.rs` from `tools/image-builder/images.toml`.
//
// - `IMAGE_DIGESTS`: `toolchain_id → "<base>@sha256:…"`. Used by the docker
// backend (`src/dynamic/sandbox/docker.rs`) to pull a pinned digest so the
// sandboxed runtime is byte-identical between hosts.
// - `IMAGE_BASES`: `toolchain_id → "<base-tag>"`. Fallback for the docker
// backend when no digest is pinned yet (e.g. fresh `images.toml` entry).
include!(concat!(env!("OUT_DIR"), "/image_digests.rs"));
/// Pinned image reference (`<base>@sha256:…`) for `toolchain_id`, or `None`
/// when the catalogue entry has not been built yet.
///
/// Phase 19 keeps the pin pure-static: `nyx-image-builder build` writes the
/// digest back into `images.toml`, the daily CI workflow opens a PR with the
/// new bytes, and a regular Rust rebuild picks up the new digest via
/// `build.rs`. There is no runtime digest fetch on the hot path.
pub fn pinned_image_ref(toolchain_id: &str) -> Option<&'static str> {
IMAGE_DIGESTS.get(toolchain_id).copied()
}
/// Base image tag (no digest) for `toolchain_id`, or `None` when the
/// toolchain is not present in the catalogue.
///
/// Used by the docker backend when [`pinned_image_ref`] returns `None`: the
/// backend issues a tag pull and records the resolved digest in telemetry so
/// drift is visible to operators even when the catalogue is unpinned.
pub fn base_image_ref(toolchain_id: &str) -> Option<&'static str> {
IMAGE_BASES.get(toolchain_id).copied()
}
/// Resolved toolchain information for a target directory.
#[derive(Debug, Clone)]
pub struct ToolchainResolution {