1pub mod doctor;
7pub mod history_ext;
8pub mod interactive;
9pub mod map_config;
10pub mod oci_build;
11pub mod reset;
12pub mod setup;
13
14pub use interactive::{
15 looks_like_local_kube_context, peel_deploy_mode_positional, resolve_deploy_invocation,
16 DeployInvocationOptions, PeeledDeployArgs, ResolvedDeployInvocation, DEPLOY_MODE_POSITIONALS,
17};
18pub use reset::{run_deploy_reset, DeployResetRequest};
19pub use xbp_deploy::{DeployMode, DeployTarget};
20
21use std::io::IsTerminal;
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use colored::Colorize;
27use xbp_deploy::{
28 is_kubernetes_provider, unique_service_names, CloudflareDeployAdapter,
29 CloudflareServiceDeployRequest, DefaultDeployPlanner, DefaultDeployRunner,
30 DefaultDeployVerifier, DeployContext, DeployFlags, DeployHistoryStore, DeployPlanner,
31 DeployRunner, DeployVerifier, HistoryEntry,
32};
33
34use self::oci_build::CliOciBuildAdapter;
35use xbp_k8s::KubectlAdapter;
36use xbp_oci::{
37 resolve_auth_from_candidates, DefaultOciPromoter, DefaultOciResolver, OciAuthRequest, OciClient,
38};
39
40use crate::cli::interactive::{print_picker_header, select_one};
41use crate::cli::ui;
42use crate::commands::cloudflare::deploy_app_by_name;
43use crate::commands::service::load_xbp_config_with_root;
44use crate::commands::terminal_table::{render_table, TableStyle};
45use crate::oci::auth_candidates_for_registry;
46
47use self::map_config::map_project_config;
48use self::setup::{ensure_deploy_env_for_target, heal_deploy_groups};
49
50struct CliCloudflareDeployAdapter {
52 project_root: PathBuf,
53}
54
55#[async_trait]
56impl CloudflareDeployAdapter for CliCloudflareDeployAdapter {
57 async fn deploy_service(
58 &self,
59 request: CloudflareServiceDeployRequest,
60 ) -> Result<Vec<String>, String> {
61 crate::commands::cloudflare::deploy_app_by_name_with_options(
62 Some(self.project_root.as_path()),
63 &request.worker_app,
64 request.dry_run,
65 request.rollout.as_deref(),
66 request.skip_deploy,
67 request.allow_unchanged_container_image,
68 request.prune_old_images,
69 request.local_build,
70 request.local_build_only,
71 )
72 .await
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct DeployRequest {
78 pub target: String,
79 pub env: Option<String>,
80 pub mode: DeployMode,
81 pub flags: DeployFlags,
82 pub debug: bool,
83 pub revitalize: Option<String>,
85 pub revitalize_out: Option<std::path::PathBuf>,
86 pub revitalize_in_place: bool,
87 pub redact_history: bool,
89 pub repair_history: bool,
91 pub engine_check: bool,
93 pub scan: bool,
95}
96
97pub async fn run_deploy(request: DeployRequest) -> Result<(), String> {
99 let (project_root, mut xbp_config) = load_xbp_config_with_root().await?;
100
101 let skip_group_heal = request.engine_check
104 || request.repair_history
105 || request.redact_history
106 || request.revitalize.is_some()
107 || matches!(request.mode, DeployMode::History | DeployMode::Status);
108 if !skip_group_heal {
109 let group_heals = heal_deploy_groups(&mut xbp_config);
110 if !group_heals.is_empty() {
111 println!(
112 "{} Auto-healed deploy.groups",
113 "✓".bright_green().bold()
114 );
115 for note in &group_heals {
116 println!(" {} {}", "·".bright_black(), note.dimmed());
117 }
118 if let Some(found) = crate::utils::find_xbp_config_upwards(&project_root) {
119 if let Err(error) =
120 crate::utils::write_xbp_project_config_at_path(&found.config_path, &xbp_config)
121 {
122 eprintln!(
123 "{} could not write healed deploy groups: {error}",
124 "!".bright_yellow()
125 );
126 } else {
127 println!(
128 " {} wrote {}",
129 "→".bright_cyan(),
130 found.config_path.display().to_string().dimmed()
131 );
132 }
133 }
134 }
135 }
136
137 let env = request
138 .env
139 .as_deref()
140 .map(str::trim)
141 .filter(|s| !s.is_empty())
142 .map(str::to_string)
143 .or_else(|| {
144 xbp_config
145 .deploy
146 .as_ref()
147 .and_then(|d| d.default_env.clone())
148 })
149 .unwrap_or_else(|| "production".into());
150
151 let xbp_config = if matches!(
153 request.mode,
154 DeployMode::History | DeployMode::Status
155 ) || request.repair_history
156 || request.engine_check
157 || request.redact_history
158 || request.revitalize.is_some()
159 {
160 xbp_config
161 } else {
162 ensure_deploy_env_for_target(
163 &project_root,
164 xbp_config,
165 &request.target,
166 &env,
167 request.flags.yes,
168 )?
169 };
170
171 let config = map_project_config(&project_root, &xbp_config);
172
173 if request.engine_check {
174 return history_ext::run_engine_check(
175 &project_root,
176 &xbp_config.project_name,
177 &config.history_dir,
178 &env,
179 request.flags.history_limit.max(1),
180 request.scan,
181 request.flags.json,
182 );
183 }
184
185 if request.repair_history {
186 return repair_deploy_history_index(&config.history_dir, request.flags.json);
187 }
188
189 if request.redact_history {
190 return redact_existing_deploy_history(
191 &project_root,
192 &xbp_config,
193 &config.history_dir,
194 request.flags.json,
195 )
196 .await;
197 }
198
199 if let Some(revitalize_id) = request.revitalize.as_deref() {
200 return revitalize_deploy_history(
201 &config.history_dir,
202 revitalize_id,
203 request.revitalize_out.as_deref(),
204 request.revitalize_in_place,
205 request.flags.json,
206 )
207 .await;
208 }
209
210 if matches!(request.mode, DeployMode::History) {
211 let store = DeployHistoryStore::new(&config.history_dir);
212 let limit = request.flags.history_limit.max(1);
213 let entries = store
214 .list(&request.target, &env, limit)
215 .map_err(|e| e.to_string())?;
216 let stats = store
217 .stats(&request.target, &env, limit)
218 .map_err(|e| e.to_string())?;
219 return print_deploy_history_table(
220 &store,
221 &entries,
222 &request.target,
223 &env,
224 &xbp_config.project_name,
225 &stats,
226 );
227 }
228
229 if matches!(request.mode, DeployMode::Status) {
230 let store = DeployHistoryStore::new(&config.history_dir);
231 let entry = store
232 .latest_status(&request.target, &env)
233 .map_err(|e| e.to_string())?;
234 return print_deploy_status_table(
235 &store,
236 entry.as_ref(),
237 &request.target,
238 &env,
239 &xbp_config.project_name,
240 );
241 }
242
243 let target = if request.target.eq_ignore_ascii_case("all") {
245 DeployTarget::All
246 } else if config.groups.contains_key(&request.target) {
247 DeployTarget::Group(request.target.clone())
248 } else {
249 DeployTarget::Service(request.target.clone())
250 };
251
252 let ctx = DeployContext {
253 env: env.clone(),
254 target,
255 config: config.clone(),
256 flags: request.flags.clone(),
257 mode: request.mode,
258 };
259
260 let oci_resolver = build_oci_resolver(&xbp_config, request.debug);
261 let planner = DefaultDeployPlanner::new(oci_resolver.clone());
262 let mut plan = planner.plan(&ctx).await.map_err(|e| e.to_string())?;
263
264 plan = interactive::apply_cli_service_filters(
265 plan,
266 &request.flags.only_services,
267 &request.flags.exclude_services,
268 )?;
269
270 let force_ni = request.flags.json || request.flags.output.is_some();
271 plan = interactive::maybe_edit_plan_services(plan, request.flags.yes, force_ni)?;
272
273 let service_count = unique_service_names(&plan).len();
274 println!(
275 "{} {} → {} service(s) env={}",
276 "Deploy".bright_cyan().bold(),
277 plan.target.label().bright_white().bold(),
278 service_count.to_string().bright_green(),
279 env.bright_yellow()
280 );
281 println!("{}", plan.render_human());
282
283 if let Some(path) = &request.flags.output {
284 if let Some(parent) = path.parent() {
285 let _ = std::fs::create_dir_all(parent);
286 }
287 std::fs::write(
288 path,
289 serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?,
290 )
291 .map_err(|e| format!("write --output: {e}"))?;
292 println!("{} {}", "wrote".bright_cyan(), path.display());
293 }
294
295 interactive::maybe_confirm_multi_service_apply(
296 &plan,
297 request.mode,
298 request.flags.yes,
299 force_ni,
300 )?;
301
302 match request.mode {
303 DeployMode::Plan => {
304 if request.flags.json {
305 println!(
306 "{}",
307 serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?
308 );
309 }
310 println!();
311 println!(
312 "{} {}",
313 "OK".bright_green().bold(),
314 "Plan only (no mutations).".bright_white()
315 );
316 println!(
317 " Re-run with {} to apply (same target/env):\n {} deploy {} --env {} --run",
318 "--run".bright_green().bold(),
319 "xbp".bright_magenta().bold(),
320 request.target.bright_white(),
321 env.bright_yellow(),
322 );
323 Ok(())
324 }
325 DeployMode::Verify => {
326 let k8s = Arc::new(KubectlAdapter::new(request.debug));
327 let verifier = DefaultDeployVerifier { k8s };
328 let report = verifier.verify(&ctx, &plan).await.map_err(|e| e.to_string())?;
329 for line in &report.lines {
330 println!("{line}");
331 }
332 for drift in &report.drifts {
333 println!("{} {drift}", "drift".bright_red());
334 }
335 if request.flags.json {
336 println!(
337 "{}",
338 serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
339 );
340 }
341 if report.ok {
342 println!("{}", "Result: ok".bright_green().bold());
343 Ok(())
344 } else {
345 println!("{}", "Result: FAILED".bright_red().bold());
346 Err(format!("verify failed: {}", report.drifts.join("; ")))
347 }
348 }
349 DeployMode::Run | DeployMode::Promote => {
350 let k8s: Arc<dyn xbp_k8s::K8sAdapter> =
351 Arc::new(KubectlAdapter::new(request.debug));
352 let promoter = build_oci_promoter(&xbp_config);
353 let confirm: Arc<dyn Fn(String) -> bool + Send + Sync> = Arc::new(|msg| {
354 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
355 return false;
356 }
357 let default_yes = msg.contains("local Docker only")
359 || msg.contains("like --local-image");
360 dialoguer::Confirm::new()
361 .with_prompt(msg)
362 .default(default_yes)
363 .interact()
364 .unwrap_or(false)
365 });
366 let cloudflare: Option<Arc<dyn xbp_deploy::CloudflareDeployAdapter>> =
369 Some(Arc::new(CliCloudflareDeployAdapter {
370 project_root: project_root.clone(),
371 }));
372
373 if matches!(request.mode, DeployMode::Run) && !request.flags.yes {
375 history_ext::warn_repeated_deploy_failures(
376 &config.history_dir,
377 &request.target,
378 &env,
379 3,
380 );
381 }
382
383 if ctx.should_run_doctor() && matches!(request.mode, DeployMode::Run) {
385 let report = doctor::run_deploy_doctor(
386 &ctx,
387 &plan,
388 &xbp_config,
389 &project_root,
390 k8s.as_ref(),
391 request.debug,
392 )
393 .await;
394 for line in &report.lines {
395 println!("{}", line.bright_black());
396 }
397 if !report.ok {
398 let cf_hint = report.lines.iter().any(|l| {
399 l.contains("cf_worker_not_found")
400 || l.contains("cf_contract_incomplete")
401 || l.contains("Known workers")
402 });
403 let hint = if cf_hint {
404 "deploy doctor failed — fix Cloudflare worker name/contract (see above), run `xbp cloudflare doctor --app <name>`, or pass --skip-doctor"
405 } else {
406 "deploy doctor failed — fix cluster/config (see above) or pass --skip-doctor"
407 };
408 return Err(hint.into());
409 }
410 }
411
412 let runner = DefaultDeployRunner {
413 k8s: Arc::clone(&k8s),
414 promoter,
415 confirm: Some(confirm),
416 cloudflare,
417 oci_build: Some(Arc::new(CliOciBuildAdapter) as Arc<dyn xbp_deploy::OciBuildAdapter>),
418 };
419 let mut result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
420 for line in &result.lines {
421 println!("{line}");
422 }
423
424 if !request.flags.dry_run {
426 upload_history_secrets_soft(
427 &project_root,
428 &xbp_config,
429 &plan,
430 &result,
431 )
432 .await;
433 }
434
435 let has_k8s = plan
438 .services
439 .iter()
440 .any(|s| is_kubernetes_provider(&s.provider));
441 if has_k8s && result.ok && !request.flags.dry_run && !request.flags.skip_rollout {
442 let verifier = DefaultDeployVerifier {
443 k8s: Arc::clone(&k8s),
444 };
445 match verifier.verify(&ctx, &plan).await {
446 Ok(report) => {
447 for line in &report.lines {
448 if xbp_deploy::k8s_verify_line_is_failure(line) {
449 println!("{} {line}", "verify".bright_red());
450 } else {
451 println!("{line}");
452 }
453 }
454 if !report.ok {
455 result.ok = false;
456 result.failed_services.extend(
457 plan.services
458 .iter()
459 .filter(|s| is_kubernetes_provider(&s.provider))
460 .map(|s| s.name.clone()),
461 );
462 result.error = Some(format!(
463 "post-deploy verify failed: {}",
464 report.drifts.join("; ")
465 ));
466 println!(
467 "{} {}",
468 "verify failed".bright_red().bold(),
469 report.drifts.join(" · ")
470 );
471 }
472 }
473 Err(e) => {
474 result.ok = false;
475 result.error = Some(format!("post-deploy verify error: {e}"));
476 println!("{} {e}", "verify error".bright_red());
477 }
478 }
479 }
480
481 if !result.ok && !request.flags.dry_run {
483 let blob = format!(
484 "{}\n{}",
485 result.error.clone().unwrap_or_default(),
486 result.lines.join("\n")
487 );
488 let has_k8s = plan
489 .services
490 .iter()
491 .any(|s| is_kubernetes_provider(&s.provider));
492 if has_k8s
493 && crate::commands::docker_desktop_k8s::offer_deploy_recovery(
494 &blob,
495 request.flags.yes,
496 request.debug,
497 )
498 .await
499 {
500 println!(
501 "{}",
502 "Retrying deploy after Docker Desktop Kubernetes recovery…"
503 .bright_cyan()
504 .bold()
505 );
506 result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
507 for line in &result.lines {
508 println!("{line}");
509 }
510 }
511 }
512
513 if request.flags.json {
514 println!(
515 "{}",
516 serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
517 );
518 }
519 if !request.flags.dry_run {
521 let targets: Vec<String> = plan.services.iter().map(|s| s.name.clone()).collect();
522 let status = if result.ok {
523 crate::commands::discord_notify::DiscordStatus::Success
524 } else {
525 crate::commands::discord_notify::DiscordStatus::Failure
526 };
527 let mode = format!("{:?}", request.mode).to_ascii_lowercase();
528 let detail = if result.ok {
529 "Deploy completed".to_string()
530 } else {
531 result
532 .error
533 .clone()
534 .unwrap_or_else(|| "deploy failed".into())
535 };
536 let all_k8s = !plan.services.is_empty()
537 && plan
538 .services
539 .iter()
540 .all(|s| is_kubernetes_provider(&s.provider));
541 let all_cf = !plan.services.is_empty()
542 && plan
543 .services
544 .iter()
545 .all(|s| xbp_deploy::is_cloudflare_provider(&s.provider));
546 let notification = if all_k8s {
547 crate::commands::discord_notify::kubernetes_deploy_notification(
548 &targets,
549 &env,
550 &mode,
551 status,
552 &detail,
553 )
554 } else if all_cf {
555 let has_containers = plan.services.iter().any(|s| {
557 s.provider.contains("container")
558 || s.provider.contains("cloudflare-containers")
559 });
560 crate::commands::discord_notify::cloudflare_deploy_notification(
561 &targets,
562 &mode,
563 status,
564 &format!("{detail} (env: {env})"),
565 has_containers,
566 )
567 } else {
568 crate::commands::discord_notify::deploy_notification(
569 &targets.join(", "),
570 &env,
571 &mode,
572 status,
573 &detail,
574 )
575 };
576 crate::commands::discord_notify::notify_discord_for_project(
577 &project_root,
578 Some(&xbp_config),
579 None,
580 notification,
581 )
582 .await;
583 }
584
585 sync_deploy_activity_to_dashboard(
587 &project_root,
588 &xbp_config,
589 &request,
590 &env,
591 &plan,
592 &result,
593 )
594 .await;
595
596 if result.ok {
597 println!("{}", "Result: ok".bright_green().bold());
598 Ok(())
599 } else {
600 println!("{}", "Result: FAILED".bright_red().bold());
601 Err(result.error.unwrap_or_else(|| "deploy failed".into()))
602 }
603 }
604 DeployMode::History | DeployMode::Status => unreachable!("handled above"),
605 }
606}
607
608async fn sync_deploy_activity_to_dashboard(
609 project_root: &std::path::Path,
610 xbp_config: &crate::strategies::XbpConfig,
611 request: &DeployRequest,
612 env: &str,
613 plan: &xbp_deploy::DeployPlan,
614 result: &xbp_deploy::DeployResult,
615) {
616 use crate::commands::cli_session::{report_ops_deploy_activity, OpsDeployActivityReport};
617
618 if request.flags.dry_run {
619 return;
620 }
621
622 let mode = format!("{:?}", request.mode).to_ascii_lowercase();
623 let namespace = plan
624 .services
625 .first()
626 .and_then(|s| s.deploy.namespace.clone())
627 .or_else(|| plan.k8s_plan.default_namespace.clone());
628 let kube_context = plan.k8s_plan.context.clone();
629 let summary = if result.ok {
630 format!(
631 "Deploy {} → {} service(s) env={}",
632 plan.target.label(),
633 plan.services.len(),
634 env
635 )
636 } else {
637 result
638 .error
639 .clone()
640 .unwrap_or_else(|| "deploy failed".into())
641 };
642
643 report_ops_deploy_activity(OpsDeployActivityReport {
644 project_root: project_root.to_path_buf(),
645 project_name: Some(xbp_config.project_name.clone()),
646 status_ok: result.ok,
647 mode,
648 target: plan.target.label(),
649 env: env.to_string(),
650 services: plan.services.iter().map(|s| s.name.clone()).collect(),
651 summary,
652 logs: Some(result.lines.join("\n")),
653 error: result.error.clone(),
654 kube_context,
655 namespace,
656 plan_hash: if plan.hash.is_empty() {
657 None
658 } else {
659 Some(plan.hash.clone())
660 },
661 })
662 .await;
663}
664
665fn build_oci_resolver(
666 xbp_config: &crate::strategies::XbpConfig,
667 _debug: bool,
668) -> Option<Arc<dyn xbp_oci::OciResolver>> {
669 let auth = resolve_auth_from_candidates(
671 &OciAuthRequest {
672 registry: "ghcr.io".into(),
673 ..Default::default()
674 },
675 &auth_candidates_for_registry("ghcr.io", xbp_config),
676 );
677 let client = OciClient::new(auth);
678 Some(Arc::new(DefaultOciResolver::new(client)) as Arc<dyn xbp_oci::OciResolver>)
679}
680
681fn build_oci_promoter(
682 xbp_config: &crate::strategies::XbpConfig,
683) -> Option<Arc<dyn xbp_oci::OciPromoter>> {
684 let auth = resolve_auth_from_candidates(
685 &OciAuthRequest {
686 registry: "ghcr.io".into(),
687 ..Default::default()
688 },
689 &auth_candidates_for_registry("ghcr.io", xbp_config),
690 );
691 let client = OciClient::new(auth);
692 Some(Arc::new(DefaultOciPromoter::new(client)) as Arc<dyn xbp_oci::OciPromoter>)
693}
694
695fn format_history_status(status: &str) -> String {
696 match status {
697 "success" | "ok" => "ok".bright_green().bold().to_string(),
698 "failed" | "error" => "failed".bright_red().bold().to_string(),
699 other if other.is_empty() => "—".dimmed().to_string(),
700 other => other.bright_yellow().to_string(),
701 }
702}
703
704fn truncate_cell(value: &str, max: usize) -> String {
705 let trimmed = value.trim();
706 if trimmed.is_empty() {
707 return "—".to_string();
708 }
709 let count = trimmed.chars().count();
710 if count <= max {
711 return trimmed.to_string();
712 }
713 let take = max.saturating_sub(1);
714 format!("{}…", trimmed.chars().take(take).collect::<String>())
715}
716
717fn history_detail_for_entry(store: &DeployHistoryStore, entry: &HistoryEntry) -> String {
718 match store.load_record(entry) {
719 Ok(Some(record)) => {
720 if let Some(err) = record.error.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
721 return truncate_cell(err, 56);
722 }
723 if !record.summary.trim().is_empty() {
724 return truncate_cell(&record.summary, 56);
725 }
726 "—".into()
727 }
728 _ => "—".into(),
729 }
730}
731
732fn print_deploy_history_table(
734 store: &DeployHistoryStore,
735 entries: &[HistoryEntry],
736 target: &str,
737 env: &str,
738 project_name: &str,
739 stats: &xbp_deploy::DeployHistoryStats,
740) -> Result<(), String> {
741 print_picker_header(
742 &format!("xbp deploy · history — {project_name}"),
743 &format!(
744 "target={} env={} {} recent deploy(s) · ok={} fail={}",
745 target,
746 if env.is_empty() { "(any)" } else { env },
747 entries.len(),
748 stats.success,
749 stats.failed,
750 ),
751 );
752 ui::divider(72);
753
754 if entries.is_empty() {
755 println!("{}", "No matching deploy history entries.".dimmed());
756 println!(
757 "{}",
758 "Hint: run `xbp deploy <target> --run` to record history under .xbp/deployments/."
759 .dimmed()
760 );
761 return Ok(());
762 }
763
764 let rows: Vec<Vec<String>> = entries
765 .iter()
766 .map(|e| {
767 let code = e
768 .error_code
769 .clone()
770 .or_else(|| {
771 store.load_record(e).ok().flatten().and_then(|r| {
772 r.error_code.or_else(|| {
773 let c = xbp_deploy::classify_deploy_error(
774 r.error.as_deref(),
775 &r.summary,
776 e.status == "success",
777 );
778 Some(c.error_code.as_str().into())
779 })
780 })
781 })
782 .unwrap_or_else(|| "—".into());
783 vec![
784 e.timestamp
785 .format("%Y-%m-%d %H:%M:%S UTC")
786 .to_string()
787 .bright_black()
788 .to_string(),
789 format_history_status(&e.status),
790 e.target.bright_white().bold().to_string(),
791 code.bright_magenta().to_string(),
792 e.id.bright_cyan().to_string(),
793 history_detail_for_entry(store, e).dimmed().to_string(),
794 ]
795 })
796 .collect();
797
798 print!(
799 "{}",
800 render_table(
801 &["When", "Status", "Target", "Code", "Id", "Detail"],
802 &rows,
803 TableStyle::Pipe,
804 " ",
805 )
806 );
807 ui::divider(72);
808 if !stats.by_error_code.is_empty() {
809 println!("{}", "By error code:".bright_black());
810 let mut codes: Vec<_> = stats.by_error_code.iter().collect();
811 codes.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
812 for (code, n) in codes.into_iter().take(12) {
813 println!(
814 " {} {}",
815 format!("{n:>3}").bright_white(),
816 code.bright_magenta()
817 );
818 }
819 ui::divider(72);
820 }
821 println!(
822 "{} {} · records in {}",
823 "Total:".bright_black(),
824 format!("{} entr{}", entries.len(), if entries.len() == 1 { "y" } else { "ies" })
825 .bright_white(),
826 store.dir.display().to_string().dimmed()
827 );
828
829 if is_interactive_deploy_tty() && entries.len() > 1 {
831 println!();
832 let labels: Vec<String> = entries
833 .iter()
834 .map(|e| {
835 format!(
836 "{} {} {} {}",
837 e.timestamp.format("%H:%M:%S"),
838 e.status,
839 e.target,
840 e.id
841 )
842 })
843 .collect();
844 match select_one("Inspect history entry (Esc to skip)", &labels, 0) {
845 Ok(Some(idx)) => {
846 if let Some(entry) = entries.get(idx) {
847 print_history_entry_detail(store, entry);
848 }
849 }
850 Ok(None) | Err(_) => {}
851 }
852 } else if is_interactive_deploy_tty() && entries.len() == 1 {
853 print_history_entry_detail(store, &entries[0]);
854 }
855
856 Ok(())
857}
858
859fn print_deploy_status_table(
860 store: &DeployHistoryStore,
861 entry: Option<&HistoryEntry>,
862 target: &str,
863 env: &str,
864 project_name: &str,
865) -> Result<(), String> {
866 print_picker_header(
867 &format!("xbp deploy · status — {project_name}"),
868 &format!(
869 "target={} env={} · latest deploy snapshot",
870 target,
871 if env.is_empty() { "(any)" } else { env }
872 ),
873 );
874 ui::divider(72);
875
876 let Some(entry) = entry else {
877 println!("{}", "No deploy status recorded.".dimmed());
878 println!(
879 "{}",
880 "Hint: run `xbp deploy <target> --run` to create the first history record.".dimmed()
881 );
882 return Ok(());
883 };
884
885 let detail = history_detail_for_entry(store, entry);
886 let rows = vec![vec![
887 entry
888 .timestamp
889 .format("%Y-%m-%d %H:%M:%S UTC")
890 .to_string()
891 .bright_black()
892 .to_string(),
893 format_history_status(&entry.status),
894 entry.target.bright_white().bold().to_string(),
895 entry.env.bright_yellow().to_string(),
896 entry.id.bright_cyan().to_string(),
897 detail.dimmed().to_string(),
898 ]];
899 print!(
900 "{}",
901 render_table(
902 &["When", "Status", "Target", "Env", "Id", "Detail"],
903 &rows,
904 TableStyle::Pipe,
905 " ",
906 )
907 );
908 ui::divider(72);
909 print_history_entry_detail(store, entry);
910 Ok(())
911}
912
913fn print_history_entry_detail(store: &DeployHistoryStore, entry: &HistoryEntry) {
914 println!();
915 println!(
916 "{} {} {} env={}",
917 "▸".bright_magenta().bold(),
918 entry.id.bright_cyan(),
919 format_history_status(&entry.status),
920 entry.env.bright_yellow()
921 );
922 match store.load_record(entry) {
923 Ok(Some(record)) => {
924 if let Some(sha) = record.git_sha.as_deref().filter(|s| !s.is_empty()) {
925 println!(" {} {}", "git:".bright_black(), sha.dimmed());
926 }
927 if let Some(ver) = record.project_version.as_deref().filter(|s| !s.is_empty()) {
928 println!(" {} {}", "version:".bright_black(), ver.bright_white());
929 }
930 if let Some(code) = record.error_code.as_deref().filter(|s| !s.is_empty()) {
931 println!(" {} {}", "code:".bright_black(), code.bright_magenta());
932 }
933 if let Some(phase) = record.phase.as_deref().filter(|s| !s.is_empty()) {
934 println!(" {} {}", "phase:".bright_black(), phase.bright_yellow());
935 }
936 if let Some(provider) = record.provider.as_deref().filter(|s| !s.is_empty()) {
937 println!(
938 " {} {}",
939 "provider:".bright_black(),
940 provider.bright_white()
941 );
942 }
943 if let Some(ns) = record.namespace.as_deref().filter(|s| !s.is_empty()) {
944 println!(" {} {}", "namespace:".bright_black(), ns.bright_white());
945 }
946 if !record.services.is_empty() {
947 println!(
948 " {} {}",
949 "services:".bright_black(),
950 record.services.join(", ").bright_white()
951 );
952 }
953 if !record.diagnostics.is_empty() {
954 println!(" {}", "diagnostics:".bright_black());
955 for d in &record.diagnostics {
956 println!(" {} {}", "·".bright_black(), d.dimmed());
957 }
958 }
959 if !record.summary.trim().is_empty() {
960 println!(
961 " {} {}",
962 "summary:".bright_black(),
963 record.summary.trim()
964 );
965 }
966 if let Some(err) = record.error.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
967 println!(" {} {}", "error:".bright_red().bold(), err.bright_red());
968 }
969 println!(
970 " {} {}",
971 "path:".bright_black(),
972 store.dir.join(&entry.path).display().to_string().dimmed()
973 );
974 }
975 Ok(None) => {
976 println!(
977 " {} {}",
978 "path:".bright_black(),
979 store.dir.join(&entry.path).display().to_string().dimmed()
980 );
981 println!(" {}", "full record missing on disk".dimmed());
982 }
983 Err(e) => {
984 println!(" {} {}", "load error:".bright_red(), e);
985 }
986 }
987}
988
989fn is_interactive_deploy_tty() -> bool {
990 std::io::stdin().is_terminal()
991 && std::io::stdout().is_terminal()
992 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
993}
994
995fn repair_deploy_history_index(
997 history_dir: &std::path::Path,
998 json: bool,
999) -> Result<(), String> {
1000 let store = DeployHistoryStore::new(history_dir);
1001 let index = store
1002 .rebuild_index_from_records()
1003 .map_err(|e| e.to_string())?;
1004 if json {
1005 println!(
1006 "{}",
1007 serde_json::to_string_pretty(&serde_json::json!({
1008 "repaired": true,
1009 "entries": index.entries.len(),
1010 "dir": store.dir.display().to_string(),
1011 }))
1012 .unwrap_or_else(|_| "{}".into())
1013 );
1014 return Ok(());
1015 }
1016 println!(
1017 "{} rebuilt deploy history index ({} entr{})",
1018 "✓".bright_green().bold(),
1019 index.entries.len(),
1020 if index.entries.len() == 1 { "y" } else { "ies" }
1021 );
1022 println!(
1023 " {} {}",
1024 "→".bright_cyan(),
1025 store.index_path().display().to_string().dimmed()
1026 );
1027 for e in index.entries.iter().take(8) {
1028 println!(
1029 " {} {} {} {}",
1030 "·".bright_black(),
1031 e.status,
1032 e.target.bright_white(),
1033 e.id.bright_cyan()
1034 );
1035 }
1036 if index.entries.len() > 8 {
1037 println!(
1038 " {} … and {} more",
1039 "·".bright_black(),
1040 index.entries.len() - 8
1041 );
1042 }
1043 Ok(())
1044}
1045
1046async fn upload_history_secrets_soft(
1047 project_root: &std::path::Path,
1048 xbp_config: &crate::strategies::XbpConfig,
1049 plan: &xbp_deploy::DeployPlan,
1050 result: &xbp_deploy::DeployResult,
1051) {
1052 use crate::commands::cli_session::{
1053 post_deploy_history_secrets, CliDeploySecretItem, CliDeploySecretsPayload,
1054 };
1055 use crate::utils::parse_github_repo_from_remote_url;
1056
1057 let Some(deployment_id) = result.history_id.as_ref() else {
1058 return;
1059 };
1060 if result.history_secrets.is_empty() {
1061 return;
1062 }
1063
1064 let (repository_owner, repository_name) = {
1065 let remote = crate::commands::version::run_git_command(
1066 project_root,
1067 &["remote", "get-url", "origin"],
1068 )
1069 .ok();
1070 remote
1071 .as_deref()
1072 .and_then(parse_github_repo_from_remote_url)
1073 .map(|(o, r)| (Some(o), Some(r)))
1074 .unwrap_or((None, None))
1075 };
1076
1077 let payload = CliDeploySecretsPayload {
1078 deployment_id: deployment_id.clone(),
1079 secrets: result
1080 .history_secrets
1081 .iter()
1082 .map(|s| CliDeploySecretItem {
1083 name: s.name.clone(),
1084 value: s.value.clone(),
1085 hash: Some(s.hash.clone()),
1086 service: s.service.clone(),
1087 })
1088 .collect(),
1089 project_name: Some(xbp_config.project_name.clone()),
1090 project_path: Some(project_root.display().to_string()),
1091 repository_owner,
1092 repository_name,
1093 env: Some(plan.env.clone()),
1094 target: Some(plan.target.label()),
1095 xbp_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1096 };
1097
1098 match post_deploy_history_secrets(&payload).await {
1099 Ok(Some(resp)) => {
1100 println!(
1101 "{} stored {} secret(s) in xbp.app for deployment `{}` (revitalize: xbp deploy --revitalize {})",
1102 "✓".bright_green().bold(),
1103 resp.upserted,
1104 resp.deployment_id,
1105 resp.deployment_id
1106 );
1107 }
1108 Ok(None) => {
1109 println!(
1110 "{} deploy history secrets not uploaded (login with `xbp login` to store in xbp.app D1)",
1111 "·".bright_black()
1112 );
1113 }
1114 Err(e) => {
1115 eprintln!(
1116 "{} could not upload deploy secrets to xbp.app: {e}",
1117 "!".bright_yellow()
1118 );
1119 }
1120 }
1121}
1122
1123async fn redact_existing_deploy_history(
1124 project_root: &std::path::Path,
1125 xbp_config: &crate::strategies::XbpConfig,
1126 history_dir: &std::path::Path,
1127 json: bool,
1128) -> Result<(), String> {
1129 use crate::commands::cli_session::{
1130 post_deploy_history_secrets, CliDeploySecretItem, CliDeploySecretsPayload,
1131 };
1132 use crate::utils::parse_github_repo_from_remote_url;
1133 use xbp_deploy::{
1134 redact_plan_runtime_env, DeployHistoryStore, DEPLOY_SECRET_PLACEHOLDER_PREFIX,
1135 };
1136
1137 let store = DeployHistoryStore::new(history_dir);
1138 let index = store.load_index().map_err(|e| e.to_string())?;
1139 if index.entries.is_empty() {
1140 println!("{}", "No deploy history entries to redact.".dimmed());
1141 return Ok(());
1142 }
1143
1144 let (repository_owner, repository_name) = {
1145 let remote = crate::commands::version::run_git_command(
1146 project_root,
1147 &["remote", "get-url", "origin"],
1148 )
1149 .ok();
1150 remote
1151 .as_deref()
1152 .and_then(parse_github_repo_from_remote_url)
1153 .map(|(o, r)| (Some(o), Some(r)))
1154 .unwrap_or((None, None))
1155 };
1156
1157 let mut files_touched = 0usize;
1158 let mut secrets_uploaded = 0usize;
1159 let mut secrets_total = 0usize;
1160
1161 for entry in &index.entries {
1162 let Some(mut record) = store
1163 .load_record(entry)
1164 .map_err(|e| e.to_string())?
1165 else {
1166 continue;
1167 };
1168
1169 let needs = record.plan.services.iter().any(|s| {
1171 s.runtime_env.iter().any(|(k, v)| {
1172 xbp_deploy::runtime_env_value_should_redact(k, v)
1173 && !v.starts_with(DEPLOY_SECRET_PLACEHOLDER_PREFIX)
1174 })
1175 });
1176 if !needs && record.runtime_env_redacted == Some(true) {
1177 continue;
1178 }
1179
1180 let secrets_found = redact_plan_runtime_env(&mut record.plan, &record.id);
1181 if secrets_found.is_empty() {
1182 if record.xbp_version.is_none() {
1184 record.xbp_version = Some(env!("CARGO_PKG_VERSION").to_string());
1185 let _ = store.write_record(&record);
1186 }
1187 continue;
1188 }
1189
1190 if record.xbp_version.is_none() {
1191 record.xbp_version = Some(env!("CARGO_PKG_VERSION").to_string());
1192 }
1193 record.runtime_env_redacted = Some(true);
1194 store
1195 .write_record(&record)
1196 .map_err(|e| e.to_string())?;
1197 files_touched += 1;
1198 secrets_total += secrets_found.len();
1199
1200 let secrets: Vec<CliDeploySecretItem> = secrets_found
1201 .iter()
1202 .map(|s| CliDeploySecretItem {
1203 name: s.name.clone(),
1204 value: s.value.clone(),
1205 hash: Some(s.hash.clone()),
1206 service: s.service.clone(),
1207 })
1208 .collect();
1209 let payload = CliDeploySecretsPayload {
1210 deployment_id: record.id.clone(),
1211 secrets,
1212 project_name: Some(xbp_config.project_name.clone()),
1213 project_path: Some(project_root.display().to_string()),
1214 repository_owner: repository_owner.clone(),
1215 repository_name: repository_name.clone(),
1216 env: Some(record.env.clone()),
1217 target: Some(record.target.clone()),
1218 xbp_version: record.xbp_version.clone(),
1219 };
1220 if let Ok(Some(resp)) = post_deploy_history_secrets(&payload).await {
1221 secrets_uploaded += resp.upserted;
1222 }
1223 }
1224
1225 if json {
1226 println!(
1227 "{}",
1228 serde_json::json!({
1229 "filesTouched": files_touched,
1230 "secretsFound": secrets_total,
1231 "secretsUploaded": secrets_uploaded,
1232 })
1233 );
1234 } else {
1235 println!(
1236 "{} redacted {} history file(s); {} secret value(s) scanned; {} uploaded to xbp.app",
1237 "✓".bright_green().bold(),
1238 files_touched,
1239 secrets_total,
1240 secrets_uploaded
1241 );
1242 if secrets_uploaded == 0 && secrets_total > 0 {
1243 println!(
1244 " {} upload skipped — run `xbp login` and ensure apps/web exposes /api/cli/deploy/secrets",
1245 "·".bright_black()
1246 );
1247 }
1248 }
1249 Ok(())
1250}
1251
1252async fn revitalize_deploy_history(
1253 history_dir: &std::path::Path,
1254 deployment_id: &str,
1255 out_path: Option<&std::path::Path>,
1256 in_place: bool,
1257 json: bool,
1258) -> Result<(), String> {
1259 use crate::commands::cli_session::fetch_deploy_history_secrets;
1260 use std::collections::BTreeMap;
1261 use xbp_deploy::{revitalize_record_runtime_env, DeployHistoryStore};
1262
1263 let store = DeployHistoryStore::new(history_dir);
1264 let mut record = store
1265 .load_record_by_id(deployment_id)
1266 .map_err(|e| e.to_string())?
1267 .ok_or_else(|| {
1268 format!(
1269 "deployment history `{}` not found under {}",
1270 deployment_id,
1271 history_dir.display()
1272 )
1273 })?;
1274
1275 let remote = fetch_deploy_history_secrets(&record.id)
1276 .await?
1277 .ok_or_else(|| "not logged in — run `xbp login`".to_string())?;
1278
1279 if remote.secrets.is_empty() {
1280 return Err(format!(
1281 "no secrets in xbp.app for deployment `{}` (was the deploy done while logged in?)",
1282 record.id
1283 ));
1284 }
1285
1286 let mut map = BTreeMap::new();
1287 for s in &remote.secrets {
1288 map.insert(s.name.clone(), s.value.clone());
1289 }
1290 let filled = revitalize_record_runtime_env(&mut record, &map);
1291 if filled == 0 {
1292 return Err(
1293 "no placeholders matched D1 secrets (id/hash mismatch or already revitalized)".into(),
1294 );
1295 }
1296
1297 let dest = if let Some(p) = out_path {
1298 p.to_path_buf()
1299 } else if in_place {
1300 store.record_path(&record.id)
1301 } else {
1302 history_dir.join(format!("{}.revitalized.json", record.id))
1303 };
1304
1305 let body = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
1306 if let Some(parent) = dest.parent() {
1307 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1308 }
1309 std::fs::write(&dest, body).map_err(|e| e.to_string())?;
1310
1311 if json {
1312 println!(
1313 "{}",
1314 serde_json::json!({
1315 "deploymentId": record.id,
1316 "filled": filled,
1317 "path": dest.display().to_string(),
1318 "xbpVersion": record.xbp_version,
1319 })
1320 );
1321 } else {
1322 println!(
1323 "{} revitalized {} placeholder(s) → {}",
1324 "✓".bright_green().bold(),
1325 filled,
1326 dest.display()
1327 );
1328 if let Some(v) = &record.xbp_version {
1329 println!(" {} xbp {}", "·".bright_black(), v.dimmed());
1330 }
1331 }
1332 Ok(())
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337 use super::*;
1338 use xbp_deploy::{resolve_target, DeployGroupView, ProjectConfig, ServiceConfigView};
1339 use std::collections::HashMap;
1340 use std::path::PathBuf;
1341
1342 #[test]
1343 fn group_preferred_over_service_name_collision_is_engine_level() {
1344 let mut groups = HashMap::new();
1346 groups.insert(
1347 "core".into(),
1348 DeployGroupView {
1349 description: None,
1350 services: vec!["api".into()],
1351 order: vec!["api".into()],
1352 },
1353 );
1354 let config = ProjectConfig {
1355 project_name: "demo".into(),
1356 version: "1.0.0".into(),
1357 project_root: PathBuf::from("."),
1358 services: vec![ServiceConfigView {
1359 name: "api".into(),
1360 root_directory: None,
1361 version: None,
1362 depends_on: vec![],
1363 environment: Default::default(),
1364 port: None,
1365 oci: None,
1366 deploy: Some(xbp_deploy::ServiceDeployView {
1367 provider: "kubernetes".into(),
1368 worker: None,
1369 rollout: None,
1370 destinations: Default::default(),
1371 envs: {
1372 let mut e = HashMap::new();
1373 e.insert(
1374 "production".into(),
1375 xbp_deploy::ServiceDeployEnvView {
1376 namespace: Some("ns".into()),
1377 replicas: None,
1378 health: vec![],
1379 kubernetes: None,
1380 env: Default::default(),
1381 env_files: vec![],
1382 config_files: vec![],
1383 container_port: None,
1384 required_env: vec![],
1385 expose: None,
1386 },
1387 );
1388 e
1389 },
1390 }),
1391 }],
1392 groups,
1393 default_env: Some("production".into()),
1394 kubernetes: None,
1395 history_dir: PathBuf::from(".xbp/deployments"),
1396 lock_file: PathBuf::from(".xbp/deploy-lock.json"),
1397 git_sha: None,
1398 };
1399 let r = resolve_target(&config, "core", "production").unwrap();
1400 assert!(matches!(r.target, DeployTarget::Group(_)));
1401 }
1402}