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(¶ms("serve,--grpc-bind,eth1:50051,--role,twin")),
"twin"
);
assert_eq!(declared_role(¶ms("serve,--role=twin")), "twin");
assert_eq!(
declared_role(¶ms("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));
}
}