Skip to main content

kopiur_api/
lib.rs

1#![warn(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4pub mod backend;
5pub mod backup;
6pub mod backup_config;
7pub mod backup_schedule;
8pub mod cluster_repository;
9pub mod common;
10pub mod maintenance;
11pub mod repository;
12pub mod restore;
13
14// Shared pure-logic modules (no controller-runtime deps). The webhook and the
15// controller both import these, so validation/resolution behavior is identical
16// across the two call sites (ADR §5.1, SKILL "one validator, two callers").
17pub mod error;
18pub mod identity;
19pub mod jitter;
20pub mod retention;
21pub mod validate;
22
23pub use backend::Backend;
24pub use backup::{
25    Backup, BackupPhase, BackupSpec, BackupStats, BackupStatus, BackupTiming, Origin,
26};
27pub use backup_config::{
28    BackupConfig, BackupConfigSpec, BackupConfigStatus, CopyMethod, GroupBy, Hook,
29    SourcePathStrategy,
30};
31pub use backup_schedule::{
32    BackupSchedule, BackupScheduleSpec, BackupScheduleStatus, ConcurrencyPolicy, ScheduleSpec,
33};
34pub use cluster_repository::{
35    AllowedNamespaces, ClusterRepository, ClusterRepositorySpec, ClusterRepositoryStatus,
36    IdentityTemplate,
37};
38pub use common::{ConfigRef, CronSpec, DeletionPolicy, ObjectRef, PhaseLabel};
39pub use maintenance::{
40    Maintenance, MaintenanceSchedule, MaintenanceSpec, MaintenanceStatus, Ownership,
41    RepositoryMaintenanceSpec, TakeoverPolicy, default_maintenance_schedule,
42};
43pub use repository::{Repository, RepositoryPhase, RepositorySpec, RepositoryStatus};
44pub use restore::{
45    OnMissingSnapshot, Restore, RestorePhase, RestoreSource, RestoreSpec, RestoreStatus,
46    RestoreTarget,
47};
48
49// Shared logic re-exports.
50pub use error::{ValidationError, ValidationResult};
51pub use identity::{IdentityInputs, identity_string, resolve_identity};
52pub use jitter::{offset as jitter_offset, substitute_h};
53pub use retention::{BackupLike, KeptSet, select_kept};
54
55/// The CRD API group for all kopiur resources.
56pub const GROUP: &str = "kopiur.home-operations.com";
57/// The current (and only, per ADR §8) API version.
58pub const VERSION: &str = "v1alpha1";
59
60/// Shared test helper: parse a YAML manifest the way the cluster does
61/// (YAML → JSON value → typed), reused by every CRD module's round-trip tests.
62///
63/// `kubectl` converts YAML to JSON before sending to the API server, and `kube`
64/// (de)serializes exclusively via `serde_json`. Going straight through `serde_yaml`
65/// would instead exercise its non-standard `!Variant` encoding of externally-tagged
66/// enums, which the real wire format never uses — so this is the representative path.
67#[cfg(test)]
68pub(crate) mod testutil {
69    pub(crate) fn from_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> T {
70        let value: serde_json::Value = serde_yaml::from_str(yaml).expect("yaml -> json value");
71        serde_json::from_value(value).expect("json value -> typed")
72    }
73}
74
75#[cfg(test)]
76mod roundtrip_tests {
77    //! Proves the `CustomResource` derive + schemars-1 + k8s-openapi-type-reuse
78    //! pattern works end to end against the exact YAML shapes in ADR §3.1.
79    use super::*;
80    use crate::testutil::from_yaml;
81    use kube::core::CustomResourceExt;
82
83    #[test]
84    fn repository_crd_metadata_is_correct() {
85        let crd = Repository::crd();
86        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
87        assert_eq!(crd.spec.names.kind, "Repository");
88        assert_eq!(crd.spec.scope, "Namespaced");
89        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
90    }
91
92    #[test]
93    fn repository_s3_roundtrip_matches_adr_shape() {
94        // Mirrors ADR §3.1 / §5.1.
95        let yaml = r#"
96backend:
97  s3:
98    bucket: my-backups
99    prefix: prod/
100    endpoint: s3.us-east-1.amazonaws.com
101    region: us-east-1
102    auth:
103      secretRef:
104        name: nas-primary-creds
105encryption:
106  passwordSecretRef:
107    name: nas-primary-creds
108    key: KOPIA_PASSWORD
109create:
110  enabled: true
111"#;
112        let spec: RepositorySpec = from_yaml(yaml);
113        // The backend is exactly one variant — the type system guarantees it.
114        match &spec.backend {
115            Backend::S3(s3) => {
116                assert_eq!(s3.bucket, "my-backups");
117                assert_eq!(s3.prefix.as_deref(), Some("prod/"));
118            }
119            other => panic!("expected S3 backend, got {}", other.kind_str()),
120        }
121        // Round-trip: serialize back and re-parse, assert structural equality.
122        let json = serde_json::to_value(&spec).expect("serialize");
123        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
124        assert_eq!(spec, reparsed);
125    }
126
127    #[test]
128    fn backend_is_externally_tagged() {
129        let spec: RepositorySpec = from_yaml(
130            "backend:\n  filesystem:\n    path: /repo\nencryption:\n  passwordSecretRef:\n    name: s\n",
131        );
132        assert_eq!(spec.backend.kind_str(), "Filesystem");
133        let v = serde_json::to_value(&spec.backend).unwrap();
134        assert_eq!(v["filesystem"]["path"], "/repo");
135    }
136
137    #[test]
138    fn unknown_backend_variant_is_rejected() {
139        let value: serde_json::Value = serde_yaml::from_str("dropbox:\n  bucket: x\n").unwrap();
140        let err = serde_json::from_value::<Backend>(value);
141        assert!(
142            err.is_err(),
143            "unknown backend variant must fail to deserialize"
144        );
145    }
146}