From 23afef733e68320400077e447a129cd762a12ea9 Mon Sep 17 00:00:00 2001 From: David Grochowski Date: Fri, 6 Nov 2026 22:57:35 -0500 Subject: [PATCH] fix(relay): parse env settings strictly and refuse invalid values Problem: config.rs already had strict helpers (parse_bool and positive_u64_from_env), but most settings parsed by hand, each with its own rules: - BUZZ_REQUIRE_AUTH_TOKEN, BUZZ_PUBKEY_ALLOWLIST, BUZZ_REQUIRE_RELAY_MEMBERSHIP, BUZZ_ALLOW_NIP_OA_AUTH, BUZZ_MEDIA_UPLOAD_RECORDS and BUZZ_SERVE_GIT_WEB_GUI accepted only exact "true"/"1". TRUE, True, On or " 1 " silently meant false, which left REST token auth or relay membership enforcement off. - BUZZ_HUDDLE_AUDIO_AVAILABLE was inverted: anything except exact "false"/"0" meant true, so FALSE or Off left huddle audio on. - BUZZ_V1_ENABLED and the two BUZZ_MEMBERSHIP_*_ENABLED flags accepted only lowercase "true". BUZZ_MESH and BUZZ_MESH_DEMO_ECHO took any-case "on" but only lowercase "true". - Numeric settings fell back to the default on any parse failure, so a typo like 10k or a u8 overflow (BUZZ_SLOW_CLIENT_GRACE_LIMIT=300) quietly ran with the default. BUZZ_OPERATOR_LISTENER_TIMEOUT_MS logged an error and did the same. - BUZZ_SEND_BUFFER=0 was accepted and reaches mpsc::channel(0) for every WebSocket connection, which panics. BUZZ_MAX_CONNECTIONS, BUZZ_MAX_CONCURRENT_HANDLERS and BUZZ_GIT_MAX_CONCURRENT_OPS accepted 0, which sizes a semaphore that admits nothing. Change: every on/off setting now goes through parse_bool. Its accepted set is unchanged: true/1/on or false/0/off in any case, surrounding whitespace ignored, blank meaning off. "yes" stays unaccepted and now fails startup instead of silently meaning false. One generic parse_optional_number / parse_number pair replaces every hand-written numeric parse. It trims the value, treats unset or blank as the default and turns a parse or range failure into a ConfigError naming the variable. parse_positive, the thin wrapper for counts, sizes and rates, replaces positive_u64_from_env. Settings that already filtered `> 0` now reject 0 instead of falling back, and the four settings above join them. Settings where 0 has a meaning keep accepting it (BUZZ_DRAIN_JITTER_MS, BUZZ_REPLICA_READ_MAX_AGE_MS, BUZZ_GIT_PACK_CACHE_MAX_BYTES, BUZZ_GIT_MAX_REPOS_PER_PUBKEY, ports). Existing clamps (BUZZ_PARTITION_AUDIT_INTERVAL_SECS, BUZZ_DRAIN_JITTER_MS, BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY) and every default are unchanged. parse_optional_bool was a duplicate of parse_bool(name, false) and is removed. Two stale comments now say that a malformed RELAY_OWNER_PUBKEY fails startup (config.rs and main.rs). Behaviour change for deployments: a value the relay used to ignore or misread now stops startup with an error naming the variable. TRUE, On and " 1 " now enable the security flags they were meant to enable. Blank numeric values still mean the default; for the rate limits, BUZZ_REPLICA_READ_MAX_AGE_MS, BUZZ_PUSH_GATEWAY_TIMEOUT_MS and BUZZ_V1_RETENTION_SECONDS that is a relaxation (blank used to fail). A blank BUZZ_HUDDLE_AUDIO_AVAILABLE now means off, as it does for every other parse_bool setting. Helm chart: Helm renders integers of a million or more as floats, so the chart passed BUZZ_GIT_MAX_PACK_BYTES="5.24288e+08" and BUZZ_GIT_PACK_CACHE_MAX_BYTES="5.36870912e+09". The relay used to ignore both and run with its own defaults; strict parsing would refuse to start every chart install. deployment.yaml now casts every integer setting with int64 before quoting (as the storage-accounting CronJob already does), and the chart version is bumped to 0.1.12. Why safe: defaults are unchanged and every value in the chart, compose files, CI and scripts now renders a canonical spelling. The new failures only fire on values that were already being ignored or misread. Tests: env_settings_parse_strictly_or_refuse_startup is a table test through Config::from_env. It fails on main with 69 mismatches (e.g. BUZZ_REQUIRE_AUTH_TOKEN="TRUE": resolved to the wrong value; BUZZ_SEND_BUFFER="0": accepted) and passes with this change. The DB pool size tests now assert refusal instead of fallback. The chart test "renders large integer settings as plain integers" fails without the int64 casts. Signed-off-by: David Grochowski --- .env.example | 5 + crates/buzz-relay/src/config.rs | 613 ++++++++++--------- crates/buzz-relay/src/main.rs | 7 +- deploy/charts/buzz/Chart.yaml | 6 +- deploy/charts/buzz/templates/deployment.yaml | 24 +- deploy/charts/buzz/tests/render_test.yaml | 31 + 6 files changed, 381 insertions(+), 305 deletions(-) diff --git a/.env.example b/.env.example index eb9a91a259b..651d1ca668e 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ # # All defaults here work with `docker compose up` out of the box. # +# Relay on/off settings accept true/false, 1/0 or on/off in any case; an empty +# value means off. Relay numeric settings take a plain integer within the +# setting's range; an empty value means the default. Any other value stops the +# relay at startup with an error naming the variable. +# # Service ports (defaults): # Postgres → localhost:5432 # Redis → localhost:6379 diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index eb4fd6ab556..5b8b7b90007 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -460,52 +460,98 @@ fn parse_client_ip_header(raw: &str) -> Result, C }) } -fn positive_u64_from_env(name: &str, default: u64) -> Result { +/// Reads `name`: unset is `None`; a non-Unicode value is a startup error. +fn env_value(name: &str) -> Result, ConfigError> { match std::env::var(name) { - Ok(raw) => raw - .parse::() - .ok() - .filter(|value| *value > 0) - .ok_or_else(|| ConfigError::InvalidValue(format!("{name} must be a positive integer"))), - Err(std::env::VarError::NotPresent) => Ok(default), + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!( "{name} must be valid Unicode" ))), } } +const POSITIVE_INTEGER: &str = "a positive integer"; +const NON_NEGATIVE_INTEGER: &str = "a non-negative integer"; + +/// Parses the numeric setting `name`. Unset or blank is `None`; a value that +/// does not parse as `T` or fails `valid` is a startup error naming the +/// variable and stating `expected`, never a silent fallback to the default. +fn parse_optional_number( + name: &str, + valid: impl Fn(&T) -> bool, + expected: &str, +) -> Result, ConfigError> { + let Some(raw) = env_value(name)? else { + return Ok(None); + }; + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + raw.parse::() + .ok() + .filter(|value| valid(value)) + .map(Some) + .ok_or_else(|| ConfigError::InvalidValue(format!("{name} must be {expected}"))) +} + +/// [`parse_optional_number`] with `default` for an unset or blank variable. +fn parse_number( + name: &str, + default: T, + valid: impl Fn(&T) -> bool, + expected: &str, +) -> Result { + Ok(parse_optional_number(name, valid, expected)?.unwrap_or(default)) +} + +/// [`parse_number`] for counts, sizes and rates where zero would admit +/// nothing (or panic, for channel capacities). +fn parse_positive( + name: &str, + default: T, +) -> Result { + parse_number( + name, + default, + |value| *value > T::default(), + POSITIVE_INTEGER, + ) +} + fn rate_limit_config_from_env() -> Result { let defaults = buzz_auth::RateLimitConfig::default(); Ok(buzz_auth::RateLimitConfig { - human_messages_per_min: positive_u64_from_env( + human_messages_per_min: parse_positive( "BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", defaults.human_messages_per_min, )?, - gif_searches_per_min: positive_u64_from_env( + gif_searches_per_min: parse_positive( "BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN", defaults.gif_searches_per_min, )?, - human_api_calls_per_min: positive_u64_from_env( + human_api_calls_per_min: parse_positive( "BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", defaults.human_api_calls_per_min, )?, - human_ws_events_per_sec: positive_u64_from_env( + human_ws_events_per_sec: parse_positive( "BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", defaults.human_ws_events_per_sec, )?, - agent_standard_messages_per_min: positive_u64_from_env( + agent_standard_messages_per_min: parse_positive( "BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN", defaults.agent_standard_messages_per_min, )?, - agent_standard_api_calls_per_min: positive_u64_from_env( + agent_standard_api_calls_per_min: parse_positive( "BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN", defaults.agent_standard_api_calls_per_min, )?, - agent_elevated_messages_per_min: positive_u64_from_env( + agent_elevated_messages_per_min: parse_positive( "BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN", defaults.agent_elevated_messages_per_min, )?, - agent_platform_messages_per_min: positive_u64_from_env( + agent_platform_messages_per_min: parse_positive( "BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN", defaults.agent_platform_messages_per_min, )?, @@ -608,26 +654,22 @@ fn parse_operator_listener_delivery_urls( Ok(endpoints) } +/// Parses the on/off setting `name`: `true`/`1`/`on` or `false`/`0`/`off` in +/// any case, with blank meaning off. Anything else is a startup error, so a +/// typo cannot silently disable (or enable) a security control. fn parse_bool(name: &str, default: bool) -> Result { - match std::env::var(name) { - Err(std::env::VarError::NotPresent) => Ok(default), - Err(error) => Err(ConfigError::InvalidValue(format!( - "{name} must be valid UTF-8: {error}" + let Some(value) = env_value(name)? else { + return Ok(default); + }; + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "on" => Ok(true), + "false" | "0" | "off" | "" => Ok(false), + _ => Err(ConfigError::InvalidValue(format!( + "{name} must be true or false" ))), - Ok(value) => match value.trim().to_ascii_lowercase().as_str() { - "true" | "1" | "on" => Ok(true), - "false" | "0" | "off" | "" => Ok(false), - _ => Err(ConfigError::InvalidValue(format!( - "{name} must be true or false" - ))), - }, } } -fn parse_optional_bool(name: &str) -> Result { - parse_bool(name, false) -} - fn ensure_git_repo_path( raw: impl Into, ) -> Result { @@ -698,49 +740,29 @@ impl Config { )); } - // Replica read budget: 0 = off (the rollout default), so this is a - // non-negative parse, unlike `positive_u64_from_env`. - let replica_read_max_age_ms = match std::env::var("BUZZ_REPLICA_READ_MAX_AGE_MS") { - Ok(raw) => raw.trim().parse::().map_err(|_| { - ConfigError::InvalidValue( - "BUZZ_REPLICA_READ_MAX_AGE_MS must be a non-negative integer".to_string(), - ) - })?, - Err(_) => 0, - }; + // Replica read budget: 0 = off (the rollout default). + let replica_read_max_age_ms = parse_number( + "BUZZ_REPLICA_READ_MAX_AGE_MS", + 0, + |_| true, + NON_NEGATIVE_INTEGER, + )?; // Drain jitter: 0 = off (default). Clamp oversized values so every // delayed close is initiated with ten seconds left in the relay's - // hard-drain budget. An empty/whitespace-only value is treated as unset - // (jitter off), matching the sibling vars in this file — so setting the - // var to "" is a valid kill switch, not a crashloop. - let drain_jitter_ms = match std::env::var("BUZZ_DRAIN_JITTER_MS") { - Ok(raw) if raw.trim().is_empty() => 0, - Ok(raw) => raw - .trim() - .parse::() - .map_err(|_| { - ConfigError::InvalidValue( - "BUZZ_DRAIN_JITTER_MS must be a non-negative integer".to_string(), - ) - })? - .min(MAX_DRAIN_JITTER_MS), - Err(_) => 0, - }; + // hard-drain budget. An empty value means unset (jitter off), so + // setting the var to "" is a valid kill switch, not a crashloop. + let drain_jitter_ms = + parse_number("BUZZ_DRAIN_JITTER_MS", 0, |_| true, NON_NEGATIVE_INTEGER)? + .min(MAX_DRAIN_JITTER_MS); let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); - let db_pool_size = std::env::var("BUZZ_DB_POOL_SIZE") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0) - .unwrap_or(50); + let db_pool_size = parse_positive("BUZZ_DB_POOL_SIZE", 50)?; - let db_read_pool_size = std::env::var("BUZZ_DB_READ_POOL_SIZE") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0); + let db_read_pool_size = + parse_optional_number("BUZZ_DB_READ_POOL_SIZE", |&v: &u32| v > 0, POSITIVE_INTEGER)?; let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); @@ -764,61 +786,37 @@ impl Config { }) .transpose()?; - let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10_000); - - let max_concurrent_handlers = std::env::var("BUZZ_MAX_CONCURRENT_HANDLERS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1024); + // Zero admits nothing: these size semaphores, and `mpsc::channel` + // panics on capacity 0 for every connection. + let max_connections = parse_positive("BUZZ_MAX_CONNECTIONS", 10_000)?; + let max_concurrent_handlers = parse_positive("BUZZ_MAX_CONCURRENT_HANDLERS", 1024)?; + let send_buffer_size = parse_positive("BUZZ_SEND_BUFFER", 1_000)?; - let send_buffer_size = std::env::var("BUZZ_SEND_BUFFER") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1_000); - - let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0) - .unwrap_or(DEFAULT_MAX_FRAME_BYTES); + let max_frame_bytes = parse_positive("BUZZ_MAX_FRAME_BYTES", DEFAULT_MAX_FRAME_BYTES)?; - let slow_client_grace_limit = std::env::var("BUZZ_SLOW_CLIENT_GRACE_LIMIT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(15); + let slow_client_grace_limit = parse_number( + "BUZZ_SLOW_CLIENT_GRACE_LIMIT", + 15, + |_| true, + "an integer in 0..=255", + )?; let client_ip_header = parse_client_ip_header(&std::env::var("BUZZ_CLIENT_IP_HEADER").unwrap_or_default())?; - let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); - - let pubkey_allowlist_enabled = std::env::var("BUZZ_PUBKEY_ALLOWLIST") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); - - let require_relay_membership = std::env::var("BUZZ_REQUIRE_RELAY_MEMBERSHIP") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let require_auth_token = parse_bool("BUZZ_REQUIRE_AUTH_TOKEN", false)?; + let pubkey_allowlist_enabled = parse_bool("BUZZ_PUBKEY_ALLOWLIST", false)?; + let require_relay_membership = parse_bool("BUZZ_REQUIRE_RELAY_MEMBERSHIP", 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") - .map(|v| !(v == "false" || v == "0")) - .unwrap_or(true); + let huddle_audio_available = parse_bool("BUZZ_HUDDLE_AUDIO_AVAILABLE", true)?; // Mesh opt-in: default OFF. Strict rollout no-regression — an image // upgrade with untouched env must not bind a new UDP port or write a // new Redis key. Horizontally-scaled deployments explicitly set - // `BUZZ_MESH=on`; anything else (absent, `off`, other values) keeps - // exact single-instance behavior. - let mesh_enabled = std::env::var("BUZZ_MESH") - .map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1") - .unwrap_or(false); + // `BUZZ_MESH=on`; absent or off keeps exact single-instance behavior. + let mesh_enabled = parse_bool("BUZZ_MESH", false)?; let mesh_bind_addr = std::env::var("BUZZ_MESH_BIND_ADDR") .map(|raw| { raw.parse::().map_err(|e| { @@ -832,15 +830,10 @@ impl Config { registry_refresh: std::time::Duration::from_secs(15), }; - // Demo echo opt-in: same strict pattern as BUZZ_MESH — explicit - // `on`/`true`/`1` only, anything else (absent, `off`, typos) is off. - let mesh_demo_echo = std::env::var("BUZZ_MESH_DEMO_ECHO") - .map(|v| v.eq_ignore_ascii_case("on") || v == "true" || v == "1") - .unwrap_or(false); + // Demo echo opt-in: default off, same parsing as BUZZ_MESH. + let mesh_demo_echo = parse_bool("BUZZ_MESH_DEMO_ECHO", false)?; - let allow_nip_oa_auth = std::env::var("BUZZ_ALLOW_NIP_OA_AUTH") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let allow_nip_oa_auth = parse_bool("BUZZ_ALLOW_NIP_OA_AUTH", false)?; let klipy = std::env::var("BUZZ_KLIPY_API_KEY") .ok() @@ -874,9 +867,9 @@ impl Config { .transpose()?; // Note: intentionally not prefixed with BUZZ_ — same relay-identity - // config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex - // pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid - // entry here is a hard config error: silently dropping an operator + // config family as RELAY_OWNER_PUBKEY. RELAY_OPERATOR_PUBKEYS is + // comma-separated 64-char hex pubkeys. As with RELAY_OWNER_PUBKEY, an + // invalid entry is a hard config error: silently dropping an operator // pubkey would silently disable provisioning for that operator. let relay_operator_api_origin = std::env::var("RELAY_OPERATOR_API_ORIGIN") .ok() @@ -976,24 +969,20 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let health_port = std::env::var("BUZZ_HEALTH_PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8080); - - let metrics_port = std::env::var("BUZZ_METRICS_PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(9102); + const PORT: &str = "a port number in 0..=65535"; + let health_port = parse_number("BUZZ_HEALTH_PORT", 8080, |_| true, PORT)?; + let metrics_port = parse_number("BUZZ_METRICS_PORT", 9102, |_| true, PORT)?; // Catalog-only and cheap, but clamp operator mistakes away from a hot // loop or a multi-day observability gap. let partition_audit_interval = Duration::from_secs( - std::env::var("BUZZ_PARTITION_AUDIT_INTERVAL_SECS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(15 * 60) - .clamp(60, 24 * 60 * 60), + parse_number( + "BUZZ_PARTITION_AUDIT_INTERVAL_SECS", + 15 * 60, + |_| true, + NON_NEGATIVE_INTEGER, + )? + .clamp(60, 24 * 60 * 60), ); let partition_manager_create_enabled = parse_bool("BUZZ_PARTITION_MANAGER_CREATE_ENABLED", true)?; @@ -1033,30 +1022,16 @@ impl Config { .or_else(|_| std::env::var("AWS_REGION")) .unwrap_or_else(|_| "us-east-1".to_string()), s3_addressing_style, - max_image_bytes: std::env::var("BUZZ_MAX_IMAGE_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(50 * 1024 * 1024), - max_gif_bytes: std::env::var("BUZZ_MAX_GIF_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10 * 1024 * 1024), - max_video_bytes: std::env::var("BUZZ_MAX_VIDEO_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500 * 1024 * 1024), - max_file_bytes: std::env::var("BUZZ_MAX_FILE_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(100 * 1024 * 1024), + max_image_bytes: parse_positive("BUZZ_MAX_IMAGE_BYTES", 50 * 1024 * 1024)?, + max_gif_bytes: parse_positive("BUZZ_MAX_GIF_BYTES", 10 * 1024 * 1024)?, + max_video_bytes: parse_positive("BUZZ_MAX_VIDEO_BYTES", 500 * 1024 * 1024)?, + max_file_bytes: parse_positive("BUZZ_MAX_FILE_BYTES", 100 * 1024 * 1024)?, public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL") .unwrap_or_else(|_| "http://localhost:3000/media".to_string()), // Per-upload-event records (`_uploads/` moderation side channel). // Off by default; coherence between the three knobs is enforced in // MediaConfig::validate at startup. - upload_records_enabled: std::env::var("BUZZ_MEDIA_UPLOAD_RECORDS") - .map(|v| v == "true" || v == "1") - .unwrap_or(false), + upload_records_enabled: parse_bool("BUZZ_MEDIA_UPLOAD_RECORDS", false)?, upload_ip_header: std::env::var("BUZZ_MEDIA_UPLOAD_IP_HEADER") .ok() .map(|s| s.trim().to_lowercase()) @@ -1067,23 +1042,11 @@ impl Config { .filter(|s| !s.is_empty()), }; let media_max_concurrent_uploads: usize = - std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS") - .ok() - .and_then(|v| v.parse().ok()) - .filter(|&v| v > 0) - .unwrap_or(8); + parse_positive("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS", 8)?; let media_max_concurrent_uploads_per_pubkey: u32 = - std::env::var("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY") - .ok() - .and_then(|v| v.parse().ok()) - .filter(|&v| v > 0) - .unwrap_or(2) + parse_positive("BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY", 2)? .min(u32::try_from(media_max_concurrent_uploads).unwrap_or(u32::MAX)); - let media_uploads_per_minute: u32 = std::env::var("BUZZ_MEDIA_UPLOADS_PER_MINUTE") - .ok() - .and_then(|v| v.parse().ok()) - .filter(|&v| v > 0) - .unwrap_or(30); + let media_uploads_per_minute: u32 = parse_positive("BUZZ_MEDIA_UPLOADS_PER_MINUTE", 30)?; for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) { warn!( @@ -1093,10 +1056,11 @@ impl Config { ); } - let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0); + let ephemeral_ttl_override = parse_optional_number( + "BUZZ_EPHEMERAL_TTL_OVERRIDE", + |&v: &i32| v > 0, + POSITIVE_INTEGER, + )?; if let Some(ttl) = ephemeral_ttl_override { warn!( @@ -1115,32 +1079,34 @@ impl Config { .map(std::path::PathBuf::from) .unwrap_or_else(|_| git_repo_path.join(".pack-cache")), )?; - let git_max_pack_bytes: u64 = std::env::var("BUZZ_GIT_MAX_PACK_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500 * 1024 * 1024); // 500 MB - let git_max_repo_bytes: u64 = std::env::var("BUZZ_GIT_MAX_REPO_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| git_max_pack_bytes.saturating_mul(2)); // 1 GB at defaults - let git_pack_cache_max_bytes: u64 = std::env::var("BUZZ_GIT_PACK_CACHE_MAX_BYTES") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| git_max_repo_bytes.saturating_mul(5)); // 5 GB at defaults + let git_max_pack_bytes: u64 = parse_number( + "BUZZ_GIT_MAX_PACK_BYTES", + 500 * 1024 * 1024, // 500 MB + |_| true, + NON_NEGATIVE_INTEGER, + )?; + let git_max_repo_bytes: u64 = parse_number( + "BUZZ_GIT_MAX_REPO_BYTES", + git_max_pack_bytes.saturating_mul(2), // 1 GB at defaults + |_| true, + NON_NEGATIVE_INTEGER, + )?; + let git_pack_cache_max_bytes: u64 = parse_number( + "BUZZ_GIT_PACK_CACHE_MAX_BYTES", + git_max_repo_bytes.saturating_mul(5), // 5 GB at defaults + |_| true, + NON_NEGATIVE_INTEGER, + )?; let git_pack_cache_max_concurrent_populations: usize = - std::env::var("BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS") - .ok() - .and_then(|v| v.parse().ok()) - .filter(|value| *value > 0) - .unwrap_or(2); - let git_max_repos_per_pubkey: u32 = std::env::var("BUZZ_GIT_MAX_REPOS_PER_PUBKEY") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(100); - let git_max_concurrent_ops: usize = std::env::var("BUZZ_GIT_MAX_CONCURRENT_OPS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(20); + parse_positive("BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS", 2)?; + let git_max_repos_per_pubkey: u32 = parse_number( + "BUZZ_GIT_MAX_REPOS_PER_PUBKEY", + 100, + |_| true, + NON_NEGATIVE_INTEGER, + )?; + // Zero would size the git subprocess semaphore to admit nothing. + let git_max_concurrent_ops: usize = parse_positive("BUZZ_GIT_MAX_CONCURRENT_OPS", 20)?; let git_hook_hmac_secret: String = std::env::var("BUZZ_GIT_HOOK_HMAC_SECRET") .unwrap_or_else(|_| { // Generate a random secret if not configured (dev mode). @@ -1177,47 +1143,20 @@ impl Config { ))); } }; - let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") { - Ok(raw) => raw - .parse::() - .ok() - .filter(|millis| (100..=10_000).contains(millis)) - .ok_or_else(|| { - ConfigError::InvalidValue( - "BUZZ_PUSH_GATEWAY_TIMEOUT_MS must be an integer in 100..=10000" - .to_string(), - ) - })?, - Err(_) => 2_000, - }; - let push_gateway_timeout = Duration::from_millis(push_gateway_timeout_millis); - let operator_listener_timeout_millis = match std::env::var( + const TIMEOUT_MS: &str = "an integer in 100..=10000"; + let timeout_ms_in_range = |millis: &u64| (100..=10_000).contains(millis); + let push_gateway_timeout = Duration::from_millis(parse_number( + "BUZZ_PUSH_GATEWAY_TIMEOUT_MS", + 2_000, + timeout_ms_in_range, + TIMEOUT_MS, + )?); + let operator_listener_timeout = Duration::from_millis(parse_number( "BUZZ_OPERATOR_LISTENER_TIMEOUT_MS", - ) { - Ok(raw) => match raw - .parse::() - .ok() - .filter(|millis| (100..=10_000).contains(millis)) - { - Some(millis) => millis, - None => { - error!( - value = %raw, - "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms (expected integer in 100..=10000)" - ); - 5_000 - } - }, - Err(std::env::VarError::NotPresent) => 5_000, - Err(err) => { - error!( - error = %err, - "invalid BUZZ_OPERATOR_LISTENER_TIMEOUT_MS; using default 5000ms" - ); - 5_000 - } - }; - let operator_listener_timeout = Duration::from_millis(operator_listener_timeout_millis); + 5_000, + timeout_ms_in_range, + TIMEOUT_MS, + )?); const MAX_POLICY_MARKDOWN_BYTES: usize = 256 * 1024; let read_policy_markdown = |name: &str| -> Result, ConfigError> { @@ -1237,7 +1176,7 @@ impl Config { }; let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?; let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?; - let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?; + let age_attestation_required = parse_bool("BUZZ_AGE_ATTESTATION_REQUIRED", false)?; let audit_enabled = parse_bool("BUZZ_AUDIT_ENABLED", true)?; let join_policy = if terms_markdown.is_none() && privacy_markdown.is_none() @@ -1402,9 +1341,7 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(std::path::PathBuf::from); - let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI") - .map(|value| value == "true" || value == "1") - .unwrap_or(false); + let serve_git_web_gui = parse_bool("BUZZ_SERVE_GIT_WEB_GUI", false)?; if let Some(ref dir) = web_dir { if !dir.join("index.html").is_file() { @@ -1426,13 +1363,15 @@ impl Config { )); } - let buzz_v1_enabled = std::env::var("BUZZ_V1_ENABLED").is_ok_and(|v| v == "true"); + let buzz_v1_enabled = parse_bool("BUZZ_V1_ENABLED", false)?; // Read only when enabled: a disabled relay ignores every v1 setting. - let buzz_v1_retention_seconds = match std::env::var("BUZZ_V1_RETENTION_SECONDS") { - Ok(v) if buzz_v1_enabled => v.parse().ok().filter(|s| *s > 0).ok_or_else(|| { - ConfigError::InvalidValue("BUZZ_V1_RETENTION_SECONDS must be positive".into()) - })?, - _ => buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, + let buzz_v1_retention_seconds = if buzz_v1_enabled { + parse_positive( + "BUZZ_V1_RETENTION_SECONDS", + buzz_db::personal_read::DEFAULT_RETENTION_SECONDS, + )? + } else { + buzz_db::personal_read::DEFAULT_RETENTION_SECONDS }; let nip_fi = crate::nip_fi_config::NipFiRelayConfig::from_env()?; @@ -1468,10 +1407,8 @@ impl Config { auth, require_auth_token, buzz_v1_enabled, - membership_snapshot_enabled: std::env::var("BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED") - .is_ok_and(|v| v == "true"), - membership_public_enabled: std::env::var("BUZZ_MEMBERSHIP_PUBLIC_ENABLED") - .is_ok_and(|v| v == "true"), + membership_snapshot_enabled: parse_bool("BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED", false)?, + membership_public_enabled: parse_bool("BUZZ_MEMBERSHIP_PUBLIC_ENABLED", false)?, buzz_v1_retention_seconds, cors_origins, relay_private_key, @@ -2352,57 +2289,51 @@ mod tests { } #[test] - fn db_pool_size_env_override_and_invalid_fallback() { + fn db_pool_size_env_override_and_invalid_refusal() { let _guards = env_guards(); - let previous = std::env::var_os("BUZZ_DB_POOL_SIZE"); - - std::env::set_var("BUZZ_DB_POOL_SIZE", "80"); - let overridden = Config::from_env().expect("config").db_pool_size; - - std::env::set_var("BUZZ_DB_POOL_SIZE", "0"); - let zero = Config::from_env().expect("config").db_pool_size; - - std::env::set_var("BUZZ_DB_POOL_SIZE", "not-a-number"); - let junk = Config::from_env().expect("config").db_pool_size; - - if let Some(value) = previous { - std::env::set_var("BUZZ_DB_POOL_SIZE", value); - } else { - std::env::remove_var("BUZZ_DB_POOL_SIZE"); - } - + let overridden = config_with_env("BUZZ_DB_POOL_SIZE", "80") + .expect("config") + .db_pool_size; + let blank = config_with_env("BUZZ_DB_POOL_SIZE", "") + .expect("config") + .db_pool_size; assert_eq!(overridden, 80); - assert_eq!(zero, 50, "zero must fall back to the default"); - assert_eq!(junk, 50, "unparsable value must fall back to the default"); + assert_eq!(blank, 50, "blank must mean the default"); + for invalid in ["0", "not-a-number"] { + assert!( + matches!( + &config_with_env("BUZZ_DB_POOL_SIZE", invalid), + Err(ConfigError::InvalidValue(message)) if message.contains("BUZZ_DB_POOL_SIZE") + ), + "{invalid:?} must refuse startup, not fall back to the default" + ); + } } #[test] - fn db_read_pool_size_env_override_and_invalid_fallback() { + fn db_read_pool_size_env_override_and_invalid_refusal() { let _guards = env_guards(); let previous = std::env::var_os("BUZZ_DB_READ_POOL_SIZE"); - std::env::remove_var("BUZZ_DB_READ_POOL_SIZE"); let unset = Config::from_env().expect("config").db_read_pool_size; - - std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "40"); - let overridden = Config::from_env().expect("config").db_read_pool_size; - - std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "0"); - let zero = Config::from_env().expect("config").db_read_pool_size; - - std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "not-a-number"); - let junk = Config::from_env().expect("config").db_read_pool_size; - if let Some(value) = previous { std::env::set_var("BUZZ_DB_READ_POOL_SIZE", value); - } else { - std::env::remove_var("BUZZ_DB_READ_POOL_SIZE"); } + let overridden = config_with_env("BUZZ_DB_READ_POOL_SIZE", "40") + .expect("config") + .db_read_pool_size; assert_eq!(unset, None, "unset must inherit the writer pool sizing"); assert_eq!(overridden, Some(40)); - assert_eq!(zero, None, "zero must fall back to inheriting"); - assert_eq!(junk, None, "unparsable value must fall back to inheriting"); + for invalid in ["0", "not-a-number"] { + assert!( + matches!( + &config_with_env("BUZZ_DB_READ_POOL_SIZE", invalid), + Err(ConfigError::InvalidValue(message)) if message.contains("BUZZ_DB_READ_POOL_SIZE") + ), + "{invalid:?} must refuse startup, not fall back to inheriting" + ); + } } #[test] @@ -2577,7 +2508,7 @@ mod tests { let _guards = env_guards(); let previous = std::env::var_os("BUZZ_AGE_ATTESTATION_REQUIRED"); std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", "sometimes"); - let result = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED"); + let result = parse_bool("BUZZ_AGE_ATTESTATION_REQUIRED", false); if let Some(value) = previous { std::env::set_var("BUZZ_AGE_ATTESTATION_REQUIRED", value); } else { @@ -2941,6 +2872,116 @@ mod tests { ); } + /// Run `Config::from_env()` with `name` forced to `value`, restoring the + /// ambient value afterwards. Callers must hold [`env_guards()`]. + fn config_with_env(name: &str, value: &str) -> Result { + let previous = std::env::var_os(name); + std::env::set_var(name, value); + let result = Config::from_env(); + match previous { + Some(previous) => std::env::set_var(name, previous), + None => std::env::remove_var(name), + } + result + } + + /// Every on/off setting accepts the same spellings, and every numeric + /// setting rejects values it cannot use, through the real `from_env` path. + /// An expectation of `None` means startup must fail naming the variable. + #[test] + fn env_settings_parse_strictly_or_refuse_startup() { + type Read = fn(&Config) -> bool; + let _guards = env_guards(); + let mut failures = Vec::new(); + let mut check = |name: &str, value: &str, expected: Option<(Read, bool)>| { + let result = config_with_env(name, value); + let passed = match (&result, expected) { + (Ok(config), Some((read, want))) => read(config) == want, + (Err(ConfigError::InvalidValue(message)), None) => message.contains(name), + _ => false, + }; + if !passed { + let got = match (result, expected) { + (Ok(_), None) => "accepted".to_string(), + (Ok(_), Some(_)) => "resolved to the wrong value".to_string(), + (Err(error), _) => format!("refused: {error}"), + }; + failures.push(format!("{name}={value:?}: {got}")); + } + }; + + let bools: [(&str, Read); 12] = [ + ("BUZZ_REQUIRE_AUTH_TOKEN", |c| c.require_auth_token), + ("BUZZ_PUBKEY_ALLOWLIST", |c| c.pubkey_allowlist_enabled), + ("BUZZ_REQUIRE_RELAY_MEMBERSHIP", |c| { + c.require_relay_membership + }), + ("BUZZ_ALLOW_NIP_OA_AUTH", |c| c.allow_nip_oa_auth), + ("BUZZ_HUDDLE_AUDIO_AVAILABLE", |c| c.huddle_audio_available), + ("BUZZ_MESH", |c| c.mesh.enabled), + ("BUZZ_MESH_DEMO_ECHO", |c| c.mesh_demo_echo), + ("BUZZ_MEDIA_UPLOAD_RECORDS", |c| { + c.media.upload_records_enabled + }), + ("BUZZ_SERVE_GIT_WEB_GUI", |c| c.serve_git_web_gui), + ("BUZZ_V1_ENABLED", |c| c.buzz_v1_enabled), + ("BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED", |c| { + c.membership_snapshot_enabled + }), + ("BUZZ_MEMBERSHIP_PUBLIC_ENABLED", |c| { + c.membership_public_enabled + }), + ]; + for (name, read) in bools { + for (value, want) in [ + ("TRUE", Some(true)), + ("On", Some(true)), + (" 1 ", Some(true)), + ("FALSE", Some(false)), + ("Off", Some(false)), + ("0", Some(false)), + ("yes", None), + ("enabled", None), + ] { + check(name, value, want.map(|want| (read, want))); + } + } + + let numbers: [(&str, &str, Option); 14] = [ + ("BUZZ_SEND_BUFFER", "0", None), + ( + "BUZZ_SEND_BUFFER", + " 64 ", + Some(|c| c.send_buffer_size == 64), + ), + ( + "BUZZ_SEND_BUFFER", + "", + Some(|c| c.send_buffer_size == 1_000), + ), + ("BUZZ_MAX_CONNECTIONS", "0", None), + ("BUZZ_MAX_CONNECTIONS", "10k", None), + ("BUZZ_MAX_CONCURRENT_HANDLERS", "0", None), + ("BUZZ_GIT_MAX_CONCURRENT_OPS", "0", None), + ("BUZZ_SLOW_CLIENT_GRACE_LIMIT", "300", None), + ("BUZZ_DB_POOL_SIZE", "0", None), + ("BUZZ_HEALTH_PORT", "70000", None), + ("BUZZ_GIT_MAX_PACK_BYTES", "500MB", None), + ("BUZZ_MEDIA_UPLOADS_PER_MINUTE", "-1", None), + ("BUZZ_OPERATOR_LISTENER_TIMEOUT_MS", "50", None), + ( + "BUZZ_GIT_PACK_CACHE_MAX_BYTES", + "0", + Some(|c| c.git_pack_cache_max_bytes == 0), + ), + ]; + for (name, value, read) in numbers { + check(name, value, read.map(|read| (read, true))); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); + } + #[test] fn client_ip_header_is_off_when_empty_and_case_insensitive() { assert_eq!(parse_client_ip_header("").unwrap(), None); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d979020bed5..f21c857eec9 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -433,12 +433,11 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { step.finish(); // NIP-43: if membership enforcement is on, a valid owner pubkey is required. - // config.rs already strips invalid values with a warning; catch the resulting - // None here so we fail fast with a clear message rather than starting a relay - // that no one can administer. + // A malformed RELAY_OWNER_PUBKEY already fails in config.rs, so None here + // means unset: fail fast rather than start a relay no one can administer. if config.require_relay_membership && config.relay_owner_pubkey.is_none() { error!( - "BUZZ_REQUIRE_RELAY_MEMBERSHIP=true but RELAY_OWNER_PUBKEY is not set or invalid. \ + "BUZZ_REQUIRE_RELAY_MEMBERSHIP=true but RELAY_OWNER_PUBKEY is not set. \ Set RELAY_OWNER_PUBKEY to a valid 64-char hex pubkey." ); return Err(anyhow::anyhow!( diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index ac3d871bc2d..67e7c2120e8 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.11 +version: 0.1.12 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -23,8 +23,8 @@ maintainers: url: https://github.com/block annotations: artifacthub.io/changes: | - - kind: changed - description: Chart-managed Pods derive tags.datadoghq.com/version from the deployed image identity instead of operator-supplied labels. + - kind: fixed + description: Integer relay settings render as plain integers, so values of a million or more (the git byte limits by default) no longer reach the relay in scientific notation. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index a47368eac71..92582358a63 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -114,8 +114,8 @@ spec: env: # ── Networking ─────────────────────────────────────────── - { name: BUZZ_BIND_ADDR, value: {{ .Values.relay.bindAddr | quote }} } - - { name: BUZZ_HEALTH_PORT, value: {{ .Values.service.healthPort | quote }} } - - { name: BUZZ_METRICS_PORT, value: {{ .Values.service.metricsPort | quote }} } + - { name: BUZZ_HEALTH_PORT, value: {{ .Values.service.healthPort | int64 | quote }} } + - { name: BUZZ_METRICS_PORT, value: {{ .Values.service.metricsPort | int64 | quote }} } - { name: RELAY_URL, value: {{ .Values.relayUrl | quote }} } {{- if .Values.pairingRelay.url }} - { name: BUZZ_PAIRING_RELAY_URL, value: {{ .Values.pairingRelay.url | quote }} } @@ -123,10 +123,10 @@ spec: - { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | quote }} } # ── Behavior ───────────────────────────────────────────── - - { name: BUZZ_MAX_CONNECTIONS, value: {{ .Values.relay.maxConnections | quote }} } - - { name: BUZZ_MAX_CONCURRENT_HANDLERS, value: {{ .Values.relay.maxConcurrentHandlers | quote }} } - - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | quote }} } - - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } + - { name: BUZZ_MAX_CONNECTIONS, value: {{ .Values.relay.maxConnections | int64 | quote }} } + - { name: BUZZ_MAX_CONCURRENT_HANDLERS, value: {{ .Values.relay.maxConcurrentHandlers | int64 | quote }} } + - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | int64 | quote }} } + - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | int64 | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } @@ -135,7 +135,7 @@ spec: - { name: BUZZ_CORS_ORIGINS, value: {{ join "," .Values.relay.corsOrigins | quote }} } {{- end }} {{- if gt (.Values.relay.ephemeralTtlOverride | int) 0 }} - - { name: BUZZ_EPHEMERAL_TTL_OVERRIDE, value: {{ .Values.relay.ephemeralTtlOverride | quote }} } + - { name: BUZZ_EPHEMERAL_TTL_OVERRIDE, value: {{ .Values.relay.ephemeralTtlOverride | int64 | quote }} } {{- end }} {{- if .Values.relay.uploadRecords }} - { name: BUZZ_MEDIA_UPLOAD_RECORDS, value: "true" } @@ -155,12 +155,12 @@ spec: # ── Git ────────────────────────────────────────────────── - { name: BUZZ_GIT_REPO_PATH, value: {{ .Values.persistence.git.mountPath | quote }} } - - { name: BUZZ_GIT_MAX_PACK_BYTES, value: {{ .Values.git.maxPackBytes | quote }} } + - { name: BUZZ_GIT_MAX_PACK_BYTES, value: {{ .Values.git.maxPackBytes | int64 | quote }} } - { name: BUZZ_GIT_PACK_CACHE_PATH, value: {{ .Values.git.packCachePath | quote }} } - - { name: BUZZ_GIT_PACK_CACHE_MAX_BYTES, value: {{ .Values.git.packCacheMaxBytes | quote }} } - - { name: BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS, value: {{ .Values.git.packCacheMaxConcurrentPopulations | quote }} } - - { name: BUZZ_GIT_MAX_REPOS_PER_PUBKEY, value: {{ .Values.git.maxReposPerPubkey | quote }} } - - { name: BUZZ_GIT_MAX_CONCURRENT_OPS, value: {{ .Values.git.maxConcurrentOps | quote }} } + - { name: BUZZ_GIT_PACK_CACHE_MAX_BYTES, value: {{ .Values.git.packCacheMaxBytes | int64 | quote }} } + - { name: BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS, value: {{ .Values.git.packCacheMaxConcurrentPopulations | int64 | quote }} } + - { name: BUZZ_GIT_MAX_REPOS_PER_PUBKEY, value: {{ .Values.git.maxReposPerPubkey | int64 | quote }} } + - { name: BUZZ_GIT_MAX_CONCURRENT_OPS, value: {{ .Values.git.maxConcurrentOps | int64 | quote }} } # ── S3 (non-secret) ────────────────────────────────────── {{- $s3Endpoint := include "buzz.s3Endpoint" . }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index f288f8df2d5..790ab6940b9 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -48,6 +48,37 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml + # The relay parses integer settings strictly; Helm renders numbers of a + # million or more as floats ("5.24288e+08") unless the template casts them. + - it: renders large integer settings as plain integers + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.maxConnections: 1000000 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_GIT_MAX_PACK_BYTES + value: "524288000" + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_GIT_PACK_CACHE_MAX_BYTES + value: "5368709120" + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_MAX_CONNECTIONS + value: "1000000" + template: templates/deployment.yaml - it: renders virtual-hosted S3 addressing for providers that require it set: relayUrl: wss://buzz.example.com