use url::Url;
use crate::error::{DeployError, Result};
use crate::types::{DeployTarget, ProjectConfig};
pub fn validate_project_config(config: &ProjectConfig, env: &str) -> Result<()> {
validate_project_config_for_target(config, env, None)
}
pub fn validate_project_config_for_target(
config: &ProjectConfig,
env: &str,
target: Option<&DeployTarget>,
) -> Result<()> {
let mut errors: Vec<String> = Vec::new();
let only_service = match target {
Some(DeployTarget::Service(name)) => Some(name.as_str()),
_ => None,
};
for svc in &config.services {
if let Some(name) = only_service {
if svc.name != name {
continue;
}
}
let Some(deploy) = &svc.deploy else {
continue;
};
let Some(env_cfg) = deploy.envs.get(env) else {
continue;
};
if let Some(oci) = &svc.oci {
if oci.image.trim().is_empty() {
errors.push(format!("service `{}` oci.image is empty", svc.name));
}
}
let provider: &str = deploy.provider.as_str();
if crate::providers::is_kubernetes_provider(provider) {
let ns = env_cfg.namespace.clone().or_else(|| {
config
.kubernetes
.as_ref()
.and_then(|k| k.default_namespace.clone())
});
if ns.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
errors.push(format!(
"service `{}` kubernetes deploy requires namespace or kubernetes.default_namespace",
svc.name
));
}
}
for health in &env_cfg.health {
if Url::parse(health).is_err() {
errors.push(format!(
"service `{}` invalid health URL `{health}`",
svc.name
));
}
}
if svc.name.contains("athena") {
for path in [
env_cfg
.kubernetes
.as_ref()
.and_then(|k| k.crds_path.as_deref()),
env_cfg
.kubernetes
.as_ref()
.and_then(|k| k.install_path.as_deref()),
]
.into_iter()
.flatten()
{
if path.to_ascii_lowercase().contains("athenacluster") {
errors.push(format!(
"service `{}` must not reference AthenaCluster paths (`{path}`)",
svc.name
));
}
}
}
}
let check_groups = match target {
Some(DeployTarget::Service(_)) => false,
Some(DeployTarget::Group(_) | DeployTarget::All) | None => true,
};
if check_groups {
for (name, group) in &config.groups {
if let Some(DeployTarget::Group(wanted)) = target {
if name != wanted {
continue;
}
}
let members = if group.order.is_empty() {
&group.services
} else {
&group.order
};
let known: Vec<&str> = members
.iter()
.map(String::as_str)
.filter(|svc| config.services.iter().any(|s| s.name == *svc))
.collect();
let unknown: Vec<&str> = members
.iter()
.map(String::as_str)
.filter(|svc| !config.services.iter().any(|s| s.name == *svc))
.collect();
if !unknown.is_empty() && known.is_empty() {
errors.push(format!(
"deploy.groups.{name} has no known services (unknown: {})",
unknown.join(", ")
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(DeployError::Validation(errors.join("; ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn rejects_athenacluster_path() {
let mut envs: HashMap<String, ServiceDeployEnvView> = HashMap::new();
envs.insert(
"production".into(),
ServiceDeployEnvView {
env: Default::default(),
env_files: vec![],
config_files: vec![],
container_port: None,
required_env: vec![],
expose: None,
namespace: Some("athena-system".into()),
replicas: None,
health: vec!["https://example.com/health".into()],
kubernetes: Some(ServiceK8sView {
manifests_base: None,
manifests_overlay: None,
workload: None,
service: None,
crds_path: Some("config/athenacluster".into()),
install_path: None,
selector: None,
}),
},
);
let config = ProjectConfig {
project_name: "x".into(),
version: "1".into(),
project_root: PathBuf::from("."),
services: vec![ServiceConfigView {
environment: Default::default(),
port: None,
name: "athena-operator".into(),
root_directory: None,
version: None,
depends_on: vec![],
oci: None,
deploy: Some(ServiceDeployView {
provider: "kubernetes-operator".into(),
worker: None,
rollout: None,
destinations: Default::default(),
envs,
}),
}],
groups: HashMap::new(),
default_env: None,
kubernetes: None,
history_dir: PathBuf::from(".xbp/deployments"),
lock_file: PathBuf::from(".xbp/deploy-lock.json"),
git_sha: None,
};
let err = validate_project_config(&config, "production").unwrap_err();
assert!(err.to_string().contains("AthenaCluster"));
}
#[test]
fn single_service_ignores_stale_groups_all() {
let mut envs: HashMap<String, ServiceDeployEnvView> = HashMap::new();
envs.insert(
"production".into(),
ServiceDeployEnvView {
env: Default::default(),
env_files: vec![],
config_files: vec![],
container_port: None,
required_env: vec![],
expose: None,
namespace: Some("ns".into()),
replicas: None,
health: vec!["https://example.com/health".into()],
kubernetes: None,
},
);
let mut groups = HashMap::new();
groups.insert(
"all".into(),
DeployGroupView {
description: None,
services: vec![
"athena-studio".into(),
"@lexington/studio".into(),
"default".into(),
],
order: vec![],
},
);
let config = ProjectConfig {
project_name: "x".into(),
version: "1".into(),
project_root: PathBuf::from("."),
services: vec![ServiceConfigView {
environment: Default::default(),
port: None,
name: "athena-studio".into(),
root_directory: None,
version: None,
depends_on: vec![],
oci: None,
deploy: Some(ServiceDeployView {
provider: "kubernetes".into(),
worker: None,
rollout: None,
destinations: Default::default(),
envs,
}),
}],
groups,
default_env: Some("production".into()),
kubernetes: None,
history_dir: PathBuf::from(".xbp/deployments"),
lock_file: PathBuf::from(".xbp/deploy-lock.json"),
git_sha: None,
};
validate_project_config(&config, "production").unwrap();
validate_project_config_for_target(
&config,
"production",
Some(&DeployTarget::Service("athena-studio".into())),
)
.unwrap();
}
}