Skip to main content

kopiur_api/
cluster_repository.rs

1//! The `ClusterRepository` CRD — a cluster-scoped, shared kopia repository
2//! operated by a platform team. ADR-0001 §3.2, ADR-0003 §3.2.
3//!
4//! Same spec surface as `Repository` (backend/encryption/create/cacheDefaults/
5//! catalog), plus a tenancy gate (`allowedNamespaces`) and per-namespace identity
6//! templating (`identityDefaults`).
7
8use crate::backend::Backend;
9use crate::common::{CacheDefaults, CatalogBounds, CreateBehavior, Encryption};
10use crate::maintenance::RepositoryMaintenanceSpec;
11use crate::repository::{CatalogStatus, RepositoryPhase, StorageStats};
12use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
13use kube::CustomResource;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// A shared kopia repository referenceable from allow-listed namespaces. ADR §3.2.
18///
19/// Cluster-scoped: note the absence of `namespaced` in `#[kube(...)]`. Secret/config
20/// references in `backend`/`encryption` therefore MUST carry an explicit `namespace`
21/// (webhook-enforced — the type system cannot express that requirement here).
22#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
23#[kube(
24    group = "kopiur.home-operations.com",
25    version = "v1alpha1",
26    kind = "ClusterRepository",
27    status = "ClusterRepositoryStatus",
28    shortname = "kopiacrepo",
29    category = "kopiur",
30    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
31    printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
32    printcolumn = r#"{"name":"Namespaces","type":"integer","jsonPath":".status.allowedNamespaceCount"}"#,
33    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
34)]
35#[serde(rename_all = "camelCase")]
36pub struct ClusterRepositorySpec {
37    /// Exactly one backend, enforced at the type level by the `Backend` enum. ADR §3.1.
38    pub backend: Backend,
39    /// Repository password, always a Secret reference. As this CR is cluster-scoped,
40    /// the ref MUST carry an explicit `namespace` (webhook-enforced). ADR §3.1/§3.2.
41    pub encryption: Encryption,
42    /// What to do when the repository does not yet exist. Same semantics as
43    /// `Repository.spec.create`. ADR §3.1/§3.2.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub create: Option<CreateBehavior>,
46    /// Cache sizing inherited by consumer `Backup`/`Restore` movers unless overridden. ADR §3.1.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub cache_defaults: Option<CacheDefaults>,
49    /// Bounds materialization of `origin: discovered` `Backup` CRs from the kopia
50    /// catalog. For a shared repo this also picks where to land discovered backups
51    /// via `catalog.fallbackNamespace`. ADR §3.1/§3.2.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub catalog: Option<CatalogBounds>,
54    /// Tenancy gate — webhook-enforced on every consumer CR. ADR §3.2.
55    pub allowed_namespaces: AllowedNamespaces,
56    /// Identity defaults applied when consumers don't override. ADR §3.2/§4.2.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub identity_defaults: Option<IdentityTemplate>,
59    /// Maintenance control. Default-managed: when absent or `enabled: true`, the
60    /// reconciler creates and owns a `Maintenance` CR for this cluster repository.
61    /// As `Maintenance` is namespaced, `maintenance.namespace` selects where it
62    /// lands (defaulting to the operator's namespace). ADR §3.2/§3.7.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub maintenance: Option<RepositoryMaintenanceSpec>,
65}
66
67/// The set of namespaces permitted to reference this `ClusterRepository`. ADR §3.2.
68///
69/// Externally-tagged: wire shape is `allowedNamespaces: { list: [...] }`,
70/// `{ selector: {...} }`, or `{ all: true }`. Exactly one variant by construction.
71///
72/// Not `Eq`: the `Selector` variant embeds `LabelSelector` (k8s-openapi, `PartialEq` only).
73#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
74#[serde(rename_all = "camelCase")]
75pub enum AllowedNamespaces {
76    /// Explicit namespace names.
77    List(Vec<String>),
78    /// Match namespaces by label.
79    Selector(LabelSelector),
80    /// Allow all namespaces (must be `true`; `false` is meaningless and rejected by webhook).
81    All(bool),
82}
83
84impl AllowedNamespaces {
85    /// Stable discriminant string for status/metrics.
86    ///
87    /// ```
88    /// use kopiur_api::cluster_repository::AllowedNamespaces;
89    ///
90    /// let ns = AllowedNamespaces::List(vec!["production".into(), "staging".into()]);
91    /// assert_eq!(ns.kind_str(), "List");
92    /// assert_eq!(AllowedNamespaces::All(true).kind_str(), "All");
93    /// ```
94    pub fn kind_str(&self) -> &'static str {
95        match self {
96            AllowedNamespaces::List(_) => "List",
97            AllowedNamespaces::Selector(_) => "Selector",
98            AllowedNamespaces::All(_) => "All",
99        }
100    }
101}
102
103/// Templates rendered (Jinja2-compatible) at admission to derive consumer identity
104/// when a `BackupConfig` doesn't override. ADR §3.2/§4.2.
105#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
106#[serde(rename_all = "camelCase")]
107pub struct IdentityTemplate {
108    /// Tera/Jinja2 template for the kopia identity *hostname*, rendered at admission
109    /// (e.g. `{{ .Namespace }}`). ADR §3.2/§4.2.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub hostname_template: Option<String>,
112    /// Tera/Jinja2 template for the kopia identity *username*, rendered at admission
113    /// (e.g. `{{ .Namespace }}-{{ .ConfigName }}`). ADR §3.2/§4.2.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub username_template: Option<String>,
116}
117
118/// Mirrors `RepositoryStatus` (ADR §3.1) plus `allowedNamespaceCount`. ADR §3.2.
119#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ClusterRepositoryStatus {
122    /// Current lifecycle phase (shared with `Repository`).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub phase: Option<RepositoryPhase>,
125    /// `metadata.generation` of the `spec` last reconciled; drives staleness detection.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub observed_generation: Option<i64>,
128    /// Kopia repository unique ID.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub unique_id: Option<String>,
131    /// Mirror of `spec.backend` discriminant for the print column.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub backend: Option<String>,
134    /// Number of namespaces currently resolved by `spec.allowedNamespaces`. ADR §3.2.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub allowed_namespace_count: Option<i64>,
137    /// Repository size and snapshot counts from the last catalog scan.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub storage_stats: Option<StorageStats>,
140    /// Catalog-materialization status (discovered-backup count, last refresh).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub catalog: Option<CatalogStatus>,
143    /// Standard Kubernetes conditions (e.g. `Connected`, `MaintenanceOwned`). ADR §3.2.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub conditions: Vec<Condition>,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::testutil::from_yaml;
152    use kube::core::CustomResourceExt;
153
154    #[test]
155    fn cluster_repository_crd_metadata_is_correct() {
156        // `crd()` exercises schema generation; mis-encoded enums panic here.
157        let crd = ClusterRepository::crd();
158        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
159        assert_eq!(crd.spec.names.kind, "ClusterRepository");
160        // Cluster-scoped: this is the load-bearing assertion vs. namespaced CRDs.
161        assert_eq!(crd.spec.scope, "Cluster");
162        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
163    }
164
165    #[test]
166    fn cluster_repository_roundtrip_matches_adr_shape() {
167        // Mirrors ADR-0001 §3.2 / §5.2.
168        let yaml = r#"
169backend:
170  s3:
171    bucket: org-kopia-repo
172    prefix: ""
173    endpoint: s3.us-east-1.amazonaws.com
174    region: us-east-1
175    auth:
176      secretRef:
177        name: kopia-platform-creds
178        namespace: kopia-system
179encryption:
180  passwordSecretRef:
181    name: kopia-platform-creds
182    namespace: kopia-system
183    key: KOPIA_PASSWORD
184create:
185  enabled: true
186  encryption: AES256-GCM-HMAC-SHA256
187allowedNamespaces:
188  list: [production, staging, billing]
189identityDefaults:
190  hostnameTemplate: "{{ .Namespace }}"
191  usernameTemplate: "{{ .Namespace }}-{{ .ConfigName }}"
192catalog:
193  retain:
194    perIdentity: 50
195    maxAgeDays: 60
196  refreshInterval: 5m
197  fallbackNamespace: kopia-system
198"#;
199        let spec: ClusterRepositorySpec = from_yaml(yaml);
200        match &spec.backend {
201            Backend::S3(s3) => assert_eq!(s3.bucket, "org-kopia-repo"),
202            other => panic!("expected S3 backend, got {}", other.kind_str()),
203        }
204        match &spec.allowed_namespaces {
205            AllowedNamespaces::List(ns) => {
206                assert_eq!(ns, &["production", "staging", "billing"]);
207            }
208            other => panic!("expected List, got {}", other.kind_str()),
209        }
210        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
211        assert_eq!(id.hostname_template.as_deref(), Some("{{ .Namespace }}"));
212        assert_eq!(
213            spec.catalog.as_ref().unwrap().fallback_namespace.as_deref(),
214            Some("kopia-system")
215        );
216
217        let json = serde_json::to_value(&spec).expect("serialize");
218        let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
219        assert_eq!(spec, reparsed);
220    }
221
222    #[test]
223    fn allowed_namespaces_selector_variant() {
224        let v: AllowedNamespaces = from_yaml(
225            "selector:\n  matchLabels: { kopiur.home-operations.com/tier: enterprise }\n",
226        );
227        assert_eq!(v.kind_str(), "Selector");
228        let json = serde_json::to_value(&v).unwrap();
229        assert_eq!(
230            json["selector"]["matchLabels"]["kopiur.home-operations.com/tier"],
231            "enterprise"
232        );
233    }
234
235    #[test]
236    fn allowed_namespaces_all_variant() {
237        let v: AllowedNamespaces = from_yaml("all: true\n");
238        assert_eq!(v.kind_str(), "All");
239        assert_eq!(serde_json::to_value(&v).unwrap()["all"], true);
240    }
241
242    #[test]
243    fn allowed_namespaces_unknown_variant_is_rejected() {
244        let value: serde_json::Value = serde_yaml::from_str("everyone: true\n").unwrap();
245        assert!(serde_json::from_value::<AllowedNamespaces>(value).is_err());
246    }
247}