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