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

nordisk/nornir

tests/install_onboarding.rs

//! Inject-assert: **fresh install / empty server / onboarding / workspace add**.
//!
//! This file is the matrix coverage for the bugs a fresh-install session surfaced
//! that the matrix SHOULD have caught automatically — every assertion is read from
//! DATA (`state_json` / the workspace RPCs), never an eyeball (LAW 6).
//!
//! The four covered bugs:
//!
//!  1. **Registered-but-unpopulated NAMED workspace must show ONBOARDING, not a
//!     red "is not served" error.** A workspace can be REGISTERED (it shows in the
//!     viz picker; `Workspaces.List` returns it) but NOT yet served — no warehouse
//!     built (a `pushed`/`external` add, a `lazy` register, or a monitored
//!     workspace whose eager build hasn't finished). Selecting it made `Viz.Timeline`
//!     answer `NotFound: workspace `<name>` is not served`, and the viz client
//!     rendered a RED "Failed to load warehouse" error. The fix routes that
//!     NotFound to the graceful "not populated — click ⬇ Populate" onboarding
//!     panel. `viz_unpopulated_named_workspace_is_onboarding_not_error` proves
//!     `state_json` reports `onboarding.unpopulated == true`, `onboarding.active`,
//!     and a NULL `timeline.error` — and the RED inverse (`onboarding_red_when_*`)
//!     proves the same oracle goes RED if the not-served NotFound ever leaks back
//!     into `timeline.error`.
//!
//!  3. **Workspace add is DESCRIPTOR-FREE / descriptor-optional.** A workspace
//!     registered by name with its members shipped in the descriptor content (no
//!     git clone, no mandatory bootstrap) registers cleanly and lists its members.
//!     `register_is_descriptor_free_and_lists_members` asserts the registered
//!     record carries the shipped members.
//!
//!  4. **An empty server serves an EMPTY REGISTRY without error.** A fresh server
//!     (boot-config dropped) starts, answers `Workspaces.List` → 0, and the
//!     default-workspace route does not panic/hard-fail.
//!     `empty_server_serves_empty_registry` asserts the empty list + that a
//!     viz app built against it lands in the empty-registry onboarding state.
//!
//! Bug #2 (the bare empty-server systemd unit has NO `NORNIR_MONITOR`/`NORNIR_POLL`
//! lines + a bare ExecStart) is a UNIT test on the install path's `render_server_unit`
//! and lives in `src/bin/nornir.rs` (the unit is private to that binary).
//!
//! Run: cargo test --features "viz,server" --test install_onboarding -- --test-threads=1
#![cfg(all(feature = "viz", feature = "server"))]

use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::time::{Duration, Instant};

use nornir::viz::UrdrThreadsApp;
use serde_json::Value;
use uuid::Uuid;

fn free_port() -> u16 {
    TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

/// A minimal committed Rust-crate fixture repo a pure-gix `file://` clone can fetch.
fn fixture_crate(dir: &Path, name: &str) {
    std::fs::create_dir_all(dir.join("src")).unwrap();
    std::fs::write(
        dir.join("Cargo.toml"),
        format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n"),
    )
    .unwrap();
    std::fs::write(
        dir.join("src").join("lib.rs"),
        format!("//! {name} fixture crate\npub fn {name}_answer() -> i32 {{ 42 }}\n"),
    )
    .unwrap();
    nornir::gitio::init(dir).unwrap();
    nornir::gitio::commit_all(dir, "init fixture").unwrap();
}

fn file_url(dir: &Path) -> String {
    format!("file://{}", dir.display())
}

/// An ephemeral `nornir-server` with a FRESH EMPTY registry (no workspaces served).
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 a server with a fresh empty registry — bug #4's "empty server serves an
/// empty registry": NO boot config, NO monitor env, NO seeded workspaces.
fn spawn_empty_server() -> Server {
    let token = "install-onboarding-token-0123456789";
    let port = free_port();
    let endpoint = format!("http://127.0.0.1:{port}");
    let root = std::env::temp_dir().join(format!(
        "nornir-onboarding-{}-{}",
        std::process::id(),
        Uuid::new_v4()
    ));
    let _ = std::fs::remove_dir_all(&root);
    let server_home = root.join("home");
    std::fs::create_dir_all(server_home.join(".nornir").join("workspaces")).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 our asserts.
        .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(),
    };
    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));
    }
    srv
}

/// Write a workspace descriptor (server-readable local path) with two
/// `file://`-cloneable members. Returns the descriptor `.toml` path.
fn write_descriptor(dir: &Path, ws_name: &str) -> PathBuf {
    let src_alpha = dir.join("src_alpha");
    let src_beta = dir.join("src_beta");
    fixture_crate(&src_alpha, "alpha");
    fixture_crate(&src_beta, "beta");
    let desc = dir.join("nornir-workspace.toml");
    std::fs::write(
        &desc,
        format!(
            "[workspace]\nname = \"{ws_name}\"\n\n\
             [repos.alpha]\ngit = \"{}\"\n\n\
             [repos.beta]\ngit = \"{}\"\n",
            file_url(&src_alpha),
            file_url(&src_beta),
        ),
    )
    .unwrap();
    desc
}

/// Build a viz app in THIN/remote mode pointed at the server, selecting `ws`.
fn thin_app(srv: &Server, ws: &str) -> UrdrThreadsApp {
    UrdrThreadsApp::with_remote(
        srv.endpoint.clone(),
        srv.token.clone(),
        ws.to_string(),
        PathBuf::new(),
        Vec::new(),
    )
}

// ─────────────────────────────────────────────────────────────────────────────
// BUG #4 — an empty server serves an empty registry without error.
// ─────────────────────────────────────────────────────────────────────────────

#[test]
fn empty_server_serves_empty_registry() {
    let srv = spawn_empty_server();

    // `Workspaces.List` → 0 (the boot-config-dropped empty server).
    let listed = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token)
        .expect("empty server must answer Workspaces.List, not error");
    assert_eq!(
        listed.len(),
        0,
        "a fresh empty server serves 0 workspaces, got: {listed:?}"
    );

    // A viz app built against the empty server lands in the EMPTY-REGISTRY
    // onboarding state (no workspace selected) — NOT an error, NOT a panic.
    let app = thin_app(&srv, "");
    let s = app.state_json();
    let onboarding = &s["onboarding"];
    assert_eq!(
        onboarding["active"],
        Value::Bool(true),
        "empty registry → onboarding active, got onboarding={onboarding}"
    );
    assert_eq!(
        onboarding["no_workspaces"],
        Value::Bool(true),
        "empty registry → no_workspaces true"
    );
    // The empty-registry case is NOT the unpopulated-named case.
    assert_eq!(
        onboarding["unpopulated"],
        Value::Bool(false),
        "empty registry is the no-selection onboarding, not the unpopulated-named one"
    );
    // No timeline error reached the user (the default route never hard-failed).
    assert!(
        s["timeline"]["error"].is_null(),
        "empty server must not surface a timeline error, got: {}",
        s["timeline"]["error"]
    );
    assert_eq!(
        s["workspace_picker"]["count"],
        Value::from(0),
        "the picker offers 0 workspaces on an empty server"
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// BUG #3 — workspace add is descriptor-free / descriptor-optional.
// ─────────────────────────────────────────────────────────────────────────────

#[test]
fn register_is_descriptor_free_and_lists_members() {
    let srv = spawn_empty_server();
    let ws = "descfree";

    let fixtures = srv.root.join("fixtures-descfree");
    std::fs::create_dir_all(&fixtures).unwrap();
    let desc = write_descriptor(&fixtures, ws);

    // `pushed` add: members are discovered from the descriptor content; no
    // mandatory bootstrap clone of a workspace REPO (the descriptor itself is the
    // source). The members are SHIPPED, not cloned from a remote workspace repo.
    let (name, mode, members) = nornir::viz::remote::register_workspace(
        &srv.endpoint,
        &srv.token,
        ws,
        &desc.to_string_lossy(),
        "pushed",
        "3600s",
    )
    .expect("descriptor-free pushed register must succeed");
    assert_eq!(name, ws, "registered the workspace we asked for");
    assert_eq!(mode, "pushed");
    assert_eq!(
        members, 2,
        "the two shipped members are discovered from the descriptor"
    );

    // It lists as registered (the row was upserted).
    let listed =
        nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).expect("list workspaces");
    assert!(
        listed.iter().any(|w| w == ws),
        "the descriptor-free workspace is listed: {listed:?}"
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// BUG #1 — a registered-but-unpopulated NAMED workspace is ONBOARDING, not RED.
// ─────────────────────────────────────────────────────────────────────────────

#[test]
fn viz_unpopulated_named_workspace_is_onboarding_not_error() {
    let srv = spawn_empty_server();
    let ws = "unpop";

    let fixtures = srv.root.join("fixtures-unpop");
    std::fs::create_dir_all(&fixtures).unwrap();
    let desc = write_descriptor(&fixtures, ws);

    // RED-before-fix PRECONDITION (proves the probe is real, not vacuous): a
    // workspace-routed RPC for `ws` BEFORE register is the not-served NotFound.
    let before = nornir::viz::remote::fetch_timeline(&srv.endpoint, &srv.token, ws);
    let before_err = before.expect_err("an unregistered workspace must NOT be served");
    let before_msg = format!("{before_err:#}");
    assert!(
        before_msg.contains("is not served") || before_msg.contains("NotFound"),
        "pre-register timeline should be a not-served NotFound, got: {before_msg}"
    );

    // REGISTER as `pushed` — registered (shows in the picker) but NOT served (no
    // warehouse handle: pushed/external have no monitored served warehouse). So
    // `Viz.Timeline` for this NAMED, selected workspace answers
    // `NotFound: workspace `unpop` is not served`.
    let (name, _mode, members) = nornir::viz::remote::register_workspace(
        &srv.endpoint,
        &srv.token,
        ws,
        &desc.to_string_lossy(),
        "pushed",
        "3600s",
    )
    .expect("register must succeed");
    assert_eq!(name, ws);
    assert_eq!(members, 2, "the workspace is registered with its members");

    // The SERVER serves every registered workspace (8fc1d7b: mode gates fetching,
    // not reading), so this registered-but-unpopulated name answers an EMPTY
    // timeline — no lanes, no snapshot — not an error.
    let tl = nornir::viz::remote::fetch_timeline(&srv.endpoint, &srv.token, ws)
        .expect("a registered workspace is served, populated or not");
    assert!(
        tl.lanes.is_empty() && tl.latest_snapshot.is_none(),
        "a registered-but-unpopulated workspace has nothing in it yet: {tl:?}"
    );

    // ── THE FIX (read from DATA, LAW 6): a thin viz app that SELECTS this
    //    registered-but-unpopulated workspace renders the ONBOARDING / "not
    //    populated — click Populate" state, NOT a red "Failed to load warehouse"
    //    error. ──
    let app = thin_app(&srv, ws);
    let s = app.state_json();

    // The workspace IS selected (so this is the NAMED case, not empty-registry).
    assert_eq!(
        s["workspace_picker"]["selected"],
        Value::from(ws),
        "the unpopulated workspace is selected in the picker"
    );
    let onboarding = &s["onboarding"];
    assert_eq!(
        onboarding["unpopulated"],
        Value::Bool(true),
        "registered-unpopulated NAMED workspace → onboarding.unpopulated, got: {onboarding}"
    );
    assert_eq!(
        onboarding["active"],
        Value::Bool(true),
        "the onboarding slice is active for the unpopulated workspace"
    );
    assert_eq!(
        onboarding["workspace"],
        Value::from(ws),
        "the onboarding slice names the unpopulated workspace"
    );
    // This is NOT the empty-registry case — a workspace IS registered.
    assert_eq!(
        onboarding["no_workspaces"],
        Value::Bool(false),
        "a workspace is registered, so the registry is NOT empty"
    );

    // ── THE CORE ASSERTION: the not-served NotFound must NOT surface as a red
    //    error. It is suppressed from `timeline.error` (onboarding, not error). ──
    assert!(
        s["timeline"]["error"].is_null(),
        "the not-served NotFound must NOT render red — onboarding instead. Got error: {}",
        s["timeline"]["error"]
    );
    assert!(
        onboarding["timeline_error"].is_null(),
        "onboarding suppresses the not-served NotFound (it's a cause, not a user error)"
    );
}