Skip to main content

laterite_admin/
plugins.rs

1//! The `laterite.plugins` built-in module: a registry table recording each
2//! plugin's operator enable/disable state, read at boot to decide which plugins
3//! load. `record_roster` records the compiled plugins here at boot, `set_enabled`
4//! is the enable/disable service (applied on the next boot), and `list_plugins`
5//! feeds the admin screen. The `quarantined_*` columns hold the system's verdict
6//! (written by a later slice).
7//!
8//! The admin screen (`index`, `toggle`) lists the roster and lets an operator
9//! flip a plugin on or off; the change is an intent stored here and applied on
10//! the next boot, so the screen says so plainly.
11
12use 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
22/// Permission gating the plugins screen and its toggle action.
23pub(crate) const MANAGE_PERMISSION: &str = "backend.manage_plugins";
24
25/// Stable id of this built-in module.
26pub const MODULE_ID: &str = "laterite.plugins";
27
28/// The built-in module owning the plugin-registry table.
29pub 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/// The plugin-registry table. `enabled` is the operator's intent (default on, and
41/// an absent row also means enabled); `version` and `created_at` record the
42/// installed version and first-seen time (filled by the install flow); the
43/// `quarantined_*` columns are the system's verdict, set when a plugin fails to
44/// load, with `quarantined_fingerprint` recording the binary it failed under so a
45/// new binary can lift it. `load_fingerprint` is the crash-journal marker: set to
46/// the current binary's fingerprint just before a plugin's init runs and cleared
47/// right after it succeeds, so a value still present at the next boot means that
48/// plugin's init took the whole process down. Applied migrations are tracked
49/// separately in `laterite_migrations`, so there is no per-plugin history here.
50#[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
95/// The plugin ids an operator has disabled (rows with `enabled` false). An absent
96/// row means enabled, so a fresh install disables nothing.
97pub 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
115/// A plugin's registry row, for the admin screen.
116pub struct PluginRow {
117    pub id: String,
118    pub enabled: bool,
119    pub version: Option<String>,
120    pub quarantined_reason: Option<String>,
121}
122
123/// Every registered plugin with its state, ordered by id, for the admin screen.
124pub 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
152/// Records the compiled plugins, inserting an enabled row for any not seen before
153/// and leaving existing rows (and their operator state) untouched. Called at boot
154/// so the table is the full plugin roster the admin screen lists.
155pub 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
181/// Sets a plugin's operator enable/disable state (upsert). Takes effect on the next
182/// boot: a disabled plugin is skipped, an enabled one migrates and registers.
183pub 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
204/// Inserts a registry row with the given enabled state and timestamps.
205async 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
225/// Reason recorded when a plugin is auto-quarantined because its init crashed
226/// the previous boot (a crash no `catch_unwind` could catch).
227pub const CRASH_REASON: &str = "init crashed the previous boot";
228
229/// Fingerprint of the running binary (its size and modification time): stable
230/// across restarts of the same executable, changed by a rebuild or redeploy. The
231/// journal uses it to tell "the same binary crashed again" from "a new binary may
232/// be fixed". Falls back to a constant when the executable cannot be inspected.
233pub 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
248/// Durably records that `plugin_id`'s init is about to run under `fingerprint`.
249/// Written before the plugin migrates or registers, so a hard crash leaves the
250/// marker for the next boot's [`reconcile_boot`] to find.
251pub 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
262/// Clears the load marker once a plugin's init has succeeded.
263pub 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
274/// Persists a quarantine against `plugin_id`: the reason, the time, and the
275/// binary `fingerprint` it failed under (so a later, different binary lifts it).
276/// Also clears any load marker, since the attempt is now resolved. A quarantined
277/// plugin is skipped at boot until it is re-enabled or the binary changes.
278pub 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
298/// Lifts a plugin's quarantine (clears the reason, time, and fingerprint).
299async 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
315/// Applies an update to one plugin row, stamping `updated_at` and scoping to
316/// `plugin_id`. The caller supplies the column values to set.
317async 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
333/// The ids and reasons of currently-quarantined plugins. The boot skips these and
334/// reports the reason.
335pub 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
364/// What [`reconcile_boot`] changed, for the boot log.
365pub struct ReconcileReport {
366    /// Plugins auto-quarantined because their init crashed the previous boot.
367    pub crashed: Vec<String>,
368    /// Plugins whose quarantine was lifted because the binary changed.
369    pub cleared: Vec<String>,
370}
371
372/// Reconciles the journal against the current binary `fingerprint` at boot,
373/// before deciding what loads:
374///
375/// - A load marker equal to `fingerprint` means that plugin's init took the
376///   process down last boot under this same binary: quarantine it.
377/// - A load marker from a different binary is a stale attempt: clear it and let
378///   the plugin try again.
379/// - A quarantine recorded under a different binary is lifted: a new build may
380///   have fixed the fault.
381pub 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
424/// One plugin row as the screen shows it: its id, installed version, and a
425/// status. `quarantined` (a reason recorded by the system) outranks the
426/// operator's `enabled` intent, since a quarantined plugin does not load however
427/// it is set.
428struct PluginView {
429    id: String,
430    version: String,
431    enabled: bool,
432    quarantined: bool,
433    quarantined_reason: String,
434    /// The label the toggle button carries: "Disable" for a live plugin,
435    /// "Enable" otherwise.
436    action_label: &'static str,
437    /// The state the toggle posts: the opposite of the current intent.
438    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    /// The POST target for a toggle, under the admin mount.
460    toggle_action: String,
461    plugins: Vec<PluginView>,
462}
463
464/// Renders the plugins screen: the full roster with each plugin's state and a
465/// toggle. Gated by [`MANAGE_PERMISSION`] at the route.
466pub(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/// The toggle form: which plugin, and the state to set (`1` on, `0` off).
482#[derive(serde::Deserialize)]
483pub(crate) struct ToggleForm {
484    plugin_id: String,
485    enable: i32,
486}
487
488/// Records a plugin's new enable/disable intent and returns to the list. The
489/// change applies on the next boot (see [`set_enabled`]).
490pub(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            )
512            .await;
513            // Two whole messages rather than an interpolated verb: the state word
514            // must localize, and its grammar varies by language.
515            let flash = if enabling {
516                t!("Plugin enabled. The change applies on the next restart.")
517            } else {
518                t!("Plugin disabled. The change applies on the next restart.")
519            };
520            session.push_flash(crate::session::FlashLevel::Success, flash);
521            Redirect::to(&back).into_response()
522        }
523        Err(_) => render_error(),
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[tokio::test]
532    async fn disabled_ids_is_empty_on_a_fresh_registry() {
533        let (db, _guard) =
534            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
535        assert!(disabled_ids(&db).await.unwrap().is_empty());
536    }
537
538    #[tokio::test]
539    async fn set_enabled_toggles_the_disabled_set() {
540        let (db, _guard) =
541            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
542        set_enabled(&db, "acme.blog", false).await.unwrap();
543        set_enabled(&db, "acme.shop", true).await.unwrap();
544        assert_eq!(
545            disabled_ids(&db).await.unwrap(),
546            vec!["acme.blog".to_string()]
547        );
548        set_enabled(&db, "acme.blog", true).await.unwrap();
549        assert!(disabled_ids(&db).await.unwrap().is_empty());
550    }
551
552    #[tokio::test]
553    async fn record_roster_adds_new_enabled_and_preserves_state() {
554        let (db, _guard) =
555            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
556        set_enabled(&db, "acme.blog", false).await.unwrap();
557        record_roster(&db, ["acme.blog", "acme.shop"])
558            .await
559            .unwrap();
560        let plugins = list_plugins(&db).await.unwrap();
561        assert_eq!(plugins.len(), 2);
562        let blog = plugins.iter().find(|p| p.id == "acme.blog").unwrap();
563        let shop = plugins.iter().find(|p| p.id == "acme.shop").unwrap();
564        assert!(!blog.enabled, "existing disabled state preserved");
565        assert!(shop.enabled, "new plugin defaults enabled");
566    }
567
568    #[tokio::test]
569    async fn a_load_marker_from_the_same_binary_quarantines_the_plugin() {
570        let (db, _guard) =
571            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
572        record_roster(&db, ["acme.crash"]).await.unwrap();
573        // The previous boot marked it loading under fp1 and never cleared it: its
574        // init took the process down. This boot runs the same binary.
575        mark_loading(&db, "acme.crash", "fp1").await.unwrap();
576        let report = reconcile_boot(&db, "fp1").await.unwrap();
577        assert_eq!(report.crashed, vec!["acme.crash".to_string()]);
578        assert!(report.cleared.is_empty());
579        let q = quarantined(&db).await.unwrap();
580        assert_eq!(
581            q,
582            vec![("acme.crash".to_string(), CRASH_REASON.to_string())]
583        );
584    }
585
586    #[tokio::test]
587    async fn a_cleanly_loaded_plugin_is_not_quarantined() {
588        let (db, _guard) =
589            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
590        record_roster(&db, ["acme.ok"]).await.unwrap();
591        mark_loading(&db, "acme.ok", "fp1").await.unwrap();
592        clear_loading(&db, "acme.ok").await.unwrap();
593        let report = reconcile_boot(&db, "fp1").await.unwrap();
594        assert!(report.crashed.is_empty());
595        assert!(quarantined(&db).await.unwrap().is_empty());
596    }
597
598    #[tokio::test]
599    async fn a_load_marker_from_an_old_binary_is_cleared_not_quarantined() {
600        let (db, _guard) =
601            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
602        record_roster(&db, ["acme.p"]).await.unwrap();
603        // Marked under an old binary; the running binary differs, so the marker is
604        // stale (a fresh build), not a same-binary crash.
605        mark_loading(&db, "acme.p", "old-fp").await.unwrap();
606        let report = reconcile_boot(&db, "new-fp").await.unwrap();
607        assert!(report.crashed.is_empty());
608        assert!(quarantined(&db).await.unwrap().is_empty());
609        // The marker was cleared, so reconciling again finds nothing to act on.
610        let again = reconcile_boot(&db, "new-fp").await.unwrap();
611        assert!(again.crashed.is_empty());
612    }
613
614    #[tokio::test]
615    async fn quarantine_persists_under_the_same_binary_and_lifts_on_a_new_one() {
616        let (db, _guard) =
617            laterite_core::testing::connect_test(&[PluginsModule.migrations()]).await;
618        record_roster(&db, ["acme.q"]).await.unwrap();
619        quarantine(&db, "acme.q", "boom", "fp1").await.unwrap();
620        // Same binary: the quarantine stands.
621        let same = reconcile_boot(&db, "fp1").await.unwrap();
622        assert!(same.cleared.is_empty());
623        assert_eq!(quarantined(&db).await.unwrap().len(), 1);
624        // A new binary may have fixed the fault: the quarantine is lifted.
625        let changed = reconcile_boot(&db, "fp2").await.unwrap();
626        assert_eq!(changed.cleared, vec!["acme.q".to_string()]);
627        assert!(quarantined(&db).await.unwrap().is_empty());
628    }
629}