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

nordisk/nornir

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