fix(workflows): require post-drain lifecycle advertisement activation

Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
Pinky
2026-09-13 10:25:43 -06:00
committed by Brain
parent 5db3f514d9
commit a30a30b8a7
5 changed files with 230 additions and 2 deletions

View File

@@ -69,6 +69,11 @@ RELAY_URL=ws://localhost:3000
# Stable relay signing key (required). `just bootstrap` generates a random key in
# the gitignored .env file. Preserve that value across restarts and backups.
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
# Host-wide NIP-11 workflow lifecycle advertisement: default off; only true/1 opts in.
# Enable only AFTER all incompatible endpoints AND existing connections are drained.
# Gates discovery only, not lifecycle enforcement. See the two-phase operator sequence:
# deploy/charts/buzz/README.md#workflow-lifecycle-activation
BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE=false
# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).

View File

@@ -209,6 +209,13 @@ pub struct Config {
/// are permitted regardless of auth method (API token, NIP-42).
pub require_relay_membership: bool,
/// Advertise the host-wide workflow lifecycle contract in NIP-11.
/// Default off. Enable `BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE=true` only after
/// every endpoint and existing connection for every served host runs
/// compatible lifecycle code; see deploy/charts/buzz/README.md#workflow-lifecycle-activation.
/// This gates discovery only, never the atomic save/delete enforcement.
pub advertise_workflow_lifecycle: bool,
/// Whether this deployment can serve huddle (voice) audio.
///
/// Huddle audio frames are relayed peer-to-peer *within a single pod*
@@ -671,6 +678,10 @@ impl Config {
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let advertise_workflow_lifecycle = std::env::var("BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
// Defaults true → single-pod (N=1) keeps today's huddle behavior. A
// horizontally-scaled deployment sets this false; see the field doc.
let huddle_audio_available = std::env::var("BUZZ_HUDDLE_AUDIO_AVAILABLE")
@@ -1226,6 +1237,7 @@ impl Config {
metrics_port,
pubkey_allowlist_enabled,
require_relay_membership,
advertise_workflow_lifecycle,
huddle_audio_available,
mesh,
mesh_demo_echo,
@@ -1334,6 +1346,42 @@ mod tests {
assert!(found.is_empty(), "unrelated vars must not warn: {found:?}");
}
#[test]
fn workflow_lifecycle_advertisement_requires_explicit_opt_in() {
// Config::from_env readers elsewhere do not share ENV_MUTEX. Keep
// this flag matrix in an isolated child rather than racing them.
const CHILD: &str = "BUZZ_TEST_WORKFLOW_ADVERTISEMENT_CONFIG_CHILD";
let _guard = ENV_MUTEX.lock().unwrap();
if std::env::var_os(CHILD).is_none() {
crate::test_support::run_exact_test_child(
"config::tests::workflow_lifecycle_advertisement_requires_explicit_opt_in",
CHILD,
);
return;
}
for (value, enabled) in [
(None, false),
(Some(""), false),
(Some("false"), false),
(Some("0"), false),
(Some("typo"), false),
(Some("true"), true),
(Some("1"), true),
] {
match value {
Some(value) => std::env::set_var("BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE", value),
None => std::env::remove_var("BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE"),
}
assert_eq!(
Config::from_env()
.expect("config")
.advertise_workflow_lifecycle,
enabled,
"advertisement config {value:?}"
);
}
}
#[test]
fn defaults_are_valid() {
let _guard = ENV_MUTEX.lock().unwrap();

View File

@@ -327,7 +327,11 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
.push("nip-pl".to_string());
info.push = Some(push);
}
if let (Some(_), Some(host)) = (relay_self, tenant_host) {
if let (true, Some(_), Some(host)) = (
state.config.advertise_workflow_lifecycle,
relay_self,
tenant_host,
) {
info.supported_extensions
.get_or_insert_default()
.push("buzz-workflows".to_string());
@@ -419,6 +423,9 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
Option<&str>,
) -> RelayInfo = RelayInfo::build;
#[cfg(test)]
mod postgres_tests;
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,120 @@
//! Exercise both served NIP-11 routes; nextest supplies an isolated database.
use std::sync::Arc;
use axum::{body::Body, http::Request};
use serde_json::{json, Value};
use tower::ServiceExt;
use crate::{config::Config, state::AppState};
async fn served_info(state: &Arc<AppState>, path: &str, host: &str) -> Value {
let response = crate::router::build_router(state.clone())
.oneshot(
Request::builder()
.uri(path)
.header("host", host)
.header("accept", "application/nostr+json")
.body(Body::empty())
.expect("request"),
)
.await
.expect("NIP-11 response");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("bounded NIP-11 body");
serde_json::from_slice(&body).expect("NIP-11 JSON")
}
#[tokio::test]
#[ignore = "requires isolated PostgreSQL"]
async fn workflow_advertisement_requires_activation_identity_and_host_on_both_routes() {
let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("isolated PostgreSQL URL");
let pool = sqlx::PgPool::connect(&url).await.expect("database");
let db = buzz_db::Db::from_pool(pool.clone());
let host = format!("activation-{}.example:8443", uuid::Uuid::new_v4());
db.ensure_configured_community(&host)
.await
.expect("community");
let keys = nostr::Keys::generate();
let mut config = Config::from_env().expect("config");
config.database_url = url;
config.redis_url = "redis://127.0.0.1:1".into();
config.admin = None;
// An unrelated enabled extension must neither bypass nor be hidden by
// lifecycle activation. No gateway, Redis, or relay process is contacted.
config.push_enabled = true;
let cache = tempfile::tempdir().expect("cache directory");
config.git_pack_cache_path = cache.path().to_path_buf();
let redis = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("redis pool");
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis.clone())
.await
.expect("pubsub"),
);
let engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let media = buzz_media::MediaStorage::new(&config.media).expect("media config");
let (state, _) = AppState::new(
config, db, redis, None, pubsub, auth, search, engine, keys, media,
);
let mut state = Arc::new(state);
for enabled in [false, true] {
for stable in [false, true] {
let mutable = Arc::get_mut(&mut state).expect("sole state owner");
let config = Arc::make_mut(&mut mutable.config);
config.advertise_workflow_lifecycle = enabled;
config.relay_private_key =
stable.then(|| mutable.relay_keypair.secret_key().to_secret_hex());
for request_host in [
host.to_uppercase(),
"unmapped.invalid".into(),
String::new(),
] {
let bound = request_host.eq_ignore_ascii_case(&host);
for path in ["/", "/info"] {
let info = served_info(&state, path, &request_host).await;
let advertised = enabled && stable && bound;
assert_eq!(
info.get("workflows").is_some(),
advertised,
"{path} enabled={enabled} stable={stable} host={request_host}"
);
let extensions = info["supported_extensions"].as_array().expect("extensions");
assert_eq!(extensions.contains(&json!("buzz-workflows")), advertised);
if advertised {
assert_eq!(info["workflows"], json!({"lifecycle": 1, "host": host}));
}
assert_eq!(
info.get("self").is_some(),
stable,
"identity is independent of activation"
);
assert_eq!(
info.get("push").is_some(),
bound,
"push is independent of activation"
);
assert_eq!(extensions.contains(&json!("nip-pl")), bound);
}
}
}
}
// Enabled + stable still fails closed if host resolution fails at runtime.
pool.close().await;
for path in ["/", "/info"] {
let info = served_info(&state, path, &host).await;
assert!(info.get("workflows").is_none());
assert!(!info["supported_extensions"]
.as_array()
.expect("extensions")
.contains(&json!("buzz-workflows")));
}
}

View File

@@ -291,12 +291,60 @@ default so long-lived WebSocket connections have time to drain.
## Upgrades
Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure.
Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. Use `helm upgrade` for code rollouts; host-wide capability activation may require a separate post-drain step, as described below.
Migration 0032 is a hard compatibility boundary for relay versions that publish repaired channel rosters. The relay verifies the roster-fence trigger catalog and behavior before opening listeners and refuses to start if 0032 is missing or inert. Apply migrations before rolling the relay; for large installations, prefer a controlled `buzz-admin migrate` job with PostgreSQL lock monitoring before the code rollout.
If you prefer decoupling migrations from serving, set `migrate.autoMigrate=false`. **In that mode the chart does not run migrations for you** — you own running `buzz-admin migrate` (separate Pod / one-shot Job) against the database before every `helm install` / `helm upgrade`. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob `migrate.preUpgradeJob.enabled` is reserved.
### Workflow lifecycle activation
The NIP-11 `buzz-workflows` extension and `workflows: {lifecycle: 1, host: ...}`
are a **host-wide** forward save/delete guarantee, not a statement about the pod
that answered discovery. `BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE` defaults to off;
only `true` or `1` enables it. This startup setting gates advertisement only:
new binaries enforce atomic lifecycle writes even while it is off. Stable relay
identity and successful request-host binding are still required to advertise.
Do not enable it in the same rolling upgrade that first installs lifecycle-capable
code. `RollingUpdate` and the version-independent Service can route a later
connection to an old replica; existing WebSockets can also continue writing
through an old replica after it leaves the Service endpoints. The additive
`workflow_deletions` migration/serving fence does not drain those old processes.
1. **Deploy with advertisement off** (unset or `false` on every replica). Apply
the required schema and roll out the lifecycle-capable image. While old and
new replicas coexist, clients must not rely on lifecycle 1. Existing split
state, including writes made during this phase, is not repaired by activation.
2. **Verify drain before enabling.** Wait for rollout completion, then verify
every HTTP/WebSocket endpoint behind every served host uses compatible code.
Include canary pools, alternate ingress routes and other deployments sharing
those hosts. Remove incompatible endpoints and finish draining/terminating
their existing connections and in-flight writes. A single successful NIP-11
probe or a ready new pod is not evidence of this host-wide condition.
3. **Activate separately**, retaining the compatible image, using the existing
`relay.extraEnv` list (append this entry; preserve other entries):
```yaml
relay:
extraEnv:
- name: BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE
value: "true"
```
Run a second `helm upgrade`/GitOps sync and wait for this config rollout.
Mixed off/on replicas are now safe because all replicas already enforce the
contract. Verify `/info` and `/` with `Accept: application/nostr+json` for each
served host: `buzz-workflows` is listed, `workflows.lifecycle` is `1`, and
`workflows.host` matches the normalized request host.
**Rollback:** switching this flag off does not disable enforcement or revoke
capabilities clients already observed. Prefer rolling back only to compatible
code. Before reintroducing incompatible code, stop traffic for the affected
hosts, disable advertisement everywhere, drain active connections/in-flight
writes, and require clients to discard cached discovery and rediscover before
resuming. If that cannot be guaranteed, do not downgrade those hosts.
## Backups
Save these. Losing any of them is data loss. See NOTES.txt printed by `helm install` for the live list: