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");
}