laterite_core/config.rs
1//! Layered configuration loading.
2
3use std::path::Path;
4
5use config::{Config, Environment, File};
6use serde::de::DeserializeOwned;
7use serde::Deserialize;
8
9use crate::error::{CoreError, CoreResult};
10
11/// Database connection settings.
12#[derive(Debug, Clone, Deserialize)]
13pub struct DatabaseConfig {
14 pub url: String,
15 #[serde(default = "default_max_connections")]
16 pub max_connections: u32,
17 #[serde(default = "default_acquire_timeout_secs")]
18 pub acquire_timeout_secs: u64,
19}
20
21/// HTTP listener settings.
22#[derive(Debug, Clone, Deserialize)]
23pub struct ServerConfig {
24 #[serde(default = "default_listen")]
25 pub listen: String,
26}
27
28/// Application-level metadata. The `name` is the human-readable application
29/// name, the baseline for the admin brand: a `BrandSetting` in the admin can
30/// override it, but this is the default when none is set.
31///
32/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
33/// in a later release is additive.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36#[non_exhaustive]
37pub struct AppMeta {
38 /// The display name of the application (e.g. `"Acme Blog"`). Shown as the
39 /// admin brand unless overridden by a brand setting.
40 pub name: String,
41 /// The application's public base URL (scheme and host, with any port), such
42 /// as `https://acme.example`. It is the canonical origin absolute links build
43 /// on, distinct from the server bind address (they differ behind a proxy or a
44 /// local `.test` domain). When unset, [`base_url`] derives it from the bind
45 /// address.
46 pub url: Option<String>,
47 /// The application's default locale (BCP 47, e.g. `"en"`): the deployment
48 /// default for UI-string translation, the last stop in a request's locale
49 /// chain before the English source when neither the operator's preference nor
50 /// the request's `Accept-Language` resolves.
51 pub locale: String,
52 /// Debug mode: error pages include the internal cause. Off by default;
53 /// enable only in development. Read via [`debug`] where request context is
54 /// not available (error rendering).
55 pub debug: bool,
56 /// The prefix of the environment variables that override this configuration
57 /// (`<PREFIX>__SECTION__KEY`). Declared here so the application and the `lat`
58 /// command agree on it; unset means the framework default. Read from the files
59 /// alone by [`declared_env_prefix`], since it selects the environment layer.
60 pub env_prefix: Option<String>,
61 /// The application key, `id:<base64 of 32 bytes>` (`lat key generate`
62 /// writes one; `LAT__APP__KEY` overrides). Everything the framework
63 /// encrypts or signs uses it. Unset, a key derived from `backend.secret` or
64 /// the key stored at the first boot is used, so a deployment without one
65 /// keeps working; a production deployment sets it.
66 pub key: Option<String>,
67 /// Keys retired by a rotation, each `id:<base64>`, still read; see
68 /// [`crate::crypt`].
69 pub previous_keys: Vec<String>,
70}
71
72impl Default for AppMeta {
73 fn default() -> Self {
74 Self {
75 name: "Laterite".to_string(),
76 url: None,
77 locale: "en".to_string(),
78 debug: false,
79 env_prefix: None,
80 key: None,
81 previous_keys: Vec::new(),
82 }
83 }
84}
85
86/// Records whether the deployment is in debug mode, once, at boot from the loaded
87/// config. Later calls are ignored (the first wins), so error rendering has a
88/// stable answer without threading config through every handler.
89pub fn set_debug(on: bool) {
90 let _ = DEBUG.set(on);
91}
92
93/// Whether the deployment is in debug mode (default `false` until [`set_debug`]).
94pub fn debug() -> bool {
95 *DEBUG.get().unwrap_or(&false)
96}
97
98static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
99
100/// The application's public base URL, without a trailing slash: the configured
101/// `url` when set and non-empty, otherwise derived from the server bind address
102/// as `http://<listen>`. A wildcard bind host (`0.0.0.0` or `[::]`) is shown via
103/// loopback so the result is browsable. Absolute links (the admin URL in the
104/// startup banner, and later notifications and share cards) build on this.
105pub fn base_url(url: Option<&str>, listen: &str) -> String {
106 match url.map(str::trim).filter(|u| !u.is_empty()) {
107 Some(url) => url.trim_end_matches('/').to_string(),
108 None => format!("http://{}", browsable_host(listen)),
109 }
110}
111
112/// Rewrites a wildcard bind host to a loopback address so a bind string like
113/// `0.0.0.0:8080` yields a URL a browser can actually open.
114fn browsable_host(listen: &str) -> String {
115 if let Some(port) = listen.strip_prefix("0.0.0.0") {
116 format!("127.0.0.1{port}")
117 } else if let Some(port) = listen.strip_prefix("[::]") {
118 format!("[::1]{port}")
119 } else {
120 listen.to_string()
121 }
122}
123
124/// The queue (`[queue]`): where jobs wait and who runs them.
125#[derive(Debug, Clone, Deserialize)]
126#[serde(default)]
127pub struct QueueConfig {
128 /// `auto`, `database` or `redis`. `auto` is `database`.
129 pub store: String,
130 /// The Redis URL (`redis://host:6379/0`) for the `redis` store.
131 pub url: Option<String>,
132 /// The prefix of every Redis key, `laterite` by default.
133 pub prefix: String,
134 /// `worker` (workers in this process), `external` (another process runs
135 /// `queue work`) or `inline` (a job runs as it is pushed).
136 pub mode: String,
137 /// How many workers the process runs in `worker` mode.
138 pub workers: usize,
139 /// The queues a worker takes from, in priority order.
140 pub queues: Vec<String>,
141 /// Seconds after which a claimed job counts as abandoned and is claimed
142 /// again. Longer than any job's timeout.
143 pub retry_after_seconds: u64,
144 /// Seconds an idle worker waits before looking again.
145 pub poll_seconds: u64,
146}
147
148impl Default for QueueConfig {
149 fn default() -> Self {
150 Self {
151 store: "auto".to_string(),
152 url: None,
153 prefix: "laterite".to_string(),
154 mode: "worker".to_string(),
155 workers: 2,
156 queues: vec!["default".to_string()],
157 retry_after_seconds: 90,
158 poll_seconds: 1,
159 }
160 }
161}
162
163/// The scheduler (`[schedule]`): who evaluates the entries each minute.
164#[derive(Debug, Clone, Deserialize)]
165#[serde(default)]
166pub struct ScheduleConfig {
167 /// `ticker` (this process evaluates the entries every minute), `external`
168 /// (another process runs `schedule run`) or `off`.
169 pub mode: String,
170}
171
172impl Default for ScheduleConfig {
173 fn default() -> Self {
174 Self {
175 mode: "ticker".to_string(),
176 }
177 }
178}
179
180/// The cache (`[cache]`): where values are kept and how the processes of a
181/// deployment learn of one another's invalidations.
182#[derive(Debug, Clone, Deserialize)]
183#[serde(default)]
184pub struct CacheConfig {
185 /// `auto`, `null`, `memory` or `redis`. `auto` is `null` while `app.debug`
186 /// is on and `memory` otherwise.
187 pub store: String,
188 /// The Redis URL (`redis://host:6379/0`) for the `redis` store.
189 pub url: Option<String>,
190 /// The prefix of every Redis key, `laterite` by default.
191 pub prefix: String,
192 /// The `memory` store's capacity in MiB.
193 pub memory_mib: u64,
194 /// How often, in seconds, a process reads the generations the others
195 /// bumped; `0` disables it for a single-process deployment.
196 pub poll_seconds: u64,
197}
198
199impl Default for CacheConfig {
200 fn default() -> Self {
201 Self {
202 store: "auto".to_string(),
203 url: None,
204 prefix: "laterite".to_string(),
205 memory_mib: 64,
206 poll_seconds: 2,
207 }
208 }
209}
210
211/// Deployment-level backend settings. Per-install brand and per-operator preferences
212/// live in the settings and preferences stores, not here.
213///
214/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
215/// in a later release is additive.
216#[derive(Debug, Clone, Deserialize)]
217#[serde(default)]
218#[non_exhaustive]
219pub struct BackendConfig {
220 /// Set the `Secure` attribute on the admin session cookie. Enable behind HTTPS
221 /// in production; leave off for plain-HTTP local development.
222 pub secure_cookie: bool,
223 /// The key the admin signs its tokens with (a private file's URL). Unset,
224 /// a key is generated at the first boot and kept in the settings table, so
225 /// set it when several instances share a database behind one hostname.
226 pub secret: Option<String>,
227 /// The default display timezone for the admin (an IANA name like
228 /// `Asia/Kolkata`). Storage is always UTC; this only affects how dates render.
229 /// An operator's own preference overrides it (later); it falls back to UTC.
230 pub timezone: String,
231 /// Admin path overrides, keyed by module id (`"rainmill.location"`) or by a
232 /// module id and one contribution's declared path
233 /// (`"rainmill.location/nodes"`), the more specific winning. Values are
234 /// absolute under the admin mount, so they survive a relocated panel. Lets a
235 /// deployment move a plugin's screens without forking it.
236 pub paths: std::collections::BTreeMap<String, String>,
237 /// Arrangement overrides, keyed by hierarchy (`"backend.nav"`,
238 /// `"backend.settings"`, a plugin's own): the keys in the wanted order.
239 /// Keys left out keep their arrangement after the listed ones; a fixed
240 /// hierarchy ignores its entry.
241 pub order: std::collections::BTreeMap<String, Vec<String>>,
242 /// Entries moved from the home their author gave them, keyed by the
243 /// entry (`"backend.access_log"`, `"resource:/orders"`); see
244 /// [`crate::arrange::Move`].
245 pub menu: std::collections::BTreeMap<String, crate::arrange::Move>,
246 /// The URL path the admin panel is mounted under, without a trailing slash
247 /// (default `/admin`). Change it to move or obscure the panel (`/manage`,
248 /// `/backend`). A leading slash is added if missing and a trailing slash is
249 /// stripped; an empty value falls back to `/admin`.
250 pub path: String,
251 /// Networks whose `X-Forwarded-For` header may be believed, as CIDR ranges
252 /// (`"10.0.0.0/8"`). The header is caller-controlled, so it is read only when
253 /// the connecting peer falls inside one of these; the client is then the
254 /// rightmost address in the chain that is not itself a trusted proxy.
255 ///
256 /// Empty (the default) trusts nothing and records the peer address, which is
257 /// right for a directly bound server. Set it to the load balancer's network
258 /// when there is one: leaving it empty behind a proxy records the proxy on
259 /// every row, and filling it in for a network that is not a proxy lets anyone
260 /// on it forge the address in the audit log.
261 pub trusted_proxies: Vec<String>,
262 /// The directory files the admin keeps are stored under (default
263 /// `storage`), relative to the working directory unless absolute. Uploaded
264 /// import files go to `private` beneath it.
265 pub storage: String,
266 /// The admin skin by code: `standard` (the default), `compact`, or one a
267 /// module registered. An unknown code aborts the boot.
268 pub skin: String,
269 /// The file extensions an upload may carry (`["jpg", "png", "pdf"]`).
270 /// Empty, the admin's default list applies: images, documents, audio,
271 /// video, fonts and archives, with no scripts, markup or stylesheets.
272 pub upload_extensions: Vec<String>,
273}
274
275impl Default for BackendConfig {
276 fn default() -> Self {
277 Self {
278 secure_cookie: false,
279 secret: None,
280 timezone: "UTC".to_string(),
281 paths: std::collections::BTreeMap::new(),
282 order: std::collections::BTreeMap::new(),
283 menu: std::collections::BTreeMap::new(),
284 path: "/admin".to_string(),
285 trusted_proxies: Vec::new(),
286 storage: "storage".to_string(),
287 skin: "standard".to_string(),
288 upload_extensions: Vec::new(),
289 }
290 }
291}
292
293fn default_max_connections() -> u32 {
294 10
295}
296
297fn default_acquire_timeout_secs() -> u64 {
298 5
299}
300
301fn default_listen() -> String {
302 "127.0.0.1:8080".to_string()
303}
304
305/// Loads a layered configuration into any deserializable type.
306///
307/// Layers, later overriding earlier:
308/// 1. `<dir>/default.toml` (required)
309/// 2. `<dir>/<APP_ENV>.toml` (optional; `APP_ENV` defaults to `development`)
310/// 3. `<dir>/local.toml` (optional, git-ignored developer overrides)
311/// 4. Environment variables `<PREFIX>__SECTION__KEY` (e.g. `LAT__DATABASE__URL`);
312/// the prefix an application declares as `app.env_prefix` is read with
313/// [`declared_env_prefix`] and passed here.
314pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
315 file_layers(dir)
316 .add_source(
317 Environment::with_prefix(env_prefix)
318 .prefix_separator("__")
319 .separator("__"),
320 )
321 .build()
322 .and_then(Config::try_deserialize)
323 .map_err(|e| CoreError::Config(e.to_string()))
324}
325
326/// The environment-variable prefix a configuration declares as `app.env_prefix`,
327/// read from the files alone: it selects the environment layer, so it cannot come
328/// from there. `None` when undeclared or blank, meaning the framework default.
329pub fn declared_env_prefix(dir: &Path) -> CoreResult<Option<String>> {
330 #[derive(Deserialize)]
331 struct Declared {
332 #[serde(default)]
333 app: DeclaredApp,
334 }
335 #[derive(Deserialize, Default)]
336 struct DeclaredApp {
337 env_prefix: Option<String>,
338 }
339 let declared: Declared = file_layers(dir)
340 .build()
341 .and_then(Config::try_deserialize)
342 .map_err(|e| CoreError::Config(e.to_string()))?;
343 Ok(declared
344 .app
345 .env_prefix
346 .map(|prefix| prefix.trim().to_string())
347 .filter(|prefix| !prefix.is_empty()))
348}
349
350/// The file layers of a config directory, in override order: `default.toml`
351/// (required), `<APP_ENV>.toml`, then `local.toml`.
352fn file_layers(dir: &Path) -> config::ConfigBuilder<config::builder::DefaultState> {
353 let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
354 Config::builder()
355 .add_source(File::from(dir.join("default.toml")).required(true))
356 .add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
357 .add_source(File::from(dir.join("local.toml")).required(false))
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[derive(Deserialize)]
365 struct TestConfig {
366 server: ServerConfig,
367 database: DatabaseConfig,
368 }
369
370 #[test]
371 fn loads_defaults_env_overrides_and_serde_defaults() {
372 let dir = tempfile::tempdir().unwrap();
373 std::fs::write(
374 dir.path().join("default.toml"),
375 "[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
376 )
377 .unwrap();
378 std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
379 let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
380 std::env::remove_var("LATERITE_TEST__DATABASE__URL");
381 assert_eq!(cfg.server.listen, "0.0.0.0:9999");
382 assert_eq!(cfg.database.url, "postgres://from-env");
383 assert_eq!(cfg.database.max_connections, 10);
384 assert_eq!(cfg.database.acquire_timeout_secs, 5);
385 }
386
387 #[test]
388 fn backend_config_defaults_and_loads() {
389 #[derive(Deserialize)]
390 struct C {
391 #[serde(default)]
392 backend: BackendConfig,
393 }
394 let dir = tempfile::tempdir().unwrap();
395 std::fs::write(dir.path().join("default.toml"), "").unwrap();
396 let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
397 assert!(!c.backend.secure_cookie);
398 assert_eq!(c.backend.timezone, "UTC");
399
400 std::fs::write(
401 dir.path().join("default.toml"),
402 "[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
403 )
404 .unwrap();
405 let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
406 assert!(c.backend.secure_cookie);
407 assert_eq!(c.backend.timezone, "Asia/Kolkata");
408 }
409
410 #[test]
411 fn declared_env_prefix_comes_from_the_files_alone() {
412 let dir = tempfile::tempdir().unwrap();
413 std::fs::write(dir.path().join("default.toml"), "[app]\nname = \"Acme\"\n").unwrap();
414 assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
415 std::fs::write(
416 dir.path().join("default.toml"),
417 "[app]\nenv_prefix = \"ACME\"\n",
418 )
419 .unwrap();
420 assert_eq!(
421 declared_env_prefix(dir.path()).unwrap().as_deref(),
422 Some("ACME")
423 );
424 // local.toml overrides it, and a blank value counts as undeclared.
425 std::fs::write(
426 dir.path().join("local.toml"),
427 "[app]\nenv_prefix = \" \"\n",
428 )
429 .unwrap();
430 assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
431 }
432
433 #[test]
434 fn base_url_prefers_config_then_derives_from_listen() {
435 // Configured URL wins and loses any trailing slash.
436 assert_eq!(
437 base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
438 "https://acme.example"
439 );
440 // Blank configured URL falls through to the derived form.
441 assert_eq!(
442 base_url(Some(" "), "127.0.0.1:9000"),
443 "http://127.0.0.1:9000"
444 );
445 // No URL: derive from the bind address.
446 assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
447 // A wildcard bind host is shown via loopback so the URL is browsable.
448 assert_eq!(base_url(None, "0.0.0.0:8080"), "http://127.0.0.1:8080");
449 assert_eq!(base_url(None, "[::]:8080"), "http://[::1]:8080");
450 }
451
452 #[test]
453 fn missing_default_file_is_a_config_error() {
454 let dir = tempfile::tempdir().unwrap();
455 let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
456 assert!(matches!(result, Err(CoreError::Config(_))));
457 }
458}