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