mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
120
crates/buzz-relay/src/nip11/postgres_tests.rs
Normal file
120
crates/buzz-relay/src/nip11/postgres_tests.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user