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,
}
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));
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}"));
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;
}
}
}
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()
));
}
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)")
));
}
}
}
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(),
);
}
}
}
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());
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(", ")
));
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}"));
}
{
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(),
);
}
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)
}
}