Skip to main content

hyperi_rustlib/deployment/
app_project.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/app_project.rs
3// Purpose:   Generate ArgoCD AppProject CRs
4//
5// License:   BUSL-1.1
6// Copyright: (c) 2026 HYPERI PTY LIMITED
7
8//! ArgoCD `AppProject` generator.
9//!
10//! [`AppProjectContract`] describes a per-team or per-business-unit
11//! ArgoCD project with restricted `sourceRepos`, `destinations`, and
12//! `roles`. Consumer Applications reference the project via their
13//! `spec.project` field.
14//!
15//! See the spec section 3.5 in
16//! `docs/superpowers/specs/2026-05-15-argocd-enterprise-enhancements-spec.md`.
17
18use std::fmt::Write as _;
19
20/// Cluster + namespace pair an AppProject may target.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AppProjectDestination {
23    /// Cluster server URL (e.g. `https://kubernetes.default.svc`).
24    pub server: String,
25    /// Namespace the project may deploy into. Use `"*"` to allow any.
26    pub namespace: String,
27}
28
29/// `AppProject` declaration.
30///
31/// Becomes one `argoproj.io/v1alpha1 AppProject` CR scoped to a team.
32#[derive(Debug, Clone)]
33pub struct AppProjectContract {
34    /// Project name. Used as `metadata.name`. Lowercase, RFC-1123-ish.
35    pub name: String,
36    /// ArgoCD namespace (where the CR lives). Usually `argocd`.
37    pub argocd_namespace: String,
38    /// Human-readable description. Becomes `spec.description`.
39    pub description: String,
40    /// Source repositories permitted for Applications in this project.
41    /// Use `["*"]` for unrestricted (not recommended for enterprise).
42    pub source_repos: Vec<String>,
43    /// Cluster/namespace pairs permitted for Applications in this project.
44    pub destinations: Vec<AppProjectDestination>,
45    /// Cluster-scoped resource kinds that Applications may create.
46    /// Format: `["<group>:<kind>"]` e.g. `["kafka.strimzi.io:KafkaTopic"]`.
47    /// Use `["*:*"]` for unrestricted.
48    pub cluster_resource_allow: Vec<String>,
49    /// Namespaced resource kinds that Applications may create.
50    /// Same format as `cluster_resource_allow`.
51    pub namespace_resource_allow: Vec<String>,
52}
53
54impl Default for AppProjectContract {
55    fn default() -> Self {
56        Self {
57            name: String::new(),
58            argocd_namespace: "argocd".into(),
59            description: String::new(),
60            source_repos: vec!["*".into()],
61            destinations: vec![AppProjectDestination {
62                server: "https://kubernetes.default.svc".into(),
63                namespace: "*".into(),
64            }],
65            cluster_resource_allow: vec!["*:*".into()],
66            namespace_resource_allow: vec!["*:*".into()],
67        }
68    }
69}
70
71/// Generate an `AppProject` YAML manifest from a contract.
72#[must_use]
73pub fn generate_argocd_app_project(project: &AppProjectContract) -> String {
74    let mut out = String::new();
75    let _ = writeln!(out, "# AUTOGENERATED -- do not edit by hand.");
76    let _ = writeln!(
77        out,
78        "# Generated by hyperi-rustlib::deployment::generate_argocd_app_project()"
79    );
80    let _ = writeln!(out, "apiVersion: argoproj.io/v1alpha1");
81    let _ = writeln!(out, "kind: AppProject");
82    let _ = writeln!(out, "metadata:");
83    let _ = writeln!(out, "  name: {}", project.name);
84    let _ = writeln!(out, "  namespace: {}", project.argocd_namespace);
85    let _ = writeln!(out, "spec:");
86    let _ = writeln!(
87        out,
88        "  description: {}",
89        quote_if_needed(&project.description)
90    );
91    let _ = writeln!(out, "  sourceRepos:");
92    for repo in &project.source_repos {
93        let _ = writeln!(out, "    - {repo}");
94    }
95    let _ = writeln!(out, "  destinations:");
96    for dest in &project.destinations {
97        let _ = writeln!(out, "    - server: {}", dest.server);
98        let _ = writeln!(out, "      namespace: {}", dest.namespace);
99    }
100    let _ = writeln!(out, "  clusterResourceWhitelist:");
101    for entry in &project.cluster_resource_allow {
102        let (group, kind) = split_resource(entry);
103        let _ = writeln!(out, "    - group: {group}");
104        let _ = writeln!(out, "      kind: {kind}");
105    }
106    let _ = writeln!(out, "  namespaceResourceWhitelist:");
107    for entry in &project.namespace_resource_allow {
108        let (group, kind) = split_resource(entry);
109        let _ = writeln!(out, "    - group: {group}");
110        let _ = writeln!(out, "      kind: {kind}");
111    }
112    out
113}
114
115fn split_resource(entry: &str) -> (&str, &str) {
116    entry.split_once(':').unwrap_or(("*", "*"))
117}
118
119fn quote_if_needed(s: &str) -> String {
120    // Quote if contains special YAML characters or is empty
121    if s.contains(':') || s.contains('#') || s.is_empty() {
122        format!("\"{s}\"")
123    } else {
124        s.to_string()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn sample_project() -> AppProjectContract {
133        AppProjectContract {
134            name: "hyperi-platform".into(),
135            description: "HyperI platform team".into(),
136            source_repos: vec![
137                "https://github.com/hyperi-io/gitops".into(),
138                "oci://ghcr.io/hyperi-io/helm-charts".into(),
139            ],
140            destinations: vec![AppProjectDestination {
141                server: "https://kubernetes.default.svc".into(),
142                namespace: "hyperi-dfe".into(),
143            }],
144            cluster_resource_allow: vec!["kafka.strimzi.io:KafkaTopic".into()],
145            namespace_resource_allow: vec!["*:*".into()],
146            ..Default::default()
147        }
148    }
149
150    #[test]
151    fn generate_produces_appproject_yaml() {
152        let yaml = generate_argocd_app_project(&sample_project());
153        assert!(yaml.contains("kind: AppProject"));
154        assert!(yaml.contains("name: hyperi-platform"));
155    }
156
157    #[test]
158    fn includes_source_repos() {
159        let yaml = generate_argocd_app_project(&sample_project());
160        assert!(yaml.contains("https://github.com/hyperi-io/gitops"));
161        assert!(yaml.contains("oci://ghcr.io/hyperi-io/helm-charts"));
162    }
163
164    #[test]
165    fn includes_destinations() {
166        let yaml = generate_argocd_app_project(&sample_project());
167        assert!(yaml.contains("server: https://kubernetes.default.svc"));
168        assert!(yaml.contains("namespace: hyperi-dfe"));
169    }
170
171    #[test]
172    fn cluster_resource_allow_splits_group_kind() {
173        let yaml = generate_argocd_app_project(&sample_project());
174        assert!(yaml.contains("group: kafka.strimzi.io"));
175        assert!(yaml.contains("kind: KafkaTopic"));
176    }
177
178    #[test]
179    fn star_kind_split_handles_namespace_wildcards() {
180        let yaml = generate_argocd_app_project(&sample_project());
181        // namespace_resource_allow = ["*:*"]
182        assert!(yaml.contains("group: *"));
183        assert!(yaml.contains("kind: *"));
184    }
185
186    #[test]
187    fn default_contract_yields_unrestricted_project() {
188        let default = AppProjectContract::default();
189        assert_eq!(default.source_repos, vec!["*"]);
190        assert_eq!(default.cluster_resource_allow, vec!["*:*"]);
191    }
192
193    #[test]
194    fn description_with_colon_is_quoted() {
195        let project = AppProjectContract {
196            name: "test".into(),
197            description: "Team: platform".into(),
198            ..Default::default()
199        };
200        let yaml = generate_argocd_app_project(&project);
201        assert!(yaml.contains("description: \"Team: platform\""));
202    }
203
204    #[test]
205    fn empty_description_is_quoted() {
206        let project = AppProjectContract {
207            name: "test".into(),
208            description: String::new(),
209            ..Default::default()
210        };
211        let yaml = generate_argocd_app_project(&project);
212        assert!(yaml.contains("description: \"\""));
213    }
214
215    #[test]
216    fn multiple_destinations_all_present() {
217        let project = AppProjectContract {
218            name: "multi".into(),
219            destinations: vec![
220                AppProjectDestination {
221                    server: "https://prod.k8s".into(),
222                    namespace: "prod".into(),
223                },
224                AppProjectDestination {
225                    server: "https://staging.k8s".into(),
226                    namespace: "staging".into(),
227                },
228            ],
229            ..Default::default()
230        };
231        let yaml = generate_argocd_app_project(&project);
232        assert!(yaml.contains("server: https://prod.k8s"));
233        assert!(yaml.contains("namespace: prod"));
234        assert!(yaml.contains("server: https://staging.k8s"));
235        assert!(yaml.contains("namespace: staging"));
236    }
237}