use std::sync::Arc;
use axum::{
Router,
extract::{Path as AxumPath, State},
response::IntoResponse,
routing::get,
};
use serde::Serialize;
use serde_json::Value;
use tokio::sync::broadcast::error::RecvError;
use super::lifecycle::{SpawnParams, spawn_managed};
use super::summary::parse_id;
use crate::core::provisioning_stage::ProvisioningStage;
use crate::daemon::provisioning::{ProvisioningLifecycle, ProvisioningProgress};
use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::state::DaemonState;
use crate::session_manager::ManagedSessionId;
#[derive(Debug, Serialize)]
pub struct AsyncSpawnResponse {
pub id: String,
pub state: String,
}
#[derive(Debug, Serialize)]
pub struct ProvisionStatusResponse {
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl ProvisionStatusResponse {
pub fn from_progress(p: &ProvisioningProgress) -> Self {
Self {
state: p.lifecycle.wire().to_string(),
stage: p.stage.map(|s| s.wire_name().to_string()),
label: p.stage.map(|s| s.label().to_string()),
detail: p.detail.clone(),
session_id: p.session_id.clone(),
name: p.name.clone(),
error: p.error.clone(),
}
}
fn ready_from_record(id: &str, name: &str) -> Self {
Self {
state: ProvisioningLifecycle::Ready.wire().to_string(),
stage: Some(ProvisioningStage::Complete.wire_name().to_string()),
label: Some(ProvisioningStage::Complete.label().to_string()),
detail: None,
session_id: Some(id.to_string()),
name: Some(name.to_string()),
error: None,
}
}
}
fn stage_frame_for_job(v: &Value, job_id: &str) -> Option<(ProvisioningStage, Option<String>)> {
if v.get("kind").and_then(Value::as_str) != Some("provisioning_stage") {
return None;
}
if v.get("session").and_then(Value::as_str) != Some(job_id) {
return None;
}
let stage = v
.get("stage")
.and_then(Value::as_str)
.and_then(ProvisioningStage::from_wire)?;
let detail = v.get("detail").and_then(Value::as_str).map(str::to_string);
Some((stage, detail))
}
pub fn spawn_background(
state: Arc<DaemonState>,
session_id: ManagedSessionId,
params: SpawnParams,
) {
let job_id = session_id.to_string();
state.provisioning.begin(&job_id);
let mut rx = state.event_tx.subscribe();
let updater_state = state.clone();
let updater_job = job_id.clone();
let updater = tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(v) => {
if let Some((stage, detail)) = stage_frame_for_job(&v, &updater_job) {
updater_state
.provisioning
.update_stage(&updater_job, stage, detail);
}
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
});
tokio::spawn(async move {
let result = spawn_managed(&state, session_id, params).await;
updater.abort();
match result {
Ok(record) => state.provisioning.finish_ready(
&job_id,
record.id.to_string(),
record.tmux_name.clone(),
),
Err(e) => state.provisioning.finish_failed(&job_id, e),
}
});
}
pub fn router() -> Router<Arc<DaemonState>> {
Router::new().route(
"/api/v1/sessions/managed/{id}/provision-status",
get(get_provision_status),
)
}
pub fn accept_async_spawn(state: Arc<DaemonState>, params: SpawnParams) -> RouteOutcome {
let session_id = ManagedSessionId::new();
let id = session_id.to_string();
spawn_background(state, session_id, params);
RouteOutcome::json(
202,
&AsyncSpawnResponse {
id,
state: "provisioning".to_string(),
},
)
}
pub async fn get_provision_status(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
) -> impl IntoResponse {
provision_status_core(&state, &id_str).await }
pub(crate) async fn provision_status_core(state: &Arc<DaemonState>, id_str: &str) -> RouteOutcome {
if let Some(progress) = state.provisioning.get(id_str) {
return RouteOutcome::ok(&ProvisionStatusResponse::from_progress(&progress));
}
let id = match parse_id(id_str) {
Ok(id) => id,
Err((code, msg)) => return RouteOutcome::text(code.as_u16(), msg),
};
let mgr = state.session_manager().await;
match mgr.get(&id).await {
Ok(record) => RouteOutcome::ok(&ProvisionStatusResponse::ready_from_record(
&record.id.to_string(),
&record.tmux_name,
)),
Err(_) => RouteOutcome::text(404, format!("no provisioning job or session for {id_str}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
fn stage_frame(session: &str, stage: &str, detail: Option<&str>) -> Value {
let mut v = serde_json::json!({
"kind": "provisioning_stage",
"session": session,
"stage": stage,
});
if let Some(d) = detail {
v.as_object_mut()
.unwrap()
.insert("detail".into(), Value::String(d.into()));
}
v
}
#[test]
fn stage_frame_matches_own_job() {
let v = stage_frame("job-1", "CloningRepo", Some("Receiving objects: 42%"));
let (stage, detail) = stage_frame_for_job(&v, "job-1").expect("own frame matches");
assert_eq!(stage, ProvisioningStage::CloningRepo);
assert_eq!(detail.as_deref(), Some("Receiving objects: 42%"));
}
#[test]
fn stage_frame_ignores_other_job_and_kind() {
let other = stage_frame("job-2", "CloningRepo", None);
assert!(stage_frame_for_job(&other, "job-1").is_none());
let hook = serde_json::json!({ "kind": "hook_event", "session": "job-1" });
assert!(stage_frame_for_job(&hook, "job-1").is_none());
let bad_stage = stage_frame("job-1", "NotAStage", None);
assert!(stage_frame_for_job(&bad_stage, "job-1").is_none());
}
#[test]
fn status_response_from_progress_maps_fields() {
let reg = crate::daemon::provisioning::ProvisioningRegistry::default();
reg.begin("job");
reg.update_stage(
"job",
ProvisioningStage::DeployingAgents,
Some("2 agents".into()),
);
let resp = ProvisionStatusResponse::from_progress(®.get("job").unwrap());
assert_eq!(resp.state, "provisioning");
assert_eq!(resp.stage.as_deref(), Some("DeployingAgents"));
assert_eq!(resp.label.as_deref(), Some("Deploying agents"));
assert_eq!(resp.detail.as_deref(), Some("2 agents"));
assert!(resp.session_id.is_none());
reg.finish_ready("job", "sess-7".into(), "tm-x-01".into());
let resp = ProvisionStatusResponse::from_progress(®.get("job").unwrap());
assert_eq!(resp.state, "ready");
assert_eq!(resp.session_id.as_deref(), Some("sess-7"));
assert_eq!(resp.name.as_deref(), Some("tm-x-01"));
reg.begin("job2");
reg.finish_failed("job2", "boom".into());
let resp = ProvisionStatusResponse::from_progress(®.get("job2").unwrap());
assert_eq!(resp.state, "failed");
assert_eq!(resp.error.as_deref(), Some("boom"));
}
async fn test_state() -> (Arc<DaemonState>, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let state = Arc::new(DaemonState::with_root_isolated_managed(tmp.path().to_owned()).await);
(state, tmp)
}
#[tokio::test]
async fn poll_route_reports_provisioning_then_ready() {
let (state, _tmp) = test_state().await;
let id = ManagedSessionId::new().to_string();
state.provisioning.begin(&id);
state.provisioning.update_stage(
&id,
ProvisioningStage::CloningRepo,
Some("Receiving objects: 10%".into()),
);
let resp = get_provision_status(State(state.clone()), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::OK);
state
.provisioning
.finish_ready(&id, id.clone(), "tm-ready-01".into());
let resp = get_provision_status(State(state.clone()), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body");
let json: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(json["state"], "ready");
assert_eq!(json["name"], "tm-ready-01");
}
#[tokio::test]
async fn poll_route_unknown_id_is_404() {
let (state, _tmp) = test_state().await;
let id = ManagedSessionId::new().to_string();
let resp = get_provision_status(State(state), AxumPath(id))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn poll_route_invalid_id_is_400() {
let (state, _tmp) = test_state().await;
let resp = get_provision_status(State(state), AxumPath("not-a-uuid".into()))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn spawn_background_registers_and_polls_ready() {
let (state, _tmp) = test_state().await;
let sid = ManagedSessionId::new();
state.provisioning.begin(&sid.to_string());
let resp = get_provision_status(State(state.clone()), AxumPath(sid.to_string()))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body");
let json: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(json["state"], "provisioning");
}
}