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

nordisk/gunnar

installtest/src/control.rs

//! **The control-plane door** — `gunnar-installtest installer --grpc-only`.
//!
//! The installer door proves a medium by gunnar's SSH identification string on
//! `:2222`. A medium whose `korp.args` say `--role twin` serves no git: `gunnar
//! serve --role twin` binds its forge listener to LOOPBACK (`serve.rs`, "No
//! forge listener on a twin"), so a correct twin medium has no banner to read
//! and the historical door goes red on it for the one reason it is right.
//!
//! This is the other witness. `Bootstrap.Status` is the one call a server
//! nobody holds a key for answers, and since 2026-09-15 it names WHO answers:
//! `build` (the same `gunnar_common::BUILD_REF` stamp the banner's comments
//! field carries) and `role`. The door asks it on the PRIVATE interface with no
//! credential and requires:
//!
//! * an answer at all — the control plane is up;
//! * a build stamp that is present and not `unknown` — the medium names which
//!   build it is (a worktree compose stamps `unknown`, MEASURED 2026-09-15);
//! * the role the medium DECLARES (`korp.args=…,--role,twin` → `twin`, none →
//!   `primary`) — the medium came up as what it says it is.
//!
//! The PUBLIC-side asymmetry `boot_installed` asserts is unchanged: a control
//! plane that answers on the public interface is still the defect.

use std::net::SocketAddr;
use std::time::Duration;

/// What `Bootstrap.Status` said about who is answering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ControlAnswer {
    pub build: String,
    pub role: String,
    pub serving_git: bool,
}

/// The role a medium declares: the value after `--role` in its `korp.args=`,
/// `primary` when it declares none (`gunnar serve`'s default).
pub fn declared_role(boot_params: &[String]) -> String {
    let mut after_flag = false;
    for tok in boot_params
        .iter()
        .filter_map(|p| p.strip_prefix("korp.args="))
        .flat_map(|tail| tail.split(','))
    {
        if after_flag {
            return tok.to_string();
        }
        if let Some(v) = tok.strip_prefix("--role=") {
            return v.to_string();
        }
        after_flag = tok == "--role";
    }
    "primary".to_string()
}

/// `Bootstrap.Status` on `addr`, with NO credential, bounded by `budget`.
pub fn bootstrap_status(addr: SocketAddr, budget: Duration) -> Result<ControlAnswer, String> {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|e| format!("a tokio runtime: {e}"))?;
    rt.block_on(async move {
        let call = async {
            let channel = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
                .map_err(|e| format!("{addr}: {e}"))?
                .connect_timeout(budget)
                .connect()
                .await
                .map_err(|e| format!("connect {addr}: {e}"))?;
            let mut client = gunnar_proto::bootstrap_client::BootstrapClient::new(channel);
            let s = client
                .status(gunnar_proto::BootstrapStatusRequest {})
                .await
                .map_err(|st| {
                    format!(
                        "Bootstrap.Status on {addr}: {:?} {}",
                        st.code(),
                        st.message()
                    )
                })?
                .into_inner();
            Ok(ControlAnswer {
                build: s.build,
                role: s.role,
                serving_git: s.serving_git,
            })
        };
        tokio::time::timeout(budget, call)
            .await
            .map_err(|_| format!("Bootstrap.Status on {addr}: no answer within {budget:?}"))?
    })
}

/// The verdict on one answer: `Ok(detail)` or `Err(why)`, by name.
pub fn grpc_only_verdict(answer: &ControlAnswer, want_role: &str) -> Result<String, String> {
    let mut why = Vec::new();
    if answer.build.is_empty() {
        why.push(
            "Bootstrap.Status names no build — a server older than the build stamp, so the medium \
             cannot say which build it is"
                .to_string(),
        );
    } else if answer.build == "unknown" {
        why.push(
            "the build stamp is `unknown` — composed from a checkout `git describe` could not read \
             (a worktree, MEASURED 2026-09-15), so this medium names no build"
                .to_string(),
        );
    }
    if answer.role != want_role {
        why.push(format!(
            "the medium declares role `{want_role}` and the box answered role `{}`",
            if answer.role.is_empty() {
                "(unreported)"
            } else {
                &answer.role
            }
        ));
    }
    if why.is_empty() {
        Ok(format!(
            "Bootstrap.Status: build {} · role {}",
            answer.build, answer.role
        ))
    } else {
        Err(why.join("; "))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn params(args: &str) -> Vec<String> {
        vec![
            "console=tty0".into(),
            format!("korp.args={args}"),
            "korp.entry=gunnar".into(),
        ]
    }

    #[test]
    fn the_role_is_read_off_the_declared_korp_args() {
        assert_eq!(
            declared_role(&params("serve,--grpc-bind,eth1:50051,--role,twin")),
            "twin"
        );
        assert_eq!(declared_role(&params("serve,--role=twin")), "twin");
        assert_eq!(
            declared_role(&params("serve,--grpc-bind,eth1:50051")),
            "primary"
        );
        assert_eq!(declared_role(&[]), "primary");
    }

    fn answer(build: &str, role: &str) -> ControlAnswer {
        ControlAnswer {
            build: build.into(),
            role: role.into(),
            serving_git: true,
        }
    }

    #[test]
    fn a_twin_that_names_its_build_and_role_passes() {
        let ok = grpc_only_verdict(&answer("v1.4.0-167-ga5db99c", "twin"), "twin").unwrap();
        assert!(
            ok.contains("v1.4.0-167-ga5db99c") && ok.contains("role twin"),
            "{ok}"
        );
    }

    /// Red-first, each by name: an old server, a worktree stamp, the wrong role.
    #[test]
    fn a_box_that_cannot_say_who_it_is_is_refused_by_name() {
        let e = grpc_only_verdict(&answer("", ""), "twin").unwrap_err();
        assert!(
            e.contains("names no build") && e.contains("(unreported)"),
            "{e}"
        );
        let e = grpc_only_verdict(&answer("unknown", "twin"), "twin").unwrap_err();
        assert!(e.contains("`unknown`"), "{e}");
        let e = grpc_only_verdict(&answer("v1.4.0-167-ga5db99c", "primary"), "twin").unwrap_err();
        assert!(
            e.contains("declares role `twin`") && e.contains("role `primary`"),
            "{e}"
        );
    }

    #[test]
    fn nothing_listening_is_an_error_not_a_hang() {
        let port = std::net::TcpListener::bind("127.0.0.1:0")
            .unwrap()
            .local_addr()
            .unwrap()
            .port();
        let t = std::time::Instant::now();
        let e =
            bootstrap_status(([127, 0, 0, 1], port).into(), Duration::from_secs(2)).unwrap_err();
        assert!(e.contains("connect") || e.contains("no answer"), "{e}");
        assert!(t.elapsed() < Duration::from_secs(5));
    }
}