Skip to main content

greentic_operator/
admin_api.rs

1//! Admin API handler with mTLS support for setup/update/remove operations.
2//!
3//! Uses types from `greentic_setup::admin` for request/response structures.
4//! Provides HTTP endpoints for bundle lifecycle management.
5
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use anyhow::Result;
10use greentic_setup::admin::routes::{
11    AdminResponse, BundleDeployRequest, BundleRemoveRequest, BundleStatus, BundleStatusResponse,
12};
13use greentic_setup::admin::tls::AdminTlsConfig;
14use http_body_util::Full;
15use hyper::body::{Bytes, Incoming};
16use hyper::{Method, Request, Response, StatusCode};
17use serde_json::{Value, json};
18
19/// Shared state for the admin API handler.
20pub struct AdminState {
21    pub tls_config: AdminTlsConfig,
22    pub bundle_root: PathBuf,
23    pub tenant: String,
24    pub team: Option<String>,
25    pub env: String,
26}
27
28/// Handle an incoming admin API request.
29///
30/// Routes:
31/// - `GET  /admin/status`          → bundle deployment status
32/// - `POST /admin/deploy`          → deploy/update a bundle
33/// - `POST /admin/remove`          → remove bundle components
34/// - `POST /admin/qa/spec`         → get QA form spec for a provider
35/// - `POST /admin/qa/validate`     → validate QA answers
36/// - `POST /admin/qa/submit`       → submit QA answers and persist
37pub async fn handle_admin_request(
38    req: Request<Incoming>,
39    path: &str,
40    state: &Arc<AdminState>,
41) -> Result<Response<Full<Bytes>>> {
42    let method = req.method().clone();
43    let sub_path = path
44        .strip_prefix("/admin")
45        .unwrap_or("")
46        .trim_end_matches('/');
47
48    match (method, sub_path) {
49        (Method::GET, "/status") => handle_status(state).await,
50        (Method::POST, "/deploy") => handle_deploy(req, state).await,
51        (Method::POST, "/remove") => handle_remove(req, state).await,
52        (Method::POST, "/qa/spec") => handle_qa_spec(req, state).await,
53        (Method::POST, "/qa/validate") => handle_qa_validate(req, state).await,
54        (Method::POST, "/qa/submit") => handle_qa_submit(req, state).await,
55        _ => json_response(
56            StatusCode::NOT_FOUND,
57            &AdminResponse::<()>::err("not found"),
58        ),
59    }
60}
61
62async fn handle_status(state: &Arc<AdminState>) -> Result<Response<Full<Bytes>>> {
63    let bundle_exists = state.bundle_root.exists();
64    let providers_dir = state.bundle_root.join("providers");
65
66    let mut provider_count = 0usize;
67    if providers_dir.exists() {
68        if let Ok(entries) = std::fs::read_dir(&providers_dir) {
69            for entry in entries.flatten() {
70                let path = entry.path();
71                if path.is_dir() {
72                    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
73                        if !name.starts_with('_') && !name.starts_with('.') {
74                            provider_count += 1;
75                        }
76                    }
77                }
78            }
79        }
80    }
81
82    let status = BundleStatusResponse {
83        status: if bundle_exists {
84            BundleStatus::Active
85        } else {
86            BundleStatus::Error
87        },
88        bundle_path: state.bundle_root.clone(),
89        pack_count: 0,
90        tenant_count: 1,
91        provider_count,
92    };
93
94    json_response(StatusCode::OK, &AdminResponse::ok(status))
95}
96
97async fn handle_deploy(
98    req: Request<Incoming>,
99    state: &Arc<AdminState>,
100) -> Result<Response<Full<Bytes>>> {
101    let body = read_body(req).await?;
102    let request: BundleDeployRequest = match serde_json::from_slice(&body) {
103        Ok(r) => r,
104        Err(e) => {
105            return json_response(
106                StatusCode::BAD_REQUEST,
107                &AdminResponse::<()>::err(format!("invalid request: {e}")),
108            );
109        }
110    };
111
112    // Use greentic-setup's SetupEngine for plan building
113    let engine = greentic_setup::SetupEngine::new(greentic_setup::engine::SetupConfig {
114        tenant: state.tenant.clone(),
115        team: state.team.clone(),
116        env: state.env.clone(),
117        offline: false,
118        verbose: false,
119    });
120
121    let setup_request = greentic_setup::engine::SetupRequest {
122        bundle: request.bundle_path,
123        bundle_name: request.bundle_name,
124        pack_refs: request.pack_refs,
125        tenants: request.tenants,
126        ..Default::default()
127    };
128
129    let mode = if state.bundle_root.exists() {
130        greentic_setup::SetupMode::Update
131    } else {
132        greentic_setup::SetupMode::Create
133    };
134
135    match engine.plan(mode, &setup_request, false) {
136        Ok(plan) => {
137            let summary = json!({
138                "mode": format!("{mode:?}"),
139                "steps": plan.steps.len(),
140                "bundle": state.bundle_root.display().to_string(),
141            });
142            json_response(StatusCode::OK, &AdminResponse::ok(summary))
143        }
144        Err(e) => json_response(
145            StatusCode::INTERNAL_SERVER_ERROR,
146            &AdminResponse::<()>::err(e.to_string()),
147        ),
148    }
149}
150
151async fn handle_remove(
152    req: Request<Incoming>,
153    state: &Arc<AdminState>,
154) -> Result<Response<Full<Bytes>>> {
155    let body = read_body(req).await?;
156    let request: BundleRemoveRequest = match serde_json::from_slice(&body) {
157        Ok(r) => r,
158        Err(e) => {
159            return json_response(
160                StatusCode::BAD_REQUEST,
161                &AdminResponse::<()>::err(format!("invalid request: {e}")),
162            );
163        }
164    };
165
166    if !state.bundle_root.exists() {
167        return json_response(
168            StatusCode::NOT_FOUND,
169            &AdminResponse::<()>::err("bundle not found"),
170        );
171    }
172
173    let engine = greentic_setup::SetupEngine::new(greentic_setup::engine::SetupConfig {
174        tenant: state.tenant.clone(),
175        team: state.team.clone(),
176        env: state.env.clone(),
177        offline: true,
178        verbose: false,
179    });
180
181    let setup_request = greentic_setup::engine::SetupRequest {
182        bundle: state.bundle_root.clone(),
183        ..Default::default()
184    };
185
186    match engine.plan(greentic_setup::SetupMode::Remove, &setup_request, false) {
187        Ok(plan) => {
188            let _ = &request; // consumed for future use
189            let summary = json!({
190                "mode": "remove",
191                "steps": plan.steps.len(),
192            });
193            json_response(StatusCode::OK, &AdminResponse::ok(summary))
194        }
195        Err(e) => json_response(
196            StatusCode::INTERNAL_SERVER_ERROR,
197            &AdminResponse::<()>::err(e.to_string()),
198        ),
199    }
200}
201
202async fn handle_qa_spec(
203    req: Request<Incoming>,
204    state: &Arc<AdminState>,
205) -> Result<Response<Full<Bytes>>> {
206    let body = read_body(req).await?;
207    let request: Value = serde_json::from_slice(&body).unwrap_or_default();
208    let provider_id = request
209        .get("provider_id")
210        .and_then(Value::as_str)
211        .unwrap_or("");
212
213    if provider_id.is_empty() {
214        return json_response(
215            StatusCode::BAD_REQUEST,
216            &AdminResponse::<()>::err("provider_id required"),
217        );
218    }
219
220    // Try to find the pack and build a FormSpec
221    let providers_dir = state.bundle_root.join("providers");
222    let pack_path = find_provider_pack(&providers_dir, provider_id);
223
224    match pack_path {
225        Some(path) => {
226            let form_spec =
227                greentic_setup::setup_to_formspec::pack_to_form_spec(&path, provider_id);
228            match form_spec {
229                Some(spec) => {
230                    let json = serde_json::to_value(&spec).unwrap_or_default();
231                    json_response(StatusCode::OK, &AdminResponse::ok(json))
232                }
233                None => json_response(
234                    StatusCode::NOT_FOUND,
235                    &AdminResponse::<()>::err("no setup spec found for provider"),
236                ),
237            }
238        }
239        None => json_response(
240            StatusCode::NOT_FOUND,
241            &AdminResponse::<()>::err(format!("provider pack not found: {provider_id}")),
242        ),
243    }
244}
245
246async fn handle_qa_validate(
247    req: Request<Incoming>,
248    state: &Arc<AdminState>,
249) -> Result<Response<Full<Bytes>>> {
250    let body = read_body(req).await?;
251    let request: Value = serde_json::from_slice(&body).unwrap_or_default();
252    let provider_id = request
253        .get("provider_id")
254        .and_then(Value::as_str)
255        .unwrap_or("");
256    let answers = request.get("answers").cloned().unwrap_or_default();
257
258    let providers_dir = state.bundle_root.join("providers");
259    let pack_path = find_provider_pack(&providers_dir, provider_id);
260
261    match pack_path {
262        Some(path) => {
263            let form_spec =
264                greentic_setup::setup_to_formspec::pack_to_form_spec(&path, provider_id);
265            match form_spec {
266                Some(spec) => {
267                    match greentic_setup::qa::wizard::validate_answers_against_form_spec(
268                        &spec, &answers,
269                    ) {
270                        Ok(()) => json_response(
271                            StatusCode::OK,
272                            &AdminResponse::ok(json!({"valid": true})),
273                        ),
274                        Err(e) => json_response(
275                            StatusCode::OK,
276                            &AdminResponse::ok(json!({"valid": false, "error": e.to_string()})),
277                        ),
278                    }
279                }
280                None => json_response(
281                    StatusCode::OK,
282                    &AdminResponse::ok(json!({"valid": true, "note": "no spec found"})),
283                ),
284            }
285        }
286        None => json_response(
287            StatusCode::NOT_FOUND,
288            &AdminResponse::<()>::err(format!("provider not found: {provider_id}")),
289        ),
290    }
291}
292
293async fn handle_qa_submit(
294    req: Request<Incoming>,
295    state: &Arc<AdminState>,
296) -> Result<Response<Full<Bytes>>> {
297    let body = read_body(req).await?;
298    let request: Value = serde_json::from_slice(&body).unwrap_or_default();
299    let provider_id = request
300        .get("provider_id")
301        .and_then(Value::as_str)
302        .unwrap_or("")
303        .to_string();
304    let answers = request.get("answers").cloned().unwrap_or_default();
305
306    if provider_id.is_empty() {
307        return json_response(
308            StatusCode::BAD_REQUEST,
309            &AdminResponse::<()>::err("provider_id required"),
310        );
311    }
312
313    // Persist answers as secrets
314    let persisted = crate::qa_persist::persist_all_config_as_secrets(
315        &state.bundle_root,
316        &state.env,
317        &state.tenant,
318        state.team.as_deref(),
319        &provider_id,
320        &answers,
321        None,
322    )
323    .await;
324
325    match persisted {
326        Ok(keys) => json_response(
327            StatusCode::OK,
328            &AdminResponse::ok(json!({
329                "persisted_keys": keys,
330                "provider_id": provider_id,
331            })),
332        ),
333        Err(e) => json_response(
334            StatusCode::INTERNAL_SERVER_ERROR,
335            &AdminResponse::<()>::err(e.to_string()),
336        ),
337    }
338}
339
340// ── Helpers ─────────────────────────────────────────────────────────
341
342fn find_provider_pack(providers_dir: &std::path::Path, provider_id: &str) -> Option<PathBuf> {
343    // Check common pack locations
344    for dir_name in &["messaging", "events", "oauth", "secrets", "mcp"] {
345        let pack = providers_dir
346            .join(dir_name)
347            .join(format!("{provider_id}.gtpack"));
348        if pack.exists() {
349            return Some(pack);
350        }
351    }
352    // Check flat layout
353    let flat = providers_dir.join(format!("{provider_id}.gtpack"));
354    if flat.exists() {
355        return Some(flat);
356    }
357    None
358}
359
360fn json_response<T: serde::Serialize>(
361    status: StatusCode,
362    body: &T,
363) -> Result<Response<Full<Bytes>>> {
364    let json = serde_json::to_vec(body)?;
365    Ok(Response::builder()
366        .status(status)
367        .header("content-type", "application/json")
368        .body(Full::new(Bytes::from(json)))
369        .unwrap())
370}
371
372async fn read_body(req: Request<Incoming>) -> Result<Vec<u8>> {
373    use http_body_util::BodyExt;
374    let body = req.into_body().collect().await?.to_bytes();
375    Ok(body.to_vec())
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn find_provider_pack_returns_none_for_missing() {
384        let tmp = tempfile::tempdir().unwrap();
385        assert!(find_provider_pack(tmp.path(), "nonexistent").is_none());
386    }
387
388    #[test]
389    fn find_provider_pack_finds_flat_pack() {
390        let tmp = tempfile::tempdir().unwrap();
391        let pack = tmp.path().join("messaging-telegram.gtpack");
392        std::fs::write(&pack, "pack").unwrap();
393        assert_eq!(
394            find_provider_pack(tmp.path(), "messaging-telegram"),
395            Some(pack)
396        );
397    }
398}