1use std::collections::HashMap;
8use std::io::IsTerminal;
9use std::path::{Path, PathBuf};
10
11use colored::Colorize;
12use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
13
14use crate::strategies::deployment_config::{
15 DeployProjectConfig, KubernetesProjectConfig, ServiceDeployConfig, ServiceDeployEnvConfig,
16 ServiceDeployKubernetesConfig, ServiceDeployManifestsConfig, ServiceConfig, XbpConfig,
17};
18use crate::utils::find_xbp_config_upwards;
19
20pub fn ensure_deploy_env_for_target(
25 project_root: &Path,
26 mut config: XbpConfig,
27 target: &str,
28 env: &str,
29 yes: bool,
30) -> Result<XbpConfig, String> {
31 let env = env.trim();
32 if env.is_empty() {
33 return Err("deploy env name is empty".into());
34 }
35
36 let missing = services_missing_deploy_env(&config, target, env)?;
37 if missing.is_empty() {
38 return Ok(config);
39 }
40
41 let interactive = std::io::stdin().is_terminal()
42 && std::io::stdout().is_terminal()
43 && std::env::var_os("XBP_NON_INTERACTIVE").is_none();
44
45 println!();
46 println!(
47 "{} {}",
48 "Deploy setup".bright_cyan().bold(),
49 format!("— missing deploy.envs.{env} for {} service(s)", missing.len()).dimmed()
50 );
51 for name in &missing {
52 println!(" {} {}", "•".bright_yellow(), name.bright_white());
53 }
54 println!();
55
56 let mut derived_all: Vec<(String, DerivedServiceDeploy)> = Vec::new();
57 for name in &missing {
58 let svc = config
59 .services
60 .as_ref()
61 .and_then(|list| list.iter().find(|s| s.name == *name))
62 .ok_or_else(|| format!("service `{name}` disappeared during deploy setup"))?;
63 let derived = derive_service_deploy(
64 project_root,
65 &config.project_name,
66 env,
67 svc,
68 config.kubernetes.as_ref(),
69 );
70 print_derived(name, env, &derived);
71 derived_all.push((name.clone(), derived));
72 }
73
74 if !interactive && !yes {
75 return Err(format!(
76 "service(s) missing deploy.envs.{env}: {}\n\n\
77 Derived defaults are shown above.\n\
78 Re-run in an interactive terminal to configure with the wizard, or pass `--yes` to accept derived defaults and write them to .xbp/xbp.toml.",
79 missing.join(", ")
80 ));
81 }
82
83 let proceed = if yes {
84 true
85 } else {
86 Confirm::with_theme(&ColorfulTheme::default())
87 .with_prompt(format!(
88 "Configure deploy.envs.{env} for {} service(s) and continue?",
89 missing.len()
90 ))
91 .default(true)
92 .interact()
93 .map_err(|e| e.to_string())?
94 };
95
96 if !proceed {
97 return Err(format!(
98 "deploy aborted: service(s) still missing deploy.envs.{env} ({})",
99 missing.join(", ")
100 ));
101 }
102
103 for (name, mut derived) in derived_all {
104 if interactive && !yes {
105 derived = refine_derived_interactively(name.as_str(), env, derived)?;
106 }
107 apply_derived_to_config(&mut config, &name, env, derived)?;
108 }
109
110 ensure_project_deploy_defaults(&mut config, env);
112
113 let config_path = resolve_config_write_path(project_root)?;
114 write_project_config(project_root, &config_path, &config)?;
115 println!(
116 "{} wrote deploy env config → {}",
117 "✓".bright_green().bold(),
118 config_path.display().to_string().dimmed()
119 );
120
121 Ok(config)
122}
123
124pub fn services_missing_deploy_env(
126 config: &XbpConfig,
127 target: &str,
128 env: &str,
129) -> Result<Vec<String>, String> {
130 let raw = target.trim();
131 if raw.is_empty() {
132 return Err("deploy target is required".into());
133 }
134
135 let services = config.services.as_deref().unwrap_or(&[]);
136
137 if raw.eq_ignore_ascii_case("all") {
138 let any = services.iter().any(|s| service_has_env(s, env));
141 if any {
142 return Ok(Vec::new());
143 }
144 return Ok(services.iter().map(|s| s.name.clone()).collect());
145 }
146
147 if let Some(group) = config
148 .deploy
149 .as_ref()
150 .and_then(|d| d.groups.get(raw))
151 {
152 let order = if group.order.is_empty() {
153 &group.services
154 } else {
155 &group.order
156 };
157 let mut missing = Vec::new();
158 for name in order {
159 let svc = services.iter().find(|s| s.name == *name).ok_or_else(|| {
160 format!("deploy group `{raw}` references unknown service `{name}`")
161 })?;
162 if !service_has_env(svc, env) {
163 missing.push(name.clone());
164 }
165 }
166 return Ok(missing);
167 }
168
169 if let Some(svc) = services.iter().find(|s| s.name == raw) {
170 return Ok(if service_has_env(svc, env) {
171 Vec::new()
172 } else {
173 vec![svc.name.clone()]
174 });
175 }
176
177 let group_keys: Vec<&String> = config
179 .deploy
180 .as_ref()
181 .map(|d| d.groups.keys().filter(|k| k.starts_with(raw)).collect())
182 .unwrap_or_default();
183 let service_names: Vec<&String> = services
184 .iter()
185 .map(|s| &s.name)
186 .filter(|n| n.starts_with(raw))
187 .collect();
188
189 if group_keys.len() == 1 && service_names.is_empty() {
190 return services_missing_deploy_env(config, group_keys[0], env);
191 }
192 if service_names.len() == 1 && group_keys.is_empty() {
193 return services_missing_deploy_env(config, service_names[0], env);
194 }
195
196 Ok(Vec::new())
198}
199
200fn service_has_env(svc: &ServiceConfig, env: &str) -> bool {
201 svc.deploy
202 .as_ref()
203 .map(|d| d.envs.contains_key(env))
204 .unwrap_or(false)
205}
206
207#[derive(Debug, Clone)]
208pub struct DerivedServiceDeploy {
209 pub provider: String,
210 pub namespace: Option<String>,
211 pub replicas: Option<u32>,
212 pub health: Vec<String>,
213 pub kubernetes: Option<ServiceDeployKubernetesConfig>,
214}
215
216pub fn derive_service_deploy(
218 project_root: &Path,
219 project_name: &str,
220 env: &str,
221 service: &ServiceConfig,
222 project_k8s: Option<&KubernetesProjectConfig>,
223) -> DerivedServiceDeploy {
224 let provider = derive_provider(&service.target);
225 let namespace = project_k8s
226 .and_then(|k| k.default_namespace.clone())
227 .filter(|s| !s.trim().is_empty())
228 .or_else(|| Some(sanitize_k8s_name(project_name)))
229 .filter(|s| !s.is_empty());
230
231 let health = derive_health_urls(service);
232 let kubernetes = if matches!(provider.as_str(), "kubernetes" | "kubernetes-operator") {
233 discover_kubernetes_block(project_root, service, env)
234 } else {
235 None
236 };
237
238 DerivedServiceDeploy {
239 provider,
240 namespace,
241 replicas: Some(1),
242 health,
243 kubernetes,
244 }
245}
246
247fn derive_provider(target: &str) -> String {
248 let t = target.trim().to_ascii_lowercase();
249 if t.contains("worker") || t.contains("cloudflare") {
250 return "worker".into();
251 }
252 if t == "kubernetes-operator" || t.contains("operator") {
253 return "kubernetes-operator".into();
254 }
255 "kubernetes".into()
258}
259
260fn derive_health_urls(service: &ServiceConfig) -> Vec<String> {
261 let Some(url) = service.url.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
262 return Vec::new();
263 };
264 let path = service
265 .healthcheck_path
266 .as_deref()
267 .map(str::trim)
268 .filter(|s| !s.is_empty())
269 .unwrap_or("/");
270
271 match join_health_url(url, path) {
272 Some(u) => vec![u],
273 None => {
274 if url.starts_with("http://") || url.starts_with("https://") {
276 vec![url.to_string()]
277 } else {
278 Vec::new()
279 }
280 }
281 }
282}
283
284fn join_health_url(base: &str, path: &str) -> Option<String> {
285 let base = base.trim().trim_end_matches('/');
286 if !(base.starts_with("http://") || base.starts_with("https://")) {
287 return None;
288 }
289 if path.trim().is_empty() || path.trim() == "/" {
290 return Some(format!("{base}/"));
291 }
292 let path = path.trim();
293 if path.starts_with('/') {
294 Some(format!("{base}{path}"))
295 } else {
296 Some(format!("{base}/{path}"))
297 }
298}
299
300fn sanitize_k8s_name(raw: &str) -> String {
301 let mut out = String::new();
302 for ch in raw.chars() {
303 let c = ch.to_ascii_lowercase();
304 if c.is_ascii_alphanumeric() {
305 out.push(c);
306 } else if c == '-' || c == '_' || c == '.' {
307 if !out.ends_with('-') {
308 out.push('-');
309 }
310 }
311 }
312 let out = out.trim_matches('-').to_string();
313 if out.is_empty() {
314 "default".into()
315 } else {
316 out.chars().take(63).collect()
317 }
318}
319
320fn discover_kubernetes_block(
321 project_root: &Path,
322 service: &ServiceConfig,
323 env: &str,
324) -> Option<ServiceDeployKubernetesConfig> {
325 let service_root = service
326 .root_directory
327 .as_deref()
328 .map(|r| project_root.join(r))
329 .unwrap_or_else(|| project_root.to_path_buf());
330
331 let base_candidates = [
332 service_root.join("k8s/base"),
333 service_root.join("kubernetes/base"),
334 service_root.join("deploy/k8s/base"),
335 service_root.join("manifests/base"),
336 project_root.join("k8s/base"),
337 project_root.join(format!("k8s/{}", service.name)),
338 project_root.join(format!("deploy/{}", service.name)),
339 ];
340 let overlay_candidates = [
341 service_root.join(format!("k8s/overlays/{env}")),
342 service_root.join(format!("kubernetes/overlays/{env}")),
343 service_root.join(format!("deploy/k8s/overlays/{env}")),
344 project_root.join(format!("k8s/overlays/{env}")),
345 project_root.join(format!("k8s/{}/overlays/{env}", service.name)),
346 ];
347
348 let base = base_candidates
349 .iter()
350 .find(|p| p.is_dir() || p.is_file())
351 .map(|p| collapse_rel(project_root, p));
352 let overlay = overlay_candidates
353 .iter()
354 .find(|p| p.is_dir() || p.is_file())
355 .map(|p| collapse_rel(project_root, p));
356
357 if base.is_none() && overlay.is_none() {
358 return Some(ServiceDeployKubernetesConfig {
360 manifests: None,
361 workload: Some(service.name.clone()),
362 service: Some(service.name.clone()),
363 crds_path: None,
364 install_path: None,
365 selector: Some(format!("app={}", service.name)),
366 });
367 }
368
369 Some(ServiceDeployKubernetesConfig {
370 manifests: Some(ServiceDeployManifestsConfig { base, overlay }),
371 workload: Some(service.name.clone()),
372 service: Some(service.name.clone()),
373 crds_path: None,
374 install_path: None,
375 selector: Some(format!("app={}", service.name)),
376 })
377}
378
379fn collapse_rel(project_root: &Path, path: &Path) -> String {
380 path.strip_prefix(project_root)
381 .map(|p| p.to_string_lossy().replace('\\', "/"))
382 .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
383}
384
385fn print_derived(service: &str, env: &str, derived: &DerivedServiceDeploy) {
386 println!(
387 "{} {} {}",
388 "derived".bright_black(),
389 service.bright_white().bold(),
390 format!("deploy.envs.{env}").dimmed()
391 );
392 println!(" provider: {}", derived.provider.bright_green());
393 println!(
394 " namespace: {}",
395 derived
396 .namespace
397 .as_deref()
398 .unwrap_or("(none)")
399 .bright_yellow()
400 );
401 if let Some(r) = derived.replicas {
402 println!(" replicas: {r}");
403 }
404 if derived.health.is_empty() {
405 println!(" health: {}", "(none)".dimmed());
406 } else {
407 println!(" health:");
408 for h in &derived.health {
409 println!(" - {}", h.bright_cyan());
410 }
411 }
412 if let Some(k8s) = &derived.kubernetes {
413 if let Some(m) = &k8s.manifests {
414 if let Some(base) = &m.base {
415 println!(" manifests.base: {base}");
416 }
417 if let Some(overlay) = &m.overlay {
418 println!(" manifests.overlay: {overlay}");
419 }
420 }
421 if let Some(w) = &k8s.workload {
422 println!(" workload: {w}");
423 }
424 }
425 println!();
426}
427
428fn refine_derived_interactively(
429 service: &str,
430 env: &str,
431 mut derived: DerivedServiceDeploy,
432) -> Result<DerivedServiceDeploy, String> {
433 let theme = ColorfulTheme::default();
434 let accept = Confirm::with_theme(&theme)
435 .with_prompt(format!(
436 "Accept derived deploy.envs.{env} for `{service}`?"
437 ))
438 .default(true)
439 .interact()
440 .map_err(|e| e.to_string())?;
441 if accept {
442 return Ok(derived);
443 }
444
445 let providers = ["kubernetes", "kubernetes-operator", "worker"];
446 let default_idx = providers
447 .iter()
448 .position(|p| *p == derived.provider.as_str())
449 .unwrap_or(0);
450 let idx = Select::with_theme(&theme)
451 .with_prompt("Deploy provider")
452 .items(&providers)
453 .default(default_idx)
454 .interact()
455 .map_err(|e| e.to_string())?;
456 derived.provider = providers[idx].to_string();
457
458 let ns: String = Input::with_theme(&theme)
459 .with_prompt("Kubernetes namespace (empty to clear)")
460 .with_initial_text(derived.namespace.clone().unwrap_or_default())
461 .allow_empty(true)
462 .interact_text()
463 .map_err(|e| e.to_string())?;
464 derived.namespace = {
465 let t = ns.trim();
466 if t.is_empty() {
467 None
468 } else {
469 Some(t.to_string())
470 }
471 };
472
473 let health_default = derived.health.join(", ");
474 let health_raw: String = Input::with_theme(&theme)
475 .with_prompt("Health URLs (comma-separated, empty for none)")
476 .with_initial_text(health_default)
477 .allow_empty(true)
478 .interact_text()
479 .map_err(|e| e.to_string())?;
480 derived.health = health_raw
481 .split(',')
482 .map(str::trim)
483 .filter(|s| !s.is_empty())
484 .map(str::to_string)
485 .collect();
486
487 if matches!(
488 derived.provider.as_str(),
489 "kubernetes" | "kubernetes-operator"
490 ) {
491 let mut k8s = derived.kubernetes.unwrap_or_default();
492 let base: String = Input::with_theme(&theme)
493 .with_prompt("Manifests base path (empty to skip)")
494 .with_initial_text(
495 k8s.manifests
496 .as_ref()
497 .and_then(|m| m.base.clone())
498 .unwrap_or_default(),
499 )
500 .allow_empty(true)
501 .interact_text()
502 .map_err(|e| e.to_string())?;
503 let overlay: String = Input::with_theme(&theme)
504 .with_prompt(format!("Manifests overlay path for {env} (empty to skip)"))
505 .with_initial_text(
506 k8s.manifests
507 .as_ref()
508 .and_then(|m| m.overlay.clone())
509 .unwrap_or_default(),
510 )
511 .allow_empty(true)
512 .interact_text()
513 .map_err(|e| e.to_string())?;
514 let workload: String = Input::with_theme(&theme)
515 .with_prompt("Workload name (Deployment/StatefulSet)")
516 .with_initial_text(k8s.workload.clone().unwrap_or_else(|| service.to_string()))
517 .allow_empty(true)
518 .interact_text()
519 .map_err(|e| e.to_string())?;
520
521 let base = empty_to_none(base);
522 let overlay = empty_to_none(overlay);
523 k8s.manifests = if base.is_some() || overlay.is_some() {
524 Some(ServiceDeployManifestsConfig { base, overlay })
525 } else {
526 None
527 };
528 k8s.workload = empty_to_none(workload);
529 k8s.service = k8s.service.or_else(|| Some(service.to_string()));
530 k8s.selector = k8s
531 .selector
532 .or_else(|| Some(format!("app={service}")));
533 derived.kubernetes = Some(k8s);
534 } else {
535 derived.kubernetes = None;
536 }
537
538 Ok(derived)
539}
540
541fn empty_to_none(s: String) -> Option<String> {
542 let t = s.trim();
543 if t.is_empty() {
544 None
545 } else {
546 Some(t.to_string())
547 }
548}
549
550fn apply_derived_to_config(
551 config: &mut XbpConfig,
552 service_name: &str,
553 env: &str,
554 derived: DerivedServiceDeploy,
555) -> Result<(), String> {
556 let services = config
557 .services
558 .as_mut()
559 .ok_or_else(|| "project has no services list".to_string())?;
560 let svc = services
561 .iter_mut()
562 .find(|s| s.name == service_name)
563 .ok_or_else(|| format!("service `{service_name}` not found in config"))?;
564
565 let mut deploy = svc.deploy.take().unwrap_or(ServiceDeployConfig {
566 provider: Some(derived.provider.clone()),
567 envs: HashMap::new(),
568 });
569 if deploy
571 .provider
572 .as_ref()
573 .map(|s| s.trim().is_empty())
574 .unwrap_or(true)
575 {
576 deploy.provider = Some(derived.provider.clone());
577 }
578
579 deploy.envs.insert(
580 env.to_string(),
581 ServiceDeployEnvConfig {
582 namespace: derived.namespace,
583 replicas: derived.replicas,
584 health: derived.health,
585 kubernetes: derived.kubernetes,
586 },
587 );
588 svc.deploy = Some(deploy);
589 Ok(())
590}
591
592fn ensure_project_deploy_defaults(config: &mut XbpConfig, env: &str) {
593 let deploy = config.deploy.get_or_insert_with(|| DeployProjectConfig {
594 default_env: Some(env.to_string()),
595 history_dir: Some(".xbp/deployments".into()),
596 lock_file: Some(".xbp/deploy-lock.json".into()),
597 groups: HashMap::new(),
598 });
599 if deploy.default_env.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
600 deploy.default_env = Some(env.to_string());
601 }
602 if deploy.history_dir.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
603 deploy.history_dir = Some(".xbp/deployments".into());
604 }
605 if deploy.lock_file.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
606 deploy.lock_file = Some(".xbp/deploy-lock.json".into());
607 }
608}
609
610fn resolve_config_write_path(project_root: &Path) -> Result<PathBuf, String> {
611 Ok(crate::utils::resolve_project_config_write_path(
612 project_root,
613 find_xbp_config_upwards(project_root).as_ref(),
614 ))
615}
616
617fn write_project_config(
618 project_root: &Path,
619 config_path: &Path,
620 config: &XbpConfig,
621) -> Result<(), String> {
622 let _ = project_root;
623 crate::utils::write_xbp_project_config_at_path(config_path, config)
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use crate::strategies::deployment_config::ServiceConfig;
630
631 fn svc(name: &str, target: &str, url: Option<&str>, health: Option<&str>) -> ServiceConfig {
632 ServiceConfig {
633 name: name.into(),
634 target: target.into(),
635 target_freeze: None,
636 branch: "main".into(),
637 port: 3000,
638 root_directory: Some("./".into()),
639 environment: None,
640 url: url.map(str::to_string),
641 healthcheck_path: health.map(str::to_string),
642 restart_policy: None,
643 restart_policy_max_failure_count: None,
644 start_wrapper: None,
645 commands: None,
646 force_run_from_root: None,
647 version_targets: None,
648 depends_on: None,
649 watch_paths: None,
650 versioning: None,
651 release: None,
652 systemd_service_name: None,
653 systemd: None,
654 openapi: None,
655 oci: None,
656 deploy: None,
657 discord: None,
658 file_associations: Vec::new(),
659 }
660 }
661
662 #[test]
663 fn derives_health_from_url_and_path() {
664 let d = derive_service_deploy(
665 Path::new("."),
666 "athena",
667 "production",
668 &svc(
669 "athena",
670 "docker-compose",
671 Some("https://mirror2.athena-cluster.com"),
672 Some("/"),
673 ),
674 None,
675 );
676 assert_eq!(d.provider, "kubernetes");
677 assert_eq!(d.namespace.as_deref(), Some("athena"));
678 assert_eq!(
679 d.health,
680 vec!["https://mirror2.athena-cluster.com/".to_string()]
681 );
682 }
683
684 #[test]
685 fn derives_worker_provider_for_cloudflare_target() {
686 let d = derive_service_deploy(
687 Path::new("."),
688 "demo",
689 "production",
690 &svc("edge", "cloudflare-worker", None, None),
691 None,
692 );
693 assert_eq!(d.provider, "worker");
694 }
695
696 fn minimal_config(services: Vec<ServiceConfig>) -> XbpConfig {
697 let services_json = serde_json::to_value(&services).expect("serialize services");
698 serde_json::from_value(serde_json::json!({
699 "project_name": "demo",
700 "version": "1.0.0",
701 "port": 3000,
702 "build_dir": ".",
703 "services": services_json,
704 }))
705 .expect("minimal XbpConfig")
706 }
707
708 #[test]
709 fn missing_env_lists_service() {
710 let config = minimal_config(vec![svc("api", "rust", None, None)]);
711 let missing = services_missing_deploy_env(&config, "api", "production").unwrap();
712 assert_eq!(missing, vec!["api".to_string()]);
713 }
714
715 #[test]
716 fn apply_derived_inserts_env() {
717 let mut config = minimal_config(vec![svc("api", "rust", None, None)]);
718 let derived = DerivedServiceDeploy {
719 provider: "kubernetes".into(),
720 namespace: Some("demo".into()),
721 replicas: Some(1),
722 health: vec![],
723 kubernetes: None,
724 };
725 apply_derived_to_config(&mut config, "api", "production", derived).unwrap();
726 let svc = &config.services.as_ref().unwrap()[0];
727 assert!(svc.deploy.as_ref().unwrap().envs.contains_key("production"));
728 }
729}