1use askama::Template;
13use axum::extract::State;
14use axum::response::{IntoResponse, Redirect, Response};
15use axum::{Extension, Form};
16
17use laterite_core::strata::*;
18use laterite_core::{t, Module, ModuleId};
19
20use crate::{render, render_error, AdminState, Shell};
21
22pub(crate) const MANAGE_PERMISSION: &str = "backend.manage_plugins";
24
25pub const MODULE_ID: &str = "laterite.plugins";
27
28pub struct PluginsModule;
30
31impl Module for PluginsModule {
32 fn id(&self) -> ModuleId {
33 ModuleId::new(MODULE_ID)
34 }
35 fn migrations(&self) -> MigrationSet {
36 MigrationSet::new(MODULE_ID, vec![Box::new(CreatePlugins)])
37 }
38}
39
40#[derive(Iden)]
51enum LateritePlugins {
52 Table,
53 PluginId,
54 Enabled,
55 Version,
56 QuarantinedReason,
57 QuarantinedAt,
58 QuarantinedFingerprint,
59 LoadFingerprint,
60 CreatedAt,
61 UpdatedAt,
62}
63
64struct CreatePlugins;
65
66#[async_trait(?Send)]
67impl Migration for CreatePlugins {
68 fn name(&self) -> &str {
69 "0001_create_plugins"
70 }
71 async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
72 s.exec(
73 Table::create()
74 .table(LateritePlugins::Table)
75 .if_not_exists()
76 .col(key_col(LateritePlugins::PluginId).not_null().primary_key())
77 .col(bool_col(LateritePlugins::Enabled).not_null().default(1))
78 .col(ColumnDef::new(LateritePlugins::Version).text())
79 .col(ColumnDef::new(LateritePlugins::QuarantinedReason).text())
80 .col(ColumnDef::new(LateritePlugins::QuarantinedAt).text())
81 .col(ColumnDef::new(LateritePlugins::QuarantinedFingerprint).text())
82 .col(ColumnDef::new(LateritePlugins::LoadFingerprint).text())
83 .col(ColumnDef::new(LateritePlugins::CreatedAt).text())
84 .col(ColumnDef::new(LateritePlugins::UpdatedAt).text().not_null())
85 .to_owned(),
86 )
87 .await
88 }
89 async fn down(&self, s: &mut Schema<'_>) -> CoreResult<()> {
90 s.exec(Table::drop().table(LateritePlugins::Table).to_owned())
91 .await
92 }
93}
94
95pub async fn disabled_ids(db: &Db) -> anyhow::Result<Vec<String>> {
98 let (sql, values) = build(
99 db.backend,
100 Query::select()
101 .column(LateritePlugins::PluginId)
102 .from(LateritePlugins::Table)
103 .and_where(Expr::col(LateritePlugins::Enabled).eq(0))
104 .to_owned(),
105 );
106 let rows = bind_values(sqlx::query(&sql), values)
107 .fetch_all(&db.pool)
108 .await?;
109 Ok(rows
110 .iter()
111 .filter_map(|r| r.get_text("plugin_id").ok())
112 .collect())
113}
114
115pub struct PluginRow {
117 pub id: String,
118 pub enabled: bool,
119 pub version: Option<String>,
120 pub quarantined_reason: Option<String>,
121}
122
123pub async fn list_plugins(db: &Db) -> anyhow::Result<Vec<PluginRow>> {
125 let (sql, values) = build(
126 db.backend,
127 Query::select()
128 .columns([
129 LateritePlugins::PluginId,
130 LateritePlugins::Enabled,
131 LateritePlugins::Version,
132 LateritePlugins::QuarantinedReason,
133 ])
134 .from(LateritePlugins::Table)
135 .order_by(LateritePlugins::PluginId, Order::Asc)
136 .to_owned(),
137 );
138 let rows = bind_values(sqlx::query(&sql), values)
139 .fetch_all(&db.pool)
140 .await?;
141 Ok(rows
142 .iter()
143 .map(|r| PluginRow {
144 id: r.get_text("plugin_id").unwrap_or_default(),
145 enabled: r.get_bool("enabled").unwrap_or(true),
146 version: r.get_text_opt("version").unwrap_or_default(),
147 quarantined_reason: r.get_text_opt("quarantined_reason").unwrap_or_default(),
148 })
149 .collect())
150}
151
152pub async fn record_roster<'a>(
156 db: &Db,
157 plugin_ids: impl IntoIterator<Item = &'a str>,
158) -> anyhow::Result<()> {
159 let (sql, values) = build(
160 db.backend,
161 Query::select()
162 .column(LateritePlugins::PluginId)
163 .from(LateritePlugins::Table)
164 .to_owned(),
165 );
166 let existing: std::collections::HashSet<String> = bind_values(sqlx::query(&sql), values)
167 .fetch_all(&db.pool)
168 .await?
169 .iter()
170 .filter_map(|r| r.get_text("plugin_id").ok())
171 .collect();
172 let now = chrono::Utc::now().to_rfc3339();
173 for id in plugin_ids {
174 if !existing.contains(id) {
175 insert_row(db, id, true, &now).await?;
176 }
177 }
178 Ok(())
179}
180
181pub async fn set_enabled(db: &Db, plugin_id: &str, enabled: bool) -> anyhow::Result<()> {
184 let now = chrono::Utc::now().to_rfc3339();
185 let (sql, values) = build(
186 db.backend,
187 Query::update()
188 .table(LateritePlugins::Table)
189 .value(LateritePlugins::Enabled, i32::from(enabled))
190 .value(LateritePlugins::UpdatedAt, now.clone())
191 .and_where(Expr::col(LateritePlugins::PluginId).eq(plugin_id))
192 .to_owned(),
193 );
194 let affected = bind_values(sqlx::query(&sql), values)
195 .execute(&db.pool)
196 .await?
197 .rows_affected();
198 if affected == 0 {
199 insert_row(db, plugin_id, enabled, &now).await?;
200 }
201 Ok(())
202}
203
204async fn insert_row(db: &Db, id: &str, enabled: bool, now: &str) -> anyhow::Result<()> {
206 let (sql, values) = build(
207 db.backend,
208 Query::insert()
209 .into_table(LateritePlugins::Table)
210 .columns([
211 LateritePlugins::PluginId,
212 LateritePlugins::Enabled,
213 LateritePlugins::CreatedAt,
214 LateritePlugins::UpdatedAt,
215 ])
216 .values_panic([id.into(), i32::from(enabled).into(), now.into(), now.into()])
217 .to_owned(),
218 );
219 bind_values(sqlx::query(&sql), values)
220 .execute(&db.pool)
221 .await?;
222 Ok(())
223}
224
225pub const CRASH_REASON: &str = "init crashed the previous boot";
228
229pub fn binary_fingerprint() -> String {
234 match std::env::current_exe().and_then(std::fs::metadata) {
235 Ok(meta) => {
236 let mtime = meta
237 .modified()
238 .ok()
239 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
240 .map(|d| d.as_secs())
241 .unwrap_or(0);
242 format!("{}-{}", meta.len(), mtime)
243 }
244 Err(_) => "unknown".to_string(),
245 }
246}
247
248pub async fn mark_loading(db: &Db, plugin_id: &str, fingerprint: &str) -> anyhow::Result<()> {
252 update_one(
253 db,
254 plugin_id,
255 Query::update()
256 .value(LateritePlugins::LoadFingerprint, fingerprint)
257 .to_owned(),
258 )
259 .await
260}
261
262pub async fn clear_loading(db: &Db, plugin_id: &str) -> anyhow::Result<()> {
264 update_one(
265 db,
266 plugin_id,
267 Query::update()
268 .value(LateritePlugins::LoadFingerprint, Option::<&str>::None)
269 .to_owned(),
270 )
271 .await
272}
273
274pub async fn quarantine(
279 db: &Db,
280 plugin_id: &str,
281 reason: &str,
282 fingerprint: &str,
283) -> anyhow::Result<()> {
284 let now = chrono::Utc::now().to_rfc3339();
285 update_one(
286 db,
287 plugin_id,
288 Query::update()
289 .value(LateritePlugins::QuarantinedReason, reason)
290 .value(LateritePlugins::QuarantinedAt, now)
291 .value(LateritePlugins::QuarantinedFingerprint, fingerprint)
292 .value(LateritePlugins::LoadFingerprint, Option::<&str>::None)
293 .to_owned(),
294 )
295 .await
296}
297
298async fn clear_quarantine(db: &Db, plugin_id: &str) -> anyhow::Result<()> {
300 update_one(
301 db,
302 plugin_id,
303 Query::update()
304 .value(LateritePlugins::QuarantinedReason, Option::<&str>::None)
305 .value(LateritePlugins::QuarantinedAt, Option::<&str>::None)
306 .value(
307 LateritePlugins::QuarantinedFingerprint,
308 Option::<&str>::None,
309 )
310 .to_owned(),
311 )
312 .await
313}
314
315async fn update_one(
318 db: &Db,
319 plugin_id: &str,
320 mut stmt: sea_query::UpdateStatement,
321) -> anyhow::Result<()> {
322 let now = chrono::Utc::now().to_rfc3339();
323 stmt.table(LateritePlugins::Table)
324 .value(LateritePlugins::UpdatedAt, now)
325 .and_where(Expr::col(LateritePlugins::PluginId).eq(plugin_id));
326 let (sql, values) = build(db.backend, stmt);
327 bind_values(sqlx::query(&sql), values)
328 .execute(&db.pool)
329 .await?;
330 Ok(())
331}
332
333pub async fn quarantined(db: &Db) -> anyhow::Result<Vec<(String, String)>> {
336 let (sql, values) = build(
337 db.backend,
338 Query::select()
339 .columns([
340 LateritePlugins::PluginId,
341 LateritePlugins::QuarantinedReason,
342 ])
343 .from(LateritePlugins::Table)
344 .and_where(Expr::col(LateritePlugins::QuarantinedReason).is_not_null())
345 .order_by(LateritePlugins::PluginId, Order::Asc)
346 .to_owned(),
347 );
348 let rows = bind_values(sqlx::query(&sql), values)
349 .fetch_all(&db.pool)
350 .await?;
351 Ok(rows
352 .iter()
353 .map(|r| {
354 let id = r.get_text("plugin_id").unwrap_or_default();
355 let reason = r
356 .get_text_opt("quarantined_reason")
357 .unwrap_or_default()
358 .unwrap_or_default();
359 (id, reason)
360 })
361 .collect())
362}
363
364pub struct ReconcileReport {
366 pub crashed: Vec<String>,
368 pub cleared: Vec<String>,
370}
371
372pub async fn reconcile_boot(db: &Db, fingerprint: &str) -> anyhow::Result<ReconcileReport> {
382 let (sql, values) = build(
383 db.backend,
384 Query::select()
385 .columns([
386 LateritePlugins::PluginId,
387 LateritePlugins::LoadFingerprint,
388 LateritePlugins::QuarantinedReason,
389 LateritePlugins::QuarantinedFingerprint,
390 ])
391 .from(LateritePlugins::Table)
392 .to_owned(),
393 );
394 let rows = bind_values(sqlx::query(&sql), values)
395 .fetch_all(&db.pool)
396 .await?;
397
398 let mut crashed = Vec::new();
399 let mut cleared = Vec::new();
400 for r in &rows {
401 let id = r.get_text("plugin_id").unwrap_or_default();
402 let load_fp = r.get_text_opt("load_fingerprint").unwrap_or_default();
403 let q_reason = r.get_text_opt("quarantined_reason").unwrap_or_default();
404 let q_fp = r
405 .get_text_opt("quarantined_fingerprint")
406 .unwrap_or_default();
407
408 if load_fp.as_deref() == Some(fingerprint) {
409 quarantine(db, &id, CRASH_REASON, fingerprint).await?;
410 crashed.push(id);
411 continue;
412 }
413 if load_fp.is_some() {
414 clear_loading(db, &id).await?;
415 }
416 if q_reason.is_some() && q_fp.as_deref() != Some(fingerprint) {
417 clear_quarantine(db, &id).await?;
418 cleared.push(id);
419 }
420 }
421 Ok(ReconcileReport { crashed, cleared })
422}
423
424struct PluginView {
429 id: String,
430 version: String,
431 enabled: bool,
432 quarantined: bool,
433 quarantined_reason: String,
434 action_label: &'static str,
437 action_target: bool,
439}
440
441impl PluginView {
442 fn from_row(row: PluginRow) -> Self {
443 PluginView {
444 version: row.version.unwrap_or_else(|| "--".to_string()),
445 quarantined: row.quarantined_reason.is_some(),
446 quarantined_reason: row.quarantined_reason.unwrap_or_default(),
447 action_label: if row.enabled { "Disable" } else { "Enable" },
448 action_target: !row.enabled,
449 enabled: row.enabled,
450 id: row.id,
451 }
452 }
453}
454
455#[derive(Template)]
456#[template(path = "plugins.html")]
457struct PluginsTemplate {
458 shell: Shell,
459 toggle_action: String,
461 plugins: Vec<PluginView>,
462}
463
464pub(crate) async fn index(
467 State(state): State<AdminState>,
468 Extension(shell): Extension<Shell>,
469) -> Response {
470 let rows = match list_plugins(&state.db).await {
471 Ok(rows) => rows,
472 Err(_) => return render_error(),
473 };
474 render(PluginsTemplate {
475 shell,
476 toggle_action: format!("{}/plugins/toggle", state.admin_path),
477 plugins: rows.into_iter().map(PluginView::from_row).collect(),
478 })
479}
480
481#[derive(serde::Deserialize)]
483pub(crate) struct ToggleForm {
484 plugin_id: String,
485 enable: i32,
486}
487
488pub(crate) async fn toggle(
491 State(state): State<AdminState>,
492 Extension(session): Extension<crate::session::SessionHandle>,
493 Extension(user): Extension<laterite_auth::AuthenticatedUser>,
494 Form(form): Form<ToggleForm>,
495) -> Response {
496 let back = format!("{}/plugins", state.admin_path);
497 let enabling = form.enable != 0;
498 match set_enabled(&state.db, &form.plugin_id, enabling).await {
499 Ok(()) => {
500 crate::audit::record(
501 &state,
502 &user,
503 if enabling {
504 "backend.plugin.enable"
505 } else {
506 "backend.plugin.disable"
507 },
508 Some("plugin"),
509 Some(form.plugin_id.as_str()),
510 None,
511 None,
512 )
513 .await;
514 let flash = if enabling {
517 t!("Plugin enabled. The change applies on the next restart.")
518 } else {
519 t!("Plugin disabled. The change applies on the next restart.")
520 };
521 session.push_flash(crate::session::FlashLevel::Success, flash);
522 Redirect::to(&back).into_response()
523 }
524 Err(_) => render_error(),
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[tokio::test]
533 async fn disabled_ids_is_empty_on_a_fresh_registry() {
534 let (db, _guard) =
535 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
536 assert!(disabled_ids(&db).await.unwrap().is_empty());
537 }
538
539 #[tokio::test]
540 async fn set_enabled_toggles_the_disabled_set() {
541 let (db, _guard) =
542 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
543 set_enabled(&db, "acme.blog", false).await.unwrap();
544 set_enabled(&db, "acme.shop", true).await.unwrap();
545 assert_eq!(
546 disabled_ids(&db).await.unwrap(),
547 vec!["acme.blog".to_string()]
548 );
549 set_enabled(&db, "acme.blog", true).await.unwrap();
550 assert!(disabled_ids(&db).await.unwrap().is_empty());
551 }
552
553 #[tokio::test]
554 async fn record_roster_adds_new_enabled_and_preserves_state() {
555 let (db, _guard) =
556 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
557 set_enabled(&db, "acme.blog", false).await.unwrap();
558 record_roster(&db, ["acme.blog", "acme.shop"])
559 .await
560 .unwrap();
561 let plugins = list_plugins(&db).await.unwrap();
562 assert_eq!(plugins.len(), 2);
563 let blog = plugins.iter().find(|p| p.id == "acme.blog").unwrap();
564 let shop = plugins.iter().find(|p| p.id == "acme.shop").unwrap();
565 assert!(!blog.enabled, "existing disabled state preserved");
566 assert!(shop.enabled, "new plugin defaults enabled");
567 }
568
569 #[tokio::test]
570 async fn a_load_marker_from_the_same_binary_quarantines_the_plugin() {
571 let (db, _guard) =
572 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
573 record_roster(&db, ["acme.crash"]).await.unwrap();
574 mark_loading(&db, "acme.crash", "fp1").await.unwrap();
577 let report = reconcile_boot(&db, "fp1").await.unwrap();
578 assert_eq!(report.crashed, vec!["acme.crash".to_string()]);
579 assert!(report.cleared.is_empty());
580 let q = quarantined(&db).await.unwrap();
581 assert_eq!(
582 q,
583 vec![("acme.crash".to_string(), CRASH_REASON.to_string())]
584 );
585 }
586
587 #[tokio::test]
588 async fn a_cleanly_loaded_plugin_is_not_quarantined() {
589 let (db, _guard) =
590 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
591 record_roster(&db, ["acme.ok"]).await.unwrap();
592 mark_loading(&db, "acme.ok", "fp1").await.unwrap();
593 clear_loading(&db, "acme.ok").await.unwrap();
594 let report = reconcile_boot(&db, "fp1").await.unwrap();
595 assert!(report.crashed.is_empty());
596 assert!(quarantined(&db).await.unwrap().is_empty());
597 }
598
599 #[tokio::test]
600 async fn a_load_marker_from_an_old_binary_is_cleared_not_quarantined() {
601 let (db, _guard) =
602 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
603 record_roster(&db, ["acme.p"]).await.unwrap();
604 mark_loading(&db, "acme.p", "old-fp").await.unwrap();
607 let report = reconcile_boot(&db, "new-fp").await.unwrap();
608 assert!(report.crashed.is_empty());
609 assert!(quarantined(&db).await.unwrap().is_empty());
610 let again = reconcile_boot(&db, "new-fp").await.unwrap();
612 assert!(again.crashed.is_empty());
613 }
614
615 #[tokio::test]
616 async fn quarantine_persists_under_the_same_binary_and_lifts_on_a_new_one() {
617 let (db, _guard) =
618 laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
619 record_roster(&db, ["acme.q"]).await.unwrap();
620 quarantine(&db, "acme.q", "boom", "fp1").await.unwrap();
621 let same = reconcile_boot(&db, "fp1").await.unwrap();
623 assert!(same.cleared.is_empty());
624 assert_eq!(quarantined(&db).await.unwrap().len(), 1);
625 let changed = reconcile_boot(&db, "fp2").await.unwrap();
627 assert_eq!(changed.cleared, vec!["acme.q".to_string()]);
628 assert!(quarantined(&db).await.unwrap().is_empty());
629 }
630}