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";
26
27#[derive(Debug, Clone, Deserialize)]
30pub struct AppConfig {
31 #[serde(default)]
32 pub app: AppMeta,
33 pub server: ServerConfig,
34 pub database: DatabaseConfig,
35 #[serde(default)]
36 pub auth: AuthConfig,
37 #[serde(default)]
38 pub backend: BackendConfig,
39 #[serde(default)]
40 pub plugins: PluginsConfig,
41}
42
43#[derive(Debug, Clone, Deserialize, Default)]
47pub struct PluginsConfig {
48 #[serde(default)]
50 pub disabled: Vec<String>,
51}
52
53pub struct BootstrapCtx {
56 db: Db,
57 capabilities: CapabilitySet,
58 translator: Translator,
59}
60
61impl BootstrapCtx {
62 pub fn db(&self) -> Db {
64 self.db.clone()
65 }
66
67 pub fn capabilities(&self) -> &CapabilitySet {
70 &self.capabilities
71 }
72
73 pub fn translator(&self) -> &Translator {
76 &self.translator
77 }
78}
79
80type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
81
82pub struct Bootstrap {
84 config_dir: PathBuf,
85 env_prefix: String,
86 app_modules: Vec<Box<dyn Module>>,
87 plugin_modules: Vec<Box<dyn Module>>,
88 extend: Option<ExtendFn>,
89}
90
91impl Bootstrap {
92 pub fn new(config_dir: impl Into<PathBuf>) -> Self {
96 Self {
97 config_dir: config_dir.into(),
98 env_prefix: DEFAULT_ENV_PREFIX.to_string(),
99 app_modules: Vec::new(),
100 plugin_modules: Vec::new(),
101 extend: None,
102 }
103 }
104
105 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
107 self.env_prefix = prefix.into();
108 self
109 }
110
111 pub fn module(mut self, module: impl Module) -> Self {
116 self.app_modules.push(Box::new(module));
117 self
118 }
119
120 pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
125 self.plugin_modules.extend(modules);
126 self
127 }
128
129 pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
132 self.extend = Some(Box::new(f));
133 self
134 }
135
136 pub async fn serve(self) -> anyhow::Result<()> {
140 let _ = tracing_subscriber::fmt()
142 .with_env_filter(
143 tracing_subscriber::EnvFilter::try_from_default_env()
144 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
145 )
146 .try_init();
147
148 let config: AppConfig = config::load(&self.config_dir, &self.env_prefix)?;
149 laterite_core::config::set_debug(config.app.debug);
151
152 let db = laterite_core::db::connect(&config.database).await?;
153
154 let plugin_ids: HashSet<String> = self
158 .plugin_modules
159 .iter()
160 .map(|m| m.id().to_string())
161 .collect();
162
163 laterite_core::migration::run(
168 &db.pool,
169 db.backend,
170 &[crate::plugins::PluginsModule.migrations()],
171 )
172 .await?;
173 crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
174
175 let fingerprint = crate::plugins::binary_fingerprint();
181 let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
182 for id in &reconcile.crashed {
183 eprintln!(
184 "Plugin '{id}' quarantined: {}.",
185 crate::plugins::CRASH_REASON
186 );
187 }
188 for id in &reconcile.cleared {
189 println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
190 }
191
192 let db_disabled = crate::plugins::disabled_ids(&db).await?;
193 let quarantined = crate::plugins::quarantined(&db).await?;
194
195 let mut registry = ModuleRegistry::new();
196 for module in builtin_modules() {
197 registry.register_boxed(module);
198 }
199 for module in self.app_modules {
200 registry.register_boxed(module);
201 }
202 for module in self.plugin_modules {
203 registry.register_boxed(module);
204 }
205 let capabilities =
206 laterite_core::capabilities::check(&db.pool, db.backend, ®istry).await?;
207
208 let ordered = registry.ordered()?;
212
213 let builtins: HashSet<String> = builtin_modules()
218 .iter()
219 .map(|m| m.id().to_string())
220 .collect();
221 let mut seed: HashMap<String, String> = HashMap::new();
222 for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
223 if builtins.contains(id) {
224 eprintln!("Ignoring request to disable built-in module '{id}'.");
225 } else {
226 seed.insert(id.clone(), "disabled".to_string());
227 }
228 }
229 for (id, reason) in &quarantined {
232 if !builtins.contains(id) {
233 seed.insert(id.clone(), format!("quarantined: {reason}"));
234 }
235 }
236 let skipped = skip_plan(&ordered, &seed);
237 report_plugins(&ordered, &skipped);
238 let active: Vec<&dyn Module> = ordered
239 .iter()
240 .copied()
241 .filter(|m| !skipped.contains_key(m.id().as_str()))
242 .collect();
243
244 let mut contributions = Registry::new();
249 let mut failed: HashMap<String, String> = HashMap::new();
250 for module in &active {
251 let mid = module.id();
252 let id = mid.as_str();
253 let eligible = plugin_ids.contains(id);
254
255 if let Some(dep) = module
256 .requires()
257 .iter()
258 .find(|r| failed.contains_key(r.as_str()))
259 {
260 let reason = format!("requires {}, which failed to load", dep.as_str());
261 println!("Plugin '{id}' off: {reason}");
262 failed.insert(id.to_string(), reason);
263 continue;
264 }
265
266 if eligible {
271 crate::plugins::mark_loading(&db, id, &fingerprint).await?;
272 }
273
274 match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
275 {
276 Ok(()) => {}
277 Err(e) if !eligible => return Err(e.into()),
278 Err(e) => {
279 let reason = format!("migration failed: {e}");
280 eprintln!("Plugin '{id}' quarantined: {reason}");
281 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
282 failed.insert(id.to_string(), reason);
283 continue;
284 }
285 }
286
287 contributions.set_owner(mid);
288 let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
289 match registered {
290 Ok(()) => {
293 if eligible {
294 crate::plugins::clear_loading(&db, id).await?;
295 }
296 }
297 Err(panic) => {
298 contributions.purge_owner(mid);
299 if !eligible {
300 resume_unwind(panic);
301 }
302 let reason = "register panicked".to_string();
303 eprintln!("Plugin '{id}' quarantined: {reason}");
304 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
305 failed.insert(id.to_string(), reason);
306 }
307 }
308 }
309 let resources = contributions.take::<Resource>();
310 let settings = contributions.take::<SettingsItem>();
311 let permissions = contributions.take::<Permission>();
312 let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
313 let persisters = contributions.take::<crate::persist::PersisterReg>();
314
315 let auth = AuthService::new(db.clone(), config.auth.clone());
316 let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
317 let admin_config = AdminConfig {
318 secure_cookie: config.backend.secure_cookie,
319 timezone: config.backend.timezone.clone(),
320 locale: config.app.locale.clone(),
321 app_name: config.app.name.clone(),
322 path: config.backend.path.clone(),
323 origin: origin.clone(),
324 };
325 let mut catalogs = laterite_core::CatalogStore::builder();
329 for module in active
330 .iter()
331 .filter(|m| !failed.contains_key(m.id().as_str()))
332 {
333 for (locale, po) in module.catalogs() {
334 catalogs = catalogs.po(locale, po).map_err(|e| {
335 laterite_core::CoreError::Config(format!(
336 "module {}: {locale} catalog: {e}",
337 module.id().as_str()
338 ))
339 })?;
340 }
341 }
342
343 let mut app = router(
344 auth,
345 db.clone(),
346 resources,
347 settings,
348 permissions,
349 picker_sources,
350 persisters,
351 admin_config,
352 std::sync::Arc::new(catalogs.build()),
353 );
354
355 if let Some(extend) = self.extend {
356 let ctx = BootstrapCtx {
357 db,
358 capabilities,
359 translator: Translator::new(&config.app.locale),
360 };
361 app = extend(app, &ctx);
362 }
363
364 let listener = bind(&config.server.listen).await?;
365 let admin_path = normalize_path(&config.backend.path);
366 println!("{} on {origin}{admin_path}", config.app.name);
367 axum::serve(listener, app).await?;
368 Ok(())
369 }
370}
371
372async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
375 use listenfd::ListenFd;
376 match ListenFd::from_env().take_tcp_listener(0)? {
377 Some(std_listener) => {
378 std_listener.set_nonblocking(true)?;
379 Ok(tokio::net::TcpListener::from_std(std_listener)?)
380 }
381 None => Ok(tokio::net::TcpListener::bind(listen).await?),
382 }
383}
384
385fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
391 let mut skipped: HashMap<String, String> = HashMap::new();
392 for module in ordered {
393 let id = module.id().as_str();
394 if let Some(reason) = seed.get(id) {
395 skipped.insert(id.to_string(), reason.clone());
396 continue;
397 }
398 if let Some(dep) = module
399 .requires()
400 .iter()
401 .find(|r| skipped.contains_key(r.as_str()))
402 {
403 skipped.insert(
404 id.to_string(),
405 format!("requires {}, which is off", dep.as_str()),
406 );
407 }
408 }
409 skipped
410}
411
412fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
415 if skipped.is_empty() {
416 return;
417 }
418 for module in ordered {
419 if let Some(reason) = skipped.get(module.id().as_str()) {
420 println!("Plugin '{}' off: {reason}", module.id());
421 }
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use laterite_core::ModuleId;
429
430 #[test]
431 fn env_prefix_defaults_and_overrides() {
432 assert_eq!(Bootstrap::new("config").env_prefix, DEFAULT_ENV_PREFIX);
433 assert_eq!(
434 Bootstrap::new("config").env_prefix("HIVE").env_prefix,
435 "HIVE"
436 );
437 }
438
439 #[test]
440 fn app_config_loads_named_sections_and_falls_back_to_defaults() {
441 let dir = tempfile::tempdir().unwrap();
442 std::fs::write(
443 dir.path().join("default.toml"),
444 "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
445 [database]\nurl = \"postgres://x\"\n",
446 )
447 .unwrap();
448 let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
449 assert_eq!(cfg.app.name, "Acme");
450 assert_eq!(cfg.server.listen, "0.0.0.0:9000");
451 assert_eq!(cfg.database.url, "postgres://x");
452 assert_eq!(cfg.backend.path, "/admin");
453 assert!(!cfg.backend.secure_cookie);
454 assert!(cfg.app.url.is_none());
455 }
456
457 struct M(&'static str, &'static [ModuleId]);
458 impl Module for M {
459 fn id(&self) -> ModuleId {
460 ModuleId::new(self.0)
461 }
462 fn requires(&self) -> &'static [ModuleId] {
463 self.1
464 }
465 }
466
467 #[test]
468 fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
469 const NONE: [ModuleId; 0] = [];
470 const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
471 const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
472 let (a, b, c, d) = (
475 M("acme.a", &NONE),
476 M("acme.b", &REQ_A),
477 M("acme.c", &REQ_B),
478 M("acme.d", &NONE),
479 );
480 let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
481
482 let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
483 .into_iter()
484 .collect();
485 let skipped = skip_plan(&ordered, &seed);
486
487 assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
489 assert!(skipped["acme.b"].contains("requires acme.a"));
490 assert!(skipped["acme.c"].contains("requires acme.b"));
491 assert!(!skipped.contains_key("acme.d"));
492 }
493}