Skip to main content

xbp_deploy/
runner.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use futures::future::join_all;
5use serde::{Deserialize, Serialize};
6use xbp_k8s::{K8sAdapter, K8sApplyOptions, KubeTarget};
7use xbp_oci::OciPromoter;
8
9use crate::bootstrap::bootstrap_workload_documents;
10use crate::cloudflare::{CloudflareDeployAdapter, CloudflareServiceDeployRequest};
11use crate::context::{DeployContext, DeployMode};
12use crate::error::{DeployError, Result};
13use crate::history::{record_from_plan_with_version, DeployHistoryStore};
14use crate::lock::{lock_from_plan, write_lock};
15use crate::oci_build::{OciBuildAdapter, OciBuildRequest};
16use crate::providers::{
17    is_cloudflare_provider, is_kubernetes_provider, is_railway_provider, provider_to_destination,
18};
19use crate::types::{DeployPlan, ServicePlan};
20use std::path::PathBuf;
21use std::process::Command;
22use std::sync::OnceLock;
23
24/// Prefer local Docker for CF container pre-build when the engine is available.
25fn docker_available_for_local_cf_build() -> bool {
26    static AVAILABLE: OnceLock<bool> = OnceLock::new();
27    *AVAILABLE.get_or_init(|| {
28        Command::new("docker")
29            .args(["info", "--format", "{{.ServerVersion}}"])
30            .output()
31            .map(|o| o.status.success())
32            .unwrap_or(false)
33    })
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DeployResult {
38    pub ok: bool,
39    pub lines: Vec<String>,
40    pub failed_services: Vec<String>,
41    pub error: Option<String>,
42    /// Secrets extracted when writing redacted deploy history (for D1 upload).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub history_id: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub history_path: Option<String>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub history_secrets: Vec<crate::history::DeployHistorySecret>,
49}
50
51#[async_trait]
52pub trait DeployRunner: Send + Sync {
53    async fn run(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<DeployResult>;
54}
55
56pub struct DefaultDeployRunner {
57    pub k8s: Arc<dyn K8sAdapter>,
58    pub promoter: Option<Arc<dyn OciPromoter>>,
59    /// Optional confirmation callback: returns true to proceed.
60    pub confirm: Option<Arc<dyn Fn(String) -> bool + Send + Sync>>,
61    /// Optional Cloudflare adapter. Required only when the plan includes CF providers.
62    pub cloudflare: Option<Arc<dyn CloudflareDeployAdapter>>,
63    /// Optional docker build/push before k8s apply when services declare oci.dockerfile.
64    pub oci_build: Option<Arc<dyn OciBuildAdapter>>,
65}
66
67#[async_trait]
68impl DeployRunner for DefaultDeployRunner {
69    async fn run(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<DeployResult> {
70        let mut lines = Vec::new();
71        let mut failed = Vec::new();
72
73        let k8s_services: Vec<&ServicePlan> = plan
74            .services
75            .iter()
76            .filter(|s| is_kubernetes_provider(&s.provider))
77            .collect();
78        let cf_services: Vec<&ServicePlan> = plan
79            .services
80            .iter()
81            .filter(|s| is_cloudflare_provider(&s.provider))
82            .collect();
83
84        lines.push(format!(
85            "run target={} env={} dry_run={} services={} k8s={} cloudflare={}",
86            plan.target.label(),
87            plan.env,
88            ctx.flags.dry_run,
89            plan.order.len(),
90            k8s_services.len(),
91            cf_services.len()
92        ));
93        if !k8s_services.is_empty() {
94            let ns_parts: Vec<String> = k8s_services
95                .iter()
96                .map(|s| {
97                    format!(
98                        "{}={}",
99                        s.name,
100                        s.deploy
101                            .namespace
102                            .as_deref()
103                            .or(plan.k8s_plan.default_namespace.as_deref())
104                            .unwrap_or("?")
105                    )
106                })
107                .collect();
108            lines.push(format!(
109                "context={:?} namespaces=[{}] plan_default={:?}",
110                plan.k8s_plan.context,
111                ns_parts.join(", "),
112                plan.k8s_plan.default_namespace
113            ));
114        }
115
116        // Promote mode: OCI only (unchanged).
117        if matches!(ctx.mode, DeployMode::Promote) {
118            return self.promote(ctx, plan).await;
119        }
120
121        // Mutable service snapshots so build can pin digests before k8s apply/bootstrap.
122        let mut k8s_work: Vec<ServicePlan> =
123            k8s_services.iter().map(|s| (*s).clone()).collect();
124
125        // May flip to true if registry push fails and the operator opts into local-only.
126        let mut local_image = ctx.flags.local_image;
127
128        // ------------------------------------------------------------------
129        // Phase 1 — build + push OCI images FIRST (before cluster confirm/apply).
130        // Requires services[].oci.dockerfile + image. Use --skip-build to skip.
131        // ------------------------------------------------------------------
132        match self
133            .run_oci_build_phase(
134                ctx,
135                plan,
136                &mut k8s_work,
137                &mut lines,
138                &mut failed,
139                &mut local_image,
140            )
141            .await
142        {
143            Ok(()) => {}
144            Err(e) => return Err(e),
145        }
146        // Drop k8s work items whose image build/push failed (keep CF destinations alive).
147        // Multi-dest services share `name` (e.g. athena-auth); fail keys use `name@destination`.
148        if !failed.is_empty() {
149            let failed_set: std::collections::HashSet<&str> =
150                failed.iter().map(String::as_str).collect();
151            k8s_work.retain(|s| !failed_set.contains(service_step_key(s).as_str()));
152            let has_remaining_cf = plan
153                .services
154                .iter()
155                .any(|s| is_cloudflare_provider(&s.provider));
156            if k8s_work.is_empty() && !has_remaining_cf {
157                lines.push(
158                    "deploy aborted: image build/push failed — fix Docker/registry, then re-run (cluster was not modified for failed services)"
159                        .into(),
160                );
161                lines.push(
162                    "hint: re-run with --local-image to build into local Docker without registry push (docker-desktop / kind / minikube / CF --local-build)"
163                        .into(),
164                );
165                return Ok(finish_result(plan, ctx, lines, failed));
166            }
167            if has_remaining_cf {
168                lines.push(
169                    "image build/push failed for some service(s) — continuing Cloudflare destinations (k8s steps for failed images skipped)"
170                        .into(),
171                );
172                lines.push(
173                    "hint: for k8s use --local-image or `docker login ghcr.io`; for CF-only re-run with --to cloudflare"
174                        .into(),
175                );
176            }
177        }
178
179        // Context confirmation only when Kubernetes services are actually applied.
180        if !k8s_work.is_empty() && ctx.require_confirmation() {
181            let msg = format!(
182                "Apply deploy `{}` to env `{}` (context={:?})? Images already built/pushed.",
183                plan.target.label(),
184                plan.env,
185                plan.k8s_plan.context
186            );
187            let ok = self
188                .confirm
189                .as_ref()
190                .map(|f| f(msg))
191                .unwrap_or(false);
192            if !ok {
193                return Err(DeployError::Cancelled);
194            }
195            lines.push("context confirmed".into());
196        }
197
198        // --- Kubernetes path (only when k8s services present; identical apply semantics) ---
199        if !k8s_work.is_empty() {
200            let target = KubeTarget {
201                context: plan.k8s_plan.context.clone(),
202                namespace: plan.k8s_plan.default_namespace.clone(),
203                kubeconfig: ctx
204                    .config
205                    .kubernetes
206                    .as_ref()
207                    .and_then(|k| k.kubeconfig.clone()),
208            };
209
210            // Fail fast with structured diagnosis when the API is down (e.g. Docker Desktop
211            // Kubernetes stopped / kindest node bootstrap failed) instead of dumping apply spam.
212            if !ctx.flags.dry_run {
213                if let Err(e) = self.k8s.ensure_ready(&target).await {
214                    failed.push("kubernetes".into());
215                    lines.push(format!("ERROR: cluster not ready: {e}"));
216                    if let Some(report) =
217                        xbp_k8s::kubectl_failure_debug_report(&e.to_string(), target.context.as_deref())
218                    {
219                        for line in report.lines().filter(|l| !l.is_empty()) {
220                            lines.push(line.to_string());
221                        }
222                    }
223                    return Ok(finish_result(plan, ctx, lines, failed));
224                }
225                lines.push(format!(
226                    "cluster ready (context={:?})",
227                    target.context.as_deref().unwrap_or("(default)")
228                ));
229            }
230
231            // Ensure per-service namespaces exist before apply/rollout.
232            if !ctx.flags.skip_apply && !ctx.flags.dry_run {
233                let opts = K8sApplyOptions {
234                    dry_run: false,
235                    server_side: false,
236                    prune: false,
237                };
238                match ensure_plan_namespaces(self.k8s.as_ref(), &target, &k8s_work, &opts).await {
239                    Ok(ns_lines) => lines.extend(ns_lines),
240                    Err(e) => {
241                        failed.push("kubernetes".into());
242                        lines.push(format!("ERROR: ensure namespaces: {e}"));
243                        return Ok(finish_result(plan, ctx, lines, failed));
244                    }
245                }
246            }
247
248            if !ctx.flags.skip_apply && failed.is_empty() {
249                let opts = K8sApplyOptions {
250                    dry_run: ctx.flags.dry_run,
251                    server_side: false,
252                    prune: false,
253                };
254                let k8s_plan = plan.to_k8s_plan();
255                let has_manifests = k8s_plan.services.iter().any(|s| {
256                    !s.manifest_paths.is_empty()
257                        || s.crds_path.is_some()
258                        || s.install_path.is_some()
259                });
260
261                // Default: bootstrap when no manifests (first-time deploy).
262                if !has_manifests && ctx.flags.bootstrap_workload {
263                    for svc in &k8s_work {
264                        if !svc.deploy.manifest_paths.is_empty() {
265                            continue;
266                        }
267                        if svc.deploy.workload.is_none() {
268                            failed.push(svc.name.clone());
269                            lines.push(format!(
270                                "[{}] bootstrap failed: services[].deploy.envs.*.kubernetes.workload is unset (e.g. deployment/{})",
271                                svc.name, svc.name
272                            ));
273                            continue;
274                        }
275                        // Compose may set container_port; default 8080 for generic services.
276                        let container_port = svc.container_port.unwrap_or(8080);
277                        // Local-only builds are not on a registry — Never avoids ImagePullBackOff.
278                        let pull_policy = if local_image {
279                            "Never"
280                        } else {
281                            "IfNotPresent"
282                        };
283                        let Some(docs) = bootstrap_workload_documents(
284                            svc,
285                            container_port,
286                            pull_policy,
287                        ) else {
288                            failed.push(svc.name.clone());
289                            lines.push(format!(
290                                "[{}] bootstrap failed: need image_ref + workload (image_ref={:?}, workload={:?}, ns={:?})",
291                                svc.name,
292                                svc.image_ref,
293                                svc.deploy.workload,
294                                svc.deploy.namespace
295                            ));
296                            continue;
297                        };
298                        let yaml = docs.join("---\n");
299                        let t = target.with_service_namespace(svc.deploy.namespace.as_deref());
300                        lines.push(format!(
301                            "[{}] bootstrap workload ns={} image={} port={} env_keys={} config_files={}",
302                            svc.name,
303                            t.namespace.as_deref().unwrap_or("default"),
304                            svc.image_ref.as_deref().unwrap_or("?"),
305                            container_port,
306                            svc.runtime_env.len(),
307                            svc.config_mounts.len()
308                        ));
309                        match write_temp_and_apply(self.k8s.as_ref(), &t, &opts, &svc.name, &yaml)
310                            .await
311                        {
312                            Ok(msg) => lines.push(format!("[{}] {msg}", svc.name)),
313                            Err(e) => {
314                                failed.push(svc.name.clone());
315                                lines.push(format!("[{}] bootstrap apply failed: {e}", svc.name));
316                            }
317                        }
318                    }
319                } else if !has_manifests {
320                    lines.push(
321                        "ERROR: no kubernetes manifest paths — bootstrap is disabled (--no-bootstrap-workload); re-enable default bootstrap or set kubernetes.manifests"
322                            .into(),
323                    );
324                    for svc in &k8s_work {
325                        if svc.deploy.workload.is_some() && svc.deploy.manifest_paths.is_empty() {
326                            failed.push(svc.name.clone());
327                        }
328                    }
329                }
330
331                if has_manifests && failed.is_empty() {
332                    match self.k8s.apply(&target, &k8s_plan, &opts).await {
333                        Ok(apply_lines) => {
334                            if apply_lines.is_empty() {
335                                lines.push("kubernetes apply: (no output)".into());
336                            }
337                            lines.extend(apply_lines);
338                        }
339                        Err(e) => {
340                            failed.push("kubernetes".into());
341                            lines.push(format!("ERROR: {e}"));
342                        }
343                    }
344                }
345            } else if ctx.flags.skip_apply {
346                lines.push("skip-apply (kubernetes)".into());
347            }
348
349            if !ctx.flags.skip_rollout && !ctx.flags.dry_run && failed.is_empty() {
350                for svc in &k8s_work {
351                    if let Some(workload) = &svc.deploy.workload {
352                        let t = target.with_service_namespace(svc.deploy.namespace.as_deref());
353                        let ns_label = t
354                            .namespace
355                            .as_deref()
356                            .unwrap_or("(cluster-default)");
357                        match self.k8s.rollout(&t, workload, "180s").await {
358                            Ok(out) => lines.push(format!(
359                                "[{}] ns={ns_label} {}",
360                                svc.name,
361                                out.lines().next().unwrap_or("ok")
362                            )),
363                            Err(e) => {
364                                failed.push(svc.name.clone());
365                                let mut msg = format!(
366                                    "[{}] rollout failed (ns={ns_label}, workload={workload}): {e}",
367                                    svc.name
368                                );
369                                if svc.deploy.manifest_paths.is_empty()
370                                    && !ctx.flags.bootstrap_workload
371                                {
372                                    msg.push_str(
373                                        " — no manifests; use default bootstrap or set kubernetes.manifests",
374                                    );
375                                }
376                                lines.push(msg);
377                            }
378                        }
379                    }
380                }
381            }
382
383            // Local routing: port-forward + hosts DNS (after successful rollout or dry-run plan).
384            // With --local-image (or registry-fail fallback), auto port-forward even without expose config.
385            if failed.is_empty() && !ctx.flags.skip_expose {
386                for svc in &k8s_work {
387                    let auto_local = local_image;
388                    if crate::expose::effective_expose(svc, auto_local).is_some() {
389                        let result = crate::expose::expose_service_with_auto(
390                            svc,
391                            ctx.flags.context.as_deref(),
392                            ctx.flags.dry_run || ctx.flags.skip_rollout,
393                            auto_local,
394                        );
395                        lines.extend(result.lines);
396                    }
397                }
398            }
399        } else {
400            lines.push("kubernetes: no k8s services in plan (skipped)".into());
401        }
402
403        // --- Explicit unsupported destinations (fail clear, no kubectl) ---
404        for svc in plan.services.iter().filter(|s| is_railway_provider(&s.provider)) {
405            if !ctx.flags.skip_apply {
406                failed.push(svc.name.clone());
407                lines.push(format!(
408                    "[{}] destination `railway` is reserved but not wired yet (use --to cloudflare or --to kubernetes)",
409                    svc.name
410                ));
411            }
412        }
413        for svc in plan.services.iter().filter(|s| {
414            let d = s
415                .destination
416                .clone()
417                .unwrap_or_else(|| provider_to_destination(&s.provider));
418            !is_kubernetes_provider(&s.provider)
419                && !is_cloudflare_provider(&s.provider)
420                && !is_railway_provider(&s.provider)
421                && d != "local"
422                && d != "oci"
423        }) {
424            if !ctx.flags.skip_apply {
425                lines.push(format!(
426                    "[{}] destination `{}` has no runner yet (provider={})",
427                    svc.name,
428                    svc.destination
429                        .as_deref()
430                        .unwrap_or("?"),
431                    svc.provider
432                ));
433            }
434        }
435
436        // --- Cloudflare path (additive; never calls kubectl) ---
437        //
438        // Build ownership:
439        // - OpenNext / Wrangler always builds Worker assets during CF deploy (unless skip_apply).
440        // - `local_image` → pre-build CF Container Dockerfile with local Docker (`--local-build`).
441        // - `skip_apply` + `local_image` → local container build only (no Wrangler).
442        // - GHCR/docker push is intentionally not used for CF-only plans.
443        if !cf_services.is_empty() {
444            let local_build_only = ctx.flags.skip_apply && local_image;
445            let skip_cf_deploy = ctx.flags.skip_apply && !local_image;
446
447            if skip_cf_deploy {
448                lines.push(
449                    "skip-apply (cloudflare): no Wrangler deploy (use full CF build strategy without skip-apply to ship)"
450                        .into(),
451                );
452            } else {
453                let Some(cf) = &self.cloudflare else {
454                    for svc in &cf_services {
455                        failed.push(svc.name.clone());
456                        lines.push(format!(
457                            "[{}] cloudflare provider `{}` requires a Cloudflare adapter (use `xbp deploy` from the CLI, or `xbp cloudflare deploy --app {}`)",
458                            svc.name,
459                            svc.provider,
460                            svc.worker_app.as_deref().unwrap_or(svc.name.as_str())
461                        ));
462                    }
463                    return Ok(finish_result(plan, ctx, lines, failed));
464                };
465
466                for svc in &cf_services {
467                    let worker_app = svc
468                        .worker_app
469                        .clone()
470                        .unwrap_or_else(|| svc.name.clone());
471                    // Prefer local Docker pre-build for CF container workers unless dry-run.
472                    // OpenNext plain Workers always build on-machine (WSL/native); this flag
473                    // only affects container image pre-build. Operators can still use
474                    // --local-image explicitly; we default prefer-local when Docker is usable.
475                    let prefer_local_cf = !ctx.flags.dry_run
476                        && (local_image
477                            || local_build_only
478                            || docker_available_for_local_cf_build());
479                    // Config-only CF deploys are the default (image may not change).
480                    // Operators who need a new image fail with --require-new-container-image.
481                    // --skip-build also implies config-only is expected.
482                    let allow_unchanged =
483                        !ctx.flags.require_new_container_image || ctx.flags.skip_build;
484                    let request = CloudflareServiceDeployRequest {
485                        service_name: svc.name.clone(),
486                        worker_app: worker_app.clone(),
487                        dry_run: ctx.flags.dry_run,
488                        rollout: svc.rollout.clone(),
489                        skip_deploy: local_build_only,
490                        allow_unchanged_container_image: allow_unchanged,
491                        prune_old_images: false,
492                        local_build: prefer_local_cf,
493                        local_build_only,
494                    };
495                    if local_build_only {
496                        lines.push(format!(
497                            "[{}] cloudflare local container build only (no Wrangler) app={}",
498                            svc.name, worker_app
499                        ));
500                    } else {
501                        lines.push(format!(
502                            "[{}] cloudflare deploy app={} dry_run={} local_build={} allow_unchanged_image={}",
503                            svc.name,
504                            worker_app,
505                            ctx.flags.dry_run,
506                            prefer_local_cf,
507                            allow_unchanged
508                        ));
509                    }
510                    match cf.deploy_service(request).await {
511                        Ok(cf_lines) => {
512                            for line in cf_lines {
513                                lines.push(format!("[{}] {line}", svc.name));
514                            }
515                        }
516                        Err(e) => {
517                            failed.push(svc.name.clone());
518                            lines.push(format!("[{}] cloudflare deploy failed: {e}", svc.name));
519                        }
520                    }
521                }
522            }
523        }
524
525        // Health probes for services that declare health URLs.
526        // For pure CF, the cloudflare workflow already verifies containers/health;
527        // env-level health still runs unless skip_health (same as k8s).
528        if !ctx.flags.skip_health && !ctx.flags.dry_run && failed.is_empty() {
529            let health_futs = plan.services.iter().flat_map(|svc| {
530                svc.deploy.health.iter().map(move |url| {
531                    let name = svc.name.clone();
532                    let url = url.clone();
533                    async move { (name, url.clone(), http_status(&url).await) }
534                })
535            });
536            for (name, url, result) in join_all(health_futs).await {
537                match result {
538                    Ok(code) if (200..400).contains(&code) => {
539                        lines.push(format!("[{name}] health {url} → {code}"));
540                    }
541                    Ok(code) => {
542                        failed.push(name.clone());
543                        lines.push(format!("[{name}] health {url} → {code}"));
544                    }
545                    Err(e) => {
546                        failed.push(name.clone());
547                        lines.push(format!("[{name}] health {url} failed: {e}"));
548                    }
549                }
550            }
551        }
552
553        Ok(finish_result(plan, ctx, lines, failed))
554    }
555}
556
557async fn write_temp_and_apply(
558    k8s: &dyn xbp_k8s::K8sAdapter,
559    target: &KubeTarget,
560    opts: &K8sApplyOptions,
561    service: &str,
562    yaml: &str,
563) -> std::result::Result<String, String> {
564    let dir = std::env::temp_dir().join(format!("xbp-bootstrap-{service}"));
565    let _ = std::fs::create_dir_all(&dir);
566    let path = dir.join("manifest.yaml");
567    std::fs::write(&path, yaml).map_err(|e| format!("write temp manifest: {e}"))?;
568    // Ensure namespace object first when set.
569    if let Some(ns) = target.namespace.as_deref().filter(|s| !s.is_empty()) {
570        let ns_yaml = format!(
571            "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: {ns}\n  labels:\n    app.kubernetes.io/managed-by: xbp\n"
572        );
573        let ns_path = dir.join("namespace.yaml");
574        let _ = std::fs::write(&ns_path, ns_yaml);
575        let _ = k8s.apply_path(target, &ns_path, opts).await;
576    }
577    k8s.apply_path(target, &path, opts)
578        .await
579        .map(|out| {
580            format!(
581                "bootstrap apply {}: {}",
582                path.display(),
583                out.lines().next().unwrap_or("ok")
584            )
585        })
586        .map_err(|e| e.to_string())
587}
588
589/// Create missing namespaces declared by the plan (idempotent apply of Namespace objects).
590async fn ensure_plan_namespaces(
591    k8s: &dyn xbp_k8s::K8sAdapter,
592    target: &KubeTarget,
593    services: &[ServicePlan],
594    opts: &K8sApplyOptions,
595) -> std::result::Result<Vec<String>, String> {
596    let mut namespaces: Vec<String> = services
597        .iter()
598        .filter_map(|s| {
599            s.deploy
600                .namespace
601                .as_deref()
602                .map(str::trim)
603                .filter(|s| !s.is_empty())
604                .map(str::to_string)
605        })
606        .collect();
607    if let Some(ns) = target
608        .namespace
609        .as_deref()
610        .map(str::trim)
611        .filter(|s| !s.is_empty())
612    {
613        namespaces.push(ns.to_string());
614    }
615    namespaces.sort();
616    namespaces.dedup();
617    if namespaces.is_empty() {
618        return Ok(Vec::new());
619    }
620
621    let dir = std::env::temp_dir().join(format!(
622        "xbp-ensure-ns-{}",
623        std::process::id()
624    ));
625    std::fs::create_dir_all(&dir).map_err(|e| format!("create temp dir: {e}"))?;
626
627    let mut lines = Vec::new();
628    for ns in namespaces {
629        let ns_yaml = format!(
630            "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: {ns}\n  labels:\n    app.kubernetes.io/managed-by: xbp\n"
631        );
632        let ns_path = dir.join(format!("{ns}.yaml"));
633        std::fs::write(&ns_path, ns_yaml).map_err(|e| format!("write namespace yaml: {e}"))?;
634        // Apply without forcing --namespace (Namespace is cluster-scoped).
635        let ns_target = KubeTarget {
636            context: target.context.clone(),
637            namespace: None,
638            kubeconfig: target.kubeconfig.clone(),
639        };
640        match k8s.apply_path(&ns_target, &ns_path, opts).await {
641            Ok(out) => {
642                let detail = out.lines().next().unwrap_or("ok");
643                lines.push(format!("ensure namespace `{ns}`: {detail}"));
644            }
645            Err(e) => {
646                let msg = e.to_string();
647                // Idempotent: already exists is success.
648                if msg.to_ascii_lowercase().contains("alreadyexists")
649                    || msg.to_ascii_lowercase().contains("already exists")
650                {
651                    lines.push(format!("ensure namespace `{ns}`: already exists"));
652                } else {
653                    return Err(format!("namespace `{ns}`: {msg}"));
654                }
655            }
656        }
657    }
658    Ok(lines)
659}
660
661fn svc_root(project_root: &std::path::Path, svc: &ServicePlan) -> Option<PathBuf> {
662    let rel = svc.root_directory.as_deref()?.trim();
663    if rel.is_empty() {
664        return None;
665    }
666    let p = PathBuf::from(rel);
667    Some(if p.is_absolute() {
668        p
669    } else {
670        project_root.join(p)
671    })
672}
673
674fn resolve_build_context(project_root: &std::path::Path, svc: &ServicePlan) -> PathBuf {
675    let service_root = svc_root(project_root, svc);
676    if let Some(ctx) = svc.build_context.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
677        let p = PathBuf::from(ctx);
678        if p.is_absolute() {
679            return p;
680        }
681        if let Some(root) = &service_root {
682            return root.join(p);
683        }
684        return project_root.join(p);
685    }
686    service_root.unwrap_or_else(|| project_root.to_path_buf())
687}
688
689/// Compact OPERATION-friendly error: service names plus first root-cause line.
690fn format_deploy_failure(failed: &[String], lines: &[String]) -> String {
691    let services = failed.join(", ");
692    let reason = first_failure_reason(failed, lines);
693    match reason {
694        Some(r) => format!("failed: {services} — {r}"),
695        None => format!("failed: {services}"),
696    }
697}
698
699fn first_failure_reason(failed: &[String], lines: &[String]) -> Option<String> {
700    // Prefer lines tagged with a failed service name.
701    for name in failed {
702        let prefix = format!("[{name}]");
703        for line in lines.iter().rev() {
704            let trimmed = line.trim();
705            if !trimmed.starts_with(&prefix) {
706                continue;
707            }
708            if let Some(rest) = extract_failure_detail(trimmed) {
709                return Some(rest);
710            }
711        }
712    }
713    // Generic ERROR: lines from apply path.
714    for line in lines.iter().rev() {
715        let trimmed = line.trim();
716        if let Some(rest) = trimmed.strip_prefix("ERROR:") {
717            let rest = rest.trim();
718            if !rest.is_empty() {
719                return Some(rest.to_string());
720            }
721        }
722    }
723    None
724}
725
726fn extract_failure_detail(line: &str) -> Option<String> {
727    // "[svc] rollout failed: …" / "[svc] apply …" / "[svc] health … failed: …"
728    for marker in ["rollout failed: ", " failed: ", "cloudflare deploy failed: "] {
729        if let Some(idx) = line.find(marker) {
730            let rest = line[idx + marker.len()..].trim();
731            if !rest.is_empty() {
732                return Some(rest.to_string());
733            }
734        }
735    }
736    None
737}
738
739fn finish_result(
740    plan: &DeployPlan,
741    ctx: &DeployContext,
742    lines: Vec<String>,
743    failed: Vec<String>,
744) -> DeployResult {
745    let ok = failed.is_empty();
746    let mut result = DeployResult {
747        ok,
748        lines: lines.clone(),
749        failed_services: failed.clone(),
750        error: if ok {
751            None
752        } else {
753            Some(format_deploy_failure(&failed, &lines))
754        },
755        history_id: None,
756        history_path: None,
757        history_secrets: Vec::new(),
758    };
759
760    if matches!(ctx.mode, DeployMode::Run) && !ctx.flags.dry_run {
761        let store = DeployHistoryStore::new(&ctx.config.history_dir);
762        let xbp_version = std::env::var("CARGO_PKG_VERSION")
763            .ok()
764            .filter(|s| !s.is_empty())
765            .or_else(|| Some(env!("CARGO_PKG_VERSION").to_string()));
766        let sanitized = record_from_plan_with_version(
767            plan,
768            ok,
769            if ok {
770                "deploy succeeded".into()
771            } else {
772                result.error.clone().unwrap_or_default()
773            },
774            result.error.clone(),
775            plan.k8s_plan.context.clone(),
776            xbp_version,
777        );
778        if let Ok(path) = store.write_record(&sanitized.record) {
779            result.history_id = Some(sanitized.record.id.clone());
780            result.history_path = Some(path.display().to_string());
781            if !sanitized.secrets.is_empty() {
782                result.lines.push(format!(
783                    "history: redacted {} secret(s) from runtime_env → placeholders (id={})",
784                    sanitized.secrets.len(),
785                    sanitized.record.id
786                ));
787            }
788        }
789        result.history_secrets = sanitized.secrets;
790        if ok {
791            let lock = lock_from_plan(plan);
792            let _ = write_lock(&ctx.config.lock_file, &lock);
793        }
794    }
795
796    result
797}
798
799impl DefaultDeployRunner {
800    /// Phase 1 of run: build and push every service image that declares `oci.dockerfile`.
801    ///
802    /// Runs before Kubernetes context confirmation / apply so registry has digests
803    /// available for bootstrap and pull. Updates `k8s_work` image_ref/digest in place.
804    ///
805    /// On registry auth/denied failures in a TTY (without `--yes`), offers to continue
806    /// with local-only load (same as `--local-image`) and sets `local_image`.
807    async fn run_oci_build_phase(
808        &self,
809        ctx: &DeployContext,
810        plan: &DeployPlan,
811        k8s_work: &mut [ServicePlan],
812        lines: &mut Vec<String>,
813        failed: &mut Vec<String>,
814        local_image: &mut bool,
815    ) -> Result<()> {
816        if ctx.flags.skip_build {
817            lines.push("phase build: skipped (--skip-build)".into());
818            return Ok(());
819        }
820
821        // Only Kubernetes (and pure OCI destination) steps need GHCR/docker push here.
822        // Cloudflare Workers / Containers own their own image path (OpenNext / wrangler
823        // containers / --local-build). Building ghcr.io for CF-only plans is wrong and
824        // blocks deploys that never touch a cluster (e.g. next-heroui-example).
825        //
826        // Defense in depth: never build for cloudflare providers even if they leak into
827        // k8s_work or carry dockerfile + image_ref from services[].oci.
828        let mut build_targets: Vec<usize> = Vec::new(); // indices into k8s_work
829        for (i, svc) in k8s_work.iter().enumerate() {
830            if is_cloudflare_provider(&svc.provider) {
831                lines.push(format!(
832                    "[{}] phase build: skip GHCR for cloudflare provider (use CF local_build / OpenNext)",
833                    svc.name
834                ));
835                continue;
836            }
837            if !is_kubernetes_provider(&svc.provider) {
838                continue;
839            }
840            if svc.dockerfile.is_some() && svc.image_ref.is_some() {
841                build_targets.push(i);
842            }
843        }
844        // Explicit `oci` destination only (not cloudflare / railway / local / k8s).
845        let mut extra: Vec<ServicePlan> = plan
846            .services
847            .iter()
848            .filter(|s| {
849                crate::providers::is_oci_provider(&s.provider)
850                    && !is_cloudflare_provider(&s.provider)
851                    && !is_kubernetes_provider(&s.provider)
852                    && s.dockerfile.is_some()
853                    && s.image_ref.is_some()
854            })
855            .cloned()
856            .collect();
857
858        if build_targets.is_empty() && extra.is_empty() {
859            let cf_with_dockerfile = plan.services.iter().any(|s| {
860                is_cloudflare_provider(&s.provider)
861                    && s.dockerfile.is_some()
862                    && s.image_ref.is_some()
863            });
864            let cf_any = plan
865                .services
866                .iter()
867                .any(|s| is_cloudflare_provider(&s.provider));
868            if cf_with_dockerfile || cf_any {
869                if *local_image {
870                    lines.push(
871                        "phase build: Cloudflare path — container pre-build runs in CF step (`local_build`); no GHCR push"
872                            .into(),
873                    );
874                } else {
875                    lines.push(
876                        "phase build: skipped GHCR (Cloudflare owns image build — OpenNext/Wrangler/containers)"
877                            .into(),
878                    );
879                }
880            }
881            return Ok(());
882        }
883
884        let n = build_targets.len() + extra.len();
885        if *local_image {
886            lines.push(format!(
887                "phase build: building {n} image(s) into local Docker (no registry push)"
888            ));
889        } else {
890            lines.push(format!(
891                "phase build/push: building and pushing {n} image(s) before cluster apply"
892            ));
893        }
894
895        let Some(builder) = &self.oci_build else {
896            for i in &build_targets {
897                lines.push(format!(
898                    "[{}] oci.dockerfile set but no build adapter wired (skip build)",
899                    k8s_work[*i].name
900                ));
901            }
902            for svc in &extra {
903                lines.push(format!(
904                    "[{}] oci.dockerfile set but no build adapter wired (skip build)",
905                    svc.name
906                ));
907            }
908            return Ok(());
909        };
910
911        for i in &build_targets {
912            let step_key = service_step_key(&k8s_work[*i]);
913            let svc_name = k8s_work[*i].name.clone();
914            let image_ref = k8s_work[*i].image_ref.clone().expect("checked");
915            let dockerfile = k8s_work[*i].dockerfile.clone().expect("checked");
916            let platforms = k8s_work[*i].platforms.clone();
917            let context = resolve_build_context(&ctx.config.project_root, &k8s_work[*i]);
918            let service_root = svc_root(&ctx.config.project_root, &k8s_work[*i]);
919
920            match self
921                .build_one_image(
922                    builder.as_ref(),
923                    ctx,
924                    &svc_name,
925                    &image_ref,
926                    dockerfile,
927                    context,
928                    platforms,
929                    service_root,
930                    local_image,
931                    lines,
932                )
933                .await
934            {
935                Ok(Some(result)) => {
936                    let svc = &mut k8s_work[*i];
937                    if let Some(pinned) = result.image_ref {
938                        // Keep tag refs for local load (digest pins need registry pulls).
939                        if *local_image && pinned.contains('@') {
940                            svc.image_ref = Some(image_ref.clone());
941                        } else {
942                            svc.image_ref = Some(pinned);
943                        }
944                    }
945                    if !*local_image {
946                        if let Some(d) = result.digest {
947                            svc.digest = Some(d);
948                        }
949                    }
950                }
951                Ok(None) => {}
952                Err(_e) => {
953                    failed.push(step_key);
954                    // Fail fast: do not build remaining images after a failure.
955                    // Error detail already logged by build_one_image.
956                    return Ok(());
957                }
958            }
959        }
960
961        for svc in &mut extra {
962            let step_key = service_step_key(svc);
963            let svc_name = svc.name.clone();
964            let image_ref = svc.image_ref.clone().expect("checked");
965            let dockerfile = svc.dockerfile.clone().expect("checked");
966            let platforms = svc.platforms.clone();
967            let context = resolve_build_context(&ctx.config.project_root, svc);
968            let service_root = svc_root(&ctx.config.project_root, svc);
969
970            match self
971                .build_one_image(
972                    builder.as_ref(),
973                    ctx,
974                    &svc_name,
975                    &image_ref,
976                    dockerfile,
977                    context,
978                    platforms,
979                    service_root,
980                    local_image,
981                    lines,
982                )
983                .await
984            {
985                Ok(_) => {}
986                Err(_e) => {
987                    failed.push(step_key);
988                    // Error detail already logged by build_one_image.
989                    return Ok(());
990                }
991            }
992        }
993
994        if failed.is_empty() && n > 0 {
995            if *local_image {
996                lines.push("phase build: complete (local Docker)".into());
997            } else {
998                lines.push("phase build/push: complete".into());
999            }
1000        }
1001        Ok(())
1002    }
1003
1004    /// Build/push one image. On registry auth failure, may prompt and retry local-only.
1005    #[allow(clippy::too_many_arguments)]
1006    async fn build_one_image(
1007        &self,
1008        builder: &dyn OciBuildAdapter,
1009        ctx: &DeployContext,
1010        svc_name: &str,
1011        image_ref: &str,
1012        dockerfile: String,
1013        context: PathBuf,
1014        platforms: Vec<String>,
1015        service_root: Option<PathBuf>,
1016        local_image: &mut bool,
1017        lines: &mut Vec<String>,
1018    ) -> std::result::Result<Option<crate::oci_build::OciBuildResult>, String> {
1019        let verb = if *local_image {
1020            "build/load"
1021        } else {
1022            "build/push"
1023        };
1024        lines.push(format!("[{svc_name}] {verb} start → {image_ref}"));
1025
1026        let req = OciBuildRequest {
1027            service: svc_name.to_string(),
1028            image_ref: image_ref.to_string(),
1029            dockerfile: Some(dockerfile.clone()),
1030            context: context.clone(),
1031            platforms: platforms.clone(),
1032            dry_run: ctx.flags.dry_run,
1033            project_root: ctx.config.project_root.clone(),
1034            service_root: service_root.clone(),
1035            local_image: *local_image,
1036        };
1037
1038        match builder.build_and_push(req).await {
1039            Ok(result) => {
1040                let used_local = result.local_only || *local_image;
1041                for line in &result.lines {
1042                    lines.push(format!("[{svc_name}] {line}"));
1043                }
1044                let done_verb = if used_local {
1045                    "build/load"
1046                } else {
1047                    "build/push"
1048                };
1049                lines.push(format!("[{svc_name}] {done_verb} ok"));
1050                Ok(Some(result))
1051            }
1052            Err(e) => {
1053                lines.push(format!("[{svc_name}] {verb} failed: {e}"));
1054                if !*local_image
1055                    && is_registry_auth_failure(&e)
1056                    && self.should_offer_local_image_fallback(ctx)
1057                {
1058                    let prompt = format!(
1059                        "[{svc_name}] Registry push failed. Continue with local Docker only (no push, like --local-image)?"
1060                    );
1061                    let accepted = self
1062                        .confirm
1063                        .as_ref()
1064                        .map(|f| f(prompt))
1065                        .unwrap_or(false);
1066                    if accepted {
1067                        *local_image = true;
1068                        lines.push(format!(
1069                            "[{svc_name}] opted into --local-image after registry failure; rebuilding into local Docker"
1070                        ));
1071                        let retry = OciBuildRequest {
1072                            service: svc_name.to_string(),
1073                            image_ref: image_ref.to_string(),
1074                            dockerfile: Some(dockerfile),
1075                            context,
1076                            platforms,
1077                            dry_run: ctx.flags.dry_run,
1078                            project_root: ctx.config.project_root.clone(),
1079                            service_root,
1080                            local_image: true,
1081                        };
1082                        match builder.build_and_push(retry).await {
1083                            Ok(result) => {
1084                                for line in &result.lines {
1085                                    lines.push(format!("[{svc_name}] {line}"));
1086                                }
1087                                lines.push(format!(
1088                                    "[{svc_name}] build/load ok (local fallback)"
1089                                ));
1090                                return Ok(Some(result));
1091                            }
1092                            Err(e2) => {
1093                                return Err(format!(
1094                                    "[{svc_name}] build/load failed after local fallback: {e2}"
1095                                ));
1096                            }
1097                        }
1098                    }
1099                    lines.push(format!(
1100                        "[{svc_name}] declined local-image fallback — fix `docker login` / GH_TOKEN / `xbp config oci`, or re-run with --local-image"
1101                    ));
1102                } else if !*local_image && is_registry_auth_failure(&e) {
1103                    lines.push(format!(
1104                        "[{svc_name}] hint: re-run with --local-image (or without --yes in a TTY to be prompted) to skip registry push"
1105                    ));
1106                }
1107                Err(format!("[{svc_name}] {verb} failed: {e}"))
1108            }
1109        }
1110    }
1111
1112    fn should_offer_local_image_fallback(&self, ctx: &DeployContext) -> bool {
1113        if ctx.flags.yes || ctx.flags.json || ctx.flags.dry_run {
1114            return false;
1115        }
1116        self.confirm.is_some()
1117    }
1118}
1119
1120/// Registry push denied / unauthorized (operator often wanted local load instead).
1121fn is_registry_auth_failure(err: &str) -> bool {
1122    let el = err.to_ascii_lowercase();
1123    el.contains("denied")
1124        || el.contains("unauthorized")
1125        || el.contains("authentication required")
1126        || el.contains("no basic auth credentials")
1127        || el.contains("requested access to the resource is denied")
1128        || el.contains("error from registry")
1129        || el.contains("registry auth")
1130}
1131
1132/// Stable step id for multi-destination plans (`name@destination` when set).
1133fn service_step_key(svc: &ServicePlan) -> String {
1134    match svc.destination.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
1135        Some(d) => format!("{}@{d}", svc.name),
1136        None => svc.name.clone(),
1137    }
1138}
1139
1140impl DefaultDeployRunner {
1141    async fn promote(&self, ctx: &DeployContext, plan: &DeployPlan) -> Result<DeployResult> {
1142        let tag = ctx
1143            .flags
1144            .promote_tag
1145            .as_deref()
1146            .ok_or_else(|| DeployError::Validation("promote requires a target tag".into()))?;
1147        let Some(promoter) = &self.promoter else {
1148            return Err(DeployError::Oci("no OCI promoter configured".into()));
1149        };
1150        let mut lines = Vec::new();
1151        let mut failed = Vec::new();
1152        for svc in &plan.services {
1153            // CF destinations never use GHCR promote.
1154            if is_cloudflare_provider(&svc.provider) {
1155                lines.push(format!(
1156                    "[{}] skip promote (cloudflare owns image path)",
1157                    svc.name
1158                ));
1159                continue;
1160            }
1161            let Some(image) = &svc.image else {
1162                continue;
1163            };
1164            let target = image.with_tag(tag);
1165            if ctx.flags.dry_run {
1166                lines.push(format!(
1167                    "[{}] dry-run promote {} -> {}",
1168                    svc.name,
1169                    image.reference(),
1170                    target.reference()
1171                ));
1172                continue;
1173            }
1174            match promoter.promote(image, &target, true).await {
1175                Ok(()) => lines.push(format!(
1176                    "[{}] promoted {} -> {}",
1177                    svc.name,
1178                    image.reference(),
1179                    target.reference()
1180                )),
1181                Err(e) => {
1182                    failed.push(svc.name.clone());
1183                    lines.push(format!("[{}] promote failed: {e}", svc.name));
1184                }
1185            }
1186        }
1187        Ok(DeployResult {
1188            ok: failed.is_empty(),
1189            lines,
1190            failed_services: failed.clone(),
1191            error: if failed.is_empty() {
1192                None
1193            } else {
1194                Some(format!("promote failed: {}", failed.join(", ")))
1195            },
1196            history_id: None,
1197            history_path: None,
1198            history_secrets: Vec::new(),
1199        })
1200    }
1201}
1202
1203async fn http_status(url: &str) -> std::result::Result<u16, String> {
1204    use tokio::process::Command;
1205    let output = Command::new("curl")
1206        .args(["-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "15", url])
1207        .output()
1208        .await
1209        .map_err(|e| e.to_string())?;
1210    if !output.status.success() {
1211        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1212    }
1213    String::from_utf8_lossy(&output.stdout)
1214        .trim()
1215        .parse()
1216        .map_err(|_| "invalid status".into())
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use crate::context::{DeployFlags, DeployMode};
1223    use crate::types::*;
1224    use async_trait::async_trait;
1225    use std::collections::HashMap;
1226    use std::path::PathBuf;
1227    use std::sync::Mutex;
1228    use xbp_k8s::{K8sApplyOptions, K8sPlan, K8sServicePlan, KubeTarget};
1229
1230    struct RecordingK8s {
1231        apply_calls: Mutex<usize>,
1232        rollout_calls: Mutex<usize>,
1233    }
1234
1235    #[async_trait]
1236    impl K8sAdapter for RecordingK8s {
1237        async fn render(
1238            &self,
1239            _plan: &K8sPlan,
1240        ) -> xbp_k8s::Result<xbp_k8s::RenderedManifests> {
1241            Ok(xbp_k8s::RenderedManifests {
1242                sources: vec![],
1243                documents: vec![],
1244            })
1245        }
1246
1247        async fn apply(
1248            &self,
1249            _target: &KubeTarget,
1250            plan: &K8sPlan,
1251            _opts: &K8sApplyOptions,
1252        ) -> xbp_k8s::Result<Vec<String>> {
1253            *self.apply_calls.lock().unwrap() += 1;
1254            Ok(vec![format!("applied {} services", plan.services.len())])
1255        }
1256
1257        async fn diff(
1258            &self,
1259            _target: &KubeTarget,
1260            _plan: &K8sPlan,
1261        ) -> xbp_k8s::Result<String> {
1262            Ok(String::new())
1263        }
1264
1265        async fn rollout(
1266            &self,
1267            _target: &KubeTarget,
1268            _workload: &str,
1269            _timeout: &str,
1270        ) -> xbp_k8s::Result<String> {
1271            *self.rollout_calls.lock().unwrap() += 1;
1272            Ok("ok".into())
1273        }
1274
1275        async fn verify(
1276            &self,
1277            _target: &KubeTarget,
1278            _plan: &K8sPlan,
1279        ) -> xbp_k8s::Result<Vec<String>> {
1280            Ok(vec![])
1281        }
1282
1283        async fn apply_path(
1284            &self,
1285            _target: &KubeTarget,
1286            _path: &std::path::Path,
1287            _opts: &K8sApplyOptions,
1288        ) -> xbp_k8s::Result<String> {
1289            Ok(String::new())
1290        }
1291    }
1292
1293    struct RecordingCf {
1294        calls: Mutex<Vec<String>>,
1295        /// Each call: (worker_app, local_build)
1296        local_build_flags: Mutex<Vec<bool>>,
1297        allow_unchanged_flags: Mutex<Vec<bool>>,
1298    }
1299
1300    impl RecordingCf {
1301        fn new() -> Self {
1302            Self {
1303                calls: Mutex::new(Vec::new()),
1304                local_build_flags: Mutex::new(Vec::new()),
1305                allow_unchanged_flags: Mutex::new(Vec::new()),
1306            }
1307        }
1308    }
1309
1310    #[async_trait]
1311    impl CloudflareDeployAdapter for RecordingCf {
1312        async fn deploy_service(
1313            &self,
1314            request: CloudflareServiceDeployRequest,
1315        ) -> std::result::Result<Vec<String>, String> {
1316            self.calls.lock().unwrap().push(request.worker_app.clone());
1317            self.local_build_flags
1318                .lock()
1319                .unwrap()
1320                .push(request.local_build);
1321            self.allow_unchanged_flags
1322                .lock()
1323                .unwrap()
1324                .push(request.allow_unchanged_container_image);
1325            Ok(vec!["cf-ok".into()])
1326        }
1327    }
1328
1329    fn base_ctx(plan_services_only: bool) -> (DeployContext, PathBuf) {
1330        let root = std::env::temp_dir().join(format!(
1331            "xbp-deploy-test-{}",
1332            std::process::id()
1333        ));
1334        let _ = std::fs::create_dir_all(&root);
1335        let ctx = DeployContext {
1336            env: "production".into(),
1337            target: DeployTarget::Service("svc".into()),
1338            config: ProjectConfig {
1339                project_name: "t".into(),
1340                version: "1.0.0".into(),
1341                project_root: root.clone(),
1342                services: vec![],
1343                groups: HashMap::new(),
1344                default_env: Some("production".into()),
1345                kubernetes: Some(KubernetesDefaults {
1346                    default_context: Some("kind".into()),
1347                    default_namespace: Some("default".into()),
1348                    require_context_confirmation_for_apply: false,
1349                    kubeconfig: None,
1350                }),
1351                history_dir: root.join("history"),
1352                lock_file: root.join("lock.json"),
1353                git_sha: None,
1354            },
1355            flags: DeployFlags {
1356                dry_run: true,
1357                yes: true,
1358                skip_health: true,
1359                skip_rollout: true,
1360                bootstrap_workload: false, // tests supply real apply paths / no bootstrap
1361                skip_doctor: true,
1362                ..DeployFlags::default()
1363            },
1364            mode: DeployMode::Run,
1365        };
1366        let _ = plan_services_only;
1367        (ctx, root)
1368    }
1369
1370    #[tokio::test]
1371    async fn k8s_only_plan_still_calls_kubectl_apply() {
1372        let k8s = Arc::new(RecordingK8s {
1373            apply_calls: Mutex::new(0),
1374            rollout_calls: Mutex::new(0),
1375        });
1376        let (ctx, root) = base_ctx(true);
1377        let manifest = root.join("api.yaml");
1378        std::fs::write(&manifest, "apiVersion: v1\nkind: ConfigMap\n").unwrap();
1379        let plan = DeployPlan {
1380            target: DeployTarget::Service("api".into()),
1381            env: "production".into(),
1382            project: "t".into(),
1383            project_version: "1".into(),
1384            git_sha: None,
1385            services: vec![ServicePlan {
1386                name: "api".into(),
1387                provider: "kubernetes".into(),
1388                destination: Some("kubernetes".into()),
1389                root_directory: None,
1390                version: "1".into(),
1391                image: None,
1392                image_ref: None,
1393                digest: None,
1394                dockerfile: None,
1395                build_context: None,
1396                platforms: vec![],
1397                worker_app: None,
1398                rollout: None,
1399                runtime_env: Default::default(),
1400                container_port: None,
1401                config_mounts: vec![],
1402                expose: None,
1403                deploy: ServiceDeployPlan {
1404                    namespace: Some("default".into()),
1405                    workload: Some("deploy/api".into()),
1406                    service: None,
1407                    health: vec![],
1408                    manifest_paths: vec![manifest.clone()],
1409                    crds_path: None,
1410                    install_path: None,
1411                    selector: None,
1412                    actions: vec![],
1413                },
1414            }],
1415            order: vec!["api".into()],
1416            oci_plan: OciPlan::default(),
1417            k8s_plan: K8sPlanView {
1418                context: Some("kind".into()),
1419                default_namespace: Some("default".into()),
1420                services: vec![K8sServicePlan {
1421                    service: "api".into(),
1422                    provider: "kubernetes".into(),
1423                    namespace: Some("default".into()),
1424                    workload: Some("deploy/api".into()),
1425                    manifest_paths: vec![manifest],
1426                    crds_path: None,
1427                    install_path: None,
1428                    selector: None,
1429                    health: vec![],
1430                }],
1431            },
1432            hash: "h".into(),
1433        };
1434        let runner = DefaultDeployRunner {
1435            k8s: k8s.clone(),
1436            promoter: None,
1437            confirm: None,
1438            cloudflare: None,
1439            oci_build: None,
1440        };
1441        let result = runner.run(&ctx, &plan).await.unwrap();
1442        assert!(result.ok, "{:?}", result.lines);
1443        assert_eq!(*k8s.apply_calls.lock().unwrap(), 1);
1444    }
1445
1446    #[tokio::test]
1447    async fn cloudflare_only_plan_does_not_call_kubectl() {
1448        let k8s = Arc::new(RecordingK8s {
1449            apply_calls: Mutex::new(0),
1450            rollout_calls: Mutex::new(0),
1451        });
1452        let cf = Arc::new(RecordingCf::new());
1453        let (ctx, _root) = base_ctx(true);
1454        let plan = DeployPlan {
1455            target: DeployTarget::Service("athena-auth".into()),
1456            env: "production".into(),
1457            project: "t".into(),
1458            project_version: "1".into(),
1459            git_sha: None,
1460            services: vec![ServicePlan {
1461                name: "athena-auth".into(),
1462                provider: "cloudflare-containers".into(),
1463                destination: Some("cloudflare".into()),
1464                root_directory: None,
1465                version: "1.14.2".into(),
1466                image: None,
1467                image_ref: None,
1468                digest: None,
1469                dockerfile: None,
1470                build_context: None,
1471                platforms: vec![],
1472                worker_app: Some("athena-auth".into()),
1473                rollout: Some("immediate".into()),
1474                runtime_env: Default::default(),
1475                container_port: None,
1476                config_mounts: vec![],
1477                expose: None,
1478                deploy: ServiceDeployPlan {
1479                    namespace: None,
1480                    workload: None,
1481                    service: None,
1482                    health: vec![],
1483                    manifest_paths: vec![],
1484                    crds_path: None,
1485                    install_path: None,
1486                    selector: None,
1487                    actions: vec![],
1488                },
1489            }],
1490            order: vec!["athena-auth".into()],
1491            oci_plan: OciPlan::default(),
1492            k8s_plan: K8sPlanView {
1493                context: None,
1494                default_namespace: None,
1495                services: vec![],
1496            },
1497            hash: "h".into(),
1498        };
1499        let runner = DefaultDeployRunner {
1500            k8s: k8s.clone(),
1501            promoter: None,
1502            confirm: None,
1503            cloudflare: Some(cf.clone()),
1504            oci_build: None,
1505        };
1506        let result = runner.run(&ctx, &plan).await.unwrap();
1507        assert!(result.ok, "{:?}", result.lines);
1508        assert_eq!(*k8s.apply_calls.lock().unwrap(), 0);
1509        assert_eq!(*k8s.rollout_calls.lock().unwrap(), 0);
1510        assert_eq!(cf.calls.lock().unwrap().as_slice(), ["athena-auth"]);
1511    }
1512
1513    #[tokio::test]
1514    async fn mixed_plan_runs_k8s_then_cf_without_mixing_adapters() {
1515        let k8s = Arc::new(RecordingK8s {
1516            apply_calls: Mutex::new(0),
1517            rollout_calls: Mutex::new(0),
1518        });
1519        let cf = Arc::new(RecordingCf::new());
1520        let (ctx, root) = base_ctx(true);
1521        let manifest = root.join("api.yaml");
1522        std::fs::write(&manifest, "apiVersion: v1\nkind: ConfigMap\n").unwrap();
1523        let plan = DeployPlan {
1524            target: DeployTarget::Group("edge".into()),
1525            env: "production".into(),
1526            project: "t".into(),
1527            project_version: "1".into(),
1528            git_sha: None,
1529            services: vec![
1530                ServicePlan {
1531                    name: "api".into(),
1532                    provider: "kubernetes".into(),
1533                    destination: Some("kubernetes".into()),
1534                    root_directory: None,
1535                    version: "1".into(),
1536                    image: None,
1537                    image_ref: None,
1538                    digest: None,
1539                    dockerfile: None,
1540                    build_context: None,
1541                    platforms: vec![],
1542                    worker_app: None,
1543                    rollout: None,
1544                    runtime_env: Default::default(),
1545                    container_port: None,
1546                    config_mounts: vec![],
1547                    expose: None,
1548                    deploy: ServiceDeployPlan {
1549                        namespace: Some("default".into()),
1550                        workload: None,
1551                        service: None,
1552                        health: vec![],
1553                        manifest_paths: vec![manifest.clone()],
1554                        crds_path: None,
1555                        install_path: None,
1556                        selector: None,
1557                        actions: vec![],
1558                    },
1559                },
1560                ServicePlan {
1561                    name: "auth".into(),
1562                    provider: "cloudflare-containers".into(),
1563                    destination: Some("cloudflare".into()),
1564                    root_directory: None,
1565                    version: "1".into(),
1566                    image: None,
1567                    image_ref: None,
1568                    digest: None,
1569                    dockerfile: None,
1570                    build_context: None,
1571                    platforms: vec![],
1572                    worker_app: Some("auth".into()),
1573                    rollout: None,
1574                    runtime_env: Default::default(),
1575                    container_port: None,
1576                    config_mounts: vec![],
1577                    expose: None,
1578                    deploy: ServiceDeployPlan {
1579                        namespace: None,
1580                        workload: None,
1581                        service: None,
1582                        health: vec![],
1583                        manifest_paths: vec![],
1584                        crds_path: None,
1585                        install_path: None,
1586                        selector: None,
1587                        actions: vec![],
1588                    },
1589                },
1590            ],
1591            order: vec!["api@kubernetes".into(), "auth@cloudflare".into()],
1592            oci_plan: OciPlan::default(),
1593            k8s_plan: K8sPlanView {
1594                context: Some("kind".into()),
1595                default_namespace: Some("default".into()),
1596                services: vec![K8sServicePlan {
1597                    service: "api".into(),
1598                    provider: "kubernetes".into(),
1599                    namespace: Some("default".into()),
1600                    workload: None,
1601                    manifest_paths: vec![manifest],
1602                    crds_path: None,
1603                    install_path: None,
1604                    selector: None,
1605                    health: vec![],
1606                }],
1607            },
1608            hash: "h".into(),
1609        };
1610        let runner = DefaultDeployRunner {
1611            k8s: k8s.clone(),
1612            promoter: None,
1613            confirm: None,
1614            cloudflare: Some(cf.clone()),
1615            oci_build: None,
1616        };
1617        let result = runner.run(&ctx, &plan).await.unwrap();
1618        assert!(result.ok, "{:?}", result.lines);
1619        assert_eq!(*k8s.apply_calls.lock().unwrap(), 1);
1620        assert_eq!(cf.calls.lock().unwrap().as_slice(), ["auth"]);
1621    }
1622
1623    #[test]
1624    fn format_deploy_failure_includes_rollout_reason() {
1625        let failed = vec!["athena".into()];
1626        let lines = vec![
1627            "run target=athena env=production dry_run=false services=1".into(),
1628            "[athena] rollout failed: kubectl failed: cannot reach cluster API at 127.0.0.1:6443 (connection refused)".into(),
1629        ];
1630        let msg = format_deploy_failure(&failed, &lines);
1631        assert!(msg.starts_with("failed: athena"), "got: {msg}");
1632        assert!(msg.contains("cannot reach cluster API"), "got: {msg}");
1633        assert!(msg.contains("connection refused"), "got: {msg}");
1634    }
1635
1636    #[test]
1637    fn format_deploy_failure_includes_notfound_with_ns() {
1638        let failed = vec!["athena".into()];
1639        let lines = vec![
1640            "[athena] rollout failed (ns=athena, workload=deploy/athena): kubectl failed: Error from server (NotFound): deployments.apps \"athena\" not found".into(),
1641        ];
1642        let msg = format_deploy_failure(&failed, &lines);
1643        assert!(msg.contains("NotFound") || msg.contains("not found"), "got: {msg}");
1644        assert!(msg.contains("ns=athena") || msg.contains("athena"), "got: {msg}");
1645    }
1646
1647    #[test]
1648    fn registry_auth_failure_detects_denied_and_unauthorized() {
1649        assert!(is_registry_auth_failure(
1650            "docker push ghcr.io/x/y:latest failed: error from registry: denied"
1651        ));
1652        assert!(is_registry_auth_failure("unauthorized: authentication required"));
1653        assert!(is_registry_auth_failure("no basic auth credentials"));
1654        assert!(!is_registry_auth_failure("Dockerfile not found: ./Dockerfile"));
1655        assert!(!is_registry_auth_failure("docker CLI not found"));
1656    }
1657
1658    struct FailPushThenLocalOci {
1659        calls: Mutex<Vec<bool>>,
1660    }
1661
1662    #[async_trait]
1663    impl OciBuildAdapter for FailPushThenLocalOci {
1664        async fn build_and_push(
1665            &self,
1666            request: OciBuildRequest,
1667        ) -> std::result::Result<crate::oci_build::OciBuildResult, String> {
1668            self.calls.lock().unwrap().push(request.local_image);
1669            if request.local_image {
1670                Ok(crate::oci_build::OciBuildResult {
1671                    lines: vec!["local ok".into()],
1672                    digest: None,
1673                    image_ref: Some(request.image_ref),
1674                    local_only: true,
1675                })
1676            } else {
1677                Err(
1678                    "docker push ghcr.io/xylex-group/athena-auth:latest failed: error from registry: denied\ndenied"
1679                        .into(),
1680                )
1681            }
1682        }
1683    }
1684
1685    #[tokio::test]
1686    async fn registry_denied_offers_local_fallback_when_confirm_yes() {
1687        let k8s = Arc::new(RecordingK8s {
1688            apply_calls: Mutex::new(0),
1689            rollout_calls: Mutex::new(0),
1690        });
1691        let oci = Arc::new(FailPushThenLocalOci {
1692            calls: Mutex::new(Vec::new()),
1693        });
1694        let (mut ctx, root) = base_ctx(true);
1695        ctx.flags.dry_run = false;
1696        ctx.flags.yes = false; // allow fallback prompt
1697        ctx.flags.skip_apply = true;
1698        ctx.flags.skip_rollout = true;
1699        ctx.flags.skip_health = true;
1700        ctx.flags.bootstrap_workload = false;
1701
1702        let dockerfile = root.join("Dockerfile");
1703        std::fs::write(&dockerfile, "FROM scratch\n").unwrap();
1704        let manifest = root.join("api.yaml");
1705        std::fs::write(&manifest, "apiVersion: v1\nkind: ConfigMap\n").unwrap();
1706
1707        let plan = DeployPlan {
1708            target: DeployTarget::Service("api".into()),
1709            env: "production".into(),
1710            project: "t".into(),
1711            project_version: "1".into(),
1712            git_sha: None,
1713            services: vec![ServicePlan {
1714                name: "api".into(),
1715                provider: "kubernetes".into(),
1716                destination: Some("kubernetes".into()),
1717                root_directory: None,
1718                version: "1".into(),
1719                image: None,
1720                image_ref: Some("ghcr.io/xylex-group/api:latest".into()),
1721                digest: None,
1722                dockerfile: Some("Dockerfile".into()),
1723                build_context: Some(".".into()),
1724                platforms: vec![],
1725                worker_app: None,
1726                rollout: None,
1727                runtime_env: Default::default(),
1728                container_port: None,
1729                config_mounts: vec![],
1730                expose: None,
1731                deploy: ServiceDeployPlan {
1732                    namespace: Some("default".into()),
1733                    workload: Some("deployment/api".into()),
1734                    service: None,
1735                    health: vec![],
1736                    manifest_paths: vec![manifest],
1737                    crds_path: None,
1738                    install_path: None,
1739                    selector: None,
1740                    actions: vec![],
1741                },
1742            }],
1743            order: vec!["api".into()],
1744            oci_plan: OciPlan::default(),
1745            k8s_plan: K8sPlanView {
1746                context: Some("kind".into()),
1747                default_namespace: Some("default".into()),
1748                services: vec![],
1749            },
1750            hash: "h".into(),
1751        };
1752
1753        let runner = DefaultDeployRunner {
1754            k8s,
1755            promoter: None,
1756            confirm: Some(Arc::new(|msg: String| {
1757                assert!(msg.contains("local Docker only"), "prompt: {msg}");
1758                true
1759            })),
1760            cloudflare: None,
1761            oci_build: Some(oci.clone()),
1762        };
1763        let result = runner.run(&ctx, &plan).await.unwrap();
1764        assert!(result.ok, "lines={:?}", result.lines);
1765        assert!(
1766            result
1767                .lines
1768                .iter()
1769                .any(|l| l.contains("opted into --local-image")),
1770            "lines={:?}",
1771            result.lines
1772        );
1773        assert_eq!(oci.calls.lock().unwrap().as_slice(), &[false, true]);
1774    }
1775
1776    struct CountingOci {
1777        calls: Mutex<usize>,
1778    }
1779
1780    #[async_trait]
1781    impl OciBuildAdapter for CountingOci {
1782        async fn build_and_push(
1783            &self,
1784            _request: OciBuildRequest,
1785        ) -> std::result::Result<crate::oci_build::OciBuildResult, String> {
1786            *self.calls.lock().unwrap() += 1;
1787            Ok(crate::oci_build::OciBuildResult {
1788                lines: vec!["oci-ok".into()],
1789                digest: None,
1790                image_ref: Some("ghcr.io/x/app:latest".into()),
1791                local_only: false,
1792            })
1793        }
1794    }
1795
1796    #[tokio::test]
1797    async fn cloudflare_only_with_dockerfile_skips_oci_build() {
1798        let k8s = Arc::new(RecordingK8s {
1799            apply_calls: Mutex::new(0),
1800            rollout_calls: Mutex::new(0),
1801        });
1802        let oci = Arc::new(CountingOci {
1803            calls: Mutex::new(0),
1804        });
1805        let cf = Arc::new(RecordingCf::new());
1806        let (mut ctx, _root) = base_ctx(true);
1807        ctx.flags.dry_run = false;
1808        ctx.flags.yes = true;
1809        ctx.flags.skip_health = true;
1810
1811        let plan = DeployPlan {
1812            target: DeployTarget::Service("next-heroui-example".into()),
1813            env: "production".into(),
1814            project: "t".into(),
1815            project_version: "1".into(),
1816            git_sha: None,
1817            services: vec![ServicePlan {
1818                name: "next-heroui-example".into(),
1819                provider: "cloudflare-worker".into(),
1820                destination: Some("cloudflare".into()),
1821                root_directory: None,
1822                version: "4.0.0".into(),
1823                image: None,
1824                image_ref: Some("ghcr.io/xylex-group/next-heroui-example:latest".into()),
1825                digest: None,
1826                dockerfile: Some("Dockerfile".into()),
1827                build_context: Some(".".into()),
1828                platforms: vec![],
1829                worker_app: Some("next-heroui-example".into()),
1830                rollout: None,
1831                runtime_env: Default::default(),
1832                container_port: None,
1833                config_mounts: vec![],
1834                expose: None,
1835                deploy: ServiceDeployPlan {
1836                    namespace: Some("default".into()),
1837                    workload: None,
1838                    service: None,
1839                    health: vec![],
1840                    manifest_paths: vec![],
1841                    crds_path: None,
1842                    install_path: None,
1843                    selector: None,
1844                    actions: vec![],
1845                },
1846            }],
1847            order: vec!["next-heroui-example@cloudflare".into()],
1848            oci_plan: OciPlan::default(),
1849            k8s_plan: K8sPlanView {
1850                context: None,
1851                default_namespace: None,
1852                services: vec![],
1853            },
1854            hash: "h".into(),
1855        };
1856
1857        let runner = DefaultDeployRunner {
1858            k8s: k8s.clone(),
1859            promoter: None,
1860            confirm: None,
1861            cloudflare: Some(cf.clone()),
1862            oci_build: Some(oci.clone()),
1863        };
1864        let result = runner.run(&ctx, &plan).await.unwrap();
1865        assert!(result.ok, "lines={:?}", result.lines);
1866        assert_eq!(
1867            *oci.calls.lock().unwrap(),
1868            0,
1869            "CF-only must not call GHCR/docker OCI adapter; lines={:?}",
1870            result.lines
1871        );
1872        assert!(
1873            result
1874                .lines
1875                .iter()
1876                .any(|l| l.contains("Cloudflare owns image build")),
1877            "lines={:?}",
1878            result.lines
1879        );
1880        assert_eq!(cf.calls.lock().unwrap().as_slice(), ["next-heroui-example"]);
1881        // Default policy: allow unchanged container image (config-only deploys).
1882        assert_eq!(
1883            cf.allow_unchanged_flags.lock().unwrap().as_slice(),
1884            &[true],
1885            "CF deploy should allow_unchanged by default"
1886        );
1887    }
1888
1889    #[tokio::test]
1890    async fn require_new_container_image_disables_allow_unchanged() {
1891        let k8s = Arc::new(RecordingK8s {
1892            apply_calls: Mutex::new(0),
1893            rollout_calls: Mutex::new(0),
1894        });
1895        let cf = Arc::new(RecordingCf::new());
1896        let (mut ctx, _root) = base_ctx(true);
1897        ctx.flags.dry_run = false;
1898        ctx.flags.yes = true;
1899        ctx.flags.skip_health = true;
1900        ctx.flags.require_new_container_image = true;
1901
1902        let plan = DeployPlan {
1903            target: DeployTarget::Service("athena-auth".into()),
1904            env: "production".into(),
1905            project: "t".into(),
1906            project_version: "1".into(),
1907            git_sha: None,
1908            services: vec![ServicePlan {
1909                name: "athena-auth".into(),
1910                provider: "cloudflare-containers".into(),
1911                destination: Some("cloudflare".into()),
1912                root_directory: None,
1913                version: "1".into(),
1914                image: None,
1915                image_ref: None,
1916                digest: None,
1917                dockerfile: None,
1918                build_context: None,
1919                platforms: vec![],
1920                worker_app: Some("athena-auth".into()),
1921                rollout: Some("immediate".into()),
1922                runtime_env: Default::default(),
1923                container_port: None,
1924                config_mounts: vec![],
1925                expose: None,
1926                deploy: ServiceDeployPlan {
1927                    namespace: None,
1928                    workload: None,
1929                    service: None,
1930                    health: vec![],
1931                    manifest_paths: vec![],
1932                    crds_path: None,
1933                    install_path: None,
1934                    selector: None,
1935                    actions: vec![],
1936                },
1937            }],
1938            order: vec!["athena-auth@cloudflare".into()],
1939            oci_plan: OciPlan::default(),
1940            k8s_plan: K8sPlanView {
1941                context: None,
1942                default_namespace: None,
1943                services: vec![],
1944            },
1945            hash: "h".into(),
1946        };
1947        let runner = DefaultDeployRunner {
1948            k8s: k8s.clone(),
1949            promoter: None,
1950            confirm: None,
1951            cloudflare: Some(cf.clone()),
1952            oci_build: None,
1953        };
1954        let result = runner.run(&ctx, &plan).await.unwrap();
1955        assert!(result.ok, "{:?}", result.lines);
1956        assert_eq!(
1957            cf.allow_unchanged_flags.lock().unwrap().as_slice(),
1958            &[false],
1959            "require_new_container_image must set allow_unchanged=false; lines={:?}",
1960            result.lines
1961        );
1962        assert_eq!(*k8s.apply_calls.lock().unwrap(), 0);
1963    }
1964
1965    #[tokio::test]
1966    async fn registry_denied_aborts_when_confirm_no() {
1967        let k8s = Arc::new(RecordingK8s {
1968            apply_calls: Mutex::new(0),
1969            rollout_calls: Mutex::new(0),
1970        });
1971        let oci = Arc::new(FailPushThenLocalOci {
1972            calls: Mutex::new(Vec::new()),
1973        });
1974        let (mut ctx, root) = base_ctx(true);
1975        ctx.flags.dry_run = false;
1976        ctx.flags.yes = false;
1977        ctx.flags.skip_apply = true;
1978
1979        std::fs::write(root.join("Dockerfile"), "FROM scratch\n").unwrap();
1980        let manifest = root.join("api.yaml");
1981        std::fs::write(&manifest, "apiVersion: v1\nkind: ConfigMap\n").unwrap();
1982
1983        let plan = DeployPlan {
1984            target: DeployTarget::Service("api".into()),
1985            env: "production".into(),
1986            project: "t".into(),
1987            project_version: "1".into(),
1988            git_sha: None,
1989            services: vec![ServicePlan {
1990                name: "api".into(),
1991                provider: "kubernetes".into(),
1992                destination: Some("kubernetes".into()),
1993                root_directory: None,
1994                version: "1".into(),
1995                image: None,
1996                image_ref: Some("ghcr.io/xylex-group/api:latest".into()),
1997                digest: None,
1998                dockerfile: Some("Dockerfile".into()),
1999                build_context: Some(".".into()),
2000                platforms: vec![],
2001                worker_app: None,
2002                rollout: None,
2003                runtime_env: Default::default(),
2004                container_port: None,
2005                config_mounts: vec![],
2006                expose: None,
2007                deploy: ServiceDeployPlan {
2008                    namespace: Some("default".into()),
2009                    workload: Some("deployment/api".into()),
2010                    service: None,
2011                    health: vec![],
2012                    manifest_paths: vec![manifest],
2013                    crds_path: None,
2014                    install_path: None,
2015                    selector: None,
2016                    actions: vec![],
2017                },
2018            }],
2019            order: vec!["api".into()],
2020            oci_plan: OciPlan::default(),
2021            k8s_plan: K8sPlanView {
2022                context: Some("kind".into()),
2023                default_namespace: Some("default".into()),
2024                services: vec![],
2025            },
2026            hash: "h".into(),
2027        };
2028
2029        let runner = DefaultDeployRunner {
2030            k8s,
2031            promoter: None,
2032            confirm: Some(Arc::new(|_msg: String| false)),
2033            cloudflare: None,
2034            oci_build: Some(oci.clone()),
2035        };
2036        let result = runner.run(&ctx, &plan).await.unwrap();
2037        assert!(!result.ok);
2038        assert!(
2039            result.failed_services.iter().any(|s| s == "api" || s == "api@kubernetes"),
2040            "failed={:?}",
2041            result.failed_services
2042        );
2043        assert_eq!(oci.calls.lock().unwrap().as_slice(), &[false]);
2044        assert!(
2045            result
2046                .lines
2047                .iter()
2048                .any(|l| l.contains("declined local-image fallback")),
2049            "lines={:?}",
2050            result.lines
2051        );
2052    }
2053
2054    #[tokio::test]
2055    async fn dual_dest_oci_fail_still_runs_cloudflare() {
2056        let k8s = Arc::new(RecordingK8s {
2057            apply_calls: Mutex::new(0),
2058            rollout_calls: Mutex::new(0),
2059        });
2060        let oci = Arc::new(FailPushThenLocalOci {
2061            calls: Mutex::new(Vec::new()),
2062        });
2063        let cf = Arc::new(RecordingCf::new());
2064        let (mut ctx, root) = base_ctx(true);
2065        ctx.flags.dry_run = false;
2066        ctx.flags.yes = true; // no local fallback prompt
2067        ctx.flags.skip_health = true;
2068        ctx.flags.bootstrap_workload = false;
2069
2070        std::fs::write(root.join("Dockerfile"), "FROM scratch\n").unwrap();
2071        let manifest = root.join("api.yaml");
2072        std::fs::write(&manifest, "apiVersion: v1\nkind: ConfigMap\n").unwrap();
2073
2074        let plan = DeployPlan {
2075            target: DeployTarget::Service("athena-auth".into()),
2076            env: "production".into(),
2077            project: "t".into(),
2078            project_version: "1".into(),
2079            git_sha: None,
2080            services: vec![
2081                ServicePlan {
2082                    name: "athena-auth".into(),
2083                    provider: "kubernetes".into(),
2084                    destination: Some("kubernetes".into()),
2085                    root_directory: None,
2086                    version: "1".into(),
2087                    image: None,
2088                    image_ref: Some("ghcr.io/xylex-group/athena-auth:latest".into()),
2089                    digest: None,
2090                    dockerfile: Some("Dockerfile".into()),
2091                    build_context: Some(".".into()),
2092                    platforms: vec![],
2093                    worker_app: None,
2094                    rollout: None,
2095                    runtime_env: Default::default(),
2096                    container_port: None,
2097                    config_mounts: vec![],
2098                    expose: None,
2099                    deploy: ServiceDeployPlan {
2100                        namespace: Some("athena".into()),
2101                        workload: Some("deployment/athena-auth".into()),
2102                        service: None,
2103                        health: vec![],
2104                        manifest_paths: vec![manifest],
2105                        crds_path: None,
2106                        install_path: None,
2107                        selector: None,
2108                        actions: vec![],
2109                    },
2110                },
2111                ServicePlan {
2112                    name: "athena-auth".into(),
2113                    provider: "cloudflare-containers".into(),
2114                    destination: Some("cloudflare".into()),
2115                    root_directory: None,
2116                    version: "1".into(),
2117                    image: None,
2118                    image_ref: Some("ghcr.io/xylex-group/athena-auth:latest".into()),
2119                    digest: None,
2120                    dockerfile: Some("Dockerfile".into()),
2121                    build_context: Some(".".into()),
2122                    platforms: vec![],
2123                    worker_app: Some("athena-auth".into()),
2124                    rollout: Some("immediate".into()),
2125                    runtime_env: Default::default(),
2126                    container_port: None,
2127                    config_mounts: vec![],
2128                    expose: None,
2129                    deploy: ServiceDeployPlan {
2130                        namespace: None,
2131                        workload: None,
2132                        service: None,
2133                        health: vec![],
2134                        manifest_paths: vec![],
2135                        crds_path: None,
2136                        install_path: None,
2137                        selector: None,
2138                        actions: vec![],
2139                    },
2140                },
2141            ],
2142            order: vec![
2143                "athena-auth@kubernetes".into(),
2144                "athena-auth@cloudflare".into(),
2145            ],
2146            oci_plan: OciPlan::default(),
2147            k8s_plan: K8sPlanView {
2148                context: Some("kind".into()),
2149                default_namespace: Some("athena".into()),
2150                services: vec![],
2151            },
2152            hash: "h".into(),
2153        };
2154
2155        let runner = DefaultDeployRunner {
2156            k8s: k8s.clone(),
2157            promoter: None,
2158            confirm: None,
2159            cloudflare: Some(cf.clone()),
2160            oci_build: Some(oci.clone()),
2161        };
2162        let result = runner.run(&ctx, &plan).await.unwrap();
2163        // Overall not ok (k8s OCI failed) but CF still ran.
2164        assert!(!result.ok, "lines={:?}", result.lines);
2165        assert!(
2166            result
2167                .failed_services
2168                .iter()
2169                .any(|s| s.contains("kubernetes") || s.contains("athena-auth")),
2170            "failed={:?}",
2171            result.failed_services
2172        );
2173        assert_eq!(oci.calls.lock().unwrap().as_slice(), &[false]);
2174        assert_eq!(cf.calls.lock().unwrap().as_slice(), ["athena-auth"]);
2175        assert_eq!(*k8s.apply_calls.lock().unwrap(), 0);
2176        assert!(
2177            result
2178                .lines
2179                .iter()
2180                .any(|l| l.contains("continuing Cloudflare")),
2181            "lines={:?}",
2182            result.lines
2183        );
2184    }
2185}