1use 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#[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 pub backend: Backend,
39 pub encryption: Encryption,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub create: Option<CreateBehavior>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub cache_defaults: Option<CacheDefaults>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub catalog: Option<CatalogBounds>,
54 pub allowed_namespaces: AllowedNamespaces,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub identity_defaults: Option<IdentityTemplate>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub maintenance: Option<RepositoryMaintenanceSpec>,
65}
66
67#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
74#[serde(rename_all = "camelCase")]
75pub enum AllowedNamespaces {
76 List(Vec<String>),
78 Selector(LabelSelector),
80 All(bool),
82}
83
84impl AllowedNamespaces {
85 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#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
106#[serde(rename_all = "camelCase")]
107pub struct IdentityTemplate {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub hostname_template: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub username_template: Option<String>,
116}
117
118#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ClusterRepositoryStatus {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub phase: Option<RepositoryPhase>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub observed_generation: Option<i64>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub unique_id: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub backend: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub allowed_namespace_count: Option<i64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub storage_stats: Option<StorageStats>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub catalog: Option<CatalogStatus>,
143 #[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 let crd = ClusterRepository::crd();
158 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
159 assert_eq!(crd.spec.names.kind, "ClusterRepository");
160 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 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}