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"];
70const DEPLOY_SCRIPT: &str = "/opt/dirmacs-ops/deploy.sh";
71const HEALTH_SCRIPT: &str = "/opt/dirmacs-ops/health.sh";
72
73pub 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 {
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 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
174pub 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
187pub 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
198pub 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
234pub 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}