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"];
70fn deploy_script() -> String {
71    std::env::var("DEPLOY_SCRIPT").unwrap_or_else(|_| "./scripts/deploy.sh".to_string())
72}
73fn health_script() -> String {
74    std::env::var("HEALTH_SCRIPT").unwrap_or_else(|_| "./scripts/health.sh".to_string())
75}
76
77/// POST /api/admin/deploy — trigger a deployment
78pub async fn trigger_deploy(
79    State(state): State<AppState>,
80    Json(req): Json<DeployRequest>,
81) -> Result<Json<DeployResponse>> {
82    let target = req.target.to_lowercase();
83    if !VALID_TARGETS.contains(&target.as_str()) {
84        return Err(AppError::InvalidInput(format!(
85            "Invalid target '{}'. Valid: {}",
86            target,
87            VALID_TARGETS.join(", ")
88        )));
89    }
90
91    let registry = &state.deploy_registry;
92
93    // Check if there's already a running deploy for this target
94    {
95        let deploys = registry.read().await;
96        for deploy in deploys.values() {
97            if deploy.target == target && deploy.status == DeployState::Running {
98                return Err(AppError::InvalidInput(format!(
99                    "Deploy already running for '{}' (id: {})",
100                    target, deploy.id
101                )));
102            }
103        }
104    }
105
106    let id = format!(
107        "{}-{}",
108        target,
109        std::time::SystemTime::now()
110            .duration_since(std::time::UNIX_EPOCH)
111            .unwrap()
112            .as_secs()
113    );
114    let now = std::time::SystemTime::now()
115        .duration_since(std::time::UNIX_EPOCH)
116        .unwrap()
117        .as_secs() as i64;
118
119    let deploy = DeployStatus {
120        id: id.clone(),
121        target: target.clone(),
122        status: DeployState::Running,
123        started_at: now,
124        finished_at: None,
125        output: String::new(),
126    };
127
128    registry.write().await.insert(id.clone(), deploy);
129
130    // Spawn the deploy process in background
131    let reg = registry.clone();
132    let deploy_id = id.clone();
133    let deploy_target = target.clone();
134    tokio::spawn(async move {
135        let result = tokio::process::Command::new(deploy_script())
136            .arg(&deploy_target)
137            .output()
138            .await;
139
140        let now = std::time::SystemTime::now()
141            .duration_since(std::time::UNIX_EPOCH)
142            .unwrap()
143            .as_secs() as i64;
144
145        let mut deploys = reg.write().await;
146        if let Some(deploy) = deploys.get_mut(&deploy_id) {
147            match result {
148                Ok(output) => {
149                    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
150                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
151                    deploy.output = if stderr.is_empty() {
152                        stdout
153                    } else {
154                        format!("{}\n--- stderr ---\n{}", stdout, stderr)
155                    };
156                    deploy.status = if output.status.success() {
157                        DeployState::Success
158                    } else {
159                        DeployState::Failed
160                    };
161                }
162                Err(e) => {
163                    deploy.output = format!("Failed to execute deploy script: {}", e);
164                    deploy.status = DeployState::Failed;
165                }
166            }
167            deploy.finished_at = Some(now);
168        }
169    });
170
171    Ok(Json(DeployResponse {
172        id,
173        status: DeployState::Running,
174        message: format!("Deploy started for '{}'", target),
175    }))
176}
177
178/// GET /api/admin/deploy/{deploy_id} — get deploy status
179pub async fn get_deploy_status(
180    State(state): State<AppState>,
181    Path(deploy_id): Path<String>,
182) -> Result<Json<DeployStatus>> {
183    let deploys = state.deploy_registry.read().await;
184    deploys
185        .get(&deploy_id)
186        .cloned()
187        .map(Json)
188        .ok_or_else(|| AppError::NotFound(format!("Deploy '{}' not found", deploy_id)))
189}
190
191/// GET /api/admin/deploys — list recent deploys
192pub async fn list_deploys(State(state): State<AppState>) -> Json<Vec<DeployStatus>> {
193    let deploys = state.deploy_registry.read().await;
194    let mut list: Vec<DeployStatus> = deploys.values().cloned().collect();
195    list.sort_by(|a, b| b.started_at.cmp(&a.started_at));
196    list.truncate(20);
197    Json(list)
198}
199
200/// GET /api/admin/services — health check all services
201pub async fn get_services_health() -> Result<Json<HashMap<String, ServiceHealth>>> {
202    let output = tokio::process::Command::new("bash")
203        .arg(&health_script())
204        .output()
205        .await
206        .map_err(|e| AppError::Internal(format!("Failed to run health script: {}", e)))?;
207
208    let stdout = String::from_utf8_lossy(&output.stdout);
209    let parsed: HashMap<String, serde_json::Value> =
210        serde_json::from_str(&stdout).map_err(|e| {
211            AppError::Internal(format!(
212                "Failed to parse health output: {} — raw: {}",
213                e, stdout
214            ))
215        })?;
216
217    let mut result = HashMap::new();
218    for (name, val) in parsed {
219        let status = val
220            .get("status")
221            .and_then(|v| v.as_str())
222            .unwrap_or("unknown")
223            .to_string();
224        let pid = val
225            .get("pid")
226            .and_then(|v| v.as_str())
227            .filter(|s| !s.is_empty())
228            .map(|s| s.to_string());
229        let port = val.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
230        result.insert(name, ServiceHealth { status, pid, port });
231    }
232
233    Ok(Json(result))
234}
235
236/// GET /api/admin/services/{service_name}/logs — recent journalctl logs for a service
237pub async fn get_service_logs(Path(service_name): Path<String>) -> 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([
247            "-u",
248            &service_name,
249            "-n",
250            "100",
251            "--no-pager",
252            "-o",
253            "short-iso",
254        ])
255        .output()
256        .await
257        .map_err(|e| AppError::Internal(format!("Failed to read logs: {}", e)))?;
258
259    let logs = String::from_utf8_lossy(&output.stdout).to_string();
260
261    Ok(Json(serde_json::json!({
262        "service": service_name,
263        "lines": logs.lines().collect::<Vec<_>>(),
264    })))
265}