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::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)]
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`. If this is set to `false`, checks are still performed but failures
341    /// will not result in `403 Unauthorized` responses.
342    #[serde(default = "default_enforce")]
343    pub enforce: bool,
344
345    /// Keys that may be used to verify a request's auth token.
346    ///
347    /// The auth token is read from the `X-Os-Auth` header (preferred)
348    /// or the standard `Authorization` header (fallback). This field is a
349    /// container keyed on a key's ID. When verifying a JWT, the `kid` field
350    /// should be read from the JWT header and used to index into this map to
351    /// select the appropriate key.
352    #[serde(default)]
353    pub keys: BTreeMap<String, AuthZVerificationKey>,
354}
355
356fn default_enforce() -> bool {
357    true
358}
359
360impl Default for AuthZ {
361    fn default() -> Self {
362        Self {
363            enforce: true,
364            keys: BTreeMap::new(),
365        }
366    }
367}
368
369/// Main configuration struct for the objectstore server.
370///
371/// This is the top-level configuration that combines all server settings including networking,
372/// storage backends, runtime, and observability options.
373///
374/// Configuration is loaded with the following precedence (highest to lowest):
375/// 1. Environment variables (prefixed with `OS__`)
376/// 2. YAML configuration file (if provided via `-c` flag)
377/// 3. Default values
378///
379/// See individual field documentation for details on each configuration option, including
380/// defaults and environment variables.
381#[derive(Debug, Deserialize, Serialize)]
382pub struct Config {
383    /// HTTP server bind address.
384    ///
385    /// The socket address (IP and port) where the HTTP server will listen for incoming
386    /// connections. Supports both IPv4 and IPv6 addresses. Note that binding to `0.0.0.0`
387    /// makes the server accessible from all network interfaces.
388    ///
389    /// # Default
390    ///
391    /// `0.0.0.0:8888` (listens on all network interfaces, port 8888)
392    ///
393    /// # Environment Variable
394    ///
395    /// `OS__HTTP_ADDR`
396    pub http_addr: SocketAddr,
397
398    /// Storage backend configuration.
399    ///
400    /// Configures the storage backend used by the server. Use `type: "filesystem"` for
401    /// development, `type: "tiered"` for production two-tier routing (small objects to a
402    /// high-volume backend, large objects to a long-term backend), or any other single backend
403    /// type for simple deployments.
404    ///
405    /// # Default
406    ///
407    /// Filesystem storage in the `./data` directory
408    ///
409    /// # Environment Variables
410    ///
411    /// - `OS__STORAGE__TYPE` — backend type (`filesystem`, `tiered`, `gcs`, `bigtable`,
412    ///   `s3compatible`)
413    /// - Additional fields depending on the type (see [`StorageConfig`])
414    ///
415    /// For tiered storage, sub-backend fields are nested under `high_volume` and `long_term`:
416    /// - `OS__STORAGE__TYPE=tiered`
417    /// - `OS__STORAGE__HIGH_VOLUME__TYPE=bigtable`
418    /// - `OS__STORAGE__LONG_TERM__TYPE=gcs`
419    ///
420    /// # Example (tiered)
421    ///
422    /// ```yaml
423    /// storage:
424    ///   type: tiered
425    ///   high_volume:
426    ///     type: bigtable
427    ///     project_id: my-project
428    ///     instance_name: objectstore
429    ///     table_name: objectstore
430    ///   long_term:
431    ///     type: gcs
432    ///     bucket: my-objectstore-bucket
433    /// ```
434    pub storage: StorageConfig,
435
436    /// Configuration of the internal task runtime.
437    ///
438    /// Controls the thread pool size and behavior of the async runtime powering the server.
439    /// See [`Runtime`] for configuration options.
440    pub runtime: Runtime,
441
442    /// Logging configuration.
443    ///
444    /// Controls log verbosity and output format. See [`LoggingConfig`] for configuration options.
445    pub logging: LoggingConfig,
446
447    /// Sentry error tracking configuration.
448    ///
449    /// Optional integration with Sentry for error tracking and performance monitoring.
450    /// See [`Sentry`] for configuration options.
451    pub sentry: Sentry,
452
453    /// Internal metrics configuration.
454    ///
455    /// Configures submission of internal metrics to a DogStatsD-compatible endpoint.
456    /// See [`objectstore_metrics::MetricsConfig`] for configuration options.
457    pub metrics: objectstore_metrics::MetricsConfig,
458
459    /// Content-based authorization configuration.
460    ///
461    /// Controls the verification and enforcement of content-based access control based on the
462    /// JWT in a request's `X-Os-Auth` or `Authorization` header.
463    pub auth: AuthZ,
464
465    /// A list of matchers for requests to discard without processing.
466    pub killswitches: Killswitches,
467
468    /// Definitions for rate limits to enforce on incoming requests.
469    pub rate_limits: RateLimits,
470
471    /// Per-use-case configuration.
472    ///
473    /// Controls properties of individual use cases such as which expiration
474    /// policies are permitted and their maximum durations. Use cases not
475    /// present in the map receive default configuration (all policies allowed,
476    /// no duration caps).
477    pub usecases: UseCases,
478
479    /// Configuration for the [`StorageService`](objectstore_service::StorageService).
480    pub service: Service,
481
482    /// Configuration for the HTTP layer.
483    ///
484    /// Controls HTTP-level settings that operate before requests reach the
485    /// storage service. See [`Http`] for configuration options.
486    pub http: Http,
487}
488
489/// Configuration for the [`StorageService`](objectstore_service::StorageService).
490///
491/// Controls operational parameters of the storage service layer that sits
492/// between the HTTP server and the storage backends.
493///
494/// Used in: [`Config::service`]
495///
496/// # Environment Variables
497///
498/// - `OS__SERVICE__MAX_CONCURRENCY`
499#[derive(Debug, Deserialize, Serialize)]
500#[serde(default)]
501pub struct Service {
502    /// Maximum number of concurrent backend operations.
503    ///
504    /// This caps the total number of in-flight storage operations (reads,
505    /// writes, deletes) across all requests. Operations that exceed the limit
506    /// are rejected with HTTP 429.
507    ///
508    /// # Default
509    ///
510    /// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
511    pub max_concurrency: usize,
512}
513
514impl Default for Service {
515    fn default() -> Self {
516        Self {
517            max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
518        }
519    }
520}
521
522/// Default maximum number of concurrent in-flight HTTP requests.
523///
524/// Requests beyond this limit are rejected with HTTP 503.
525pub const DEFAULT_MAX_HTTP_REQUESTS: usize = 10_000;
526
527/// Configuration for the HTTP layer.
528///
529/// Controls behaviour at the HTTP request level, before requests reach the
530/// storage service. Grouping these settings separately from [`Service`] keeps
531/// HTTP-layer and service-layer concerns distinct and provides a natural home
532/// for future HTTP-level settings (e.g. timeouts, body size limits).
533///
534/// Used in: [`Config::http`]
535///
536/// # Environment Variables
537///
538/// - `OS__HTTP__MAX_REQUESTS`
539#[derive(Debug, Deserialize, Serialize)]
540#[serde(default)]
541pub struct Http {
542    /// Maximum number of concurrent in-flight HTTP requests.
543    ///
544    /// This is a flood protection limit. When the number of requests currently
545    /// being processed reaches this value, new requests are rejected immediately
546    /// with HTTP 503. Health and readiness endpoints (`/health`, `/ready`) are
547    /// excluded from this limit.
548    ///
549    /// Unlike readiness-based backpressure, direct rejection responds in
550    /// milliseconds and recovers the moment any in-flight request completes.
551    ///
552    /// # Default
553    ///
554    /// [`DEFAULT_MAX_HTTP_REQUESTS`]
555    ///
556    /// # Environment Variable
557    ///
558    /// `OS__HTTP__MAX_REQUESTS`
559    pub max_requests: usize,
560}
561
562impl Default for Http {
563    fn default() -> Self {
564        Self {
565            max_requests: DEFAULT_MAX_HTTP_REQUESTS,
566        }
567    }
568}
569
570impl Default for Config {
571    fn default() -> Self {
572        Self {
573            http_addr: "0.0.0.0:8888".parse().unwrap(),
574
575            storage: StorageConfig::FileSystem(FileSystemConfig {
576                path: PathBuf::from("data"),
577            }),
578
579            runtime: Runtime::default(),
580            logging: LoggingConfig::default(),
581            sentry: Sentry::default(),
582            metrics: objectstore_metrics::MetricsConfig::default(),
583            auth: AuthZ::default(),
584            killswitches: Killswitches::default(),
585            rate_limits: RateLimits::default(),
586            usecases: UseCases::default(),
587            service: Service::default(),
588            http: Http::default(),
589        }
590    }
591}
592
593impl Config {
594    /// Loads configuration from the provided arguments.
595    ///
596    /// Configuration is merged in the following order (later sources override earlier ones):
597    /// 1. Default values
598    /// 2. YAML configuration file (if provided in `args`)
599    /// 3. Environment variables (prefixed with `OS__`)
600    ///
601    /// # Errors
602    ///
603    /// Returns an error if:
604    /// - The YAML configuration file cannot be read or parsed
605    /// - Environment variables contain invalid values
606    /// - Required fields are missing or invalid
607    pub fn load(path: Option<&Path>) -> Result<Self> {
608        let mut figment = figment::Figment::from(Serialized::defaults(Config::default()));
609        if let Some(path) = path {
610            figment = figment.merge(Yaml::file(path));
611        }
612        let config = figment
613            .merge(Env::prefixed(ENV_PREFIX).split("__"))
614            .extract()?;
615
616        Ok(config)
617    }
618}
619
620#[cfg(test)]
621#[expect(
622    clippy::result_large_err,
623    reason = "figment::Error is inherently large"
624)]
625mod tests {
626    use std::io::Write;
627
628    use objectstore_service::backend::HighVolumeStorageConfig;
629    use secrecy::ExposeSecret;
630
631    use crate::killswitches::Killswitch;
632    use crate::rate_limits::{BandwidthLimits, RateLimits, ThroughputLimits, ThroughputRule};
633
634    use super::*;
635
636    #[test]
637    fn configurable_via_env() {
638        figment::Jail::expect_with(|jail| {
639            jail.set_env("OS__STORAGE__TYPE", "s3compatible");
640            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:8888");
641            jail.set_env("OS__STORAGE__BUCKET", "whatever");
642            jail.set_env("OS__METRICS__TAGS__FOO", "bar");
643            jail.set_env("OS__METRICS__TAGS__BAZ", "qux");
644            jail.set_env("OS__SENTRY__DSN", "abcde");
645            jail.set_env("OS__SENTRY__SAMPLE_RATE", "0.5");
646            jail.set_env("OS__SENTRY__ENVIRONMENT", "production");
647            jail.set_env("OS__SENTRY__SERVER_NAME", "objectstore-deadbeef");
648            jail.set_env("OS__SENTRY__TRACES_SAMPLE_RATE", "0.5");
649
650            let config = Config::load(None).unwrap();
651
652            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
653                panic!("expected s3 storage");
654            };
655            assert_eq!(c.endpoint, "http://localhost:8888");
656            assert_eq!(c.bucket, "whatever");
657            assert_eq!(
658                config.metrics.tags,
659                [("foo".into(), "bar".into()), ("baz".into(), "qux".into())].into()
660            );
661
662            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
663            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
664            assert_eq!(
665                config.sentry.server_name.as_deref(),
666                Some("objectstore-deadbeef")
667            );
668            assert_eq!(config.sentry.sample_rate, 0.5);
669            assert_eq!(config.sentry.traces_sample_rate, 0.5);
670
671            Ok(())
672        });
673    }
674
675    #[test]
676    fn configurable_via_yaml() {
677        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
678        tempfile
679            .write_all(
680                br#"
681            storage:
682                type: s3compatible
683                endpoint: http://localhost:8888
684                bucket: whatever
685            sentry:
686                dsn: abcde
687                environment: production
688                server_name: objectstore-deadbeef
689                sample_rate: 0.5
690                traces_sample_rate: 0.5
691            "#,
692            )
693            .unwrap();
694
695        figment::Jail::expect_with(|_jail| {
696            let config = Config::load(Some(tempfile.path())).unwrap();
697
698            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
699                panic!("expected s3 storage");
700            };
701            assert_eq!(c.endpoint, "http://localhost:8888");
702            assert_eq!(c.bucket, "whatever");
703
704            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
705            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
706            assert_eq!(
707                config.sentry.server_name.as_deref(),
708                Some("objectstore-deadbeef")
709            );
710            assert_eq!(config.sentry.sample_rate, 0.5);
711            assert_eq!(config.sentry.traces_sample_rate, 0.5);
712
713            Ok(())
714        });
715    }
716
717    #[test]
718    fn configured_with_env_and_yaml() {
719        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
720        tempfile
721            .write_all(
722                br#"
723            storage:
724                type: s3compatible
725                endpoint: http://localhost:8888
726                bucket: whatever
727            "#,
728            )
729            .unwrap();
730
731        figment::Jail::expect_with(|jail| {
732            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:9001");
733
734            let config = Config::load(Some(tempfile.path())).unwrap();
735
736            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
737                panic!("expected s3 storage");
738            };
739            // Env should overwrite the yaml config
740            assert_eq!(c.endpoint, "http://localhost:9001");
741
742            Ok(())
743        });
744    }
745
746    #[test]
747    fn tiered_storage_via_yaml() {
748        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
749        tempfile
750            .write_all(
751                br#"
752            storage:
753                type: tiered
754                high_volume:
755                    type: bigtable
756                    project_id: my-project
757                    instance_name: objectstore
758                    table_name: objectstore
759                long_term:
760                    type: gcs
761                    bucket: my-objectstore-bucket
762            "#,
763            )
764            .unwrap();
765
766        figment::Jail::expect_with(|_jail| {
767            let config = Config::load(Some(tempfile.path())).unwrap();
768
769            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
770                panic!("expected tiered storage");
771            };
772            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
773            assert_eq!(hv.project_id, "my-project");
774            let StorageConfig::Gcs(lt) = c.long_term.as_ref() else {
775                panic!("expected gcs long_term");
776            };
777            assert_eq!(lt.bucket, "my-objectstore-bucket");
778
779            Ok(())
780        });
781    }
782
783    #[test]
784    fn tiered_storage_via_env() {
785        figment::Jail::expect_with(|jail| {
786            jail.set_env("OS__STORAGE__TYPE", "tiered");
787            jail.set_env("OS__STORAGE__HIGH_VOLUME__TYPE", "bigtable");
788            jail.set_env("OS__STORAGE__HIGH_VOLUME__PROJECT_ID", "my-project");
789            jail.set_env("OS__STORAGE__HIGH_VOLUME__INSTANCE_NAME", "my-instance");
790            jail.set_env("OS__STORAGE__HIGH_VOLUME__TABLE_NAME", "my-table");
791            jail.set_env("OS__STORAGE__LONG_TERM__TYPE", "filesystem");
792            jail.set_env("OS__STORAGE__LONG_TERM__PATH", "/data/lt");
793
794            let config = Config::load(None).unwrap();
795
796            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
797                panic!("expected tiered storage");
798            };
799            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
800            assert_eq!(hv.project_id, "my-project");
801            assert_eq!(hv.instance_name, "my-instance");
802            assert_eq!(hv.table_name, "my-table");
803            let StorageConfig::FileSystem(lt) = c.long_term.as_ref() else {
804                panic!("expected filesystem long_term");
805            };
806            assert_eq!(lt.path, Path::new("/data/lt"));
807
808            Ok(())
809        });
810    }
811
812    #[test]
813    fn metrics_addr_via_env() {
814        figment::Jail::expect_with(|jail| {
815            jail.set_env("OS__METRICS__ADDR", "127.0.0.1:8125");
816
817            let config = Config::load(None).unwrap();
818            assert_eq!(config.metrics.addr.as_deref(), Some("127.0.0.1:8125"));
819
820            Ok(())
821        });
822    }
823
824    #[test]
825    fn configure_auth_with_env() {
826        figment::Jail::expect_with(|jail| {
827            jail.set_env("OS__AUTH__ENFORCE", "true");
828            jail.set_env(
829                "OS__AUTH__KEYS",
830                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"],}}"#,
831            );
832
833            let config = Config::load(None).unwrap();
834
835            assert!(config.auth.enforce);
836
837            let kid1 = config.auth.keys.get("kid1").unwrap();
838            assert_eq!(kid1.key_files[0], Path::new("abcde"));
839            assert_eq!(kid1.key_files[1], Path::new("fghij"));
840            assert_eq!(
841                kid1.key_files[2],
842                Path::new("this is a test\n  multiline string\nend of string\n"),
843            );
844            assert_eq!(
845                kid1.max_permissions,
846                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
847            );
848
849            let kid2 = config.auth.keys.get("kid2").unwrap();
850            assert_eq!(kid2.key_files[0], Path::new("12345"));
851            assert_eq!(kid2.max_permissions, HashSet::new());
852
853            Ok(())
854        });
855    }
856
857    #[test]
858    fn configure_auth_with_yaml() {
859        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
860        tempfile
861            .write_all(
862                br#"
863                auth:
864                    enforce: true
865                    keys:
866                        kid1:
867                            key_files:
868                                - "abcde"
869                                - "fghij"
870                                - |
871                                  this is a test
872                                    multiline string
873                                  end of string
874                            max_permissions:
875                                - "object.read"
876                                - "object.write"
877                        kid2:
878                            key_files:
879                                - "12345"
880            "#,
881            )
882            .unwrap();
883
884        figment::Jail::expect_with(|_jail| {
885            let config = Config::load(Some(tempfile.path())).unwrap();
886
887            assert!(config.auth.enforce);
888
889            let kid1 = config.auth.keys.get("kid1").unwrap();
890            assert_eq!(kid1.key_files[0], Path::new("abcde"));
891            assert_eq!(kid1.key_files[1], Path::new("fghij"));
892            assert_eq!(
893                kid1.key_files[2],
894                Path::new("this is a test\n  multiline string\nend of string\n")
895            );
896            assert_eq!(
897                kid1.max_permissions,
898                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
899            );
900
901            let kid2 = config.auth.keys.get("kid2").unwrap();
902            assert_eq!(kid2.key_files[0], Path::new("12345"));
903            assert_eq!(kid2.max_permissions, HashSet::new());
904
905            Ok(())
906        });
907    }
908
909    #[test]
910    fn auth_enforce_defaults_to_true() {
911        figment::Jail::expect_with(|_jail| {
912            let config = Config::load(None).unwrap();
913            assert!(config.auth.enforce);
914            Ok(())
915        });
916    }
917
918    #[test]
919    fn auth_enforce_defaults_to_true_when_omitted_from_yaml() {
920        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
921        tempfile
922            .write_all(
923                br#"
924                auth:
925                    keys: {}
926            "#,
927            )
928            .unwrap();
929
930        figment::Jail::expect_with(|_jail| {
931            let config = Config::load(Some(tempfile.path())).unwrap();
932            assert!(config.auth.enforce);
933            Ok(())
934        });
935    }
936
937    #[test]
938    fn auth_enforce_can_be_disabled() {
939        figment::Jail::expect_with(|jail| {
940            jail.set_env("OS__AUTH__ENFORCE", "false");
941            let config = Config::load(None).unwrap();
942            assert!(!config.auth.enforce);
943            Ok(())
944        });
945    }
946
947    #[test]
948    fn configure_killswitches_with_yaml() {
949        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
950        tempfile
951            .write_all(
952                br#"
953                killswitches:
954                  - usecase: broken_usecase
955                  - scopes:
956                      org: "42"
957                  - service: "test-*"
958                  - scopes:
959                      org: "42"
960                      project: "4711"
961                  - usecase: attachments
962                    scopes:
963                      org: "42"
964                    service: "test-*"
965                "#,
966            )
967            .unwrap();
968
969        figment::Jail::expect_with(|_jail| {
970            let expected = [
971                Killswitch {
972                    usecase: Some("broken_usecase".into()),
973                    scopes: BTreeMap::new(),
974                    service: None,
975                    service_matcher: std::sync::OnceLock::new(),
976                },
977                Killswitch {
978                    usecase: None,
979                    scopes: BTreeMap::from([("org".into(), "42".into())]),
980                    service: None,
981                    service_matcher: std::sync::OnceLock::new(),
982                },
983                Killswitch {
984                    usecase: None,
985                    scopes: BTreeMap::new(),
986                    service: Some("test-*".into()),
987                    service_matcher: std::sync::OnceLock::new(),
988                },
989                Killswitch {
990                    usecase: None,
991                    scopes: BTreeMap::from([
992                        ("org".into(), "42".into()),
993                        ("project".into(), "4711".into()),
994                    ]),
995                    service: None,
996                    service_matcher: std::sync::OnceLock::new(),
997                },
998                Killswitch {
999                    usecase: Some("attachments".into()),
1000                    scopes: BTreeMap::from([("org".into(), "42".into())]),
1001                    service: Some("test-*".into()),
1002                    service_matcher: std::sync::OnceLock::new(),
1003                },
1004            ];
1005
1006            let config = Config::load(Some(tempfile.path())).unwrap();
1007            assert_eq!(&config.killswitches.0, &expected,);
1008
1009            Ok(())
1010        });
1011    }
1012
1013    #[test]
1014    fn configure_rate_limits_with_yaml() {
1015        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1016        tempfile
1017            .write_all(
1018                br#"
1019                rate_limits:
1020                  throughput:
1021                    global_rps: 1000
1022                    burst: 100
1023                    usecase_pct: 50
1024                    scope_pct: 25
1025                    rules:
1026                      - usecase: "high_priority"
1027                        scopes:
1028                          - ["org", "123"]
1029                        rps: 500
1030                      - scopes:
1031                          - ["org", "456"]
1032                          - ["project", "789"]
1033                        pct: 10
1034                  bandwidth:
1035                    global_bps: 1048576
1036                    usecase_pct: 50
1037                    scope_pct: 25
1038                "#,
1039            )
1040            .unwrap();
1041
1042        figment::Jail::expect_with(|_jail| {
1043            let expected = RateLimits {
1044                throughput: ThroughputLimits {
1045                    global_rps: Some(1000),
1046                    burst: 100,
1047                    usecase_pct: Some(50),
1048                    scope_pct: Some(25),
1049                    rules: vec![
1050                        ThroughputRule {
1051                            usecase: Some("high_priority".to_string()),
1052                            scopes: vec![("org".to_string(), "123".to_string())],
1053                            rps: Some(500),
1054                            pct: None,
1055                        },
1056                        ThroughputRule {
1057                            usecase: None,
1058                            scopes: vec![
1059                                ("org".to_string(), "456".to_string()),
1060                                ("project".to_string(), "789".to_string()),
1061                            ],
1062                            rps: None,
1063                            pct: Some(10),
1064                        },
1065                    ],
1066                },
1067                bandwidth: BandwidthLimits {
1068                    global_bps: Some(1_048_576),
1069                    usecase_pct: Some(50),
1070                    scope_pct: Some(25),
1071                },
1072            };
1073
1074            let config = Config::load(Some(tempfile.path())).unwrap();
1075            assert_eq!(config.rate_limits, expected);
1076
1077            Ok(())
1078        });
1079    }
1080}