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