mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
## Summary Adds bounded, serialized advancement of an empty right-edge partition catch-all for `events` and `delivery_log`. The repaired catalogs currently end in an empty catch-all from 2027-01-01, and the merged manager (#6515) only audits them. Without this change, January writes land in the catch-all, after which only an operator can move them. When `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED=true`, each relay keeps six dedicated monthlies after the current month. It does this at startup and on every audit interval by replacing the empty catch-all with canonical monthlies and a new canonical `<table>_p_future` after the horizon. From today's catalogs the first run creates January–March 2027 and moves the catch-all to April 2027. ## Safety design - **Refuse before locking.** The following return `operator_required` without taking any table lock: - a populated catch-all, or one whose occupancy is unknown; - a catch-all with child-only triggers, which the replacements would not inherit; - a misaligned bound; - a `DEFAULT`, anomalous or pending-detach child; - a name collision. - **One winner.** A schema-scoped `pg_try_advisory_xact_lock` is taken before any DDL. Losers report `skipped_locked` and never issue DDL. The winner re-plans from a fresh audit before taking any table lock and checks for collisions. A runner that lost a race therefore sees the committed layout as a no-op, not as colliding names. - **Writer lock order.** First `ONLY` the parent is locked `ACCESS EXCLUSIVE` under a 2 s `lock_timeout`. Then each foreign-key counterpart (today only `communities`) is locked in OID order `SHARE ROW EXCLUSIVE NOWAIT`, then the catch-all `NOWAIT`. - The counterparts are read again once the parent is locked, and that set is the one locked. Any FK DDL on or to the parent conflicts with the parent lock, so the set can't change after that. An FK committed while maintenance waited is therefore locked `NOWAIT` too, so attaching never waits on it under the parent lock. The read before the lock remains as a cheap early refusal. - This is the order an insert takes locks (parent, then the FK check on `communities`), so maintenance cannot deadlock with ingest. The internal manual runbook used to lock `communities` first; it now uses this order too. - `SHARE ROW EXCLUSIVE` is the strongest lock the DDL takes on `communities`; measured on PG 16 and 17, the catch-all `DROP` takes none, as long as the catch-all's only foreign keys are those inherited from the parent. A catch-all with its own foreign key, or one that another table's foreign key references, is refused: once before any lock, and again after the catch-all lock, where the check is authoritative. Readers and other writers' `FOR KEY SHARE` checks proceed. A concurrent `communities` writer makes the run fail fast with `lock_timeout`. - A `40P01` deadlock victim is also reported as `lock_timeout`. - Parents that are referenced by a foreign key, or that reference an inherited table, are refused, including when such a key commits while maintenance waits on the parent. - **Re-plan under lock.** The catalog is re-audited under those locks. The transaction proceeds only if the plan is unchanged. - **Bounded.** `statement_timeout` 5 s, `idle_in_transaction_session_timeout` 5 s, and a 10 s client deadline per table. A deadline-abandoned connection is closed, never returned to the pool, and the server rolls back anything uncommitted. If the deadline fires after `COMMIT` was sent, the change may have landed; the next audit reports the truth. The orphaned backend keeps its locks until its in-flight statement ends, at most `statement_timeout`. - **Verify before commit.** The changed catalog is re-audited inside the transaction for bounds, canonical kinds, trigger parity and catch-all emptiness, and serving safety must not regress. It also checks that every new child has valid, ready clones of each parent index and validated clones of each PK/unique/FK constraint. - **Audits tolerate a concurrent advance.** Catalog rows come from the statement snapshot, but `pg_get_expr` renders from current catalog state. So an audit that overlaps another relay's `DROP` of a catch-all can see a partition that no longer exists. The read-only audit detects this (a NULL bound, or `42P01` from the emptiness probe) and retries up to twice with a fresh snapshot. - **Fail fast on audit probes.** If another relay's audit is probing the catch-all's emptiness, the catch-all `NOWAIT` lock fails and the run reports `lock_timeout`. The parent lock is then released immediately, and the next runner or interval completes the advance. Uncovered-month creation (`BUZZ_PARTITION_MANAGER_CREATE_ENABLED`, default `true`) now runs through the same locked transaction, replacing the unlocked per-month `CREATE`. That flag remains the kill switch for all automatic partition DDL: with it off, advancement is disabled too. With advancement disabled (the default) the periodic loop stays read-only and startup behaves as before, apart from the locking. A runner that planned DDL always re-audits before returning, even when a peer did the work, so it never reports the pre-maintenance layout. A serialized re-plan that finds only disabled work reports `skipped_disabled`. ## Behavior changes - The relay audit horizon and the `buzz-admin partition-audit` default are now 6 months (`PARTITION_MANAGER_MONTHS_AHEAD`), up from 3. Until advancement is enabled, months already covered by the catch-all make `degraded` true. That correctly reports a runway under six months. - New metric `buzz_partition_maintenance_runs_total{table,outcome}` with outcomes `noop | skipped_disabled | created | advanced | skipped_locked | operator_required | lock_timeout | deadline | error`. `buzz_partition_create_attempts_total` keeps its per-month `created | skipped_covered | error` labels. `error` there still means a DDL or collision failure; `lock_timeout` and `deadline` runs add nothing to it, and only `buzz_partition_maintenance_runs_total` records them. - `ensure_future_partitions` (free function and `Db` method) is removed; `maintain_partitions` is the one entry point. - New advisory-lock label `lock_type="partition_maintenance"`. ## Rollout Enable `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` per environment in BPCI: `bb-block-staging` first, then production. The staging canary proves a real advance because the six-month horizon already extends past January. Fallback: rerun the internal manual runbook, which uses the same parent-first lock order as of 2026-10-05. It keeps `ACCESS EXCLUSIVE` on the counterparts, because its detach/attach can drop FK triggers on them. ## Testing - `cargo test -p buzz-db --lib partition`: 17 unit tests. The 10 new ones cover: - plans: repaired layout, policy gating, no-op, refusal states including child-only triggers, December→January; - the create kill switch also stopping advancement; - the month cap at exactly 120; - per-month metric outcomes for transient failures versus DDL failures; - the mid-audit-drop retry classifier. - `cargo test -p buzz-db --lib partition -- --ignored` (PostgreSQL 17): 48/48 on 9 consecutive runs, including 17 new tests: - advance to the canonical layout, with routing and idempotent rerun; - replacing a canonically named catch-all in one transaction; - populated catch-all refused in under 1 s while the parent is locked; - a `communities` writer making the run fail fast and roll back; - a `communities` reader not blocking the advance; - an in-flight insert's FK check, and its write to `communities`, behind queued maintenance completing without a deadlock; - a foreign key committed while maintenance waits on the parent, with a writer on its new counterpart, failing fast as `lock_timeout` on that table instead of waiting under the parent lock; - a foreign key referencing `events`, committed while maintenance waits on the parent, being refused under the parent lock; - a catch-all's own foreign key, or a foreign key referencing it, being refused before the parent lock with the key intact, and a catch-all key committed while maintenance waits on the parent being refused after the catch-all lock; - a `40P01` deadlock victim being classified as `lock_timeout`; - parent contention timing out without blocking the other table; - held advisory lock skipping with no DDL; - concurrent runners converging, with at most a transient `lock_timeout`; - a stale audit after a concurrent advance being a no-op, and the losing runner returning the committed layout; - a re-plan that finds only disabled work reporting `skipped_disabled`; - an audit retrying when a listed partition is dropped mid-audit; - a catch-all with a child-only trigger being refused; - client deadline abandoning the attempt and closing the session (scoped to the test's own blocked backend); - catch-all name collision; - incoming foreign key refused. - Mutation checks: - restoring the old counterpart-first `ACCESS EXCLUSIVE` order fails the reader test (`lock_timeout`) and the writer test (a ~1 s deadlock wait); - dropping `NOWAIT` from the counterpart lock, or allowing a populated catch-all, fails the matching tests; - removing the parent-locked counterpart re-read fails the committed-FK tests (the parent is held 2.06 s waiting on the new counterpart), and ignoring its refusal fails the referencing-FK test; - locking the counterparts `SHARE ROW EXCLUSIVE` before the parent fails the writer test; - removing either catch-all foreign-key check fails its test. - Full PostgreSQL lane: 716/718. Both failures are timing-sensitive tests outside partition code, and each passes 3/3 when run alone: - `hanging_redis_peer_times_out_before_the_health_listener_binds`; - `readiness_check_cancellation_balances_waiter_and_inflight_connection`. - `cargo clippy -p buzz-db -p buzz-relay -p buzz-admin --all-targets -D warnings` and `cargo fmt --check` are clean. ## Related Refs #2396. This PR conflicts textually with #7994 (partition audit observability follow-ups) in `crates/buzz-db/src/store/partition.rs` and `crates/buzz-relay/src/main.rs`. Whichever PR merges second will be rebased onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Ian Oberst <ioberst@block.xyz> Co-authored-by: Ian Oberst <ioberst@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1094 lines
37 KiB
Rust
1094 lines
37 KiB
Rust
#![deny(unsafe_code)]
|
|
|
|
//! Buzz instance administration CLI.
|
|
//!
|
|
//! # Member management (NIP-43)
|
|
//!
|
|
//! ## Why only kind:13534 (membership list), not kind:8000/8001 (deltas)
|
|
//!
|
|
//! CLI intentionally does not emit kind 8000/8001 deltas —
|
|
//! `publish_nip43_delta` is in-process-only (no Redis hop), so a sidecar call
|
|
//! stores but never pushes. The 13534 list snapshot is the authoritative roster
|
|
//! and rides Redis to live clients. Do not wire a delta call that passes
|
|
//! in-process tests and silently no-ops in the deployed `compose exec` path.
|
|
//!
|
|
//! ## Same-second domination guard
|
|
//!
|
|
//! The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats
|
|
//! same-second domination for serial invocations; it does NOT serialize
|
|
//! concurrent CLI processes — two near-simultaneous adds can read the same
|
|
//! newest timestamp and collide on the bumped second. run.sh serialization is
|
|
//! the guard against parallel adds (e.g. `xargs -P`).
|
|
|
|
mod communities;
|
|
mod deletions;
|
|
|
|
use std::future::Future;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use anyhow::Result;
|
|
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
|
|
use buzz_core::tenant::{relay_url_authority, TenantContext};
|
|
use buzz_db::{partition::PartitionAuditReport, Db, DbConfig};
|
|
use buzz_media::{BucketSnapshot, MediaConfig, MediaStorage, S3AddressingStyle, SweepError};
|
|
use buzz_pubsub::{EventTopic, PubSubManager};
|
|
use clap::{Parser, Subcommand};
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use serde::Serialize;
|
|
use sqlx::Row;
|
|
use tracing::warn;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "buzz-admin", about = "Buzz instance administration")]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Command {
|
|
/// Add a pubkey to the relay membership list.
|
|
///
|
|
/// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row,
|
|
/// publishes a kind:13534 membership roster via Redis so live clients see
|
|
/// the updated list immediately.
|
|
AddMember {
|
|
/// Nostr public key — bech32 npub or 64-char hex.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
|
|
/// Role: "admin" or "member" (default: member). Cannot be "owner" —
|
|
/// use RELAY_OWNER_PUBKEY config to set the relay owner.
|
|
#[arg(long, default_value = "member")]
|
|
role: String,
|
|
},
|
|
/// Remove a pubkey from the relay membership list.
|
|
///
|
|
/// Accepts a bech32 npub or 64-char hex pubkey. After removing the DB row,
|
|
/// publishes a kind:13534 membership roster via Redis. Cannot remove the
|
|
/// relay owner — change RELAY_OWNER_PUBKEY config instead.
|
|
RemoveMember {
|
|
/// Nostr public key — bech32 npub or 64-char hex.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
|
|
/// Only remove if the member's current role matches this value.
|
|
/// Omit to remove regardless of role.
|
|
#[arg(long)]
|
|
role: Option<String>,
|
|
},
|
|
/// List all relay members.
|
|
ListMembers,
|
|
/// Generate a new Nostr keypair (for bootstrapping).
|
|
GenerateKey,
|
|
/// Run pending database migrations.
|
|
Migrate,
|
|
/// Compute one complete S3 storage snapshot and persist it for relay readers.
|
|
StorageSnapshot {
|
|
/// Abort before folding a page that would exceed this object count.
|
|
#[arg(long, default_value_t = 10_000_000)]
|
|
max_objects: u64,
|
|
},
|
|
/// Run the partition catalog audit using a read-only database session.
|
|
PartitionAudit {
|
|
/// Future months to include in the coverage check.
|
|
#[arg(long, default_value_t = buzz_db::partition::PARTITION_MANAGER_MONTHS_AHEAD)]
|
|
months_ahead: u32,
|
|
},
|
|
/// Inspect deployment-wide Buzz product feedback.
|
|
ProductFeedback {
|
|
#[command(subcommand)]
|
|
command: ProductFeedbackCommand,
|
|
},
|
|
/// Reversible whole-community lifecycle controls.
|
|
Communities {
|
|
#[command(subcommand)]
|
|
command: communities::CommunitiesCommand,
|
|
},
|
|
/// Durable CLI-only whole-community deletion control plane.
|
|
Deletions {
|
|
#[command(subcommand)]
|
|
command: deletions::DeletionsCommand,
|
|
},
|
|
/// Emit missing kind:39000/39001/39002 channel discovery events, or
|
|
/// republish only a targeted channel's kind:39002 roster.
|
|
///
|
|
/// Without `--channel`, only channels missing discovery metadata are
|
|
/// reconciled. With `--channel`, only that channel's member snapshot is
|
|
/// replaced; canonical metadata and admin events remain untouched.
|
|
ReconcileChannels {
|
|
/// Optional channel UUID to force-republish.
|
|
#[arg(long)]
|
|
channel: Option<String>,
|
|
|
|
/// Relay private key (hex) for signing events. Falls back to
|
|
/// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates
|
|
/// an ephemeral key (events will be unverifiable after restart).
|
|
#[arg(long)]
|
|
relay_key: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ProductFeedbackCommand {
|
|
/// List feedback across every community as JSON.
|
|
List {
|
|
/// Maximum records to return.
|
|
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
|
limit: u16,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
// Install the ring CryptoProvider for rustls. The workspace redis TLS
|
|
// feature compiles both aws-lc-rs and ring in transitively, so rustls can't
|
|
// auto-select a provider and would panic on the first rediss:// (ElastiCache)
|
|
// Redis TLS connection without this. Mirrors buzz-relay's main().
|
|
rustls::crypto::ring::default_provider()
|
|
.install_default()
|
|
.expect("failed to install rustls crypto provider");
|
|
|
|
let cli = Cli::parse();
|
|
|
|
let code = match run(cli).await {
|
|
Ok(code) => code,
|
|
Err(e) => {
|
|
eprintln!("error: {e}");
|
|
5
|
|
}
|
|
};
|
|
std::process::exit(code);
|
|
}
|
|
|
|
async fn run(cli: Cli) -> Result<i32> {
|
|
match cli.command {
|
|
Command::GenerateKey => {
|
|
let keys = Keys::generate();
|
|
println!("Public key: {}", keys.public_key().to_hex());
|
|
println!("Secret key: {}", keys.secret_key().display_secret());
|
|
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
|
|
Ok(0)
|
|
}
|
|
Command::Migrate => {
|
|
let db = connect_db().await?;
|
|
db.migrate().await?;
|
|
println!("Database migrations complete.");
|
|
Ok(0)
|
|
}
|
|
Command::StorageSnapshot { max_objects } => cmd_storage_snapshot(max_objects).await,
|
|
Command::PartitionAudit { months_ahead } => cmd_partition_audit(months_ahead).await,
|
|
Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await,
|
|
Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await,
|
|
Command::ListMembers => cmd_list_members().await,
|
|
Command::ProductFeedback {
|
|
command: ProductFeedbackCommand::List { limit },
|
|
} => cmd_list_product_feedback(limit).await,
|
|
Command::Communities { command } => communities::run(command).await,
|
|
Command::Deletions { command } => deletions::run(command).await,
|
|
Command::ReconcileChannels { channel, relay_key } => {
|
|
reconcile_channels(channel, relay_key).await?;
|
|
Ok(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One session: the worker detaches its lock-owning connection, and the
|
|
/// cold-start pool opens no idle replacements while it scans S3.
|
|
fn storage_snapshot_db_config(base: DbConfig) -> DbConfig {
|
|
DbConfig {
|
|
max_connections: 1,
|
|
..base
|
|
}
|
|
}
|
|
|
|
async fn cmd_storage_snapshot(max_objects: u64) -> Result<i32> {
|
|
let max_objects_db = i64::try_from(max_objects)
|
|
.map_err(|_| anyhow::anyhow!("--max-objects must be at most {}", i64::MAX))?;
|
|
if max_objects == 0 {
|
|
return Err(anyhow::anyhow!("--max-objects must be greater than zero"));
|
|
}
|
|
|
|
let db = Db::connect_cold_start(
|
|
storage_snapshot_db_config(db_config_from_env()),
|
|
"storage_snapshot",
|
|
)
|
|
.await?;
|
|
let mut leader = db.try_lock_storage_accounting().await?.ok_or_else(|| {
|
|
anyhow::anyhow!("another storage-snapshot worker already holds the lease")
|
|
})?;
|
|
let storage = Arc::new(MediaStorage::new(&storage_config_from_env()?)?);
|
|
let code_sha =
|
|
std::env::var("BUZZ_STORAGE_SNAPSHOT_CODE_SHA").unwrap_or_else(|_| "unknown".to_string());
|
|
if code_sha.is_empty() || code_sha.len() > 128 {
|
|
return Err(anyhow::anyhow!(
|
|
"BUZZ_STORAGE_SNAPSHOT_CODE_SHA must contain 1 to 128 bytes"
|
|
));
|
|
}
|
|
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({
|
|
"event": "storage_snapshot_started",
|
|
"max_objects": max_objects,
|
|
"code_sha": code_sha,
|
|
})
|
|
);
|
|
let run_started = Instant::now();
|
|
let listed_objects = Arc::new(AtomicU64::new(0));
|
|
let fold = buzz_media::fold_bucket_listing(max_objects, move |token| {
|
|
let storage = Arc::clone(&storage);
|
|
let listed_objects = Arc::clone(&listed_objects);
|
|
async move {
|
|
let page = storage.list_page(token, 1000).await?;
|
|
let page_objects = u64::try_from(page.objects.len()).unwrap_or(u64::MAX);
|
|
let before = listed_objects.fetch_add(page_objects, Ordering::Relaxed);
|
|
let after = before.saturating_add(page_objects);
|
|
if before / 100_000 != after / 100_000 {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({
|
|
"event": "storage_snapshot_progress",
|
|
"listed_objects": after,
|
|
"max_objects": max_objects,
|
|
})
|
|
);
|
|
}
|
|
Ok(page)
|
|
}
|
|
});
|
|
let snapshot_code_sha = code_sha.clone();
|
|
let persisted = persist_completed_fold(fold, move |encoded, duration_ms| async move {
|
|
leader
|
|
.save_snapshot(&encoded, duration_ms, max_objects_db, &snapshot_code_sha)
|
|
.await?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
let (snapshot, duration_ms) = match persisted {
|
|
Ok(completed) => completed,
|
|
Err(error) => {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({
|
|
"event": "storage_snapshot_failed",
|
|
"duration_ms": i64::try_from(run_started.elapsed().as_millis()).unwrap_or(i64::MAX),
|
|
"max_objects": max_objects,
|
|
"code_sha": code_sha,
|
|
"error": error.to_string(),
|
|
})
|
|
);
|
|
return Err(error);
|
|
}
|
|
};
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({
|
|
"event": "storage_snapshot_completed",
|
|
"duration_ms": duration_ms,
|
|
"listed_objects": snapshot.physical_objects,
|
|
"listed_bytes": snapshot.physical_bytes,
|
|
"logical_objects": snapshot.logical_objects,
|
|
"logical_bytes": snapshot.logical_bytes,
|
|
"max_objects": max_objects,
|
|
"code_sha": code_sha,
|
|
})
|
|
);
|
|
Ok(0)
|
|
}
|
|
|
|
async fn persist_completed_fold<FoldFuture, Persist, PersistFuture>(
|
|
fold: FoldFuture,
|
|
persist: Persist,
|
|
) -> Result<(BucketSnapshot, i64)>
|
|
where
|
|
FoldFuture: Future<Output = std::result::Result<BucketSnapshot, SweepError>>,
|
|
Persist: FnOnce(serde_json::Value, i64) -> PersistFuture,
|
|
PersistFuture: Future<Output = Result<()>>,
|
|
{
|
|
let started = Instant::now();
|
|
let snapshot = fold.await?;
|
|
let duration_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX);
|
|
let encoded = serde_json::to_value(&snapshot)?;
|
|
persist(encoded, duration_ms).await?;
|
|
Ok((snapshot, duration_ms))
|
|
}
|
|
|
|
fn storage_config_from_env() -> Result<MediaConfig> {
|
|
let required = |name: &str| {
|
|
std::env::var(name).map_err(|_| anyhow::anyhow!("{name} must be set for storage-snapshot"))
|
|
};
|
|
let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE")
|
|
.unwrap_or_else(|_| "path".to_string())
|
|
.parse::<S3AddressingStyle>()
|
|
.map_err(anyhow::Error::msg)?;
|
|
Ok(MediaConfig {
|
|
s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT").unwrap_or_default(),
|
|
s3_access_key: std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_default(),
|
|
s3_secret_key: std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_default(),
|
|
s3_bucket: required("BUZZ_S3_BUCKET")?,
|
|
s3_region: std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
|
|
s3_addressing_style: addressing_style,
|
|
max_image_bytes: 1,
|
|
max_gif_bytes: 1,
|
|
max_video_bytes: 1,
|
|
max_file_bytes: 1,
|
|
public_base_url: "http://storage-snapshot.invalid/media".to_string(),
|
|
upload_records_enabled: false,
|
|
upload_ip_header: None,
|
|
upload_port_header: None,
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PartitionAuditIdentity {
|
|
database: String,
|
|
user: String,
|
|
schema: String,
|
|
default_transaction_read_only: bool,
|
|
transaction_read_only: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PartitionAuditOutput {
|
|
schema_version: u32,
|
|
mode: &'static str,
|
|
source_sha: &'static str,
|
|
build_id: &'static str,
|
|
build_url: &'static str,
|
|
outcome: &'static str,
|
|
months_ahead: u32,
|
|
identity: PartitionAuditIdentity,
|
|
report: PartitionAuditReport,
|
|
}
|
|
|
|
async fn cmd_partition_audit(months_ahead: u32) -> Result<i32> {
|
|
let db_url = std::env::var("DATABASE_URL")
|
|
.map_err(|_| anyhow::anyhow!("DATABASE_URL is required for partition-audit"))?;
|
|
let config = DbConfig {
|
|
database_url: db_url,
|
|
max_connections: 1,
|
|
min_connections: 1,
|
|
statement_timeout_ms: 30_000,
|
|
default_transaction_read_only: true,
|
|
..DbConfig::default()
|
|
};
|
|
let pool = Db::connect_writer_pool(&config).await?;
|
|
let row = sqlx::query(
|
|
"SELECT current_database() AS database, current_user AS user, \
|
|
current_schema() AS schema, \
|
|
current_setting('default_transaction_read_only') = 'on' \
|
|
AS default_transaction_read_only, \
|
|
current_setting('transaction_read_only') = 'on' AS transaction_read_only",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await?;
|
|
let identity = PartitionAuditIdentity {
|
|
database: row.try_get("database")?,
|
|
user: row.try_get("user")?,
|
|
schema: row.try_get("schema")?,
|
|
default_transaction_read_only: row.try_get("default_transaction_read_only")?,
|
|
transaction_read_only: row.try_get("transaction_read_only")?,
|
|
};
|
|
if !identity.default_transaction_read_only || !identity.transaction_read_only {
|
|
anyhow::bail!("partition-audit connection is not read-only");
|
|
}
|
|
|
|
let report = Db::from_pool(pool)
|
|
.audit_partitions_report(months_ahead)
|
|
.await;
|
|
let outcome = if !report.errors.is_empty() {
|
|
"error"
|
|
} else if !report.serving_safe() {
|
|
"unsafe"
|
|
} else if report.tables.iter().any(|table| table.degraded()) {
|
|
"degraded"
|
|
} else {
|
|
"ok"
|
|
};
|
|
let code = match outcome {
|
|
"error" => 5,
|
|
"unsafe" => 2,
|
|
_ => 0,
|
|
};
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&PartitionAuditOutput {
|
|
schema_version: 1,
|
|
mode: "read_only",
|
|
source_sha: option_env!("BUZZ_SOURCE_SHA").unwrap_or("unknown"),
|
|
build_id: option_env!("BUZZ_BUILD_ID").unwrap_or("local"),
|
|
build_url: option_env!("BUZZ_BUILD_URL").unwrap_or("unknown"),
|
|
outcome,
|
|
months_ahead,
|
|
identity,
|
|
report,
|
|
})?
|
|
);
|
|
Ok(code)
|
|
}
|
|
|
|
async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
|
|
if let Err(msg) = validate_role(&role) {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
|
|
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg) {
|
|
Ok(h) => h,
|
|
Err(msg) => {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
};
|
|
|
|
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
|
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
match db
|
|
.add_relay_member(tenant.community(), &pubkey_hex, &role, None)
|
|
.await
|
|
{
|
|
Ok(true) => println!("added {pubkey_hex} as {role}"),
|
|
Ok(false) => println!("already a member: {pubkey_hex} (no change)"),
|
|
Err(e) => {
|
|
eprintln!("error: DB write failed: {e}");
|
|
return Ok(5);
|
|
}
|
|
}
|
|
|
|
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
|
|
eprintln!("warning: member added to DB but list publish failed: {e}");
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> Result<i32> {
|
|
if let Some(ref role) = role_filter {
|
|
if let Err(msg) = validate_role(role) {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
}
|
|
|
|
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg) {
|
|
Ok(h) => h,
|
|
Err(msg) => {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
};
|
|
|
|
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
|
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
use buzz_db::relay_members::RemoveResult;
|
|
let result = if let Some(ref role) = role_filter {
|
|
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
|
|
.await
|
|
} else {
|
|
db.remove_relay_member(tenant.community(), &pubkey_hex)
|
|
.await
|
|
};
|
|
|
|
match result {
|
|
Ok(RemoveResult::Removed) => println!("removed {pubkey_hex}"),
|
|
Ok(RemoveResult::NotFound) => {
|
|
eprintln!("error: member not found: {pubkey_hex}");
|
|
return Ok(2);
|
|
}
|
|
Ok(RemoveResult::IsOwner) => {
|
|
eprintln!(
|
|
"error: cannot remove relay owner: {pubkey_hex}\n\
|
|
To change the owner, update RELAY_OWNER_PUBKEY and restart."
|
|
);
|
|
return Ok(3);
|
|
}
|
|
Ok(RemoveResult::RoleMismatch) => {
|
|
let role_str = role_filter.as_deref().unwrap_or("(unknown)");
|
|
eprintln!("error: role mismatch — {pubkey_hex} is not currently '{role_str}'");
|
|
return Ok(4);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("error: DB write failed: {e}");
|
|
return Ok(5);
|
|
}
|
|
}
|
|
|
|
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
|
|
eprintln!("warning: member removed from DB but list publish failed: {e}");
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
async fn cmd_list_product_feedback(limit: u16) -> Result<i32> {
|
|
let db = connect_db().await?;
|
|
let feedback = db.list_product_feedback(i64::from(limit)).await?;
|
|
println!("{}", serde_json::to_string_pretty(&feedback)?);
|
|
Ok(0)
|
|
}
|
|
|
|
async fn cmd_list_members() -> Result<i32> {
|
|
let db = connect_db().await?;
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
let members = db.list_relay_members(tenant.community()).await?;
|
|
|
|
if members.is_empty() {
|
|
println!("(no relay members)");
|
|
return Ok(0);
|
|
}
|
|
|
|
println!(
|
|
"{:<66} {:<8} {:<66} created_at",
|
|
"pubkey", "role", "added_by"
|
|
);
|
|
println!("{}", "-".repeat(160));
|
|
for m in &members {
|
|
let added_by = m.added_by.as_deref().unwrap_or("-");
|
|
println!(
|
|
"{:<66} {:<8} {:<66} {}",
|
|
m.pubkey,
|
|
m.role,
|
|
added_by,
|
|
m.created_at.format("%Y-%m-%dT%H:%M:%SZ")
|
|
);
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
|
|
fn validate_role(role: &str) -> std::result::Result<(), String> {
|
|
match role {
|
|
"member" | "admin" => Ok(()),
|
|
"owner" => {
|
|
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
|
|
}
|
|
other => Err(format!(
|
|
"invalid role '{other}': must be 'member' or 'admin'"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Parse a bech32 npub or 64-char hex pubkey into lowercase hex.
|
|
fn parse_pubkey_hex(input: &str) -> std::result::Result<String, String> {
|
|
nostr::PublicKey::parse(input)
|
|
.map(|pk| pk.to_hex())
|
|
.map_err(|e| format!("invalid pubkey '{input}': {e}"))
|
|
}
|
|
|
|
/// Publish kind:13534 with `custom_created_at = max(now, newest_existing + 1s)`.
|
|
///
|
|
/// Guarantees the new event is not dominated by a same-second prior invocation,
|
|
/// so `replace_addressable_event` always inserts and dispatches to Redis.
|
|
///
|
|
/// See module-level doc for the TOCTOU caveat on concurrent CLI processes.
|
|
async fn publish_membership_list_with_bump(
|
|
db: &Db,
|
|
pubsub: &Arc<PubSubManager>,
|
|
relay_keypair: &Keys,
|
|
tenant: &TenantContext,
|
|
) -> Result<()> {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let relay_pubkey = relay_keypair.public_key();
|
|
let relay_pubkey_bytes = relay_pubkey.to_bytes();
|
|
|
|
// Query the newest existing kind:13534 for this relay's pubkey (channel_id=None).
|
|
let newest_ts = db
|
|
.get_latest_global_replaceable(
|
|
tenant.community(),
|
|
KIND_NIP43_MEMBERSHIP_LIST as i32,
|
|
&relay_pubkey_bytes,
|
|
)
|
|
.await?
|
|
.map(|e| e.event.created_at.as_secs());
|
|
|
|
// custom_created_at = max(now, existing + 1s) — defeats same-second domination.
|
|
let ts = match newest_ts {
|
|
Some(existing) => (existing + 1).max(now),
|
|
None => now,
|
|
};
|
|
|
|
let members = db.list_relay_members(tenant.community()).await?;
|
|
|
|
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
|
|
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
|
|
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
|
|
for member in &members {
|
|
tags.push(
|
|
Tag::parse(["member", &member.pubkey, &member.role])
|
|
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
|
|
);
|
|
}
|
|
|
|
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "")
|
|
.tags(tags)
|
|
.custom_created_at(nostr::Timestamp::from(ts))
|
|
.sign_with_keys(relay_keypair)
|
|
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
|
|
|
|
let (stored, was_inserted) = db
|
|
.replace_addressable_event(tenant.community(), &event, None)
|
|
.await?;
|
|
if was_inserted {
|
|
// Publish to Redis so live clients receive the updated roster.
|
|
// Community-global scope (EventTopic::Global) matches the relay's own
|
|
// membership-list publish path; the tenant fixes the community.
|
|
if let Err(e) = pubsub
|
|
.publish_event(tenant, EventTopic::Global, &stored.event)
|
|
.await
|
|
{
|
|
warn!("Redis publish of kind:13534 failed: {e}");
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
member_count = members.len(),
|
|
ts,
|
|
"NIP-43 membership list published by buzz-admin"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Connect to DB, Redis pub/sub, and load the relay keypair.
|
|
///
|
|
/// `BUZZ_RELAY_PRIVATE_KEY` is required — the CLI signs kind:13534 events.
|
|
async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {
|
|
let db = connect_db().await?;
|
|
|
|
let relay_keypair = {
|
|
let hex = std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member.\n\
|
|
The relay must have a stable signing key to publish kind:13534 events."
|
|
)
|
|
})?;
|
|
Keys::parse(&hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))?
|
|
};
|
|
|
|
let redis_url =
|
|
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
|
|
|
|
let redis_pool = {
|
|
let cfg = deadpool_redis::Config::from_url(&redis_url);
|
|
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
|
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
|
|
};
|
|
|
|
let pubsub = Arc::new(
|
|
PubSubManager::new(&redis_url, redis_pool)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("PubSub init failed: {e}"))?,
|
|
);
|
|
|
|
Ok((db, pubsub, relay_keypair))
|
|
}
|
|
|
|
async fn connect_db() -> Result<Db> {
|
|
Ok(Db::new(&db_config_from_env()).await?)
|
|
}
|
|
|
|
fn db_config_from_env() -> DbConfig {
|
|
let db_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| DbConfig::default().database_url);
|
|
DbConfig {
|
|
database_url: db_url,
|
|
..DbConfig::default()
|
|
}
|
|
.with_session_timeouts_from_env()
|
|
}
|
|
|
|
/// Resolve the deployment's tenant from the configured `RELAY_URL` host.
|
|
///
|
|
/// `buzz-admin` runs inside the relay container (`compose exec relay
|
|
/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same
|
|
/// single community against the durable `communities` host map. This is
|
|
/// deliberately NOT a default tenant: an unmapped host fails closed with an
|
|
/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is
|
|
/// single-community per invocation — there is no cross-community sweep.
|
|
async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
|
|
let relay_url =
|
|
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
|
|
// Derive the authority the *same* way startup seeding and live request
|
|
// resolution do (`buzz_core::tenant::relay_url_authority`): host plus an
|
|
// explicit non-default port, IPv6 brackets preserved. A plain
|
|
// `Url::host_str()` drops the port/brackets, so for `ws://localhost:3000`
|
|
// the admin would look up `localhost` while startup seeded `localhost:3000`
|
|
// — and `wss://relay.example:8443` would resolve `relay.example`. Sharing
|
|
// the helper keeps buzz-admin byte-identical to the community startup seeds.
|
|
let host = relay_url_authority(&relay_url);
|
|
let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"RELAY_URL host '{host}' is not mapped to a community.\n\
|
|
buzz-admin operates on the configured relay's community; ensure the \
|
|
relay has started and seeded its community (or set RELAY_URL to a \
|
|
mapped host)."
|
|
)
|
|
})?;
|
|
Ok(TenantContext::resolved(record.id, record.host))
|
|
}
|
|
|
|
async fn reconcile_channels(
|
|
channel_arg: Option<String>,
|
|
relay_key_arg: Option<String>,
|
|
) -> Result<()> {
|
|
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
|
|
use buzz_db::event::EventQuery;
|
|
|
|
let db = connect_db().await?;
|
|
|
|
// Resolve relay signing key: arg > env > ephemeral. Force-republish must
|
|
// never use an ephemeral key because it replaces an existing authoritative
|
|
// snapshot.
|
|
let configured_relay_key =
|
|
relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok());
|
|
if channel_arg.is_some() && configured_relay_key.is_none() {
|
|
return Err(anyhow::anyhow!(
|
|
"--channel requires --relay-key or BUZZ_RELAY_PRIVATE_KEY"
|
|
));
|
|
}
|
|
let relay_keys = match configured_relay_key {
|
|
Some(key_hex) => {
|
|
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
|
|
}
|
|
None => {
|
|
let k = Keys::generate();
|
|
eprintln!(
|
|
"Warning: no relay key provided — using ephemeral key {}",
|
|
k.public_key().to_hex()
|
|
);
|
|
eprintln!("Events signed with this key won't be verifiable after this run.");
|
|
eprintln!("Pass --relay-key or set BUZZ_RELAY_PRIVATE_KEY for production use.");
|
|
k
|
|
}
|
|
};
|
|
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
let target_channel = channel_arg
|
|
.as_deref()
|
|
.map(uuid::Uuid::parse_str)
|
|
.transpose()
|
|
.map_err(|e| anyhow::anyhow!("invalid --channel UUID: {e}"))?;
|
|
let channels = if let Some(target) = target_channel {
|
|
vec![db
|
|
.get_channel(tenant.community(), target)
|
|
.await
|
|
.map_err(|_| {
|
|
anyhow::anyhow!("channel {target} not found in community {}", tenant.host())
|
|
})?]
|
|
} else {
|
|
db.list_channels(tenant.community(), None).await?
|
|
};
|
|
if channels.is_empty() {
|
|
println!("No channels in database.");
|
|
return Ok(());
|
|
}
|
|
|
|
let mut reconciled = 0u32;
|
|
let mut skipped = 0u32;
|
|
|
|
for channel in &channels {
|
|
let channel_id_str = channel.id.to_string();
|
|
|
|
// Check if kind:39000 already exists
|
|
let existing = db
|
|
.query_events(&EventQuery {
|
|
kinds: Some(vec![39000]),
|
|
d_tag: Some(channel_id_str.clone()),
|
|
limit: Some(1),
|
|
..EventQuery::for_community(tenant.community())
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
if !existing.is_empty() && target_channel.is_none() {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
let members = db.get_members(tenant.community(), channel.id).await?;
|
|
|
|
// A targeted repair is deliberately roster-only. kind:39000 metadata
|
|
// is richer than this legacy backfill builder, and kind:39001 is not
|
|
// part of the stale-roster incident; replacing either can destroy
|
|
// canonical state. Full backfill still creates all three event kinds
|
|
// for channels with no discovery metadata.
|
|
if target_channel.is_none() {
|
|
// kind:39000 — channel metadata
|
|
{
|
|
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
|
|
tags.push(Tag::parse(["name", &channel.name])?);
|
|
if let Some(ref desc) = channel.description {
|
|
if !desc.is_empty() {
|
|
tags.push(Tag::parse(["about", desc])?);
|
|
}
|
|
}
|
|
if channel.visibility == "private" {
|
|
tags.push(Tag::parse(["private"])?);
|
|
} else {
|
|
tags.push(Tag::parse(["public"])?);
|
|
}
|
|
if channel.channel_type == "dm" {
|
|
tags.push(Tag::parse(["hidden"])?);
|
|
}
|
|
tags.push(Tag::parse(["closed"])?);
|
|
tags.push(Tag::parse(["t", &channel.channel_type])?);
|
|
|
|
let event = EventBuilder::new(Kind::Custom(39000), "")
|
|
.tags(tags)
|
|
.sign_with_keys(&relay_keys)
|
|
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
|
|
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
|
.await?;
|
|
}
|
|
|
|
// kind:39001 — admins
|
|
{
|
|
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
|
|
for m in members
|
|
.iter()
|
|
.filter(|m| m.role == "owner" || m.role == "admin")
|
|
{
|
|
let pk = hex::encode(&m.pubkey);
|
|
tags.push(Tag::parse(["p", &pk, &m.role])?);
|
|
}
|
|
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
|
|
.tags(tags)
|
|
.sign_with_keys(&relay_keys)
|
|
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
|
|
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
// kind:39002 — members
|
|
{
|
|
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
|
|
for m in &members {
|
|
let pk = hex::encode(&m.pubkey);
|
|
tags.push(Tag::parse(["p", &pk, "", &m.role])?);
|
|
}
|
|
let event = EventBuilder::new(Kind::Custom(39002), "")
|
|
.tags(tags)
|
|
.sign_with_keys(&relay_keys)
|
|
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
|
|
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
|
.await?;
|
|
}
|
|
|
|
reconciled += 1;
|
|
}
|
|
|
|
println!(
|
|
"Reconciled {reconciled} channels ({skipped} already had events, {} total).",
|
|
channels.len()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod storage_snapshot_tests {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn storage_snapshot_holds_a_single_database_session() {
|
|
let config = storage_snapshot_db_config(DbConfig {
|
|
max_connections: 20,
|
|
lock_timeout_ms: 123,
|
|
..DbConfig::default()
|
|
});
|
|
assert_eq!(config.max_connections, 1);
|
|
assert_eq!(config.lock_timeout_ms, 123);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_fold_never_invokes_snapshot_persistence() {
|
|
let persist_calls = Arc::new(AtomicUsize::new(0));
|
|
let observed_calls = Arc::clone(&persist_calls);
|
|
let result = persist_completed_fold(
|
|
async { Err::<BucketSnapshot, _>(SweepError::MalformedPage) },
|
|
move |_, _| async move {
|
|
observed_calls.fetch_add(1, Ordering::SeqCst);
|
|
Ok(())
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert_eq!(persist_calls.load(Ordering::SeqCst), 0);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_partition_audit_month_horizon() {
|
|
let cli = Cli::try_parse_from(["buzz-admin", "partition-audit", "--months-ahead", "6"])
|
|
.expect("parse partition-audit command");
|
|
assert!(matches!(
|
|
cli.command,
|
|
Command::PartitionAudit { months_ahead: 6 }
|
|
));
|
|
}
|
|
|
|
const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
|
|
#[test]
|
|
fn communities_command_parses_archive() {
|
|
let cli = Cli::try_parse_from([
|
|
"buzz-admin",
|
|
"communities",
|
|
"archive",
|
|
"--host",
|
|
"example.communities.buzz.xyz",
|
|
"--owner-pubkey",
|
|
OWNER,
|
|
"--operator-id",
|
|
"codex",
|
|
"--reason",
|
|
"requested deletion",
|
|
])
|
|
.unwrap();
|
|
|
|
match cli.command {
|
|
Command::Communities {
|
|
command:
|
|
communities::CommunitiesCommand::Archive {
|
|
host,
|
|
owner_pubkey,
|
|
operator_id,
|
|
reason,
|
|
},
|
|
} => {
|
|
assert_eq!(host, "example.communities.buzz.xyz");
|
|
assert_eq!(owner_pubkey, OWNER);
|
|
assert_eq!(operator_id, "codex");
|
|
assert_eq!(reason, "requested deletion");
|
|
}
|
|
_ => panic!("expected communities archive command"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn communities_command_parses_unarchive() {
|
|
let cli = Cli::try_parse_from([
|
|
"buzz-admin",
|
|
"communities",
|
|
"unarchive",
|
|
"--host",
|
|
"example.communities.buzz.xyz",
|
|
"--owner-pubkey",
|
|
OWNER,
|
|
"--operator-id",
|
|
"codex",
|
|
"--reason",
|
|
"rollback",
|
|
])
|
|
.unwrap();
|
|
|
|
match cli.command {
|
|
Command::Communities {
|
|
command:
|
|
communities::CommunitiesCommand::Unarchive {
|
|
host,
|
|
owner_pubkey,
|
|
operator_id,
|
|
reason,
|
|
},
|
|
} => {
|
|
assert_eq!(host, "example.communities.buzz.xyz");
|
|
assert_eq!(owner_pubkey, OWNER);
|
|
assert_eq!(operator_id, "codex");
|
|
assert_eq!(reason, "rollback");
|
|
}
|
|
_ => panic!("expected communities unarchive command"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn communities_commands_require_all_safety_and_audit_arguments() {
|
|
let missing_argument_cases = [
|
|
vec![
|
|
"buzz-admin",
|
|
"communities",
|
|
"archive",
|
|
"--owner-pubkey",
|
|
OWNER,
|
|
"--operator-id",
|
|
"codex",
|
|
"--reason",
|
|
"requested deletion",
|
|
],
|
|
vec![
|
|
"buzz-admin",
|
|
"communities",
|
|
"archive",
|
|
"--host",
|
|
"example.communities.buzz.xyz",
|
|
"--operator-id",
|
|
"codex",
|
|
"--reason",
|
|
"requested deletion",
|
|
],
|
|
vec![
|
|
"buzz-admin",
|
|
"communities",
|
|
"unarchive",
|
|
"--host",
|
|
"example.communities.buzz.xyz",
|
|
"--owner-pubkey",
|
|
OWNER,
|
|
"--reason",
|
|
"rollback",
|
|
],
|
|
vec![
|
|
"buzz-admin",
|
|
"communities",
|
|
"unarchive",
|
|
"--host",
|
|
"example.communities.buzz.xyz",
|
|
"--owner-pubkey",
|
|
OWNER,
|
|
"--operator-id",
|
|
"codex",
|
|
],
|
|
];
|
|
|
|
for args in missing_argument_cases {
|
|
assert!(Cli::try_parse_from(args).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn communities_commands_do_not_expose_deletion_approval_arguments() {
|
|
let command = Cli::try_parse_from([
|
|
"buzz-admin",
|
|
"communities",
|
|
"archive",
|
|
"--host",
|
|
"example.communities.buzz.xyz",
|
|
"--owner-pubkey",
|
|
OWNER,
|
|
"--operator-id",
|
|
"codex",
|
|
"--reason",
|
|
"requested deletion",
|
|
"--approved-by",
|
|
"second-operator",
|
|
]);
|
|
|
|
assert!(command.is_err());
|
|
}
|
|
}
|