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

nordisk/edda

crates/nornir-fleet/examples/run_local_fleet.rs

//! **`run_local_fleet`** — the human / CI entry to ACTUALLY run the fleet locally.
//!
//! This is the first real headless end-to-end run of the full-system-autotest fleet
//! with the mounted apps launched as **local processes** (no container host):
//!
//! ```text
//!   LocalProcessSpinner  ─▶ spawn each mounted app  (cargo run … --robot-remote <sock>)
//!         │                  bind <dir>/<member>.sock, hold on a Ping barrier
//!         ├─ emit scenarios ─▶ run each app's `scenarios` → <dir>/<member>.scenarios.json
//!         ▼
//!   FleetHarness::mock  ─▶ lower fleet.runa.ron  (spin=mock boot, drive=REAL sockets)
//!         │  with_egui_wire( ScenarioWire( MountRouter(dir, spun-Ready set), scenarios ) )
//!         ▼
//!   CoverageGate        ─▶ member × surface board  +  the honest Gap == ∅ verdict
//!         ▼
//!   (Drop)              ─▶ RampGuard teardown: every child killed + reaped, sockets gone
//! ```
//!
//! ## Run it
//! ```text
//! # spin every mounted app that resolves under $NORDISK_GIT_ROOT (default: edda's parent):
//! cargo run -p fleet --example run_local_fleet
//!
//! # or pick which apps to spin (the rest stay honestly probe-only):
//! cargo run -p fleet --example run_local_fleet -- dwarves ordning
//!
//! # override the socket dir / the git root holding the sibling app repos:
//! FLEET_SOCKET_DIR=/tmp/fleet NORDISK_GIT_ROOT=/var/run/media/$USER/T9/git \
//!   cargo run -p fleet --example run_local_fleet
//! ```
//!
//! HONESTY (no false green): only apps this run actually spins Ready are driven for
//! real; every other candidate (`nornir` while its mount lands, or any app not
//! requested / not resolvable / failed to come up) is **probe-only** — its `@fat`
//! nodes stay uncovered and the gate reds, naming each `app:tab@fat`. The exit code
//! is 0 only on a genuine `Gap == ∅`.

#[cfg(unix)]
fn main() -> std::process::ExitCode {
    use std::path::{Path, PathBuf};
    use std::process::Command;
    use std::time::Duration;

    use nornir_fleet::{
        FleetHarness, LaunchSpec, LocalProcessSpinner, MountRouter, Pipeline, ScenarioSource,
        ScenarioWire, Step, CANONICAL_MOUNTED_MEMBERS,
    };

    /// The checked-in constellation descriptor — the real artifact (all five members).
    const FLEET_RON: &str = include_str!("../fleet.runa.ron");

    // ── per-app launch knowledge (the run config): repo dir + package + the CLI that
    //    mounts a CmdJsonServer on `{socket}`, and the CLI that emits its scenarios ──
    //    Cross-repo: each `cargo run` launches from the app's sibling repo under
    //    $NORDISK_GIT_ROOT. Returns (repo_subdir, run_args, emit_args).
    fn app_cli(member: &str) -> Option<(&'static str, Vec<String>, Vec<String>)> {
        let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
        let (repo, run, emit): (&str, Vec<String>, Vec<String>) = match member {
            // dwarves-gui scenarios --emit is the confirmed first real mount.
            "dwarves" => (
                "dwarves",
                s(&["run", "-p", "dwarves-gui", "--features", "robot-remote", "--", "--robot-remote", "{socket}"]),
                s(&["run", "-p", "dwarves-gui", "--features", "robot-remote", "--", "scenarios"]),
            ),
            "holger" => (
                "holger",
                s(&["run", "-p", "holger-server-ui", "--features", "robot-remote", "--", "--robot-remote", "{socket}"]),
                s(&["run", "-p", "holger-server-ui", "--features", "robot-remote", "--", "scenarios"]),
            ),
            // `--bin korp` is required: korp's default `iceberg` feature makes a second
            // bin (`korp-appliance`) available, so a bare `cargo run -p korp` is ambiguous.
            "korp" => (
                "korp",
                s(&["run", "-p", "korp", "--bin", "korp", "--features", "robot-remote", "--", "--robot-remote", "{socket}"]),
                s(&["run", "-p", "korp", "--bin", "korp", "--features", "robot-remote", "--", "scenarios"]),
            ),
            // The mount bin is `ordning-robot-remote` (the [[bin]] name — dashes, not
            // the underscored file path); `--bin` matches the target name exactly.
            "ordning" => (
                "ordning",
                s(&["run", "-p", "ordning-gui", "--features", "robot-remote", "--bin", "ordning-robot-remote", "--", "--robot-remote", "{socket}"]),
                s(&["run", "-p", "ordning-gui", "--features", "robot-remote", "--bin", "ordning-robot-remote", "--", "scenarios"]),
            ),
            // nornir's mount is the `urdr-threads` viz bin (NOT nornir-server): the
            // `robot-remote` feature pulls `viz`; `server` is needed too (the saga gRPC
            // service is gated on it — a viz build without server won't compile it).
            // urdr-threads has no `scenarios` subcommand, so the emit falls to the
            // fleet's liveness probe (ping + read_state per tab) — honestly driven, just
            // not from a recorded per-tab library.
            "nornir" => (
                "nornir",
                s(&["run", "-p", "nornir", "--bin", "urdr-threads", "--features", "robot-remote server", "--", "--robot-remote", "{socket}"]),
                s(&["run", "-p", "nornir", "--bin", "urdr-threads", "--features", "robot-remote server", "--", "scenarios"]),
            ),
            _ => return None,
        };
        Some((repo, run, emit))
    }

    // ── the fleet's declared members + their surfaces, read from the descriptor ─────
    let pipeline = match Pipeline::from_ron_str(FLEET_RON) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("run_local_fleet: fleet.runa.ron did not parse: {e}");
            return std::process::ExitCode::from(2);
        }
    };
    let all_members: Vec<String> = pipeline
        .stages
        .iter()
        .flat_map(|s| &s.steps)
        .filter_map(|st| match st {
            Step::SpinFleetMember { name, .. } => Some(name.clone()),
            _ => None,
        })
        .collect();

    // ── which apps to spin: CLI args, else every canonical mounted candidate ────────
    let requested: Vec<String> = {
        let args: Vec<String> = std::env::args().skip(1).collect();
        if args.is_empty() {
            CANONICAL_MOUNTED_MEMBERS.iter().map(|s| s.to_string()).collect()
        } else {
            args
        }
    };

    let git_root: PathBuf = std::env::var("NORDISK_GIT_ROOT")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            // default: the parent of this crate's repo (edda) — the git-root siblings.
            Path::new(env!("CARGO_MANIFEST_DIR"))
                .ancestors()
                .nth(2) // …/edda/crates/nornir-fleet → …/ (git root)
                .map(Path::to_path_buf)
                .unwrap_or_else(|| PathBuf::from(".."))
        });

    let dir: PathBuf = std::env::var("FLEET_SOCKET_DIR").map(PathBuf::from).unwrap_or_else(|_| {
        std::env::temp_dir().join(format!("run-local-fleet-{}", std::process::id()))
    });
    let _ = std::fs::create_dir_all(&dir);

    println!("run_local_fleet — headless local fleet");
    println!("  git root:   {}", git_root.display());
    println!("  socket dir: {}", dir.display());
    println!("  candidates: {}", requested.join(", "));
    println!();

    // ── 1. SPIN the requested apps as local processes; emit their scenarios ─────────
    let mut spinner = LocalProcessSpinner::new(&dir);
    for member in &requested {
        let Some((repo, run_args, emit_args)) = app_cli(member) else {
            println!("  · {member:9} — no launch recipe (skipped)");
            continue;
        };
        let repo_dir = git_root.join(repo);
        if !repo_dir.exists() {
            println!("  · {member:9} — repo {} not found (probe-only)", repo_dir.display());
            continue;
        }

        // Emit the app's recorded scenarios → <dir>/<member>.scenarios.json (best effort:
        // an app with no emit path just falls back to the fleet's liveness probe).
        match Command::new("cargo").args(&emit_args).current_dir(&repo_dir).output() {
            Ok(out) if out.status.success() && !out.stdout.is_empty() => {
                let path = dir.join(format!("{member}.scenarios.json"));
                if std::fs::write(&path, &out.stdout).is_ok() {
                    println!("  · {member:9} — emitted scenarios → {}", path.display());
                }
            }
            _ => println!("  · {member:9} — no scenarios emitted (probe fallback)"),
        }

        let spec = LaunchSpec::new("cargo", run_args)
            .in_dir(&repo_dir)
            .with_timeout(Duration::from_secs(180)); // cold `cargo run` may compile first
        match spinner.spin(member, &spec) {
            Ok(true) => println!("  ✓ {member:9} — Ready (mounted on {})", spinner.socket_path(member).display()),
            Ok(false) => println!("  ✗ {member:9} — did NOT come Ready (probe-only / red)"),
            Err(e) => println!("  ✗ {member:9} — launch failed: {e} (probe-only / red)"),
        }
    }
    let spun_ready = spinner.ready_members();
    println!();
    println!("  spun Ready: [{}]", spun_ready.join(", "));
    println!();

    // ── 2. DRIVE: lower the pipeline (mock boot) with the fat leg dialing the LIVE
    //    sockets — MountRouter mounted = exactly the members spun Ready (un-spun
    //    candidates stay honestly probe-only), scenarios from the emitted files ──────
    let source = ScenarioSource::from_dir(&dir, all_members.iter().map(String::as_str))
        .unwrap_or_else(|e| {
            eprintln!("run_local_fleet: scenario load warning: {e}");
            ScenarioSource::new()
        });
    let wire = ScenarioWire::new(MountRouter::new(&dir, spun_ready.clone()), source);

    let outcome = match FleetHarness::mock("nordisk").with_egui_wire(wire).run(&pipeline) {
        Ok(o) => o,
        Err(e) => {
            eprintln!("run_local_fleet: pipeline lowering failed: {e}");
            return std::process::ExitCode::from(2);
        }
    };

    // ── 3. BOARD: member × surface (fat/thin) coverage, honestly per driven extent ──
    print_board(&outcome, &all_members);

    // ── 4. VERDICT: the hard Gap == ∅ zero (exit 0 only on a genuine green) ─────────
    println!();
    if outcome.gate_green() {
        println!("VERDICT: Gap == ∅  ✅ GREEN — {}", outcome.gate_summary());
        std::process::ExitCode::SUCCESS
    } else {
        println!("VERDICT: Gap != ∅  ❌ RED (honest) — {}", outcome.gate_summary());
        std::process::ExitCode::FAILURE
    }
    // spinner drops here → RampGuard teardown (every child killed + reaped, sockets gone).
}

/// Print the member × surface board from the streamed rows + the gate gap — honest
/// about which apps were driven and what is missing.
#[cfg(unix)]
fn print_board(outcome: &nornir_fleet::FleetOutcome, all_members: &[String]) {
    use std::collections::BTreeMap;

    // Tally (pass, fail, skip) per (member, leg) from the drive rows. leg ∈ {egui, web}.
    let mut tally: BTreeMap<(String, String), (usize, usize, usize)> = BTreeMap::new();
    for row in &outcome.rows {
        // test_name = "<member>::<leg>::<tab>" for drive rows (skip the coverage row).
        let parts: Vec<&str> = row.test_name.splitn(3, "::").collect();
        if parts.len() != 3 {
            continue;
        }
        let key = (parts[0].to_string(), parts[1].to_string());
        let cell = tally.entry(key).or_default();
        match row.status.as_str() {
            "pass" => cell.0 += 1,
            "fail" => cell.1 += 1,
            "skip" => cell.2 += 1,
            _ => {}
        }
    }

    let fmt = |t: Option<&(usize, usize, usize)>| -> String {
        match t {
            None => "        —".to_string(),
            Some((p, f, s)) => {
                let mut parts = Vec::new();
                if *p > 0 {
                    parts.push(format!("{p} driven"));
                }
                if *f > 0 {
                    parts.push(format!("{f} FAILED"));
                }
                if *s > 0 {
                    parts.push(format!("{s} probe-only"));
                }
                if parts.is_empty() { "—".to_string() } else { parts.join(", ") }
            }
        }
    };

    println!("Fleet local run — member × surface coverage");
    println!("  {:<9}  {:<28}  {:<28}", "member", "egui (fat)", "web (thin)");
    println!("  {:-<9}  {:-<28}  {:-<28}", "", "", "");
    for m in all_members {
        let fat = fmt(tally.get(&(m.clone(), "egui".to_string())));
        let thin = fmt(tally.get(&(m.clone(), "web".to_string())));
        println!("  {m:<9}  {fat:<28}  {thin:<28}");
    }

    if let Some(gate) = &outcome.gate {
        println!();
        println!(
            "  coverage: {} / {} nodes covered, {} missing",
            gate.gap.covered,
            gate.gap.total,
            gate.gap.missing.len()
        );
        if !gate.gap.missing.is_empty() {
            let mut keys: Vec<String> = gate.gap.missing.iter().map(|n| n.key_str()).collect();
            keys.sort();
            let show = 12usize.min(keys.len());
            println!("  missing (first {show} of {}):", keys.len());
            for k in keys.iter().take(show) {
                println!("    - {k}");
            }
            if keys.len() > show {
                println!("    … and {} more", keys.len() - show);
            }
        }
    }
}

#[cfg(not(unix))]
fn main() {
    eprintln!("run_local_fleet: Unix-socket only (the mounted apps serve on AF_UNIX sockets)");
}