use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use crate::strategies::deployment_config::{
DeployProjectConfig, KubernetesProjectConfig, ServiceDeployConfig, ServiceDeployEnvConfig,
ServiceDeployKubernetesConfig, ServiceDeployManifestsConfig, ServiceConfig, XbpConfig,
};
use crate::utils::find_xbp_config_upwards;
pub fn ensure_deploy_env_for_target(
project_root: &Path,
mut config: XbpConfig,
target: &str,
env: &str,
yes: bool,
) -> Result<XbpConfig, String> {
let env = env.trim();
if env.is_empty() {
return Err("deploy env name is empty".into());
}
let missing = services_missing_deploy_env(&config, target, env)?;
if missing.is_empty() {
return Ok(config);
}
let interactive = std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var_os("XBP_NON_INTERACTIVE").is_none();
println!();
println!(
"{} {}",
"Deploy setup".bright_cyan().bold(),
format!("— missing deploy.envs.{env} for {} service(s)", missing.len()).dimmed()
);
for name in &missing {
println!(" {} {}", "•".bright_yellow(), name.bright_white());
}
println!();
let mut derived_all: Vec<(String, DerivedServiceDeploy)> = Vec::new();
for name in &missing {
let svc = config
.services
.as_ref()
.and_then(|list| list.iter().find(|s| s.name == *name))
.ok_or_else(|| format!("service `{name}` disappeared during deploy setup"))?;
let derived = derive_service_deploy(
project_root,
&config.project_name,
env,
svc,
config.kubernetes.as_ref(),
);
print_derived(name, env, &derived);
derived_all.push((name.clone(), derived));
}
if !interactive && !yes {
return Err(format!(
"service(s) missing deploy.envs.{env}: {}\n\n\
Derived defaults are shown above.\n\
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.",
missing.join(", ")
));
}
let proceed = if yes {
true
} else {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Configure deploy.envs.{env} for {} service(s) and continue?",
missing.len()
))
.default(true)
.interact()
.map_err(|e| e.to_string())?
};
if !proceed {
return Err(format!(
"deploy aborted: service(s) still missing deploy.envs.{env} ({})",
missing.join(", ")
));
}
for (name, mut derived) in derived_all {
if interactive && !yes {
derived = refine_derived_interactively(name.as_str(), env, derived)?;
}
apply_derived_to_config(&mut config, &name, env, derived)?;
}
ensure_project_deploy_defaults(&mut config, env);
let config_path = resolve_config_write_path(project_root)?;
write_project_config(project_root, &config_path, &config)?;
println!(
"{} wrote deploy env config → {}",
"✓".bright_green().bold(),
config_path.display().to_string().dimmed()
);
Ok(config)
}
pub fn services_missing_deploy_env(
config: &XbpConfig,
target: &str,
env: &str,
) -> Result<Vec<String>, String> {
let raw = target.trim();
if raw.is_empty() {
return Err("deploy target is required".into());
}
let services = config.services.as_deref().unwrap_or(&[]);
if raw.eq_ignore_ascii_case("all") {
let any = services.iter().any(|s| service_has_env(s, env));
if any {
return Ok(Vec::new());
}
return Ok(services.iter().map(|s| s.name.clone()).collect());
}
if let Some(group) = config
.deploy
.as_ref()
.and_then(|d| d.groups.get(raw))
{
let order = if group.order.is_empty() {
&group.services
} else {
&group.order
};
let mut missing = Vec::new();
for name in order {
let svc = services.iter().find(|s| s.name == *name).ok_or_else(|| {
format!("deploy group `{raw}` references unknown service `{name}`")
})?;
if !service_has_env(svc, env) {
missing.push(name.clone());
}
}
return Ok(missing);
}
if let Some(svc) = services.iter().find(|s| s.name == raw) {
return Ok(if service_has_env(svc, env) {
Vec::new()
} else {
vec![svc.name.clone()]
});
}
let group_keys: Vec<&String> = config
.deploy
.as_ref()
.map(|d| d.groups.keys().filter(|k| k.starts_with(raw)).collect())
.unwrap_or_default();
let service_names: Vec<&String> = services
.iter()
.map(|s| &s.name)
.filter(|n| n.starts_with(raw))
.collect();
if group_keys.len() == 1 && service_names.is_empty() {
return services_missing_deploy_env(config, group_keys[0], env);
}
if service_names.len() == 1 && group_keys.is_empty() {
return services_missing_deploy_env(config, service_names[0], env);
}
Ok(Vec::new())
}
fn service_has_env(svc: &ServiceConfig, env: &str) -> bool {
svc.deploy
.as_ref()
.map(|d| d.envs.contains_key(env))
.unwrap_or(false)
}
#[derive(Debug, Clone)]
pub struct DerivedServiceDeploy {
pub provider: String,
pub namespace: Option<String>,
pub replicas: Option<u32>,
pub health: Vec<String>,
pub kubernetes: Option<ServiceDeployKubernetesConfig>,
}
pub fn derive_service_deploy(
project_root: &Path,
project_name: &str,
env: &str,
service: &ServiceConfig,
project_k8s: Option<&KubernetesProjectConfig>,
) -> DerivedServiceDeploy {
let provider = derive_provider(&service.target);
let namespace = project_k8s
.and_then(|k| k.default_namespace.clone())
.filter(|s| !s.trim().is_empty())
.or_else(|| Some(sanitize_k8s_name(project_name)))
.filter(|s| !s.is_empty());
let health = derive_health_urls(service);
let kubernetes = if matches!(provider.as_str(), "kubernetes" | "kubernetes-operator") {
discover_kubernetes_block(project_root, service, env)
} else {
None
};
DerivedServiceDeploy {
provider,
namespace,
replicas: Some(1),
health,
kubernetes,
}
}
fn derive_provider(target: &str) -> String {
let t = target.trim().to_ascii_lowercase();
if t.contains("worker") || t.contains("cloudflare") {
return "worker".into();
}
if t == "kubernetes-operator" || t.contains("operator") {
return "kubernetes-operator".into();
}
"kubernetes".into()
}
fn derive_health_urls(service: &ServiceConfig) -> Vec<String> {
let Some(url) = service.url.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
return Vec::new();
};
let path = service
.healthcheck_path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("/");
match join_health_url(url, path) {
Some(u) => vec![u],
None => {
if url.starts_with("http://") || url.starts_with("https://") {
vec![url.to_string()]
} else {
Vec::new()
}
}
}
}
fn join_health_url(base: &str, path: &str) -> Option<String> {
let base = base.trim().trim_end_matches('/');
if !(base.starts_with("http://") || base.starts_with("https://")) {
return None;
}
if path.trim().is_empty() || path.trim() == "/" {
return Some(format!("{base}/"));
}
let path = path.trim();
if path.starts_with('/') {
Some(format!("{base}{path}"))
} else {
Some(format!("{base}/{path}"))
}
}
fn sanitize_k8s_name(raw: &str) -> String {
let mut out = String::new();
for ch in raw.chars() {
let c = ch.to_ascii_lowercase();
if c.is_ascii_alphanumeric() {
out.push(c);
} else if c == '-' || c == '_' || c == '.' {
if !out.ends_with('-') {
out.push('-');
}
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() {
"default".into()
} else {
out.chars().take(63).collect()
}
}
fn discover_kubernetes_block(
project_root: &Path,
service: &ServiceConfig,
env: &str,
) -> Option<ServiceDeployKubernetesConfig> {
let service_root = service
.root_directory
.as_deref()
.map(|r| project_root.join(r))
.unwrap_or_else(|| project_root.to_path_buf());
let base_candidates = [
service_root.join("k8s/base"),
service_root.join("kubernetes/base"),
service_root.join("deploy/k8s/base"),
service_root.join("manifests/base"),
project_root.join("k8s/base"),
project_root.join(format!("k8s/{}", service.name)),
project_root.join(format!("deploy/{}", service.name)),
];
let overlay_candidates = [
service_root.join(format!("k8s/overlays/{env}")),
service_root.join(format!("kubernetes/overlays/{env}")),
service_root.join(format!("deploy/k8s/overlays/{env}")),
project_root.join(format!("k8s/overlays/{env}")),
project_root.join(format!("k8s/{}/overlays/{env}", service.name)),
];
let base = base_candidates
.iter()
.find(|p| p.is_dir() || p.is_file())
.map(|p| collapse_rel(project_root, p));
let overlay = overlay_candidates
.iter()
.find(|p| p.is_dir() || p.is_file())
.map(|p| collapse_rel(project_root, p));
if base.is_none() && overlay.is_none() {
return Some(ServiceDeployKubernetesConfig {
manifests: None,
workload: Some(service.name.clone()),
service: Some(service.name.clone()),
crds_path: None,
install_path: None,
selector: Some(format!("app={}", service.name)),
});
}
Some(ServiceDeployKubernetesConfig {
manifests: Some(ServiceDeployManifestsConfig { base, overlay }),
workload: Some(service.name.clone()),
service: Some(service.name.clone()),
crds_path: None,
install_path: None,
selector: Some(format!("app={}", service.name)),
})
}
fn collapse_rel(project_root: &Path, path: &Path) -> String {
path.strip_prefix(project_root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
}
fn print_derived(service: &str, env: &str, derived: &DerivedServiceDeploy) {
println!(
"{} {} {}",
"derived".bright_black(),
service.bright_white().bold(),
format!("deploy.envs.{env}").dimmed()
);
println!(" provider: {}", derived.provider.bright_green());
println!(
" namespace: {}",
derived
.namespace
.as_deref()
.unwrap_or("(none)")
.bright_yellow()
);
if let Some(r) = derived.replicas {
println!(" replicas: {r}");
}
if derived.health.is_empty() {
println!(" health: {}", "(none)".dimmed());
} else {
println!(" health:");
for h in &derived.health {
println!(" - {}", h.bright_cyan());
}
}
if let Some(k8s) = &derived.kubernetes {
if let Some(m) = &k8s.manifests {
if let Some(base) = &m.base {
println!(" manifests.base: {base}");
}
if let Some(overlay) = &m.overlay {
println!(" manifests.overlay: {overlay}");
}
}
if let Some(w) = &k8s.workload {
println!(" workload: {w}");
}
}
println!();
}
fn refine_derived_interactively(
service: &str,
env: &str,
mut derived: DerivedServiceDeploy,
) -> Result<DerivedServiceDeploy, String> {
let theme = ColorfulTheme::default();
let accept = Confirm::with_theme(&theme)
.with_prompt(format!(
"Accept derived deploy.envs.{env} for `{service}`?"
))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if accept {
return Ok(derived);
}
let providers = ["kubernetes", "kubernetes-operator", "worker"];
let default_idx = providers
.iter()
.position(|p| *p == derived.provider.as_str())
.unwrap_or(0);
let idx = Select::with_theme(&theme)
.with_prompt("Deploy provider")
.items(&providers)
.default(default_idx)
.interact()
.map_err(|e| e.to_string())?;
derived.provider = providers[idx].to_string();
let ns: String = Input::with_theme(&theme)
.with_prompt("Kubernetes namespace (empty to clear)")
.with_initial_text(derived.namespace.clone().unwrap_or_default())
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
derived.namespace = {
let t = ns.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
};
let health_default = derived.health.join(", ");
let health_raw: String = Input::with_theme(&theme)
.with_prompt("Health URLs (comma-separated, empty for none)")
.with_initial_text(health_default)
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
derived.health = health_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if matches!(
derived.provider.as_str(),
"kubernetes" | "kubernetes-operator"
) {
let mut k8s = derived.kubernetes.unwrap_or_default();
let base: String = Input::with_theme(&theme)
.with_prompt("Manifests base path (empty to skip)")
.with_initial_text(
k8s.manifests
.as_ref()
.and_then(|m| m.base.clone())
.unwrap_or_default(),
)
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let overlay: String = Input::with_theme(&theme)
.with_prompt(format!("Manifests overlay path for {env} (empty to skip)"))
.with_initial_text(
k8s.manifests
.as_ref()
.and_then(|m| m.overlay.clone())
.unwrap_or_default(),
)
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let workload: String = Input::with_theme(&theme)
.with_prompt("Workload name (Deployment/StatefulSet)")
.with_initial_text(k8s.workload.clone().unwrap_or_else(|| service.to_string()))
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let base = empty_to_none(base);
let overlay = empty_to_none(overlay);
k8s.manifests = if base.is_some() || overlay.is_some() {
Some(ServiceDeployManifestsConfig { base, overlay })
} else {
None
};
k8s.workload = empty_to_none(workload);
k8s.service = k8s.service.or_else(|| Some(service.to_string()));
k8s.selector = k8s
.selector
.or_else(|| Some(format!("app={service}")));
derived.kubernetes = Some(k8s);
} else {
derived.kubernetes = None;
}
Ok(derived)
}
fn empty_to_none(s: String) -> Option<String> {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
fn apply_derived_to_config(
config: &mut XbpConfig,
service_name: &str,
env: &str,
derived: DerivedServiceDeploy,
) -> Result<(), String> {
let services = config
.services
.as_mut()
.ok_or_else(|| "project has no services list".to_string())?;
let svc = services
.iter_mut()
.find(|s| s.name == service_name)
.ok_or_else(|| format!("service `{service_name}` not found in config"))?;
let mut deploy = svc.deploy.take().unwrap_or(ServiceDeployConfig {
provider: Some(derived.provider.clone()),
envs: HashMap::new(),
});
if deploy
.provider
.as_ref()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
deploy.provider = Some(derived.provider.clone());
}
deploy.envs.insert(
env.to_string(),
ServiceDeployEnvConfig {
namespace: derived.namespace,
replicas: derived.replicas,
health: derived.health,
kubernetes: derived.kubernetes,
},
);
svc.deploy = Some(deploy);
Ok(())
}
fn ensure_project_deploy_defaults(config: &mut XbpConfig, env: &str) {
let deploy = config.deploy.get_or_insert_with(|| DeployProjectConfig {
default_env: Some(env.to_string()),
history_dir: Some(".xbp/deployments".into()),
lock_file: Some(".xbp/deploy-lock.json".into()),
groups: HashMap::new(),
});
if deploy.default_env.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
deploy.default_env = Some(env.to_string());
}
if deploy.history_dir.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
deploy.history_dir = Some(".xbp/deployments".into());
}
if deploy.lock_file.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
deploy.lock_file = Some(".xbp/deploy-lock.json".into());
}
}
fn resolve_config_write_path(project_root: &Path) -> Result<PathBuf, String> {
Ok(crate::utils::resolve_project_config_write_path(
project_root,
find_xbp_config_upwards(project_root).as_ref(),
))
}
fn write_project_config(
project_root: &Path,
config_path: &Path,
config: &XbpConfig,
) -> Result<(), String> {
let _ = project_root;
crate::utils::write_xbp_project_config_at_path(config_path, config)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::strategies::deployment_config::ServiceConfig;
fn svc(name: &str, target: &str, url: Option<&str>, health: Option<&str>) -> ServiceConfig {
ServiceConfig {
name: name.into(),
target: target.into(),
target_freeze: None,
branch: "main".into(),
port: 3000,
root_directory: Some("./".into()),
environment: None,
url: url.map(str::to_string),
healthcheck_path: health.map(str::to_string),
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
depends_on: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
discord: None,
file_associations: Vec::new(),
}
}
#[test]
fn derives_health_from_url_and_path() {
let d = derive_service_deploy(
Path::new("."),
"athena",
"production",
&svc(
"athena",
"docker-compose",
Some("https://mirror2.athena-cluster.com"),
Some("/"),
),
None,
);
assert_eq!(d.provider, "kubernetes");
assert_eq!(d.namespace.as_deref(), Some("athena"));
assert_eq!(
d.health,
vec!["https://mirror2.athena-cluster.com/".to_string()]
);
}
#[test]
fn derives_worker_provider_for_cloudflare_target() {
let d = derive_service_deploy(
Path::new("."),
"demo",
"production",
&svc("edge", "cloudflare-worker", None, None),
None,
);
assert_eq!(d.provider, "worker");
}
fn minimal_config(services: Vec<ServiceConfig>) -> XbpConfig {
let services_json = serde_json::to_value(&services).expect("serialize services");
serde_json::from_value(serde_json::json!({
"project_name": "demo",
"version": "1.0.0",
"port": 3000,
"build_dir": ".",
"services": services_json,
}))
.expect("minimal XbpConfig")
}
#[test]
fn missing_env_lists_service() {
let config = minimal_config(vec![svc("api", "rust", None, None)]);
let missing = services_missing_deploy_env(&config, "api", "production").unwrap();
assert_eq!(missing, vec!["api".to_string()]);
}
#[test]
fn apply_derived_inserts_env() {
let mut config = minimal_config(vec![svc("api", "rust", None, None)]);
let derived = DerivedServiceDeploy {
provider: "kubernetes".into(),
namespace: Some("demo".into()),
replicas: Some(1),
health: vec![],
kubernetes: None,
};
apply_derived_to_config(&mut config, "api", "production", derived).unwrap();
let svc = &config.services.as_ref().unwrap()[0];
assert!(svc.deploy.as_ref().unwrap().envs.contains_key("production"));
}
}