Skip to main content

ares/api/handlers/
deploy.rs

1use crate::types::{AppError, Result};
2use crate::AppState;
3use axum::{
4    extract::{Path, State},
5    Json,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12// ---------------------------------------------------------------------------
13// Types
14// ---------------------------------------------------------------------------
15
16#[derive(Debug, Clone, Serialize)]
17pub struct DeployStatus {
18    pub id: String,
19    pub target: String,
20    pub status: DeployState,
21    pub started_at: i64,
22    pub finished_at: Option<i64>,
23    pub output: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(rename_all = "snake_case")]
28pub enum DeployState {
29    Running,
30    Success,
31    Failed,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct DeployRequest {
36    pub target: String,
37}
38
39#[derive(Debug, Serialize)]
40pub struct DeployResponse {
41    pub id: String,
42    pub status: DeployState,
43    pub message: String,
44}
45
46#[derive(Debug, Serialize)]
47pub struct ServiceHealth {
48    pub status: String,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub pid: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub port: Option<u16>,
53}
54
55// ---------------------------------------------------------------------------
56// Deploy registry — in-memory store for deploy status
57// ---------------------------------------------------------------------------
58
59pub type DeployRegistry = Arc<RwLock<HashMap<String, DeployStatus>>>;
60
61pub fn new_deploy_registry() -> DeployRegistry {
62    Arc::new(RwLock::new(HashMap::new()))
63}
64
65// ---------------------------------------------------------------------------
66// Handlers
67// ---------------------------------------------------------------------------
68
69const VALID_TARGETS: &[&str] = &["ares", "admin", "eruka", "dotdot"];
70const DEPLOY_SCRIPT: &str = "/opt/dirmacs-ops/deploy.sh";
71const HEALTH_SCRIPT: &str = "/opt/dirmacs-ops/health.sh";
72
73/// POST /api/admin/deploy — trigger a deployment
74pub async fn trigger_deploy(
75    State(state): State<AppState>,
76    Json(req): Json<DeployRequest>,
77) -> Result<Json<DeployResponse>> {
78    let target = req.target.to_lowercase();
79    if !VALID_TARGETS.contains(&target.as_str()) {
80        return Err(AppError::InvalidInput(format!(
81            "Invalid target '{}'. Valid: {}",
82            target,
83            VALID_TARGETS.join(", ")
84        )));
85    }
86
87    let registry = &state.deploy_registry;
88
89    // Check if there's already a running deploy for this target
90    {
91        let deploys = registry.read().await;
92        for deploy in deploys.values() {
93            if deploy.target == target && deploy.status == DeployState::Running {
94                return Err(AppError::InvalidInput(format!(
95                    "Deploy already running for '{}' (id: {})",
96                    target, deploy.id
97                )));
98            }
99        }
100    }
101
102    let id = format!(
103        "{}-{}",
104        target,
105        std::time::SystemTime::now()
106            .duration_since(std::time::UNIX_EPOCH)
107            .unwrap()
108            .as_secs()
109    );
110    let now = std::time::SystemTime::now()
111        .duration_since(std::time::UNIX_EPOCH)
112        .unwrap()
113        .as_secs() as i64;
114
115    let deploy = DeployStatus {
116        id: id.clone(),
117        target: target.clone(),
118        status: DeployState::Running,
119        started_at: now,
120        finished_at: None,
121        output: String::new(),
122    };
123
124    registry.write().await.insert(id.clone(), deploy);
125
126    // Spawn the deploy process in background
127    let reg = registry.clone();
128    let deploy_id = id.clone();
129    let deploy_target = target.clone();
130    tokio::spawn(async move {
131        let result = tokio::process::Command::new(DEPLOY_SCRIPT)
132            .arg(&deploy_target)
133            .output()
134            .await;
135
136        let now = std::time::SystemTime::now()
137            .duration_since(std::time::UNIX_EPOCH)
138            .unwrap()
139            .as_secs() as i64;
140
141        let mut deploys = reg.write().await;
142        if let Some(deploy) = deploys.get_mut(&deploy_id) {
143            match result {
144                Ok(output) => {
145                    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
146                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
147                    deploy.output = if stderr.is_empty() {
148                        stdout
149                    } else {
150                        format!("{}\n--- stderr ---\n{}", stdout, stderr)
151                    };
152                    deploy.status = if output.status.success() {
153                        DeployState::Success
154                    } else {
155                        DeployState::Failed
156                    };
157                }
158                Err(e) => {
159                    deploy.output = format!("Failed to execute deploy script: {}", e);
160                    deploy.status = DeployState::Failed;
161                }
162            }
163            deploy.finished_at = Some(now);
164        }
165    });
166
167    Ok(Json(DeployResponse {
168        id,
169        status: DeployState::Running,
170        message: format!("Deploy started for '{}'", target),
171    }))
172}
173
174/// GET /api/admin/deploy/{deploy_id} — get deploy status
175pub async fn get_deploy_status(
176    State(state): State<AppState>,
177    Path(deploy_id): Path<String>,
178) -> Result<Json<DeployStatus>> {
179    let deploys = state.deploy_registry.read().await;
180    deploys
181        .get(&deploy_id)
182        .cloned()
183        .map(Json)
184        .ok_or_else(|| AppError::NotFound(format!("Deploy '{}' not found", deploy_id)))
185}
186
187/// GET /api/admin/deploys — list recent deploys
188pub async fn list_deploys(
189    State(state): State<AppState>,
190) -> Json<Vec<DeployStatus>> {
191    let deploys = state.deploy_registry.read().await;
192    let mut list: Vec<DeployStatus> = deploys.values().cloned().collect();
193    list.sort_by(|a, b| b.started_at.cmp(&a.started_at));
194    list.truncate(20);
195    Json(list)
196}
197
198/// GET /api/admin/services — health check all services
199pub async fn get_services_health() -> Result<Json<HashMap<String, ServiceHealth>>> {
200    let output = tokio::process::Command::new("bash")
201        .arg(HEALTH_SCRIPT)
202        .output()
203        .await
204        .map_err(|e| AppError::Internal(format!("Failed to run health script: {}", e)))?;
205
206    let stdout = String::from_utf8_lossy(&output.stdout);
207    let parsed: HashMap<String, serde_json::Value> =
208        serde_json::from_str(&stdout).map_err(|e| {
209            AppError::Internal(format!("Failed to parse health output: {} — raw: {}", e, stdout))
210        })?;
211
212    let mut result = HashMap::new();
213    for (name, val) in parsed {
214        let status = val
215            .get("status")
216            .and_then(|v| v.as_str())
217            .unwrap_or("unknown")
218            .to_string();
219        let pid = val
220            .get("pid")
221            .and_then(|v| v.as_str())
222            .filter(|s| !s.is_empty())
223            .map(|s| s.to_string());
224        let port = val
225            .get("port")
226            .and_then(|v| v.as_u64())
227            .map(|p| p as u16);
228        result.insert(name, ServiceHealth { status, pid, port });
229    }
230
231    Ok(Json(result))
232}
233
234/// GET /api/admin/services/{service_name}/logs — recent journalctl logs for a service
235pub async fn get_service_logs(
236    Path(service_name): Path<String>,
237) -> Result<Json<serde_json::Value>> {
238    if !["ares", "eruka", "caddy", "postgresql"].contains(&service_name.as_str()) {
239        return Err(AppError::InvalidInput(format!(
240            "Unknown service: {}",
241            service_name
242        )));
243    }
244
245    let output = tokio::process::Command::new("journalctl")
246        .args(["-u", &service_name, "-n", "100", "--no-pager", "-o", "short-iso"])
247        .output()
248        .await
249        .map_err(|e| AppError::Internal(format!("Failed to read logs: {}", e)))?;
250
251    let logs = String::from_utf8_lossy(&output.stdout).to_string();
252
253    Ok(Json(serde_json::json!({
254        "service": service_name,
255        "lines": logs.lines().collect::<Vec<_>>(),
256    })))
257}