tests/server_ops_jobs.rs
//! **Server-side ops emit JobRecords — the "every op in the jobs panel" gap.**
//!
//! The job ledger ([`nornir::jobs`]) is the one durable list of every long-running
//! server op; the Jobs panel (`Viz.Jobs` → facett-jobview) reads it. Before this
//! sweep only *some* server ops wrote a JobRecord (workspace populate/fetch/
//! republish, test-matrix, bench, release-run, arch-generate). Several heavy ops —
//! the **security scan** (`Mimir.SecurityScan`), the **docs render** (`Docs.Render`)
//! and the **release-readiness gate suite** (`Release.GateAll`) — ran with NO ledger
//! wiring, so they never showed up in the Jobs panel. This test proves they now do.
//!
//! It obeys the test-matrix LAWS:
//! * **LAW 4 (END-TO-END = THROUGH THE SERVER):** every op is driven via its real
//! gRPC RPC against a running `nornir-server`, and the jobs are read BACK through
//! the server via `Viz.Jobs` (`fetch_jobs`) — not a local ledger read.
//! * **LAW 2 (RAGNARÖK + inverse):** for each op the SAME `Viz.Jobs` oracle reads
//! `0` jobs of that op's kind BEFORE the op runs (empty → 0), then `> 0` (the
//! fresh terminal job) after. A surface that can't go to 0 on no-input asserts
//! nothing.
//!
//! Real input: THIS repo, monitored as a one-repo workspace (a path member — no
//! clone). Real output: `Viz.Jobs` rows of kind `security_scan`, `docs_render`, and
//! `release_run`, each terminal.
//!
//! Run: cargo test --features "viz,server" --test server_ops_jobs -- --test-threads=1
#![cfg(all(feature = "viz", feature = "server"))]
use std::io::Write as _;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use nornir::jobs::{JobRecord, kind, status};
const WS: &str = "opsjobsws";
const REPO: &str = "nornir";
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
struct Server {
child: Child,
root: PathBuf,
endpoint: String,
token: String,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Spawn an EMPTY server (fresh registry), then register a single workspace whose
/// only member is a PATH member pointing at THIS repo, via the real
/// `Workspaces.Register` RPC. A path member is skipped by the fetch loop (no
/// clone), so the workspace is served instantly with `nornir` as a real on-disk
/// repo the ops run over. (Registering through the RPC is how the server gets a
/// served workspace — the legacy `NORNIR_MONITOR` env is no longer read.)
fn spawn_server() -> Server {
let token = "server-ops-jobs-token-0123456789";
let port = free_port();
let endpoint = format!("http://127.0.0.1:{port}");
static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"nornir-server-ops-jobs-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let server_home = root.join("home");
let registry_root = server_home.join(".nornir").join("workspaces");
std::fs::create_dir_all(®istry_root).unwrap();
// A path-member descriptor (server-readable, under the server's own root).
let desc = root.join(format!("{WS}.toml"));
let mut f = std::fs::File::create(&desc).unwrap();
writeln!(f, "[workspace]\nname = \"{WS}\"\n").unwrap();
writeln!(
f,
"[repos.\"{REPO}\"]\npath = \"{}\"\n",
env!("CARGO_MANIFEST_DIR")
)
.unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
.env("NORNIR_SERVER_ADDR", format!("127.0.0.1:{port}"))
.env("NORNIR_SERVER_TOKEN", token)
.env("HOME", &server_home)
// Long poll so the SWEEP loop never races us — we assert THIS op's job, not
// a later poll-tick republish.
.env("NORNIR_POLL", "3600s")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nornir-server");
let srv = Server {
child,
root,
endpoint: endpoint.clone(),
token: token.to_string(),
};
// Wait for the empty server to accept RPCs.
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if nornir::viz::remote::list_workspaces(&endpoint, token).is_ok() {
break;
}
assert!(Instant::now() < deadline, "empty server never came up");
std::thread::sleep(Duration::from_millis(200));
}
// Register the path-member workspace through the real RPC → it becomes served.
let (name, _mode, members) = nornir::viz::remote::register_workspace(
&endpoint,
token,
WS,
&desc.to_string_lossy(),
"monitored",
"3600s",
)
.expect("register the ops workspace (path member, no clone)");
assert_eq!(name, WS);
assert_eq!(members, 1, "the descriptor's single path member registered");
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if let Ok(ws) = nornir::viz::remote::list_workspaces(&endpoint, token) {
if ws.iter().any(|s| s == WS) {
break;
}
}
assert!(
Instant::now() < deadline,
"server never served the ops workspace"
);
std::thread::sleep(Duration::from_millis(300));
}
srv
}
/// All jobs of `kind` for the workspace, read BACK through the server (LAW 4).
fn jobs_of_kind(srv: &Server, k: &str) -> Vec<JobRecord> {
nornir::viz::remote::fetch_jobs(&srv.endpoint, &srv.token, WS, k, 0)
.unwrap_or_default()
.into_iter()
.filter(|r| r.kind == k && r.workspace == WS)
.collect()
}
/// Poll `Viz.Jobs` (through the server) until a terminal job of `kind` appears, or
/// panic at the deadline. The ops run inline server-side, but we still read the
/// ledger back over the wire to honor LAW 4.
fn poll_terminal_job(srv: &Server, k: &str) -> JobRecord {
let deadline = Instant::now() + Duration::from_secs(120);
loop {
if let Some(r) = jobs_of_kind(srv, k).into_iter().find(|r| r.is_terminal()) {
return r;
}
assert!(
Instant::now() < deadline,
"no terminal `{k}` job appeared via Viz.Jobs after the op ran"
);
std::thread::sleep(Duration::from_millis(300));
}
}
#[test]
fn security_scan_emits_a_security_scan_job_visible_through_server() {
let srv = spawn_server();
// LAW 2 inverse (empty → 0): no `security_scan` job before the op runs.
assert!(
jobs_of_kind(&srv, kind::SECURITY_SCAN).is_empty(),
"RAGNARÖK inverse: 0 security_scan jobs before the scan"
);
// Drive THROUGH THE SERVER (LAW 4): the real Mimir.SecurityScan RPC. The op is
// tracked whether it SUCCEEDS (an SBOM/vuln report) or FAILS (e.g. the checkout
// layout for a path member doesn't resolve `cargo metadata`) — the whole point
// is that the op shows in the Jobs panel either way, so accept both.
let _ = nornir::viz::remote::security_scan(&srv.endpoint, &srv.token, REPO, WS);
// LAW 2 (RAGNARÖK): a fresh terminal job of the right kind + target appears,
// its status mirroring the op outcome (done on a clean scan, failed if the scan
// errored — both are terminal and both are tracked).
let job = poll_terminal_job(&srv, kind::SECURITY_SCAN);
assert_eq!(job.kind, kind::SECURITY_SCAN);
assert!(
job.status == status::DONE || job.status == status::FAILED,
"the scan job reached a terminal state: {job:?}"
);
assert_eq!(job.target, REPO, "the job targets the scanned repo");
assert_eq!(job.workspace, WS);
assert!(job.elapsed_ms.is_some(), "terminal job records elapsed");
assert!(
job.parent_id.is_none(),
"a standalone scan is a top-level job"
);
}
#[test]
fn docs_render_emits_a_docs_render_job_visible_through_server() {
let srv = spawn_server();
assert!(
jobs_of_kind(&srv, kind::DOCS_RENDER).is_empty(),
"RAGNARÖK inverse: 0 docs_render jobs before the render"
);
// Docs.Render runs `cargo metadata` + the templates over the checkout. It may
// legitimately fail on some repos (unresolved siblings), so accept either a
// success OR a server error — EITHER WAY a terminal `docs_render` job must land
// (the whole point: the op is tracked regardless of outcome).
let _ = nornir::viz::remote::docs_render(&srv.endpoint, &srv.token, REPO, WS);
let job = poll_terminal_job(&srv, kind::DOCS_RENDER);
assert_eq!(job.kind, kind::DOCS_RENDER);
assert!(
job.is_terminal(),
"render job reached a terminal state: {job:?}"
);
assert_eq!(job.target, REPO);
assert_eq!(job.workspace, WS);
assert!(job.elapsed_ms.is_some(), "terminal job records elapsed");
}
/// **`Ops.RunRelease` states WHICH gates produced its verdict.**
///
/// The 🚀 Release button used to load each repo's `[gates]` config, discard it
/// (`let _ = &repo_cfg;`) and run one hardcoded `no_path_patches`, then report
/// `status = pass` with an EMPTY message and a summary reading "N/N repo(s)
/// ready". An operator could not tell a full configured suite from one gate from
/// none at all — the verdict was unfalsifiable from the outside.
///
/// Both release surfaces now run the same `run_gate_suite` driver over
/// `release::gate::selected_gates`, and each target names the gates it passed.
/// This asserts the operator-visible half through the real RPC (LAW 4): the
/// per-repo message must NAME a gate. It fails against the old handler, whose
/// message was `String::new()`.
#[test]
fn run_release_names_the_gates_that_produced_its_verdict() {
let srv = spawn_server();
let res = nornir::viz::remote::run_release(&srv.endpoint, &srv.token, REPO, WS)
.expect("Ops.RunRelease RPC must complete");
assert_eq!(
res.targets.len(),
1,
"one target for the one monitored repo: {res:?}"
);
let (name, status, message) = &res.targets[0];
assert_eq!(name, REPO);
assert!(
!message.trim().is_empty(),
"the release verdict must say what it checked, not pass silently: {res:?}"
);
// A served monitored workspace builds its repos from `config::Repo::default()`,
// so nothing is declared and the suite runs the `no_path_patches` FLOOR — a
// suite that checked nothing must never report readiness. Whether this repo's
// Cargo.toml passes that gate is not this test's business; either verdict names
// the gate.
assert!(
message.contains("no_path_patches"),
"the floor gate must be named in the {status} verdict: {message}"
);
}
#[test]
fn gate_all_emits_a_release_run_job_visible_through_server() {
let srv = spawn_server();
assert!(
jobs_of_kind(&srv, kind::RELEASE_RUN).is_empty(),
"RAGNARÖK inverse: 0 release_run jobs before the gate suite"
);
// Release.GateAll over the monitored checkout. The verdict (pass/fail of the
// individual gates) is irrelevant here; the contract is that the gate SUITE
// itself is a tracked `release_run` job.
let _ = nornir::viz::remote::gate_all(&srv.endpoint, &srv.token, REPO, WS)
.expect("Release.GateAll RPC must complete");
let job = poll_terminal_job(&srv, kind::RELEASE_RUN);
assert_eq!(job.kind, kind::RELEASE_RUN);
assert!(
job.is_terminal(),
"gate suite job reached a terminal state: {job:?}"
);
assert_eq!(job.target, REPO);
assert_eq!(job.workspace, WS);
assert!(job.elapsed_ms.is_some(), "terminal job records elapsed");
assert!(
job.parent_id.is_none(),
"a standalone gate suite is a top-level job"
);
}