ifc_lite_server/
config.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Server configuration loaded from environment variables.
6
7/// Server configuration.
8///
9/// `Debug` is implemented by hand (not derived) so the bearer token in
10/// `api_token` is never leaked through logs, traces, or panic reports.
11#[derive(Clone)]
12pub struct Config {
13    /// Port to listen on.
14    pub port: u16,
15    /// Directory for cache storage.
16    pub cache_dir: String,
17    /// Maximum file size in MB.
18    pub max_file_size_mb: usize,
19    /// Request timeout in seconds.
20    pub request_timeout_secs: u64,
21    /// Longest a socket write may make no progress before the connection is
22    /// closed (`IFC_STREAM_IDLE_TIMEOUT_SECS`, default 600; 0 disables).
23    /// This is enforced by the connection owner, so closing the transport
24    /// drops Hyper's current frame, the response body, and its admission
25    /// reservation. Producer time does not count because no write is pending.
26    pub stream_idle_timeout_secs: u64,
27    /// Number of worker threads for parallel processing.
28    pub worker_threads: usize,
29    /// Initial batch size for fast first frame (first 3 batches).
30    pub initial_batch_size: usize,
31    /// Maximum batch size for throughput (batches 11+).
32    pub max_batch_size: usize,
33    /// Maximum cache age in days.
34    pub cache_max_age_days: u64,
35    /// Allowed CORS origins (comma-separated, or "*" for all in development).
36    pub cors_origins: Vec<String>,
37    /// Parse jobs allowed to run at once (`IFC_MAX_CONCURRENT_PARSES`,
38    /// default = `worker_threads`). The admission CPU gate.
39    pub max_concurrent_parses: usize,
40    /// Total upload bytes (in MB) allowed to be admitted at once
41    /// (`IFC_MEM_BUDGET_MB`). Default: auto-detected from the cgroup memory
42    /// limit (70% of it) when readable, else 0 = memory gate disabled.
43    pub mem_budget_mb: usize,
44    /// Requests allowed to wait for an admission permit before immediate
45    /// rejection (`IFC_ADMISSION_QUEUE_DEPTH`, default = 2 * worker_threads).
46    pub admission_queue_depth: usize,
47    /// Longest a queued request waits for a permit, seconds
48    /// (`IFC_ADMISSION_QUEUE_TIMEOUT_SECS`, default 5).
49    pub admission_queue_timeout_secs: u64,
50    /// RSS high-water percentage of the memory budget above which new parse
51    /// jobs are shed (`IFC_MEM_SHED_PCT`, default 85).
52    pub mem_shed_pct: u8,
53    /// Expose `GET /api/v1/metrics` (`IFC_METRICS_ENABLED`, default false).
54    /// Protected by the bearer token when one is configured.
55    pub metrics_enabled: bool,
56    /// Optional bearer token for the compute/parse routes.
57    ///
58    /// Read from `IFC_SERVER_API_TOKEN` (falling back to `API_TOKEN`). When set,
59    /// the parse/cache routes require an `Authorization: Bearer <token>` header
60    /// and return 401 otherwise. When unset (the default), those routes stay
61    /// open so the public viewer -> server flow keeps working, and the server
62    /// logs a startup warning that it is unauthenticated. The health endpoint is
63    /// always open regardless of this setting (liveness probes).
64    pub api_token: Option<String>,
65}
66
67impl std::fmt::Debug for Config {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("Config")
70            .field("port", &self.port)
71            .field("cache_dir", &self.cache_dir)
72            .field("max_file_size_mb", &self.max_file_size_mb)
73            .field("request_timeout_secs", &self.request_timeout_secs)
74            .field("stream_idle_timeout_secs", &self.stream_idle_timeout_secs)
75            .field("worker_threads", &self.worker_threads)
76            .field("initial_batch_size", &self.initial_batch_size)
77            .field("max_batch_size", &self.max_batch_size)
78            .field("cache_max_age_days", &self.cache_max_age_days)
79            .field("cors_origins", &self.cors_origins)
80            .field("max_concurrent_parses", &self.max_concurrent_parses)
81            .field("mem_budget_mb", &self.mem_budget_mb)
82            .field("admission_queue_depth", &self.admission_queue_depth)
83            .field("admission_queue_timeout_secs", &self.admission_queue_timeout_secs)
84            .field("mem_shed_pct", &self.mem_shed_pct)
85            .field("metrics_enabled", &self.metrics_enabled)
86            // Redacted: never print the bearer token.
87            .field("api_token", &self.api_token.as_ref().map(|_| "<redacted>"))
88            .finish()
89    }
90}
91
92impl Config {
93    /// Load configuration from environment variables.
94    pub fn from_env() -> Self {
95        Self::from_lookup(|key| std::env::var(key).ok())
96    }
97
98    /// The actual parsing, over an injected variable lookup.
99    ///
100    /// Split out from [`Self::from_env`] purely so the defaults and the
101    /// parse/clamp rules are unit-testable without mutating the process
102    /// environment — `from_env` is called from other tests in this binary
103    /// (`parity_tests::test_state`), so a test that set `IFC_SERVER_API_TOKEN`
104    /// globally would silently switch those tests to an authenticated server.
105    /// Behavior is unchanged: `from_env` passes the real `std::env::var`.
106    ///
107    /// `pub(crate)` so tests outside this module (`cors_tests`) can build a
108    /// config that is pinned to the shipped defaults rather than to whatever
109    /// the CI runner happens to export.
110    pub(crate) fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Self {
111        let worker_threads: usize = get("WORKER_THREADS")
112            .unwrap_or_else(|| num_cpus::get().to_string())
113            .parse()
114            .unwrap_or_else(|_| num_cpus::get());
115        Self {
116            port: get("PORT")
117                .unwrap_or_else(|| "8080".into())
118                .parse()
119                .unwrap_or(8080),
120            cache_dir: get("CACHE_DIR").unwrap_or_else(|| {
121                // Auto-detect environment:
122                // - Docker: use /app/cache (created in Dockerfile)
123                // - Local dev: use ./.cache relative to server directory
124                if std::path::Path::new("/.dockerenv").exists() {
125                    "/app/cache".into()
126                } else {
127                    // Use absolute path for local development to avoid issues
128                    std::env::current_dir()
129                        .ok()
130                        .and_then(|dir| dir.join(".cache").to_str().map(|s| s.to_string()))
131                        .unwrap_or_else(|| "./.cache".into())
132                }
133            }),
134            max_file_size_mb: get("MAX_FILE_SIZE_MB")
135                .unwrap_or_else(|| "500".into())
136                .parse()
137                .unwrap_or(500),
138            request_timeout_secs: get("REQUEST_TIMEOUT_SECS")
139                .unwrap_or_else(|| "300".into())
140                .parse()
141                .unwrap_or(300),
142            stream_idle_timeout_secs: get("IFC_STREAM_IDLE_TIMEOUT_SECS")
143                .and_then(|value| value.parse().ok())
144                .unwrap_or(600),
145            worker_threads,
146            max_concurrent_parses: get("IFC_MAX_CONCURRENT_PARSES")
147                .and_then(|v| v.parse().ok())
148                .unwrap_or(worker_threads)
149                .max(1),
150            // Self-tune to the tightest readable memory ceiling (cgroup, else
151            // physical RAM), 70% of it; an explicit IFC_MEM_BUDGET_MB wins and
152            // `=0` is an opt-out. Only 0 when no ceiling is readable, which
153            // main() warns about (memory admission then falls back to OFF).
154            mem_budget_mb: crate::mem_policy::resolve_mem_budget_mb(
155                get("IFC_MEM_BUDGET_MB").and_then(|v| v.parse().ok()),
156                crate::mem_policy::cgroup_memory_limit_bytes(),
157                crate::mem_policy::total_physical_memory_bytes(),
158            ),
159            admission_queue_depth: get("IFC_ADMISSION_QUEUE_DEPTH")
160                .and_then(|v| v.parse().ok())
161                .unwrap_or(worker_threads * 2),
162            admission_queue_timeout_secs: get("IFC_ADMISSION_QUEUE_TIMEOUT_SECS")
163                .and_then(|v| v.parse().ok())
164                .unwrap_or(5),
165            mem_shed_pct: get("IFC_MEM_SHED_PCT")
166                .and_then(|v| v.parse::<u8>().ok())
167                .unwrap_or(85)
168                .min(100),
169            metrics_enabled: get("IFC_METRICS_ENABLED")
170                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
171                .unwrap_or(false),
172            initial_batch_size: get("INITIAL_BATCH_SIZE")
173                .unwrap_or_else(|| "100".into())
174                .parse()
175                .unwrap_or(100),
176            max_batch_size: get("MAX_BATCH_SIZE")
177                .unwrap_or_else(|| "1000".into())
178                .parse()
179                .unwrap_or(1000),
180            cache_max_age_days: get("CACHE_MAX_AGE_DAYS")
181                .unwrap_or_else(|| "7".into())
182                .parse()
183                .unwrap_or(7),
184            cors_origins: get("CORS_ORIGINS")
185                .unwrap_or_else(|| {
186                    // Default: allow common development origins
187                    "http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173".into()
188                })
189                .split(',')
190                .map(|s| s.trim().to_string())
191                .filter(|s| !s.is_empty())
192                .collect(),
193            api_token: get("IFC_SERVER_API_TOKEN")
194                .or_else(|| get("API_TOKEN"))
195                .map(|s| s.trim().to_string())
196                .filter(|s| !s.is_empty()),
197        }
198    }
199}
200
201impl Default for Config {
202    fn default() -> Self {
203        Self::from_env()
204    }
205}
206
207#[cfg(test)]
208#[path = "config_tests.rs"]
209mod config_tests;