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

nordisk/nornir

tests/solo_embeds_server.rs

//! **The `nornir-solo` proof** — the solo atom really does carry a server.
//!
//! `solo` (`nornir_build_thing::plugin::Role::Solo`) claims something a descriptor
//! cannot check: that ONE process puts the UI up with the server running inside
//! itself. The vacuous pass is obvious and cheap — declare the atom, add a feature,
//! ship a binary that starts no server, and every guard that reads the declaration
//! stays green forever. So nothing in this file reads the declaration.
//!
//! [`solo_binary_serves_real_grpc`] runs the BUILT `nornir-solo`, waits for the
//! marker it prints only after a real TCP connection to its bind address is
//! ACCEPTED, then makes a REAL `Health.Ping` gRPC call to that port and requires the
//! reply to carry this build's own `nornir::VERSION_LINE`.
//!
//! [`the_ui_atom_serves_nothing`] is the anti-vacuous anchor and the more important
//! half: the IDENTICAL probe against `nornir-ui` — the same source file, the same
//! flags, one feature less — must find NOTHING listening. Without it "a port
//! answered" could mean a stray `nornir-server` on the box, a leaked port from
//! another test, or a probe that never actually connected.
//!
//! Both are headless: `--robot-remote <socket>` hands the process to the fleet's
//! blocking Unix-socket drive mount instead of `eframe::run_native`, so no display
//! is needed and the process stays alive long enough to be probed. That is why the
//! applied tests are `#[cfg(all(feature = "solo", feature = "robot-remote"))]` —
//! and why [`ci_compiles_the_solo_proof`], which is ALWAYS compiled, exists.
//!
//! Run: `cargo test --features solo,robot-remote --test solo_embeds_server`.

use std::path::Path;

#[path = "support/ci_commands.rs"]
mod ci_commands;
use ci_commands::{ci_commands_listing, ci_runs};

fn repo_root() -> &'static Path {
    Path::new(env!("CARGO_MANIFEST_DIR"))
}

/// **The anti-vacuum check**, always compiled.
///
/// The two behavioural tests below are cfg-gated on features that are OFF by
/// default, so a plain `cargo test` compiles them out and this file reads green
/// having asserted nothing about solo at all. That is the same trap
/// `tests/inotify_watch_wiring.rs` was written to close for `inotify`, and it is
/// closed the same way: CI must build the features, or this goes red.
///
/// A deliberately dumb substring check — the failure it exists to catch is "the
/// step was deleted", not "the step was reformatted".
#[test]
fn ci_compiles_the_solo_proof() {
    assert!(
        ci_runs("--features solo,robot-remote"),
        "no CI COMMAND builds `solo,robot-remote`, so the only tests that prove the \
         `nornir-solo` atom actually embeds a server are compiled out of every \
         automatic run — the atom would be a declaration with nothing behind it, which \
         is exactly the vacuous pass the solo role invites. Restore the \
         `--features solo,robot-remote` line in .woodpecker.yml's build-test step; a \
         `#` in front of it counts as gone (measured green over exactly that on \
         2026-08-23, hence the command-list read). What CI does run:\n{}",
        ci_commands_listing()
    );
}

#[cfg(all(feature = "solo", feature = "robot-remote"))]
mod applied {
    use std::io::{BufRead, BufReader};
    use std::net::{SocketAddr, TcpStream};
    use std::process::{Child, Command, Stdio};
    use std::sync::mpsc;
    use std::time::{Duration, Instant};

    /// The exact marker `start_embedded_server` prints, and ONLY after a real TCP
    /// connect to the bound address succeeded.
    const MARKER: &str = "NORNIR-SOLO: gRPC listening on";

    /// An OS-assigned free TCP port, released so the child can bind it. (Small
    /// TOCTOU window; acceptable for a local test, and a lost race shows up as the
    /// child failing to bind — never as a false pass, because nothing else in this
    /// file treats "could not connect" as success for the solo leg.)
    fn free_port() -> u16 {
        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
        l.local_addr().expect("local_addr").port()
    }

    /// A child process plus a reader thread pumping its stdout into a channel, so
    /// the test can wait for a line with a deadline instead of blocking forever.
    struct Spawned {
        child: Child,
        lines: mpsc::Receiver<String>,
    }

    impl Drop for Spawned {
        fn drop(&mut self) {
            let _ = self.child.kill();
            let _ = self.child.wait();
        }
    }

    /// Launch one of the two GUI-source binaries HEADLESS (`--robot-remote`), with a
    /// throwaway `$HOME` (so it never touches the caller's real `~/.nornir`, its
    /// registry or its warehouse lock) and an explicit bind address.
    fn spawn(exe: &str, addr: &SocketAddr, home: &std::path::Path) -> Spawned {
        let sock = home.join("robot.sock");
        let mut child = Command::new(exe)
            .arg("--robot-remote")
            .arg(&sock)
            .env("HOME", home)
            .env("NORNIR_SERVER_ADDR", addr.to_string())
            // ≥16 chars — `resolve_bearer` rejects a shorter secret outright.
            .env("NORNIR_SERVER_TOKEN", "solo-proof-token-0123456789")
            // Never inherit the caller's pointer at a live server: a solo binary that
            // silently proxied an already-running :7878 would pass this whole file.
            .env_remove("NORNIR_SERVER")
            .env_remove("NORNIR_WORKSPACE")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap_or_else(|e| panic!("spawn {exe}: {e}"));

        let stdout = child.stdout.take().expect("piped stdout");
        let (tx, rx) = mpsc::channel();
        std::thread::spawn(move || {
            for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                eprintln!("[child] {line}");
                if tx.send(line).is_err() {
                    break;
                }
            }
        });
        Spawned { child, lines: rx }
    }

    /// Wait up to `budget` for a stdout line containing `needle`.
    fn wait_for_line(s: &Spawned, needle: &str, budget: Duration) -> Option<String> {
        let deadline = Instant::now() + budget;
        while let Some(left) = deadline.checked_duration_since(Instant::now()) {
            match s.lines.recv_timeout(left.min(Duration::from_millis(500))) {
                Ok(line) if line.contains(needle) => return Some(line),
                Ok(_) => continue,
                Err(mpsc::RecvTimeoutError::Timeout) => continue,
                Err(mpsc::RecvTimeoutError::Disconnected) => return None,
            }
        }
        None
    }

    fn tcp_answers(addr: &SocketAddr) -> bool {
        TcpStream::connect_timeout(addr, Duration::from_millis(400)).is_ok()
    }

    /// A real gRPC `Health.Ping` against `addr`, returning `(status, version)`.
    /// `Health.Ping` is the one un-intercepted RPC by contract, so this needs no
    /// bearer and tests the SERVER, not the auth layer.
    async fn ping(addr: &SocketAddr) -> (String, String) {
        use nornir::server_grpc::pb;
        let channel = tonic::transport::Channel::from_shared(format!("http://{addr}"))
            .expect("uri")
            .connect()
            .await
            .unwrap_or_else(|e| panic!("gRPC connect to {addr}: {e}"));
        let reply = pb::health_client::HealthClient::new(channel)
            .ping(tonic::Request::new(pb::Empty {}))
            .await
            .unwrap_or_else(|e| panic!("Health.Ping on {addr}: {e}"))
            .into_inner();
        (reply.status, reply.version)
    }

    /// ★ **THE SOLO PROOF — applied behaviour, not a declaration.**
    #[test]
    fn solo_binary_serves_real_grpc() {
        let home = tempfile::tempdir().expect("throwaway HOME");
        let addr: SocketAddr = format!("127.0.0.1:{}", free_port()).parse().expect("addr");

        // Nothing must be listening BEFORE we start — otherwise a stray server on the
        // box would satisfy every assertion below without nornir-solo doing anything.
        assert!(
            !tcp_answers(&addr),
            "{addr} was already accepting connections before nornir-solo started — the \
             probe cannot attribute a response to the solo binary"
        );

        let solo = spawn(env!("CARGO_BIN_EXE_nornir-solo"), &addr, home.path());
        let marker = wait_for_line(&solo, MARKER, Duration::from_secs(120)).unwrap_or_else(|| {
            panic!(
                "nornir-solo never printed `{MARKER}` — it opened (or tried to open) a UI \
                 claiming a server it did not start"
            )
        });
        assert!(
            marker.contains(&addr.to_string()),
            "the marker names a different address than the one we bound: {marker} (wanted {addr})"
        );

        // The marker is a claim; this is the measurement. A REAL gRPC call, over a
        // REAL socket, into the REAL registered service stack of that process.
        let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
        let (status, version) = rt.block_on(ping(&addr));
        assert_eq!(
            status, "ok",
            "Health.Ping from the embedded server did not answer ok"
        );
        assert_eq!(
            version,
            nornir::VERSION_LINE,
            "the embedded server reported a different identity than the binary it lives \
             in — the response did not come from this process"
        );
    }

    /// ★ **THE ANTI-VACUOUS ANCHOR.** Same source file, same flags, one feature
    /// less: `nornir-ui` must serve NOTHING. If this ever passes-by-answering, the
    /// test above is measuring something other than the `solo` feature.
    #[test]
    fn the_ui_atom_serves_nothing() {
        let home = tempfile::tempdir().expect("throwaway HOME");
        let addr: SocketAddr = format!("127.0.0.1:{}", free_port()).parse().expect("addr");

        let ui = spawn(env!("CARGO_BIN_EXE_nornir-ui"), &addr, home.path());

        // Give it at least as long as a solo boot would plausibly need to bind.
        assert!(
            wait_for_line(&ui, MARKER, Duration::from_secs(15)).is_none(),
            "`nornir-ui` printed the solo listening marker — the `solo` cfg block is \
             compiled into a build that does not declare the feature"
        );

        let deadline = Instant::now() + Duration::from_secs(10);
        while Instant::now() < deadline {
            assert!(
                !tcp_answers(&addr),
                "something is listening on {addr} after starting `nornir-ui`, which \
                 embeds no server — either the UI atom secretly starts one, or this \
                 probe is not measuring what it claims (which would make the solo \
                 test above vacuous too)"
            );
            std::thread::sleep(Duration::from_millis(250));
        }
    }
}