xbp-deploy 10.46.0

Service-centric declarative deploy engine for XBP.
Documentation
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}"));
            }
        }

        // Digest lock comparison
        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());
        }

        // Parallel health
        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())
}

/// Ensure verify surfaces as DeployError when ok=false if desired by caller.
pub fn verify_to_result(v: VerifyResult) -> Result<VerifyResult> {
    if v.ok {
        Ok(v)
    } else {
        Err(DeployError::Verify(v.drifts.join("; ")))
    }
}