1use crate::backend::Backend;
9use crate::common::{
10 CatalogBounds, CreateBehavior, DeletionProtectionSpec, Encryption, IdentityDefaults,
11 MoverDefaults, NamespaceDeletePolicy, RepositoryMode, ScheduleDefaults,
12 default_namespace_delete_policy, default_repository_mode,
13};
14use crate::maintenance::RepositoryMaintenanceSpec;
15use crate::repository::{
16 BootstrapSpec, CatalogStatus, ObservedRepositoryParameters, RepositoryHealthSpec,
17 RepositoryHealthStatus, RepositoryParameters, RepositoryPhase, StorageStats,
18};
19use crate::server::{ClusterServerSpec, ServerStatus};
20use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
21use kube::CustomResource;
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24
25#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
27#[kube(
28 group = "kopiur.home-operations.com",
29 version = "v1alpha1",
30 kind = "ClusterRepository",
31 status = "ClusterRepositoryStatus",
32 shortname = "kopiacrepo",
33 category = "kopiur",
34 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
35 printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
36 printcolumn = r#"{"name":"Namespaces","type":"integer","jsonPath":".status.allowedNamespaceCount"}"#,
37 printcolumn = r#"{"name":"Server","type":"string","jsonPath":".status.server.endpoint"}"#,
38 printcolumn = r#"{"name":"IndexBlobs","type":"integer","jsonPath":".status.storageStats.indexBlobCount","priority":1}"#,
39 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
40)]
41#[schemars(extend("x-kubernetes-validations" = [
50 {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.splitter) == has(oldSelf.create.splitter) && (!has(self.create.splitter) || self.create.splitter == oldSelf.create.splitter))", "message": "create.splitter is immutable after creation"},
51 {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.hash) == has(oldSelf.create.hash) && (!has(self.create.hash) || self.create.hash == oldSelf.create.hash))", "message": "create.hash is immutable after creation"},
52 {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.encryption) == has(oldSelf.create.encryption) && (!has(self.create.encryption) || self.create.encryption == oldSelf.create.encryption))", "message": "create.encryption is immutable after creation"},
53 {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.ecc) == has(oldSelf.create.ecc) && (!has(self.create.ecc) || self.create.ecc == oldSelf.create.ecc))", "message": "create.ecc is immutable after creation"}
54]))]
55#[serde(rename_all = "camelCase")]
56pub struct ClusterRepositorySpec {
57 pub backend: Backend,
59 pub encryption: Encryption,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub create: Option<CreateBehavior>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub bootstrap: Option<BootstrapSpec>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub mover_defaults: Option<MoverDefaults>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub schedule_defaults: Option<ScheduleDefaults>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub catalog: Option<CatalogBounds>,
80 pub allowed_namespaces: AllowedNamespaces,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub identity_defaults: Option<IdentityDefaults>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub server: Option<ClusterServerSpec>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub maintenance: Option<RepositoryMaintenanceSpec>,
91 #[serde(default = "default_namespace_delete_policy")]
93 #[schemars(default = "default_namespace_delete_policy")]
94 pub on_namespace_delete: NamespaceDeletePolicy,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub deletion_protection: Option<DeletionProtectionSpec>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub credential_projection: Option<ClusterRepoCredentialProjection>,
101 #[serde(default = "default_repository_mode")]
103 #[schemars(default = "default_repository_mode")]
104 pub mode: RepositoryMode,
105 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
107 pub suspend: bool,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub health: Option<RepositoryHealthSpec>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub parameters: Option<RepositoryParameters>,
114}
115
116#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
118#[serde(rename_all = "camelCase")]
119pub struct ClusterRepoCredentialProjection {
120 #[serde(default)]
122 pub allowed: bool,
123}
124
125#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
127#[serde(rename_all = "camelCase")]
128pub enum AllowedNamespaces {
129 List(Vec<String>),
131 Selector(LabelSelector),
133 All(bool),
135}
136
137impl AllowedNamespaces {
138 pub fn kind_str(&self) -> &'static str {
148 match self {
149 AllowedNamespaces::List(_) => "List",
150 AllowedNamespaces::Selector(_) => "Selector",
151 AllowedNamespaces::All(_) => "All",
152 }
153 }
154}
155
156#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
158#[serde(rename_all = "camelCase")]
159pub struct ClusterRepositoryStatus {
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub phase: Option<RepositoryPhase>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub observed_generation: Option<i64>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub resolved_credential_version: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub unique_id: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub backend: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub allowed_namespace_count: Option<i64>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub storage_stats: Option<StorageStats>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub catalog: Option<CatalogStatus>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub server: Option<ServerStatus>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub last_reverify_at: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub health: Option<RepositoryHealthStatus>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub parameters: Option<ObservedRepositoryParameters>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub conditions: Vec<Condition>,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::testutil::from_yaml;
207 use kube::core::CustomResourceExt;
208
209 #[test]
210 fn cluster_repository_crd_metadata_is_correct() {
211 let crd = ClusterRepository::crd();
213 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
214 assert_eq!(crd.spec.names.kind, "ClusterRepository");
215 assert_eq!(crd.spec.scope, "Cluster");
217 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
218 }
219
220 #[test]
221 fn cluster_repository_roundtrip_matches_adr_shape() {
222 let yaml = r#"
224backend:
225 s3:
226 bucket: org-kopia-repo
227 prefix: ""
228 endpoint: s3.us-east-1.amazonaws.com
229 region: us-east-1
230 auth:
231 secretRef:
232 name: kopia-platform-creds
233 namespace: kopia-system
234encryption:
235 passwordSecretRef:
236 name: kopia-platform-creds
237 namespace: kopia-system
238 key: KOPIA_PASSWORD
239create:
240 enabled: true
241 encryption: AES256-GCM-HMAC-SHA256
242allowedNamespaces:
243 list: [production, staging, billing]
244identityDefaults:
245 hostnameExpr: "namespace"
246 usernameExpr: "namespace + '-' + policyName"
247catalog:
248 retain:
249 perIdentity: 50
250 maxAgeDays: 60
251 refreshInterval: 5m
252 fallbackNamespace: kopia-system
253"#;
254 let spec: ClusterRepositorySpec = from_yaml(yaml);
255 match &spec.backend {
256 Backend::S3(s3) => assert_eq!(s3.bucket, "org-kopia-repo"),
257 other => panic!("expected S3 backend, got {}", other.kind_str()),
258 }
259 match &spec.allowed_namespaces {
260 AllowedNamespaces::List(ns) => {
261 assert_eq!(ns, &["production", "staging", "billing"]);
262 }
263 other => panic!("expected List, got {}", other.kind_str()),
264 }
265 let id = spec.identity_defaults.as_ref().expect("identityDefaults");
266 assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
267 assert_eq!(
268 id.username_expr.as_deref(),
269 Some("namespace + '-' + policyName")
270 );
271 assert_eq!(
272 spec.catalog.as_ref().unwrap().fallback_namespace.as_deref(),
273 Some("kopia-system")
274 );
275
276 let json = serde_json::to_value(&spec).expect("serialize");
277 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
278 assert_eq!(spec, reparsed);
279 }
280
281 #[test]
282 fn allowed_namespaces_selector_variant() {
283 let v: AllowedNamespaces = from_yaml(
284 "selector:\n matchLabels: { kopiur.home-operations.com/tier: enterprise }\n",
285 );
286 assert_eq!(v.kind_str(), "Selector");
287 let json = serde_json::to_value(&v).unwrap();
288 assert_eq!(
289 json["selector"]["matchLabels"]["kopiur.home-operations.com/tier"],
290 "enterprise"
291 );
292 }
293
294 #[test]
295 fn allowed_namespaces_all_variant() {
296 let v: AllowedNamespaces = from_yaml("all: true\n");
297 assert_eq!(v.kind_str(), "All");
298 assert_eq!(serde_json::to_value(&v).unwrap()["all"], true);
299 }
300
301 #[test]
302 fn allowed_namespaces_unknown_variant_is_rejected() {
303 let value: serde_json::Value = serde_yaml::from_str("everyone: true\n").unwrap();
304 assert!(serde_json::from_value::<AllowedNamespaces>(value).is_err());
305 }
306
307 #[test]
308 fn schedule_defaults_timezone_round_trips() {
309 let yaml = r#"
310backend: { filesystem: { path: /repo } }
311encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
312allowedNamespaces: { all: true }
313scheduleDefaults:
314 timezone: America/New_York
315"#;
316 let spec: ClusterRepositorySpec = from_yaml(yaml);
317 assert_eq!(
318 spec.schedule_defaults
319 .as_ref()
320 .and_then(|d| d.timezone.as_deref()),
321 Some("America/New_York")
322 );
323 let json = serde_json::to_value(&spec).expect("serialize");
324 assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
325 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
326 assert_eq!(spec, reparsed);
327
328 let bare: ClusterRepositorySpec = from_yaml(
330 "backend: { filesystem: { path: /repo } }\n\
331 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
332 allowedNamespaces: { all: true }\n",
333 );
334 assert!(bare.schedule_defaults.is_none());
335 assert!(
336 serde_json::to_value(&bare)
337 .unwrap()
338 .get("scheduleDefaults")
339 .is_none(),
340 "absent scheduleDefaults must be elided"
341 );
342 }
343
344 #[test]
345 fn identity_defaults_cluster_round_trips() {
346 let yaml = r#"
349backend: { filesystem: { path: /repo } }
350encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
351allowedNamespaces: { all: true }
352identityDefaults:
353 cluster: east
354"#;
355 let spec: ClusterRepositorySpec = from_yaml(yaml);
356 let id = spec.identity_defaults.as_ref().expect("identityDefaults");
357 assert_eq!(id.cluster.as_deref(), Some("east"));
358 assert!(id.hostname_expr.is_none());
359 assert!(id.username_expr.is_none());
360
361 let json = serde_json::to_value(&spec).expect("serialize");
362 assert_eq!(json["identityDefaults"]["cluster"], "east");
363 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
364 assert_eq!(spec, reparsed);
365
366 let bare: ClusterRepositorySpec = from_yaml(
371 "backend: { filesystem: { path: /repo } }\n\
372 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
373 allowedNamespaces: { all: true }\n\
374 identityDefaults:\n hostnameExpr: namespace\n",
375 );
376 let id = bare.identity_defaults.as_ref().expect("identityDefaults");
377 assert!(id.cluster.is_none());
378 assert!(
379 serde_json::to_value(&bare).unwrap()["identityDefaults"]
380 .get("cluster")
381 .is_none(),
382 "absent identityDefaults.cluster must be elided"
383 );
384 }
385
386 #[test]
387 fn deletion_protection_threshold_schema_default_matches_the_constant() {
388 let crd = ClusterRepository::crd();
389 let json = serde_json::to_value(&crd).unwrap();
390 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
391 assert_eq!(
392 spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
393 serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
394 );
395 assert_eq!(
396 crate::consts::effective_mass_deletion_threshold(None),
397 crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
398 );
399 }
400
401 #[test]
402 fn deletion_protection_round_trips_on_cluster_repository() {
403 let yaml = r#"
404backend: { filesystem: { path: /repo } }
405encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
406allowedNamespaces: { all: true }
407deletionProtection:
408 threshold: 0
409"#;
410 let spec: ClusterRepositorySpec = from_yaml(yaml);
411 assert_eq!(
412 spec.deletion_protection.as_ref().and_then(|d| d.threshold),
413 Some(0)
414 );
415 assert_eq!(
416 crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
417 0,
418 "Some(0) must pass through as the disable sentinel"
419 );
420 let json = serde_json::to_value(&spec).expect("serialize");
421 assert_eq!(json["deletionProtection"]["threshold"], 0);
422 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
423 assert_eq!(spec, reparsed);
424
425 let bare: ClusterRepositorySpec = from_yaml(
427 "backend: { filesystem: { path: /repo } }\n\
428 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
429 allowedNamespaces: { all: true }\n",
430 );
431 assert!(bare.deletion_protection.is_none());
432 assert!(
433 serde_json::to_value(&bare)
434 .unwrap()
435 .get("deletionProtection")
436 .is_none(),
437 "absent deletionProtection must be elided"
438 );
439 }
440
441 #[test]
442 fn catalog_foreign_snapshots_round_trips_on_cluster_repository() {
443 use crate::common::ForeignSnapshots;
444
445 let yaml = r#"
446backend: { filesystem: { path: /repo } }
447encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
448allowedNamespaces: { all: true }
449identityDefaults:
450 cluster: east
451catalog:
452 fallbackNamespace: kopia-system
453 foreignSnapshots: Fallback
454"#;
455 let spec: ClusterRepositorySpec = from_yaml(yaml);
456 assert_eq!(
457 spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
458 Some(ForeignSnapshots::Fallback)
459 );
460 let json = serde_json::to_value(&spec).expect("serialize");
461 assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
462 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
463 assert_eq!(spec, reparsed);
464
465 let yaml_ignore = r#"
466backend: { filesystem: { path: /repo } }
467encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
468allowedNamespaces: { all: true }
469identityDefaults:
470 cluster: east
471catalog:
472 foreignSnapshots: Ignore
473"#;
474 let spec: ClusterRepositorySpec = from_yaml(yaml_ignore);
475 assert_eq!(
476 spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
477 Some(ForeignSnapshots::Ignore)
478 );
479
480 let bare: ClusterRepositorySpec = from_yaml(
482 "backend: { filesystem: { path: /repo } }\n\
483 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
484 allowedNamespaces: { all: true }\n\
485 catalog: {}\n",
486 );
487 assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
488 assert!(
489 serde_json::to_value(&bare).unwrap()["catalog"]
490 .get("foreignSnapshots")
491 .is_none(),
492 "absent catalog.foreignSnapshots must be elided"
493 );
494 }
495
496 #[test]
497 fn catalog_adoption_round_trips_on_cluster_repository() {
498 use crate::common::SnapshotAdoption;
499
500 let yaml = r#"
501backend: { filesystem: { path: /repo } }
502encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }
503allowedNamespaces: { all: true }
504catalog:
505 adoption: Ignore
506"#;
507 let spec: ClusterRepositorySpec = from_yaml(yaml);
508 assert_eq!(
509 spec.catalog.as_ref().and_then(|c| c.adoption),
510 Some(SnapshotAdoption::Ignore)
511 );
512 let json = serde_json::to_value(&spec).expect("serialize");
513 assert_eq!(json["catalog"]["adoption"], "Ignore");
514 let reparsed: ClusterRepositorySpec = serde_json::from_value(json).expect("reparse");
515 assert_eq!(spec, reparsed);
516
517 let bare: ClusterRepositorySpec = from_yaml(
519 "backend: { filesystem: { path: /repo } }\n\
520 encryption: { passwordSecretRef: { name: s, namespace: kopia-system } }\n\
521 allowedNamespaces: { all: true }\n\
522 catalog: {}\n",
523 );
524 assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
525 assert!(
526 serde_json::to_value(&bare).unwrap()["catalog"]
527 .get("adoption")
528 .is_none(),
529 "absent catalog.adoption must be elided"
530 );
531 }
532
533 #[test]
534 fn catalog_foreign_snapshots_unknown_variant_is_rejected() {
535 let value: serde_json::Value = serde_yaml::from_str("foreignSnapshots: Delete\n").unwrap();
536 assert!(serde_json::from_value::<crate::common::CatalogBounds>(value).is_err());
537 }
538
539 #[test]
540 fn catalog_foreign_snapshots_schema_carries_no_default() {
541 let crd = ClusterRepository::crd();
547 let json = serde_json::to_value(&crd).unwrap();
548 let prop = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
549 ["properties"]["catalog"]["properties"]["foreignSnapshots"];
550 assert!(
551 prop.get("default").is_none(),
552 "catalog.foreignSnapshots must NOT carry a schema default: {prop}"
553 );
554 assert_eq!(prop["enum"].as_array().map(|a| a.len()), Some(2), "{prop}");
556 }
557}