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 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 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
137pub 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}