xbp 10.57.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Preflight “deploy doctor” before run/apply.
//!
//! Chains local cluster readiness, namespace ensure, OCI dockerfile presence,
//! and Cloudflare Workers Builds build_command sync so `xbp deploy` bootstraps
//! both kubernetes and Cloudflare destinations.

use std::path::Path;

use xbp_deploy::{
    is_cloudflare_provider, is_kubernetes_provider, logical_service_name, DeployContext,
    DeployPlan,
};
use xbp_k8s::{K8sAdapter, KubeTarget};

use crate::commands::cloudflare::preflight_cf_worker_candidates;
use crate::commands::workers::builds_config::{
    print_build_command_sync_report, sync_local_wrangler_build_command,
    sync_workers_builds_build_command_trying_tokens,
};
use crate::strategies::XbpConfig;

#[derive(Debug, Default)]
pub struct DeployDoctorReport {
    pub lines: Vec<String>,
    pub ok: bool,
}

/// Run preflight checks; returns ok=false if hard failures (cluster down without recovery).
pub async fn run_deploy_doctor(
    ctx: &DeployContext,
    plan: &DeployPlan,
    xbp_config: &XbpConfig,
    project_root: &Path,
    k8s: &dyn K8sAdapter,
    debug: bool,
) -> DeployDoctorReport {
    let mut report = DeployDoctorReport {
        lines: vec!["deploy doctor: preflight".into()],
        ok: true,
    };

    let has_k8s = plan
        .services
        .iter()
        .any(|s| is_kubernetes_provider(&s.provider));
    let has_cf = plan
        .services
        .iter()
        .any(|s| is_cloudflare_provider(&s.provider));

    // --- Kubernetes ---
    if has_k8s {
        report.lines.push("doctor: kubernetes".into());
        let target = KubeTarget {
            context: plan.k8s_plan.context.clone().or_else(|| {
                ctx.config
                    .kubernetes
                    .as_ref()
                    .and_then(|k| k.default_context.clone())
            }),
            namespace: plan.k8s_plan.default_namespace.clone(),
            kubeconfig: ctx
                .config
                .kubernetes
                .as_ref()
                .and_then(|k| k.kubeconfig.clone()),
        };

        match k8s.ensure_ready(&target).await {
            Ok(()) => {
                report.lines.push(format!(
                    "  cluster ready (context={:?})",
                    target.context.as_deref().unwrap_or("(default)")
                ));
            }
            Err(e) => {
                report.lines.push(format!("  cluster not ready: {e}"));
                // Attempt Docker Desktop recovery when it looks local.
                let blob = e.to_string();
                if crate::commands::docker_desktop_k8s::looks_like_docker_desktop_k8s_issue(&blob)
                    || crate::commands::docker_desktop_k8s::looks_like_docker_desktop_k8s_issue(
                        &format!("{:?}", target.context),
                    )
                {
                    report
                        .lines
                        .push("  attempting Docker Desktop Kubernetes recovery…".into());
                    match crate::commands::docker_desktop_k8s::maybe_recover_docker_desktop_k8s(
                        &blob,
                        crate::commands::docker_desktop_k8s::DoctorOptions {
                            yes: ctx.flags.yes,
                            debug,
                            prefer_kubeadm: true,
                        },
                    )
                    .await
                    {
                        Ok(crate::commands::docker_desktop_k8s::DoctorOutcome::Ready) => {
                            report.lines.push("  cluster recovered".into());
                            if k8s.ensure_ready(&target).await.is_err() {
                                report.ok = false;
                                report
                                    .lines
                                    .push("  ERROR: still not ready after recovery".into());
                            }
                        }
                        Ok(_) => {
                            report.ok = false;
                            report.lines.push(
                                "  ERROR: recovery skipped/failed — run `xbp kubernetes doctor`".into(),
                            );
                        }
                        Err(re) => {
                            report.ok = false;
                            report.lines.push(format!("  ERROR: recovery error: {re}"));
                        }
                    }
                } else {
                    report.ok = false;
                }
            }
        }

        // Namespace inventory + ensure missing configured NS
        let inv = crate::commands::k8s_inventory::collect_namespace_inventory(
            target.context.as_deref(),
            Some(xbp_config),
            debug,
        )
        .await;
        if inv.live_error.is_none() && !inv.missing.is_empty() {
            report.lines.push(format!(
                "  ensuring missing namespaces: {}",
                inv.missing.join(", ")
            ));
            match crate::commands::k8s_inventory::ensure_configured_namespaces(
                inv.context.as_deref(),
                &inv.missing,
                ctx.flags.yes || !crate::commands::docker_desktop_k8s::is_interactive_tty(),
                debug,
            )
            .await
            {
                Ok(created) => {
                    if !created.is_empty() {
                        report
                            .lines
                            .push(format!("  created: {}", created.join(", ")));
                    }
                }
                Err(e) => report.lines.push(format!("  namespace ensure: {e}")),
            }
        } else if inv.live_error.is_none() {
            report.lines.push(format!(
                "  namespaces ok (live={}, configured={})",
                inv.live.len(),
                inv.configured
                    .iter()
                    .map(|c| c.namespace.as_str())
                    .collect::<std::collections::BTreeSet<_>>()
                    .len()
            ));
        }

        // Manifest / bootstrap awareness
        for svc in plan.services.iter().filter(|s| is_kubernetes_provider(&s.provider)) {
            if svc.deploy.workload.is_some()
                && svc.deploy.manifest_paths.is_empty()
                && ctx.flags.bootstrap_workload
            {
                report.lines.push(format!(
                    "  [{}] will bootstrap Deployment/Service (no manifests)",
                    svc.name
                ));
            } else if svc.deploy.workload.is_some() && svc.deploy.manifest_paths.is_empty() {
                report.lines.push(format!(
                    "  [{}] WARNING: no manifests and bootstrap disabled",
                    svc.name
                ));
            }
            if svc.dockerfile.is_some() && !ctx.flags.skip_build {
                if ctx.flags.local_image {
                    report.lines.push(format!(
                        "  [{}] will build local image {} (no registry push)",
                        svc.name,
                        svc.image_ref.as_deref().unwrap_or("(image)")
                    ));
                } else {
                    report.lines.push(format!(
                        "  [{}] will build+push {}",
                        svc.name,
                        svc.image_ref.as_deref().unwrap_or("(image)")
                    ));
                }
            }
        }

        // Production + docker-desktop warning
        if plan.env.eq_ignore_ascii_case("production") {
            let ctx_name = target
                .context
                .clone()
                .or_else(|| current_kubectl_context());
            if ctx_name
                .as_deref()
                .is_some_and(|c| c.eq_ignore_ascii_case("docker-desktop"))
            {
                report.lines.push(
                    "  WARNING: env=production but kube context looks like docker-desktop (local)"
                        .into(),
                );
            }
        }
    }

    // --- Cloudflare ---
    if has_cf {
        report.lines.push("doctor: cloudflare".into());
        let workers = xbp_config.workers.as_deref().unwrap_or(&[]);
        for svc in plan.services.iter().filter(|s| is_cloudflare_provider(&s.provider)) {
            let logical = logical_service_name(&svc.name);
            let mut candidates: Vec<String> = Vec::new();
            if let Some(app) = svc.worker_app.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
                candidates.push(app.to_string());
            }
            candidates.push(logical.to_string());
            // Also try configured workers that map to this service name.
            for w in workers {
                if w.name == logical
                    || w.script_name.as_deref() == Some(logical)
                    || w.service.as_deref() == Some(logical)
                {
                    candidates.push(w.name.clone());
                    if let Some(script) = w.script_name.clone() {
                        candidates.push(script);
                    }
                }
            }

            report.lines.push(format!(
                "  [{}] resolving worker (candidates: {})",
                svc.name,
                candidates
                    .iter()
                    .map(|s| s.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ));

            // Hard preflight: resolve app + local contract (fail deploy early).
            let resolved_app_name = match preflight_cf_worker_candidates(project_root, &candidates) {
                Ok((name, notes)) => {
                    for note in notes {
                        report.lines.push(format!("  [{name}] {note}"));
                    }
                    name
                }
                Err(e) => {
                    report.ok = false;
                    for line in e.lines() {
                        report.lines.push(format!("  ERROR: {line}"));
                    }
                    continue;
                }
            };

            let worker = workers.iter().find(|w| {
                w.name == resolved_app_name
                    || w.script_name.as_deref() == Some(resolved_app_name.as_str())
                    || w.name == logical
                    || w.script_name.as_deref() == Some(logical)
            });
            let worker_root = worker
                .map(|w| {
                    let p = Path::new(w.root.as_str());
                    if p.is_absolute() {
                        p.to_path_buf()
                    } else {
                        project_root.join(p)
                    }
                })
                .unwrap_or_else(|| project_root.join(&resolved_app_name));

            report.lines.push(format!(
                "  [{}] worker_app={resolved_app_name} root={}",
                svc.name,
                worker_root.display()
            ));

            if let Ok(Some(msg)) = sync_local_wrangler_build_command(&worker_root) {
                report.lines.push(format!("  local wrangler: {msg}"));
            }

            // Builds API sync — try env then config tokens (env often shadows set-key).
            {
                let script_name = worker
                    .and_then(|w| w.script_name.clone())
                    .filter(|s| !s.is_empty())
                    .unwrap_or_else(|| resolved_app_name.clone());
                let root_dir = worker_root
                    .strip_prefix(project_root)
                    .ok()
                    .map(|r| r.to_string_lossy().replace('\\', "/"));
                match sync_workers_builds_build_command_trying_tokens(
                    &script_name,
                    &worker_root,
                    root_dir.as_deref(),
                    false,
                )
                .await
                {
                    Ok(sync_report) => {
                        for u in &sync_report.updated_triggers {
                            report.lines.push(format!("  builds sync: {u}"));
                        }
                        for u in &sync_report.unchanged_triggers {
                            report.lines.push(format!("  builds ok: {u}"));
                        }
                        for n in &sync_report.notes {
                            report.lines.push(format!("  builds note: {n}"));
                        }
                        if debug {
                            print_build_command_sync_report(&sync_report);
                        }
                    }
                    Err(e) => {
                        let explained =
                            crate::commands::workers::builds_config::explain_builds_api_error(&e);
                        report
                            .lines
                            .push(format!("  builds sync skipped: {explained}"));
                    }
                }
            }

            if crate::config::cloudflare_env_token_shadows_config() {
                report.lines.push(
                    "  warn: CLOUDFLARE_API_TOKEN env differs from xbp config (env wins; may lack Builds scope)"
                        .into(),
                );
            }

            // package manager heal (empty packages field) + install-aware PM for CI command
            match crate::utils::ensure_pnpm_workspace_packages(&worker_root) {
                Ok(fixed) if !fixed.is_empty() => {
                    for p in fixed {
                        report
                            .lines
                            .push(format!("  healed workspace packages: {}", p.display()));
                    }
                }
                Ok(_) => {}
                Err(e) => report.lines.push(format!("  workspace heal: {e}")),
            }
            let pm = crate::utils::detect_js_install_package_manager(&worker_root);
            report.lines.push(format!(
                "  install package manager={} build≈{}",
                pm.name(),
                crate::utils::suggested_build_command(&worker_root, "build")
            ));
        }
    }

    if report.ok {
        report.lines.push("deploy doctor: ok".into());
    } else {
        report.lines.push("deploy doctor: FAILED".into());
    }

    report
}

fn current_kubectl_context() -> Option<String> {
    let output = std::process::Command::new("kubectl")
        .args(["config", "current-context"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if s.is_empty() {
        None
    } else {
        Some(s)
    }
}