Skip to main content

xbp_deploy/
verifier.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use futures::future::join_all;
5use serde::{Deserialize, Serialize};
6use xbp_k8s::{K8sAdapter, KubeTarget};
7
8use crate::context::DeployContext;
9use crate::error::{DeployError, Result};
10use crate::lock::load_lock;
11use crate::types::DeployPlan;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct VerifyResult {
15    pub ok: bool,
16    pub lines: Vec<String>,
17    pub drifts: Vec<String>,
18}
19
20#[async_trait]
21pub trait DeployVerifier: Send + Sync {
22    async fn verify(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<VerifyResult>;
23}
24
25pub struct DefaultDeployVerifier {
26    pub k8s: Arc<dyn K8sAdapter>,
27}
28
29#[async_trait]
30impl DeployVerifier for DefaultDeployVerifier {
31    async fn verify(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<VerifyResult> {
32        let mut lines: Vec<String> = Vec::new();
33        let mut drifts: Vec<String> = Vec::new();
34
35        let target: KubeTarget = KubeTarget {
36            context: plan.k8s_plan.context.clone(),
37            namespace: plan.k8s_plan.default_namespace.clone(),
38            kubeconfig: ctx
39                .config
40                .kubernetes
41                .as_ref()
42                .and_then(|k| k.kubeconfig.clone()),
43        };
44
45        match self.k8s.verify(&target, &plan.to_k8s_plan()).await {
46            Ok(vlines) => lines.extend(vlines),
47            Err(e) => {
48                drifts.push(format!("k8s verify: {e}"));
49                lines.push(format!("k8s verify error: {e}"));
50            }
51        }
52
53        // Digest lock comparison
54        if let Some(lock) = load_lock(&ctx.config.lock_file)? {
55            if lock.hash != plan.hash && !plan.hash.is_empty() && !lock.hash.is_empty() {
56                drifts.push(format!(
57                    "plan hash drift: lock={} plan={}",
58                    lock.hash, plan.hash
59                ));
60            }
61            for svc in &plan.services {
62                if let Some(expected) = lock.images.get(&svc.name) {
63                    match &svc.digest {
64                        Some(d) if d != &expected.digest => {
65                            drifts.push(format!(
66                                "[{}] digest mismatch lock={} plan={}",
67                                svc.name, expected.digest, d
68                            ));
69                        }
70                        None => {
71                            drifts.push(format!(
72                                "[{}] missing plan digest (lock has {})",
73                                svc.name, expected.digest
74                            ));
75                        }
76                        _ => lines.push(format!("[{}] digest matches lock", svc.name)),
77                    }
78                }
79            }
80            for (name, img) in &lock.images {
81                if !plan.services.iter().any(|s| s.name == *name) {
82                    drifts.push(format!("[{name}] present in lock but not in plan"));
83                }
84                let _ = img;
85            }
86        } else {
87            lines.push("no deploy-lock.json (skip lock compare)".into());
88        }
89
90        // Parallel health
91        if !ctx.flags.skip_health {
92            let futs = plan.services.iter().flat_map(|svc| {
93                svc.deploy.health.iter().map(move |url| {
94                    let name = svc.name.clone();
95                    let url = url.clone();
96                    async move {
97                        let status = probe(&url).await;
98                        (name, url, status)
99                    }
100                })
101            });
102            for (name, url, status) in join_all(futs).await {
103                match status {
104                    Ok(code) if (200..400).contains(&code) => {
105                        lines.push(format!("[{name}] health {url} → {code}"));
106                    }
107                    Ok(code) => drifts.push(format!("[{name}] health {url} → {code}")),
108                    Err(e) => drifts.push(format!("[{name}] health {url}: {e}")),
109                }
110            }
111        }
112
113        let ok: bool = drifts.is_empty();
114        if !ok {
115            lines.push(format!("drift count: {}", drifts.len()));
116        }
117        Ok(VerifyResult { ok, lines, drifts })
118    }
119}
120
121async fn probe(url: &str) -> std::result::Result<u16, String> {
122    use tokio::process::Command;
123    let output: std::process::Output = Command::new("curl")
124        .args(["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "15", url])
125        .output()
126        .await
127        .map_err(|e| e.to_string())?;
128    if !output.status.success() {
129        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
130    }
131    String::from_utf8_lossy(&output.stdout)
132        .trim()
133        .parse()
134        .map_err(|_| "invalid status".into())
135}
136
137/// Ensure verify surfaces as DeployError when ok=false if desired by caller.
138pub fn verify_to_result(v: VerifyResult) -> Result<VerifyResult> {
139    if v.ok {
140        Ok(v)
141    } else {
142        Err(DeployError::Verify(v.drifts.join("; ")))
143    }
144}