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 #[serde(default)]
71 pub mail: laterite_core::config::MailConfig,
72}
73
74#[derive(Debug, Clone, Deserialize, Default)]
78pub struct PluginsConfig {
79 #[serde(default)]
81 pub disabled: Vec<String>,
82}
83
84pub 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 pub fn db(&self) -> Db {
100 self.db.clone()
101 }
102
103 pub fn capabilities(&self) -> &CapabilitySet {
106 &self.capabilities
107 }
108
109 pub fn translator(&self) -> &Translator {
112 &self.translator
113 }
114
115 pub fn events(&self) -> &laterite_core::Events {
118 &self.events
119 }
120
121 pub fn cache(&self) -> &laterite_core::Cache {
124 &self.cache
125 }
126
127 pub fn queue(&self) -> &laterite_core::Queue {
130 &self.queue
131 }
132
133 pub fn schedule(&self) -> &laterite_core::Schedule {
136 &self.schedule
137 }
138
139 pub fn mail(&self) -> &laterite_core::Mail {
142 &self.mail
143 }
144}
145
146type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
147
148pub 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 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 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
175 self.env_prefix = Some(prefix.into());
176 self
177 }
178
179 pub fn module(mut self, module: impl Module) -> Self {
184 self.app_modules.push(Box::new(module));
185 self
186 }
187
188 pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
193 self.plugin_modules.extend(modules);
194 self
195 }
196
197 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 pub async fn serve(self) -> anyhow::Result<()> {
209 self.prepare().await?.serve().await
210 }
211
212 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 async fn prepare(self) -> anyhow::Result<Prepared> {
236 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 laterite_core::config::set_debug(config.app.debug);
251
252 let db = laterite_core::db::connect(&config.database).await?;
253
254 let plugin_ids: HashSet<String> = self
258 .plugin_modules
259 .iter()
260 .map(|m| m.id().to_string())
261 .collect();
262
263 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 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, ®istry).await?;
307
308 let ordered = registry.ordered()?;
312
313 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 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 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 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 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 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, ®.base_path, base, &config.backend.paths);
459 screens.push((owner, reg));
460 }
461
462 let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
463
464 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 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 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 let mut settings = Vec::new();
514 for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
515 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 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 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 let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
564
565 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 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 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 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 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 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 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 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
766struct 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 let _ = tokio::time::timeout(WORKER_GRACE, worker).await;
800 }
801 Ok(())
802 }
803
804 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 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
873const WORKER_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
875
876async 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
899async 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
925fn 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
952fn 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 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 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
1038const SIGNING_KEY: &str = "laterite.signing_key";
1040
1041pub 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
1073pub 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}