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;