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
24fn 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 #[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 pub confirm: Option<Arc<dyn Fn(String) -> bool + Send + Sync>>,
61 pub cloudflare: Option<Arc<dyn CloudflareDeployAdapter>>,
63 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 if matches!(ctx.mode, DeployMode::Promote) {
118 return self.promote(ctx, plan).await;
119 }
120
121 let mut k8s_work: Vec<ServicePlan> =
123 k8s_services.iter().map(|s| (*s).clone()).collect();
124
125 let mut local_image = ctx.flags.local_image;
127
128 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 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 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 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 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 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 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 let container_port = svc.container_port.unwrap_or(8080);
277 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 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 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 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 let prefer_local_cf = !ctx.flags.dry_run
476 && (local_image
477 || local_build_only
478 || docker_available_for_local_cf_build());
479 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 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 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
589async 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 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 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
689fn 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 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 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 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 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 let mut build_targets: Vec<usize> = Vec::new(); 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 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 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 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 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 #[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
1120fn 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
1132fn 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 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 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, 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; 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 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; 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 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}