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/// - `OS__SERVICE__CONCURRENCY_QUEUE`
512/// - `OS__SERVICE__CONCURRENCY_TIMEOUT`
513/// - `OS__SERVICE__BULK_CONCURRENCY_PCT`
514#[derive(Debug, Deserialize, Serialize)]
515#[serde(default)]
516pub struct Service {
517    /// Maximum number of concurrent backend operations.
518    ///
519    /// This caps the total number of in-flight storage operations (reads,
520    /// writes, deletes) across all requests. Operations that exceed the limit
521    /// are rejected with HTTP 429.
522    ///
523    /// # Default
524    ///
525    /// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
526    pub max_concurrency: u32,
527
528    /// Maximum number of requests that may wait for a concurrency permit.
529    ///
530    /// When all `max_concurrency` execution slots are held, up to this many
531    /// additional requests will park and wait (for at most
532    /// `concurrency_timeout`) instead of being rejected immediately.
533    /// Requests beyond that are rejected with HTTP 429.
534    ///
535    /// Sizing guidance: `concurrency_queue ≈ permit_release_rate ×
536    /// acceptable_added_latency`.
537    ///
538    /// # Default
539    ///
540    /// `0`
541    pub concurrency_queue: u32,
542
543    /// Maximum time a caller may wait for a concurrency permit.
544    ///
545    /// Applies to both queued normal requests and bulk operations
546    /// waiting for the bulk and execution semaphores.
547    ///
548    /// # Default
549    ///
550    /// `1s`
551    #[serde(with = "humantime_serde")]
552    pub concurrency_timeout: Duration,
553
554    /// Percentage of `max_concurrency` available to bulk operations
555    /// (e.g. parallelized batch requests).
556    ///
557    /// This sets a safe operating point: below this level there is
558    /// little-to-no performance degradation, leaving room for more tasks
559    /// to be admitted via the queue before rejection is necessary.
560    ///
561    /// Clamped to 1..=100. At 100, bulk operations can use all execution
562    /// slots. Lower values leave headroom for single-object requests.
563    ///
564    /// # Default
565    ///
566    /// `60`
567    pub bulk_concurrency_pct: u32,
568}
569
570impl Default for Service {
571    fn default() -> Self {
572        Self {
573            max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
574            concurrency_queue: 0,
575            concurrency_timeout: Duration::from_secs(1),
576            bulk_concurrency_pct: 60,
577        }
578    }
579}
580
581/// Default maximum number of concurrent in-flight HTTP requests.
582///
583/// Requests beyond this limit are rejected with HTTP 503.
584pub const DEFAULT_MAX_HTTP_REQUESTS: usize = 10_000;
585
586/// Configuration for the HTTP layer.
587///
588/// Controls behaviour at the HTTP request level, before requests reach the
589/// storage service. Grouping these settings separately from [`Service`] keeps
590/// HTTP-layer and service-layer concerns distinct and provides a natural home
591/// for future HTTP-level settings (e.g. timeouts, body size limits).
592///
593/// Used in: [`Config::http`]
594///
595/// # Environment Variables
596///
597/// - `OS__HTTP__MAX_REQUESTS`
598#[derive(Debug, Deserialize, Serialize)]
599#[serde(default)]
600pub struct Http {
601    /// Maximum number of concurrent in-flight HTTP requests.
602    ///
603    /// This is a flood protection limit. When the number of requests currently
604    /// being processed reaches this value, new requests are rejected immediately
605    /// with HTTP 503. Health and readiness endpoints (`/health`, `/ready`) are
606    /// excluded from this limit.
607    ///
608    /// Unlike readiness-based backpressure, direct rejection responds in
609    /// milliseconds and recovers the moment any in-flight request completes.
610    ///
611    /// # Default
612    ///
613    /// [`DEFAULT_MAX_HTTP_REQUESTS`]
614    ///
615    /// # Environment Variable
616    ///
617    /// `OS__HTTP__MAX_REQUESTS`
618    pub max_requests: usize,
619}
620
621impl Default for Http {
622    fn default() -> Self {
623        Self {
624            max_requests: DEFAULT_MAX_HTTP_REQUESTS,
625        }
626    }
627}
628
629impl Default for Config {
630    fn default() -> Self {
631        Self {
632            http_addr: "0.0.0.0:8888".parse().unwrap(),
633
634            storage: StorageConfig::FileSystem(FileSystemConfig {
635                path: PathBuf::from("data"),
636            }),
637
638            runtime: Runtime::default(),
639            logging: LoggingConfig::default(),
640            sentry: Sentry::default(),
641            metrics: objectstore_metrics::MetricsConfig::default(),
642            auth: AuthZ::default(),
643            killswitches: Killswitches::default(),
644            rate_limits: RateLimits::default(),
645            usecases: UseCases::default(),
646            service: Service::default(),
647            http: Http::default(),
648        }
649    }
650}
651
652impl Config {
653    /// Loads configuration from the provided arguments.
654    ///
655    /// Configuration is merged in the following order (later sources override earlier ones):
656    /// 1. Default values
657    /// 2. YAML configuration file (if provided in `args`)
658    /// 3. Environment variables (prefixed with `OS__`)
659    ///
660    /// # Errors
661    ///
662    /// Returns an error if:
663    /// - The YAML configuration file cannot be read or parsed
664    /// - Environment variables contain invalid values
665    /// - Required fields are missing or invalid
666    pub fn load(path: Option<&Path>) -> Result<Self> {
667        let mut figment = figment::Figment::from(Serialized::defaults(Config::default()));
668        if let Some(path) = path {
669            figment = figment.merge(Yaml::file(path));
670        }
671        let config = figment
672            .merge(Env::prefixed(ENV_PREFIX).split("__"))
673            .extract()?;
674
675        Ok(config)
676    }
677}
678
679#[cfg(test)]
680#[expect(
681    clippy::result_large_err,
682    reason = "figment::Error is inherently large"
683)]
684mod tests {
685    use std::io::Write;
686
687    use objectstore_service::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig};
688    use secrecy::ExposeSecret;
689
690    use crate::killswitches::Killswitch;
691    use crate::rate_limits::{BandwidthLimits, RateLimits, ThroughputLimits, ThroughputRule};
692
693    use super::*;
694
695    #[test]
696    fn configurable_via_env() {
697        figment::Jail::expect_with(|jail| {
698            jail.set_env("OS__STORAGE__TYPE", "s3compatible");
699            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:8888");
700            jail.set_env("OS__STORAGE__BUCKET", "whatever");
701            jail.set_env("OS__METRICS__TAGS__FOO", "bar");
702            jail.set_env("OS__METRICS__TAGS__BAZ", "qux");
703            jail.set_env("OS__SENTRY__DSN", "abcde");
704            jail.set_env("OS__SENTRY__SAMPLE_RATE", "0.5");
705            jail.set_env("OS__SENTRY__ENVIRONMENT", "production");
706            jail.set_env("OS__SENTRY__SERVER_NAME", "objectstore-deadbeef");
707            jail.set_env("OS__SENTRY__TRACES_SAMPLE_RATE", "0.5");
708
709            let config = Config::load(None).unwrap();
710
711            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
712                panic!("expected s3 storage");
713            };
714            assert_eq!(c.endpoint, "http://localhost:8888");
715            assert_eq!(c.bucket, "whatever");
716            assert_eq!(
717                config.metrics.tags,
718                [("foo".into(), "bar".into()), ("baz".into(), "qux".into())].into()
719            );
720
721            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
722            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
723            assert_eq!(
724                config.sentry.server_name.as_deref(),
725                Some("objectstore-deadbeef")
726            );
727            assert_eq!(config.sentry.sample_rate, 0.5);
728            assert_eq!(config.sentry.traces_sample_rate, 0.5);
729
730            Ok(())
731        });
732    }
733
734    #[test]
735    fn configurable_via_yaml() {
736        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
737        tempfile
738            .write_all(
739                br#"
740            storage:
741                type: s3compatible
742                endpoint: http://localhost:8888
743                bucket: whatever
744            sentry:
745                dsn: abcde
746                environment: production
747                server_name: objectstore-deadbeef
748                sample_rate: 0.5
749                traces_sample_rate: 0.5
750            "#,
751            )
752            .unwrap();
753
754        figment::Jail::expect_with(|_jail| {
755            let config = Config::load(Some(tempfile.path())).unwrap();
756
757            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
758                panic!("expected s3 storage");
759            };
760            assert_eq!(c.endpoint, "http://localhost:8888");
761            assert_eq!(c.bucket, "whatever");
762
763            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
764            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
765            assert_eq!(
766                config.sentry.server_name.as_deref(),
767                Some("objectstore-deadbeef")
768            );
769            assert_eq!(config.sentry.sample_rate, 0.5);
770            assert_eq!(config.sentry.traces_sample_rate, 0.5);
771
772            Ok(())
773        });
774    }
775
776    #[test]
777    fn configured_with_env_and_yaml() {
778        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
779        tempfile
780            .write_all(
781                br#"
782            storage:
783                type: s3compatible
784                endpoint: http://localhost:8888
785                bucket: whatever
786            "#,
787            )
788            .unwrap();
789
790        figment::Jail::expect_with(|jail| {
791            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:9001");
792
793            let config = Config::load(Some(tempfile.path())).unwrap();
794
795            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
796                panic!("expected s3 storage");
797            };
798            // Env should overwrite the yaml config
799            assert_eq!(c.endpoint, "http://localhost:9001");
800
801            Ok(())
802        });
803    }
804
805    #[test]
806    fn tiered_storage_via_yaml() {
807        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
808        tempfile
809            .write_all(
810                br#"
811            storage:
812                type: tiered
813                high_volume:
814                    type: bigtable
815                    project_id: my-project
816                    instance_name: objectstore
817                    table_name: objectstore
818                long_term:
819                    type: gcs
820                    bucket: my-objectstore-bucket
821            "#,
822            )
823            .unwrap();
824
825        figment::Jail::expect_with(|_jail| {
826            let config = Config::load(Some(tempfile.path())).unwrap();
827
828            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
829                panic!("expected tiered storage");
830            };
831            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
832            assert_eq!(hv.project_id, "my-project");
833            let MultipartUploadStorageConfig::Gcs(lt) = &c.long_term else {
834                panic!("expected gcs long_term");
835            };
836            assert_eq!(lt.bucket, "my-objectstore-bucket");
837
838            Ok(())
839        });
840    }
841
842    #[test]
843    fn tiered_storage_via_env() {
844        figment::Jail::expect_with(|jail| {
845            jail.set_env("OS__STORAGE__TYPE", "tiered");
846            jail.set_env("OS__STORAGE__HIGH_VOLUME__TYPE", "bigtable");
847            jail.set_env("OS__STORAGE__HIGH_VOLUME__PROJECT_ID", "my-project");
848            jail.set_env("OS__STORAGE__HIGH_VOLUME__INSTANCE_NAME", "my-instance");
849            jail.set_env("OS__STORAGE__HIGH_VOLUME__TABLE_NAME", "my-table");
850            jail.set_env("OS__STORAGE__LONG_TERM__TYPE", "filesystem");
851            jail.set_env("OS__STORAGE__LONG_TERM__PATH", "/data/lt");
852
853            let config = Config::load(None).unwrap();
854
855            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
856                panic!("expected tiered storage");
857            };
858            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
859            assert_eq!(hv.project_id, "my-project");
860            assert_eq!(hv.instance_name, "my-instance");
861            assert_eq!(hv.table_name, "my-table");
862            let MultipartUploadStorageConfig::FileSystem(lt) = &c.long_term else {
863                panic!("expected filesystem long_term");
864            };
865            assert_eq!(lt.path, Path::new("/data/lt"));
866
867            Ok(())
868        });
869    }
870
871    #[test]
872    fn metrics_addr_via_env() {
873        figment::Jail::expect_with(|jail| {
874            jail.set_env("OS__METRICS__ADDR", "127.0.0.1:8125");
875
876            let config = Config::load(None).unwrap();
877            assert_eq!(config.metrics.addr.as_deref(), Some("127.0.0.1:8125"));
878
879            Ok(())
880        });
881    }
882
883    #[test]
884    fn configure_auth_with_env() {
885        figment::Jail::expect_with(|jail| {
886            jail.set_env("OS__AUTH__ENFORCE", "true");
887            jail.set_env(
888                "OS__AUTH__KEYS",
889                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"],}}"#,
890            );
891
892            let config = Config::load(None).unwrap();
893
894            assert!(config.auth.enforce);
895
896            let kid1 = config.auth.keys.get("kid1").unwrap();
897            assert_eq!(kid1.key_files[0], Path::new("abcde"));
898            assert_eq!(kid1.key_files[1], Path::new("fghij"));
899            assert_eq!(
900                kid1.key_files[2],
901                Path::new("this is a test\n  multiline string\nend of string\n"),
902            );
903            assert_eq!(
904                kid1.max_permissions,
905                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
906            );
907
908            let kid2 = config.auth.keys.get("kid2").unwrap();
909            assert_eq!(kid2.key_files[0], Path::new("12345"));
910            assert_eq!(kid2.max_permissions, HashSet::new());
911
912            Ok(())
913        });
914    }
915
916    #[test]
917    fn configure_auth_with_yaml() {
918        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
919        tempfile
920            .write_all(
921                br#"
922                auth:
923                    enforce: true
924                    keys:
925                        kid1:
926                            key_files:
927                                - "abcde"
928                                - "fghij"
929                                - |
930                                  this is a test
931                                    multiline string
932                                  end of string
933                            max_permissions:
934                                - "object.read"
935                                - "object.write"
936                        kid2:
937                            key_files:
938                                - "12345"
939            "#,
940            )
941            .unwrap();
942
943        figment::Jail::expect_with(|_jail| {
944            let config = Config::load(Some(tempfile.path())).unwrap();
945
946            assert!(config.auth.enforce);
947
948            let kid1 = config.auth.keys.get("kid1").unwrap();
949            assert_eq!(kid1.key_files[0], Path::new("abcde"));
950            assert_eq!(kid1.key_files[1], Path::new("fghij"));
951            assert_eq!(
952                kid1.key_files[2],
953                Path::new("this is a test\n  multiline string\nend of string\n")
954            );
955            assert_eq!(
956                kid1.max_permissions,
957                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
958            );
959
960            let kid2 = config.auth.keys.get("kid2").unwrap();
961            assert_eq!(kid2.key_files[0], Path::new("12345"));
962            assert_eq!(kid2.max_permissions, HashSet::new());
963
964            Ok(())
965        });
966    }
967
968    #[test]
969    fn auth_enforce_defaults_to_true() {
970        figment::Jail::expect_with(|_jail| {
971            let config = Config::load(None).unwrap();
972            assert!(config.auth.enforce);
973            Ok(())
974        });
975    }
976
977    #[test]
978    fn auth_enforce_defaults_to_true_when_omitted_from_yaml() {
979        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
980        tempfile
981            .write_all(
982                br#"
983                auth:
984                    keys: {}
985            "#,
986            )
987            .unwrap();
988
989        figment::Jail::expect_with(|_jail| {
990            let config = Config::load(Some(tempfile.path())).unwrap();
991            assert!(config.auth.enforce);
992            Ok(())
993        });
994    }
995
996    #[test]
997    fn auth_enforce_can_be_disabled() {
998        figment::Jail::expect_with(|jail| {
999            jail.set_env("OS__AUTH__ENFORCE", "false");
1000            let config = Config::load(None).unwrap();
1001            assert!(!config.auth.enforce);
1002            Ok(())
1003        });
1004    }
1005
1006    #[test]
1007    fn configure_killswitches_with_yaml() {
1008        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1009        tempfile
1010            .write_all(
1011                br#"
1012                killswitches:
1013                  - usecase: broken_usecase
1014                  - scopes:
1015                      org: "42"
1016                  - service: "test-*"
1017                  - scopes:
1018                      org: "42"
1019                      project: "4711"
1020                  - usecase: attachments
1021                    scopes:
1022                      org: "42"
1023                    service: "test-*"
1024                "#,
1025            )
1026            .unwrap();
1027
1028        figment::Jail::expect_with(|_jail| {
1029            let expected = [
1030                Killswitch {
1031                    usecase: Some("broken_usecase".into()),
1032                    scopes: BTreeMap::new(),
1033                    service: None,
1034                },
1035                Killswitch {
1036                    usecase: None,
1037                    scopes: BTreeMap::from([("org".into(), "42".into())]),
1038                    service: None,
1039                },
1040                Killswitch {
1041                    usecase: None,
1042                    scopes: BTreeMap::new(),
1043                    service: Some("test-*".into()),
1044                },
1045                Killswitch {
1046                    usecase: None,
1047                    scopes: BTreeMap::from([
1048                        ("org".into(), "42".into()),
1049                        ("project".into(), "4711".into()),
1050                    ]),
1051                    service: None,
1052                },
1053                Killswitch {
1054                    usecase: Some("attachments".into()),
1055                    scopes: BTreeMap::from([("org".into(), "42".into())]),
1056                    service: Some("test-*".into()),
1057                },
1058            ];
1059
1060            let config = Config::load(Some(tempfile.path())).unwrap();
1061            assert_eq!(config.killswitches.as_slice(), &expected);
1062
1063            Ok(())
1064        });
1065    }
1066
1067    #[test]
1068    fn configure_rate_limits_with_yaml() {
1069        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1070        tempfile
1071            .write_all(
1072                br#"
1073                rate_limits:
1074                  throughput:
1075                    global_rps: 1000
1076                    burst: 100
1077                    usecase_pct: 50
1078                    scope_pct: 25
1079                    rules:
1080                      - usecase: "high_priority"
1081                        scopes:
1082                          - ["org", "123"]
1083                        rps: 500
1084                      - scopes:
1085                          - ["org", "456"]
1086                          - ["project", "789"]
1087                        pct: 10
1088                  bandwidth:
1089                    global_bps: 1048576
1090                    burst_ms: 2000
1091                    usecase_pct: 50
1092                    scope_pct: 25
1093                    report_only: true
1094                "#,
1095            )
1096            .unwrap();
1097
1098        figment::Jail::expect_with(|_jail| {
1099            let expected = RateLimits {
1100                throughput: ThroughputLimits {
1101                    global_rps: Some(1000),
1102                    burst: 100,
1103                    usecase_pct: Some(50),
1104                    scope_pct: Some(25),
1105                    rules: vec![
1106                        ThroughputRule {
1107                            usecase: Some("high_priority".to_string()),
1108                            scopes: vec![("org".to_string(), "123".to_string())],
1109                            rps: Some(500),
1110                            pct: None,
1111                        },
1112                        ThroughputRule {
1113                            usecase: None,
1114                            scopes: vec![
1115                                ("org".to_string(), "456".to_string()),
1116                                ("project".to_string(), "789".to_string()),
1117                            ],
1118                            rps: None,
1119                            pct: Some(10),
1120                        },
1121                    ],
1122                },
1123                bandwidth: BandwidthLimits {
1124                    global_bps: Some(1_048_576),
1125                    burst_ms: 2000,
1126                    usecase_pct: Some(50),
1127                    scope_pct: Some(25),
1128                    report_only: true,
1129                },
1130            };
1131
1132            let config = Config::load(Some(tempfile.path())).unwrap();
1133            assert_eq!(config.rate_limits, expected);
1134
1135            Ok(())
1136        });
1137    }
1138}