1use crate::backend::Backend;
4use crate::common::{
5 CatalogBounds, CreateBehavior, DeletionProtectionSpec, Encryption, FailurePolicy,
6 IdentityDefaults, MoverDefaults, NamespaceDeletePolicy, RepositoryMode, ScheduleDefaults,
7 default_namespace_delete_policy, default_repository_mode,
8};
9use crate::maintenance::RepositoryMaintenanceSpec;
10use crate::server::{ServerSpec, ServerStatus};
11use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
12use kube::CustomResource;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
18#[kube(
19 group = "kopiur.home-operations.com",
20 version = "v1alpha1",
21 kind = "Repository",
22 namespaced,
23 status = "RepositoryStatus",
24 shortname = "kopiarepo",
25 category = "kopiur",
26 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
27 printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
28 printcolumn = r#"{"name":"Server","type":"string","jsonPath":".status.server.endpoint"}"#,
29 printcolumn = r#"{"name":"IndexBlobs","type":"integer","jsonPath":".status.storageStats.indexBlobCount","priority":1}"#,
30 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
31)]
32#[schemars(extend("x-kubernetes-validations" = [
45 {"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"},
46 {"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"},
47 {"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"},
48 {"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"}
49]))]
50#[serde(rename_all = "camelCase")]
51pub struct RepositorySpec {
52 pub backend: Backend,
54 pub encryption: Encryption,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub create: Option<CreateBehavior>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub bootstrap: Option<BootstrapSpec>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub mover_defaults: Option<MoverDefaults>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub schedule_defaults: Option<ScheduleDefaults>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub catalog: Option<CatalogBounds>,
75 #[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<ServerSpec>,
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 = "default_repository_mode")]
100 #[schemars(default = "default_repository_mode")]
101 pub mode: RepositoryMode,
102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
104 pub suspend: bool,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub health: Option<RepositoryHealthSpec>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub parameters: Option<RepositoryParameters>,
111}
112
113#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
117#[serde(rename_all = "camelCase")]
118pub struct BootstrapSpec {
119 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub failure_policy: Option<FailurePolicy>,
127}
128
129#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
142#[serde(rename_all = "camelCase")]
143pub struct RepositoryParameters {
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub epoch: Option<EpochParameters>,
148}
149
150#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
165#[serde(rename_all = "camelCase")]
166pub struct EpochParameters {
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub min_duration: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub refresh_frequency: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub advance_on_count: Option<i64>,
178 #[serde(
187 default,
188 rename = "advanceOnSizeMiB",
189 skip_serializing_if = "Option::is_none"
190 )]
191 pub advance_on_size_mb: Option<i64>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub checkpoint_frequency: Option<i64>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub delete_parallelism: Option<i64>,
198}
199
200#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
212#[serde(rename_all = "camelCase")]
213pub struct ObservedEpochParameters {
214 pub enabled: bool,
216 pub min_duration: String,
218 pub refresh_frequency: String,
220 pub cleanup_safety_margin: String,
223 pub advance_on_count: i64,
225 #[serde(rename = "advanceOnSizeMiB")]
227 pub advance_on_size_mb: i64,
228 pub checkpoint_frequency: i64,
230 pub delete_parallelism: i64,
232}
233
234#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
236#[serde(rename_all = "camelCase")]
237pub struct ObservedRepositoryParameters {
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub epoch: Option<ObservedEpochParameters>,
241}
242
243#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct RepositoryHealthSpec {
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 #[schemars(default = "default_index_blob_warn_threshold")]
250 pub index_blob_warn_threshold: Option<i64>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub probe: Option<RepositoryHealthProbeSpec>,
255}
256
257fn default_index_blob_warn_threshold() -> Option<i64> {
264 Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
265}
266
267#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
282#[serde(rename_all = "camelCase")]
283pub struct RepositoryHealthProbeSpec {
284 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
287 pub enabled: bool,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
291 #[schemars(default = "default_health_probe_interval")]
292 pub interval: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
297 #[schemars(default = "default_health_probe_failure_threshold")]
298 pub failure_threshold: Option<i64>,
299}
300
301fn default_health_probe_interval() -> Option<String> {
307 Some("30m".to_string())
308}
309
310fn default_health_probe_failure_threshold() -> Option<i64> {
314 Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
315}
316
317impl RepositoryHealthProbeSpec {
318 pub fn enabled(health: Option<&RepositoryHealthSpec>) -> bool {
321 health
322 .and_then(|h| h.probe.as_ref())
323 .is_some_and(|p| p.enabled)
324 }
325
326 pub fn effective_interval(health: Option<&RepositoryHealthSpec>) -> std::time::Duration {
333 health
334 .and_then(|h| h.probe.as_ref())
335 .and_then(|p| p.interval.as_deref())
336 .and_then(crate::duration::parse_go_duration)
337 .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
338 }
339
340 pub fn effective_failure_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
346 health
347 .and_then(|h| h.probe.as_ref())
348 .and_then(|p| p.failure_threshold)
349 .map(|t| t.max(1))
350 .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
351 }
352}
353
354pub fn resolve_index_blob_warn_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
374 health
375 .and_then(|h| h.index_blob_warn_threshold)
376 .unwrap_or(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
377}
378
379#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
381pub enum RepositoryPhase {
382 #[default]
384 Pending,
385 Initializing,
387 Ready,
389 Degraded,
391 Failed,
393}
394
395impl crate::common::PhaseLabel for RepositoryPhase {
396 const ALL: &'static [Self] = &[
397 Self::Pending,
398 Self::Initializing,
399 Self::Ready,
400 Self::Degraded,
401 Self::Failed,
402 ];
403 fn label(&self) -> &'static str {
404 match self {
405 Self::Pending => "Pending",
406 Self::Initializing => "Initializing",
407 Self::Ready => "Ready",
408 Self::Degraded => "Degraded",
409 Self::Failed => "Failed",
410 }
411 }
412}
413
414#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
416#[serde(rename_all = "camelCase")]
417pub struct RepositoryStatus {
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub phase: Option<RepositoryPhase>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub observed_generation: Option<i64>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub resolved_credential_version: Option<String>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub unique_id: Option<String>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub backend: Option<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub storage_stats: Option<StorageStats>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub catalog: Option<CatalogStatus>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub server: Option<ServerStatus>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub last_reverify_at: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub health: Option<RepositoryHealthStatus>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub parameters: Option<ObservedRepositoryParameters>,
453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
455 pub conditions: Vec<Condition>,
456}
457
458#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
464#[serde(rename_all = "camelCase")]
465pub struct RepositoryHealthStatus {
466 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub last_probe_at: Option<String>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub last_healthy_at: Option<String>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub consecutive_probe_failures: Option<i64>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub first_failure_at: Option<String>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub probe_attempt_at: Option<String>,
490}
491
492#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
494#[serde(rename_all = "camelCase")]
495pub struct StorageStats {
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub snapshot_count: Option<i64>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub total_size: Option<String>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub total_size_bytes: Option<i64>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub last_observed_at: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub index_blob_count: Option<i64>,
515}
516
517#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
519#[serde(rename_all = "camelCase")]
520pub struct CatalogStatus {
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub discovered_backup_count: Option<i64>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub last_refresh_at: Option<String>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub foreign_snapshot_count: Option<i64>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub scan_request_honored: Option<String>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub scan_request_attempt_at: Option<String>,
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::common::RepositoryMode;
555 use crate::testutil::from_yaml;
556 use kube::core::CustomResourceExt;
557
558 #[test]
559 fn repository_schema_emits_context_free_defaults() {
560 let crd = Repository::crd();
567 let json = serde_json::to_value(&crd).unwrap();
568 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
569 assert_eq!(
570 spec["properties"]["health"]["properties"]["indexBlobWarnThreshold"]["default"],
571 serde_json::json!(1000)
572 );
573 assert_eq!(
574 spec["properties"]["health"]["properties"]["probe"]["properties"]["interval"]["default"],
575 serde_json::json!("30m")
576 );
577 assert_eq!(
578 spec["properties"]["health"]["properties"]["probe"]["properties"]["failureThreshold"]["default"],
579 serde_json::json!(3)
580 );
581 assert_eq!(
582 spec["properties"]["catalog"]["properties"]["refreshInterval"]["default"],
583 serde_json::json!("1h")
584 );
585 assert_eq!(
586 spec["properties"]["server"]["properties"]["service"]["properties"]["port"]["default"],
587 serde_json::json!(51515)
588 );
589 }
590
591 #[test]
592 fn health_probe_interval_schema_default_matches_the_duration_constant() {
593 let s = default_health_probe_interval().expect("some");
598 assert_eq!(
599 crate::duration::parse_go_duration(&s),
600 Some(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL),
601 "default_health_probe_interval() string must parse to DEFAULT_HEALTH_PROBE_INTERVAL"
602 );
603 assert_eq!(
604 default_health_probe_failure_threshold(),
605 Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
606 );
607 assert_eq!(
608 default_index_blob_warn_threshold(),
609 Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
610 );
611 }
612
613 #[test]
614 fn mode_suspend_and_ecc_roundtrip() {
615 let yaml = r#"
618backend: { filesystem: { path: /repo } }
619encryption: { passwordSecretRef: { name: s } }
620create:
621 enabled: true
622 encryption: AES256-GCM-HMAC-SHA256
623 ecc:
624 algorithm: REED-SOLOMON-CRC32
625 overheadPercent: 2
626mode: ReadOnly
627suspend: true
628"#;
629 let spec: RepositorySpec = from_yaml(yaml);
630 assert_eq!(spec.mode, RepositoryMode::ReadOnly);
631 assert!(!spec.mode.allows_writes());
632 assert!(spec.suspend);
633 let ecc = spec.create.as_ref().unwrap().ecc.as_ref().expect("ecc");
634 assert_eq!(ecc.algorithm.as_deref(), Some("REED-SOLOMON-CRC32"));
635 assert_eq!(ecc.overhead_percent, Some(2));
636
637 let json = serde_json::to_value(&spec).expect("serialize");
638 assert_eq!(json["mode"], "ReadOnly");
639 assert_eq!(json["suspend"], true);
640 let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
641 assert_eq!(spec, reparsed);
642 }
643
644 #[test]
645 fn bootstrap_failure_policy_round_trips() {
646 let spec: RepositorySpec = from_yaml(
647 r#"
648backend: { rclone: { remotePath: "mydrive:backups", startupTimeout: 2m } }
649encryption: { passwordSecretRef: { name: s } }
650bootstrap:
651 failurePolicy:
652 activeDeadlineSeconds: 600
653 backoffLimit: 1
654"#,
655 );
656 let fp = spec
657 .bootstrap
658 .as_ref()
659 .and_then(|b| b.failure_policy.as_ref())
660 .expect("bootstrap.failurePolicy");
661 assert_eq!(fp.active_deadline_seconds, Some(600));
662 assert_eq!(fp.backoff_limit, Some(1));
663 let bare: RepositorySpec = from_yaml(
665 r#"
666backend: { filesystem: { path: /repo } }
667encryption: { passwordSecretRef: { name: s } }
668"#,
669 );
670 assert!(bare.bootstrap.is_none());
671 }
672
673 #[test]
674 fn health_threshold_parses_and_resolver_honors_default_and_disable() {
675 let bare: RepositorySpec = from_yaml(
677 r#"
678backend: { filesystem: { path: /repo } }
679encryption: { passwordSecretRef: { name: s } }
680"#,
681 );
682 assert!(bare.health.is_none());
683 assert_eq!(
684 resolve_index_blob_warn_threshold(bare.health.as_ref()),
685 crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD
686 );
687
688 let tuned: RepositorySpec = from_yaml(
690 r#"
691backend: { filesystem: { path: /repo } }
692encryption: { passwordSecretRef: { name: s } }
693health:
694 indexBlobWarnThreshold: 250
695"#,
696 );
697 assert_eq!(
698 resolve_index_blob_warn_threshold(tuned.health.as_ref()),
699 250
700 );
701
702 let disabled: RepositorySpec = from_yaml(
704 r#"
705backend: { filesystem: { path: /repo } }
706encryption: { passwordSecretRef: { name: s } }
707health:
708 indexBlobWarnThreshold: 0
709"#,
710 );
711 assert_eq!(
712 resolve_index_blob_warn_threshold(disabled.health.as_ref()),
713 0
714 );
715 }
716
717 #[test]
718 fn deletion_protection_threshold_schema_default_matches_the_constant() {
719 let crd = Repository::crd();
723 let json = serde_json::to_value(&crd).unwrap();
724 let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
725 assert_eq!(
726 spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
727 serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
728 );
729 assert_eq!(
730 crate::consts::effective_mass_deletion_threshold(None),
731 crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
732 );
733 }
734
735 #[test]
736 fn deletion_protection_round_trips_and_zero_disables() {
737 use crate::common::DeletionProtectionSpec;
738
739 let spec: RepositorySpec = from_yaml(
740 "backend: { filesystem: { path: /repo } }\n\
741 encryption: { passwordSecretRef: { name: s } }\n\
742 deletionProtection:\n threshold: 25\n",
743 );
744 assert_eq!(
745 spec.deletion_protection.as_ref().and_then(|d| d.threshold),
746 Some(25)
747 );
748 assert_eq!(
749 crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
750 25
751 );
752 let json = serde_json::to_value(&spec).expect("serialize");
753 assert_eq!(json["deletionProtection"]["threshold"], 25);
754 let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
755 assert_eq!(spec, reparsed);
756
757 let disabled = DeletionProtectionSpec { threshold: Some(0) };
759 assert_eq!(
760 crate::consts::effective_mass_deletion_threshold(Some(&disabled)),
761 0
762 );
763
764 let bare: RepositorySpec = from_yaml(
766 "backend: { filesystem: { path: /repo } }\n\
767 encryption: { passwordSecretRef: { name: s } }\n",
768 );
769 assert!(bare.deletion_protection.is_none());
770 assert!(
771 serde_json::to_value(&bare)
772 .unwrap()
773 .get("deletionProtection")
774 .is_none(),
775 "absent deletionProtection must be elided"
776 );
777 }
778
779 #[test]
780 fn storage_stats_index_blob_count_roundtrips() {
781 let stats = StorageStats {
782 snapshot_count: Some(12),
783 total_size: None,
784 total_size_bytes: Some(442_000_000),
785 last_observed_at: None,
786 index_blob_count: Some(1448),
787 };
788 let json = serde_json::to_value(&stats).unwrap();
789 assert_eq!(json["indexBlobCount"], 1448);
790 assert_eq!(json["totalSizeBytes"], 442_000_000_i64);
791 let back: StorageStats = serde_json::from_value(json).unwrap();
792 assert_eq!(back, stats);
793 }
794
795 #[test]
796 fn catalog_status_foreign_snapshot_count_roundtrips() {
797 let status: CatalogStatus = from_yaml(
798 "discoveredBackupCount: 42\nlastRefreshAt: 2026-06-01T00:00:00Z\nforeignSnapshotCount: 7\n",
799 );
800 assert_eq!(status.foreign_snapshot_count, Some(7));
801 let json = serde_json::to_value(&status).unwrap();
802 assert_eq!(json["foreignSnapshotCount"], 7);
803 let back: CatalogStatus = serde_json::from_value(json).unwrap();
804 assert_eq!(back, status);
805
806 let bare: CatalogStatus = from_yaml("{}\n");
808 assert!(bare.foreign_snapshot_count.is_none());
809 assert!(
810 serde_json::to_value(&bare)
811 .unwrap()
812 .get("foreignSnapshotCount")
813 .is_none(),
814 "absent foreignSnapshotCount must be elided"
815 );
816 }
817
818 #[test]
819 fn catalog_status_scan_request_honored_roundtrips() {
820 let status: CatalogStatus = from_yaml(
821 "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestHonored: 2026-06-01T00:00:00Z\n",
822 );
823 assert_eq!(
824 status.scan_request_honored.as_deref(),
825 Some("2026-06-01T00:00:00Z")
826 );
827 let json = serde_json::to_value(&status).unwrap();
828 assert_eq!(json["scanRequestHonored"], "2026-06-01T00:00:00Z");
829 let back: CatalogStatus = serde_json::from_value(json).unwrap();
830 assert_eq!(back, status);
831
832 let bare: CatalogStatus = from_yaml("{}\n");
834 assert!(bare.scan_request_honored.is_none());
835 assert!(
836 serde_json::to_value(&bare)
837 .unwrap()
838 .get("scanRequestHonored")
839 .is_none(),
840 "absent scanRequestHonored must be elided"
841 );
842 }
843
844 #[test]
845 fn catalog_status_scan_request_attempt_at_roundtrips() {
846 let status: CatalogStatus = from_yaml(
847 "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestAttemptAt: 2026-06-01T00:05:00Z\n",
848 );
849 assert_eq!(
850 status.scan_request_attempt_at.as_deref(),
851 Some("2026-06-01T00:05:00Z")
852 );
853 let json = serde_json::to_value(&status).unwrap();
854 assert_eq!(json["scanRequestAttemptAt"], "2026-06-01T00:05:00Z");
855 let back: CatalogStatus = serde_json::from_value(json).unwrap();
856 assert_eq!(back, status);
857
858 let bare: CatalogStatus = from_yaml("{}\n");
860 assert!(bare.scan_request_attempt_at.is_none());
861 assert!(
862 serde_json::to_value(&bare)
863 .unwrap()
864 .get("scanRequestAttemptAt")
865 .is_none(),
866 "absent scanRequestAttemptAt must be elided"
867 );
868 }
869
870 #[test]
871 fn repository_crd_exposes_index_blobs_print_column() {
872 let crd = Repository::crd();
873 let json = serde_json::to_value(&crd).unwrap();
874 let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
875 .as_array()
876 .expect("printer columns present");
877 assert!(
878 cols.iter().any(|c| c["name"] == "IndexBlobs"
879 && c["jsonPath"] == ".status.storageStats.indexBlobCount"),
880 "Repository must surface the IndexBlobs print column"
881 );
882 }
883
884 #[test]
885 fn repository_crd_carries_immutability_transition_rules() {
886 let crd = Repository::crd();
891 let json = serde_json::to_value(&crd).unwrap();
892 let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
893 ["x-kubernetes-validations"]
894 .as_array()
895 .expect("spec.x-kubernetes-validations present");
896 let has = |needle: &str| {
897 rules
898 .iter()
899 .any(|r| r["rule"].as_str().is_some_and(|s| s.contains(needle)))
900 };
901 assert!(
902 !has("self.encryption == oldSelf.encryption"),
903 "the password Secret ref must NOT be locked (a rename must be allowed)"
904 );
905 assert!(has("self.create.splitter == oldSelf.create.splitter"));
906 assert!(has("self.create.hash == oldSelf.create.hash"));
907 assert!(has("self.create.ecc == oldSelf.create.ecc"));
908 }
909
910 #[test]
911 fn create_immutability_rules_guard_each_optional_leaf_with_has() {
912 let crd = Repository::crd();
920 let json = serde_json::to_value(&crd).unwrap();
921 let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
922 ["x-kubernetes-validations"]
923 .as_array()
924 .expect("spec.x-kubernetes-validations present");
925 for leaf in ["splitter", "hash", "encryption", "ecc"] {
926 let rule = rules
927 .iter()
928 .find_map(|r| {
929 let s = r["rule"].as_str()?;
930 s.contains(&format!("self.create.{leaf} == oldSelf.create.{leaf}"))
931 .then_some(s)
932 })
933 .unwrap_or_else(|| panic!("missing create.{leaf} immutability rule"));
934 assert!(
935 rule.contains(&format!("has(self.create.{leaf})"))
936 && rule.contains(&format!("has(oldSelf.create.{leaf})")),
937 "create.{leaf} immutability rule must `has()`-guard the leaf on BOTH sides \
938 (else `create: {{enabled: true}}` 422s every update); got: {rule}"
939 );
940 }
941 }
942
943 #[test]
944 fn mode_defaults_to_readwrite_and_emits_openapi_default() {
945 let spec: RepositorySpec = from_yaml(
947 "backend: { filesystem: { path: /repo } }\nencryption: { passwordSecretRef: { name: s } }\n",
948 );
949 assert_eq!(spec.mode, RepositoryMode::ReadWrite);
950 assert!(!spec.suspend);
951 assert_eq!(serde_json::to_value(&spec).unwrap()["mode"], "ReadWrite");
953
954 let crd = Repository::crd();
955 let json = serde_json::to_value(&crd).unwrap();
956 let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
957 ["properties"]["mode"]["default"];
958 assert_eq!(default, "ReadWrite");
959 }
960
961 #[test]
962 fn health_probe_helpers_default_and_parse() {
963 use crate::consts::{
964 DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD, DEFAULT_HEALTH_PROBE_INTERVAL,
965 };
966 assert!(!RepositoryHealthProbeSpec::enabled(None));
968 assert_eq!(
969 RepositoryHealthProbeSpec::effective_interval(None),
970 DEFAULT_HEALTH_PROBE_INTERVAL
971 );
972 assert_eq!(
973 RepositoryHealthProbeSpec::effective_failure_threshold(None),
974 DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
975 );
976
977 let spec: RepositorySpec = from_yaml(
979 "backend: { filesystem: { path: /repo } }\n\
980 encryption: { passwordSecretRef: { name: s } }\n\
981 health:\n probe:\n enabled: true\n interval: 45m\n failureThreshold: 5\n",
982 );
983 assert!(RepositoryHealthProbeSpec::enabled(spec.health.as_ref()));
984 assert_eq!(
985 RepositoryHealthProbeSpec::effective_interval(spec.health.as_ref()),
986 std::time::Duration::from_secs(45 * 60)
987 );
988 assert_eq!(
989 RepositoryHealthProbeSpec::effective_failure_threshold(spec.health.as_ref()),
990 5
991 );
992 let disabled: RepositorySpec = from_yaml(
994 "backend: { filesystem: { path: /repo } }\n\
995 encryption: { passwordSecretRef: { name: s } }\n\
996 health:\n probe:\n interval: 1h\n",
997 );
998 assert!(!RepositoryHealthProbeSpec::enabled(
999 disabled.health.as_ref()
1000 ));
1001 let json = serde_json::to_value(&disabled).unwrap();
1002 assert!(
1003 json["health"]["probe"].get("enabled").is_none(),
1004 "enabled: false must be elided"
1005 );
1006 }
1007
1008 #[test]
1009 fn schedule_defaults_timezone_round_trips() {
1010 let spec: RepositorySpec = from_yaml(
1011 "backend: { filesystem: { path: /repo } }\n\
1012 encryption: { passwordSecretRef: { name: s } }\n\
1013 scheduleDefaults:\n timezone: America/New_York\n",
1014 );
1015 assert_eq!(
1016 spec.schedule_defaults
1017 .as_ref()
1018 .and_then(|d| d.timezone.as_deref()),
1019 Some("America/New_York")
1020 );
1021 let json = serde_json::to_value(&spec).expect("serialize");
1022 assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
1023 let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1024 assert_eq!(spec, reparsed);
1025
1026 let bare: RepositorySpec = from_yaml(
1028 "backend: { filesystem: { path: /repo } }\n\
1029 encryption: { passwordSecretRef: { name: s } }\n",
1030 );
1031 assert!(bare.schedule_defaults.is_none());
1032 assert!(
1033 serde_json::to_value(&bare)
1034 .unwrap()
1035 .get("scheduleDefaults")
1036 .is_none(),
1037 "absent scheduleDefaults must be elided"
1038 );
1039 }
1040
1041 #[test]
1042 fn identity_defaults_cluster_round_trips_on_repository() {
1043 let spec: RepositorySpec = from_yaml(
1047 "backend: { filesystem: { path: /repo } }\n\
1048 encryption: { passwordSecretRef: { name: s } }\n\
1049 identityDefaults:\n cluster: east\n hostnameExpr: namespace\n",
1050 );
1051 let id = spec.identity_defaults.as_ref().expect("identityDefaults");
1052 assert_eq!(id.cluster.as_deref(), Some("east"));
1053 assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
1054 assert!(id.username_expr.is_none());
1055
1056 let json = serde_json::to_value(&spec).expect("serialize");
1057 assert_eq!(json["identityDefaults"]["cluster"], "east");
1058 assert_eq!(json["identityDefaults"]["hostnameExpr"], "namespace");
1059 let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1060 assert_eq!(spec, reparsed);
1061
1062 let bare: RepositorySpec = from_yaml(
1064 "backend: { filesystem: { path: /repo } }\n\
1065 encryption: { passwordSecretRef: { name: s } }\n",
1066 );
1067 assert!(bare.identity_defaults.is_none());
1068 assert!(
1069 serde_json::to_value(&bare)
1070 .unwrap()
1071 .get("identityDefaults")
1072 .is_none(),
1073 "absent identityDefaults must be elided"
1074 );
1075 }
1076
1077 #[test]
1078 fn catalog_foreign_snapshots_round_trips_on_repository() {
1079 use crate::common::ForeignSnapshots;
1080
1081 let spec: RepositorySpec = from_yaml(
1082 "backend: { filesystem: { path: /repo } }\n\
1083 encryption: { passwordSecretRef: { name: s } }\n\
1084 catalog:\n foreignSnapshots: Fallback\n",
1085 );
1086 assert_eq!(
1087 spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1088 Some(ForeignSnapshots::Fallback)
1089 );
1090 let json = serde_json::to_value(&spec).expect("serialize");
1091 assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
1092 let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1093 assert_eq!(spec, reparsed);
1094
1095 let spec: RepositorySpec = from_yaml(
1096 "backend: { filesystem: { path: /repo } }\n\
1097 encryption: { passwordSecretRef: { name: s } }\n\
1098 catalog:\n foreignSnapshots: Ignore\n",
1099 );
1100 assert_eq!(
1101 spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1102 Some(ForeignSnapshots::Ignore)
1103 );
1104
1105 let bare: RepositorySpec = from_yaml(
1107 "backend: { filesystem: { path: /repo } }\n\
1108 encryption: { passwordSecretRef: { name: s } }\n\
1109 catalog: {}\n",
1110 );
1111 assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
1112 assert!(
1113 serde_json::to_value(&bare).unwrap()["catalog"]
1114 .get("foreignSnapshots")
1115 .is_none(),
1116 "absent catalog.foreignSnapshots must be elided"
1117 );
1118 }
1119
1120 #[test]
1121 fn catalog_adoption_round_trips_on_repository() {
1122 use crate::common::SnapshotAdoption;
1123
1124 let spec: RepositorySpec = from_yaml(
1125 "backend: { filesystem: { path: /repo } }\n\
1126 encryption: { passwordSecretRef: { name: s } }\n\
1127 catalog:\n adoption: Ignore\n",
1128 );
1129 assert_eq!(
1130 spec.catalog.as_ref().and_then(|c| c.adoption),
1131 Some(SnapshotAdoption::Ignore)
1132 );
1133 let json = serde_json::to_value(&spec).expect("serialize");
1134 assert_eq!(json["catalog"]["adoption"], "Ignore");
1135 let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1136 assert_eq!(spec, reparsed);
1137
1138 let bare: RepositorySpec = from_yaml(
1140 "backend: { filesystem: { path: /repo } }\n\
1141 encryption: { passwordSecretRef: { name: s } }\n\
1142 catalog: {}\n",
1143 );
1144 assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
1145 assert!(
1146 serde_json::to_value(&bare).unwrap()["catalog"]
1147 .get("adoption")
1148 .is_none(),
1149 "absent catalog.adoption must be elided"
1150 );
1151
1152 let crd = Repository::crd();
1153 let crd_json = serde_json::to_value(&crd).unwrap();
1154 let prop = &crd_json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1155 ["properties"]["catalog"]["properties"]["adoption"];
1156 assert!(
1157 prop.get("default").is_none(),
1158 "catalog.adoption must NOT carry a schema default: {prop}"
1159 );
1160 }
1161}