use std::fmt::Write as _;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppProjectDestination {
pub server: String,
pub namespace: String,
}
#[derive(Debug, Clone)]
pub struct AppProjectContract {
pub name: String,
pub argocd_namespace: String,
pub description: String,
pub source_repos: Vec<String>,
pub destinations: Vec<AppProjectDestination>,
pub cluster_resource_allow: Vec<String>,
pub namespace_resource_allow: Vec<String>,
}
impl Default for AppProjectContract {
fn default() -> Self {
Self {
name: String::new(),
argocd_namespace: "argocd".into(),
description: String::new(),
source_repos: vec!["*".into()],
destinations: vec![AppProjectDestination {
server: "https://kubernetes.default.svc".into(),
namespace: "*".into(),
}],
cluster_resource_allow: vec!["*:*".into()],
namespace_resource_allow: vec!["*:*".into()],
}
}
}
#[must_use]
pub fn generate_argocd_app_project(project: &AppProjectContract) -> String {
let mut out = String::new();
let _ = writeln!(out, "# AUTOGENERATED — do not edit by hand.");
let _ = writeln!(
out,
"# Generated by hyperi-rustlib::deployment::generate_argocd_app_project()"
);
let _ = writeln!(out, "apiVersion: argoproj.io/v1alpha1");
let _ = writeln!(out, "kind: AppProject");
let _ = writeln!(out, "metadata:");
let _ = writeln!(out, " name: {}", project.name);
let _ = writeln!(out, " namespace: {}", project.argocd_namespace);
let _ = writeln!(out, "spec:");
let _ = writeln!(
out,
" description: {}",
quote_if_needed(&project.description)
);
let _ = writeln!(out, " sourceRepos:");
for repo in &project.source_repos {
let _ = writeln!(out, " - {repo}");
}
let _ = writeln!(out, " destinations:");
for dest in &project.destinations {
let _ = writeln!(out, " - server: {}", dest.server);
let _ = writeln!(out, " namespace: {}", dest.namespace);
}
let _ = writeln!(out, " clusterResourceWhitelist:");
for entry in &project.cluster_resource_allow {
let (group, kind) = split_resource(entry);
let _ = writeln!(out, " - group: {group}");
let _ = writeln!(out, " kind: {kind}");
}
let _ = writeln!(out, " namespaceResourceWhitelist:");
for entry in &project.namespace_resource_allow {
let (group, kind) = split_resource(entry);
let _ = writeln!(out, " - group: {group}");
let _ = writeln!(out, " kind: {kind}");
}
out
}
fn split_resource(entry: &str) -> (&str, &str) {
entry.split_once(':').unwrap_or(("*", "*"))
}
fn quote_if_needed(s: &str) -> String {
if s.contains(':') || s.contains('#') || s.is_empty() {
format!("\"{s}\"")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_project() -> AppProjectContract {
AppProjectContract {
name: "hyperi-platform".into(),
description: "HyperI platform team".into(),
source_repos: vec![
"https://github.com/hyperi-io/gitops".into(),
"oci://ghcr.io/hyperi-io/helm-charts".into(),
],
destinations: vec![AppProjectDestination {
server: "https://kubernetes.default.svc".into(),
namespace: "hyperi-dfe".into(),
}],
cluster_resource_allow: vec!["kafka.strimzi.io:KafkaTopic".into()],
namespace_resource_allow: vec!["*:*".into()],
..Default::default()
}
}
#[test]
fn generate_produces_appproject_yaml() {
let yaml = generate_argocd_app_project(&sample_project());
assert!(yaml.contains("kind: AppProject"));
assert!(yaml.contains("name: hyperi-platform"));
}
#[test]
fn includes_source_repos() {
let yaml = generate_argocd_app_project(&sample_project());
assert!(yaml.contains("https://github.com/hyperi-io/gitops"));
assert!(yaml.contains("oci://ghcr.io/hyperi-io/helm-charts"));
}
#[test]
fn includes_destinations() {
let yaml = generate_argocd_app_project(&sample_project());
assert!(yaml.contains("server: https://kubernetes.default.svc"));
assert!(yaml.contains("namespace: hyperi-dfe"));
}
#[test]
fn cluster_resource_allow_splits_group_kind() {
let yaml = generate_argocd_app_project(&sample_project());
assert!(yaml.contains("group: kafka.strimzi.io"));
assert!(yaml.contains("kind: KafkaTopic"));
}
#[test]
fn star_kind_split_handles_namespace_wildcards() {
let yaml = generate_argocd_app_project(&sample_project());
assert!(yaml.contains("group: *"));
assert!(yaml.contains("kind: *"));
}
#[test]
fn default_contract_yields_unrestricted_project() {
let default = AppProjectContract::default();
assert_eq!(default.source_repos, vec!["*"]);
assert_eq!(default.cluster_resource_allow, vec!["*:*"]);
}
#[test]
fn description_with_colon_is_quoted() {
let project = AppProjectContract {
name: "test".into(),
description: "Team: platform".into(),
..Default::default()
};
let yaml = generate_argocd_app_project(&project);
assert!(yaml.contains("description: \"Team: platform\""));
}
#[test]
fn empty_description_is_quoted() {
let project = AppProjectContract {
name: "test".into(),
description: String::new(),
..Default::default()
};
let yaml = generate_argocd_app_project(&project);
assert!(yaml.contains("description: \"\""));
}
#[test]
fn multiple_destinations_all_present() {
let project = AppProjectContract {
name: "multi".into(),
destinations: vec![
AppProjectDestination {
server: "https://prod.k8s".into(),
namespace: "prod".into(),
},
AppProjectDestination {
server: "https://staging.k8s".into(),
namespace: "staging".into(),
},
],
..Default::default()
};
let yaml = generate_argocd_app_project(&project);
assert!(yaml.contains("server: https://prod.k8s"));
assert!(yaml.contains("namespace: prod"));
assert!(yaml.contains("server: https://staging.k8s"));
assert!(yaml.contains("namespace: staging"));
}
}