use std::collections::{BTreeMap, BTreeSet, HashSet};
use crate::error::{DeployError, Result};
use crate::lock::compute_plan_hash;
use crate::types::{DeployPlan, ServicePlan};
pub fn logical_service_name(step_or_name: &str) -> &str {
step_or_name
.split_once('@')
.map(|(base, _)| base)
.unwrap_or(step_or_name)
}
pub fn retain_services_in_plan(mut plan: DeployPlan, keep: &HashSet<String>) -> Result<DeployPlan> {
if keep.is_empty() {
return Err(DeployError::Validation(
"no services left in deploy plan after filter".into(),
));
}
plan.services
.retain(|svc| keep.contains(logical_service_name(&svc.name)));
if plan.services.is_empty() {
return Err(DeployError::Validation(
"no services left in deploy plan after filter".into(),
));
}
plan.order
.retain(|step| keep.contains(logical_service_name(step)));
plan.oci_plan.images = plan
.oci_plan
.images
.into_iter()
.filter(|(name, _)| keep.contains(logical_service_name(name)))
.collect::<BTreeMap<_, _>>();
plan.k8s_plan
.services
.retain(|svc| keep.contains(logical_service_name(&svc.service)));
let flag_ns = plan.k8s_plan.default_namespace.clone();
let mut service_ns = plan
.k8s_plan
.services
.iter()
.filter_map(|s| s.namespace.as_ref())
.map(|s| s.trim())
.filter(|s| !s.is_empty());
plan.k8s_plan.default_namespace = if let Some(first) = service_ns.next() {
if service_ns.all(|n| n == first) {
Some(first.to_string())
} else {
flag_ns
}
} else {
flag_ns
};
plan.hash = compute_plan_hash(&plan);
Ok(plan)
}
pub fn filter_deploy_plan(
plan: DeployPlan,
only: &[String],
exclude: &[String],
) -> Result<DeployPlan> {
let present: BTreeSet<String> = plan
.services
.iter()
.map(|s| logical_service_name(&s.name).to_string())
.collect();
if only.is_empty() && exclude.is_empty() {
return Ok(plan);
}
let only_norm: Vec<String> = only
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let exclude_norm: Vec<String> = exclude
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
validate_known_names(&present, &only_norm, "only")?;
validate_known_names(&present, &exclude_norm, "exclude")?;
let mut keep: HashSet<String> = if only_norm.is_empty() {
present.iter().cloned().collect()
} else {
only_norm.into_iter().collect()
};
for name in &exclude_norm {
keep.remove(name);
}
retain_services_in_plan(plan, &keep)
}
pub fn unique_service_names(plan: &DeployPlan) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for step in &plan.order {
let name = logical_service_name(step).to_string();
if seen.insert(name.clone()) {
out.push(name);
}
}
if out.is_empty() {
for svc in &plan.services {
let name = logical_service_name(&svc.name).to_string();
if seen.insert(name.clone()) {
out.push(name);
}
}
}
out
}
pub fn service_plan_labels(plan: &DeployPlan) -> Vec<(String, String)> {
let mut by_name: BTreeMap<String, &ServicePlan> = BTreeMap::new();
for svc in &plan.services {
by_name
.entry(logical_service_name(&svc.name).to_string())
.or_insert(svc);
}
unique_service_names(plan)
.into_iter()
.filter_map(|name| {
let svc = by_name.get(&name)?;
let ns = svc
.deploy
.namespace
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| format!(" · ns={s}"))
.unwrap_or_default();
let label = format!("{} · {}{}", name, svc.provider, ns);
Some((name, label))
})
.collect()
}
fn validate_known_names(
present: &BTreeSet<String>,
names: &[String],
flag: &str,
) -> Result<()> {
let mut unknown = Vec::new();
for name in names {
if !present.contains(name) {
unknown.push(name.clone());
}
}
if unknown.is_empty() {
return Ok(());
}
let suggestions: Vec<&str> = present.iter().map(String::as_str).take(12).collect();
Err(DeployError::Validation(format!(
"unknown service name(s) for --{flag}: {}\n\
services in plan: {}",
unknown.join(", "),
if suggestions.is_empty() {
"(none)".into()
} else {
suggestions.join(", ")
}
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
DeployTarget, K8sPlanView, OciImagePlan, OciPlan, ServiceDeployPlan,
};
fn svc(name: &str, provider: &str) -> ServicePlan {
ServicePlan {
name: name.into(),
provider: provider.into(),
destination: None,
root_directory: None,
version: "1.0.0".into(),
image: None,
image_ref: None,
digest: None,
dockerfile: None,
build_context: None,
platforms: vec![],
worker_app: None,
rollout: None,
runtime_env: Default::default(),
container_port: None,
config_mounts: vec![],
expose: None,
deploy: ServiceDeployPlan {
namespace: Some("default".into()),
workload: None,
service: None,
health: vec![],
manifest_paths: vec![],
crds_path: None,
install_path: None,
selector: None,
actions: vec![],
},
}
}
fn sample_plan() -> DeployPlan {
let mut plan = DeployPlan {
target: DeployTarget::All,
env: "production".into(),
project: "demo".into(),
project_version: "0.1.0".into(),
git_sha: None,
services: vec![
svc("api", "kubernetes"),
svc("worker", "kubernetes"),
svc("web", "cloudflare-containers"),
],
order: vec!["api".into(), "worker".into(), "web".into()],
oci_plan: OciPlan {
images: BTreeMap::from([(
"api".into(),
OciImagePlan {
ref_str: "ghcr.io/x/api:1".into(),
digest: Some("sha256:aaa".into()),
},
)]),
},
k8s_plan: K8sPlanView {
context: Some("docker-desktop".into()),
default_namespace: Some("default".into()),
services: vec![
xbp_k8s::K8sServicePlan {
service: "api".into(),
provider: "kubernetes".into(),
namespace: Some("default".into()),
workload: None,
manifest_paths: vec![],
crds_path: None,
install_path: None,
selector: None,
health: vec![],
},
xbp_k8s::K8sServicePlan {
service: "worker".into(),
provider: "kubernetes".into(),
namespace: Some("default".into()),
workload: None,
manifest_paths: vec![],
crds_path: None,
install_path: None,
selector: None,
health: vec![],
},
],
},
hash: String::new(),
};
plan.hash = compute_plan_hash(&plan);
plan
}
#[test]
fn only_keeps_named_services() {
let plan = sample_plan();
let filtered =
filter_deploy_plan(plan, &["api".into(), "web".into()], &[]).expect("filter");
let names = unique_service_names(&filtered);
assert_eq!(names, vec!["api".to_string(), "web".to_string()]);
assert_eq!(filtered.services.len(), 2);
assert!(filtered.k8s_plan.services.iter().all(|s| s.service == "api"));
assert!(filtered.oci_plan.images.contains_key("api"));
assert!(!filtered.hash.is_empty());
}
#[test]
fn exclude_drops_services() {
let plan = sample_plan();
let filtered = filter_deploy_plan(plan, &[], &["worker".into()]).expect("filter");
assert_eq!(
unique_service_names(&filtered),
vec!["api".to_string(), "web".to_string()]
);
}
#[test]
fn only_then_exclude() {
let plan = sample_plan();
let filtered =
filter_deploy_plan(plan, &["api".into(), "worker".into()], &["worker".into()])
.expect("filter");
assert_eq!(unique_service_names(&filtered), vec!["api".to_string()]);
}
#[test]
fn empty_result_errors() {
let plan = sample_plan();
let err = filter_deploy_plan(plan, &[], &["api".into(), "worker".into(), "web".into()])
.unwrap_err();
assert!(err.to_string().contains("no services left"));
}
#[test]
fn unknown_name_errors() {
let plan = sample_plan();
let err = filter_deploy_plan(plan, &["nope".into()], &[]).unwrap_err();
assert!(err.to_string().contains("unknown service"));
assert!(err.to_string().contains("nope"));
}
#[test]
fn destination_steps_match_logical_name() {
let mut plan = sample_plan();
plan.services[0].name = "api@kubernetes".into();
plan.order[0] = "api@kubernetes".into();
plan.hash = compute_plan_hash(&plan);
let filtered = filter_deploy_plan(plan, &["api".into()], &[]).expect("filter");
assert_eq!(filtered.services.len(), 1);
assert_eq!(logical_service_name(&filtered.services[0].name), "api");
}
}