Skip to main content

xbp_deploy/
runner.rs

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    /// Optional confirmation callback: returns true to proceed.
32    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        // Promote mode: OCI only.
54        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        // History + lock
163        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    // Minimal dependency: use std + tokio process curl fallback, or reqwest if available.
241    // Keep deploy crate free of reqwest — use TCP-less approach via `std::process` curl optional.
242    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}