1use std::sync::Arc;
2
3use async_trait::async_trait;
4use futures::future::join_all;
5use serde::{Deserialize, Serialize};
6use xbp_k8s::{K8sAdapter, K8sApplyOptions, KubeTarget};
7use xbp_oci::OciPromoter;
8
9use crate::context::{DeployContext, DeployMode};
10use crate::error::{DeployError, Result};
11use crate::history::{record_from_plan, DeployHistoryStore};
12use crate::lock::{lock_from_plan, write_lock};
13use crate::types::DeployPlan;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DeployResult {
17 pub ok: bool,
18 pub lines: Vec<String>,
19 pub failed_services: Vec<String>,
20 pub error: Option<String>,
21}
22
23#[async_trait]
24pub trait DeployRunner: Send + Sync {
25 async fn run(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<DeployResult>;
26}
27
28pub struct DefaultDeployRunner {
29 pub k8s: Arc<dyn K8sAdapter>,
30 pub promoter: Option<Arc<dyn OciPromoter>>,
31 pub confirm: Option<Arc<dyn Fn(String) -> bool + Send + Sync>>,
33}
34
35#[async_trait]
36impl DeployRunner for DefaultDeployRunner {
37 async fn run(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<DeployResult> {
38 let mut lines = Vec::new();
39 let mut failed = Vec::new();
40
41 lines.push(format!(
42 "run target={} env={} dry_run={} services={}",
43 plan.target.label(),
44 plan.env,
45 ctx.flags.dry_run,
46 plan.order.len()
47 ));
48 lines.push(format!(
49 "context={:?} namespace={:?}",
50 plan.k8s_plan.context, plan.k8s_plan.default_namespace
51 ));
52
53 if matches!(ctx.mode, DeployMode::Promote) {
55 return self.promote(ctx, plan).await;
56 }
57
58 if ctx.require_confirmation() {
59 let msg = format!(
60 "Apply deploy `{}` to env `{}` (context={:?})?",
61 plan.target.label(),
62 plan.env,
63 plan.k8s_plan.context
64 );
65 let ok = self
66 .confirm
67 .as_ref()
68 .map(|f| f(msg))
69 .unwrap_or(false);
70 if !ok {
71 return Err(DeployError::Cancelled);
72 }
73 lines.push("context confirmed".into());
74 }
75
76 let target = KubeTarget {
77 context: plan.k8s_plan.context.clone(),
78 namespace: plan.k8s_plan.default_namespace.clone(),
79 kubeconfig: ctx
80 .config
81 .kubernetes
82 .as_ref()
83 .and_then(|k| k.kubeconfig.clone()),
84 };
85
86 if !ctx.flags.skip_apply {
87 let opts = K8sApplyOptions {
88 dry_run: ctx.flags.dry_run,
89 server_side: false,
90 prune: false,
91 };
92 match self.k8s.apply(&target, &plan.to_k8s_plan(), &opts).await {
93 Ok(apply_lines) => lines.extend(apply_lines),
94 Err(e) => {
95 failed.push("kubernetes".into());
96 lines.push(format!("ERROR: {e}"));
97 }
98 }
99 } else {
100 lines.push("skip-apply".into());
101 }
102
103 if !ctx.flags.skip_rollout && !ctx.flags.dry_run && failed.is_empty() {
104 for svc in &plan.services {
105 if let Some(workload) = &svc.deploy.workload {
106 let mut t = target.clone();
107 if t.namespace.is_none() {
108 t.namespace = svc.deploy.namespace.clone();
109 }
110 match self.k8s.rollout(&t, workload, "180s").await {
111 Ok(out) => lines.push(format!(
112 "[{}] {}",
113 svc.name,
114 out.lines().next().unwrap_or("ok")
115 )),
116 Err(e) => {
117 failed.push(svc.name.clone());
118 lines.push(format!("[{}] rollout failed: {e}", svc.name));
119 }
120 }
121 }
122 }
123 }
124
125 if !ctx.flags.skip_health && !ctx.flags.dry_run && failed.is_empty() {
126 let health_futs = plan.services.iter().flat_map(|svc| {
127 svc.deploy.health.iter().map(move |url| {
128 let name = svc.name.clone();
129 let url = url.clone();
130 async move { (name, url.clone(), http_status(&url).await) }
131 })
132 });
133 for (name, url, result) in join_all(health_futs).await {
134 match result {
135 Ok(code) if (200..400).contains(&code) => {
136 lines.push(format!("[{name}] health {url} → {code}"));
137 }
138 Ok(code) => {
139 failed.push(name.clone());
140 lines.push(format!("[{name}] health {url} → {code}"));
141 }
142 Err(e) => {
143 failed.push(name.clone());
144 lines.push(format!("[{name}] health {url} failed: {e}"));
145 }
146 }
147 }
148 }
149
150 let ok = failed.is_empty();
151 let result = DeployResult {
152 ok,
153 lines: lines.clone(),
154 failed_services: failed.clone(),
155 error: if ok {
156 None
157 } else {
158 Some(format!("failed: {}", failed.join(", ")))
159 },
160 };
161
162 if matches!(ctx.mode, DeployMode::Run) && !ctx.flags.dry_run {
164 let store = DeployHistoryStore::new(&ctx.config.history_dir);
165 let record = record_from_plan(
166 plan,
167 ok,
168 if ok {
169 "deploy succeeded".into()
170 } else {
171 result.error.clone().unwrap_or_default()
172 },
173 result.error.clone(),
174 plan.k8s_plan.context.clone(),
175 );
176 let _ = store.write_record(&record);
177 if ok {
178 let lock = lock_from_plan(plan);
179 let _ = write_lock(&ctx.config.lock_file, &lock);
180 }
181 }
182
183 Ok(result)
184 }
185}
186
187impl DefaultDeployRunner {
188 async fn promote(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<DeployResult> {
189 let tag = ctx
190 .flags
191 .promote_tag
192 .as_deref()
193 .ok_or_else(|| DeployError::Validation("promote requires a target tag".into()))?;
194 let Some(promoter) = &self.promoter else {
195 return Err(DeployError::Oci("no OCI promoter configured".into()));
196 };
197 let mut lines = Vec::new();
198 let mut failed = Vec::new();
199 for svc in &plan.services {
200 let Some(image) = &svc.image else {
201 continue;
202 };
203 let target = image.with_tag(tag);
204 if ctx.flags.dry_run {
205 lines.push(format!(
206 "[{}] dry-run promote {} -> {}",
207 svc.name,
208 image.reference(),
209 target.reference()
210 ));
211 continue;
212 }
213 match promoter.promote(image, &target, true).await {
214 Ok(()) => lines.push(format!(
215 "[{}] promoted {} -> {}",
216 svc.name,
217 image.reference(),
218 target.reference()
219 )),
220 Err(e) => {
221 failed.push(svc.name.clone());
222 lines.push(format!("[{}] promote failed: {e}", svc.name));
223 }
224 }
225 }
226 Ok(DeployResult {
227 ok: failed.is_empty(),
228 lines,
229 failed_services: failed.clone(),
230 error: if failed.is_empty() {
231 None
232 } else {
233 Some(format!("promote failed: {}", failed.join(", ")))
234 },
235 })
236 }
237}
238
239async fn http_status(url: &str) -> std::result::Result<u16, String> {
240 use tokio::process::Command;
243 let output = Command::new("curl")
244 .args(["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "15", url])
245 .output()
246 .await
247 .map_err(|e| e.to_string())?;
248 if !output.status.success() {
249 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
250 }
251 String::from_utf8_lossy(&output.stdout)
252 .trim()
253 .parse()
254 .map_err(|_| "invalid status".into())
255}