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
34use std::borrow::Cow;
35use std::collections::{BTreeMap, HashSet};
36use std::fmt;
37use std::net::SocketAddr;
38use std::path::{Path, PathBuf};
39use std::time::Duration;
40
41use anyhow::Result;
42use figment::providers::{Env, Format, Serialized, Yaml};
43use objectstore_service::backend::local_fs::FileSystemConfig;
44use objectstore_types::auth::Permission;
45use secrecy::{CloneableSecret, SecretBox, SerializableSecret, zeroize::Zeroize};
46use serde::{Deserialize, Serialize};
47
48pub use objectstore_log::{LevelFilter, LogFormat, LoggingConfig};
49pub use objectstore_service::backend::{MultipartUploadStorageConfig, StorageConfig};
50
51use crate::killswitches::Killswitches;
52use crate::rate_limits::RateLimits;
53use crate::usecases::UseCases;
54
55/// Environment variable prefix for all configuration options.
56const ENV_PREFIX: &str = "OS__";
57
58/// Newtype around `String` that may protect against accidental
59/// logging of secrets in our configuration struct. Use with
60/// [`secrecy::SecretBox`].
61#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
62pub struct ConfigSecret(String);
63
64impl ConfigSecret {
65    /// Returns the secret value as a string slice.
66    pub fn as_str(&self) -> &str {
67        self.0.as_str()
68    }
69}
70
71impl From<&str> for ConfigSecret {
72    fn from(str: &str) -> Self {
73        ConfigSecret(str.to_string())
74    }
75}
76
77impl std::ops::Deref for ConfigSecret {
78    type Target = str;
79    fn deref(&self) -> &Self::Target {
80        &self.0
81    }
82}
83
84impl fmt::Debug for ConfigSecret {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
86        write!(f, "[redacted]")
87    }
88}
89
90impl CloneableSecret for ConfigSecret {}
91impl SerializableSecret for ConfigSecret {}
92impl Zeroize for ConfigSecret {
93    fn zeroize(&mut self) {
94        self.0.zeroize();
95    }
96}
97
98/// Runtime configuration for the Tokio async runtime.
99///
100/// Controls the threading behavior of the server's async runtime.
101///
102/// Used in: [`Config::runtime`]
103#[derive(Debug, Deserialize, Serialize)]
104#[serde(default)]
105pub struct Runtime {
106    /// Number of worker threads for the server runtime.
107    ///
108    /// This controls the size of the Tokio thread pool used to execute async tasks. More threads
109    /// can improve concurrency for CPU-bound workloads, but too many threads can increase context
110    /// switching overhead.
111    ///
112    /// Set this in accordance with the resources available to the server, especially in Kubernetes
113    /// environments.
114    ///
115    /// # Default
116    ///
117    /// Defaults to the number of CPU cores on the host machine.
118    ///
119    /// # Environment Variable
120    ///
121    /// `OS__RUNTIME__WORKER_THREADS`
122    ///
123    /// # Considerations
124    ///
125    /// - For I/O-bound workloads, the default (number of CPU cores) is usually sufficient
126    /// - For CPU-intensive workloads, consider matching or exceeding the number of cores
127    /// - Setting this too high can lead to increased memory usage and context switching
128    pub worker_threads: usize,
129
130    /// Interval in seconds for reporting internal runtime metrics.
131    ///
132    /// Defaults to `10` seconds.
133    #[serde(with = "humantime_serde")]
134    pub metrics_interval: Duration,
135}
136
137impl Default for Runtime {
138    fn default() -> Self {
139        Self {
140            worker_threads: num_cpus::get(),
141            metrics_interval: Duration::from_secs(10),
142        }
143    }
144}
145
146/// [Sentry](https://sentry.io/) error tracking and performance monitoring configuration.
147///
148/// Configures integration with Sentry for error tracking, performance monitoring, and distributed
149/// tracing. Sentry is disabled by default and only enabled when a DSN is provided.
150///
151/// Used in: [`Config::sentry`]
152#[derive(Debug, Deserialize, Serialize)]
153pub struct Sentry {
154    /// Sentry DSN (Data Source Name).
155    ///
156    /// When set, enables Sentry error tracking and performance monitoring. When `None`, Sentry
157    /// integration is completely disabled.
158    ///
159    /// # Default
160    ///
161    /// `None` (Sentry disabled)
162    ///
163    /// # Environment Variable
164    ///
165    /// `OS__SENTRY__DSN`
166    pub dsn: Option<SecretBox<ConfigSecret>>,
167
168    /// Environment name for this deployment.
169    ///
170    /// Used to distinguish events from different environments (e.g., "production", "staging",
171    /// "development"). This appears in the Sentry UI and can be used for filtering.
172    ///
173    /// # Default
174    ///
175    /// `None`
176    ///
177    /// # Environment Variable
178    ///
179    /// `OS__SENTRY__ENVIRONMENT`
180    pub environment: Option<Cow<'static, str>>,
181
182    /// Server name or identifier.
183    ///
184    /// Used to identify which server instance sent an event. Useful in multi-server deployments for
185    /// tracking which instance encountered an error. Set to the hostname or pod name of the server.
186    ///
187    /// # Default
188    ///
189    /// `None`
190    ///
191    /// # Environment Variable
192    ///
193    /// `OS__SENTRY__SERVER_NAME`
194    pub server_name: Option<Cow<'static, str>>,
195
196    /// Error event sampling rate.
197    ///
198    /// Controls what percentage of error events are sent to Sentry. A value of `1.0` sends all
199    /// errors, while `0.5` sends 50% of errors, and `0.0` sends no errors.
200    ///
201    /// # Default
202    ///
203    /// `1.0` (send all errors)
204    ///
205    /// # Environment Variable
206    ///
207    /// `OS__SENTRY__SAMPLE_RATE`
208    pub sample_rate: f32,
209
210    /// Performance trace sampling rate.
211    ///
212    /// Controls what percentage of transactions (traces) are sent to Sentry for performance
213    /// monitoring. A value of `1.0` sends all traces, while `0.01` sends 1% of traces.
214    ///
215    /// **Important**: Performance traces can generate significant data volume in high-traffic
216    /// systems. Start with a low rate (0.01-0.1) and adjust based on traffic and Sentry quota.
217    ///
218    /// # Default
219    ///
220    /// `0.01` (send 1% of traces)
221    ///
222    /// # Environment Variable
223    ///
224    /// `OS__SENTRY__TRACES_SAMPLE_RATE`
225    pub traces_sample_rate: f32,
226
227    /// Whether to inherit sampling decisions from incoming traces.
228    ///
229    /// When `true` (default), if an incoming request contains a distributed tracing header with a
230    /// sampling decision (e.g., from an upstream service), that decision is honored. When `false`,
231    /// the local `traces_sample_rate` is always used instead.
232    ///
233    /// When this is enabled, the calling service effectively controls the sampling decision for the
234    /// entire trace. Set this to `false` if you want to have independent sampling control at the
235    /// objectstore level.
236    ///
237    /// # Default
238    ///
239    /// `true`
240    ///
241    /// # Environment Variable
242    ///
243    /// `OS__SENTRY__INHERIT_SAMPLING_DECISION`
244    pub inherit_sampling_decision: bool,
245
246    /// Enable Sentry SDK debug mode.
247    ///
248    /// When enabled, the Sentry SDK will output debug information to stderr, which can be useful
249    /// for troubleshooting Sentry integration issues. It is discouraged to enable this in
250    /// production as it generates verbose logging.
251    ///
252    /// # Default
253    ///
254    /// `false`
255    ///
256    /// # Environment Variable
257    ///
258    /// `OS__SENTRY__DEBUG`
259    pub debug: bool,
260
261    /// Additional tags to attach to all Sentry events.
262    ///
263    /// Key-value pairs that are sent as tags with every event reported to Sentry. Useful for adding
264    /// context such as deployment identifiers or environment details.
265    ///
266    /// # Default
267    ///
268    /// Empty (no tags)
269    ///
270    /// # Environment Variables
271    ///
272    /// Each tag is set individually:
273    /// - `OS__SENTRY__TAGS__FOO=foo`
274    /// - `OS__SENTRY__TAGS__BAR=bar`
275    ///
276    /// # YAML Example
277    ///
278    /// ```yaml
279    /// sentry:
280    ///   tags:
281    ///     foo: foo
282    ///     bar: bar
283    /// ```
284    pub tags: BTreeMap<String, String>,
285}
286
287impl Sentry {
288    /// Returns whether Sentry integration is enabled.
289    ///
290    /// Sentry is considered enabled if a DSN is configured.
291    pub fn is_enabled(&self) -> bool {
292        self.dsn.is_some()
293    }
294}
295
296impl Default for Sentry {
297    fn default() -> Self {
298        Self {
299            dsn: None,
300            environment: None,
301            server_name: None,
302            sample_rate: 1.0,
303            traces_sample_rate: 0.01,
304            inherit_sampling_decision: true,
305            debug: false,
306            tags: BTreeMap::new(),
307        }
308    }
309}
310
311// Logging configuration is defined in `objectstore_log::LoggingConfig`.
312// Metrics configuration is defined in `objectstore_metrics::MetricsConfig`.
313
314/// A key that may be used to verify a request's auth token and its associated
315/// permissions. May contain multiple key versions to facilitate rotation.
316#[derive(Debug, Deserialize, Serialize)]
317pub struct AuthZVerificationKey {
318    /// Files that contain versions of this key's key material which may be used to verify
319    /// signatures.
320    ///
321    /// If a key is being rotated, the old and new versions of that key should both be
322    /// configured so objectstore can verify signatures while the updated key is still
323    /// rolling out. Otherwise, this should only contain the most recent version of a key.
324    pub key_files: Vec<PathBuf>,
325
326    /// The maximum set of permissions that this key's signer is authorized to grant.
327    ///
328    /// If a request's auth token grants full permission but it was signed by a key
329    /// that is only allowed to grant read permission, then the request only has
330    /// read permission.
331    #[serde(default)]
332    pub max_permissions: HashSet<Permission>,
333}
334
335/// Configuration for content-based authorization.
336#[derive(Debug, Deserialize, Serialize)]
337pub struct AuthZ {
338    /// Whether to enforce content-based authorization or not.
339    ///
340    /// Defaults to `true`, resulting in `403 Unauthorized` responses for unauthorized requests. Set
341    /// to `false` to permit unauthorized requests. Authorization checks are still performed if
342    /// keys are configured, but only result in warnings.
343    #[serde(default = "default_enforce")]
344    pub enforce: bool,
345
346    /// Keys that may be used to verify a request's auth token.
347    ///
348    /// The auth token is read from the `X-Os-Auth` header (preferred)
349    /// or the standard `Authorization` header (fallback). This field is a
350    /// container keyed on a key's ID. When verifying a JWT, the `kid` field
351    /// should be read from the JWT header and used to index into this map to
352    /// select the appropriate key.
353    #[serde(default)]
354    pub keys: BTreeMap<String, AuthZVerificationKey>,
355}
356
357fn default_enforce() -> bool {
358    true
359}
360
361impl AuthZ {
362    /// Returns whether content-based authorization is active.
363    ///
364    /// Authorization is considered active if enforcement is enabled or at least one key is
365    /// configured. Without enforcement, authorization checks are still performed and reported but
366    /// failures will not result in `403 Unauthorized`
367    pub fn is_active(&self) -> bool {
368        self.enforce || !self.keys.is_empty()
369    }
370}
371
372impl Default for AuthZ {
373    fn default() -> Self {
374        Self {
375            enforce: true,
376            keys: BTreeMap::new(),
377        }
378    }
379}
380
381/// Main configuration struct for the objectstore server.
382///
383/// This is the top-level configuration that combines all server settings including networking,
384/// storage backends, runtime, and observability options.
385///
386/// Configuration is loaded with the following precedence (highest to lowest):
387/// 1. Environment variables (prefixed with `OS__`)
388/// 2. YAML configuration file (if provided via `-c` flag)
389/// 3. Default values
390///
391/// See individual field documentation for details on each configuration option, including
392/// defaults and environment variables.
393#[derive(Debug, Deserialize, Serialize)]
394pub struct Config {
395    /// HTTP server bind address.
396    ///
397    /// The socket address (IP and port) where the HTTP server will listen for incoming
398    /// connections. Supports both IPv4 and IPv6 addresses. Note that binding to `0.0.0.0`
399    /// makes the server accessible from all network interfaces.
400    ///
401    /// # Default
402    ///
403    /// `0.0.0.0:8888` (listens on all network interfaces, port 8888)
404    ///
405    /// # Environment Variable
406    ///
407    /// `OS__HTTP_ADDR`
408    pub http_addr: SocketAddr,
409
410    /// Storage backend configuration.
411    ///
412    /// Configures the storage backend used by the server. Use `type: "filesystem"` for
413    /// development, `type: "tiered"` for production two-tier routing (small objects to a
414    /// high-volume backend, large objects to a long-term backend), or any other single backend
415    /// type for simple deployments.
416    ///
417    /// # Default
418    ///
419    /// Filesystem storage in the `./data` directory
420    ///
421    /// # Environment Variables
422    ///
423    /// - `OS__STORAGE__TYPE` — backend type (`filesystem`, `tiered`, `gcs`, `bigtable`,
424    ///   `s3compatible`)
425    /// - Additional fields depending on the type (see [`StorageConfig`])
426    ///
427    /// For tiered storage, sub-backend fields are nested under `high_volume` and `long_term`:
428    /// - `OS__STORAGE__TYPE=tiered`
429    /// - `OS__STORAGE__HIGH_VOLUME__TYPE=bigtable`
430    /// - `OS__STORAGE__LONG_TERM__TYPE=gcs`
431    ///
432    /// # Example (tiered)
433    ///
434    /// ```yaml
435    /// storage:
436    ///   type: tiered
437    ///   high_volume:
438    ///     type: bigtable
439    ///     project_id: my-project
440    ///     instance_name: objectstore
441    ///     table_name: objectstore
442    ///   long_term:
443    ///     type: gcs
444    ///     bucket: my-objectstore-bucket
445    /// ```
446    pub storage: StorageConfig,
447
448    /// Configuration of the internal task runtime.
449    ///
450    /// Controls the thread pool size and behavior of the async runtime powering the server.
451    /// See [`Runtime`] for configuration options.
452    pub runtime: Runtime,
453
454    /// Logging configuration.
455    ///
456    /// Controls log verbosity and output format. See [`LoggingConfig`] for configuration options.
457    pub logging: LoggingConfig,
458
459    /// Sentry error tracking configuration.
460    ///
461    /// Optional integration with Sentry for error tracking and performance monitoring.
462    /// See [`Sentry`] for configuration options.
463    pub sentry: Sentry,
464
465    /// Internal metrics configuration.
466    ///
467    /// Configures submission of internal metrics to a DogStatsD-compatible endpoint.
468    /// See [`objectstore_metrics::MetricsConfig`] for configuration options.
469    pub metrics: objectstore_metrics::MetricsConfig,
470
471    /// Content-based authorization configuration.
472    ///
473    /// Controls the verification and enforcement of content-based access control based on the
474    /// JWT in a request's `X-Os-Auth` or `Authorization` header.
475    pub auth: AuthZ,
476
477    /// A list of matchers for requests to discard without processing.
478    pub killswitches: Killswitches,
479
480    /// Definitions for rate limits to enforce on incoming requests.
481    pub rate_limits: RateLimits,
482
483    /// Per-use-case configuration.
484    ///
485    /// Controls properties of individual use cases such as which expiration
486    /// policies are permitted and their maximum durations. Use cases not
487    /// present in the map receive default configuration (all policies allowed,
488    /// no duration caps).
489    pub usecases: UseCases,
490
491    /// Configuration for the [`StorageService`](objectstore_service::StorageService).
492    pub service: Service,
493
494    /// Configuration for the HTTP layer.
495    ///
496    /// Controls HTTP-level settings that operate before requests reach the
497    /// storage service. See [`Http`] for configuration options.
498    pub http: Http,
499}
500
501/// Configuration for the [`StorageService`](objectstore_service::StorageService).
502///
503/// Controls operational parameters of the storage service layer that sits
504/// between the HTTP server and the storage backends.
505///
506/// Used in: [`Config::service`]
507///
508/// # Environment Variables
509///
510/// - `OS__SERVICE__MAX_CONCURRENCY`
511#[derive(Debug, Deserialize, Serialize)]
512#[serde(default)]
513pub struct Service {
514    /// Maximum number of concurrent backend operations.
515    ///
516    /// This caps the total number of in-flight storage operations (reads,
517    /// writes, deletes) across all requests. Operations that exceed the limit
518    /// are rejected with HTTP 429.
519    ///
520    /// # Default
521    ///
522    /// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
523    pub max_concurrency: u32,
524}
525
526impl Default for Service {
527    fn default() -> Self {
528        Self {
529            max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
530        }
531    }
532}
533
534/// Default maximum number of concurrent in-flight HTTP requests.
535///
536/// Requests beyond this limit are rejected with HTTP 503.
537pub const DEFAULT_MAX_HTTP_REQUESTS: usize = 10_000;
538
539/// Configuration for the HTTP layer.
540///
541/// Controls behaviour at the HTTP request level, before requests reach the
542/// storage service. Grouping these settings separately from [`Service`] keeps
543/// HTTP-layer and service-layer concerns distinct and provides a natural home
544/// for future HTTP-level settings (e.g. timeouts, body size limits).
545///
546/// Used in: [`Config::http`]
547///
548/// # Environment Variables
549///
550/// - `OS__HTTP__MAX_REQUESTS`
551#[derive(Debug, Deserialize, Serialize)]
552#[serde(default)]
553pub struct Http {
554    /// Maximum number of concurrent in-flight HTTP requests.
555    ///
556    /// This is a flood protection limit. When the number of requests currently
557    /// being processed reaches this value, new requests are rejected immediately
558    /// with HTTP 503. Health and readiness endpoints (`/health`, `/ready`) are
559    /// excluded from this limit.
560    ///
561    /// Unlike readiness-based backpressure, direct rejection responds in
562    /// milliseconds and recovers the moment any in-flight request completes.
563    ///
564    /// # Default
565    ///
566    /// [`DEFAULT_MAX_HTTP_REQUESTS`]
567    ///
568    /// # Environment Variable
569    ///
570    /// `OS__HTTP__MAX_REQUESTS`
571    pub max_requests: usize,
572}
573
574impl Default for Http {
575    fn default() -> Self {
576        Self {
577            max_requests: DEFAULT_MAX_HTTP_REQUESTS,
578        }
579    }
580}
581
582impl Default for Config {
583    fn default() -> Self {
584        Self {
585            http_addr: "0.0.0.0:8888".parse().unwrap(),
586
587            storage: StorageConfig::FileSystem(FileSystemConfig {
588                path: PathBuf::from("data"),
589            }),
590
591            runtime: Runtime::default(),
592            logging: LoggingConfig::default(),
593            sentry: Sentry::default(),
594            metrics: objectstore_metrics::MetricsConfig::default(),
595            auth: AuthZ::default(),
596            killswitches: Killswitches::default(),
597            rate_limits: RateLimits::default(),
598            usecases: UseCases::default(),
599            service: Service::default(),
600            http: Http::default(),
601        }
602    }
603}
604
605impl Config {
606    /// Loads configuration from the provided arguments.
607    ///
608    /// Configuration is merged in the following order (later sources override earlier ones):
609    /// 1. Default values
610    /// 2. YAML configuration file (if provided in `args`)
611    /// 3. Environment variables (prefixed with `OS__`)
612    ///
613    /// # Errors
614    ///
615    /// Returns an error if:
616    /// - The YAML configuration file cannot be read or parsed
617    /// - Environment variables contain invalid values
618    /// - Required fields are missing or invalid
619    pub fn load(path: Option<&Path>) -> Result<Self> {
620        let mut figment = figment::Figment::from(Serialized::defaults(Config::default()));
621        if let Some(path) = path {
622            figment = figment.merge(Yaml::file(path));
623        }
624        let config = figment
625            .merge(Env::prefixed(ENV_PREFIX).split("__"))
626            .extract()?;
627
628        Ok(config)
629    }
630}
631
632#[cfg(test)]
633#[expect(
634    clippy::result_large_err,
635    reason = "figment::Error is inherently large"
636)]
637mod tests {
638    use std::io::Write;
639
640    use objectstore_service::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig};
641    use secrecy::ExposeSecret;
642
643    use crate::killswitches::Killswitch;
644    use crate::rate_limits::{BandwidthLimits, RateLimits, ThroughputLimits, ThroughputRule};
645
646    use super::*;
647
648    #[test]
649    fn configurable_via_env() {
650        figment::Jail::expect_with(|jail| {
651            jail.set_env("OS__STORAGE__TYPE", "s3compatible");
652            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:8888");
653            jail.set_env("OS__STORAGE__BUCKET", "whatever");
654            jail.set_env("OS__METRICS__TAGS__FOO", "bar");
655            jail.set_env("OS__METRICS__TAGS__BAZ", "qux");
656            jail.set_env("OS__SENTRY__DSN", "abcde");
657            jail.set_env("OS__SENTRY__SAMPLE_RATE", "0.5");
658            jail.set_env("OS__SENTRY__ENVIRONMENT", "production");
659            jail.set_env("OS__SENTRY__SERVER_NAME", "objectstore-deadbeef");
660            jail.set_env("OS__SENTRY__TRACES_SAMPLE_RATE", "0.5");
661
662            let config = Config::load(None).unwrap();
663
664            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
665                panic!("expected s3 storage");
666            };
667            assert_eq!(c.endpoint, "http://localhost:8888");
668            assert_eq!(c.bucket, "whatever");
669            assert_eq!(
670                config.metrics.tags,
671                [("foo".into(), "bar".into()), ("baz".into(), "qux".into())].into()
672            );
673
674            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
675            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
676            assert_eq!(
677                config.sentry.server_name.as_deref(),
678                Some("objectstore-deadbeef")
679            );
680            assert_eq!(config.sentry.sample_rate, 0.5);
681            assert_eq!(config.sentry.traces_sample_rate, 0.5);
682
683            Ok(())
684        });
685    }
686
687    #[test]
688    fn configurable_via_yaml() {
689        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
690        tempfile
691            .write_all(
692                br#"
693            storage:
694                type: s3compatible
695                endpoint: http://localhost:8888
696                bucket: whatever
697            sentry:
698                dsn: abcde
699                environment: production
700                server_name: objectstore-deadbeef
701                sample_rate: 0.5
702                traces_sample_rate: 0.5
703            "#,
704            )
705            .unwrap();
706
707        figment::Jail::expect_with(|_jail| {
708            let config = Config::load(Some(tempfile.path())).unwrap();
709
710            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
711                panic!("expected s3 storage");
712            };
713            assert_eq!(c.endpoint, "http://localhost:8888");
714            assert_eq!(c.bucket, "whatever");
715
716            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
717            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
718            assert_eq!(
719                config.sentry.server_name.as_deref(),
720                Some("objectstore-deadbeef")
721            );
722            assert_eq!(config.sentry.sample_rate, 0.5);
723            assert_eq!(config.sentry.traces_sample_rate, 0.5);
724
725            Ok(())
726        });
727    }
728
729    #[test]
730    fn configured_with_env_and_yaml() {
731        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
732        tempfile
733            .write_all(
734                br#"
735            storage:
736                type: s3compatible
737                endpoint: http://localhost:8888
738                bucket: whatever
739            "#,
740            )
741            .unwrap();
742
743        figment::Jail::expect_with(|jail| {
744            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:9001");
745
746            let config = Config::load(Some(tempfile.path())).unwrap();
747
748            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
749                panic!("expected s3 storage");
750            };
751            // Env should overwrite the yaml config
752            assert_eq!(c.endpoint, "http://localhost:9001");
753
754            Ok(())
755        });
756    }
757
758    #[test]
759    fn tiered_storage_via_yaml() {
760        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
761        tempfile
762            .write_all(
763                br#"
764            storage:
765                type: tiered
766                high_volume:
767                    type: bigtable
768                    project_id: my-project
769                    instance_name: objectstore
770                    table_name: objectstore
771                long_term:
772                    type: gcs
773                    bucket: my-objectstore-bucket
774            "#,
775            )
776            .unwrap();
777
778        figment::Jail::expect_with(|_jail| {
779            let config = Config::load(Some(tempfile.path())).unwrap();
780
781            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
782                panic!("expected tiered storage");
783            };
784            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
785            assert_eq!(hv.project_id, "my-project");
786            let MultipartUploadStorageConfig::Gcs(lt) = &c.long_term else {
787                panic!("expected gcs long_term");
788            };
789            assert_eq!(lt.bucket, "my-objectstore-bucket");
790
791            Ok(())
792        });
793    }
794
795    #[test]
796    fn tiered_storage_via_env() {
797        figment::Jail::expect_with(|jail| {
798            jail.set_env("OS__STORAGE__TYPE", "tiered");
799            jail.set_env("OS__STORAGE__HIGH_VOLUME__TYPE", "bigtable");
800            jail.set_env("OS__STORAGE__HIGH_VOLUME__PROJECT_ID", "my-project");
801            jail.set_env("OS__STORAGE__HIGH_VOLUME__INSTANCE_NAME", "my-instance");
802            jail.set_env("OS__STORAGE__HIGH_VOLUME__TABLE_NAME", "my-table");
803            jail.set_env("OS__STORAGE__LONG_TERM__TYPE", "filesystem");
804            jail.set_env("OS__STORAGE__LONG_TERM__PATH", "/data/lt");
805
806            let config = Config::load(None).unwrap();
807
808            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
809                panic!("expected tiered storage");
810            };
811            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
812            assert_eq!(hv.project_id, "my-project");
813            assert_eq!(hv.instance_name, "my-instance");
814            assert_eq!(hv.table_name, "my-table");
815            let MultipartUploadStorageConfig::FileSystem(lt) = &c.long_term else {
816                panic!("expected filesystem long_term");
817            };
818            assert_eq!(lt.path, Path::new("/data/lt"));
819
820            Ok(())
821        });
822    }
823
824    #[test]
825    fn metrics_addr_via_env() {
826        figment::Jail::expect_with(|jail| {
827            jail.set_env("OS__METRICS__ADDR", "127.0.0.1:8125");
828
829            let config = Config::load(None).unwrap();
830            assert_eq!(config.metrics.addr.as_deref(), Some("127.0.0.1:8125"));
831
832            Ok(())
833        });
834    }
835
836    #[test]
837    fn configure_auth_with_env() {
838        figment::Jail::expect_with(|jail| {
839            jail.set_env("OS__AUTH__ENFORCE", "true");
840            jail.set_env(
841                "OS__AUTH__KEYS",
842                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"],}}"#,
843            );
844
845            let config = Config::load(None).unwrap();
846
847            assert!(config.auth.enforce);
848
849            let kid1 = config.auth.keys.get("kid1").unwrap();
850            assert_eq!(kid1.key_files[0], Path::new("abcde"));
851            assert_eq!(kid1.key_files[1], Path::new("fghij"));
852            assert_eq!(
853                kid1.key_files[2],
854                Path::new("this is a test\n  multiline string\nend of string\n"),
855            );
856            assert_eq!(
857                kid1.max_permissions,
858                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
859            );
860
861            let kid2 = config.auth.keys.get("kid2").unwrap();
862            assert_eq!(kid2.key_files[0], Path::new("12345"));
863            assert_eq!(kid2.max_permissions, HashSet::new());
864
865            Ok(())
866        });
867    }
868
869    #[test]
870    fn configure_auth_with_yaml() {
871        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
872        tempfile
873            .write_all(
874                br#"
875                auth:
876                    enforce: true
877                    keys:
878                        kid1:
879                            key_files:
880                                - "abcde"
881                                - "fghij"
882                                - |
883                                  this is a test
884                                    multiline string
885                                  end of string
886                            max_permissions:
887                                - "object.read"
888                                - "object.write"
889                        kid2:
890                            key_files:
891                                - "12345"
892            "#,
893            )
894            .unwrap();
895
896        figment::Jail::expect_with(|_jail| {
897            let config = Config::load(Some(tempfile.path())).unwrap();
898
899            assert!(config.auth.enforce);
900
901            let kid1 = config.auth.keys.get("kid1").unwrap();
902            assert_eq!(kid1.key_files[0], Path::new("abcde"));
903            assert_eq!(kid1.key_files[1], Path::new("fghij"));
904            assert_eq!(
905                kid1.key_files[2],
906                Path::new("this is a test\n  multiline string\nend of string\n")
907            );
908            assert_eq!(
909                kid1.max_permissions,
910                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
911            );
912
913            let kid2 = config.auth.keys.get("kid2").unwrap();
914            assert_eq!(kid2.key_files[0], Path::new("12345"));
915            assert_eq!(kid2.max_permissions, HashSet::new());
916
917            Ok(())
918        });
919    }
920
921    #[test]
922    fn auth_enforce_defaults_to_true() {
923        figment::Jail::expect_with(|_jail| {
924            let config = Config::load(None).unwrap();
925            assert!(config.auth.enforce);
926            Ok(())
927        });
928    }
929
930    #[test]
931    fn auth_enforce_defaults_to_true_when_omitted_from_yaml() {
932        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
933        tempfile
934            .write_all(
935                br#"
936                auth:
937                    keys: {}
938            "#,
939            )
940            .unwrap();
941
942        figment::Jail::expect_with(|_jail| {
943            let config = Config::load(Some(tempfile.path())).unwrap();
944            assert!(config.auth.enforce);
945            Ok(())
946        });
947    }
948
949    #[test]
950    fn auth_enforce_can_be_disabled() {
951        figment::Jail::expect_with(|jail| {
952            jail.set_env("OS__AUTH__ENFORCE", "false");
953            let config = Config::load(None).unwrap();
954            assert!(!config.auth.enforce);
955            Ok(())
956        });
957    }
958
959    #[test]
960    fn configure_killswitches_with_yaml() {
961        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
962        tempfile
963            .write_all(
964                br#"
965                killswitches:
966                  - usecase: broken_usecase
967                  - scopes:
968                      org: "42"
969                  - service: "test-*"
970                  - scopes:
971                      org: "42"
972                      project: "4711"
973                  - usecase: attachments
974                    scopes:
975                      org: "42"
976                    service: "test-*"
977                "#,
978            )
979            .unwrap();
980
981        figment::Jail::expect_with(|_jail| {
982            let expected = [
983                Killswitch {
984                    usecase: Some("broken_usecase".into()),
985                    scopes: BTreeMap::new(),
986                    service: None,
987                },
988                Killswitch {
989                    usecase: None,
990                    scopes: BTreeMap::from([("org".into(), "42".into())]),
991                    service: None,
992                },
993                Killswitch {
994                    usecase: None,
995                    scopes: BTreeMap::new(),
996                    service: Some("test-*".into()),
997                },
998                Killswitch {
999                    usecase: None,
1000                    scopes: BTreeMap::from([
1001                        ("org".into(), "42".into()),
1002                        ("project".into(), "4711".into()),
1003                    ]),
1004                    service: None,
1005                },
1006                Killswitch {
1007                    usecase: Some("attachments".into()),
1008                    scopes: BTreeMap::from([("org".into(), "42".into())]),
1009                    service: Some("test-*".into()),
1010                },
1011            ];
1012
1013            let config = Config::load(Some(tempfile.path())).unwrap();
1014            assert_eq!(config.killswitches.as_slice(), &expected);
1015
1016            Ok(())
1017        });
1018    }
1019
1020    #[test]
1021    fn configure_rate_limits_with_yaml() {
1022        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1023        tempfile
1024            .write_all(
1025                br#"
1026                rate_limits:
1027                  throughput:
1028                    global_rps: 1000
1029                    burst: 100
1030                    usecase_pct: 50
1031                    scope_pct: 25
1032                    rules:
1033                      - usecase: "high_priority"
1034                        scopes:
1035                          - ["org", "123"]
1036                        rps: 500
1037                      - scopes:
1038                          - ["org", "456"]
1039                          - ["project", "789"]
1040                        pct: 10
1041                  bandwidth:
1042                    global_bps: 1048576
1043                    burst_ms: 2000
1044                    usecase_pct: 50
1045                    scope_pct: 25
1046                    report_only: true
1047                "#,
1048            )
1049            .unwrap();
1050
1051        figment::Jail::expect_with(|_jail| {
1052            let expected = RateLimits {
1053                throughput: ThroughputLimits {
1054                    global_rps: Some(1000),
1055                    burst: 100,
1056                    usecase_pct: Some(50),
1057                    scope_pct: Some(25),
1058                    rules: vec![
1059                        ThroughputRule {
1060                            usecase: Some("high_priority".to_string()),
1061                            scopes: vec![("org".to_string(), "123".to_string())],
1062                            rps: Some(500),
1063                            pct: None,
1064                        },
1065                        ThroughputRule {
1066                            usecase: None,
1067                            scopes: vec![
1068                                ("org".to_string(), "456".to_string()),
1069                                ("project".to_string(), "789".to_string()),
1070                            ],
1071                            rps: None,
1072                            pct: Some(10),
1073                        },
1074                    ],
1075                },
1076                bandwidth: BandwidthLimits {
1077                    global_bps: Some(1_048_576),
1078                    burst_ms: 2000,
1079                    usecase_pct: Some(50),
1080                    scope_pct: Some(25),
1081                    report_only: true,
1082                },
1083            };
1084
1085            let config = Config::load(Some(tempfile.path())).unwrap();
1086            assert_eq!(config.rate_limits, expected);
1087
1088            Ok(())
1089        });
1090    }
1091}