feat(admin): invoke community FI upgrades

Add a one-shot signed operator command and a pod runbook for testing a connected client across a policy change.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
Brad Seiler
2026-11-05 22:56:25 -05:00
parent 9a6cb3ffdf
commit 9c962c35ca
7 changed files with 762 additions and 1 deletions

5
Cargo.lock generated
View File

@@ -969,6 +969,8 @@ name = "buzz-admin"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"buzz-audit",
"buzz-auth",
"buzz-core",
@@ -981,10 +983,13 @@ dependencies = [
"clap",
"hex",
"nostr 0.44.7",
"reqwest 0.13.4",
"rustls",
"serde",
"serde_json",
"sha2 0.11.0",
"sqlx",
"tempfile",
"tokio",
"tracing",
"url",

View File

@@ -468,7 +468,7 @@ test-unit:
# parser, validation, and propagation-evidence tests, and the partition
# audit outcome contract, NIP-FI policy/backfill, and user-deletion intake.
cargo nextest run -p buzz-admin \
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)'
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^user_deletions::tests::/)'
# Multi-tenant conformance gate (buzz-conformance): the independent
# replay checker + golden fixtures. No infra — pure in-process trace
# replay — so it belongs in the unit job. Run all targets (lib + the

View File

@@ -27,6 +27,9 @@ serde_json = { workspace = true }
serde = { workspace = true }
anyhow = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }
reqwest = { workspace = true }
sha2 = { workspace = true }
# Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process
# CryptoProvider installed at startup. The workspace redis TLS feature compiles
# both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we
@@ -39,4 +42,6 @@ uuid = { workspace = true }
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
axum = { workspace = true }
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }

View File

@@ -22,6 +22,7 @@
mod communities;
mod deletions;
mod nip_fi;
mod nip_fi_upgrade;
mod user_deletions;
use std::future::Future;
@@ -124,6 +125,8 @@ enum Command {
#[command(subcommand)]
command: nip_fi::NipFiCommand,
},
/// Require NIP-FI through the relay's existing signed operator API.
NipFiUpgrade(nip_fi_upgrade::Args),
/// Durable CLI-only whole-community deletion control plane.
Deletions {
#[command(subcommand)]
@@ -213,6 +216,11 @@ async fn run(cli: Cli) -> Result<i32> {
Command::Deletions { command } => deletions::run(command).await,
Command::UserDeletions { command } => user_deletions::run(command).await,
Command::NipFi { command } => nip_fi::run(command).await,
// Retain transport/file causes for this operator command without changing
// the diagnostics of other admin commands. Key parse errors are redacted.
Command::NipFiUpgrade(args) => nip_fi_upgrade::run(args)
.await
.map_err(|error| anyhow::anyhow!("{error:#}")),
Command::ReconcileChannels { channel, relay_key } => {
reconcile_channels(channel, relay_key).await?;
Ok(0)

View File

@@ -0,0 +1,526 @@
//! One-shot calls to the existing operator upgrade API; no database or worker startup.
use std::{io::Read, path::Path, path::PathBuf, time::Duration};
use anyhow::{ensure, Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use clap::{Args as ClapArgs, Subcommand};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use reqwest::{redirect::Policy, Method};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use url::Url;
use uuid::Uuid;
const PATH: &str = "/operator/communities/nip-fi-upgrade";
const MAX_BYTES: usize = 64 * 1024;
/// Explicit operator destination and protected signing material.
#[derive(Debug, ClapArgs)]
pub struct Args {
/// Must match the relay's RELAY_OPERATOR_API_ORIGIN (HTTP for a trusted mesh, or HTTPS).
#[arg(long)]
origin: String,
/// File containing the authorized deployment operator's hex secret key or nsec.
#[arg(long)]
key_file: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Accept or recover an upgrade using the exact saved request tuple.
Enable {
/// JSON with operation_id, community_id, expected_host, actor_pubkey,
/// expected_owner_pubkey and expected_issuer. Reuse after response loss.
#[arg(long)]
request: PathBuf,
},
/// Read the original operator's durable receipt without sending a mutation.
Status {
#[arg(long)]
operation_id: Uuid,
},
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct UpgradeRequest {
operation_id: Uuid,
community_id: Uuid,
expected_host: String,
actor_pubkey: String,
expected_owner_pubkey: String,
expected_issuer: String,
}
impl UpgradeRequest {
fn validate(&self) -> Result<()> {
ensure!(
!self.operation_id.is_nil() && !self.community_id.is_nil(),
"UUIDs must not be nil"
);
let host = crate::communities::normalize_host_authority(&self.expected_host)
.map_err(anyhow::Error::msg)?;
ensure!(
host == self.expected_host && !host.contains(':'),
"expected_host must be canonical lowercase without a port or trailing dot"
);
for key in [&self.actor_pubkey, &self.expected_owner_pubkey] {
ensure!(
key.len() == 64
&& key
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)),
"owner public keys must be 64 lowercase hex characters"
);
}
Ok(())
}
}
fn read_bounded(path: &Path, limit: usize) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
std::fs::File::open(path)
.with_context(|| format!("cannot open {}", path.display()))?
.take((limit + 1) as u64)
.read_to_end(&mut bytes)
.with_context(|| format!("cannot read {}", path.display()))?;
ensure!(bytes.len() <= limit, "input file exceeds size limit");
Ok(bytes)
}
fn endpoint(origin: &str, operation: Option<Uuid>) -> Result<Url> {
let base = Url::parse(origin).context("invalid operator origin")?;
ensure!(
matches!(base.scheme(), "http" | "https")
&& base.host_str().is_some()
&& base.username().is_empty()
&& base.password().is_none()
&& base.path() == "/"
&& base.query().is_none()
&& base.fragment().is_none(),
"operator origin must be an HTTP(S) origin without credentials, path, query or fragment"
);
let mut url = base.join(PATH)?;
if let Some(id) = operation {
ensure!(!id.is_nil(), "operation_id must not be nil");
url.query_pairs_mut()
.append_pair("operation_id", &id.to_string());
}
Ok(url)
}
fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result<String> {
let mut tags = vec![
Tag::parse(["u", url.as_str()])?,
Tag::parse(["method", method.as_str()])?,
Tag::parse(["nonce", &Uuid::new_v4().to_string()])?,
];
if let Some(body) = body {
tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?);
}
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)?;
Ok(format!("Nostr {}", STANDARD.encode(event.as_json())))
}
fn receipt_exit_code(
receipt: &Value,
operation_id: Uuid,
operator: &str,
request: Option<&UpgradeRequest>,
) -> Result<i32> {
ensure!(
receipt["operation_id"].as_str() == Some(operation_id.to_string().as_str())
&& receipt["operator_pubkey"].as_str() == Some(operator)
&& receipt["requires_nip_fi"].as_bool() == Some(true)
&& receipt["policy_generation"].as_i64().is_some_and(|g| g > 0),
"unconfirmed: response is not the expected durable acceptance receipt"
);
if let Some(request) = request {
let expected = serde_json::to_value(request)?;
ensure!(
expected
.as_object()
.context("request must be an object")?
.iter()
.all(|(k, v)| receipt[k] == *v),
"unconfirmed: receipt tuple does not match the saved request"
);
}
let reasons = receipt["pending_reasons"]
.as_array()
.context("unconfirmed: missing pending reasons")?;
ensure!(
reasons.iter().all(Value::is_string),
"unconfirmed: invalid pending reasons"
);
match receipt["state"].as_str() {
Some("active") if reasons.is_empty() => Ok(0),
Some("pending") => Ok(10),
Some("unavailable") => Ok(12),
_ => anyhow::bail!("unconfirmed: invalid upgrade state"),
}
}
/// Send exactly one signed request. A timeout never means the upgrade was rejected.
pub async fn run(args: Args) -> Result<i32> {
let request = match &args.command {
Command::Enable { request } => {
let request: UpgradeRequest =
serde_json::from_slice(&read_bounded(request, MAX_BYTES)?)?;
request.validate()?;
Some(request)
}
Command::Status { .. } => None,
};
let operation_id = match &args.command {
Command::Enable { .. } => request.as_ref().context("missing request")?.operation_id,
Command::Status { operation_id } => *operation_id,
};
let url = endpoint(&args.origin, request.is_none().then_some(operation_id))?;
// Do not include the secret or its parse error in diagnostics.
let secret = read_bounded(&args.key_file, 1024)?;
let keys = std::str::from_utf8(&secret)
.ok()
.and_then(|s| Keys::parse(s.trim()).ok())
.context("invalid operator key file")?;
let body = request.as_ref().map(serde_json::to_vec).transpose()?;
let method = if body.is_some() {
Method::POST
} else {
Method::GET
};
let auth = authorization(&keys, &method, &url, body.as_deref())?;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.retry(reqwest::retry::never())
.timeout(Duration::from_secs(30))
.build()?;
let mut call = client.request(method, url).header("Authorization", auth);
if let Some(body) = body {
call = call.header("Content-Type", "application/json").body(body);
}
let mut response = call
.send()
.await
.context("unconfirmed: request failed; recover the same operation_id")?;
let status = response.status();
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.context("unconfirmed: response interrupted; recover the same operation_id")?
{
ensure!(
bytes.len() + chunk.len() <= MAX_BYTES,
"unconfirmed: response exceeds size limit"
);
bytes.extend_from_slice(&chunk);
}
if !status.is_success() {
// Preserve known machine-readable codes, without echoing arbitrary response bodies.
let error = serde_json::from_slice::<Value>(&bytes).ok();
let code = error.as_ref().and_then(|value| value["code"].as_str());
let detail = match code {
Some(
code @ ("invalid_request"
| "community_not_found"
| "operation_not_found"
| "idempotency_conflict"
| "host_conflict"
| "owner_conflict"
| "lifecycle_conflict"
| "issuer_conflict"
| "upgrades_disabled"
| "authorization_unavailable"),
) => code,
_ if error.is_none() => "non-JSON error response",
_ => "unrecognized error code",
};
anyhow::bail!("operator API returned HTTP {} ({detail}); recover the same operation_id after an uncertain enable", status.as_u16());
}
// Print only structured relay output, never transport headers or signing material.
let receipt: Value = serde_json::from_slice(&bytes).with_context(|| {
format!(
"unconfirmed: invalid JSON response (HTTP {})",
status.as_u16()
)
})?;
let code = receipt_exit_code(
&receipt,
operation_id,
&keys.public_key().to_hex(),
request.as_ref(),
)?;
println!("{}", serde_json::to_string(&receipt)?);
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Bytes, http::HeaderMap, routing::any, Json, Router};
use tokio::sync::mpsc;
fn tuple() -> UpgradeRequest {
let owner = Keys::generate().public_key().to_hex();
UpgradeRequest {
operation_id: Uuid::new_v4(),
community_id: Uuid::new_v4(),
expected_host: "test.communities.example.org".into(),
actor_pubkey: owner.clone(),
expected_owner_pubkey: owner,
expected_issuer: "https://issuer.example.org".into(),
}
}
#[tokio::test]
async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() {
for (field, value) in [
("expected_host", "TEST.communities.example.org"),
("expected_host", "test.communities.example.org."),
("expected_host", "test.communities.example.org:443"),
("expected_host", "https://test.communities.example.org"),
("actor_pubkey", &"A".repeat(64)),
("expected_owner_pubkey", &"A".repeat(64)),
("actor_pubkey", &"a".repeat(63)),
("expected_owner_pubkey", &"g".repeat(64)),
] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("request.json");
let mut input = serde_json::to_value(tuple()).unwrap();
input[field] = value.into();
std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap();
let error = run(Args {
origin: "http://127.0.0.1:1".into(),
key_file: dir.path().join("absent-key"),
command: Command::Enable { request: path },
})
.await
.unwrap_err();
let message = format!("{error:#}");
assert!(
message.contains("host") || message.contains("public keys"),
"{message}"
);
assert!(
!message.contains("absent-key"),
"key read preceded validation: {message}"
);
}
}
#[tokio::test]
async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() {
use axum::http::StatusCode;
for (status, body, expected) in [
(
409,
r#"{"code":"owner_conflict","error":"private detail"}"#,
"owner_conflict",
),
(409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"),
(409, r#"{"code":"host_conflict"}"#, "host_conflict"),
(
409,
r#"{"code":"idempotency_conflict"}"#,
"idempotency_conflict",
),
(503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"),
(
502,
"<html>private detail</html>",
"non-JSON error response",
),
(
409,
r#"{"code":"private detail"}"#,
"unrecognized error code",
),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
let app = Router::new().route(
PATH,
any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }),
);
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let dir = tempfile::tempdir().unwrap();
let key_file = dir.path().join("key");
let request_file = dir.path().join("request.json");
let keys = Keys::generate();
std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap();
std::fs::write(&request_file, serde_json::to_vec(&tuple()).unwrap()).unwrap();
let error = run(Args {
origin,
key_file,
command: Command::Enable {
request: request_file,
},
})
.await
.unwrap_err();
let message = format!("{error:#}");
assert!(message.contains(&format!("HTTP {status}")), "{message}");
assert!(message.contains(expected), "{message}");
assert!(!message.contains("private detail"), "{message}");
assert!(!message.contains(&keys.secret_key().display_secret().to_string()));
server.abort();
}
}
fn receipt(request: &UpgradeRequest, keys: &Keys, state: &str) -> Value {
let mut value = serde_json::to_value(request).unwrap();
value["operator_pubkey"] = keys.public_key().to_hex().into();
value["policy_generation"] = 1.into();
value["requires_nip_fi"] = true.into();
value["state"] = state.into();
value["pending_reasons"] = serde_json::json!([]);
value
}
#[tokio::test]
async fn command_signs_exact_post_body_and_get_query_and_preserves_operation() {
let keys = Keys::generate();
let tuple = tuple();
let response = receipt(&tuple, &keys, "pending");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
let (sender, mut requests) = mpsc::channel(4);
let app = Router::new().route(
PATH,
any(
move |method: Method, uri: axum::http::Uri, headers: HeaderMap, body: Bytes| {
let response = response.clone();
let sender = sender.clone();
async move {
sender.send((method, uri, headers, body)).await.unwrap();
Json(response)
}
},
),
);
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let dir = tempfile::tempdir().unwrap();
let key_file = dir.path().join("key");
let request_file = dir.path().join("request.json");
std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap();
std::fs::write(&request_file, serde_json::to_vec(&tuple).unwrap()).unwrap();
for command in [
Command::Enable {
request: request_file,
},
Command::Status {
operation_id: tuple.operation_id,
},
] {
assert_eq!(
run(Args {
origin: origin.clone(),
key_file: key_file.clone(),
command
})
.await
.unwrap(),
10
);
let (method, uri, headers, body) = requests.recv().await.unwrap();
let encoded = headers["Authorization"]
.to_str()
.unwrap()
.strip_prefix("Nostr ")
.unwrap();
let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap();
let url = format!("{origin}{uri}");
let pubkey = buzz_auth::verify_nip98_event(
&event,
&url,
method.as_str(),
(method == Method::POST).then_some(body.as_ref()),
)
.unwrap();
assert_eq!(pubkey, keys.public_key());
if method == Method::POST {
assert_eq!(
serde_json::from_slice::<Value>(&body).unwrap(),
serde_json::to_value(&tuple).unwrap()
);
assert!(
buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err()
);
} else {
assert_eq!(
uri.query(),
Some(format!("operation_id={}", tuple.operation_id).as_str())
);
assert!(buzz_auth::verify_nip98_event(
&event,
&format!("{origin}{PATH}"),
"GET",
None
)
.is_err());
}
}
assert!(requests.try_recv().is_err());
server.abort();
}
#[test]
fn only_matching_required_receipts_can_report_success() {
let keys = Keys::generate();
let request = tuple();
let operator = keys.public_key().to_hex();
for (state, code) in [("active", 0), ("pending", 10), ("unavailable", 12)] {
assert_eq!(
receipt_exit_code(
&receipt(&request, &keys, state),
request.operation_id,
&operator,
Some(&request)
)
.unwrap(),
code
);
}
for (field, bad) in [
("operation_id", Value::from(Uuid::new_v4().to_string())),
(
"operator_pubkey",
Value::from(Keys::generate().public_key().to_hex()),
),
("community_id", Value::from(Uuid::new_v4().to_string())),
("requires_nip_fi", Value::from(false)),
("policy_generation", Value::from(0)),
("state", Value::from("unknown")),
("pending_reasons", serde_json::json!(["not_drained"])),
] {
let mut value = receipt(&request, &keys, "active");
value[field] = bad;
assert!(
receipt_exit_code(&value, request.operation_id, &operator, Some(&request)).is_err(),
"accepted invalid {field}"
);
}
}
#[test]
fn origin_cannot_select_a_different_path_or_embed_credentials() {
for origin in [
"https://user:password@example.org",
"https://example.org/wrong",
"https://example.org/?q=x",
"https://example.org/#fragment",
"file:///tmp/key",
] {
assert!(endpoint(origin, None).is_err(), "accepted {origin}");
}
}
}

View File

@@ -5,6 +5,10 @@ A deployment that provisions communities under one host suffix, for example
each community in `community_nip_fi_issuers`. The relay writes these rows when
it creates a community; `buzz-admin nip-fi` backfills and edits them.
To make an existing community require federated sign-in, use the separate
[community upgrade command and manual test](operator-nip-fi-upgrade.md).
An issuer binding alone does not change that community's required policy.
A host may hold a binding only when it is exactly one DNS label under the
suffix, has no port, and is not *reserved*. A host is reserved when
`https://<host>` is a `command_audiences` entry of any issuer in

View File

@@ -0,0 +1,213 @@
# Require NIP-FI on an existing community
`buzz-admin nip-fi-upgrade` makes one signed HTTP call to the existing relay
operator API. It needs no database access, application startup, sponsorship or
billing workflow. `enable` accepts the saved upgrade tuple; `status` reads its
durable receipt. Both require an authorized deployment operator key.
The [upgrade API contract](community-auth-upgrades.md) defines the receipt and
policy semantics used below.
This command is a trusted operator surface. The operator must independently
authenticate the community owner and verify the owner's registered public key
before putting it in `actor_pubkey`. A caller-supplied public key is not proof
of the owner's identity. The relay still checks current ownership, community
identity, issuer trust and the upgrade admission gate. This command does not
replace an application's owner authentication or admission policy.
## Before the test
Use a disposable staging community whose policy is not yet required. Verify its
UUID, canonical lowercase host, current owner and active issuer binding against
authoritative records. Use the current owner's key for both public-key fields.
The issuer must match the relay's provisioning configuration and active trust.
Verify `BUZZ_NIP_FI_MODE=shadow` on **every serving replica** before connecting
the test client. Shadow observes optional communities and enforces their stored
required policy after an upgrade. `off` ignores stored requirements; `enforce`
already requires sign-in everywhere. Either mode invalidates this transition
test. Keep shadow mode unchanged throughout the test and record it in evidence.
All serving relay replicas must support persisted required policy and closing
existing sessions after an upgrade. Apply their required migrations and finish
the rollout before the test. The deployment must already have authorized
`BUZZ_NIP_FI_UPGRADES_ENABLED=true`; this command does not change that gate.
Only the deployment's authorized operator may enable that gate, after verifying
the fleet prerequisites in the API contract. Record that operator's approval.
Use a client that supports required-policy discovery and federated sign-in.
Identify the configured `RELAY_OPERATOR_API_ORIGIN` and a key in
`RELAY_OPERATOR_PUBKEYS`. The origin is the operator API origin, not the target
community URL. Use HTTPS outside a trusted internal mesh. Provision the key as
a protected file/Secret through the deployment's existing secret process; never
paste it into arguments, logs or a committed manifest. Do not borrow a client
or managed agent key.
## Save the operation, then leave the client connected
Save `request.json` with one newly generated operation UUID and the verified
values. Keep this file unchanged for retries. The command refuses noncanonical
hosts or public keys before sending a POST. These are example values only:
```json
{
"operation_id": "deecfc7a-9ec2-4ec5-b15b-9e567ad59740",
"community_id": "00fb4822-7930-40fc-9eed-315926f83ca7",
"expected_host": "test.communities.example.org",
"actor_pubkey": "<verified owner public key: 64 lowercase hex characters>",
"expected_owner_pubkey": "<same verified owner public key>",
"expected_issuer": "https://issuer.example.org"
}
```
Open the test community in an existing client and confirm it can read and send
a message without federated sign-in. Keep that session connected. Record the
client version, relay image digests and time, plus the community's `/info`
response showing `requires_nip_fi: false`. Do not sign out, restart the client,
or change relay configuration during the acceptance test.
## Run one pod
Use a relay image containing this command, pinned to its verified digest. The
normal relay image includes `/usr/local/bin/buzz-admin`. Check the command's
`--help` before the test if using a previously built image.
Select the staging context and namespace explicitly. Create the input ConfigMap
from the saved request, and use an existing Secret with a `key` entry:
```sh
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create configmap nip-fi-upgrade-input \
--from-file=request.json
```
Replace the image, origin and Secret name below with reviewed deployment
values. Save as `upgrade-pod.yaml`. This is a single Pod, with no Job controller,
retry policy or application/service labels. It mounts only the input and key.
Use the deployment's approved network/mesh settings if its operator API needs
them; confirm the pod can reach that API without adding it to serving workloads.
If a mesh sidecar is injected, use the platform's approved one-shot completion
mechanism (for example, native sidecars). An ordinary sidecar can keep this Pod
running after the command exits; retain the upgrade container's exit status and
logs, and arrange sidecar shutdown without weakening required network policy.
```yaml
apiVersion: v1
kind: Pod
metadata:
name: nip-fi-upgrade-enable
spec:
restartPolicy: Never
activeDeadlineSeconds: 60
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: upgrade
image: ghcr.io/block/buzz@sha256:<verified-digest>
command: [/usr/local/bin/buzz-admin]
args:
- nip-fi-upgrade
- --origin
- https://operator.example.org
- --key-file
- /operator/key
- enable
- --request
- /input/request.json
volumeMounts:
- {name: operator, mountPath: /operator, readOnly: true}
- {name: input, mountPath: /input, readOnly: true}
volumes:
- name: operator
secret:
secretName: reviewed-upgrade-operator
defaultMode: 0440
- name: input
configMap:
name: nip-fi-upgrade-input
```
```sh
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create -f upgrade-pod.yaml
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" logs -f pod/nip-fi-upgrade-enable -c upgrade
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" get pod nip-fi-upgrade-enable -o json
```
The last command records the pod UID, observed image ID and container exit code.
Do not use Pod phase alone: the expected accepted outcome in this relay version
is exit **10**, which Kubernetes labels Failed. This is acceptance, not a command
failure or a certified fleet-wide connection drain.
| Exit | Meaning |
| --- | --- |
| 10 | Expected success: valid matching receipt, `requires_nip_fi: true`, state `pending` |
| 12 | Valid acceptance receipt, target/trust currently `unavailable` |
| 5 | Request error or unconfirmed result; acceptance may already have happened |
This relay version never reports `active`. The command reserves exit 0 for a
future valid `active` receipt with no pending reasons; do not wait for it here.
`connection_closure_unverified` is expected even after successful socket sweeps:
the receipt does not certify fleet-wide closure. Continue with the client test.
Retain and investigate other/unknown pending reasons; they remain unverified.
Each HTTP call has a 30-second timeout, no automatic retry and no redirects.
The command verifies the returned operation, operator, required policy and
positive generation. For enable it also verifies every saved tuple field.
## Confirm policy and recover the same operation
Create another pod from the same manifest with a new pod name and replace the
`enable --request /input/request.json` arguments with:
```yaml
- status
- --operation-id
- deecfc7a-9ec2-4ec5-b15b-9e567ad59740
```
Use the actual saved operation UUID, same API origin and same operator key.
Compare the receipt's complete tuple and generation with the saved evidence.
A successful receipt must report `requires_nip_fi: true`. Also read the target
community's `/info` and confirm it advertises required policy.
After timeout, response loss or pod termination, status may recover an accepted
receipt. If it cannot confirm acceptance, repeat enable with the **same saved
request** and a new pod name. Never create a new operation UUID to escape an
uncertain result. `pending` and `unavailable` do not undo required policy; retain
their state and reasons. A pending receipt with `connection_closure_unverified`
does not prevent the manual client test from passing on observed evidence.
An unavailable target or unknown obligations require investigation.
Turning off admission, deleting the pod or rolling back the command cannot undo
an accepted policy change.
## Observe the existing client
Record `BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` on every serving replica (default
30 seconds, clamped to 1–300). Before the test, choose and record a finite client
reconnect/sign-in budget from that client's configured reconnect behavior. Wait
at least the largest replica interval plus that client budget after acceptance.
Record the resulting observation deadline. If sign-in has not appeared by then,
record the test as failed or blocked with logs; do not wait indefinitely.
1. After the upgrade, verify the already-connected client demands federated
sign-in before continuing to read or send in the community. Let the client
handle the disconnect/reconnect. Do not manually sign out or restart it.
2. Complete sign-in with the test user's allowed issuer, then verify reading and
sending work again in that same community.
3. Record a failure if access continues without sign-in, sign-in never appears,
or access does not resume. A changed `/info` response alone is not a pass.
Record the exact invocation/manifest, saved request, operator public key,
community UUID/host, pod UID/observed image ID, all serving relay versions and
shadow modes, revalidator intervals, client reconnect budget/deadline,
client version, pre/post `/info`, receipt/generation/state, timestamps and observed
client behavior. Keep secret key bytes and authorization headers out of evidence.
Mark unexecuted steps as **not run**, and keep policy confirmation separate from
the client result. Export logs and pod metadata before deleting the temporary
pods and input ConfigMap. Use a fresh staging community for another first-upgrade
test; do not clear persisted policy to reset this one.
For a mistaken acceptance, stop the caller and follow the API contract's
containment procedure. Archive or isolate the affected community through the
deployment's authorized operator process; there is no policy rollback command.