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    #[serde(default)]
65    pub cache: laterite_core::config::CacheConfig,
66    #[serde(default)]
67    pub queue: laterite_core::config::QueueConfig,
68    #[serde(default)]
69    pub schedule: laterite_core::config::ScheduleConfig,
70    #[serde(default)]
71    pub mail: laterite_core::config::MailConfig,
72}
73
74/// Deployment control over which plugins load. A disabled plugin's migrations do
75/// not run and it contributes nothing; any plugin that `requires` a disabled one
76/// is skipped in turn. Built-in modules cannot be disabled.
77#[derive(Debug, Clone, Deserialize, Default)]
78pub struct PluginsConfig {
79    /// Module ids to disable at boot, e.g. `disabled = ["rainmill.location"]`.
80    #[serde(default)]
81    pub disabled: Vec<String>,
82}
83
84/// Passed to [`Bootstrap::extend`] so an app's own routes can share the database
85/// and the detected database capabilities.
86pub struct BootstrapCtx {
87    db: Db,
88    capabilities: CapabilitySet,
89    translator: Translator,
90    events: laterite_core::Events,
91    cache: laterite_core::Cache,
92    queue: laterite_core::Queue,
93    schedule: laterite_core::Schedule,
94    mail: laterite_core::Mail,
95}
96
97impl BootstrapCtx {
98    /// The connected database, for an app's routes to build their state on.
99    pub fn db(&self) -> Db {
100        self.db.clone()
101    }
102
103    /// The database capabilities available on this deployment, for gating
104    /// optional features.
105    pub fn capabilities(&self) -> &CapabilitySet {
106        &self.capabilities
107    }
108
109    /// The UI-string translator for the configured locale, for an app's routes
110    /// to resolve their own strings.
111    pub fn translator(&self) -> &Translator {
112        &self.translator
113    }
114
115    /// The application's event bus, with every module's listeners on it, for an
116    /// app's routes to announce on.
117    pub fn events(&self) -> &laterite_core::Events {
118        &self.events
119    }
120
121    /// The application's cache, for an app's routes to read through and
122    /// bump. See [`laterite_core::cache`].
123    pub fn cache(&self) -> &laterite_core::Cache {
124        &self.cache
125    }
126
127    /// The application's queue, for an app's routes to push jobs on. See
128    /// [`laterite_core::queue`].
129    pub fn queue(&self) -> &laterite_core::Queue {
130        &self.queue
131    }
132
133    /// The application's schedule, for an app's routes to list or run an
134    /// entry. See [`laterite_core::schedule`].
135    pub fn schedule(&self) -> &laterite_core::Schedule {
136        &self.schedule
137    }
138
139    /// The mail service, for an app's routes to send through. See
140    /// [`laterite_core::mail`].
141    pub fn mail(&self) -> &laterite_core::Mail {
142        &self.mail
143    }
144}
145
146type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
147
148/// Builds and serves a Laterite application.
149pub struct Bootstrap {
150    config_dir: PathBuf,
151    env_prefix: Option<String>,
152    app_modules: Vec<Box<dyn Module>>,
153    plugin_modules: Vec<Box<dyn Module>>,
154    extend: Option<ExtendFn>,
155}
156
157impl Bootstrap {
158    /// Loads config from `config_dir` (`default.toml`, the `APP_ENV` overlay, and
159    /// `local.toml`). Env vars override under the prefix the config declares as
160    /// `app.env_prefix`, else [`DEFAULT_ENV_PREFIX`]; [`Bootstrap::env_prefix`]
161    /// overrides both.
162    pub fn new(config_dir: impl Into<PathBuf>) -> Self {
163        Self {
164            config_dir: config_dir.into(),
165            env_prefix: None,
166            app_modules: Vec::new(),
167            plugin_modules: Vec::new(),
168            extend: None,
169        }
170    }
171
172    /// Overrides the config env-var prefix from code, above the config's own
173    /// `app.env_prefix` declaration and [`DEFAULT_ENV_PREFIX`].
174    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
175        self.env_prefix = Some(prefix.into());
176        self
177    }
178
179    /// Registers one of the application's own modules, after the built-in ones. Its
180    /// migrations and contributions run after those of the modules it names in
181    /// `requires`. App modules are load-critical: a failure at boot aborts, rather
182    /// than being quarantined like a plugin registered with [`Bootstrap::modules`].
183    pub fn module(mut self, module: impl Module) -> Self {
184        self.app_modules.push(Box::new(module));
185        self
186    }
187
188    /// Registers a set of plugins, typically the generated `plugins-manifest`.
189    /// Plugins are isolated at boot: one that fails to migrate or register is
190    /// quarantined (logged and skipped, taking its dependents with it) rather than
191    /// aborting the whole application.
192    pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
193        self.plugin_modules.extend(modules);
194        self
195    }
196
197    /// Merges the app's own routes onto the admin router before serving. The
198    /// closure gets the assembled router and a [`BootstrapCtx`].
199    pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
200        self.extend = Some(Box::new(f));
201        self
202    }
203
204    /// Loads config, connects, migrates, builds the router, and serves, with
205    /// the queue's workers beside it when `queue.mode` is `worker`. Uses a
206    /// `systemfd`-inherited socket in development, else binds the configured
207    /// address. Stops on Ctrl-C or SIGTERM, letting running jobs finish.
208    pub async fn serve(self) -> anyhow::Result<()> {
209        self.prepare().await?.serve().await
210    }
211
212    /// Reads the process's own arguments and does what they ask: `serve`
213    /// (the default, as [`Bootstrap::serve`]), `queue work [--once]
214    /// [--stop-when-empty] [--queue a,b]` (a worker alone) or `schedule run
215    /// [--once]` (the ticker alone). The scaffolded `main` calls this, so
216    /// `lat queue work` and `lat schedule run` have something to run.
217    pub async fn run(self) -> anyhow::Result<()> {
218        let args: Vec<String> = std::env::args().skip(1).collect();
219        match args.first().map(String::as_str) {
220            None | Some("serve") => self.serve().await,
221            Some("queue") if args.get(1).map(String::as_str) == Some("work") => {
222                self.prepare().await?.work(&args[2..]).await
223            }
224            Some("schedule") if args.get(1).map(String::as_str) == Some("run") => {
225                self.prepare().await?.tick(&args[2..]).await
226            }
227            Some(other) => anyhow::bail!(
228                "unknown command `{other}`: the application takes `serve` (the default), `queue work` or `schedule run`"
229            ),
230        }
231    }
232
233    /// Everything before listening: config, the database, migrations, the
234    /// registry, the router, the services.
235    async fn prepare(self) -> anyhow::Result<Prepared> {
236        // Log subscriber (RUST_LOG, default below); try_init yields to an app's own.
237        let _ = tracing_subscriber::fmt()
238            .with_env_filter(
239                tracing_subscriber::EnvFilter::try_from_default_env()
240                    .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
241            )
242            .try_init();
243
244        let env_prefix = resolve_env_prefix(
245            self.env_prefix.clone(),
246            config::declared_env_prefix(&self.config_dir)?,
247        );
248        let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
249        // Debug gates the cause on error pages; the log records it regardless.
250        laterite_core::config::set_debug(config.app.debug);
251
252        let db = laterite_core::db::connect(&config.database).await?;
253
254        // Plugins (registered via `modules`) are quarantine-eligible; built-ins and
255        // the app's own modules are load-critical. Capture the plugin ids before the
256        // modules move into the registry.
257        let plugin_ids: HashSet<String> = self
258            .plugin_modules
259            .iter()
260            .map(|m| m.id().to_string())
261            .collect();
262
263        // Create the plugin-registry table, record the compiled plugins in it, and
264        // read the operator's disabled set, all before deciding what loads. The
265        // built-in PluginsModule carries this same migration, applied idempotently in
266        // the load loop below.
267        laterite_core::migration::run(
268            &db.pool,
269            db.backend,
270            &[crate::plugins::PluginsModule.migrations()],
271        )
272        .await?;
273        crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
274
275        // Crash-loop journal: reconcile the previous boot's load markers against
276        // this binary's fingerprint. A plugin whose init took the whole process
277        // down under this same binary is auto-quarantined so it is not retried
278        // into the same crash; a quarantine recorded under a different binary is
279        // lifted, giving a fresh build another chance.
280        let fingerprint = crate::plugins::binary_fingerprint();
281        let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
282        for id in &reconcile.crashed {
283            eprintln!(
284                "Plugin '{id}' quarantined: {}.",
285                crate::plugins::CRASH_REASON
286            );
287        }
288        for id in &reconcile.cleared {
289            println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
290        }
291
292        let db_disabled = crate::plugins::disabled_ids(&db).await?;
293        let quarantined = crate::plugins::quarantined(&db).await?;
294
295        let mut registry = ModuleRegistry::new();
296        for module in builtin_modules() {
297            registry.register_boxed(module);
298        }
299        for module in self.app_modules {
300            registry.register_boxed(module);
301        }
302        for module in self.plugin_modules {
303            registry.register_boxed(module);
304        }
305        let capabilities =
306            laterite_core::capabilities::check(&db.pool, db.backend, &registry).await?;
307
308        // The modules in dependency order, driving both migrations and
309        // registration so a module's tables and contributions follow its
310        // requirements'.
311        let ordered = registry.ordered()?;
312
313        // Narrow to the modules that will actually load. Config can disable a
314        // plugin, and any plugin that requires a disabled one is skipped in turn.
315        // The registry itself is untouched, so ordered()'s dependency checks still
316        // hold; this only decides what migrates and registers.
317        let builtins: HashSet<String> = builtin_modules()
318            .iter()
319            .map(|m| m.id().to_string())
320            .collect();
321        let mut seed: HashMap<String, String> = HashMap::new();
322        for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
323            if builtins.contains(id) {
324                eprintln!("Ignoring request to disable built-in module '{id}'.");
325            } else {
326                seed.insert(id.clone(), "disabled".to_string());
327            }
328        }
329        // Quarantined plugins are skipped like disabled ones, but keep the
330        // system's reason. A built-in never carries a quarantine, but guard anyway.
331        for (id, reason) in &quarantined {
332            if !builtins.contains(id) {
333                seed.insert(id.clone(), format!("quarantined: {reason}"));
334            }
335        }
336        let skipped = skip_plan(&ordered, &seed);
337        report_plugins(&ordered, &skipped);
338        let active: Vec<&dyn Module> = ordered
339            .iter()
340            .copied()
341            .filter(|m| !skipped.contains_key(m.id().as_str()))
342            .collect();
343
344        // Load each active module in dependency order: migrate its tables, then
345        // register its contributions. A plugin that fails either step is quarantined
346        // (its partial contributions rolled back) and its dependents skipped; a
347        // built-in or app module failing is fatal.
348        let mut contributions = Registry::new();
349        let mut failed: HashMap<String, String> = HashMap::new();
350        for module in &active {
351            let mid = module.id();
352            let id = mid.as_str();
353            let eligible = plugin_ids.contains(id);
354
355            if let Some(dep) = module
356                .requires()
357                .iter()
358                .find(|r| failed.contains_key(r.as_str()))
359            {
360                let reason = format!("requires {}, which failed to load", dep.as_str());
361                println!("Plugin '{id}' off: {reason}");
362                failed.insert(id.to_string(), reason);
363                continue;
364            }
365
366            // Journal the attempt before any of the plugin's own code runs, so an
367            // uncatchable crash during migrate or register leaves a marker the next
368            // boot will find. Only plugins are journaled: a built-in or app module
369            // crashing is a fatal bug to fix, not something to quarantine.
370            if eligible {
371                crate::plugins::mark_loading(&db, id, &fingerprint).await?;
372            }
373
374            match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
375            {
376                Ok(()) => {}
377                Err(e) if !eligible => return Err(e.into()),
378                Err(e) => {
379                    let reason = format!("migration failed: {e}");
380                    eprintln!("Plugin '{id}' quarantined: {reason}");
381                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
382                    failed.insert(id.to_string(), reason);
383                    continue;
384                }
385            }
386
387            contributions.set_owner(mid);
388            let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
389            match registered {
390                // Init succeeded: clear the marker so this plugin is not blamed for
391                // a crash later in the boot.
392                Ok(()) => {
393                    if eligible {
394                        crate::plugins::clear_loading(&db, id).await?;
395                    }
396                }
397                Err(panic) => {
398                    contributions.purge_owner(mid);
399                    if !eligible {
400                        resume_unwind(panic);
401                    }
402                    let reason = "register panicked".to_string();
403                    eprintln!("Plugin '{id}' quarantined: {reason}");
404                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
405                    failed.insert(id.to_string(), reason);
406                }
407            }
408        }
409        // Resolve where each module's screens mount before any route is built,
410        // so a namespace, a declared base and a deployment override all land in
411        // one place and a clash is caught below.
412        // Every module has migrated and registered: now each boots, in the same
413        // order, seeing the whole registry. A plugin whose boot fails is
414        // quarantined like one whose migration failed; anything else aborts.
415        for module in &active {
416            let mid = module.id();
417            let id = mid.as_str();
418            if failed.contains_key(id) {
419                continue;
420            }
421            let cx = laterite_core::BootCx {
422                db: &db,
423                registry: &contributions,
424            };
425            match module.boot(&cx).await {
426                Ok(()) => {}
427                Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
428                Err(e) => {
429                    let reason = format!("boot failed: {e}");
430                    eprintln!("Plugin '{id}' quarantined: {reason}");
431                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
432                    contributions.purge_owner(mid);
433                    failed.insert(id.to_string(), reason);
434                }
435            }
436        }
437        let active: Vec<&dyn Module> = active
438            .into_iter()
439            .filter(|m| !failed.contains_key(m.id().as_str()))
440            .collect();
441        let bases: HashMap<String, Option<&'static str>> = active
442            .iter()
443            .map(|m| (m.id().to_string(), m.admin_base()))
444            .collect();
445        let mut resources = Vec::new();
446        for (owner, mut resource) in contributions.take_owned::<Resource>() {
447            let base = bases.get(owner.as_str()).copied().flatten();
448            crate::rebase_resource(
449                &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
450                &mut resource,
451            );
452            resources.push((owner, resource));
453        }
454        let mut screens = Vec::new();
455        for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
456            let base = bases.get(owner.as_str()).copied().flatten();
457            reg.base_path =
458                crate::routemap::resolve(owner, &reg.base_path, base, &config.backend.paths);
459            screens.push((owner, reg));
460        }
461
462        let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
463
464        // Every admin path in one list, the framework's own included, so a module
465        // shadowing a built-in screen is caught as loudly as two modules clashing.
466        let mut claims: Vec<(String, String)> = crate::builtin_resources()
467            .iter()
468            .map(|r| (r.base_path.clone(), "the framework".to_string()))
469            .collect();
470        claims.extend(
471            resources
472                .iter()
473                .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
474        );
475        claims.extend(
476            screens
477                .iter()
478                .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
479        );
480        crate::routemap::check_segments(&claims);
481        crate::routemap::check_collisions(&claims);
482        let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
483        // Every sealed column is on record, so `lat crypt rotate` finds it.
484        for resource in &resources {
485            if let Some(form) = &resource.form {
486                for field in form.fields.iter().filter(|f| f.field_type == "sensitive") {
487                    laterite_core::crypt::register_column(
488                        &db,
489                        &form.entity,
490                        &field.name,
491                        laterite_core::crypt::FIELD_PURPOSE,
492                    )
493                    .await?;
494                }
495            }
496        }
497        let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
498        // Public paths are literal, so they get their own check: two claims on one
499        // path, or anything reaching inside the panel.
500        crate::routemap::check_public(
501            &public
502                .iter()
503                .map(|(owner, r)| (r.path.clone(), owner.to_string()))
504                .collect::<Vec<_>>(),
505            &crate::normalize_path(&config.backend.path),
506        );
507        let public: Vec<crate::routes::PublicRouteReg> =
508            public.into_iter().map(|(_, r)| r).collect();
509        // Settings screens resolve like every other contributed screen: through
510        // the owning module's namespace. Before this they used their storage key
511        // as a URL, which is both a dotted path and a screen the deployment
512        // could not move with [backend.paths].
513        let mut settings = Vec::new();
514        for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
515            // The screen's own name within its module: the storage key with the
516            // module's prefix removed, since the namespace supplies that part.
517            let declared = item
518                .code
519                .strip_prefix(&format!("{}.", owner.as_str()))
520                .unwrap_or(&item.code)
521                .replace('.', "/");
522            let base = bases.get(owner.as_str()).copied().flatten();
523            item.mount_at(crate::routemap::resolve(
524                owner,
525                &format!("/{declared}"),
526                base,
527                &config.backend.paths,
528            ));
529            settings.push((owner, item));
530        }
531        // A settings screen claims a path like any other, so one shadowing a
532        // resource is caught at boot rather than silently winning.
533        let settings_claims: Vec<(String, String)> = settings
534            .iter()
535            .filter(|(_, i)| i.link.is_none())
536            .map(|(owner, i)| (i.route(), owner.to_string()))
537            .collect();
538        crate::routemap::check_segments(&settings_claims);
539        let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
540        // The cache, over the generations table every module has migrated,
541        // knowing every namespace a module registered.
542        let cache = laterite_core::cache::from_config(&config.cache, &db, config.app.debug).await?;
543        for (owner, namespace) in contributions.take_owned::<laterite_core::cache::CacheNamespace>()
544        {
545            cache.allow(namespace.qualified(owner));
546        }
547        if config.cache.poll_seconds > 0 {
548            cache.start_polling(std::time::Duration::from_secs(config.cache.poll_seconds));
549        }
550        tracing::info!(store = cache.store(), "cache ready");
551        let permissions = contributions.take::<Permission>();
552        let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
553        let persisters = contributions.take::<crate::persist::PersisterReg>();
554        let listeners = contributions.take::<laterite_core::ModelListenerReg>();
555        let field_types = contributions.take::<crate::field::FieldTypeReg>();
556        let column_types = contributions.take::<crate::list::ColumnTypeReg>();
557        let nav = contributions.take::<crate::nav::NavItem>();
558        let resource_hooks = contributions.take::<crate::ResourceHook>();
559        let widgets = contributions.take::<crate::widget::WidgetReg>();
560        let skins = contributions.take::<crate::skin::Skin>();
561        let assets = contributions.take::<crate::AssetReg>();
562        // Owned, so each icon is namespaced to the module that contributed it.
563        let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
564
565        // One bus for the application, carrying every listener a module
566        // contributed, in dependency order.
567        let mut bus = laterite_core::Events::builder(db.clone());
568        for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
569            bus = bus.registered(owner, listener);
570        }
571        let events = bus.build()?;
572
573        // The queue, knowing every job a module registered; its workers start
574        // once the server listens (or alone, under `queue work`).
575        let queue = laterite_core::queue::from_config(
576            &config.queue,
577            contributions.take::<laterite_core::queue::JobReg>(),
578            laterite_core::queue::Services {
579                db: db.clone(),
580                events: events.clone(),
581                cache: cache.clone(),
582                parameters: laterite_core::parameters::Parameters::with_cache(
583                    db.clone(),
584                    cache.clone(),
585                ),
586            },
587        )
588        .await?;
589        tracing::info!(
590            driver = queue.driver_name(),
591            mode = ?queue.mode(),
592            jobs = queue.job_names().len(),
593            "queue ready"
594        );
595
596        let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
597        // Expired sessions and stay-signed-in credentials are swept hourly (the
598        // auth module's boot did the first sweep), so the tables hold only what
599        // a request could still use. The sweep is a schedule entry beside the
600        // modules' own.
601        let mut entries = contributions.take::<laterite_core::schedule::ScheduleEntry>();
602        entries.push({
603            let auth = auth.clone();
604            laterite_core::schedule::ScheduleEntry::hourly("laterite.auth::purge")
605                .description("Remove expired sessions and stay-signed-in credentials.")
606                .run(move |_cx| {
607                    let auth = auth.clone();
608                    async move {
609                        purge_expired(&auth).await;
610                        Ok(())
611                    }
612                })
613        });
614        let schedule = laterite_core::Schedule::new(
615            entries,
616            laterite_core::schedule::Services {
617                db: db.clone(),
618                events: events.clone(),
619                cache: cache.clone(),
620                parameters: laterite_core::parameters::Parameters::with_cache(
621                    db.clone(),
622                    cache.clone(),
623                ),
624                queue: queue.clone(),
625            },
626        )?;
627        let schedule_mode = laterite_core::schedule::mode_from_config(&config.schedule)?;
628        tracing::info!(entries = schedule.len(), mode = ?schedule_mode, "schedule ready");
629        let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
630        let secret = signing_key(&db, config.backend.secret.as_deref()).await?;
631        let crypt = crypt_from_config(&config, &secret)?;
632
633        // Mail: the templates every module registered, seeded into their
634        // tables; the transport from `[mail]` under the saved Mail settings.
635        let templates = laterite_core::mail::Templates::new(db.clone(), cache.clone());
636        templates.set_app_name(&config.app.name);
637        templates
638            .seed(
639                &contributions.take::<laterite_core::mail::MailTemplateReg>(),
640                &contributions.take::<laterite_core::mail::MailLayoutReg>(),
641                &contributions.take::<laterite_core::mail::MailPartialReg>(),
642            )
643            .await?;
644        let brand: crate::settings::mail_brand::MailBrandSetting =
645            crate::settings::store::load(&db).await.unwrap_or_default();
646        templates.set_brand_css(&brand.css()).await;
647        let saved: crate::settings::mail::MailSetting =
648            crate::settings::store::load(&db).await.unwrap_or_default();
649        let mail_config = crate::settings::mail::effective(&config.mail, &saved, &crypt);
650        let mail = laterite_core::Mail::from_config(
651            &mail_config,
652            contributions.take::<laterite_core::mail::MailDriverReg>(),
653            templates,
654            queue.clone(),
655            db.clone(),
656        )?;
657        tracing::info!(driver = mail.driver_name(), "mail ready");
658        let admin_config = AdminConfig {
659            secure_cookie: config.backend.secure_cookie,
660            trusted_proxies: config.backend.trusted_proxies.clone(),
661            timezone: config.backend.timezone.clone(),
662            locale: config.app.locale.clone(),
663            app_name: config.app.name.clone(),
664            path: config.backend.path.clone(),
665            origin: origin.clone(),
666            storage_dir: config.backend.storage.clone(),
667            skin: config.backend.skin.clone(),
668            upload_extensions: config.backend.upload_extensions.clone(),
669            order: config.backend.order.clone(),
670            menu: config.backend.menu.clone(),
671            secret,
672            crypt: Some(std::sync::Arc::new(crypt)),
673            cache: Some(cache.clone()),
674            queue: Some(queue.clone()),
675            schedule: Some(schedule.clone()),
676            mail: Some(mail.clone()),
677            mail_config: config.mail.clone(),
678        };
679        // Merge every loaded module's PO catalogs into the shared store the request
680        // translator looks up against. A malformed catalog fails boot, like a bad
681        // descriptor key. Skipped and failed modules do not contribute.
682        let mut catalogs = laterite_core::CatalogStore::builder();
683        for module in active
684            .iter()
685            .filter(|m| !failed.contains_key(m.id().as_str()))
686        {
687            for (locale, po) in module.catalogs() {
688                catalogs = catalogs.po(locale, po).map_err(|e| {
689                    laterite_core::CoreError::Config(format!(
690                        "module {}: {locale} catalog: {e}",
691                        module.id().as_str()
692                    ))
693                })?;
694            }
695        }
696
697        // A renamed permission is repaired wherever an operator's own choices
698        // still hold the old code, before the roles below are rebuilt.
699        let renamed =
700            laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
701                .await?;
702        if renamed > 0 {
703            tracing::info!(rows = renamed, "rewrote renamed permission codes");
704        }
705
706        // The framework's own roles, rewritten from the registry now that every
707        // module has contributed: a new permission lands in its role at the boot
708        // that introduces it.
709        laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
710
711        let mut app = router(
712            auth,
713            db.clone(),
714            crate::Contributions {
715                nav,
716                resource_hooks,
717                widgets,
718                skins,
719                assets,
720                resources,
721                settings,
722                permissions,
723                picker_sources,
724                persisters,
725                listeners,
726                field_types,
727                column_types,
728                icons,
729                screens,
730                public_routes: public,
731                // Whatever is left is plugin-defined: types the framework knows
732                // nothing about, kept so one module's route can read another's
733                // contributions.
734                plugin_defined: std::sync::Arc::new(contributions),
735            },
736            admin_config,
737            std::sync::Arc::new(catalogs.build()),
738        );
739
740        if let Some(extend) = self.extend {
741            let ctx = BootstrapCtx {
742                db,
743                capabilities,
744                translator: Translator::new(&config.app.locale),
745                events,
746                cache,
747                queue: queue.clone(),
748                schedule: schedule.clone(),
749                mail: mail.clone(),
750            };
751            app = extend(app, &ctx);
752        }
753
754        let admin_path = normalize_path(&config.backend.path);
755        Ok(Prepared {
756            app,
757            listen: config.server.listen.clone(),
758            banner: format!("{} on {origin}{admin_path}", config.app.name),
759            queue,
760            schedule,
761            schedule_mode,
762        })
763    }
764}
765
766/// An application ready to listen or to work.
767struct Prepared {
768    app: Router,
769    listen: String,
770    banner: String,
771    queue: laterite_core::Queue,
772    schedule: laterite_core::Schedule,
773    schedule_mode: laterite_core::schedule::Mode,
774}
775
776impl Prepared {
777    async fn serve(self) -> anyhow::Result<()> {
778        let listener = bind(&self.listen).await?;
779        println!("{}", self.banner);
780        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
781        let mut workers = if self.queue.mode() == laterite_core::queue::Mode::Worker {
782            self.queue.spawn_workers(stop_rx.clone())
783        } else {
784            Vec::new()
785        };
786        if self.schedule_mode == laterite_core::schedule::Mode::Ticker {
787            workers.push(self.schedule.spawn_ticker(stop_rx));
788        }
789        axum::serve(
790            listener,
791            self.app
792                .into_make_service_with_connect_info::<std::net::SocketAddr>(),
793        )
794        .with_graceful_shutdown(shutdown_signal())
795        .await?;
796        let _ = stop_tx.send(true);
797        for worker in workers {
798            // A running job gets its timeout to finish; the stop is not a kill.
799            let _ = tokio::time::timeout(WORKER_GRACE, worker).await;
800        }
801        Ok(())
802    }
803
804    /// A worker alone, until Ctrl-C or the arguments end it.
805    async fn work(self, args: &[String]) -> anyhow::Result<()> {
806        let mut options = laterite_core::queue::WorkOptions::default();
807        let mut rest = args.iter();
808        while let Some(arg) = rest.next() {
809            match arg.as_str() {
810                "--once" => options = options.once(true),
811                "--stop-when-empty" => options = options.stop_when_empty(true),
812                "--queue" => {
813                    let list = rest.next().ok_or_else(|| {
814                        anyhow::anyhow!("--queue takes a list, like `high,default`")
815                    })?;
816                    options =
817                        options.queues(list.split(',').map(|q| q.trim().to_string()).collect());
818                }
819                other => anyhow::bail!("unknown worker option `{other}`"),
820            }
821        }
822        if self.queue.mode() == laterite_core::queue::Mode::Inline {
823            anyhow::bail!(
824                "queue.mode is `inline`: jobs run as they are pushed, so there is nothing to work"
825            );
826        }
827        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
828        tokio::spawn(async move {
829            shutdown_signal().await;
830            let _ = stop_tx.send(true);
831        });
832        let done = self.queue.work(options, stop_rx).await?;
833        println!("Processed {done} job(s).");
834        Ok(())
835    }
836
837    /// The ticker alone, until Ctrl-C; `--once` evaluates this minute and
838    /// returns.
839    async fn tick(self, args: &[String]) -> anyhow::Result<()> {
840        let once = match args {
841            [] => false,
842            [flag] if flag == "--once" => true,
843            other => anyhow::bail!("unknown schedule option `{}`", other.join(" ")),
844        };
845        if self.schedule_mode == laterite_core::schedule::Mode::Off {
846            anyhow::bail!("schedule.mode is `off`");
847        }
848        if once {
849            let tick = self.schedule.tick(chrono::Utc::now()).await?;
850            println!(
851                "Ran {} entr(y|ies), skipped {}.",
852                tick.ran.len(),
853                tick.skipped.len()
854            );
855            for name in tick.ran {
856                println!("  ran {name}");
857            }
858            for name in tick.skipped {
859                println!("  skipped {name}");
860            }
861            return Ok(());
862        }
863        let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
864        tokio::spawn(async move {
865            shutdown_signal().await;
866            let _ = stop_tx.send(true);
867        });
868        self.schedule.run_ticker(stop_rx).await;
869        Ok(())
870    }
871}
872
873/// How long a stopping server waits for its workers' running jobs.
874const WORKER_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
875
876/// Resolves on Ctrl-C, or on SIGTERM where there is one.
877async fn shutdown_signal() {
878    let ctrl_c = async {
879        let _ = tokio::signal::ctrl_c().await;
880    };
881    #[cfg(unix)]
882    let terminate = async {
883        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
884            Ok(mut signal) => {
885                signal.recv().await;
886            }
887            Err(_) => std::future::pending::<()>().await,
888        }
889    };
890    #[cfg(not(unix))]
891    let terminate = std::future::pending::<()>();
892    tokio::select! {
893        _ = ctrl_c => {}
894        _ = terminate => {}
895    }
896    tracing::info!("stopping");
897}
898
899/// Binds the listener, preferring a `systemfd`-inherited socket (so the port
900/// survives reloads) and falling back to binding `listen`.
901/// One sweep of expired sessions and credentials, logged when it removed any.
902async fn purge_expired(auth: &AuthService) {
903    match auth.purge_expired().await {
904        Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
905            sessions = purged.sessions,
906            remember_tokens = purged.remember_tokens,
907            "purged expired sessions and credentials"
908        ),
909        Ok(_) => {}
910        Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
911    }
912}
913
914async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
915    use listenfd::ListenFd;
916    match ListenFd::from_env().take_tcp_listener(0)? {
917        Some(std_listener) => {
918            std_listener.set_nonblocking(true)?;
919            Ok(tokio::net::TcpListener::from_std(std_listener)?)
920        }
921        None => Ok(tokio::net::TcpListener::bind(listen).await?),
922    }
923}
924
925/// Given modules in dependency order (dependencies first) and a `seed` of ids
926/// already known to be off (disabled or quarantined) each with its reason,
927/// returns the full set to skip, each with a reason. One forward pass suffices
928/// because `ordered` places a module after everything it requires: a seeded
929/// module is skipped, and so is anything that requires an already-skipped one.
930fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
931    let mut skipped: HashMap<String, String> = HashMap::new();
932    for module in ordered {
933        let id = module.id().as_str();
934        if let Some(reason) = seed.get(id) {
935            skipped.insert(id.to_string(), reason.clone());
936            continue;
937        }
938        if let Some(dep) = module
939            .requires()
940            .iter()
941            .find(|r| skipped.contains_key(r.as_str()))
942        {
943            skipped.insert(
944                id.to_string(),
945                format!("requires {}, which is off", dep.as_str()),
946            );
947        }
948    }
949    skipped
950}
951
952/// Prints one line per skipped plugin, in dependency order. Silent when every
953/// module loads, so a normal boot stays quiet.
954fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
955    if skipped.is_empty() {
956        return;
957    }
958    for module in ordered {
959        if let Some(reason) = skipped.get(module.id().as_str()) {
960            println!("Plugin '{}' off: {reason}", module.id());
961        }
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use laterite_core::ModuleId;
969
970    #[test]
971    fn env_prefix_is_unset_until_overridden_in_code() {
972        assert_eq!(Bootstrap::new("config").env_prefix, None);
973        assert_eq!(
974            Bootstrap::new("config")
975                .env_prefix("ACME")
976                .env_prefix
977                .as_deref(),
978            Some("ACME")
979        );
980    }
981
982    #[test]
983    fn app_config_loads_named_sections_and_falls_back_to_defaults() {
984        let dir = tempfile::tempdir().unwrap();
985        std::fs::write(
986            dir.path().join("default.toml"),
987            "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
988             [database]\nurl = \"postgres://x\"\n",
989        )
990        .unwrap();
991        let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
992        assert_eq!(cfg.app.name, "Acme");
993        assert_eq!(cfg.server.listen, "0.0.0.0:9000");
994        assert_eq!(cfg.database.url, "postgres://x");
995        assert_eq!(cfg.backend.path, "/admin");
996        assert!(!cfg.backend.secure_cookie);
997        assert!(cfg.app.url.is_none());
998    }
999
1000    struct M(&'static str, &'static [ModuleId]);
1001    impl Module for M {
1002        fn id(&self) -> ModuleId {
1003            ModuleId::new(self.0)
1004        }
1005        fn requires(&self) -> &'static [ModuleId] {
1006            self.1
1007        }
1008    }
1009
1010    #[test]
1011    fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
1012        const NONE: [ModuleId; 0] = [];
1013        const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
1014        const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
1015        // Dependency order (deps first): a, b (requires a), c (requires b), and an
1016        // independent d.
1017        let (a, b, c, d) = (
1018            M("acme.a", &NONE),
1019            M("acme.b", &REQ_A),
1020            M("acme.c", &REQ_B),
1021            M("acme.d", &NONE),
1022        );
1023        let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
1024
1025        let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
1026            .into_iter()
1027            .collect();
1028        let skipped = skip_plan(&ordered, &seed);
1029
1030        // a is off by the seed; b then c cascade off; d is unaffected.
1031        assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
1032        assert!(skipped["acme.b"].contains("requires acme.a"));
1033        assert!(skipped["acme.c"].contains("requires acme.b"));
1034        assert!(!skipped.contains_key("acme.d"));
1035    }
1036}
1037
1038/// The settings code the generated signing key is kept under.
1039const SIGNING_KEY: &str = "laterite.signing_key";
1040
1041/// The application's crypt: `app.key` and `app.previous_keys` when set, else a
1042/// key derived from `backend.secret` or the key stored at the first boot (id
1043/// 0), so a deployment that configured nothing keeps a stable key. A key that
1044/// does not parse aborts the boot; a production deployment without `app.key`
1045/// is warned to run `lat key generate`.
1046pub fn crypt_from_config(
1047    config: &AppConfig,
1048    legacy: &[u8],
1049) -> anyhow::Result<laterite_core::Crypt> {
1050    match config
1051        .app
1052        .key
1053        .as_deref()
1054        .map(str::trim)
1055        .filter(|k| !k.is_empty())
1056    {
1057        Some(key) => laterite_core::Crypt::from_config(key, &config.app.previous_keys)
1058            .map_err(|e| anyhow::anyhow!("app.key: {e}")),
1059        None => {
1060            if !config.app.debug {
1061                tracing::warn!(
1062                    "app.key is not set; the key derives from backend.secret or the stored key. Run `lat key generate`."
1063                );
1064            }
1065            Ok(laterite_core::Crypt::new(
1066                laterite_core::MasterKey::derived(0, legacy),
1067                Vec::new(),
1068            ))
1069        }
1070    }
1071}
1072
1073/// The signing key: `backend.secret` when set, else the one generated at the
1074/// first boot and kept in the settings table, so tokens survive a restart.
1075pub async fn legacy_key_material(
1076    db: &laterite_core::Db,
1077    configured: Option<&str>,
1078) -> anyhow::Result<Vec<u8>> {
1079    signing_key(db, configured).await
1080}
1081
1082async fn signing_key(db: &laterite_core::Db, configured: Option<&str>) -> anyhow::Result<Vec<u8>> {
1083    if let Some(secret) = configured.map(str::trim).filter(|s| !s.is_empty()) {
1084        return Ok(secret.as_bytes().to_vec());
1085    }
1086    if let Some(serde_json::Value::String(hex)) =
1087        crate::settings::store::get(db, SIGNING_KEY).await?
1088    {
1089        if let Some(key) = crate::routes::unhex(&hex).filter(|k| k.len() >= 16) {
1090            return Ok(key);
1091        }
1092    }
1093    let key = crate::random_key();
1094    let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
1095    crate::settings::store::set(db, SIGNING_KEY, &serde_json::Value::String(hex)).await?;
1096    Ok(key)
1097}