Skip to main content

xbp_cli/commands/deploy_engine/
setup.rs

1//! Interactive / derived setup for missing `services[].deploy.envs.<env>`.
2//!
3//! When `xbp deploy <target> --env <env>` hits a service with no deploy env,
4//! offer a wizard (TTY) or accept derived defaults (`--yes`) and write
5//! `.xbp/xbp.toml` before planning continues.
6
7use 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
20/// Ensure every service needed for `target`+`env` has `deploy.envs.<env>`.
21///
22/// Returns an updated config (possibly written to disk). On non-interactive
23/// terminals without `--yes`, returns a structured error that lists derived defaults.
24pub 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    // Project-level deploy defaults (default_env / history paths).
111    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
124/// Services referenced by `target` that lack `deploy.envs.<env>`.
125pub 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        // `all` only deploys services that already declare the env — if none do,
139        // offer to set up every known service.
140        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    // Unique prefix (same rules as engine, but only report missing for the match).
178    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    // Unknown target — let the engine produce the full suggestion list.
197    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
216/// Derive a sensible `deploy.envs.<env>` block from service target, URL, and tree layout.
217pub 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    // Default engine path for compose/docker/rust/next/etc. Plan still works with
256    // health-only when no manifests exist.
257    "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            // Bare URL without valid parse — only keep if it already looks like a URL.
275            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        // Still set workload so plan can describe a rollout wait when manifests are added later.
359        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    // Keep an existing non-empty provider; otherwise use derived.
570    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}