Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/scan-worker/tests/sidecar.rs

//! **The scan worker as a SIDECAR** (lane B4, A31 §7.3): the real
//! `holger-scan-worker serve` binary on a unix socket, and the server's own
//! [`ScanWorker`] over a `Remote` sandbox that asks it. This is the Kubernetes
//! shape without Kubernetes: two processes, one shared directory, no runtime.

use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};

use holger_sandbox::{EngineKind, Sandbox, SandboxConfig};
use server_lib::sandbox::{JobTemplate, ScanWorker};
use server_lib::scanner::{ScanSubject, ScannerKind};

struct Sidecar {
    child: Child,
    _tmp: tempfile::TempDir,
    share: PathBuf,
    socket: PathBuf,
}

impl Drop for Sidecar {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

fn start() -> Sidecar {
    let tmp = tempfile::tempdir().unwrap();
    let share = tmp.path().join("share");
    let scratch = tmp.path().join("scratch");
    std::fs::create_dir_all(&share).unwrap();
    std::fs::create_dir_all(&scratch).unwrap();
    let socket = share.join("worker.sock");
    let child = Command::new(env!("CARGO_BIN_EXE_holger-scan-worker"))
        .arg("serve")
        .arg("--socket")
        .arg(&socket)
        .arg("--share")
        .arg(&share)
        .arg("--scratch")
        .arg(&scratch)
        .stdin(Stdio::null())
        .spawn()
        .expect("the sidecar starts");
    wait_for(&socket);
    Sidecar {
        child,
        _tmp: tmp,
        share,
        socket,
    }
}

fn wait_for(socket: &Path) {
    let t = Instant::now();
    while !socket.exists() {
        assert!(
            t.elapsed() < Duration::from_secs(20),
            "the sidecar never bound {}",
            socket.display()
        );
        std::thread::sleep(Duration::from_millis(20));
    }
}

fn worker(side: &Sidecar) -> Arc<ScanWorker> {
    let cfg = SandboxConfig {
        engine: EngineKind::Remote,
        socket: Some(side.socket.display().to_string()),
        work_dir: Some(side.share.display().to_string()),
        ..Default::default()
    };
    let template = JobTemplate {
        format_name: "raw".into(),
        inspect: holger_inspect::InspectConfig::default(),
        kinds: vec![
            ScannerKind::Inspection,
            ScannerKind::License,
            ScannerKind::Sbom,
        ],
        license: Default::default(),
        allow_pickle: Vec::new(),
        secrets: None,
    };
    Arc::new(ScanWorker::with_sandbox(Sandbox::from_config(cfg).unwrap(), template).unwrap())
}

#[test]
fn the_server_scans_through_the_sidecar_and_the_report_is_checked() {
    let side = start();
    let w = worker(&side);
    assert!(w.is_isolated());
    w.ready().expect("the sidecar answers");
    let bytes = b"hello from the shared directory\n";
    let r = w
        .report(&ScanSubject::new("hello.txt", "raw", bytes))
        .expect("the scan runs in the sidecar");
    use sha2::Digest as _;
    let want: String = sha2::Sha256::digest(bytes)
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect();
    assert_eq!(r.sha256, want, "the worker read the bytes the server wrote");
    assert_eq!(w.runs(), 1);
}

#[test]
fn the_probe_through_the_sidecar_shows_no_new_privileges_and_no_network_client() {
    let side = start();
    let v = worker(&side)
        .probe()
        .expect("the probe runs in the sidecar");
    assert_eq!(v["no_new_privs"], "1", "{v}");
    assert!(v["engine"].as_str().unwrap().contains("sidecar"), "{v}");
}

/// **The probe fits the job's own limits** (lane OX-HOLFLAV). In a Kubernetes pod
/// `GET /-/sandbox/probe` answered `[holger-sandbox-3] the scan worker was killed by
/// its limits`: the probe built its 1 GiB "past the scratch cap" write as one 1 GiB
/// buffer, which the sidecar's `RLIMIT_AS` (the job's memory plus 64 MiB) refuses,
/// and a write past `RLIMIT_FSIZE` is a SIGXFSZ, not an error. The probe now writes
/// its scratch in the job's own `TMPDIR` in 1 MiB pieces and reads the cap as a
/// refusal, under a job memory far below 1 GiB.
#[test]
fn the_probe_runs_inside_a_small_job_memory_and_scratch_limit() {
    let side = start();
    let cfg = SandboxConfig {
        engine: EngineKind::Remote,
        socket: Some(side.socket.display().to_string()),
        work_dir: Some(side.share.display().to_string()),
        limits: holger_sandbox::Limits {
            memory_mib: 256,
            scratch_mib: 32,
            ..Default::default()
        },
        ..Default::default()
    };
    let template = JobTemplate {
        format_name: "raw".into(),
        inspect: holger_inspect::InspectConfig::default(),
        kinds: vec![ScannerKind::Inspection],
        license: Default::default(),
        allow_pickle: Vec::new(),
        secrets: None,
    };
    let w = ScanWorker::with_sandbox(Sandbox::from_config(cfg).unwrap(), template).unwrap();
    let v = w
        .probe()
        .expect("the probe runs inside a 256 MiB job with a 32 MiB scratch");
    assert_eq!(v["write_scratch"], "written", "{v}");
    assert!(
        v["write_past_scratch_cap"]
            .as_str()
            .unwrap()
            .starts_with("refused"),
        "the scratch cap holds: {v}"
    );
}

#[test]
fn the_sidecar_gone_makes_the_worker_not_ready_by_name() {
    let mut side = start();
    let w = worker(&side);
    w.ready().unwrap();
    let _ = side.child.kill();
    let _ = side.child.wait();
    let _ = std::fs::remove_file(&side.socket);
    let e = w.ready().unwrap_err();
    assert_eq!(e.code().code, "holger-sandbox-1", "{e}");
}

/// The sidecar is PID 1 in its container, which ignores a signal it has no
/// handler for: without one the kubelet waits out the grace period and kills it.
#[test]
fn sigterm_ends_the_sidecar_and_takes_its_socket_with_it() {
    let mut side = start();
    let pid = rustix::process::Pid::from_raw(side.child.id() as i32).unwrap();
    rustix::process::kill_process(pid, rustix::process::Signal::TERM).unwrap();
    let t = Instant::now();
    let status = loop {
        if let Some(s) = side.child.try_wait().unwrap() {
            break s;
        }
        assert!(
            t.elapsed() < Duration::from_secs(5),
            "the sidecar ignored SIGTERM"
        );
        std::thread::sleep(Duration::from_millis(20));
    };
    assert!(status.success(), "a clean exit: {status:?}");
    assert!(!side.socket.exists(), "the socket is removed");
}