Skip to main content

bamboo_server/handlers/agent/plugin/
handlers.rs

1//! `/api/v1/plugins` handlers: install / update / list / remove.
2//!
3//! Every handler constructs a fresh `ServerPluginInstaller::new(state.clone())`
4//! per request — the same pattern every other handler in this crate uses for
5//! `web::Data<AppState>` (it's `Arc`-backed, so cloning is cheap) — and either
6//! calls it directly (`list`/`uninstall`) or drives it through
7//! `crate::plugin_source`'s stage-then-install seam (`install`/`update`),
8//! matching `examples/install_plugin.rs`'s reference usage.
9
10use std::path::PathBuf;
11
12use actix_web::{web, HttpResponse, Responder};
13use bamboo_plugin::{InstallDisposition, PluginError, PluginInstaller, PluginSource};
14
15use crate::app_state::AppState;
16use crate::plugin_installer::ServerPluginInstaller;
17use crate::plugin_source::{install_plugin_from_source, stage_plugin_source, PluginSourceInput};
18
19use super::api_types::{to_view, InstallPluginRequest, PluginListResponse};
20use super::errors::plugin_error_response;
21
22fn plugins_root(state: &AppState) -> PathBuf {
23    state.app_data_dir.join("plugins")
24}
25
26/// The wire `SourceSpec` reuses `bamboo_plugin::PluginSource`'s own serde
27/// shape (see `api_types` module docs) directly as the request body — a
28/// `url` source's `sha256`/`allow_unverified`/`allow_untrusted_host`/
29/// `allow_unsigned`/`insecure` flow straight through to
30/// `PluginSourceInput::Url`, which `plugin_source::fetch_manifest_bundle`
31/// enforces (the three-layer trust model: host allowlist, signature,
32/// checksum — plus the `insecure`/`plugin_trust.enforcement` aggregate
33/// escape hatch over all three — see that module's docs). `signed_by` is a
34/// RESULT of staging (which key verified), never an input, so it's dropped
35/// here — the request-side `PluginSource::Url` field is meaningless on the
36/// way in and `fetch_manifest_bundle` recomputes it fresh. `insecure`, by
37/// contrast, genuinely IS an input here (the caller's `--insecure` /
38/// `"insecure": true` opt-in) — this authenticated/local-only HTTP surface
39/// is already behind the access-password middleware (see `routes`), same as
40/// every other `/api/v1/plugins` route.
41fn to_source_input(source: PluginSource) -> PluginSourceInput {
42    match source {
43        PluginSource::LocalDir { path } => PluginSourceInput::LocalDir(path),
44        PluginSource::LocalArchive { path } => PluginSourceInput::LocalArchive(path),
45        PluginSource::Url {
46            url,
47            sha256,
48            allow_unverified,
49            allow_untrusted_host,
50            allow_unsigned,
51            signed_by: _,
52            insecure,
53        } => PluginSourceInput::Url {
54            url,
55            sha256,
56            allow_unverified,
57            allow_untrusted_host,
58            allow_unsigned,
59            insecure,
60        },
61    }
62}
63
64/// `GET /api/v1/plugins`
65pub async fn list_plugins(state: web::Data<AppState>) -> impl Responder {
66    let installer = ServerPluginInstaller::new(state.clone());
67    match installer.list().await {
68        Ok(entries) => {
69            let mut plugins = Vec::with_capacity(entries.len());
70            for entry in entries {
71                plugins.push(to_view(entry, &state.service_manager).await);
72            }
73            HttpResponse::Ok().json(PluginListResponse { plugins })
74        }
75        Err(error) => plugin_error_response(&error),
76    }
77}
78
79/// `POST /api/v1/plugins/install` — always `InstallDisposition::FailIfInstalled`
80/// (surfaces `PluginError::AlreadyInstalled` as 409 if the id is already
81/// registered; retry via `POST /{id}/update` instead).
82pub async fn install_plugin(
83    state: web::Data<AppState>,
84    body: web::Json<InstallPluginRequest>,
85) -> impl Responder {
86    let installer = ServerPluginInstaller::new(state.clone());
87    let root = plugins_root(&state);
88    let input = to_source_input(body.into_inner().source);
89    let trust = state.config.read().await.plugin_trust.clone();
90
91    match install_plugin_from_source(
92        &installer,
93        input,
94        &root,
95        &trust,
96        InstallDisposition::FailIfInstalled,
97    )
98    .await
99    {
100        Ok(entry) => HttpResponse::Created().json(to_view(entry, &state.service_manager).await),
101        Err(error) => plugin_error_response(&error),
102    }
103}
104
105/// `POST /api/v1/plugins/{id}/update` — `InstallDisposition::Upgrade`.
106///
107/// Unlike `install`, this route's URL names the target id up front, so —
108/// before handing off to the installer — the staged source's OWN manifest id
109/// (the id `install()` will actually key the upgrade by) is checked against
110/// the path segment. A mismatch is refused as a 400 rather than silently
111/// upgrading whatever id the body's source happens to declare, which would
112/// otherwise be README-legible but genuinely confusing (a request the URL
113/// promises operates on `foo` silently upgrading `bar`).
114pub async fn update_plugin(
115    state: web::Data<AppState>,
116    path: web::Path<String>,
117    body: web::Json<InstallPluginRequest>,
118) -> impl Responder {
119    let path_id = path.into_inner();
120    let installer = ServerPluginInstaller::new(state.clone());
121    let root = plugins_root(&state);
122    let input = to_source_input(body.into_inner().source);
123    let trust = state.config.read().await.plugin_trust.clone();
124
125    // Same-id upgrade ordering (issue #479): `stage_plugin_source` below
126    // swaps `plugin_dir`'s ENTIRE contents (old bundle -> `.backup-*`,
127    // staged bundle -> `plugin_dir`) BEFORE `install()` ever runs — so any
128    // service this plugin currently owns must be stopped BEFORE staging,
129    // not after `install()`'s own `register_services` step, or the old
130    // process could still be holding the about-to-be-replaced binary open
131    // (and would otherwise briefly run stale code post-swap either way).
132    // `path_id` is the ONLY point in this flow where the target id is known
133    // up front (a fresh `install_plugin` has no prior id to look up). See
134    // `ServerPluginInstaller::stop_services_for_upgrade`'s doc comment for
135    // the full stop -> swap -> start sequencing this establishes.
136    let stopped_services = installer.stop_services_for_upgrade(&path_id).await;
137
138    let staged = match stage_plugin_source(input, &root, &trust).await {
139        Ok(staged) => staged,
140        Err(error) => {
141            installer
142                .restart_services_after_failed_upgrade(&path_id, &stopped_services)
143                .await;
144            return plugin_error_response(&error);
145        }
146    };
147    if staged.manifest.id != path_id {
148        let manifest_id = staged.manifest.id.clone();
149        staged.rollback().await;
150        installer
151            .restart_services_after_failed_upgrade(&path_id, &stopped_services)
152            .await;
153        return plugin_error_response(&PluginError::InvalidManifest(format!(
154            "path id '{path_id}' does not match the source's manifest id '{manifest_id}'"
155        )));
156    }
157
158    let manifest = staged.manifest.clone();
159    let plugin_dir = staged.plugin_dir.clone();
160    let source = staged.source.clone();
161    match installer
162        .install(
163            &manifest,
164            &plugin_dir,
165            source,
166            InstallDisposition::Upgrade,
167            chrono::Utc::now(),
168        )
169        .await
170    {
171        Ok(entry) => {
172            staged.commit().await;
173            HttpResponse::Ok().json(to_view(entry, &state.service_manager).await)
174        }
175        Err(error) => {
176            staged.rollback().await;
177            // `plugin_dir` is now back to the pre-upgrade bundle's bytes —
178            // restart exactly what `stop_services_for_upgrade` stopped.
179            installer
180                .restart_services_after_failed_upgrade(&path_id, &stopped_services)
181                .await;
182            plugin_error_response(&error)
183        }
184    }
185}
186
187/// `DELETE /api/v1/plugins/{id}`
188pub async fn remove_plugin(state: web::Data<AppState>, path: web::Path<String>) -> impl Responder {
189    let id = path.into_inner();
190    let installer = ServerPluginInstaller::new(state.clone());
191    match installer.uninstall(&id).await {
192        Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "id": id, "removed": true })),
193        Err(error) => plugin_error_response(&error),
194    }
195}