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

nordisk/nornir

tests/cli_jobs.rs

//! CLI ⟺ ledger parity for `nornir jobs` — the reader that was missing (the job
//! engine had a writer everywhere + a `Viz.Jobs` RPC + a viz tab, but no way to
//! *query the ledger from the CLI*). This drives the real `nornir` binary against
//! an isolated `$HOME` + an explicit `--config` (so the workspace NAME, hence the
//! `jobs.redb` path, is deterministic), and asserts the command reflects exactly
//! what the library wrote: inject records → read them back through the CLI.

use std::path::{Path, PathBuf};
use std::process::Command;

use nornir::config::Loaded;
use nornir::jobs::{JobRecord, JobStore, kind, status};

fn nornir() -> Command {
    Command::new(env!("CARGO_BIN_EXE_nornir"))
}

/// Write a minimal `workspace_jobstest/release/nornir.toml` under `home`, returning
/// its path. The `workspace_` prefix in the path makes `workspace_name()` resolve
/// to `jobstest` for both the test and the spawned CLI.
fn write_config(home: &Path) -> PathBuf {
    let cfg = home.join("workspace_jobstest/release/nornir.toml");
    std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
    // A repo entry is required to be a valid nornir.toml; jobs reads don't touch it.
    std::fs::write(&cfg, "[repo.demo]\npath = \"demo\"\n").unwrap();
    cfg
}

/// The exact `jobs.redb` root the CLI opens for this config under `home`.
fn warehouse_root(home: &Path, cfg: &Path) -> PathBuf {
    let prev = std::env::var_os("HOME");
    // SAFETY: this is the only test in the file; it runs single-threaded.
    unsafe { std::env::set_var("HOME", home) };
    let loaded = Loaded {
        nornir: Default::default(),
        config_path: cfg.to_path_buf(),
        workspace_root: cfg.parent().unwrap().to_path_buf(),
    };
    let root = loaded.warehouse_root();
    match prev {
        Some(p) => unsafe { std::env::set_var("HOME", p) },
        None => unsafe { std::env::remove_var("HOME") },
    }
    root
}

fn run_jobs(home: &Path, cfg: &Path, extra: &[&str]) -> std::process::Output {
    let mut c = nornir();
    c.arg("--config").arg(cfg).arg("jobs").args(extra);
    c.env("HOME", home).env_remove("NORNIR_SERVER");
    c.output().expect("run nornir jobs")
}

#[test]
fn jobs_empty_then_reflects_seeded_records() {
    let home = tempfile::tempdir().expect("tempdir");
    let cfg = write_config(home.path());

    // 1) Empty ledger → empty JSON array.
    let out = run_jobs(home.path(), &cfg, &["--json"]);
    assert!(
        out.status.success(),
        "jobs --json: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(
        String::from_utf8_lossy(&out.stdout).trim(),
        "[]",
        "no jobs yet ⇒ []"
    );

    // 2) Inject real records into the EXACT jobs.redb the CLI reads.
    let root = warehouse_root(home.path(), &cfg);
    let store = JobStore::open(&root).expect("open jobs ledger");
    let h = store.start(
        kind::CONTAINER,
        "alpine:3",
        "jobstest",
        serde_json::json!({"x": 1}),
    );
    let id_done = h.job_id().to_string();
    h.finish(serde_json::json!({"exit_code": 0}), "");
    let _live = store.start(
        kind::BENCH_RUN,
        "facett",
        "jobstest",
        serde_json::Value::Null,
    );
    drop(store); // release the single-writer lock before the CLI opens it

    // 3) `--json` returns BOTH.
    let out = run_jobs(home.path(), &cfg, &["--json"]);
    assert!(
        out.status.success(),
        "jobs --json: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let rows: Vec<JobRecord> = serde_json::from_slice(&out.stdout).expect("JobRecord array");
    assert_eq!(rows.len(), 2, "both injected jobs read back: {rows:?}");
    assert!(
        rows.iter()
            .any(|r| r.job_id == id_done && r.status == status::DONE && r.kind == kind::CONTAINER)
    );
    assert!(
        rows.iter()
            .any(|r| r.kind == kind::BENCH_RUN && !r.is_terminal())
    );

    // 4) `--kind container` scopes to the container job only.
    let out = run_jobs(home.path(), &cfg, &["--kind", "container", "--json"]);
    let rows: Vec<JobRecord> = serde_json::from_slice(&out.stdout).expect("JobRecord array");
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].kind, kind::CONTAINER);

    // 5) `--running` keeps only the non-terminal job; table mode renders + counts it.
    let out = run_jobs(home.path(), &cfg, &["--running"]);
    assert!(out.status.success());
    let text = String::from_utf8_lossy(&out.stdout);
    assert!(
        text.contains("bench_run"),
        "running view shows the live bench job: {text}"
    );
    assert!(
        !text.contains(&id_done),
        "running view hides the finished container job"
    );
    assert!(
        text.contains("1 running"),
        "summary counts the running job: {text}"
    );
}

/// `nornir container --image … -- <cmd>` runs a real container AS a tracked job:
/// the output is captured, the exit code propagates, and a `container` row shows
/// up in `nornir jobs`. Gated on podman (this box has it per [[this-box-is-oden]]).
#[test]
fn container_command_runs_and_tracks_a_job() {
    let podman_ok = Command::new("podman")
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !podman_ok {
        eprintln!("skip: podman not available");
        return;
    }
    let home = tempfile::tempdir().expect("tempdir");
    let cfg = write_config(home.path());

    // Run `alpine echo …` through the CLI.
    let out = nornir()
        .arg("--config")
        .arg(&cfg)
        .args([
            "container",
            "--image",
            "docker.io/library/alpine:3",
            "--",
            "echo",
            "nornir-cli-container-ok",
        ])
        .env("HOME", home.path())
        .env_remove("NORNIR_SERVER")
        .output()
        .expect("run nornir container");
    assert!(
        out.status.success(),
        "container exited non-zero: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("nornir-cli-container-ok"),
        "container stdout passed through: {:?}",
        String::from_utf8_lossy(&out.stdout)
    );

    // The job is in the ledger, kind=container, done.
    let out = run_jobs(home.path(), &cfg, &["--kind", "container", "--json"]);
    let rows: Vec<JobRecord> = serde_json::from_slice(&out.stdout).expect("JobRecord array");
    assert_eq!(rows.len(), 1, "exactly one container job tracked");
    assert_eq!(rows[0].kind, kind::CONTAINER);
    assert_eq!(rows[0].status, status::DONE, "exit 0 ⇒ done");
    assert!(
        rows[0].detail_json.contains("nornir-cli-container-ok"),
        "logs on the row"
    );
}

/// A non-zero container exit propagates as the CLI's exit code AND records a
/// `failed` job row. Gated on podman.
#[test]
fn container_command_propagates_nonzero_exit_and_records_failed() {
    let podman_ok = Command::new("podman")
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !podman_ok {
        eprintln!("skip: podman not available");
        return;
    }
    let home = tempfile::tempdir().expect("tempdir");
    let cfg = write_config(home.path());

    let out = nornir()
        .arg("--config")
        .arg(&cfg)
        .args([
            "container",
            "--image",
            "docker.io/library/alpine:3",
            "--",
            "sh",
            "-c",
            "exit 5",
        ])
        .env("HOME", home.path())
        .env_remove("NORNIR_SERVER")
        .output()
        .expect("run nornir container");
    assert_eq!(
        out.status.code(),
        Some(5),
        "container exit code propagated to the CLI"
    );

    let out = run_jobs(home.path(), &cfg, &["--kind", "container", "--json"]);
    let rows: Vec<JobRecord> = serde_json::from_slice(&out.stdout).expect("JobRecord array");
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].status, status::FAILED, "non-zero exit ⇒ failed row");
}