Skip to content

brokkr-utils::config

Rust

Brokkr Config Module

This module provides a common configuration framework for our crates.

For detailed configuration documentation, including environment variables, configuration file format, and best practices, see the Configuration Guide.

Variable Naming Convention

Variables in this configuration framework follow these naming conventions:

  • Struct fields use snake_case (e.g., database, log_level)
  • Environment variables use SCREAMING_SNAKE_CASE and are prefixed with “BROKKR__” (e.g., BROKKR__DATABASE__URL)
  • Configuration file keys use snake_case (e.g., database.url, log.level)

Configuration Overriding

The configuration values are loaded and overridden in the following order (later sources take precedence):

  1. Default values from the embedded default.toml file
  2. Values from an optional external configuration file (if provided)
  3. Environment variables

To override a configuration value:

  • In a configuration file: Use the appropriate key (e.g., database.url = "new_value")
  • Using environment variables: Set the variable with the “BROKKR__” prefix and “__” as separators (e.g., BROKKR__DATABASE__URL=new_value)

Available Environment Variables

The following environment variables can be used to configure Brokkr:

  • BROKKR__DATABASE__URL: Sets the database connection URL Default: “postgres://brokkr:brokkr@localhost:5433/brokkr”

  • BROKKR__DATABASE__SCHEMA: Sets the PostgreSQL schema for multi-tenant isolation Default: None (uses public schema) Example: “tenant_acme”

  • BROKKR__LOG__LEVEL: Sets the log level for the application Default: “debug” Possible values: “trace”, “debug”, “info”, “warn”, “error”

  • BROKKR__PAK__PREFIX: Sets the prefix for PAKs (Prefixed API Keys) Default: “brokkr”

  • BROKKR__PAK__RNG: Sets the random number generator type for PAK generation Default: “osrng”

  • BROKKR__PAK__DIGEST: Sets the digest algorithm for PAK generation Default: 8

  • BROKKR__PAK__SHORT_TOKEN_LENGTH: Sets the length of short PAK tokens Default: 8

  • BROKKR__PAK__LONG_TOKEN_LENGTH: Sets the length of long PAK tokens Default: 24

  • BROKKR__PAK__SHORT_TOKEN_PREFIX: Sets the prefix for short PAK tokens Default: “BR”

  • BROKKR__BROKER__AUTH_CACHE_TTL_SECONDS: TTL for cached PAK auth results in seconds Default: 60 (set to 0 to disable caching)

pub

Derives: Debug, Deserialize, Clone

Represents the main settings structure for the application

Name Type Description
database Database Database configuration
log Log Logging configuration
pak PAK PAK configuration
agent Agent Agent configuration
broker Broker Broker configuration
cors Cors CORS configuration
telemetry Telemetry Telemetry configuration

pub

fn new(file: Option<String>) -> Result<Self, ConfigError>

Creates a new Settings instance

Parameters:

Name Type Description
file Option<String> An optional path to a configuration file

Returns:

Returns a Result containing the Settings instance or a ConfigError

Source
pub fn new(file: Option<String>) -> Result<Self, ConfigError> {
// Start with default settings from the embedded TOML file
let mut s = Config::builder()
.add_source(File::from_str(DEFAULT_SETTINGS, config::FileFormat::Toml));
// If a configuration file is provided, add it as a source
s = match file {
Some(x) => s.add_source(File::with_name(x.as_str())),
None => s,
};
// Add environment variables as a source, prefixed with "BROKKR" and using "__" as a separator
s = s.add_source(Environment::with_prefix("BROKKR").separator("__"));
// Build the configuration
let settings = s.build().unwrap();
// Deserialize the configuration into a Settings instance
settings.try_deserialize()
}

pub

Derives: Debug, Deserialize, Clone

Represents the CORS configuration

Name Type Description
allowed_origins Vec<String> Allowed origins for CORS requests Use “*” to allow all origins (not recommended for production) Can be set as comma-separated string via env var: “origin1,origin2”
allowed_methods Vec<String> Allowed HTTP methods Can be set as comma-separated string via env var: “GET,POST,PUT”
allowed_headers Vec<String> Allowed HTTP headers Can be set as comma-separated string via env var: “Authorization,Content-Type”
max_age_seconds u64 Max age for preflight cache in seconds

pub

Derives: Debug, Deserialize, Clone

Name Type Description
pak_hash Option<String> PAK Hash
diagnostic_cleanup_interval_seconds Option<u64> Interval for diagnostic cleanup task in seconds (default: 900 = 15 minutes)
diagnostic_max_age_hours Option<i64> Maximum age for completed/expired diagnostics before deletion in hours (default: 1)
webhook_encryption_key Option<String> Webhook encryption key (hex-encoded, 32 bytes for AES-256) If not provided, a random key will be generated on startup (not recommended for production)
webhook_delivery_interval_seconds Option<u64> Webhook delivery worker interval in seconds (default: 5)
webhook_delivery_batch_size Option<i64> Webhook delivery batch size (default: 50)
webhook_cleanup_retention_days Option<i64> Webhook delivery cleanup retention in days (default: 7)
audit_log_retention_days Option<i64> Audit log retention in days (default: 90)
auth_cache_ttl_seconds Option<u64> Auth cache TTL in seconds (default: 60). Set to 0 to disable caching.
agent_events_retention_days Option<i64> Agent-events retention in days (default: 30). Events older than this are hard-deleted by the eviction worker. Leaving this unset applies the 30-day default; set it explicitly to 0 to DISABLE eviction and retain all agent events indefinitely.

pub

Derives: Debug, Deserialize, Clone

Represents the agent configuration

Name Type Description
broker_url String Broker URL
polling_interval u64 Polling interval in seconds
kubeconfig_path Option<String> Kubeconfig path
max_retries u32 Max number of retries
pak String PAK
agent_name String Agent name
cluster_name String Cluster name
health_port Option<u16> Health check HTTP server port
deployment_health_enabled Option<bool> Whether deployment health checking is enabled
deployment_health_interval Option<u64> Interval for deployment health checks in seconds
ws_force_rest bool Force the agent to use REST polling only, never opening the internal WebSocket channel. Defaults to false: WS is opt-out per ADR-0008. Set this for restricted environments (no WS through ingress) or for debugging the REST fallback path in isolation.
ws_url Option<String> Optional override for the internal WS upgrade URL.
When unset (default), the agent derives the WS URL from broker_url via ws_url_from_broker_url — http→ws, https→wss, append /internal/ws/agent. This is the right behavior for nearly every deployment.
Set this when WS traffic flows through a different ingress / proxy than REST: e.g. a sticky-session-capable WS-aware load balancer in front of the broker, while REST goes direct, or — in tests — a toxiproxy sidecar that can sever WS in isolation. Must be a full ws://host:port/path or wss://... URL.
kube_event_uid_cache_cap Option<usize> Max entries in the kube-events UID→ownership LRU cache (WS-07). The cache turns the per-Event annotation lookup into an O(1) hit for already-seen objects; the bound stops unmanaged-object churn from growing it without limit. When unset, defaults to 10_000 (sized for ~10k managed pods). Bump for larger clusters; the trade-off is memory vs. how often an evicted entry forces a fresh dynamic-API lookup.
watch_namespace Option<String> Restrict the agent’s cluster-wide watchers and health discovery to a single namespace (for namespace-scoped RBAC deployments). When unset the agent watches cluster-wide. The Helm chart sets this from the downward API when rbac.clusterWide: false.
generator_ids Option<String> Generator scopes this agent serves, as a comma-separated list of generator UUIDs. On startup the agent self-registers with each so the broker only targets it with stacks owned by these generators (ADR-0009 / BROKKR-I-0030). Resolvable via BROKKR__AGENT__GENERATOR_IDS or a config file. When unset, the agent serves system/fleet scope only — the broker auto-registers every agent with the system generator regardless. A --generator-ids CLI flag overrides this; the legacy bare BROKKR_GENERATOR_IDS env var is still honored (deprecated) as a fallback.

pub

Derives: Debug, Deserialize, Clone

Represents the database configuration

Name Type Description
url String Database connection URL
schema Option<String> Optional schema name for multi-tenant isolation

pub

Derives: Debug, Deserialize, Clone

Represents the logging configuration

Name Type Description
level String Log level (e.g., “info”, “debug”, “warn”, “error”)
format String Log format: “text” for human-readable, “json” for structured JSON

pub

Derives: Debug, Deserialize, Clone

Represents the telemetry (OpenTelemetry) configuration with hierarchical overrides

Name Type Description
enabled bool Whether telemetry is enabled (base default)
otlp_endpoint String OTLP endpoint for trace export (gRPC)
service_name String Service name for traces
sampling_rate f64 Sampling rate (0.0 to 1.0)
broker TelemetryOverride Broker-specific overrides
agent TelemetryOverride Agent-specific overrides

pub

fn for_broker(&self) -> ResolvedTelemetry

Get resolved telemetry config for broker (base merged with broker overrides)

Source
pub fn for_broker(&self) -> ResolvedTelemetry {
ResolvedTelemetry {
enabled: self.broker.enabled.unwrap_or(self.enabled),
otlp_endpoint: self
.broker
.otlp_endpoint
.clone()
.unwrap_or_else(|| self.otlp_endpoint.clone()),
service_name: self
.broker
.service_name
.clone()
.unwrap_or_else(|| self.service_name.clone()),
sampling_rate: self.broker.sampling_rate.unwrap_or(self.sampling_rate),
}
}

pub

fn for_agent(&self) -> ResolvedTelemetry

Get resolved telemetry config for agent (base merged with agent overrides)

Source
pub fn for_agent(&self) -> ResolvedTelemetry {
ResolvedTelemetry {
enabled: self.agent.enabled.unwrap_or(self.enabled),
otlp_endpoint: self
.agent
.otlp_endpoint
.clone()
.unwrap_or_else(|| self.otlp_endpoint.clone()),
service_name: self
.agent
.service_name
.clone()
.unwrap_or_else(|| self.service_name.clone()),
sampling_rate: self.agent.sampling_rate.unwrap_or(self.sampling_rate),
}
}

pub

Derives: Debug, Deserialize, Clone, Default

Component-specific telemetry overrides (all fields optional)

Name Type Description
enabled Option<bool> Override enabled flag
otlp_endpoint Option<String> Override OTLP endpoint
service_name Option<String> Override service name
sampling_rate Option<f64> Override sampling rate

pub

Derives: Debug, Clone

Resolved telemetry configuration after merging base with overrides

Name Type Description
enabled bool
otlp_endpoint String
service_name String
sampling_rate f64

pub

Derives: Debug, Deserialize, Clone

Represents the PAK configuration

Name Type Description
prefix Option<String> PAK prefix
digest Option<String> Digest algorithm for PAK
rng Option<String> RNG type for PAK
short_token_length Option<usize> Short token length for PAK
short_token_length_str Option<String> Short token length as a string
short_token_prefix Option<String> Prefix for short tokens
long_token_length Option<usize> Long token length for PAK
long_token_length_str Option<String> Long token length as a string

pub

fn short_length_as_str(&mut self)

Convert short token length to string

Source
pub fn short_length_as_str(&mut self) {
self.short_token_length_str = self.short_token_length.map(|v| v.to_string());
}

pub

fn long_length_as_str(&mut self)

Convert long token length to string

Source
pub fn long_length_as_str(&mut self) {
self.long_token_length_str = self.long_token_length.map(|v| v.to_string());
}

pub

Derives: Debug, Clone

Dynamic configuration values that can be hot-reloaded at runtime.

These settings can be updated without restarting the application. Changes are applied atomically via the RwLock in ReloadableConfig.

Name Type Description
log_level String Log level (e.g., “info”, “debug”, “warn”, “error”)
diagnostic_cleanup_interval_seconds u64 Interval for diagnostic cleanup task in seconds
diagnostic_max_age_hours i64 Maximum age for completed/expired diagnostics before deletion in hours
webhook_delivery_interval_seconds u64 Webhook delivery worker interval in seconds
webhook_delivery_batch_size i64 Webhook delivery batch size
webhook_cleanup_retention_days i64 Webhook delivery cleanup retention in days
cors_allowed_origins Vec<String> Allowed origins for CORS requests
cors_max_age_seconds u64 Max age for CORS preflight cache in seconds

pub

fn from_settings(settings: &Settings) -> Self

Create DynamicConfig from Settings

Source
pub fn from_settings(settings: &Settings) -> Self {
Self {
log_level: settings.log.level.clone(),
diagnostic_cleanup_interval_seconds: settings
.broker
.diagnostic_cleanup_interval_seconds
.unwrap_or(900),
diagnostic_max_age_hours: settings.broker.diagnostic_max_age_hours.unwrap_or(1),
webhook_delivery_interval_seconds: settings
.broker
.webhook_delivery_interval_seconds
.unwrap_or(5),
webhook_delivery_batch_size: settings.broker.webhook_delivery_batch_size.unwrap_or(50),
webhook_cleanup_retention_days: settings
.broker
.webhook_cleanup_retention_days
.unwrap_or(7),
cors_allowed_origins: settings.cors.allowed_origins.clone(),
cors_max_age_seconds: settings.cors.max_age_seconds,
}
}

pub

Derives: Debug, Clone

Represents a configuration change detected during reload

Name Type Description
key String The configuration key that changed
old_value String The old value (as string for display)
new_value String The new value (as string for display)

pub

Derives: Clone

Configuration wrapper that separates static (restart-required) settings from dynamic (hot-reloadable) settings.

Static settings are immutable after creation and require an application restart to change. Dynamic settings can be updated at runtime via the reload() method.

Examples:

use brokkr_utils::config::ReloadableConfig;
let config = ReloadableConfig::new(None)?;
// Read dynamic config (thread-safe)
let log_level = config.log_level();
// Reload config from sources
let changes = config.reload()?;
for change in changes {
println!("Changed {}: {} -> {}", change.key, change.old_value, change.new_value);
}

pub

fn new(file: Option<String>) -> Result<Self, ConfigError>

Creates a new ReloadableConfig instance

Parameters:

Name Type Description
file Option<String> An optional path to a configuration file

Returns:

Returns a Result containing the ReloadableConfig instance or a ConfigError

Source
pub fn new(file: Option<String>) -> Result<Self, ConfigError> {
let settings = Settings::new(file.clone())?;
let dynamic = DynamicConfig::from_settings(&settings);
Ok(Self {
static_config: settings,
dynamic: Arc::new(RwLock::new(dynamic)),
config_file: file,
})
}

pub

fn from_settings(
settings: Settings,
config_file: Option<String>,
) -> Self

Creates a ReloadableConfig from an existing Settings instance

Parameters:

Name Type Description
settings Settings The Settings instance to wrap
config_file Option<String> An optional path to the config file for future reloads

Returns:

Returns a ReloadableConfig instance

Source
pub fn from_settings(settings: Settings, config_file: Option<String>) -> Self {
let dynamic = DynamicConfig::from_settings(&settings);
Self {
static_config: settings,
dynamic: Arc::new(RwLock::new(dynamic)),
config_file,
}
}

pub

fn static_config(&self) -> &Settings

Get a reference to the static (immutable) settings

These settings require an application restart to change.

Source
pub fn static_config(&self) -> &Settings {
&self.static_config
}

pub

fn reload(&self) -> Result<Vec<ConfigChange>, ConfigError>

Reload dynamic configuration from sources (file + environment)

Returns a list of configuration changes that were applied. Thread-safe: blocks writers during reload.

Source
pub fn reload(&self) -> Result<Vec<ConfigChange>, ConfigError> {
// Load fresh settings from sources
let new_settings = Settings::new(self.config_file.clone())?;
let new_dynamic = DynamicConfig::from_settings(&new_settings);
// Acquire write lock and compute changes
let mut dynamic = self
.dynamic
.write()
.map_err(|e| ConfigError::Message(format!("Failed to acquire write lock: {}", e)))?;
let mut changes = Vec::new();
// Check each field for changes
if dynamic.log_level != new_dynamic.log_level {
changes.push(ConfigChange {
key: "log.level".to_string(),
old_value: dynamic.log_level.clone(),
new_value: new_dynamic.log_level.clone(),
});
}
if dynamic.diagnostic_cleanup_interval_seconds
!= new_dynamic.diagnostic_cleanup_interval_seconds
{
changes.push(ConfigChange {
key: "broker.diagnostic_cleanup_interval_seconds".to_string(),
old_value: dynamic.diagnostic_cleanup_interval_seconds.to_string(),
new_value: new_dynamic.diagnostic_cleanup_interval_seconds.to_string(),
});
}
if dynamic.diagnostic_max_age_hours != new_dynamic.diagnostic_max_age_hours {
changes.push(ConfigChange {
key: "broker.diagnostic_max_age_hours".to_string(),
old_value: dynamic.diagnostic_max_age_hours.to_string(),
new_value: new_dynamic.diagnostic_max_age_hours.to_string(),
});
}
if dynamic.webhook_delivery_interval_seconds
!= new_dynamic.webhook_delivery_interval_seconds
{
changes.push(ConfigChange {
key: "broker.webhook_delivery_interval_seconds".to_string(),
old_value: dynamic.webhook_delivery_interval_seconds.to_string(),
new_value: new_dynamic.webhook_delivery_interval_seconds.to_string(),
});
}
if dynamic.webhook_delivery_batch_size != new_dynamic.webhook_delivery_batch_size {
changes.push(ConfigChange {
key: "broker.webhook_delivery_batch_size".to_string(),
old_value: dynamic.webhook_delivery_batch_size.to_string(),
new_value: new_dynamic.webhook_delivery_batch_size.to_string(),
});
}
if dynamic.webhook_cleanup_retention_days != new_dynamic.webhook_cleanup_retention_days {
changes.push(ConfigChange {
key: "broker.webhook_cleanup_retention_days".to_string(),
old_value: dynamic.webhook_cleanup_retention_days.to_string(),
new_value: new_dynamic.webhook_cleanup_retention_days.to_string(),
});
}
if dynamic.cors_allowed_origins != new_dynamic.cors_allowed_origins {
changes.push(ConfigChange {
key: "cors.allowed_origins".to_string(),
old_value: format!("{:?}", dynamic.cors_allowed_origins),
new_value: format!("{:?}", new_dynamic.cors_allowed_origins),
});
}
if dynamic.cors_max_age_seconds != new_dynamic.cors_max_age_seconds {
changes.push(ConfigChange {
key: "cors.max_age_seconds".to_string(),
old_value: dynamic.cors_max_age_seconds.to_string(),
new_value: new_dynamic.cors_max_age_seconds.to_string(),
});
}
// Apply the new configuration
let log_level_changed = dynamic.log_level != new_dynamic.log_level;
let new_log_level = new_dynamic.log_level.clone();
*dynamic = new_dynamic;
drop(dynamic);
// Push the log level into the logger (BROKKR-T-0292).
//
// Applied here rather than by callers so that *every* reload path gets
// it — the ConfigMap watcher and `POST /admin/config/reload` alike.
// Before this, `reload()` recomputed the level, reported it as a change,
// and left the logger untouched: `update_log_level` existed and had no
// caller outside its own tests, so the level was restart-only in
// practice while being advertised as hot-reloadable.
//
// The lock is released first: `update_log_level` only touches an atomic
// and `log::set_max_level`, but holding a config write lock across a
// call into another module is how deadlocks get introduced later.
if log_level_changed {
if let Err(e) = crate::logging::update_log_level(&new_log_level) {
// A bad level in config should not abort a reload that also
// carried good changes; the level simply stays where it was.
eprintln!("failed to apply reloaded log level '{new_log_level}': {e}");
}
// The broker logs through `tracing`, and `update_log_level` above
// only moves the `log` crate's level. Change the `tracing` filter
// too, or the reload is reported and the output does not change.
match crate::telemetry::set_log_level(&new_log_level) {
Ok(true) => {}
Ok(false) => eprintln!(
"reloaded log level '{new_log_level}' not applied to tracing: \
RUST_LOG set the filter at startup, or tracing is not initialized"
),
Err(e) => {
eprintln!("failed to apply reloaded log level '{new_log_level}': {e}")
}
}
}
Ok(changes)
}

pub

fn log_level(&self) -> String

Get current log level

Source
pub fn log_level(&self) -> String {
self.dynamic
.read()
.map(|d| d.log_level.clone())
.unwrap_or_else(|_| "info".to_string())
}
diagnostic_​cleanup_​interval_​seconds
Section titled “diagnostic_​cleanup_​interval_​seconds”

pub

fn diagnostic_cleanup_interval_seconds(&self) -> u64

Get diagnostic cleanup interval in seconds

Source
pub fn diagnostic_cleanup_interval_seconds(&self) -> u64 {
self.dynamic
.read()
.map(|d| d.diagnostic_cleanup_interval_seconds)
.unwrap_or(900)
}

pub

fn diagnostic_max_age_hours(&self) -> i64

Get diagnostic max age in hours

Source
pub fn diagnostic_max_age_hours(&self) -> i64 {
self.dynamic
.read()
.map(|d| d.diagnostic_max_age_hours)
.unwrap_or(1)
}
webhook_​delivery_​interval_​seconds
Section titled “webhook_​delivery_​interval_​seconds”

pub

fn webhook_delivery_interval_seconds(&self) -> u64

Get webhook delivery interval in seconds

Source
pub fn webhook_delivery_interval_seconds(&self) -> u64 {
self.dynamic
.read()
.map(|d| d.webhook_delivery_interval_seconds)
.unwrap_or(5)
}

pub

fn webhook_delivery_batch_size(&self) -> i64

Get webhook delivery batch size

Source
pub fn webhook_delivery_batch_size(&self) -> i64 {
self.dynamic
.read()
.map(|d| d.webhook_delivery_batch_size)
.unwrap_or(50)
}

pub

fn webhook_cleanup_retention_days(&self) -> i64

Get webhook cleanup retention in days

Source
pub fn webhook_cleanup_retention_days(&self) -> i64 {
self.dynamic
.read()
.map(|d| d.webhook_cleanup_retention_days)
.unwrap_or(7)
}

pub

fn cors_allowed_origins(&self) -> Vec<String>

Get CORS allowed origins

Source
pub fn cors_allowed_origins(&self) -> Vec<String> {
self.dynamic
.read()
.map(|d| d.cors_allowed_origins.clone())
.unwrap_or_else(|_| vec!["*".to_string()])
}

pub

fn cors_max_age_seconds(&self) -> u64

Get CORS max age in seconds

Source
pub fn cors_max_age_seconds(&self) -> u64 {
self.dynamic
.read()
.map(|d| d.cors_max_age_seconds)
.unwrap_or(3600)
}

pub

fn dynamic_snapshot(&self) -> Option<DynamicConfig>

Get a snapshot of all dynamic config values

Source
pub fn dynamic_snapshot(&self) -> Option<DynamicConfig> {
self.dynamic.read().ok().map(|d| d.clone())
}
Name Type Value Description
DEFAULT_ADMIN_PAK_HASH &str "4c697273df3d764cba950bb5c04368097685f09259f5bd880d892cf1... The admin PAK hash shipped in the embedded default.toml.
This is a publicly known credential: the raw PAK it hashes is written in a comment directly above it in default.toml, and both are in the public source tree. It exists so the development and test harnesses work with zero configuration; a broker that still has it as its admin credential accepts an admin PAK that anyone can read out of the repository.
It lives here, next to the include_str! of the file it mirrors, so the literal and its source can be compared without a runtime config load — and default_admin_pak_hash_constant_matches_default_toml in this module’s test suite fails the build if they ever drift. Consumers must compare against this constant rather than re-deriving the value, so there is exactly one definition of “the shipped default” in the codebase.
See brokkr_broker::utils::detect_default_admin_pak_hash for the startup backstop that acts on it (BROKKR-T-0298).