use std::sync::Arc;
use async_trait::async_trait;
use futures::future::join_all;
use serde::{Deserialize, Serialize};
use xbp_k8s::{K8sAdapter, KubeTarget};
use crate::context::DeployContext;
use crate::error::{DeployError, Result};
use crate::lock::load_lock;
use crate::types::DeployPlan;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyResult {
pub ok: bool,
pub lines: Vec<String>,
pub drifts: Vec<String>,
}
#[async_trait]
pub trait DeployVerifier: Send + Sync {
async fn verify(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<VerifyResult>;
}
pub struct DefaultDeployVerifier {
pub k8s: Arc<dyn K8sAdapter>,
}
#[async_trait]
impl DeployVerifier for DefaultDeployVerifier {
async fn verify(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<VerifyResult> {
let mut lines: Vec<String> = Vec::new();
let mut drifts: Vec<String> = Vec::new();
let target: KubeTarget = KubeTarget {
context: plan.k8s_plan.context.clone(),
namespace: plan.k8s_plan.default_namespace.clone(),
kubeconfig: ctx
.config
.kubernetes
.as_ref()
.and_then(|k| k.kubeconfig.clone()),
};
match self.k8s.verify(&target, &plan.to_k8s_plan()).await {
Ok(vlines) => lines.extend(vlines),
Err(e) => {
drifts.push(format!("k8s verify: {e}"));
lines.push(format!("k8s verify error: {e}"));
}
}
if let Some(lock) = load_lock(&ctx.config.lock_file)? {
if lock.hash != plan.hash && !plan.hash.is_empty() && !lock.hash.is_empty() {
drifts.push(format!(
"plan hash drift: lock={} plan={}",
lock.hash, plan.hash
));
}
for svc in &plan.services {
if let Some(expected) = lock.images.get(&svc.name) {
match &svc.digest {
Some(d) if d != &expected.digest => {
drifts.push(format!(
"[{}] digest mismatch lock={} plan={}",
svc.name, expected.digest, d
));
}
None => {
drifts.push(format!(
"[{}] missing plan digest (lock has {})",
svc.name, expected.digest
));
}
_ => lines.push(format!("[{}] digest matches lock", svc.name)),
}
}
}
for (name, img) in &lock.images {
if !plan.services.iter().any(|s| s.name == *name) {
drifts.push(format!("[{name}] present in lock but not in plan"));
}
let _ = img;
}
} else {
lines.push("no deploy-lock.json (skip lock compare)".into());
}
if !ctx.flags.skip_health {
let futs = plan.services.iter().flat_map(|svc| {
svc.deploy.health.iter().map(move |url| {
let name = svc.name.clone();
let url = url.clone();
async move {
let status = probe(&url).await;
(name, url, status)
}
})
});
for (name, url, status) in join_all(futs).await {
match status {
Ok(code) if (200..400).contains(&code) => {
lines.push(format!("[{name}] health {url} → {code}"));
}
Ok(code) => drifts.push(format!("[{name}] health {url} → {code}")),
Err(e) => drifts.push(format!("[{name}] health {url}: {e}")),
}
}
}
let ok: bool = drifts.is_empty();
if !ok {
lines.push(format!("drift count: {}", drifts.len()));
}
Ok(VerifyResult { ok, lines, drifts })
}
}
async fn probe(url: &str) -> std::result::Result<u16, String> {
use tokio::process::Command;
let output: std::process::Output = Command::new("curl")
.args(["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "15", url])
.output()
.await
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.map_err(|_| "invalid status".into())
}
pub fn verify_to_result(v: VerifyResult) -> Result<VerifyResult> {
if v.ok {
Ok(v)
} else {
Err(DeployError::Verify(v.drifts.join("; ")))
}
}