Files
buzz/crates/buzz-relay/tests/boot_lifecycle.rs
ravarora2 891208914e Add authenticated WebSocket recovery telemetry (#7546)
## Summary

Argo rollout recovery needs to distinguish an open WebSocket from a
connection that completed NIP-42 authentication and is usable. The relay
currently exposes total WebSocket connections, but it cannot answer
whether authenticated clients recovered after a pod drain or why
authentication failed.

This change adds a bounded recovery contract:

- `buzz_auth_attempts_total{method="nip42"}`
- `buzz_auth_outcomes_total{method="nip42",outcome}`
- `buzz_auth_duration_seconds{method="nip42",outcome}`
- `buzz_ws_authenticated_connections_active`
- `buzz_auth_post_terminal_frames_total{state}`

`buzz_auth_attempts_total` now has one consistent recovery-oriented
unit: a challenge lifecycle successfully queued to the connection
writer. This intentionally replaces the historical unit of AUTH frames
that reached the pending handler. AUTH frames received after a lifecycle
is already authenticated or failed are protocol noise; they use the
separate bounded post-terminal counter and cannot inflate rollout-gating
attempts or outcomes.

Each issued challenge reaches exactly one terminal outcome.
Authentication transitions and active-gauge changes share one short
synchronous state lock, cancellation can claim a pending database-backed
AUTH before a late handler result, and a drop guard reconciles aborted
or panicked connection futures. Accounting is terminalized before writer
joins, while terminal socket delivery has a one-second best-effort bound
so a stalled sink cannot retain the authenticated gauge or connection
permit indefinitely.

Dependency failures are distinct from policy verdicts: allowlist and
relay-membership lookup errors now emit `allowlist_check_error` and
`relay_membership_check_error`, never `allowlist_denied` or
`not_relay_member`. The fail-closed allow/deny policy is unchanged. All
rollout-facing labels come from fixed enums; identities, challenges,
URLs, and raw error strings are never exported.

All fixed counter and histogram label sets, including zero histogram
buckets, counts, and sums, are present on the first real exporter
scrape. This is the first implementation slice of the
rollout-reliability plan. A later PR will consume these metrics from
opt-in Argo Rollout analysis; merging this PR alone does not change
deployment behavior.

### Related issue

None found.

### Testing

Validated at final head `f9992cac835284c25ee0f99e17ddba0b932d7c69`,
which contains current `origin/main`:

- `cargo fmt --all -- --check`
- `cargo clippy -p buzz-relay --all-targets -- -D warnings`
- compiled every `buzz-relay` test target
- release builds for `buzz-relay`, `buzz`, `buzz-admin`, and
`buzz-test-cli`
- 16/16 connection lifecycle/writer/production-dispatch tests
- 7/7 AUTH handler tests
- bounded metric-contract exporter test
- 13/13 relay main-target tests (1 PostgreSQL-only test ignored)
- 9/9 real boot-lifecycle tests, including a production install and
first Prometheus scrape

Mutation checks proved that the production regressions fail when any of
these are removed or reintroduced:

- challenge attempt start in `handle_active_connection`
- authentication series initialization in the real metric installer
- separation of post-terminal AUTH floods from authoritative attempts

The exact-head release binaries were also exercised against the isolated
review database:

1. Started the release relay and verified health/readiness.
2. Created a channel with the regular CLI.
3. Held one authenticated WebSocket subscription open and authenticated
a second WebSocket publisher.
4. Published a message and read it back through the regular CLI.
5. Observed `attempt_delta=2`, `success_delta=2`,
`active_while_subscribed=1`, and `active_after_disconnect=0`.
6. Sent SIGTERM and waited for graceful relay shutdown.

### Local caveat

The full relay library target consistently reached 1,049 passes with 89
intentionally ignored tests, but the untouched
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`
intermittently returned HTTP 504 instead of 200. One isolated rerun
passed; later isolated and full reruns reproduced the 504. No mesh files
are changed by this PR, and all changed-path, main-target,
boot-lifecycle, lint, build, mutation, and live-local checks above
passed at the final head.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-09-16 16:06:33 -04:00

503 lines
17 KiB
Rust

use std::{
collections::BTreeMap,
io::{Read as _, Write as _},
net::{TcpListener, TcpStream},
process::{Child, Command, ExitStatus, Output, Stdio},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use serde_json::Value;
use buzz_relay::lifecycle::StartupPhase;
const VALID_RELAY_PRIVATE_KEY: &str =
"0000000000000000000000000000000000000000000000000000000000000001";
const CHILD_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_CAPTURE_BYTES: u64 = 1024 * 1024;
struct RelayProcess {
child: Option<Child>,
stdout: Option<JoinHandle<CapturedStream>>,
stderr: Option<JoinHandle<CapturedStream>>,
scratch_dir: std::path::PathBuf,
}
struct CapturedStream {
retained: Vec<u8>,
total_bytes: u64,
}
impl RelayProcess {
fn spawn(environment: &[(&str, &str)]) -> Self {
let scratch_dir =
std::env::temp_dir().join(format!("buzz-boot-lifecycle-{}", uuid::Uuid::new_v4()));
let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-relay"));
command
.env_clear()
.env("RUST_BACKTRACE", "0")
.env("RUST_LOG", "buzz_relay=info")
.env("BUZZ_GIT_REPO_PATH", scratch_dir.join("repos"))
.env("BUZZ_GIT_PACK_CACHE_PATH", scratch_dir.join("pack-cache"))
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (name, value) in environment {
command.env(name, value);
}
let mut child = command.spawn().expect("spawn buzz-relay child process");
let stdout = child.stdout.take().expect("relay stdout pipe");
let stderr = child.stderr.take().expect("relay stderr pipe");
Self {
child: Some(child),
stdout: Some(thread::spawn(move || capture_stream(stdout))),
stderr: Some(thread::spawn(move || capture_stream(stderr))),
scratch_dir,
}
}
fn try_wait(&mut self) -> Option<ExitStatus> {
self.child
.as_mut()
.expect("relay child")
.try_wait()
.expect("poll relay child")
}
fn wait(mut self, timeout: Duration) -> Output {
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = self.try_wait() {
break status;
}
if Instant::now() >= deadline {
let child = self.child.as_mut().expect("relay child");
let _ = child.kill();
let _ = child.wait();
panic!("buzz-relay child exceeded {timeout:?}");
}
thread::sleep(Duration::from_millis(10));
};
self.child.take();
let output = Output {
status,
stdout: join_capture(self.stdout.take(), "stdout"),
stderr: join_capture(self.stderr.take(), "stderr"),
};
let _ = std::fs::remove_dir_all(&self.scratch_dir);
output
}
fn terminate(mut self) -> Output {
self.child
.as_mut()
.expect("relay child")
.kill()
.expect("terminate exact relay child");
self.wait(Duration::from_secs(2))
}
}
impl Drop for RelayProcess {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
let _ = std::fs::remove_dir_all(&self.scratch_dir);
}
}
fn capture_stream(mut stream: impl std::io::Read) -> CapturedStream {
let mut retained = Vec::new();
let mut total_bytes = 0_u64;
let mut chunk = [0_u8; 8192];
loop {
let read = stream.read(&mut chunk).expect("read relay output pipe");
if read == 0 {
break;
}
total_bytes = total_bytes.saturating_add(u64::try_from(read).expect("read size fits u64"));
let remaining = usize::try_from(MAX_CAPTURE_BYTES)
.expect("capture ceiling fits usize")
.saturating_sub(retained.len());
retained.extend_from_slice(&chunk[..read.min(remaining)]);
}
CapturedStream {
retained,
total_bytes,
}
}
fn join_capture(capture: Option<JoinHandle<CapturedStream>>, stream: &str) -> Vec<u8> {
let capture = capture
.expect("relay capture thread")
.join()
.expect("relay capture thread must not panic");
assert!(
capture.total_bytes <= MAX_CAPTURE_BYTES,
"relay {stream} exceeded {MAX_CAPTURE_BYTES} bytes: {}",
capture.total_bytes,
);
capture.retained
}
fn run_relay(environment: &[(&str, &str)]) -> Output {
RelayProcess::spawn(environment).wait(CHILD_TIMEOUT)
}
fn scrape_metrics(port: u16) -> std::io::Result<String> {
let address = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))?;
stream.set_read_timeout(Some(Duration::from_millis(500)))?;
stream.write_all(b"GET /metrics HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(response)
}
fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String {
let deadline = Instant::now() + Duration::from_secs(8);
loop {
assert!(
process.try_wait().is_none(),
"relay exited before its metrics endpoint became usable"
);
if let Ok(response) = scrape_metrics(port) {
if response.contains("buzz_audit_enabled") {
return response;
}
}
assert!(
Instant::now() < deadline,
"relay metrics did not become scrapeable within 8s"
);
thread::sleep(Duration::from_millis(20));
}
}
fn assert_no_startup_lifecycle_metrics(scrape: &str) {
for line in scrape.lines() {
let Some(name) = line
.strip_prefix("# HELP ")
.or_else(|| line.strip_prefix("# TYPE "))
.and_then(|rest| rest.split_ascii_whitespace().next())
else {
continue;
};
assert!(
!["startup", "boot", "lifecycle"]
.iter()
.any(|term| name.contains(term))
&& !StartupPhase::ALL
.iter()
.any(|phase| name.contains(phase.as_str())),
"logs-only lifecycle contract emitted metric family {name}"
);
}
}
fn assert_auth_metrics_have_stable_boot_zeros(scrape: &str) {
const OUTCOMES: [&str; 11] = [
"success",
"invalid",
"banned",
"ban_check_error",
"allowlist_check_error",
"allowlist_denied",
"relay_membership_check_error",
"not_relay_member",
"timeout",
"disconnect",
"shutdown",
];
assert!(scrape.contains("# TYPE buzz_auth_attempts_total counter"));
assert!(scrape.contains("buzz_auth_attempts_total{method=\"nip42\"} 0"));
assert!(scrape.contains("# TYPE buzz_auth_outcomes_total counter"));
assert!(scrape.contains("# TYPE buzz_auth_duration_seconds histogram"));
assert!(scrape.contains("# TYPE buzz_auth_post_terminal_frames_total counter"));
assert!(scrape.contains("buzz_auth_post_terminal_frames_total{state=\"authenticated\"} 0"));
assert!(scrape.contains("buzz_auth_post_terminal_frames_total{state=\"failed\"} 0"));
assert!(scrape.contains("buzz_ws_authenticated_connections_active 0"));
for outcome in OUTCOMES {
assert!(scrape.contains(&format!(
"buzz_auth_outcomes_total{{method=\"nip42\",outcome=\"{outcome}\"}} 0"
)));
assert!(scrape.contains(&format!(
"buzz_auth_duration_seconds_count{{method=\"nip42\",outcome=\"{outcome}\"}} 0"
)));
assert!(scrape.contains(&format!(
"buzz_auth_duration_seconds_sum{{method=\"nip42\",outcome=\"{outcome}\"}} 0"
)));
assert!(scrape.lines().any(|line| {
line.starts_with("buzz_auth_duration_seconds_bucket{")
&& line.contains("le=\"+Inf\"")
&& line.contains("method=\"nip42\"")
&& line.contains(&format!("outcome=\"{outcome}\""))
&& line.ends_with(" 0")
}));
}
}
fn lifecycle_events(output: &Output) -> Vec<Value> {
let mut events: Vec<Value> = output
.stdout
.split(|byte| *byte == b'\n')
.chain(output.stderr.split(|byte| *byte == b'\n'))
.filter_map(|line| serde_json::from_slice::<Value>(line).ok())
.filter(|event| event["event_name"] == "buzz_process_lifecycle")
.collect();
events.sort_by_key(|event| event["sequence"].as_u64());
events
}
fn lifecycle_events_from(bytes: &[u8]) -> Vec<Value> {
bytes
.split(|byte| *byte == b'\n')
.filter_map(|line| serde_json::from_slice::<Value>(line).ok())
.filter(|event| event["event_name"] == "buzz_process_lifecycle")
.collect()
}
fn assert_accounting(events: &[Value]) {
assert!(!events.is_empty(), "child emitted no lifecycle events");
let boot_id = events[0]["process_boot_id"]
.as_str()
.expect("process_boot_id");
let mut counts = BTreeMap::<String, (usize, usize)>::new();
for (index, event) in events.iter().enumerate() {
assert_eq!(event["schema_version"], 1);
assert_eq!(event["sequence"], u64::try_from(index + 1).unwrap());
assert_eq!(event["process_boot_id"], boot_id);
assert_eq!(event["track"], "startup");
let count = counts
.entry(event["phase"].as_str().expect("phase").to_owned())
.or_default();
match event["edge"].as_str() {
Some("started") => count.0 += 1,
Some("terminal") => count.1 += 1,
other => panic!("unexpected lifecycle edge: {other:?}"),
}
}
assert!(
counts
.values()
.all(|(started, terminal)| *started == 1 && *terminal == 1),
"every started phase must have one terminal: {counts:?}"
);
}
fn assert_terminal(events: &[Value], phase: &str, status: &str, reason: Option<&str>) {
let terminal = events
.iter()
.find(|event| event["phase"] == phase && event["edge"] == "terminal")
.unwrap_or_else(|| panic!("missing {phase} terminal"));
assert_eq!(terminal["status"], status);
match reason {
Some(reason) => assert_eq!(terminal["reason"], reason),
None => assert!(terminal["reason"].is_null()),
}
}
fn phases(events: &[Value]) -> Vec<&str> {
events
.iter()
.filter(|event| event["edge"] == "started")
.map(|event| event["phase"].as_str().expect("phase"))
.collect()
}
#[test]
fn invalid_config_terminalizes_at_main_even_with_logs_disabled() {
let output = run_relay(&[
("RUST_LOG", "off"),
("BUZZ_BIND_ADDR", "not-a-socket-address"),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_eq!(
phases(&events),
[
"process_telemetry",
"crypto_init",
"tracing_init",
"config_load"
]
);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
assert_terminal(
&events,
"process_telemetry",
"failed",
Some("config_invalid"),
);
assert_eq!(lifecycle_events_from(&output.stderr), events);
assert!(lifecycle_events_from(&output.stdout).is_empty());
}
#[test]
#[cfg(unix)]
fn config_filesystem_failure_has_a_bounded_terminal() {
let output = run_relay(&[
("RUST_LOG", "off"),
("BUZZ_GIT_REPO_PATH", "/dev/null/not-a-directory"),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
assert_terminal(
&events,
"process_telemetry",
"failed",
Some("config_invalid"),
);
}
#[test]
fn invalid_config_value_has_the_same_bounded_terminal() {
let output = run_relay(&[("RUST_LOG", "off"), ("BUZZ_DRAIN_JITTER_MS", "bogus")]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
assert_terminal(
&events,
"process_telemetry",
"failed",
Some("config_invalid"),
);
}
#[test]
fn configured_otlp_terminalizes_tracing_before_a_later_failure() {
let output = run_relay(&[
("RUST_LOG", "off"),
("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4317"),
("BUZZ_BIND_ADDR", "not-a-socket-address"),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "tracing_init", "succeeded", None);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
}
#[test]
fn missing_key_stops_before_metrics_bind() {
let output = run_relay(&[]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_eq!(
phases(&events),
[
"process_telemetry",
"crypto_init",
"tracing_init",
"config_load",
"key_load"
]
);
assert_terminal(&events, "key_load", "failed", Some("missing"));
assert_terminal(&events, "process_telemetry", "failed", Some("missing"));
}
#[test]
fn invalid_key_uses_a_bounded_reason_without_leaking_the_value() {
let secret = "private-key-material-that-must-not-appear";
let output = run_relay(&[("BUZZ_RELAY_PRIVATE_KEY", secret)]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "key_load", "failed", Some("required_invalid"));
let combined = [output.stdout, output.stderr].concat();
assert!(!String::from_utf8_lossy(&combined).contains(secret));
}
#[test]
fn occupied_metrics_port_has_a_typed_bind_terminal() {
let occupied = TcpListener::bind(("0.0.0.0", 0)).expect("bind occupied port");
let port = occupied.local_addr().expect("occupied address").port();
let port = port.to_string();
let output = run_relay(&[
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &port),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "metrics_bind", "failed", Some("bind"));
assert_terminal(&events, "process_telemetry", "failed", Some("bind"));
}
#[test]
fn otlp_build_failure_is_degraded_without_leaking_endpoint_credentials() {
let secret = "telemetry-secret-marker";
let endpoint = format!("https://telemetry-user:{secret}@[");
let fake_database = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake database");
let database_url = format!(
"postgres://buzz@127.0.0.1:{}/buzz",
fake_database.local_addr().expect("database address").port()
);
let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve metrics port");
let port = reserved.local_addr().expect("metrics address").port();
drop(reserved);
let port_value = port.to_string();
let mut process = RelayProcess::spawn(&[
("OTEL_EXPORTER_OTLP_ENDPOINT", &endpoint),
("RUST_LOG", "buzz_relay=warn"),
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &port_value),
("DATABASE_URL", &database_url),
]);
let scrape = wait_for_relay_metrics(&mut process, port);
assert_no_startup_lifecycle_metrics(&scrape);
let output = process.terminate();
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "tracing_init", "degraded", Some("exporter_build"));
assert_terminal(
&events,
"process_telemetry",
"degraded",
Some("exporter_build"),
);
let combined = [output.stdout, output.stderr].concat();
assert!(!String::from_utf8_lossy(&combined).contains(secret));
}
#[test]
fn successful_main_emits_complete_lifecycle_without_startup_metrics() {
let fake_database = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake database");
let database_url = format!(
"postgres://buzz@127.0.0.1:{}/buzz",
fake_database.local_addr().expect("database address").port()
);
let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve metrics port");
let port = reserved.local_addr().expect("metrics address").port();
drop(reserved);
let port_value = port.to_string();
let mut process = RelayProcess::spawn(&[
("RUST_LOG", "off"),
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &port_value),
("DATABASE_URL", &database_url),
]);
let scrape = wait_for_relay_metrics(&mut process, port);
assert_no_startup_lifecycle_metrics(&scrape);
assert_auth_metrics_have_stable_boot_zeros(&scrape);
let output = process.terminate();
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "crypto_init", "succeeded", None);
assert_terminal(&events, "tracing_init", "succeeded", None);
assert_terminal(&events, "config_load", "succeeded", None);
assert_terminal(&events, "key_load", "succeeded", None);
assert_terminal(&events, "metrics_bind", "succeeded", None);
assert_terminal(&events, "process_telemetry", "succeeded", None);
}