1use 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
23pub const DEFAULT_ENV_PREFIX: &str = "LAT";
27
28fn 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#[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}
71
72#[derive(Debug, Clone, Deserialize, Default)]
76pub struct PluginsConfig {
77 #[serde(default)]
79 pub disabled: Vec<String>,
80}
81
82pub struct BootstrapCtx {
85 db: Db,
86 capabilities: CapabilitySet,
87 translator: Translator,
88 events: laterite_core::Events,
89 cache: laterite_core::Cache,
90 queue: laterite_core::Queue,
91 schedule: laterite_core::Schedule,
92}
93
94impl BootstrapCtx {
95 pub fn db(&self) -> Db {
97 self.db.clone()
98 }
99
100 pub fn capabilities(&self) -> &CapabilitySet {
103 &self.capabilities
104 }
105
106 pub fn translator(&self) -> &Translator {
109 &self.translator
110 }
111
112 pub fn events(&self) -> &laterite_core::Events {
115 &self.events
116 }
117
118 pub fn cache(&self) -> &laterite_core::Cache {
121 &self.cache
122 }
123
124 pub fn queue(&self) -> &laterite_core::Queue {
127 &self.queue
128 }
129
130 pub fn schedule(&self) -> &laterite_core::Schedule {
133 &self.schedule
134 }
135}
136
137type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
138
139pub struct Bootstrap {
141 config_dir: PathBuf,
142 env_prefix: Option<String>,
143 app_modules: Vec<Box<dyn Module>>,
144 plugin_modules: Vec<Box<dyn Module>>,
145 extend: Option<ExtendFn>,
146}
147
148impl Bootstrap {
149 pub fn new(config_dir: impl Into<PathBuf>) -> Self {
154 Self {
155 config_dir: config_dir.into(),
156 env_prefix: None,
157 app_modules: Vec::new(),
158 plugin_modules: Vec::new(),
159 extend: None,
160 }
161 }
162
163 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
166 self.env_prefix = Some(prefix.into());
167 self
168 }
169
170 pub fn module(mut self, module: impl Module) -> Self {
175 self.app_modules.push(Box::new(module));
176 self
177 }
178
179 pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
184 self.plugin_modules.extend(modules);
185 self
186 }
187
188 pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
191 self.extend = Some(Box::new(f));
192 self
193 }
194
195 pub async fn serve(self) -> anyhow::Result<()> {
200 self.prepare().await?.serve().await
201 }
202
203 pub async fn run(self) -> anyhow::Result<()> {
209 let args: Vec<String> = std::env::args().skip(1).collect();
210 match args.first().map(String::as_str) {
211 None | Some("serve") => self.serve().await,
212 Some("queue") if args.get(1).map(String::as_str) == Some("work") => {
213 self.prepare().await?.work(&args[2..]).await
214 }
215 Some("schedule") if args.get(1).map(String::as_str) == Some("run") => {
216 self.prepare().await?.tick(&args[2..]).await
217 }
218 Some(other) => anyhow::bail!(
219 "unknown command `{other}`: the application takes `serve` (the default), `queue work` or `schedule run`"
220 ),
221 }
222 }
223
224 async fn prepare(self) -> anyhow::Result<Prepared> {
227 let _ = tracing_subscriber::fmt()
229 .with_env_filter(
230 tracing_subscriber::EnvFilter::try_from_default_env()
231 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
232 )
233 .try_init();
234
235 let env_prefix = resolve_env_prefix(
236 self.env_prefix.clone(),
237 config::declared_env_prefix(&self.config_dir)?,
238 );
239 let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
240 laterite_core::config::set_debug(config.app.debug);
242
243 let db = laterite_core::db::connect(&config.database).await?;
244
245 let plugin_ids: HashSet<String> = self
249 .plugin_modules
250 .iter()
251 .map(|m| m.id().to_string())
252 .collect();
253
254 laterite_core::migration::run(
259 &db.pool,
260 db.backend,
261 &[crate::plugins::PluginsModule.migrations()],
262 )
263 .await?;
264 crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
265
266 let fingerprint = crate::plugins::binary_fingerprint();
272 let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
273 for id in &reconcile.crashed {
274 eprintln!(
275 "Plugin '{id}' quarantined: {}.",
276 crate::plugins::CRASH_REASON
277 );
278 }
279 for id in &reconcile.cleared {
280 println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
281 }
282
283 let db_disabled = crate::plugins::disabled_ids(&db).await?;
284 let quarantined = crate::plugins::quarantined(&db).await?;
285
286 let mut registry = ModuleRegistry::new();
287 for module in builtin_modules() {
288 registry.register_boxed(module);
289 }
290 for module in self.app_modules {
291 registry.register_boxed(module);
292 }
293 for module in self.plugin_modules {
294 registry.register_boxed(module);
295 }
296 let capabilities =
297 laterite_core::capabilities::check(&db.pool, db.backend, ®istry).await?;
298
299 let ordered = registry.ordered()?;
303
304 let builtins: HashSet<String> = builtin_modules()
309 .iter()
310 .map(|m| m.id().to_string())
311 .collect();
312 let mut seed: HashMap<String, String> = HashMap::new();
313 for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
314 if builtins.contains(id) {
315 eprintln!("Ignoring request to disable built-in module '{id}'.");
316 } else {
317 seed.insert(id.clone(), "disabled".to_string());
318 }
319 }
320 for (id, reason) in &quarantined {
323 if !builtins.contains(id) {
324 seed.insert(id.clone(), format!("quarantined: {reason}"));
325 }
326 }
327 let skipped = skip_plan(&ordered, &seed);
328 report_plugins(&ordered, &skipped);
329 let active: Vec<&dyn Module> = ordered
330 .iter()
331 .copied()
332 .filter(|m| !skipped.contains_key(m.id().as_str()))
333 .collect();
334
335 let mut contributions = Registry::new();
340 let mut failed: HashMap<String, String> = HashMap::new();
341 for module in &active {
342 let mid = module.id();
343 let id = mid.as_str();
344 let eligible = plugin_ids.contains(id);
345
346 if let Some(dep) = module
347 .requires()
348 .iter()
349 .find(|r| failed.contains_key(r.as_str()))
350 {
351 let reason = format!("requires {}, which failed to load", dep.as_str());
352 println!("Plugin '{id}' off: {reason}");
353 failed.insert(id.to_string(), reason);
354 continue;
355 }
356
357 if eligible {
362 crate::plugins::mark_loading(&db, id, &fingerprint).await?;
363 }
364
365 match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
366 {
367 Ok(()) => {}
368 Err(e) if !eligible => return Err(e.into()),
369 Err(e) => {
370 let reason = format!("migration failed: {e}");
371 eprintln!("Plugin '{id}' quarantined: {reason}");
372 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
373 failed.insert(id.to_string(), reason);
374 continue;
375 }
376 }
377
378 contributions.set_owner(mid);
379 let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
380 match registered {
381 Ok(()) => {
384 if eligible {
385 crate::plugins::clear_loading(&db, id).await?;
386 }
387 }
388 Err(panic) => {
389 contributions.purge_owner(mid);
390 if !eligible {
391 resume_unwind(panic);
392 }
393 let reason = "register panicked".to_string();
394 eprintln!("Plugin '{id}' quarantined: {reason}");
395 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
396 failed.insert(id.to_string(), reason);
397 }
398 }
399 }
400 for module in &active {
407 let mid = module.id();
408 let id = mid.as_str();
409 if failed.contains_key(id) {
410 continue;
411 }
412 let cx = laterite_core::BootCx {
413 db: &db,
414 registry: &contributions,
415 };
416 match module.boot(&cx).await {
417 Ok(()) => {}
418 Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
419 Err(e) => {
420 let reason = format!("boot failed: {e}");
421 eprintln!("Plugin '{id}' quarantined: {reason}");
422 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
423 contributions.purge_owner(mid);
424 failed.insert(id.to_string(), reason);
425 }
426 }
427 }
428 let active: Vec<&dyn Module> = active
429 .into_iter()
430 .filter(|m| !failed.contains_key(m.id().as_str()))
431 .collect();
432 let bases: HashMap<String, Option<&'static str>> = active
433 .iter()
434 .map(|m| (m.id().to_string(), m.admin_base()))
435 .collect();
436 let mut resources = Vec::new();
437 for (owner, mut resource) in contributions.take_owned::<Resource>() {
438 let base = bases.get(owner.as_str()).copied().flatten();
439 crate::rebase_resource(
440 &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
441 &mut resource,
442 );
443 resources.push((owner, resource));
444 }
445 let mut screens = Vec::new();
446 for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
447 let base = bases.get(owner.as_str()).copied().flatten();
448 reg.base_path =
449 crate::routemap::resolve(owner, ®.base_path, base, &config.backend.paths);
450 screens.push((owner, reg));
451 }
452
453 let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
454
455 let mut claims: Vec<(String, String)> = crate::builtin_resources()
458 .iter()
459 .map(|r| (r.base_path.clone(), "the framework".to_string()))
460 .collect();
461 claims.extend(
462 resources
463 .iter()
464 .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
465 );
466 claims.extend(
467 screens
468 .iter()
469 .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
470 );
471 crate::routemap::check_segments(&claims);
472 crate::routemap::check_collisions(&claims);
473 let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
474 for resource in &resources {
476 if let Some(form) = &resource.form {
477 for field in form.fields.iter().filter(|f| f.field_type == "sensitive") {
478 laterite_core::crypt::register_column(
479 &db,
480 &form.entity,
481 &field.name,
482 laterite_core::crypt::FIELD_PURPOSE,
483 )
484 .await?;
485 }
486 }
487 }
488 let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
489 crate::routemap::check_public(
492 &public
493 .iter()
494 .map(|(owner, r)| (r.path.clone(), owner.to_string()))
495 .collect::<Vec<_>>(),
496 &crate::normalize_path(&config.backend.path),
497 );
498 let public: Vec<crate::routes::PublicRouteReg> =
499 public.into_iter().map(|(_, r)| r).collect();
500 let mut settings = Vec::new();
505 for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
506 let declared = item
509 .code
510 .strip_prefix(&format!("{}.", owner.as_str()))
511 .unwrap_or(&item.code)
512 .replace('.', "/");
513 let base = bases.get(owner.as_str()).copied().flatten();
514 item.mount_at(crate::routemap::resolve(
515 owner,
516 &format!("/{declared}"),
517 base,
518 &config.backend.paths,
519 ));
520 settings.push((owner, item));
521 }
522 let settings_claims: Vec<(String, String)> = settings
525 .iter()
526 .filter(|(_, i)| i.link.is_none())
527 .map(|(owner, i)| (i.route(), owner.to_string()))
528 .collect();
529 crate::routemap::check_segments(&settings_claims);
530 let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
531 let cache = laterite_core::cache::from_config(&config.cache, &db, config.app.debug).await?;
534 for (owner, namespace) in contributions.take_owned::<laterite_core::cache::CacheNamespace>()
535 {
536 cache.allow(namespace.qualified(owner));
537 }
538 if config.cache.poll_seconds > 0 {
539 cache.start_polling(std::time::Duration::from_secs(config.cache.poll_seconds));
540 }
541 tracing::info!(store = cache.store(), "cache ready");
542 let permissions = contributions.take::<Permission>();
543 let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
544 let persisters = contributions.take::<crate::persist::PersisterReg>();
545 let listeners = contributions.take::<laterite_core::ModelListenerReg>();
546 let field_types = contributions.take::<crate::field::FieldTypeReg>();
547 let column_types = contributions.take::<crate::list::ColumnTypeReg>();
548 let nav = contributions.take::<crate::nav::NavItem>();
549 let resource_hooks = contributions.take::<crate::ResourceHook>();
550 let widgets = contributions.take::<crate::widget::WidgetReg>();
551 let skins = contributions.take::<crate::skin::Skin>();
552 let assets = contributions.take::<crate::AssetReg>();
553 let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
555
556 let mut bus = laterite_core::Events::builder(db.clone());
559 for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
560 bus = bus.registered(owner, listener);
561 }
562 let events = bus.build()?;
563
564 let queue = laterite_core::queue::from_config(
567 &config.queue,
568 contributions.take::<laterite_core::queue::JobReg>(),
569 laterite_core::queue::Services {
570 db: db.clone(),
571 events: events.clone(),
572 cache: cache.clone(),
573 parameters: laterite_core::parameters::Parameters::with_cache(
574 db.clone(),
575 cache.clone(),
576 ),
577 },
578 )
579 .await?;
580 tracing::info!(
581 driver = queue.driver_name(),
582 mode = ?queue.mode(),
583 jobs = queue.job_names().len(),
584 "queue ready"
585 );
586
587 let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
588 let mut entries = contributions.take::<laterite_core::schedule::ScheduleEntry>();
593 entries.push({
594 let auth = auth.clone();
595 laterite_core::schedule::ScheduleEntry::hourly("laterite.auth::purge")
596 .description("Remove expired sessions and stay-signed-in credentials.")
597 .run(move |_cx| {
598 let auth = auth.clone();
599 async move {
600 purge_expired(&auth).await;
601 Ok(())
602 }
603 })
604 });
605 let schedule = laterite_core::Schedule::new(
606 entries,
607 laterite_core::schedule::Services {
608 db: db.clone(),
609 events: events.clone(),
610 cache: cache.clone(),
611 parameters: laterite_core::parameters::Parameters::with_cache(
612 db.clone(),
613 cache.clone(),
614 ),
615 queue: queue.clone(),
616 },
617 )?;
618 let schedule_mode = laterite_core::schedule::mode_from_config(&config.schedule)?;
619 tracing::info!(entries = schedule.len(), mode = ?schedule_mode, "schedule ready");
620 let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
621 let secret = signing_key(&db, config.backend.secret.as_deref()).await?;
622 let crypt = crypt_from_config(&config, &secret)?;
623 let admin_config = AdminConfig {
624 secure_cookie: config.backend.secure_cookie,
625 trusted_proxies: config.backend.trusted_proxies.clone(),
626 timezone: config.backend.timezone.clone(),
627 locale: config.app.locale.clone(),
628 app_name: config.app.name.clone(),
629 path: config.backend.path.clone(),
630 origin: origin.clone(),
631 storage_dir: config.backend.storage.clone(),
632 skin: config.backend.skin.clone(),
633 upload_extensions: config.backend.upload_extensions.clone(),
634 order: config.backend.order.clone(),
635 menu: config.backend.menu.clone(),
636 secret,
637 crypt: Some(std::sync::Arc::new(crypt)),
638 cache: Some(cache.clone()),
639 queue: Some(queue.clone()),
640 schedule: Some(schedule.clone()),
641 };
642 let mut catalogs = laterite_core::CatalogStore::builder();
646 for module in active
647 .iter()
648 .filter(|m| !failed.contains_key(m.id().as_str()))
649 {
650 for (locale, po) in module.catalogs() {
651 catalogs = catalogs.po(locale, po).map_err(|e| {
652 laterite_core::CoreError::Config(format!(
653 "module {}: {locale} catalog: {e}",
654 module.id().as_str()
655 ))
656 })?;
657 }
658 }
659
660 let renamed =
663 laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
664 .await?;
665 if renamed > 0 {
666 tracing::info!(rows = renamed, "rewrote renamed permission codes");
667 }
668
669 laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
673
674 let mut app = router(
675 auth,
676 db.clone(),
677 crate::Contributions {
678 nav,
679 resource_hooks,
680 widgets,
681 skins,
682 assets,
683 resources,
684 settings,
685 permissions,
686 picker_sources,
687 persisters,
688 listeners,
689 field_types,
690 column_types,
691 icons,
692 screens,
693 public_routes: public,
694 plugin_defined: std::sync::Arc::new(contributions),
698 },
699 admin_config,
700 std::sync::Arc::new(catalogs.build()),
701 );
702
703 if let Some(extend) = self.extend {
704 let ctx = BootstrapCtx {
705 db,
706 capabilities,
707 translator: Translator::new(&config.app.locale),
708 events,
709 cache,
710 queue: queue.clone(),
711 schedule: schedule.clone(),
712 };
713 app = extend(app, &ctx);
714 }
715
716 let admin_path = normalize_path(&config.backend.path);
717 Ok(Prepared {
718 app,
719 listen: config.server.listen.clone(),
720 banner: format!("{} on {origin}{admin_path}", config.app.name),
721 queue,
722 schedule,
723 schedule_mode,
724 })
725 }
726}
727
728struct Prepared {
730 app: Router,
731 listen: String,
732 banner: String,
733 queue: laterite_core::Queue,
734 schedule: laterite_core::Schedule,
735 schedule_mode: laterite_core::schedule::Mode,
736}
737
738impl Prepared {
739 async fn serve(self) -> anyhow::Result<()> {
740 let listener = bind(&self.listen).await?;
741 println!("{}", self.banner);
742 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
743 let mut workers = if self.queue.mode() == laterite_core::queue::Mode::Worker {
744 self.queue.spawn_workers(stop_rx.clone())
745 } else {
746 Vec::new()
747 };
748 if self.schedule_mode == laterite_core::schedule::Mode::Ticker {
749 workers.push(self.schedule.spawn_ticker(stop_rx));
750 }
751 axum::serve(
752 listener,
753 self.app
754 .into_make_service_with_connect_info::<std::net::SocketAddr>(),
755 )
756 .with_graceful_shutdown(shutdown_signal())
757 .await?;
758 let _ = stop_tx.send(true);
759 for worker in workers {
760 let _ = tokio::time::timeout(WORKER_GRACE, worker).await;
762 }
763 Ok(())
764 }
765
766 async fn work(self, args: &[String]) -> anyhow::Result<()> {
768 let mut options = laterite_core::queue::WorkOptions::default();
769 let mut rest = args.iter();
770 while let Some(arg) = rest.next() {
771 match arg.as_str() {
772 "--once" => options = options.once(true),
773 "--stop-when-empty" => options = options.stop_when_empty(true),
774 "--queue" => {
775 let list = rest.next().ok_or_else(|| {
776 anyhow::anyhow!("--queue takes a list, like `high,default`")
777 })?;
778 options =
779 options.queues(list.split(',').map(|q| q.trim().to_string()).collect());
780 }
781 other => anyhow::bail!("unknown worker option `{other}`"),
782 }
783 }
784 if self.queue.mode() == laterite_core::queue::Mode::Inline {
785 anyhow::bail!(
786 "queue.mode is `inline`: jobs run as they are pushed, so there is nothing to work"
787 );
788 }
789 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
790 tokio::spawn(async move {
791 shutdown_signal().await;
792 let _ = stop_tx.send(true);
793 });
794 let done = self.queue.work(options, stop_rx).await?;
795 println!("Processed {done} job(s).");
796 Ok(())
797 }
798
799 async fn tick(self, args: &[String]) -> anyhow::Result<()> {
802 let once = match args {
803 [] => false,
804 [flag] if flag == "--once" => true,
805 other => anyhow::bail!("unknown schedule option `{}`", other.join(" ")),
806 };
807 if self.schedule_mode == laterite_core::schedule::Mode::Off {
808 anyhow::bail!("schedule.mode is `off`");
809 }
810 if once {
811 let tick = self.schedule.tick(chrono::Utc::now()).await?;
812 println!(
813 "Ran {} entr(y|ies), skipped {}.",
814 tick.ran.len(),
815 tick.skipped.len()
816 );
817 for name in tick.ran {
818 println!(" ran {name}");
819 }
820 for name in tick.skipped {
821 println!(" skipped {name}");
822 }
823 return Ok(());
824 }
825 let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
826 tokio::spawn(async move {
827 shutdown_signal().await;
828 let _ = stop_tx.send(true);
829 });
830 self.schedule.run_ticker(stop_rx).await;
831 Ok(())
832 }
833}
834
835const WORKER_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
837
838async fn shutdown_signal() {
840 let ctrl_c = async {
841 let _ = tokio::signal::ctrl_c().await;
842 };
843 #[cfg(unix)]
844 let terminate = async {
845 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
846 Ok(mut signal) => {
847 signal.recv().await;
848 }
849 Err(_) => std::future::pending::<()>().await,
850 }
851 };
852 #[cfg(not(unix))]
853 let terminate = std::future::pending::<()>();
854 tokio::select! {
855 _ = ctrl_c => {}
856 _ = terminate => {}
857 }
858 tracing::info!("stopping");
859}
860
861async fn purge_expired(auth: &AuthService) {
865 match auth.purge_expired().await {
866 Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
867 sessions = purged.sessions,
868 remember_tokens = purged.remember_tokens,
869 "purged expired sessions and credentials"
870 ),
871 Ok(_) => {}
872 Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
873 }
874}
875
876async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
877 use listenfd::ListenFd;
878 match ListenFd::from_env().take_tcp_listener(0)? {
879 Some(std_listener) => {
880 std_listener.set_nonblocking(true)?;
881 Ok(tokio::net::TcpListener::from_std(std_listener)?)
882 }
883 None => Ok(tokio::net::TcpListener::bind(listen).await?),
884 }
885}
886
887fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
893 let mut skipped: HashMap<String, String> = HashMap::new();
894 for module in ordered {
895 let id = module.id().as_str();
896 if let Some(reason) = seed.get(id) {
897 skipped.insert(id.to_string(), reason.clone());
898 continue;
899 }
900 if let Some(dep) = module
901 .requires()
902 .iter()
903 .find(|r| skipped.contains_key(r.as_str()))
904 {
905 skipped.insert(
906 id.to_string(),
907 format!("requires {}, which is off", dep.as_str()),
908 );
909 }
910 }
911 skipped
912}
913
914fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
917 if skipped.is_empty() {
918 return;
919 }
920 for module in ordered {
921 if let Some(reason) = skipped.get(module.id().as_str()) {
922 println!("Plugin '{}' off: {reason}", module.id());
923 }
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use laterite_core::ModuleId;
931
932 #[test]
933 fn env_prefix_is_unset_until_overridden_in_code() {
934 assert_eq!(Bootstrap::new("config").env_prefix, None);
935 assert_eq!(
936 Bootstrap::new("config")
937 .env_prefix("ACME")
938 .env_prefix
939 .as_deref(),
940 Some("ACME")
941 );
942 }
943
944 #[test]
945 fn app_config_loads_named_sections_and_falls_back_to_defaults() {
946 let dir = tempfile::tempdir().unwrap();
947 std::fs::write(
948 dir.path().join("default.toml"),
949 "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
950 [database]\nurl = \"postgres://x\"\n",
951 )
952 .unwrap();
953 let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
954 assert_eq!(cfg.app.name, "Acme");
955 assert_eq!(cfg.server.listen, "0.0.0.0:9000");
956 assert_eq!(cfg.database.url, "postgres://x");
957 assert_eq!(cfg.backend.path, "/admin");
958 assert!(!cfg.backend.secure_cookie);
959 assert!(cfg.app.url.is_none());
960 }
961
962 struct M(&'static str, &'static [ModuleId]);
963 impl Module for M {
964 fn id(&self) -> ModuleId {
965 ModuleId::new(self.0)
966 }
967 fn requires(&self) -> &'static [ModuleId] {
968 self.1
969 }
970 }
971
972 #[test]
973 fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
974 const NONE: [ModuleId; 0] = [];
975 const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
976 const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
977 let (a, b, c, d) = (
980 M("acme.a", &NONE),
981 M("acme.b", &REQ_A),
982 M("acme.c", &REQ_B),
983 M("acme.d", &NONE),
984 );
985 let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
986
987 let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
988 .into_iter()
989 .collect();
990 let skipped = skip_plan(&ordered, &seed);
991
992 assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
994 assert!(skipped["acme.b"].contains("requires acme.a"));
995 assert!(skipped["acme.c"].contains("requires acme.b"));
996 assert!(!skipped.contains_key("acme.d"));
997 }
998}
999
1000const SIGNING_KEY: &str = "laterite.signing_key";
1002
1003pub fn crypt_from_config(
1009 config: &AppConfig,
1010 legacy: &[u8],
1011) -> anyhow::Result<laterite_core::Crypt> {
1012 match config
1013 .app
1014 .key
1015 .as_deref()
1016 .map(str::trim)
1017 .filter(|k| !k.is_empty())
1018 {
1019 Some(key) => laterite_core::Crypt::from_config(key, &config.app.previous_keys)
1020 .map_err(|e| anyhow::anyhow!("app.key: {e}")),
1021 None => {
1022 if !config.app.debug {
1023 tracing::warn!(
1024 "app.key is not set; the key derives from backend.secret or the stored key. Run `lat key generate`."
1025 );
1026 }
1027 Ok(laterite_core::Crypt::new(
1028 laterite_core::MasterKey::derived(0, legacy),
1029 Vec::new(),
1030 ))
1031 }
1032 }
1033}
1034
1035pub async fn legacy_key_material(
1038 db: &laterite_core::Db,
1039 configured: Option<&str>,
1040) -> anyhow::Result<Vec<u8>> {
1041 signing_key(db, configured).await
1042}
1043
1044async fn signing_key(db: &laterite_core::Db, configured: Option<&str>) -> anyhow::Result<Vec<u8>> {
1045 if let Some(secret) = configured.map(str::trim).filter(|s| !s.is_empty()) {
1046 return Ok(secret.as_bytes().to_vec());
1047 }
1048 if let Some(serde_json::Value::String(hex)) =
1049 crate::settings::store::get(db, SIGNING_KEY).await?
1050 {
1051 if let Some(key) = crate::routes::unhex(&hex).filter(|k| k.len() >= 16) {
1052 return Ok(key);
1053 }
1054 }
1055 let key = crate::random_key();
1056 let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
1057 crate::settings::store::set(db, SIGNING_KEY, &serde_json::Value::String(hex)).await?;
1058 Ok(key)
1059}