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}