Skip to main content

laterite_admin/
bootstrap.rs

1//! Application bootstrap: one call to load config, connect, migrate, build the
2//! admin router, and serve it.
3//!
4//! [`Bootstrap`] keeps an application's `main` small and stable: framework
5//! internals (config fields, the migration runner, router assembly) change here,
6//! not in every app. An app registers its own modules; each module supplies its
7//! migrations and admin surfaces (resources, permissions, settings) from its
8//! `register`, and the app adds extra routes via [`Bootstrap::extend`].
9
10use std::collections::{HashMap, HashSet};
11use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
12use std::path::PathBuf;
13
14use axum::Router;
15use laterite_auth::{AuthConfig, AuthService};
16use laterite_core::config::{self, AppMeta, BackendConfig, DatabaseConfig, ServerConfig};
17use laterite_core::{CapabilitySet, Db, Module, ModuleRegistry, Registry, Translator};
18use serde::Deserialize;
19
20use crate::settings::SettingsItem;
21use crate::{builtin_modules, normalize_path, router, AdminConfig, Permission, Resource};
22
23/// Default env-var prefix for config overrides (`LAT__SECTION__KEY`). Tooling like
24/// `lat serve` relies on it; override per app with [`Bootstrap::env_prefix`].
25pub const DEFAULT_ENV_PREFIX: &str = "LAT";
26
27/// The configuration [`Bootstrap`] loads. An app needing more loads its own with
28/// [`laterite_core::config::load`].
29#[derive(Debug, Clone, Deserialize)]
30pub struct AppConfig {
31    #[serde(default)]
32    pub app: AppMeta,
33    pub server: ServerConfig,
34    pub database: DatabaseConfig,
35    #[serde(default)]
36    pub auth: AuthConfig,
37    #[serde(default)]
38    pub backend: BackendConfig,
39    #[serde(default)]
40    pub plugins: PluginsConfig,
41}
42
43/// Deployment control over which plugins load. A disabled plugin's migrations do
44/// not run and it contributes nothing; any plugin that `requires` a disabled one
45/// is skipped in turn. Built-in modules cannot be disabled.
46#[derive(Debug, Clone, Deserialize, Default)]
47pub struct PluginsConfig {
48    /// Module ids to disable at boot, e.g. `disabled = ["rainmill.location"]`.
49    #[serde(default)]
50    pub disabled: Vec<String>,
51}
52
53/// Passed to [`Bootstrap::extend`] so an app's own routes can share the database
54/// and the detected database capabilities.
55pub struct BootstrapCtx {
56    db: Db,
57    capabilities: CapabilitySet,
58    translator: Translator,
59}
60
61impl BootstrapCtx {
62    /// The connected database, for an app's routes to build their state on.
63    pub fn db(&self) -> Db {
64        self.db.clone()
65    }
66
67    /// The database capabilities available on this deployment, for gating
68    /// optional features.
69    pub fn capabilities(&self) -> &CapabilitySet {
70        &self.capabilities
71    }
72
73    /// The UI-string translator for the configured locale, for an app's routes
74    /// to resolve their own strings.
75    pub fn translator(&self) -> &Translator {
76        &self.translator
77    }
78}
79
80type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
81
82/// Builds and serves a Laterite application.
83pub struct Bootstrap {
84    config_dir: PathBuf,
85    env_prefix: String,
86    app_modules: Vec<Box<dyn Module>>,
87    plugin_modules: Vec<Box<dyn Module>>,
88    extend: Option<ExtendFn>,
89}
90
91impl Bootstrap {
92    /// Loads config from `config_dir` (`default.toml`, the `APP_ENV` overlay, and
93    /// `local.toml`). Env vars override under [`DEFAULT_ENV_PREFIX`]; change the
94    /// prefix with [`Bootstrap::env_prefix`].
95    pub fn new(config_dir: impl Into<PathBuf>) -> Self {
96        Self {
97            config_dir: config_dir.into(),
98            env_prefix: DEFAULT_ENV_PREFIX.to_string(),
99            app_modules: Vec::new(),
100            plugin_modules: Vec::new(),
101            extend: None,
102        }
103    }
104
105    /// Overrides the config env-var prefix (default [`DEFAULT_ENV_PREFIX`]).
106    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
107        self.env_prefix = prefix.into();
108        self
109    }
110
111    /// Registers one of the application's own modules, after the built-in ones. Its
112    /// migrations and contributions run after those of the modules it names in
113    /// `requires`. App modules are load-critical: a failure at boot aborts, rather
114    /// than being quarantined like a plugin registered with [`Bootstrap::modules`].
115    pub fn module(mut self, module: impl Module) -> Self {
116        self.app_modules.push(Box::new(module));
117        self
118    }
119
120    /// Registers a set of plugins, typically the generated `plugins-manifest`.
121    /// Plugins are isolated at boot: one that fails to migrate or register is
122    /// quarantined (logged and skipped, taking its dependents with it) rather than
123    /// aborting the whole application.
124    pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
125        self.plugin_modules.extend(modules);
126        self
127    }
128
129    /// Merges the app's own routes onto the admin router before serving. The
130    /// closure gets the assembled router and a [`BootstrapCtx`].
131    pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
132        self.extend = Some(Box::new(f));
133        self
134    }
135
136    /// Loads config, connects, migrates, builds the router, and serves. Uses a
137    /// `systemfd`-inherited socket in development, else binds the configured
138    /// address.
139    pub async fn serve(self) -> anyhow::Result<()> {
140        // Log subscriber (RUST_LOG, default below); try_init yields to an app's own.
141        let _ = tracing_subscriber::fmt()
142            .with_env_filter(
143                tracing_subscriber::EnvFilter::try_from_default_env()
144                    .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
145            )
146            .try_init();
147
148        let config: AppConfig = config::load(&self.config_dir, &self.env_prefix)?;
149        // Debug gates the cause on error pages; the log records it regardless.
150        laterite_core::config::set_debug(config.app.debug);
151
152        let db = laterite_core::db::connect(&config.database).await?;
153
154        // Plugins (registered via `modules`) are quarantine-eligible; built-ins and
155        // the app's own modules are load-critical. Capture the plugin ids before the
156        // modules move into the registry.
157        let plugin_ids: HashSet<String> = self
158            .plugin_modules
159            .iter()
160            .map(|m| m.id().to_string())
161            .collect();
162
163        // Create the plugin-registry table, record the compiled plugins in it, and
164        // read the operator's disabled set, all before deciding what loads. The
165        // built-in PluginsModule carries this same migration, applied idempotently in
166        // the load loop below.
167        laterite_core::migration::run(
168            &db.pool,
169            db.backend,
170            &[crate::plugins::PluginsModule.migrations()],
171        )
172        .await?;
173        crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
174
175        // Crash-loop journal: reconcile the previous boot's load markers against
176        // this binary's fingerprint. A plugin whose init took the whole process
177        // down under this same binary is auto-quarantined so it is not retried
178        // into the same crash; a quarantine recorded under a different binary is
179        // lifted, giving a fresh build another chance.
180        let fingerprint = crate::plugins::binary_fingerprint();
181        let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
182        for id in &reconcile.crashed {
183            eprintln!(
184                "Plugin '{id}' quarantined: {}.",
185                crate::plugins::CRASH_REASON
186            );
187        }
188        for id in &reconcile.cleared {
189            println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
190        }
191
192        let db_disabled = crate::plugins::disabled_ids(&db).await?;
193        let quarantined = crate::plugins::quarantined(&db).await?;
194
195        let mut registry = ModuleRegistry::new();
196        for module in builtin_modules() {
197            registry.register_boxed(module);
198        }
199        for module in self.app_modules {
200            registry.register_boxed(module);
201        }
202        for module in self.plugin_modules {
203            registry.register_boxed(module);
204        }
205        let capabilities =
206            laterite_core::capabilities::check(&db.pool, db.backend, &registry).await?;
207
208        // The modules in dependency order, driving both migrations and
209        // registration so a module's tables and contributions follow its
210        // requirements'.
211        let ordered = registry.ordered()?;
212
213        // Narrow to the modules that will actually load. Config can disable a
214        // plugin, and any plugin that requires a disabled one is skipped in turn.
215        // The registry itself is untouched, so ordered()'s dependency checks still
216        // hold; this only decides what migrates and registers.
217        let builtins: HashSet<String> = builtin_modules()
218            .iter()
219            .map(|m| m.id().to_string())
220            .collect();
221        let mut seed: HashMap<String, String> = HashMap::new();
222        for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
223            if builtins.contains(id) {
224                eprintln!("Ignoring request to disable built-in module '{id}'.");
225            } else {
226                seed.insert(id.clone(), "disabled".to_string());
227            }
228        }
229        // Quarantined plugins are skipped like disabled ones, but keep the
230        // system's reason. A built-in never carries a quarantine, but guard anyway.
231        for (id, reason) in &quarantined {
232            if !builtins.contains(id) {
233                seed.insert(id.clone(), format!("quarantined: {reason}"));
234            }
235        }
236        let skipped = skip_plan(&ordered, &seed);
237        report_plugins(&ordered, &skipped);
238        let active: Vec<&dyn Module> = ordered
239            .iter()
240            .copied()
241            .filter(|m| !skipped.contains_key(m.id().as_str()))
242            .collect();
243
244        // Load each active module in dependency order: migrate its tables, then
245        // register its contributions. A plugin that fails either step is quarantined
246        // (its partial contributions rolled back) and its dependents skipped; a
247        // built-in or app module failing is fatal.
248        let mut contributions = Registry::new();
249        let mut failed: HashMap<String, String> = HashMap::new();
250        for module in &active {
251            let mid = module.id();
252            let id = mid.as_str();
253            let eligible = plugin_ids.contains(id);
254
255            if let Some(dep) = module
256                .requires()
257                .iter()
258                .find(|r| failed.contains_key(r.as_str()))
259            {
260                let reason = format!("requires {}, which failed to load", dep.as_str());
261                println!("Plugin '{id}' off: {reason}");
262                failed.insert(id.to_string(), reason);
263                continue;
264            }
265
266            // Journal the attempt before any of the plugin's own code runs, so an
267            // uncatchable crash during migrate or register leaves a marker the next
268            // boot will find. Only plugins are journaled: a built-in or app module
269            // crashing is a fatal bug to fix, not something to quarantine.
270            if eligible {
271                crate::plugins::mark_loading(&db, id, &fingerprint).await?;
272            }
273
274            match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
275            {
276                Ok(()) => {}
277                Err(e) if !eligible => return Err(e.into()),
278                Err(e) => {
279                    let reason = format!("migration failed: {e}");
280                    eprintln!("Plugin '{id}' quarantined: {reason}");
281                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
282                    failed.insert(id.to_string(), reason);
283                    continue;
284                }
285            }
286
287            contributions.set_owner(mid);
288            let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
289            match registered {
290                // Init succeeded: clear the marker so this plugin is not blamed for
291                // a crash later in the boot.
292                Ok(()) => {
293                    if eligible {
294                        crate::plugins::clear_loading(&db, id).await?;
295                    }
296                }
297                Err(panic) => {
298                    contributions.purge_owner(mid);
299                    if !eligible {
300                        resume_unwind(panic);
301                    }
302                    let reason = "register panicked".to_string();
303                    eprintln!("Plugin '{id}' quarantined: {reason}");
304                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
305                    failed.insert(id.to_string(), reason);
306                }
307            }
308        }
309        let resources = contributions.take::<Resource>();
310        let settings = contributions.take::<SettingsItem>();
311        let permissions = contributions.take::<Permission>();
312        let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
313        let persisters = contributions.take::<crate::persist::PersisterReg>();
314
315        let auth = AuthService::new(db.clone(), config.auth.clone());
316        let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
317        let admin_config = AdminConfig {
318            secure_cookie: config.backend.secure_cookie,
319            timezone: config.backend.timezone.clone(),
320            locale: config.app.locale.clone(),
321            app_name: config.app.name.clone(),
322            path: config.backend.path.clone(),
323            origin: origin.clone(),
324        };
325        // Merge every loaded module's PO catalogs into the shared store the request
326        // translator looks up against. A malformed catalog fails boot, like a bad
327        // descriptor key. Skipped and failed modules do not contribute.
328        let mut catalogs = laterite_core::CatalogStore::builder();
329        for module in active
330            .iter()
331            .filter(|m| !failed.contains_key(m.id().as_str()))
332        {
333            for (locale, po) in module.catalogs() {
334                catalogs = catalogs.po(locale, po).map_err(|e| {
335                    laterite_core::CoreError::Config(format!(
336                        "module {}: {locale} catalog: {e}",
337                        module.id().as_str()
338                    ))
339                })?;
340            }
341        }
342
343        let mut app = router(
344            auth,
345            db.clone(),
346            resources,
347            settings,
348            permissions,
349            picker_sources,
350            persisters,
351            admin_config,
352            std::sync::Arc::new(catalogs.build()),
353        );
354
355        if let Some(extend) = self.extend {
356            let ctx = BootstrapCtx {
357                db,
358                capabilities,
359                translator: Translator::new(&config.app.locale),
360            };
361            app = extend(app, &ctx);
362        }
363
364        let listener = bind(&config.server.listen).await?;
365        let admin_path = normalize_path(&config.backend.path);
366        println!("{} on {origin}{admin_path}", config.app.name);
367        axum::serve(listener, app).await?;
368        Ok(())
369    }
370}
371
372/// Binds the listener, preferring a `systemfd`-inherited socket (so the port
373/// survives reloads) and falling back to binding `listen`.
374async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
375    use listenfd::ListenFd;
376    match ListenFd::from_env().take_tcp_listener(0)? {
377        Some(std_listener) => {
378            std_listener.set_nonblocking(true)?;
379            Ok(tokio::net::TcpListener::from_std(std_listener)?)
380        }
381        None => Ok(tokio::net::TcpListener::bind(listen).await?),
382    }
383}
384
385/// Given modules in dependency order (dependencies first) and a `seed` of ids
386/// already known to be off (disabled or quarantined) each with its reason,
387/// returns the full set to skip, each with a reason. One forward pass suffices
388/// because `ordered` places a module after everything it requires: a seeded
389/// module is skipped, and so is anything that requires an already-skipped one.
390fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
391    let mut skipped: HashMap<String, String> = HashMap::new();
392    for module in ordered {
393        let id = module.id().as_str();
394        if let Some(reason) = seed.get(id) {
395            skipped.insert(id.to_string(), reason.clone());
396            continue;
397        }
398        if let Some(dep) = module
399            .requires()
400            .iter()
401            .find(|r| skipped.contains_key(r.as_str()))
402        {
403            skipped.insert(
404                id.to_string(),
405                format!("requires {}, which is off", dep.as_str()),
406            );
407        }
408    }
409    skipped
410}
411
412/// Prints one line per skipped plugin, in dependency order. Silent when every
413/// module loads, so a normal boot stays quiet.
414fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
415    if skipped.is_empty() {
416        return;
417    }
418    for module in ordered {
419        if let Some(reason) = skipped.get(module.id().as_str()) {
420            println!("Plugin '{}' off: {reason}", module.id());
421        }
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use laterite_core::ModuleId;
429
430    #[test]
431    fn env_prefix_defaults_and_overrides() {
432        assert_eq!(Bootstrap::new("config").env_prefix, DEFAULT_ENV_PREFIX);
433        assert_eq!(
434            Bootstrap::new("config").env_prefix("HIVE").env_prefix,
435            "HIVE"
436        );
437    }
438
439    #[test]
440    fn app_config_loads_named_sections_and_falls_back_to_defaults() {
441        let dir = tempfile::tempdir().unwrap();
442        std::fs::write(
443            dir.path().join("default.toml"),
444            "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
445             [database]\nurl = \"postgres://x\"\n",
446        )
447        .unwrap();
448        let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
449        assert_eq!(cfg.app.name, "Acme");
450        assert_eq!(cfg.server.listen, "0.0.0.0:9000");
451        assert_eq!(cfg.database.url, "postgres://x");
452        assert_eq!(cfg.backend.path, "/admin");
453        assert!(!cfg.backend.secure_cookie);
454        assert!(cfg.app.url.is_none());
455    }
456
457    struct M(&'static str, &'static [ModuleId]);
458    impl Module for M {
459        fn id(&self) -> ModuleId {
460            ModuleId::new(self.0)
461        }
462        fn requires(&self) -> &'static [ModuleId] {
463            self.1
464        }
465    }
466
467    #[test]
468    fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
469        const NONE: [ModuleId; 0] = [];
470        const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
471        const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
472        // Dependency order (deps first): a, b (requires a), c (requires b), and an
473        // independent d.
474        let (a, b, c, d) = (
475            M("acme.a", &NONE),
476            M("acme.b", &REQ_A),
477            M("acme.c", &REQ_B),
478            M("acme.d", &NONE),
479        );
480        let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
481
482        let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
483            .into_iter()
484            .collect();
485        let skipped = skip_plan(&ordered, &seed);
486
487        // a is off by the seed; b then c cascade off; d is unaffected.
488        assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
489        assert!(skipped["acme.b"].contains("requires acme.a"));
490        assert!(skipped["acme.c"].contains("requires acme.b"));
491        assert!(!skipped.contains_key("acme.d"));
492    }
493}