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/tests/local_process_spinner.rs

//! Proof that [`LocalProcessSpinner`] works against a REAL child process (no
//! container, no thread::spawn) — the spin mechanism the `run_local_fleet` example
//! drives the five real apps with. To keep this light and deterministic we launch a
//! TRIVIAL in-repo mock (`fleet_mock_server`, a `CmdJsonServer` over a bare
//! click-counter host) instead of the heavy real apps.
//!
//! It proves the full spin contract:
//!  * spawn a child + bounded-poll until its socket answers a `Ping` → **Ready**;
//!  * a `CmdJsonClient` drives one step over the LIVE socket and the state moves;
//!  * a member whose child exits without binding is honestly **NOT Ready**;
//!  * **Drop = teardown**: the child is killed + reaped and the socket file removed.

#![cfg(unix)]

use std::io::BufReader;
use std::os::unix::net::UnixStream;
use std::time::Duration;

use nornir_fleet::{LaunchSpec, LocalProcessSpinner};
use nornir_robotui::cmd_json::{CmdJsonClient, IoChannel};
use nornir_robotui::{Action, Assert, Step as UiStep};
use serde_json::json;

/// The trivial mock app the spinner launches — a `CmdJsonServer` on `--robot-remote
/// <socket>` (Cargo hands us its built path). NO hyphen in the name → the env var
/// resolves through `env!`.
const MOCK_BIN: &str = env!("CARGO_BIN_EXE_fleet_mock_server");

/// Open a driver client over a member's live socket (the SAME IoChannel + client the
/// fat leg uses).
fn client_for(path: &std::path::Path) -> CmdJsonClient<IoChannel<BufReader<UnixStream>, UnixStream>> {
    let stream = UnixStream::connect(path).expect("connect to the live mock socket");
    let reader = stream.try_clone().expect("clone read half");
    CmdJsonClient::new(IoChannel::new(BufReader::new(reader), stream))
}

/// The happy path: spawn the mock, come Ready over a real socket, drive one step that
/// MOVES the remote state, then Drop and assert the child is gone + the socket removed.
#[test]
fn spinner_launches_pings_drives_one_step_and_tears_down() {
    let dir = std::env::temp_dir().join(format!("fleet-spinner-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();

    let socket;
    let pid;
    {
        let mut spinner = LocalProcessSpinner::new(&dir);
        let spec = LaunchSpec::new(MOCK_BIN, ["--robot-remote", "{socket}"])
            .with_timeout(Duration::from_secs(10));

        let ready = spinner.spin("mock", &spec).expect("spawn ok");
        assert!(ready, "the mock bound its socket and answered a Ping → Ready");
        assert!(spinner.is_ready("mock"));
        assert_eq!(spinner.ready_members(), vec!["mock".to_string()]);

        socket = spinner.socket_path("mock");
        pid = spinner.pid("mock").expect("a tracked child pid");
        assert!(socket.exists(), "the live socket file exists while spun");

        // Drive one whole step over the LIVE socket — click bumps /clicks, and the
        // wired-back state must reflect it (red-when-broken: no move → the step fails).
        let mut client = client_for(&socket);
        client.ping().expect("host answers ping");
        let before = client.state_at("/clicks").expect("read /clicks");
        assert_eq!(before, Some(json!(0)), "fresh mock starts at 0");

        let (pass, msg) = client
            .drive_step(
                UiStep::new("bump → clicks == 1")
                    .action(Action::ClickButton("Bump".into()))
                    .assert(Assert::State { pointer: "/clicks".into(), expected: json!(1) }),
            )
            .expect("drive step over the wire");
        assert!(pass, "the remote step passed: {msg}");

        let after = client.state_at("/clicks").expect("read /clicks again");
        assert_eq!(after, Some(json!(1)), "the driven step MOVED the remote state");
        assert_ne!(before, after, "state moved (the spinner drove a real live app)");
        // client drops → the server's serve() sees EOF and loops to accept again.
    } // spinner drops here → teardown

    // Teardown killed + reaped the child and removed the socket.
    assert!(!socket.exists(), "Drop removed the member's socket file");
    #[cfg(target_os = "linux")]
    assert!(
        !std::path::Path::new(&format!("/proc/{pid}")).exists(),
        "Drop killed + reaped the child (pid {pid} is gone)"
    );

    let _ = std::fs::remove_dir_all(&dir);
}

/// A member whose child exits WITHOUT binding a socket is honestly NOT Ready (the
/// gate would then red — no false green), and teardown is still clean. The mock's
/// `scenarios` subcommand emits + exits immediately, never binding.
#[test]
fn spinner_reports_not_ready_when_the_child_never_binds() {
    let dir = std::env::temp_dir().join(format!("fleet-spinner-noready-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();

    let mut spinner = LocalProcessSpinner::new(&dir);
    // `scenarios` prints its library and returns → the child exits before any bind.
    let spec = LaunchSpec::new(MOCK_BIN, ["scenarios"]).with_timeout(Duration::from_secs(5));

    let ready = spinner.spin("mock", &spec).expect("spawn ok");
    assert!(!ready, "a child that exits without binding is NOT Ready");
    assert!(!spinner.is_ready("mock"));
    assert!(spinner.ready_members().is_empty());
    assert!(!spinner.socket_path("mock").exists(), "no socket was ever bound");

    let _ = std::fs::remove_dir_all(&dir);
}

/// A bogus program is the one hard spawn error (nothing was launched to track).
#[test]
fn spinner_errors_on_a_bogus_program() {
    let dir = std::env::temp_dir().join(format!("fleet-spinner-bogus-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let mut spinner = LocalProcessSpinner::new(&dir);
    let spec = LaunchSpec::new("/no/such/fleet/binary/at/all", ["--robot-remote", "{socket}"]);
    assert!(spinner.spin("mock", &spec).is_err(), "spawning a missing program errors");
    let _ = std::fs::remove_dir_all(&dir);
}