Skip to main content

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}