mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 19: Track E.3 — Docker backend + nyx-image-builder + pinned digests
This commit is contained in:
parent
6ca9bddedb
commit
7ca0c053f5
9 changed files with 1412 additions and 0 deletions
261
src/dynamic/sandbox/docker.rs
Normal file
261
src/dynamic/sandbox/docker.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue