objectstore_server/config.rs
1//! Configuration for the objectstore server.
2//!
3//! This module provides the configuration system for the objectstore HTTP server. Configuration can
4//! be loaded from multiple sources with the following precedence (highest to lowest):
5//!
6//! 1. Environment variables (prefixed with `OS__`)
7//! 2. YAML configuration file (specified via `-c` or `--config` flag)
8//! 3. Defaults
9//!
10//! See [`Config`] for a description of all configuration fields and their defaults.
11//!
12//! # Environment Variables
13//!
14//! Environment variables use `OS__` as a prefix and double underscores (`__`) to denote nested
15//! configuration structures. For example:
16//!
17//! - `OS__HTTP_ADDR=0.0.0.0:8888` sets the HTTP server address
18//! - `OS__STORAGE__TYPE=filesystem` sets the storage type
19//! - `OS__STORAGE__PATH=/data` sets the directory path
20//!
21//! # YAML Configuration File
22//!
23//! Configuration can also be provided via a YAML file. The above configuration in YAML format would
24//! look like this:
25//!
26//! ```yaml
27//! http_addr: 0.0.0.0:8888
28//!
29//! storage:
30//! type: filesystem
31//! path: /data
32//! ```
33//!
34//! # Variable References
35//!
36//! Any configuration value may be written as a reference, which is resolved after all
37//! sources have been merged. `${file:PATH}` is replaced by that file's contents and
38//! `${VAR_NAME}` by that environment variable:
39//!
40//! ```yaml
41//! storage_cogs:
42//! type: kafka
43//! override_params:
44//! sasl.password: ${file:/var/secrets/kafka-password}
45//! ```
46//!
47//! A reference must be the entire value: `${A}` works, `prefix-${A}` does not. Referencing
48//! a file that cannot be read, or an environment variable that is not set, is an error at
49//! startup.
50//!
51//! ## Relationship to `OS__` environment variables
52//!
53//! These are different mechanisms and neither replaces the other. They can even be used
54//! together: `OS__SENTRY__DSN=${SENTRY_DSN}` will set the `sentry.dsn` YAML key to the
55//! value of the `SENTRY_DSN` environment variable.
56
57use std::borrow::Cow;
58use std::collections::{BTreeMap, HashSet};
59use std::fmt;
60use std::net::SocketAddr;
61use std::path::{Path, PathBuf};
62use std::time::Duration;
63
64use anyhow::Result;
65use figment::providers::{Env, Format, Serialized, Yaml};
66use objectstore_service::backend::local_fs::FileSystemConfig;
67use objectstore_service::change_stream::CostTrackerConfig;
68use objectstore_types::auth::Permission;
69use secrecy::{CloneableSecret, SecretBox, SerializableSecret, zeroize::Zeroize};
70use serde::{Deserialize, Serialize};
71
72pub use objectstore_log::{LevelFilter, LogFormat, LoggingConfig};
73pub use objectstore_service::backend::{MultipartUploadStorageConfig, StorageConfig};
74
75use crate::killswitches::Killswitches;
76use crate::rate_limits::RateLimits;
77use crate::usecases::UseCases;
78
79/// Environment variable prefix for all configuration options.
80const ENV_PREFIX: &str = "OS__";
81
82/// Newtype around `String` that may protect against accidental
83/// logging of secrets in our configuration struct. Use with
84/// [`secrecy::SecretBox`].
85#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
86pub struct ConfigSecret(String);
87
88impl ConfigSecret {
89 /// Returns the secret value as a string slice.
90 pub fn as_str(&self) -> &str {
91 self.0.as_str()
92 }
93}
94
95impl From<&str> for ConfigSecret {
96 fn from(str: &str) -> Self {
97 ConfigSecret(str.to_string())
98 }
99}
100
101impl std::ops::Deref for ConfigSecret {
102 type Target = str;
103 fn deref(&self) -> &Self::Target {
104 &self.0
105 }
106}
107
108impl fmt::Debug for ConfigSecret {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
110 write!(f, "[redacted]")
111 }
112}
113
114impl CloneableSecret for ConfigSecret {}
115impl SerializableSecret for ConfigSecret {}
116impl Zeroize for ConfigSecret {
117 fn zeroize(&mut self) {
118 self.0.zeroize();
119 }
120}
121
122/// Runtime configuration for the Tokio async runtime.
123///
124/// Controls the threading behavior of the server's async runtime.
125///
126/// Used in: [`Config::runtime`]
127#[derive(Debug, Deserialize, Serialize)]
128#[serde(default)]
129pub struct Runtime {
130 /// Number of worker threads for the server runtime.
131 ///
132 /// This controls the size of the Tokio thread pool used to execute async tasks. More threads
133 /// can improve concurrency for CPU-bound workloads, but too many threads can increase context
134 /// switching overhead.
135 ///
136 /// Set this in accordance with the resources available to the server, especially in Kubernetes
137 /// environments.
138 ///
139 /// # Default
140 ///
141 /// Defaults to the number of CPU cores on the host machine.
142 ///
143 /// # Environment Variable
144 ///
145 /// `OS__RUNTIME__WORKER_THREADS`
146 ///
147 /// # Considerations
148 ///
149 /// - For I/O-bound workloads, the default (number of CPU cores) is usually sufficient
150 /// - For CPU-intensive workloads, consider matching or exceeding the number of cores
151 /// - Setting this too high can lead to increased memory usage and context switching
152 pub worker_threads: usize,
153
154 /// Interval in seconds for reporting internal runtime metrics.
155 ///
156 /// Defaults to `10` seconds.
157 #[serde(with = "humantime_serde")]
158 pub metrics_interval: Duration,
159}
160
161impl Default for Runtime {
162 fn default() -> Self {
163 Self {
164 worker_threads: num_cpus::get(),
165 metrics_interval: Duration::from_secs(10),
166 }
167 }
168}
169
170/// [Sentry](https://sentry.io/) error tracking and performance monitoring configuration.
171///
172/// Configures integration with Sentry for error tracking, performance monitoring, and distributed
173/// tracing. Sentry is disabled by default and only enabled when a DSN is provided.
174///
175/// Used in: [`Config::sentry`]
176#[derive(Debug, Deserialize, Serialize)]
177pub struct Sentry {
178 /// Sentry DSN (Data Source Name).
179 ///
180 /// When set, enables Sentry error tracking and performance monitoring. When `None`, Sentry
181 /// integration is completely disabled.
182 ///
183 /// # Default
184 ///
185 /// `None` (Sentry disabled)
186 ///
187 /// # Environment Variable
188 ///
189 /// `OS__SENTRY__DSN`
190 pub dsn: Option<SecretBox<ConfigSecret>>,
191
192 /// Environment name for this deployment.
193 ///
194 /// Used to distinguish events from different environments (e.g., "production", "staging",
195 /// "development"). This appears in the Sentry UI and can be used for filtering.
196 ///
197 /// # Default
198 ///
199 /// `None`
200 ///
201 /// # Environment Variable
202 ///
203 /// `OS__SENTRY__ENVIRONMENT`
204 pub environment: Option<Cow<'static, str>>,
205
206 /// Server name or identifier.
207 ///
208 /// Used to identify which server instance sent an event. Useful in multi-server deployments for
209 /// tracking which instance encountered an error. Set to the hostname or pod name of the server.
210 ///
211 /// # Default
212 ///
213 /// `None`
214 ///
215 /// # Environment Variable
216 ///
217 /// `OS__SENTRY__SERVER_NAME`
218 pub server_name: Option<Cow<'static, str>>,
219
220 /// Error event sampling rate.
221 ///
222 /// Controls what percentage of error events are sent to Sentry. A value of `1.0` sends all
223 /// errors, while `0.5` sends 50% of errors, and `0.0` sends no errors.
224 ///
225 /// # Default
226 ///
227 /// `1.0` (send all errors)
228 ///
229 /// # Environment Variable
230 ///
231 /// `OS__SENTRY__SAMPLE_RATE`
232 pub sample_rate: f32,
233
234 /// Performance trace sampling rate.
235 ///
236 /// Controls what percentage of transactions (traces) are sent to Sentry for performance
237 /// monitoring. A value of `1.0` sends all traces, while `0.01` sends 1% of traces.
238 ///
239 /// **Important**: Performance traces can generate significant data volume in high-traffic
240 /// systems. Start with a low rate (0.01-0.1) and adjust based on traffic and Sentry quota.
241 ///
242 /// # Default
243 ///
244 /// `0.01` (send 1% of traces)
245 ///
246 /// # Environment Variable
247 ///
248 /// `OS__SENTRY__TRACES_SAMPLE_RATE`
249 pub traces_sample_rate: f32,
250
251 /// Whether to inherit sampling decisions from incoming traces.
252 ///
253 /// When `true` (default), if an incoming request contains a distributed tracing header with a
254 /// sampling decision (e.g., from an upstream service), that decision is honored. When `false`,
255 /// the local `traces_sample_rate` is always used instead.
256 ///
257 /// When this is enabled, the calling service effectively controls the sampling decision for the
258 /// entire trace. Set this to `false` if you want to have independent sampling control at the
259 /// objectstore level.
260 ///
261 /// # Default
262 ///
263 /// `true`
264 ///
265 /// # Environment Variable
266 ///
267 /// `OS__SENTRY__INHERIT_SAMPLING_DECISION`
268 pub inherit_sampling_decision: bool,
269
270 /// Enable Sentry SDK debug mode.
271 ///
272 /// When enabled, the Sentry SDK will output debug information to stderr, which can be useful
273 /// for troubleshooting Sentry integration issues. It is discouraged to enable this in
274 /// production as it generates verbose logging.
275 ///
276 /// # Default
277 ///
278 /// `false`
279 ///
280 /// # Environment Variable
281 ///
282 /// `OS__SENTRY__DEBUG`
283 pub debug: bool,
284
285 /// Additional tags to attach to all Sentry events.
286 ///
287 /// Key-value pairs that are sent as tags with every event reported to Sentry. Useful for adding
288 /// context such as deployment identifiers or environment details.
289 ///
290 /// # Default
291 ///
292 /// Empty (no tags)
293 ///
294 /// # Environment Variables
295 ///
296 /// Each tag is set individually:
297 /// - `OS__SENTRY__TAGS__FOO=foo`
298 /// - `OS__SENTRY__TAGS__BAR=bar`
299 ///
300 /// # YAML Example
301 ///
302 /// ```yaml
303 /// sentry:
304 /// tags:
305 /// foo: foo
306 /// bar: bar
307 /// ```
308 pub tags: BTreeMap<String, String>,
309}
310
311impl Sentry {
312 /// Returns whether Sentry integration is enabled.
313 ///
314 /// Sentry is considered enabled if a DSN is configured.
315 pub fn is_enabled(&self) -> bool {
316 self.dsn.is_some()
317 }
318}
319
320impl Default for Sentry {
321 fn default() -> Self {
322 Self {
323 dsn: None,
324 environment: None,
325 server_name: None,
326 sample_rate: 1.0,
327 traces_sample_rate: 0.01,
328 inherit_sampling_decision: true,
329 debug: false,
330 tags: BTreeMap::new(),
331 }
332 }
333}
334
335// Logging configuration is defined in `objectstore_log::LoggingConfig`.
336// Metrics configuration is defined in `objectstore_metrics::MetricsConfig`.
337
338/// A key that may be used to verify a request's auth token and its associated
339/// permissions. May contain multiple key versions to facilitate rotation.
340#[derive(Debug, Deserialize, Serialize)]
341pub struct AuthZVerificationKey {
342 /// Files that contain versions of this key's key material which may be used to verify
343 /// signatures.
344 ///
345 /// If a key is being rotated, the old and new versions of that key should both be
346 /// configured so objectstore can verify signatures while the updated key is still
347 /// rolling out. Otherwise, this should only contain the most recent version of a key.
348 pub key_files: Vec<PathBuf>,
349
350 /// The maximum set of permissions that this key's signer is authorized to grant.
351 ///
352 /// If a request's auth token grants full permission but it was signed by a key
353 /// that is only allowed to grant read permission, then the request only has
354 /// read permission.
355 #[serde(default)]
356 pub max_permissions: HashSet<Permission>,
357}
358
359/// Configuration for content-based authorization.
360#[derive(Debug, Deserialize, Serialize)]
361pub struct AuthZ {
362 /// Whether to enforce content-based authorization or not.
363 ///
364 /// Defaults to `true`, resulting in `403 Unauthorized` responses for unauthorized requests. Set
365 /// to `false` to permit unauthorized requests. Authorization checks are still performed if
366 /// keys are configured, but only result in warnings.
367 #[serde(default = "default_enforce")]
368 pub enforce: bool,
369
370 /// Keys that may be used to verify a request's auth token.
371 ///
372 /// The auth token is read from the `X-Os-Auth` header (preferred)
373 /// or the standard `Authorization` header (fallback). This field is a
374 /// container keyed on a key's ID. When verifying a JWT, the `kid` field
375 /// should be read from the JWT header and used to index into this map to
376 /// select the appropriate key.
377 #[serde(default)]
378 pub keys: BTreeMap<String, AuthZVerificationKey>,
379}
380
381fn default_enforce() -> bool {
382 true
383}
384
385impl AuthZ {
386 /// Returns whether content-based authorization is active.
387 ///
388 /// Authorization is considered active if enforcement is enabled or at least one key is
389 /// configured. Without enforcement, authorization checks are still performed and reported but
390 /// failures will not result in `403 Unauthorized`
391 pub fn is_active(&self) -> bool {
392 self.enforce || !self.keys.is_empty()
393 }
394}
395
396impl Default for AuthZ {
397 fn default() -> Self {
398 Self {
399 enforce: true,
400 keys: BTreeMap::new(),
401 }
402 }
403}
404
405/// Main configuration struct for the objectstore server.
406///
407/// This is the top-level configuration that combines all server settings including networking,
408/// storage backends, runtime, and observability options.
409///
410/// Configuration is loaded with the following precedence (highest to lowest):
411/// 1. Environment variables (prefixed with `OS__`)
412/// 2. YAML configuration file (if provided via `-c` flag)
413/// 3. Default values
414///
415/// See individual field documentation for details on each configuration option, including
416/// defaults and environment variables.
417#[derive(Debug, Deserialize, Serialize)]
418pub struct Config {
419 /// HTTP server bind address.
420 ///
421 /// The socket address (IP and port) where the HTTP server will listen for incoming
422 /// connections. Supports both IPv4 and IPv6 addresses. Note that binding to `0.0.0.0`
423 /// makes the server accessible from all network interfaces.
424 ///
425 /// # Default
426 ///
427 /// `0.0.0.0:8888` (listens on all network interfaces, port 8888)
428 ///
429 /// # Environment Variable
430 ///
431 /// `OS__HTTP_ADDR`
432 pub http_addr: SocketAddr,
433
434 /// Storage backend configuration.
435 ///
436 /// Configures the storage backend used by the server. Use `type: "filesystem"` for
437 /// development, `type: "tiered"` for production two-tier routing (small objects to a
438 /// high-volume backend, large objects to a long-term backend), or any other single backend
439 /// type for simple deployments.
440 ///
441 /// # Default
442 ///
443 /// Filesystem storage in the `./data` directory
444 ///
445 /// # Environment Variables
446 ///
447 /// - `OS__STORAGE__TYPE` — backend type (`filesystem`, `tiered`, `gcs`, `bigtable`,
448 /// `s3compatible`)
449 /// - Additional fields depending on the type (see [`StorageConfig`])
450 ///
451 /// For tiered storage, sub-backend fields are nested under `high_volume` and `long_term`:
452 /// - `OS__STORAGE__TYPE=tiered`
453 /// - `OS__STORAGE__HIGH_VOLUME__TYPE=bigtable`
454 /// - `OS__STORAGE__LONG_TERM__TYPE=gcs`
455 ///
456 /// # Example (tiered)
457 ///
458 /// ```yaml
459 /// storage:
460 /// type: tiered
461 /// high_volume:
462 /// type: bigtable
463 /// project_id: my-project
464 /// instance_name: objectstore
465 /// table_name: objectstore
466 /// long_term:
467 /// type: gcs
468 /// bucket: my-objectstore-bucket
469 /// ```
470 pub storage: StorageConfig,
471
472 /// Cost tracking sink for backends' change streams.
473 ///
474 /// A transport owns connections and a send queue, so it is configured once here and
475 /// shared by every backend. What each backend reports, and how much of it, is
476 /// configured per backend under [`storage`](Self::storage).
477 ///
478 /// Absent is the default, and disables reporting entirely.
479 ///
480 /// # Example
481 ///
482 /// ```yaml
483 /// storage_cogs:
484 /// type: kafka
485 /// topic: shared-resources-inventory
486 /// bootstrap_servers: [kafka:9092]
487 /// ```
488 ///
489 /// # Environment Variables
490 ///
491 /// - `OS__STORAGE_COGS__TYPE=kafka`
492 /// - `OS__STORAGE_COGS__TOPIC=shared-resources-inventory`
493 /// - `OS__STORAGE_COGS__BOOTSTRAP_SERVERS=kafka:9092`
494 /// - `OS__STORAGE_COGS__OVERRIDE_PARAMS__<PROPERTY>=<value>`
495 #[serde(default)]
496 pub storage_cogs: Option<CostTrackerConfig>,
497
498 /// Configuration of the internal task runtime.
499 ///
500 /// Controls the thread pool size and behavior of the async runtime powering the server.
501 /// See [`Runtime`] for configuration options.
502 pub runtime: Runtime,
503
504 /// Logging configuration.
505 ///
506 /// Controls log verbosity and output format. See [`LoggingConfig`] for configuration options.
507 pub logging: LoggingConfig,
508
509 /// Sentry error tracking configuration.
510 ///
511 /// Optional integration with Sentry for error tracking and performance monitoring.
512 /// See [`Sentry`] for configuration options.
513 pub sentry: Sentry,
514
515 /// Internal metrics configuration.
516 ///
517 /// Configures submission of internal metrics to a DogStatsD-compatible endpoint.
518 /// See [`objectstore_metrics::MetricsConfig`] for configuration options.
519 pub metrics: objectstore_metrics::MetricsConfig,
520
521 /// Content-based authorization configuration.
522 ///
523 /// Controls the verification and enforcement of content-based access control based on the
524 /// JWT in a request's `X-Os-Auth` or `Authorization` header.
525 pub auth: AuthZ,
526
527 /// A list of matchers for requests to discard without processing.
528 pub killswitches: Killswitches,
529
530 /// Definitions for rate limits to enforce on incoming requests.
531 pub rate_limits: RateLimits,
532
533 /// Per-use-case configuration.
534 ///
535 /// Controls properties of individual use cases such as which expiration
536 /// policies are permitted and their maximum durations. Use cases not
537 /// present in the map receive default configuration (all policies allowed,
538 /// no duration caps).
539 pub usecases: UseCases,
540
541 /// Configuration for the [`StorageService`](objectstore_service::StorageService).
542 pub service: Service,
543
544 /// Configuration for the HTTP layer.
545 ///
546 /// Controls HTTP-level settings that operate before requests reach the
547 /// storage service. See [`Http`] for configuration options.
548 pub http: Http,
549}
550
551/// Configuration for the [`StorageService`](objectstore_service::StorageService).
552///
553/// Controls operational parameters of the storage service layer that sits
554/// between the HTTP server and the storage backends.
555///
556/// Used in: [`Config::service`]
557///
558/// # Environment Variables
559///
560/// - `OS__SERVICE__MAX_CONCURRENCY`
561/// - `OS__SERVICE__CONCURRENCY_QUEUE`
562/// - `OS__SERVICE__CONCURRENCY_TIMEOUT`
563/// - `OS__SERVICE__BULK_CONCURRENCY_PCT`
564#[derive(Debug, Deserialize, Serialize)]
565#[serde(default)]
566pub struct Service {
567 /// Maximum number of concurrent backend operations.
568 ///
569 /// This caps the total number of in-flight storage operations (reads,
570 /// writes, deletes) across all requests. Operations that exceed the limit
571 /// are rejected with HTTP 429.
572 ///
573 /// # Default
574 ///
575 /// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
576 pub max_concurrency: u32,
577
578 /// Maximum number of requests that may wait for a concurrency permit.
579 ///
580 /// When all `max_concurrency` execution slots are held, up to this many
581 /// additional requests will park and wait (for at most
582 /// `concurrency_timeout`) instead of being rejected immediately.
583 /// Requests beyond that are rejected with HTTP 429.
584 ///
585 /// Sizing guidance: `concurrency_queue ≈ permit_release_rate ×
586 /// acceptable_added_latency`.
587 ///
588 /// # Default
589 ///
590 /// `0`
591 pub concurrency_queue: u32,
592
593 /// Maximum time a caller may wait for a concurrency permit.
594 ///
595 /// Applies to both queued normal requests and bulk operations
596 /// waiting for the bulk and execution semaphores.
597 ///
598 /// # Default
599 ///
600 /// `1s`
601 #[serde(with = "humantime_serde")]
602 pub concurrency_timeout: Duration,
603
604 /// Percentage of `max_concurrency` available to bulk operations
605 /// (e.g. parallelized batch requests).
606 ///
607 /// This sets a safe operating point: below this level there is
608 /// little-to-no performance degradation, leaving room for more tasks
609 /// to be admitted via the queue before rejection is necessary.
610 ///
611 /// Clamped to 1..=100. At 100, bulk operations can use all execution
612 /// slots. Lower values leave headroom for single-object requests.
613 ///
614 /// # Default
615 ///
616 /// `60`
617 pub bulk_concurrency_pct: u32,
618}
619
620impl Default for Service {
621 fn default() -> Self {
622 Self {
623 max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
624 concurrency_queue: 0,
625 concurrency_timeout: Duration::from_secs(1),
626 bulk_concurrency_pct: 60,
627 }
628 }
629}
630
631/// Default maximum number of concurrent in-flight HTTP requests.
632///
633/// Requests beyond this limit are rejected with HTTP 503.
634pub const DEFAULT_MAX_HTTP_REQUESTS: usize = 10_000;
635
636/// Configuration for the HTTP layer.
637///
638/// Controls behaviour at the HTTP request level, before requests reach the
639/// storage service. Grouping these settings separately from [`Service`] keeps
640/// HTTP-layer and service-layer concerns distinct and provides a natural home
641/// for future HTTP-level settings (e.g. timeouts, body size limits).
642///
643/// Used in: [`Config::http`]
644///
645/// # Environment Variables
646///
647/// - `OS__HTTP__MAX_REQUESTS`
648#[derive(Debug, Deserialize, Serialize)]
649#[serde(default)]
650pub struct Http {
651 /// Maximum number of concurrent in-flight HTTP requests.
652 ///
653 /// This is a flood protection limit. When the number of requests currently
654 /// being processed reaches this value, new requests are rejected immediately
655 /// with HTTP 503. Health and readiness endpoints (`/health`, `/ready`) are
656 /// excluded from this limit.
657 ///
658 /// Unlike readiness-based backpressure, direct rejection responds in
659 /// milliseconds and recovers the moment any in-flight request completes.
660 ///
661 /// # Default
662 ///
663 /// [`DEFAULT_MAX_HTTP_REQUESTS`]
664 ///
665 /// # Environment Variable
666 ///
667 /// `OS__HTTP__MAX_REQUESTS`
668 pub max_requests: usize,
669}
670
671impl Default for Http {
672 fn default() -> Self {
673 Self {
674 max_requests: DEFAULT_MAX_HTTP_REQUESTS,
675 }
676 }
677}
678
679impl Default for Config {
680 fn default() -> Self {
681 Self {
682 http_addr: "0.0.0.0:8888".parse().unwrap(),
683
684 storage: StorageConfig::FileSystem(FileSystemConfig {
685 path: PathBuf::from("data"),
686 }),
687
688 storage_cogs: None,
689 runtime: Runtime::default(),
690 logging: LoggingConfig::default(),
691 sentry: Sentry::default(),
692 metrics: objectstore_metrics::MetricsConfig::default(),
693 auth: AuthZ::default(),
694 killswitches: Killswitches::default(),
695 rate_limits: RateLimits::default(),
696 usecases: UseCases::default(),
697 service: Service::default(),
698 http: Http::default(),
699 }
700 }
701}
702
703impl Config {
704 /// Loads configuration from the provided arguments.
705 ///
706 /// Configuration is merged in the following order (later sources override earlier ones):
707 /// 1. Default values
708 /// 2. YAML configuration file (if provided in `args`)
709 /// 3. Environment variables (prefixed with `OS__`)
710 ///
711 /// Any value in the merged configuration may then be written as `${file:PATH}` or
712 /// `${VAR_NAME}` to have it replaced by that file's contents or that environment
713 /// variable — see [variable references](self#variable-references).
714 ///
715 /// # Errors
716 ///
717 /// Returns an error if:
718 /// - The YAML configuration file cannot be read or parsed
719 /// - Environment variables contain invalid values
720 /// - Required fields are missing or invalid
721 /// - A `${file:PATH}` reference names a file that cannot be read, or a `${VAR_NAME}`
722 /// reference names an environment variable that is not set
723 pub fn load(path: Option<&Path>) -> Result<Self> {
724 let mut figment = figment::Figment::from(Serialized::defaults(Config::default()));
725 if let Some(path) = path {
726 figment = figment.merge(Yaml::file(path));
727 }
728
729 // Merge first, then resolve variables against the merged value, so a reference is
730 // resolved wherever it came from and whichever layer won.
731 let merged: figment::value::Value = figment
732 .merge(Env::prefixed(ENV_PREFIX).split("__"))
733 .extract()?;
734
735 let base_path = path.and_then(Path::parent).unwrap_or(Path::new(""));
736
737 // The file source must come first: `${file:x}` also matches the environment
738 // source's `${` prefix, which would otherwise look up a variable named `file:x`.
739 let mut source = (
740 serde_vars::FileSource::new()
741 .with_variable_prefix("${file:")
742 .with_variable_suffix("}")
743 .with_base_path(base_path),
744 serde_vars::EnvSource::default()
745 .with_variable_prefix("${")
746 .with_variable_suffix("}"),
747 );
748 let config = serde_vars::deserialize(&merged, &mut source)?;
749
750 Ok(config)
751 }
752}
753
754#[cfg(test)]
755#[expect(
756 clippy::result_large_err,
757 reason = "figment::Error is inherently large"
758)]
759mod tests {
760 use std::io::Write;
761
762 use objectstore_service::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig};
763 use secrecy::ExposeSecret;
764
765 use crate::killswitches::Killswitch;
766 use crate::rate_limits::{BandwidthLimits, RateLimits, ThroughputLimits, ThroughputRule};
767
768 use super::*;
769
770 #[test]
771 fn configurable_via_env() {
772 figment::Jail::expect_with(|jail| {
773 jail.set_env("OS__STORAGE__TYPE", "s3compatible");
774 jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:8888");
775 jail.set_env("OS__STORAGE__BUCKET", "whatever");
776 jail.set_env("OS__METRICS__TAGS__FOO", "bar");
777 jail.set_env("OS__METRICS__TAGS__BAZ", "qux");
778 jail.set_env("OS__SENTRY__DSN", "abcde");
779 jail.set_env("OS__SENTRY__SAMPLE_RATE", "0.5");
780 jail.set_env("OS__SENTRY__ENVIRONMENT", "production");
781 jail.set_env("OS__SENTRY__SERVER_NAME", "objectstore-deadbeef");
782 jail.set_env("OS__SENTRY__TRACES_SAMPLE_RATE", "0.5");
783
784 let config = Config::load(None).unwrap();
785
786 let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
787 panic!("expected s3 storage");
788 };
789 assert_eq!(c.endpoint, "http://localhost:8888");
790 assert_eq!(c.bucket, "whatever");
791 assert_eq!(
792 config.metrics.tags,
793 [("foo".into(), "bar".into()), ("baz".into(), "qux".into())].into()
794 );
795
796 assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
797 assert_eq!(config.sentry.environment.as_deref(), Some("production"));
798 assert_eq!(
799 config.sentry.server_name.as_deref(),
800 Some("objectstore-deadbeef")
801 );
802 assert_eq!(config.sentry.sample_rate, 0.5);
803 assert_eq!(config.sentry.traces_sample_rate, 0.5);
804
805 Ok(())
806 });
807 }
808
809 #[test]
810 fn configurable_via_yaml() {
811 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
812 tempfile
813 .write_all(
814 br#"
815 storage:
816 type: s3compatible
817 endpoint: http://localhost:8888
818 bucket: whatever
819 sentry:
820 dsn: abcde
821 environment: production
822 server_name: objectstore-deadbeef
823 sample_rate: 0.5
824 traces_sample_rate: 0.5
825 "#,
826 )
827 .unwrap();
828
829 figment::Jail::expect_with(|_jail| {
830 let config = Config::load(Some(tempfile.path())).unwrap();
831
832 let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
833 panic!("expected s3 storage");
834 };
835 assert_eq!(c.endpoint, "http://localhost:8888");
836 assert_eq!(c.bucket, "whatever");
837
838 assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
839 assert_eq!(config.sentry.environment.as_deref(), Some("production"));
840 assert_eq!(
841 config.sentry.server_name.as_deref(),
842 Some("objectstore-deadbeef")
843 );
844 assert_eq!(config.sentry.sample_rate, 0.5);
845 assert_eq!(config.sentry.traces_sample_rate, 0.5);
846
847 Ok(())
848 });
849 }
850
851 #[test]
852 fn configured_with_env_and_yaml() {
853 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
854 tempfile
855 .write_all(
856 br#"
857 storage:
858 type: s3compatible
859 endpoint: http://localhost:8888
860 bucket: whatever
861 "#,
862 )
863 .unwrap();
864
865 figment::Jail::expect_with(|jail| {
866 jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:9001");
867
868 let config = Config::load(Some(tempfile.path())).unwrap();
869
870 let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
871 panic!("expected s3 storage");
872 };
873 // Env should overwrite the yaml config
874 assert_eq!(c.endpoint, "http://localhost:9001");
875
876 Ok(())
877 });
878 }
879
880 #[test]
881 fn tiered_storage_via_yaml() {
882 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
883 tempfile
884 .write_all(
885 br#"
886 storage:
887 type: tiered
888 high_volume:
889 type: bigtable
890 project_id: my-project
891 instance_name: objectstore
892 table_name: objectstore
893 long_term:
894 type: gcs
895 bucket: my-objectstore-bucket
896 "#,
897 )
898 .unwrap();
899
900 figment::Jail::expect_with(|_jail| {
901 let config = Config::load(Some(tempfile.path())).unwrap();
902
903 let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
904 panic!("expected tiered storage");
905 };
906 let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
907 assert_eq!(hv.project_id, "my-project");
908 let MultipartUploadStorageConfig::Gcs(lt) = &c.long_term else {
909 panic!("expected gcs long_term");
910 };
911 assert_eq!(lt.bucket, "my-objectstore-bucket");
912
913 Ok(())
914 });
915 }
916
917 #[test]
918 fn tiered_storage_via_env() {
919 figment::Jail::expect_with(|jail| {
920 jail.set_env("OS__STORAGE__TYPE", "tiered");
921 jail.set_env("OS__STORAGE__HIGH_VOLUME__TYPE", "bigtable");
922 jail.set_env("OS__STORAGE__HIGH_VOLUME__PROJECT_ID", "my-project");
923 jail.set_env("OS__STORAGE__HIGH_VOLUME__INSTANCE_NAME", "my-instance");
924 jail.set_env("OS__STORAGE__HIGH_VOLUME__TABLE_NAME", "my-table");
925 jail.set_env("OS__STORAGE__LONG_TERM__TYPE", "filesystem");
926 jail.set_env("OS__STORAGE__LONG_TERM__PATH", "/data/lt");
927
928 let config = Config::load(None).unwrap();
929
930 let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
931 panic!("expected tiered storage");
932 };
933 let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
934 assert_eq!(hv.project_id, "my-project");
935 assert_eq!(hv.instance_name, "my-instance");
936 assert_eq!(hv.table_name, "my-table");
937 let MultipartUploadStorageConfig::FileSystem(lt) = &c.long_term else {
938 panic!("expected filesystem long_term");
939 };
940 assert_eq!(lt.path, Path::new("/data/lt"));
941
942 Ok(())
943 });
944 }
945
946 #[test]
947 fn storage_cogs_via_env() {
948 figment::Jail::expect_with(|jail| {
949 jail.set_env("OS__STORAGE__TYPE", "bigtable");
950 jail.set_env("OS__STORAGE__PROJECT_ID", "my-project");
951 jail.set_env("OS__STORAGE__INSTANCE_NAME", "my-instance");
952 jail.set_env("OS__STORAGE__TABLE_NAME", "my-table");
953 jail.set_env(
954 "OS__STORAGE__COGS__SHARED_RESOURCE_ID",
955 "bigtable_objectstore",
956 );
957 jail.set_env("OS__STORAGE__COGS__SAMPLE_RATE", "0.5");
958 jail.set_env("OS__STORAGE_COGS__TYPE", "kafka");
959 jail.set_env("OS__STORAGE_COGS__TOPIC", "my-topic");
960 jail.set_env("OS__STORAGE_COGS__BOOTSTRAP_SERVERS", "[kafka:9092]");
961
962 let config = Config::load(None).unwrap();
963
964 let StorageConfig::BigTable(storage) = &dbg!(&config).storage else {
965 panic!("expected bigtable storage");
966 };
967 let stream = storage.cogs.as_ref().expect("change stream");
968 assert_eq!(stream.shared_resource_id, "bigtable_objectstore");
969 assert_eq!(stream.sample_rate, 0.5);
970
971 let CostTrackerConfig::Kafka(kafka) =
972 config.storage_cogs.as_ref().expect("kafka transport");
973 assert_eq!(kafka.topic, "my-topic");
974 assert_eq!(kafka.bootstrap_servers, ["kafka:9092"]);
975
976 Ok(())
977 });
978 }
979
980 #[test]
981 fn a_backend_stream_defaults_to_reporting_everything_and_no_transport() {
982 figment::Jail::expect_with(|jail| {
983 jail.set_env("OS__STORAGE__TYPE", "gcs");
984 jail.set_env("OS__STORAGE__BUCKET", "my-bucket");
985 jail.set_env("OS__STORAGE__COGS__SHARED_RESOURCE_ID", "gcs_objectstore");
986
987 let config = Config::load(None).unwrap();
988
989 let StorageConfig::Gcs(storage) = &dbg!(&config).storage else {
990 panic!("expected gcs storage");
991 };
992 let stream = storage.cogs.as_ref().expect("change stream");
993 assert_eq!(stream.sample_rate, 1.0, "reports everything by default");
994 assert!(
995 config.storage_cogs.is_none(),
996 "no transport is configured by default"
997 );
998
999 Ok(())
1000 });
1001 }
1002
1003 #[test]
1004 fn serde_var_yaml_references() {
1005 let secrets = tempfile::tempdir().unwrap();
1006 let absolute_secret = secrets.path().join("kafka-password");
1007 std::fs::write(&absolute_secret, "hunter2").unwrap();
1008
1009 let dir = tempfile::tempdir().unwrap();
1010 std::fs::write(dir.path().join("relative-password"), "hunter3").unwrap();
1011
1012 let config_path = dir.path().join("config.yml");
1013 std::fs::write(
1014 &config_path,
1015 format!(
1016 r#"
1017 storage_cogs:
1018 type: kafka
1019 override_params:
1020 sasl.mechanism: SCRAM-SHA-256
1021 not.a.reference: prod-${{NOT_A_VAR
1022 from.env: ${{KAFKA_SASL_PASSWORD}}
1023 from.relative.file: ${{file:relative-password}}
1024 from.absolute.file: ${{file:{}}}
1025 "#,
1026 absolute_secret.display()
1027 ),
1028 )
1029 .unwrap();
1030
1031 figment::Jail::expect_with(|jail| {
1032 jail.set_env("KAFKA_SASL_PASSWORD", "hunter1");
1033
1034 let config = Config::load(Some(&config_path)).unwrap();
1035
1036 let CostTrackerConfig::Kafka(sink) =
1037 config.storage_cogs.as_ref().expect("kafka transport");
1038 assert_eq!(sink.override_params["from.env"], "hunter1");
1039 assert_eq!(sink.override_params["from.relative.file"], "hunter3");
1040 assert_eq!(
1041 sink.override_params["from.absolute.file"], "hunter2",
1042 "an absolute path ignores the config directory"
1043 );
1044 assert_eq!(sink.override_params["sasl.mechanism"], "SCRAM-SHA-256");
1045 assert_eq!(
1046 sink.override_params["not.a.reference"], "prod-${NOT_A_VAR",
1047 "a value that is not a reference is left alone"
1048 );
1049
1050 Ok(())
1051 });
1052 }
1053
1054 #[test]
1055 fn serde_vars_yaml_reference_failure() {
1056 let dir = tempfile::tempdir().unwrap();
1057 for (name, reference) in [
1058 ("missing-file.yml", "${file:nope}"),
1059 ("unset-var.yml", "${FAKE_VAR}"),
1060 ] {
1061 let config_path = dir.path().join(name);
1062 std::fs::write(
1063 &config_path,
1064 format!(
1065 r#"
1066 storage_cogs:
1067 type: kafka
1068 override_params:
1069 sasl.password: {reference}
1070 "#
1071 ),
1072 )
1073 .unwrap();
1074
1075 figment::Jail::expect_with(|_jail| {
1076 assert!(Config::load(Some(&config_path)).is_err(), "{reference}");
1077 Ok(())
1078 });
1079 }
1080 }
1081
1082 #[test]
1083 fn serde_vars_env_reference() {
1084 figment::Jail::expect_with(|jail| {
1085 jail.set_env("SENTRY_DSN", "https://public@example.invalid/1");
1086 jail.set_env("OS__SENTRY__DSN", "${SENTRY_DSN}");
1087
1088 let config = Config::load(None).unwrap();
1089
1090 assert_eq!(
1091 config.sentry.dsn.unwrap().expose_secret().as_str(),
1092 "https://public@example.invalid/1"
1093 );
1094
1095 Ok(())
1096 });
1097 }
1098
1099 #[test]
1100 fn metrics_addr_via_env() {
1101 figment::Jail::expect_with(|jail| {
1102 jail.set_env("OS__METRICS__ADDR", "127.0.0.1:8125");
1103
1104 let config = Config::load(None).unwrap();
1105 assert_eq!(config.metrics.addr.as_deref(), Some("127.0.0.1:8125"));
1106
1107 Ok(())
1108 });
1109 }
1110
1111 #[test]
1112 fn configure_auth_with_env() {
1113 figment::Jail::expect_with(|jail| {
1114 jail.set_env("OS__AUTH__ENFORCE", "true");
1115 jail.set_env(
1116 "OS__AUTH__KEYS",
1117 r#"{kid1={key_files=["abcde","fghij","this is a test\n multiline string\nend of string\n"],max_permissions=["object.read", "object.write"],}, kid2={key_files=["12345"],}}"#,
1118 );
1119
1120 let config = Config::load(None).unwrap();
1121
1122 assert!(config.auth.enforce);
1123
1124 let kid1 = config.auth.keys.get("kid1").unwrap();
1125 assert_eq!(kid1.key_files[0], Path::new("abcde"));
1126 assert_eq!(kid1.key_files[1], Path::new("fghij"));
1127 assert_eq!(
1128 kid1.key_files[2],
1129 Path::new("this is a test\n multiline string\nend of string\n"),
1130 );
1131 assert_eq!(
1132 kid1.max_permissions,
1133 HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
1134 );
1135
1136 let kid2 = config.auth.keys.get("kid2").unwrap();
1137 assert_eq!(kid2.key_files[0], Path::new("12345"));
1138 assert_eq!(kid2.max_permissions, HashSet::new());
1139
1140 Ok(())
1141 });
1142 }
1143
1144 #[test]
1145 fn configure_auth_with_yaml() {
1146 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1147 tempfile
1148 .write_all(
1149 br#"
1150 auth:
1151 enforce: true
1152 keys:
1153 kid1:
1154 key_files:
1155 - "abcde"
1156 - "fghij"
1157 - |
1158 this is a test
1159 multiline string
1160 end of string
1161 max_permissions:
1162 - "object.read"
1163 - "object.write"
1164 kid2:
1165 key_files:
1166 - "12345"
1167 "#,
1168 )
1169 .unwrap();
1170
1171 figment::Jail::expect_with(|_jail| {
1172 let config = Config::load(Some(tempfile.path())).unwrap();
1173
1174 assert!(config.auth.enforce);
1175
1176 let kid1 = config.auth.keys.get("kid1").unwrap();
1177 assert_eq!(kid1.key_files[0], Path::new("abcde"));
1178 assert_eq!(kid1.key_files[1], Path::new("fghij"));
1179 assert_eq!(
1180 kid1.key_files[2],
1181 Path::new("this is a test\n multiline string\nend of string\n")
1182 );
1183 assert_eq!(
1184 kid1.max_permissions,
1185 HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
1186 );
1187
1188 let kid2 = config.auth.keys.get("kid2").unwrap();
1189 assert_eq!(kid2.key_files[0], Path::new("12345"));
1190 assert_eq!(kid2.max_permissions, HashSet::new());
1191
1192 Ok(())
1193 });
1194 }
1195
1196 #[test]
1197 fn auth_enforce_defaults_to_true() {
1198 figment::Jail::expect_with(|_jail| {
1199 let config = Config::load(None).unwrap();
1200 assert!(config.auth.enforce);
1201 Ok(())
1202 });
1203 }
1204
1205 #[test]
1206 fn auth_enforce_defaults_to_true_when_omitted_from_yaml() {
1207 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1208 tempfile
1209 .write_all(
1210 br#"
1211 auth:
1212 keys: {}
1213 "#,
1214 )
1215 .unwrap();
1216
1217 figment::Jail::expect_with(|_jail| {
1218 let config = Config::load(Some(tempfile.path())).unwrap();
1219 assert!(config.auth.enforce);
1220 Ok(())
1221 });
1222 }
1223
1224 #[test]
1225 fn auth_enforce_can_be_disabled() {
1226 figment::Jail::expect_with(|jail| {
1227 jail.set_env("OS__AUTH__ENFORCE", "false");
1228 let config = Config::load(None).unwrap();
1229 assert!(!config.auth.enforce);
1230 Ok(())
1231 });
1232 }
1233
1234 #[test]
1235 fn configure_killswitches_with_yaml() {
1236 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1237 tempfile
1238 .write_all(
1239 br#"
1240 killswitches:
1241 - usecase: broken_usecase
1242 - scopes:
1243 org: "42"
1244 - service: "test-*"
1245 - scopes:
1246 org: "42"
1247 project: "4711"
1248 - usecase: attachments
1249 scopes:
1250 org: "42"
1251 service: "test-*"
1252 "#,
1253 )
1254 .unwrap();
1255
1256 figment::Jail::expect_with(|_jail| {
1257 let expected = [
1258 Killswitch {
1259 usecase: Some("broken_usecase".into()),
1260 scopes: BTreeMap::new(),
1261 service: None,
1262 },
1263 Killswitch {
1264 usecase: None,
1265 scopes: BTreeMap::from([("org".into(), "42".into())]),
1266 service: None,
1267 },
1268 Killswitch {
1269 usecase: None,
1270 scopes: BTreeMap::new(),
1271 service: Some("test-*".into()),
1272 },
1273 Killswitch {
1274 usecase: None,
1275 scopes: BTreeMap::from([
1276 ("org".into(), "42".into()),
1277 ("project".into(), "4711".into()),
1278 ]),
1279 service: None,
1280 },
1281 Killswitch {
1282 usecase: Some("attachments".into()),
1283 scopes: BTreeMap::from([("org".into(), "42".into())]),
1284 service: Some("test-*".into()),
1285 },
1286 ];
1287
1288 let config = Config::load(Some(tempfile.path())).unwrap();
1289 assert_eq!(config.killswitches.as_slice(), &expected);
1290
1291 Ok(())
1292 });
1293 }
1294
1295 #[test]
1296 fn configure_rate_limits_with_yaml() {
1297 let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1298 tempfile
1299 .write_all(
1300 br#"
1301 rate_limits:
1302 throughput:
1303 global_rps: 1000
1304 burst: 100
1305 usecase_pct: 50
1306 scope_pct: 25
1307 rules:
1308 - usecase: "high_priority"
1309 scopes:
1310 - ["org", "123"]
1311 rps: 500
1312 - scopes:
1313 - ["org", "456"]
1314 - ["project", "789"]
1315 pct: 10
1316 bandwidth:
1317 global_bps: 1048576
1318 burst_ms: 2000
1319 usecase_pct: 50
1320 scope_pct: 25
1321 report_only: true
1322 "#,
1323 )
1324 .unwrap();
1325
1326 figment::Jail::expect_with(|_jail| {
1327 let expected = RateLimits {
1328 throughput: ThroughputLimits {
1329 global_rps: Some(1000),
1330 burst: 100,
1331 usecase_pct: Some(50),
1332 scope_pct: Some(25),
1333 rules: vec![
1334 ThroughputRule {
1335 usecase: Some("high_priority".to_string()),
1336 scopes: vec![("org".to_string(), "123".to_string())],
1337 rps: Some(500),
1338 pct: None,
1339 },
1340 ThroughputRule {
1341 usecase: None,
1342 scopes: vec![
1343 ("org".to_string(), "456".to_string()),
1344 ("project".to_string(), "789".to_string()),
1345 ],
1346 rps: None,
1347 pct: Some(10),
1348 },
1349 ],
1350 },
1351 bandwidth: BandwidthLimits {
1352 global_bps: Some(1_048_576),
1353 burst_ms: 2000,
1354 usecase_pct: Some(50),
1355 scope_pct: Some(25),
1356 report_only: true,
1357 },
1358 };
1359
1360 let config = Config::load(Some(tempfile.path())).unwrap();
1361 assert_eq!(config.rate_limits, expected);
1362
1363 Ok(())
1364 });
1365 }
1366}