ares/api/handlers/
deploy.rs1use 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#[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
55pub type DeployRegistry = Arc<RwLock<HashMap<String, DeployStatus>>>;
60
61pub fn new_deploy_registry() -> DeployRegistry {
62 Arc::new(RwLock::new(HashMap::new()))
63}
64
65const 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
77pub 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 {
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 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
178pub 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
191pub 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
200pub 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
236pub 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}