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}
65
66#[derive(Debug, Clone, Deserialize, Default)]
70pub struct PluginsConfig {
71 #[serde(default)]
73 pub disabled: Vec<String>,
74}
75
76pub struct BootstrapCtx {
79 db: Db,
80 capabilities: CapabilitySet,
81 translator: Translator,
82}
83
84impl BootstrapCtx {
85 pub fn db(&self) -> Db {
87 self.db.clone()
88 }
89
90 pub fn capabilities(&self) -> &CapabilitySet {
93 &self.capabilities
94 }
95
96 pub fn translator(&self) -> &Translator {
99 &self.translator
100 }
101}
102
103type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
104
105pub struct Bootstrap {
107 config_dir: PathBuf,
108 env_prefix: Option<String>,
109 app_modules: Vec<Box<dyn Module>>,
110 plugin_modules: Vec<Box<dyn Module>>,
111 extend: Option<ExtendFn>,
112}
113
114impl Bootstrap {
115 pub fn new(config_dir: impl Into<PathBuf>) -> Self {
120 Self {
121 config_dir: config_dir.into(),
122 env_prefix: None,
123 app_modules: Vec::new(),
124 plugin_modules: Vec::new(),
125 extend: None,
126 }
127 }
128
129 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
132 self.env_prefix = Some(prefix.into());
133 self
134 }
135
136 pub fn module(mut self, module: impl Module) -> Self {
141 self.app_modules.push(Box::new(module));
142 self
143 }
144
145 pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
150 self.plugin_modules.extend(modules);
151 self
152 }
153
154 pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
157 self.extend = Some(Box::new(f));
158 self
159 }
160
161 pub async fn serve(self) -> anyhow::Result<()> {
165 let _ = tracing_subscriber::fmt()
167 .with_env_filter(
168 tracing_subscriber::EnvFilter::try_from_default_env()
169 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
170 )
171 .try_init();
172
173 let env_prefix = resolve_env_prefix(
174 self.env_prefix.clone(),
175 config::declared_env_prefix(&self.config_dir)?,
176 );
177 let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
178 laterite_core::config::set_debug(config.app.debug);
180
181 let db = laterite_core::db::connect(&config.database).await?;
182
183 let plugin_ids: HashSet<String> = self
187 .plugin_modules
188 .iter()
189 .map(|m| m.id().to_string())
190 .collect();
191
192 laterite_core::migration::run(
197 &db.pool,
198 db.backend,
199 &[crate::plugins::PluginsModule.migrations()],
200 )
201 .await?;
202 crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
203
204 let fingerprint = crate::plugins::binary_fingerprint();
210 let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
211 for id in &reconcile.crashed {
212 eprintln!(
213 "Plugin '{id}' quarantined: {}.",
214 crate::plugins::CRASH_REASON
215 );
216 }
217 for id in &reconcile.cleared {
218 println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
219 }
220
221 let db_disabled = crate::plugins::disabled_ids(&db).await?;
222 let quarantined = crate::plugins::quarantined(&db).await?;
223
224 let mut registry = ModuleRegistry::new();
225 for module in builtin_modules() {
226 registry.register_boxed(module);
227 }
228 for module in self.app_modules {
229 registry.register_boxed(module);
230 }
231 for module in self.plugin_modules {
232 registry.register_boxed(module);
233 }
234 let capabilities =
235 laterite_core::capabilities::check(&db.pool, db.backend, ®istry).await?;
236
237 let ordered = registry.ordered()?;
241
242 let builtins: HashSet<String> = builtin_modules()
247 .iter()
248 .map(|m| m.id().to_string())
249 .collect();
250 let mut seed: HashMap<String, String> = HashMap::new();
251 for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
252 if builtins.contains(id) {
253 eprintln!("Ignoring request to disable built-in module '{id}'.");
254 } else {
255 seed.insert(id.clone(), "disabled".to_string());
256 }
257 }
258 for (id, reason) in &quarantined {
261 if !builtins.contains(id) {
262 seed.insert(id.clone(), format!("quarantined: {reason}"));
263 }
264 }
265 let skipped = skip_plan(&ordered, &seed);
266 report_plugins(&ordered, &skipped);
267 let active: Vec<&dyn Module> = ordered
268 .iter()
269 .copied()
270 .filter(|m| !skipped.contains_key(m.id().as_str()))
271 .collect();
272
273 let mut contributions = Registry::new();
278 let mut failed: HashMap<String, String> = HashMap::new();
279 for module in &active {
280 let mid = module.id();
281 let id = mid.as_str();
282 let eligible = plugin_ids.contains(id);
283
284 if let Some(dep) = module
285 .requires()
286 .iter()
287 .find(|r| failed.contains_key(r.as_str()))
288 {
289 let reason = format!("requires {}, which failed to load", dep.as_str());
290 println!("Plugin '{id}' off: {reason}");
291 failed.insert(id.to_string(), reason);
292 continue;
293 }
294
295 if eligible {
300 crate::plugins::mark_loading(&db, id, &fingerprint).await?;
301 }
302
303 match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
304 {
305 Ok(()) => {}
306 Err(e) if !eligible => return Err(e.into()),
307 Err(e) => {
308 let reason = format!("migration failed: {e}");
309 eprintln!("Plugin '{id}' quarantined: {reason}");
310 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
311 failed.insert(id.to_string(), reason);
312 continue;
313 }
314 }
315
316 contributions.set_owner(mid);
317 let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
318 match registered {
319 Ok(()) => {
322 if eligible {
323 crate::plugins::clear_loading(&db, id).await?;
324 }
325 }
326 Err(panic) => {
327 contributions.purge_owner(mid);
328 if !eligible {
329 resume_unwind(panic);
330 }
331 let reason = "register panicked".to_string();
332 eprintln!("Plugin '{id}' quarantined: {reason}");
333 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
334 failed.insert(id.to_string(), reason);
335 }
336 }
337 }
338 let bases: HashMap<String, Option<&'static str>> = active
342 .iter()
343 .map(|m| (m.id().to_string(), m.admin_base()))
344 .collect();
345 let mut resources = Vec::new();
346 for (owner, mut resource) in contributions.take_owned::<Resource>() {
347 let base = bases.get(owner.as_str()).copied().flatten();
348 crate::rebase_resource(
349 &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
350 &mut resource,
351 );
352 resources.push((owner, resource));
353 }
354 let mut screens = Vec::new();
355 for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
356 let base = bases.get(owner.as_str()).copied().flatten();
357 reg.base_path =
358 crate::routemap::resolve(owner, ®.base_path, base, &config.backend.paths);
359 screens.push((owner, reg));
360 }
361
362 let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
363
364 let mut claims: Vec<(String, String)> = crate::builtin_resources()
367 .iter()
368 .map(|r| (r.base_path.clone(), "the framework".to_string()))
369 .collect();
370 claims.extend(
371 resources
372 .iter()
373 .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
374 );
375 claims.extend(
376 screens
377 .iter()
378 .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
379 );
380 crate::routemap::check_segments(&claims);
381 crate::routemap::check_collisions(&claims);
382 let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
383 let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
384 crate::routemap::check_public(
387 &public
388 .iter()
389 .map(|(owner, r)| (r.path.clone(), owner.to_string()))
390 .collect::<Vec<_>>(),
391 &crate::normalize_path(&config.backend.path),
392 );
393 let public: Vec<crate::routes::PublicRouteReg> =
394 public.into_iter().map(|(_, r)| r).collect();
395 let mut settings = Vec::new();
400 for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
401 let declared = item
404 .code
405 .strip_prefix(&format!("{}.", owner.as_str()))
406 .unwrap_or(&item.code)
407 .replace('.', "/");
408 let base = bases.get(owner.as_str()).copied().flatten();
409 item.mount_at(crate::routemap::resolve(
410 owner,
411 &format!("/{declared}"),
412 base,
413 &config.backend.paths,
414 ));
415 settings.push((owner, item));
416 }
417 let settings_claims: Vec<(String, String)> = settings
420 .iter()
421 .filter(|(_, i)| i.link.is_none())
422 .map(|(owner, i)| (i.route(), owner.to_string()))
423 .collect();
424 crate::routemap::check_segments(&settings_claims);
425 let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
426 let permissions = contributions.take::<Permission>();
427 let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
428 let persisters = contributions.take::<crate::persist::PersisterReg>();
429 let listeners = contributions.take::<laterite_core::ModelListenerReg>();
430 let field_types = contributions.take::<crate::field::FieldTypeReg>();
431 let column_types = contributions.take::<crate::list::ColumnTypeReg>();
432
433 let auth = AuthService::new(db.clone(), config.auth.clone());
434 let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
435 let admin_config = AdminConfig {
436 secure_cookie: config.backend.secure_cookie,
437 trusted_proxies: config.backend.trusted_proxies.clone(),
438 timezone: config.backend.timezone.clone(),
439 locale: config.app.locale.clone(),
440 app_name: config.app.name.clone(),
441 path: config.backend.path.clone(),
442 origin: origin.clone(),
443 };
444 let mut catalogs = laterite_core::CatalogStore::builder();
448 for module in active
449 .iter()
450 .filter(|m| !failed.contains_key(m.id().as_str()))
451 {
452 for (locale, po) in module.catalogs() {
453 catalogs = catalogs.po(locale, po).map_err(|e| {
454 laterite_core::CoreError::Config(format!(
455 "module {}: {locale} catalog: {e}",
456 module.id().as_str()
457 ))
458 })?;
459 }
460 }
461
462 let mut app = router(
463 auth,
464 db.clone(),
465 crate::Contributions {
466 resources,
467 settings,
468 permissions,
469 picker_sources,
470 persisters,
471 listeners,
472 field_types,
473 column_types,
474 screens,
475 public_routes: public,
476 plugin_defined: std::sync::Arc::new(contributions),
480 },
481 admin_config,
482 std::sync::Arc::new(catalogs.build()),
483 );
484
485 if let Some(extend) = self.extend {
486 let ctx = BootstrapCtx {
487 db,
488 capabilities,
489 translator: Translator::new(&config.app.locale),
490 };
491 app = extend(app, &ctx);
492 }
493
494 let listener = bind(&config.server.listen).await?;
495 let admin_path = normalize_path(&config.backend.path);
496 println!("{} on {origin}{admin_path}", config.app.name);
497 axum::serve(
498 listener,
499 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
500 )
501 .await?;
502 Ok(())
503 }
504}
505
506async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
509 use listenfd::ListenFd;
510 match ListenFd::from_env().take_tcp_listener(0)? {
511 Some(std_listener) => {
512 std_listener.set_nonblocking(true)?;
513 Ok(tokio::net::TcpListener::from_std(std_listener)?)
514 }
515 None => Ok(tokio::net::TcpListener::bind(listen).await?),
516 }
517}
518
519fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
525 let mut skipped: HashMap<String, String> = HashMap::new();
526 for module in ordered {
527 let id = module.id().as_str();
528 if let Some(reason) = seed.get(id) {
529 skipped.insert(id.to_string(), reason.clone());
530 continue;
531 }
532 if let Some(dep) = module
533 .requires()
534 .iter()
535 .find(|r| skipped.contains_key(r.as_str()))
536 {
537 skipped.insert(
538 id.to_string(),
539 format!("requires {}, which is off", dep.as_str()),
540 );
541 }
542 }
543 skipped
544}
545
546fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
549 if skipped.is_empty() {
550 return;
551 }
552 for module in ordered {
553 if let Some(reason) = skipped.get(module.id().as_str()) {
554 println!("Plugin '{}' off: {reason}", module.id());
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use laterite_core::ModuleId;
563
564 #[test]
565 fn env_prefix_is_unset_until_overridden_in_code() {
566 assert_eq!(Bootstrap::new("config").env_prefix, None);
567 assert_eq!(
568 Bootstrap::new("config")
569 .env_prefix("ACME")
570 .env_prefix
571 .as_deref(),
572 Some("ACME")
573 );
574 }
575
576 #[test]
577 fn app_config_loads_named_sections_and_falls_back_to_defaults() {
578 let dir = tempfile::tempdir().unwrap();
579 std::fs::write(
580 dir.path().join("default.toml"),
581 "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
582 [database]\nurl = \"postgres://x\"\n",
583 )
584 .unwrap();
585 let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
586 assert_eq!(cfg.app.name, "Acme");
587 assert_eq!(cfg.server.listen, "0.0.0.0:9000");
588 assert_eq!(cfg.database.url, "postgres://x");
589 assert_eq!(cfg.backend.path, "/admin");
590 assert!(!cfg.backend.secure_cookie);
591 assert!(cfg.app.url.is_none());
592 }
593
594 struct M(&'static str, &'static [ModuleId]);
595 impl Module for M {
596 fn id(&self) -> ModuleId {
597 ModuleId::new(self.0)
598 }
599 fn requires(&self) -> &'static [ModuleId] {
600 self.1
601 }
602 }
603
604 #[test]
605 fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
606 const NONE: [ModuleId; 0] = [];
607 const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
608 const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
609 let (a, b, c, d) = (
612 M("acme.a", &NONE),
613 M("acme.b", &REQ_A),
614 M("acme.c", &REQ_B),
615 M("acme.d", &NONE),
616 );
617 let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
618
619 let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
620 .into_iter()
621 .collect();
622 let skipped = skip_plan(&ordered, &seed);
623
624 assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
626 assert!(skipped["acme.b"].contains("requires acme.a"));
627 assert!(skipped["acme.c"].contains("requires acme.b"));
628 assert!(!skipped.contains_key("acme.d"));
629 }
630}