1use std::collections::BTreeSet;
10use std::io::IsTerminal;
11
12use colored::Colorize;
13use dialoguer::Input;
14use xbp_deploy::DeployMode;
15
16use crate::cli::interactive::{
17 confirm, pad_label, print_picker_header, searchable_multi_select, searchable_select_required,
18 select_one, xbp_theme,
19};
20use crate::strategies::XbpConfig;
21use xbp_deploy::{
22 filter_deploy_plan, retain_services_in_plan, service_plan_labels, unique_service_names,
23 DeployPlan,
24};
25use std::collections::HashSet;
26
27#[derive(Debug, Clone)]
28pub struct ResolvedDeployInvocation {
29 pub target: String,
30 pub env: Option<String>,
31 pub mode: DeployMode,
32 pub promote_tag: Option<String>,
34 pub local_image: bool,
36 pub skip_build: bool,
37 pub dry_run: bool,
38 pub skip_doctor: bool,
39 pub skip_health: bool,
40 pub skip_rollout: bool,
41 pub skip_apply: bool,
42 pub skip_oci: bool,
43 pub skip_expose: bool,
45}
46
47#[derive(Debug, Clone, Default)]
49pub struct DeployInvocationOptions {
50 pub promote_tag: Option<String>,
51 pub local_image: bool,
52 pub push_image: bool,
53 pub skip_build: bool,
54 pub dry_run: bool,
55 pub skip_doctor: bool,
56 pub skip_health: bool,
57 pub skip_rollout: bool,
58 pub skip_apply: bool,
59 pub skip_oci: bool,
60 pub skip_expose: bool,
61 pub kube_context: Option<String>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum ImageBuildStrategy {
68 LocalLoad,
70 PushRegistry,
72 SkipBuild,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum TargetDestProfile {
79 CloudflareOnly,
80 KubernetesOnly,
81 Mixed,
82 Unknown,
83}
84
85impl TargetDestProfile {
86 fn has_cloudflare(self) -> bool {
87 matches!(self, Self::CloudflareOnly | Self::Mixed)
88 }
89
90 fn has_kubernetes(self) -> bool {
91 matches!(self, Self::KubernetesOnly | Self::Mixed)
92 }
93
94 fn is_cloudflare_only(self) -> bool {
95 matches!(self, Self::CloudflareOnly)
96 }
97}
98
99pub const DEPLOY_MODE_POSITIONALS: &[&str] = &["plan", "run", "verify", "status", "history"];
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct PeeledDeployArgs {
105 pub target: Option<String>,
107 pub mode: DeployMode,
108 pub mode_explicit: bool,
110}
111
112pub fn peel_deploy_mode_positional(
120 target: Option<String>,
121 mode: DeployMode,
122 mode_explicit: bool,
123) -> Result<PeeledDeployArgs, String> {
124 let Some(raw) = target
125 .as_deref()
126 .map(str::trim)
127 .filter(|s| !s.is_empty())
128 .map(str::to_string)
129 else {
130 return Ok(PeeledDeployArgs {
131 target: None,
132 mode,
133 mode_explicit,
134 });
135 };
136
137 let lower = raw.to_ascii_lowercase();
138 let positional_mode = match lower.as_str() {
139 "plan" => Some(DeployMode::Plan),
140 "run" => Some(DeployMode::Run),
141 "verify" => Some(DeployMode::Verify),
142 "status" => Some(DeployMode::Status),
143 "history" => Some(DeployMode::History),
144 _ => None,
145 };
146
147 let Some(from_positional) = positional_mode else {
148 return Ok(PeeledDeployArgs {
149 target: Some(raw),
150 mode,
151 mode_explicit,
152 });
153 };
154
155 if mode_explicit {
156 let flag_label = mode_flag_label(mode);
157 let pos_label = mode_flag_label(from_positional);
158 if std::mem::discriminant(&mode) != std::mem::discriminant(&from_positional) {
159 return Err(format!(
160 "`{raw}` is a deploy *mode*, not a service name — and it conflicts with `{flag_label}`.\n\n\
161 Plan only (no mutations):\n xbp deploy --plan\n xbp deploy <service> --plan\n\n\
162 Apply after reviewing the plan:\n xbp deploy --run\n xbp deploy <service> --run\n\n\
163 Do not combine `{pos_label}` with `{flag_label}`."
164 ));
165 }
166 return Ok(PeeledDeployArgs {
168 target: None,
169 mode,
170 mode_explicit: true,
171 });
172 }
173
174 Ok(PeeledDeployArgs {
175 target: None,
176 mode: from_positional,
177 mode_explicit: true,
178 })
179}
180
181fn mode_flag_label(mode: DeployMode) -> &'static str {
182 match mode {
183 DeployMode::Plan => "--plan",
184 DeployMode::Run => "--run",
185 DeployMode::Verify => "--verify",
186 DeployMode::Status => "--status",
187 DeployMode::History => "--history",
188 DeployMode::Promote => "--promote",
189 }
190}
191
192pub fn resolve_deploy_invocation(
194 config: &XbpConfig,
195 target: Option<String>,
196 env: Option<String>,
197 mode: DeployMode,
198 mode_explicit: bool,
199 yes: bool,
200 force_non_interactive: bool,
201 options: DeployInvocationOptions,
202) -> Result<ResolvedDeployInvocation, String> {
203 let interactive = is_interactive() && !force_non_interactive && !yes;
204 let target_raw = target
205 .as_deref()
206 .map(str::trim)
207 .filter(|s| !s.is_empty())
208 .map(str::to_string);
209
210 let choices = deploy_target_choices(config);
211 if choices.is_empty() {
212 return Err(
213 "No services[] defined in project config. Run `xbp init` or `xbp version discover` first."
214 .into(),
215 );
216 }
217
218 let resolved_target = match target_raw {
219 Some(t) => t,
220 None if interactive => prompt_target(config, &choices)?,
221 None if choices.len() == 1 => {
222 let only = choices[0].value.clone();
223 println!(
224 "{} using sole deploy target `{}`",
225 "deploy".bright_cyan().bold(),
226 only.bright_white()
227 );
228 only
229 }
230 None => {
231 return Err(format!(
232 "deploy target is required.\n\nAvailable targets:\n{}\n\n\
233 Examples:\n xbp deploy {}\n xbp deploy --plan\n xbp deploy all --env production --plan\n\n\
234 In a TTY, omit <TARGET> to pick interactively.",
235 format_target_help(&choices),
236 choices[0].value
237 ));
238 }
239 };
240
241 let resolved_mode = if mode_explicit || !interactive {
242 mode
243 } else {
244 prompt_mode(mode)?
245 };
246
247 let resolved_env = match env
248 .as_deref()
249 .map(str::trim)
250 .filter(|s| !s.is_empty())
251 .map(str::to_string)
252 {
253 Some(e) => Some(e),
254 None if interactive => prompt_env(config, &resolved_target)?,
255 None => None, };
257
258 let promote_tag = if matches!(resolved_mode, DeployMode::Promote) {
259 match options
260 .promote_tag
261 .as_deref()
262 .map(str::trim)
263 .filter(|s| !s.is_empty())
264 .map(str::to_string)
265 {
266 Some(tag) => Some(tag),
267 None if interactive => Some(prompt_promote_tag(config, &resolved_target, &resolved_env)?),
268 None if yes => {
269 return Err(
270 "promote mode requires a tag. Pass `--promote <TAG>` (e.g. `--promote stable`) or run interactively."
271 .into(),
272 );
273 }
274 None => {
275 return Err(
276 "promote mode requires `--promote <TAG>`.\n\nExample:\n xbp deploy athena --promote stable --dry-run\n\nIn a TTY, choose promote mode and enter the tag when prompted."
277 .into(),
278 );
279 }
280 }
281 } else {
282 None
283 };
284
285 let dest_profile = target_dest_profile(config, &resolved_target);
288 let needs_image_options = matches!(
289 resolved_mode,
290 DeployMode::Plan | DeployMode::Run | DeployMode::Promote | DeployMode::Verify
291 );
292 let (
293 local_image,
294 skip_build,
295 dry_run,
296 skip_doctor,
297 skip_health,
298 skip_rollout,
299 skip_apply,
300 skip_oci,
301 skip_expose,
302 ) = if interactive && needs_image_options {
303 prompt_deploy_build_and_apply_options(
304 config,
305 &resolved_target,
306 &resolved_mode,
307 &options,
308 dest_profile,
309 )?
310 } else {
311 resolve_image_options_noninteractive(&options, dest_profile)
312 };
313
314 if interactive {
315 match resolved_mode {
316 DeployMode::Plan => {
317 let image_note = image_strategy_note(dest_profile, local_image, skip_build);
318 println!(
319 " {} plan only (no mutations) for `{}`{} · {} — re-run with {} to apply",
320 "·".bright_cyan(),
321 resolved_target.bright_white(),
322 resolved_env
323 .as_deref()
324 .map(|e| format!(" (env: {e})"))
325 .unwrap_or_default()
326 .bright_black(),
327 image_note.bright_yellow(),
328 "--run".bright_green().bold()
329 );
330 }
331 DeployMode::Run | DeployMode::Promote => {
332 let label = match resolved_mode {
333 DeployMode::Promote => {
334 format!(
335 "promote → :{}",
336 promote_tag.as_deref().unwrap_or("?")
337 )
338 }
339 _ => {
340 if dest_profile.is_cloudflare_only() {
341 "run (Cloudflare Worker deploy)".to_string()
342 } else {
343 "run (apply)".to_string()
344 }
345 }
346 };
347 let image_note = image_strategy_note(dest_profile, local_image, skip_build);
348 let mut extras = Vec::new();
349 if dry_run {
350 extras.push("dry-run");
351 }
352 if skip_doctor {
353 extras.push("skip-doctor");
354 }
355 if skip_health {
356 extras.push("skip-health");
357 }
358 if skip_rollout {
359 extras.push("skip-rollout");
360 }
361 if skip_expose && dest_profile.has_kubernetes() {
362 extras.push("skip-expose");
363 }
364 if skip_apply {
365 if dest_profile.is_cloudflare_only() && local_image {
366 extras.push("local-build-only (no wrangler deploy)");
367 } else {
368 extras.push("skip-apply");
369 }
370 }
371 let extras_s = if extras.is_empty() {
372 String::new()
373 } else {
374 format!(" · {}", extras.join(", "))
375 };
376 let ok = confirm(
377 &format!(
378 "Apply deploy `{label}` for target `{}`{} · {}{}?",
379 resolved_target,
380 resolved_env
381 .as_deref()
382 .map(|e| format!(" (env: {e})"))
383 .unwrap_or_default(),
384 image_note,
385 extras_s
386 ),
387 matches!(resolved_mode, DeployMode::Promote),
388 )?;
389 if !ok {
390 return Err("deploy aborted by user".into());
391 }
392 }
393 _ => {}
394 }
395 } else if local_image && !skip_build && !options.skip_oci && dest_profile.has_kubernetes() {
396 if !force_non_interactive {
398 println!(
399 "{} OCI images: local Docker load only (no registry push). Use {} or the interactive picker to push.",
400 "i".bright_blue().bold(),
401 "--push-image".bright_yellow()
402 );
403 }
404 }
405
406 Ok(ResolvedDeployInvocation {
407 target: resolved_target,
408 env: resolved_env,
409 mode: resolved_mode,
410 promote_tag,
411 local_image,
412 skip_build,
413 dry_run,
414 skip_doctor,
415 skip_health,
416 skip_rollout,
417 skip_apply,
418 skip_oci,
419 skip_expose,
420 })
421}
422
423type DeployOptionBundle = (bool, bool, bool, bool, bool, bool, bool, bool, bool);
424
425fn image_strategy_note(profile: TargetDestProfile, local_image: bool, skip_build: bool) -> &'static str {
426 if skip_build {
427 return if profile.is_cloudflare_only() {
428 "reuse existing CF image / config-only"
429 } else {
430 "skip image build"
431 };
432 }
433 if profile.is_cloudflare_only() {
434 return if local_image {
435 "local Docker container pre-build + Wrangler"
436 } else {
437 "OpenNext / Wrangler build + deploy"
438 };
439 }
440 if local_image {
441 "local Docker load"
442 } else {
443 "registry push"
444 }
445}
446
447fn resolve_image_options_noninteractive(
448 options: &DeployInvocationOptions,
449 profile: TargetDestProfile,
450) -> DeployOptionBundle {
451 let local_image = if options.push_image {
452 false
453 } else if options.local_image {
454 true
455 } else if profile.is_cloudflare_only() {
456 false
458 } else {
459 looks_like_local_kube_context(options.kube_context.as_deref())
460 };
461 (
462 local_image,
463 options.skip_build,
464 options.dry_run,
465 options.skip_doctor,
466 options.skip_health,
467 options.skip_rollout,
468 options.skip_apply,
469 options.skip_oci,
470 options.skip_expose,
471 )
472}
473
474fn prompt_deploy_build_and_apply_options(
476 config: &XbpConfig,
477 target: &str,
478 mode: &DeployMode,
479 options: &DeployInvocationOptions,
480 profile: TargetDestProfile,
481) -> Result<DeployOptionBundle, String> {
482 let has_dockerfile = target_has_dockerfile(config, target);
483 let local_default = profile.has_kubernetes()
485 && looks_like_local_kube_context(options.kube_context.as_deref());
486
487 let strategy = if options.skip_build {
489 ImageBuildStrategy::SkipBuild
490 } else if options.push_image {
491 ImageBuildStrategy::PushRegistry
492 } else if options.local_image {
493 ImageBuildStrategy::LocalLoad
494 } else if profile.is_cloudflare_only() {
495 if matches!(mode, DeployMode::History | DeployMode::Status) {
498 ImageBuildStrategy::SkipBuild
499 } else {
500 prompt_cloudflare_build_strategy(has_dockerfile)?
501 }
502 } else if !has_dockerfile {
503 ImageBuildStrategy::SkipBuild
504 } else if matches!(mode, DeployMode::History | DeployMode::Status) {
505 ImageBuildStrategy::SkipBuild
506 } else {
507 prompt_image_build_strategy(local_default, has_dockerfile, profile)?
508 };
509
510 let (local_image, skip_build) = match strategy {
511 ImageBuildStrategy::LocalLoad => (true, false),
512 ImageBuildStrategy::PushRegistry => (false, false),
513 ImageBuildStrategy::SkipBuild => (false, true),
514 };
515
516 if matches!(mode, DeployMode::Plan | DeployMode::Verify) {
518 return Ok((
519 local_image,
520 skip_build,
521 options.dry_run,
522 options.skip_doctor,
523 options.skip_health,
524 options.skip_rollout,
525 options.skip_apply,
526 options.skip_oci,
527 options.skip_expose,
528 ));
529 }
530
531 if matches!(mode, DeployMode::History | DeployMode::Status) {
532 return Ok(resolve_image_options_noninteractive(options, profile));
533 }
534
535 let apply = prompt_apply_options(options, profile)?;
537
538 Ok((
539 local_image,
540 skip_build,
541 apply.dry_run,
542 apply.skip_doctor,
543 apply.skip_health,
544 apply.skip_rollout,
545 apply.skip_apply,
546 apply.skip_oci,
547 apply.skip_expose,
548 ))
549}
550
551#[derive(Debug, Clone, Copy)]
552struct ApplyOptionToggles {
553 dry_run: bool,
554 skip_doctor: bool,
555 skip_health: bool,
556 skip_rollout: bool,
557 skip_apply: bool,
558 skip_oci: bool,
559 skip_expose: bool,
560}
561
562fn prompt_cloudflare_build_strategy(has_dockerfile: bool) -> Result<ImageBuildStrategy, String> {
564 print_picker_header(
565 "xbp deploy · Cloudflare build",
566 if has_dockerfile {
567 "Worker + Container: OpenNext/Wrangler deploys by default; optional local Docker pre-build"
568 } else {
569 "Cloudflare Worker: OpenNext install/build + wrangler deploy (no cluster / GHCR)"
570 },
571 );
572
573 let options = [
574 (
575 ImageBuildStrategy::PushRegistry, "full CF deploy · OpenNext/Wrangler build + deploy Worker (and containers if configured)",
577 ),
578 (
579 ImageBuildStrategy::LocalLoad,
580 "local container · docker build container image locally, then Wrangler deploy/push",
581 ),
582 (
583 ImageBuildStrategy::SkipBuild,
584 "config-only · skip local pre-build; allow unchanged container digest if present",
585 ),
586 ];
587 let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
588 let idx = select_one("Cloudflare build strategy", &labels, 0)?
590 .ok_or_else(|| "Selection cancelled".to_string())?;
591 Ok(options[idx].0)
592}
593
594fn prompt_image_build_strategy(
595 local_default: bool,
596 has_dockerfile: bool,
597 profile: TargetDestProfile,
598) -> Result<ImageBuildStrategy, String> {
599 let subtitle = if profile == TargetDestProfile::Mixed {
600 "Mixed k8s + Cloudflare target — OCI options apply to cluster half; CF still builds via Wrangler"
601 } else if local_default {
602 "Local kube context detected — default is load into Docker (no ghcr push)"
603 } else if has_dockerfile {
604 "Services declare oci.dockerfile — choose build + load or push to registry"
605 } else {
606 "No oci.dockerfile on selected services — skip build uses existing images"
607 };
608 print_picker_header("xbp deploy · OCI images", subtitle);
609
610 let options = [
611 (
612 ImageBuildStrategy::LocalLoad,
613 "local load · docker build into local engine (no registry push) ← docker-desktop / kind",
614 ),
615 (
616 ImageBuildStrategy::PushRegistry,
617 "registry push · docker build + push (needs docker login for ghcr/…)",
618 ),
619 (
620 ImageBuildStrategy::SkipBuild,
621 "skip build · use existing image ref (no docker build)",
622 ),
623 ];
624 let default = if !has_dockerfile {
625 2
626 } else if local_default {
627 0
628 } else {
629 1
630 };
631 let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
632 let idx = select_one("Image build strategy", &labels, default)?
633 .ok_or_else(|| "Selection cancelled".to_string())?;
634 Ok(options[idx].0)
635}
636
637fn prompt_apply_options(
638 seed: &DeployInvocationOptions,
639 profile: TargetDestProfile,
640) -> Result<ApplyOptionToggles, String> {
641 let header = if profile.is_cloudflare_only() {
642 "Space toggles · Enter confirms · leave all off for a normal Worker deploy"
643 } else {
644 "Space toggles · Enter confirms · leave all off for a normal apply"
645 };
646 print_picker_header("xbp deploy · apply options", header);
647
648 let skip_apply_label = if profile.is_cloudflare_only() {
650 "skip apply · skip Wrangler/Worker deploy (with local container strategy → build-only)"
651 } else if profile == TargetDestProfile::Mixed {
652 "skip apply · skip kubectl + Wrangler apply (still may build)"
653 } else {
654 "skip apply · skip kubectl apply (still may build/verify)"
655 };
656 let skip_doctor_label = if profile.is_cloudflare_only() {
657 "skip doctor · skip Cloudflare preflight (Wrangler/account)"
658 } else if profile.has_cloudflare() {
659 "skip doctor · skip cluster + Cloudflare preflight"
660 } else {
661 "skip doctor · skip cluster preflight"
662 };
663 let dry_run_label = if profile.is_cloudflare_only() {
664 "dry-run · print CF actions only (no Wrangler mutations)"
665 } else {
666 "dry-run · print actions only (no registry/cluster mutations)"
667 };
668
669 let mut items: Vec<(&str, bool)> = vec![
670 (dry_run_label, seed.dry_run),
671 (skip_doctor_label, seed.skip_doctor),
672 (
673 "skip health · skip HTTP health probes after rollout",
674 seed.skip_health,
675 ),
676 (
677 "skip rollout wait · do not wait for workload / container ready",
678 seed.skip_rollout,
679 ),
680 (skip_apply_label, seed.skip_apply),
681 ];
682 if profile.has_kubernetes() {
683 items.push((
684 "skip OCI resolve · skip registry digest lookups",
685 seed.skip_oci,
686 ));
687 items.push((
688 "skip expose · skip port-forward / hosts DNS after rollout",
689 seed.skip_expose,
690 ));
691 }
692
693 let labels: Vec<&str> = items.iter().map(|(l, _)| *l).collect();
694 let defaults: Vec<bool> = items.iter().map(|(_, d)| *d).collect();
695 let selected = searchable_multi_select("Apply toggles", &labels, &defaults)?;
696
697 let on = |i: usize| selected.contains(&i);
698 Ok(ApplyOptionToggles {
700 dry_run: on(0),
701 skip_doctor: on(1),
702 skip_health: on(2),
703 skip_rollout: on(3),
704 skip_apply: on(4),
705 skip_oci: profile.has_kubernetes() && on(5),
706 skip_expose: profile.has_kubernetes() && on(6),
707 })
708}
709
710fn target_dest_profile(config: &XbpConfig, target: &str) -> TargetDestProfile {
712 let names = services_for_target(config, target);
713 let services = config.services.as_deref().unwrap_or(&[]);
714 let selected: Vec<&crate::strategies::ServiceConfig> = if names.is_empty() {
715 services.iter().collect()
716 } else {
717 services
718 .iter()
719 .filter(|s| names.iter().any(|n| n == &s.name))
720 .collect()
721 };
722 if selected.is_empty() {
723 return TargetDestProfile::Unknown;
724 }
725
726 let mut any_cf = false;
727 let mut any_k8s = false;
728 for svc in selected {
729 let Some(deploy) = svc.deploy.as_ref() else {
730 continue;
731 };
732 if !deploy.destinations.is_empty() {
733 for (name, dest) in &deploy.destinations {
734 if dest.enabled == Some(false) {
735 continue;
736 }
737 let provider = dest
738 .provider
739 .as_deref()
740 .unwrap_or(name.as_str())
741 .to_ascii_lowercase();
742 let key = name.to_ascii_lowercase();
743 if provider.contains("cloudflare")
744 || provider.contains("worker")
745 || key.contains("cloudflare")
746 || key == "worker"
747 || key == "cf"
748 {
749 any_cf = true;
750 }
751 if provider.contains("kubernetes")
752 || provider.contains("k8s")
753 || key.contains("kubernetes")
754 || key == "k8s"
755 {
756 any_k8s = true;
757 }
758 }
759 } else if let Some(provider) = deploy.provider.as_deref() {
760 let p = provider.to_ascii_lowercase();
761 if p.contains("cloudflare") || p.contains("worker") {
762 any_cf = true;
763 }
764 if p.contains("kubernetes") || p.contains("k8s") {
765 any_k8s = true;
766 }
767 }
768 }
769
770 match (any_cf, any_k8s) {
771 (true, false) => TargetDestProfile::CloudflareOnly,
772 (false, true) => TargetDestProfile::KubernetesOnly,
773 (true, true) => TargetDestProfile::Mixed,
774 (false, false) => TargetDestProfile::Unknown,
775 }
776}
777
778fn target_has_dockerfile(config: &XbpConfig, target: &str) -> bool {
779 let names = services_for_target(config, target);
780 let services = config.services.as_deref().unwrap_or(&[]);
781 if names.is_empty() {
782 return services.iter().any(|s| {
784 s.oci
785 .as_ref()
786 .and_then(|o| o.dockerfile.as_ref())
787 .is_some_and(|d| !d.trim().is_empty())
788 });
789 }
790 names.iter().any(|name| {
791 services.iter().any(|s| {
792 s.name == *name
793 && s.oci
794 .as_ref()
795 .and_then(|o| o.dockerfile.as_ref())
796 .is_some_and(|d| !d.trim().is_empty())
797 })
798 })
799}
800
801pub fn looks_like_local_kube_context(explicit: Option<&str>) -> bool {
803 let ctx = explicit
804 .map(str::trim)
805 .filter(|s| !s.is_empty())
806 .map(str::to_string)
807 .or_else(current_kubectl_context_name);
808 let Some(ctx) = ctx else {
809 return false;
810 };
811 let c = ctx.to_ascii_lowercase();
812 c == "docker-desktop"
813 || c == "docker-for-desktop"
814 || c == "rancher-desktop"
815 || c == "minikube"
816 || c.starts_with("kind-")
817 || c.starts_with("k3d-")
818 || c.contains("desktop")
819}
820
821fn current_kubectl_context_name() -> Option<String> {
822 let output = std::process::Command::new("kubectl")
823 .args(["config", "current-context"])
824 .output()
825 .ok()?;
826 if !output.status.success() {
827 return None;
828 }
829 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
830 if s.is_empty() {
831 None
832 } else {
833 Some(s)
834 }
835}
836
837#[derive(Debug, Clone)]
838struct TargetChoice {
839 value: String,
841 label: String,
843}
844
845fn deploy_target_choices(config: &XbpConfig) -> Vec<TargetChoice> {
846 let mut out = Vec::new();
847 let all_services = config.services.as_deref().unwrap_or(&[]);
848 let services: Vec<_> = all_services
850 .iter()
851 .filter(|s| crate::commands::deploy_engine::setup::service_is_deploy_candidate(s))
852 .collect();
853
854 let name_w = services
856 .iter()
857 .map(|s| s.name.chars().count())
858 .chain(
859 config
860 .deploy
861 .as_ref()
862 .map(|d| d.groups.keys().map(|k| k.chars().count()).collect::<Vec<_>>())
863 .unwrap_or_default(),
864 )
865 .max()
866 .unwrap_or(8)
867 .clamp(8, 28);
868
869 if let Some(deploy) = config.deploy.as_ref() {
870 let known: std::collections::BTreeSet<&str> =
871 all_services.iter().map(|s| s.name.as_str()).collect();
872 let mut group_names: Vec<_> = deploy.groups.keys().cloned().collect();
873 group_names.sort();
874 for name in group_names {
875 let group = &deploy.groups[&name];
876 let members_src = if group.order.is_empty() {
877 &group.services
878 } else {
879 &group.order
880 };
881 let members: Vec<&str> = members_src
882 .iter()
883 .map(String::as_str)
884 .filter(|n| known.contains(*n))
885 .collect();
886 if members.is_empty() {
887 continue;
888 }
889 let members_preview = if members.len() > 8 {
890 format!(
891 "{}, … (+{})",
892 members[..8].join(", "),
893 members.len() - 8
894 )
895 } else {
896 members.join(", ")
897 };
898 let desc = group
899 .description
900 .as_deref()
901 .filter(|s| !s.is_empty())
902 .map(|d| format!(" · {d}"))
903 .unwrap_or_default();
904 out.push(TargetChoice {
905 value: name.clone(),
906 label: format!(
907 "{} {} [{} svc] [{}]{}",
908 pad_label("GROUP", 7),
909 pad_label(&name, name_w),
910 members.len(),
911 members_preview,
912 desc
913 ),
914 });
915 }
916 }
917
918 for svc in &services {
919 let provider = svc
920 .deploy
921 .as_ref()
922 .and_then(|d| d.provider.as_deref())
923 .filter(|s| !s.is_empty())
924 .unwrap_or(svc.target.as_str());
925 let envs: Vec<_> = svc
926 .deploy
927 .as_ref()
928 .map(|d| {
929 let mut keys: Vec<_> = d.envs.keys().cloned().collect();
930 keys.sort();
931 keys
932 })
933 .unwrap_or_default();
934 let env_hint = if envs.is_empty() {
935 "no deploy.envs".to_string()
936 } else {
937 format!("envs {}", envs.join(", "))
938 };
939 out.push(TargetChoice {
940 value: svc.name.clone(),
941 label: format!(
942 "{} {} {} · {} · :{}",
943 pad_label("SVC", 7),
944 pad_label(&svc.name, name_w),
945 pad_label(provider, 12),
946 env_hint,
947 svc.port
948 ),
949 });
950 }
951
952 if !services.is_empty() {
953 out.push(TargetChoice {
954 value: "all".into(),
955 label: format!(
956 "{} {} every deployable service with deploy.envs ({})",
957 pad_label("ALL", 7),
958 pad_label("all", name_w),
959 services.len()
960 ),
961 });
962 }
963
964 out
965}
966
967fn format_target_help(choices: &[TargetChoice]) -> String {
968 choices
969 .iter()
970 .map(|c| format!(" • {}", c.value))
971 .collect::<Vec<_>>()
972 .join("\n")
973}
974
975fn prompt_target(config: &XbpConfig, choices: &[TargetChoice]) -> Result<String, String> {
976 print_picker_header(
977 "xbp deploy · pick a target",
978 "GROUP / SVC / ALL · type to fuzzy-filter · Enter selects · Esc cancels",
979 );
980
981 let default_idx = default_target_index(config, choices);
982 let labels: Vec<&str> = choices.iter().map(|c| c.label.as_str()).collect();
983 let idx = searchable_select_required("Deploy target", &labels, default_idx)?;
984 Ok(choices[idx].value.clone())
985}
986
987fn default_target_index(config: &XbpConfig, choices: &[TargetChoice]) -> usize {
988 let services = config.services.as_deref().unwrap_or(&[]);
990 if services.len() == 1 {
991 if let Some(i) = choices.iter().position(|c| c.value == services[0].name) {
992 return i;
993 }
994 }
995 if let Some(i) = choices.iter().position(|c| c.label.starts_with("GROUP")) {
996 return i;
997 }
998 0
999}
1000
1001fn prompt_mode(current: DeployMode) -> Result<DeployMode, String> {
1002 print_picker_header(
1003 "xbp deploy · mode",
1004 "How should this deploy run? (plan is safe / no mutations)",
1005 );
1006 let modes = [
1007 (DeployMode::Run, "run · apply providers (k8s / worker / …)"),
1008 (DeployMode::Plan, "plan · print plan only (no mutations)"),
1009 (DeployMode::Verify, "verify · read-only rollout/health checks"),
1010 (DeployMode::Status, "status · status snapshot"),
1011 (DeployMode::History, "history · recent deploy records"),
1012 (
1013 DeployMode::Promote,
1014 "promote · retag OCI images by digest (prompts for tag)",
1015 ),
1016 ];
1017 let default = if matches!(current, DeployMode::Plan) {
1020 0 } else {
1022 modes
1023 .iter()
1024 .position(|(m, _)| std::mem::discriminant(m) == std::mem::discriminant(¤t))
1025 .unwrap_or(0)
1026 };
1027 let labels: Vec<&str> = modes.iter().map(|(_, l)| *l).collect();
1028 let idx = select_one("Deploy mode", &labels, default)?
1029 .ok_or_else(|| "Selection cancelled".to_string())?;
1030 Ok(modes[idx].0)
1031}
1032
1033fn prompt_promote_tag(
1035 config: &XbpConfig,
1036 target: &str,
1037 env: &Option<String>,
1038) -> Result<String, String> {
1039 println!();
1040 println!(
1041 "{} retag images for `{}` by digest (no rebuild)",
1042 "promote".bright_cyan().bold(),
1043 target.bright_white()
1044 );
1045 println!(
1046 "{}",
1047 "The source digest comes from the plan/lock; the tag is the new mutable pointer (e.g. stable, production)."
1048 .bright_black()
1049 );
1050
1051 let mut suggestions: Vec<String> = vec![
1052 "stable".into(),
1053 "latest".into(),
1054 "production".into(),
1055 "staging".into(),
1056 "canary".into(),
1057 ];
1058 if let Some(e) = env.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
1059 if !suggestions.iter().any(|s| s == e) {
1060 suggestions.insert(0, e.to_string());
1061 }
1062 }
1063 if let Some(default_env) = config
1064 .deploy
1065 .as_ref()
1066 .and_then(|d| d.default_env.as_deref())
1067 .map(str::trim)
1068 .filter(|s| !s.is_empty())
1069 {
1070 if !suggestions.iter().any(|s| s == default_env) {
1071 suggestions.insert(0, default_env.to_string());
1072 }
1073 }
1074 let mut seen = BTreeSet::new();
1076 suggestions.retain(|s| seen.insert(s.clone()));
1077
1078 let mut labels: Vec<String> = suggestions
1079 .iter()
1080 .map(|t| format!(":{t}"))
1081 .collect();
1082 labels.push("custom tag…".into());
1083
1084 let idx = select_one("Promotion tag", &labels, 0)?
1085 .ok_or_else(|| "Selection cancelled".to_string())?;
1086
1087 let tag = if idx < suggestions.len() {
1088 suggestions[idx].clone()
1089 } else {
1090 Input::with_theme(&xbp_theme())
1091 .with_prompt("Custom promotion tag (no leading colon)")
1092 .validate_with(|input: &String| -> Result<(), &str> {
1093 let t = input.trim();
1094 if t.is_empty() {
1095 return Err("tag must not be empty");
1096 }
1097 if t.contains(char::is_whitespace) {
1098 return Err("tag must not contain whitespace");
1099 }
1100 if t.starts_with(':') {
1101 return Err("omit the leading colon (enter stable, not :stable)");
1102 }
1103 if t.contains('/') || t.contains('@') {
1104 return Err("enter a bare tag, not a full image reference");
1105 }
1106 Ok(())
1107 })
1108 .interact_text()
1109 .map_err(|e| e.to_string())?
1110 .trim()
1111 .to_string()
1112 };
1113
1114 println!(
1115 "{} will retag plan digests → `…:{}`",
1116 "promote".bright_green().bold(),
1117 tag.bright_white()
1118 );
1119 Ok(tag)
1120}
1121
1122fn prompt_env(config: &XbpConfig, target: &str) -> Result<Option<String>, String> {
1123 let mut envs: BTreeSet<String> = BTreeSet::new();
1124 if let Some(default) = config
1125 .deploy
1126 .as_ref()
1127 .and_then(|d| d.default_env.clone())
1128 .filter(|s| !s.trim().is_empty())
1129 {
1130 envs.insert(default);
1131 }
1132 envs.insert("production".into());
1133 envs.insert("staging".into());
1134 envs.insert("development".into());
1135
1136 let services = config.services.as_deref().unwrap_or(&[]);
1137 let names = services_for_target(config, target);
1138 for name in names {
1139 if let Some(svc) = services.iter().find(|s| s.name == name) {
1140 if let Some(deploy) = &svc.deploy {
1141 for env in deploy.envs.keys() {
1142 envs.insert(env.clone());
1143 }
1144 }
1145 }
1146 }
1147
1148 let mut list: Vec<String> = envs.into_iter().collect();
1149 list.sort();
1150 if let Some(default) = config
1152 .deploy
1153 .as_ref()
1154 .and_then(|d| d.default_env.as_deref())
1155 {
1156 if let Some(i) = list.iter().position(|e| e == default) {
1157 let d = list.remove(i);
1158 list.insert(0, d);
1159 }
1160 } else if let Some(i) = list.iter().position(|e| e == "production") {
1161 let d = list.remove(i);
1162 list.insert(0, d);
1163 }
1164
1165 if list.len() == 1 {
1166 return Ok(Some(list[0].clone()));
1167 }
1168
1169 let labels: Vec<String> = list
1170 .iter()
1171 .map(|e| {
1172 if config
1173 .deploy
1174 .as_ref()
1175 .and_then(|d| d.default_env.as_deref())
1176 == Some(e.as_str())
1177 {
1178 format!("{e} (project default)")
1179 } else {
1180 e.clone()
1181 }
1182 })
1183 .collect();
1184 print_picker_header(
1185 "xbp deploy · environment",
1186 "Environment keys from deploy.default_env + service deploy.envs",
1187 );
1188 let idx = select_one("Deploy environment", &labels, 0)?
1189 .ok_or_else(|| "Selection cancelled".to_string())?;
1190 Ok(Some(list[idx].clone()))
1191}
1192
1193fn services_for_target(config: &XbpConfig, target: &str) -> Vec<String> {
1194 let raw = target.trim();
1195 let services = config.services.as_deref().unwrap_or(&[]);
1196 if raw.eq_ignore_ascii_case("all") {
1197 return services.iter().map(|s| s.name.clone()).collect();
1198 }
1199 if let Some(group) = config.deploy.as_ref().and_then(|d| d.groups.get(raw)) {
1200 return if group.order.is_empty() {
1201 group.services.clone()
1202 } else {
1203 group.order.clone()
1204 };
1205 }
1206 if services.iter().any(|s| s.name == raw) {
1207 return vec![raw.to_string()];
1208 }
1209 Vec::new()
1210}
1211
1212fn is_interactive() -> bool {
1213 std::io::stdin().is_terminal()
1214 && std::io::stdout().is_terminal()
1215 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
1216}
1217
1218pub fn maybe_edit_plan_services(
1223 plan: DeployPlan,
1224 yes: bool,
1225 force_non_interactive: bool,
1226) -> Result<DeployPlan, String> {
1227 let names = unique_service_names(&plan);
1228 if names.len() <= 1 {
1229 return Ok(plan);
1230 }
1231 let interactive = is_interactive() && !force_non_interactive && !yes;
1232 if !interactive {
1233 return Ok(plan);
1234 }
1235
1236 print_picker_header(
1237 "xbp deploy · plan services",
1238 &format!(
1239 "{} services from target `{}` · adjust before apply",
1240 names.len(),
1241 plan.target.label()
1242 ),
1243 );
1244
1245 let actions = [
1246 format!("Continue with all {} services", names.len()),
1247 "Edit services…".to_string(),
1248 "Cancel deploy".to_string(),
1249 ];
1250 let default = if names.len() > 3 { 1 } else { 0 };
1252 let idx = select_one("Plan services", &actions, default)?
1253 .ok_or_else(|| "Selection cancelled".to_string())?;
1254
1255 match idx {
1256 0 => Ok(plan),
1257 2 => Err("deploy cancelled".into()),
1258 _ => {
1259 let labeled = service_plan_labels(&plan);
1260 let labels: Vec<&str> = labeled.iter().map(|(_, l)| l.as_str()).collect();
1261 let defaults = vec![true; labeled.len()];
1262 let selected = searchable_multi_select(
1263 "Services to include in this deploy",
1264 &labels,
1265 &defaults,
1266 )?;
1267 if selected.is_empty() {
1268 return Err("deploy cancelled: no services selected".into());
1269 }
1270 let keep: HashSet<String> = selected
1271 .into_iter()
1272 .filter_map(|i| labeled.get(i).map(|(n, _)| n.clone()))
1273 .collect();
1274 let filtered = retain_services_in_plan(plan, &keep).map_err(|e| e.to_string())?;
1275 println!(
1276 "{} plan now has {} service(s): {}",
1277 "deploy".bright_cyan().bold(),
1278 unique_service_names(&filtered).len(),
1279 unique_service_names(&filtered).join(", ")
1280 );
1281 Ok(filtered)
1282 }
1283 }
1284}
1285
1286pub fn apply_cli_service_filters(
1288 plan: DeployPlan,
1289 only: &[String],
1290 exclude: &[String],
1291) -> Result<DeployPlan, String> {
1292 filter_deploy_plan(plan, only, exclude).map_err(|e| e.to_string())
1293}
1294
1295pub fn maybe_confirm_multi_service_apply(
1297 plan: &DeployPlan,
1298 mode: DeployMode,
1299 yes: bool,
1300 force_non_interactive: bool,
1301) -> Result<(), String> {
1302 if !matches!(mode, DeployMode::Run | DeployMode::Promote) {
1303 return Ok(());
1304 }
1305 if yes || force_non_interactive || !is_interactive() {
1306 return Ok(());
1307 }
1308 let n = unique_service_names(plan).len();
1309 if n <= 1 {
1310 return Ok(());
1311 }
1312 let ok = confirm(
1313 &format!("Apply deploy for {} service(s)?", n),
1314 false,
1315 )?;
1316 if !ok {
1317 return Err("deploy cancelled".into());
1318 }
1319 Ok(())
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::*;
1325
1326 fn sample_config() -> XbpConfig {
1327 serde_json::from_value(serde_json::json!({
1328 "project_name": "demo",
1329 "version": "1.0.0",
1330 "port": 8080,
1331 "build_dir": ".",
1332 "services": [
1333 {"name": "api", "target": "rust", "branch": "main", "port": 8080},
1334 {"name": "web", "target": "nextjs", "branch": "main", "port": 3000}
1335 ],
1336 "deploy": {
1337 "default_env": "production",
1338 "groups": {
1339 "core": {
1340 "services": ["api", "web"],
1341 "description": "core stack"
1342 }
1343 }
1344 }
1345 }))
1346 .expect("sample config")
1347 }
1348
1349 #[test]
1350 fn target_choices_include_group_service_and_all() {
1351 let cfg = sample_config();
1352 let choices = deploy_target_choices(&cfg);
1353 let values: Vec<_> = choices.iter().map(|c| c.value.as_str()).collect();
1354 assert!(values.contains(&"core"));
1355 assert!(values.contains(&"api"));
1356 assert!(values.contains(&"web"));
1357 assert!(values.contains(&"all"));
1358 }
1359
1360 #[test]
1361 fn default_target_prefers_group_when_multiple_services() {
1362 let cfg = sample_config();
1363 let choices = deploy_target_choices(&cfg);
1364 let idx = default_target_index(&cfg, &choices);
1365 assert_eq!(choices[idx].value, "core");
1366 }
1367
1368 #[test]
1369 fn sole_service_is_default_when_no_group() {
1370 let cfg: XbpConfig = serde_json::from_value(serde_json::json!({
1371 "project_name": "solo",
1372 "version": "0.1.0",
1373 "port": 1,
1374 "build_dir": ".",
1375 "services": [
1376 {"name": "xbp-github-runner", "target": "rust", "branch": "main", "port": 3599}
1377 ]
1378 }))
1379 .expect("solo config");
1380 let choices = deploy_target_choices(&cfg);
1381 let idx = default_target_index(&cfg, &choices);
1382 assert_eq!(choices[idx].value, "xbp-github-runner");
1383 }
1384
1385 #[test]
1386 fn peel_plan_positional_sets_plan_mode() {
1387 let peeled = peel_deploy_mode_positional(
1388 Some("plan".into()),
1389 DeployMode::Plan,
1390 false,
1391 )
1392 .expect("peel");
1393 assert_eq!(peeled.target, None);
1394 assert!(matches!(peeled.mode, DeployMode::Plan));
1395 assert!(peeled.mode_explicit);
1396 }
1397
1398 #[test]
1399 fn peel_run_positional_sets_run_mode() {
1400 let peeled = peel_deploy_mode_positional(Some("run".into()), DeployMode::Plan, false)
1401 .expect("peel");
1402 assert_eq!(peeled.target, None);
1403 assert!(matches!(peeled.mode, DeployMode::Run));
1404 }
1405
1406 #[test]
1407 fn peel_plan_with_run_flag_errors() {
1408 let err = peel_deploy_mode_positional(Some("plan".into()), DeployMode::Run, true)
1409 .expect_err("conflict");
1410 assert!(err.contains("mode"), "{err}");
1411 assert!(err.contains("--run") || err.contains("--plan"), "{err}");
1412 }
1413
1414 #[test]
1415 fn peel_service_name_unchanged() {
1416 let peeled = peel_deploy_mode_positional(
1417 Some("athena".into()),
1418 DeployMode::Plan,
1419 false,
1420 )
1421 .expect("peel");
1422 assert_eq!(peeled.target.as_deref(), Some("athena"));
1423 assert!(!peeled.mode_explicit);
1424 }
1425}