Skip to main content

kopiur_api/
lib.rs

1#![warn(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4pub mod backend;
5pub mod cluster_repository;
6pub mod common;
7pub mod consts;
8pub mod maintenance;
9pub mod repository;
10pub mod repository_replication;
11pub mod restore;
12pub mod server;
13pub mod snapshot;
14pub mod snapshot_policy;
15pub mod snapshot_schedule;
16
17// Shared pure-logic modules (no controller-runtime deps). The webhook and the
18// controller both import these, so validation/resolution behavior is identical
19// across the two call sites (ADR §5.1, SKILL "one validator, two callers").
20pub mod creds;
21pub mod duration;
22pub mod error;
23pub mod identity;
24pub mod invariants;
25pub mod jitter;
26pub mod preflight;
27pub mod recorded;
28pub mod retention;
29pub mod schema;
30pub mod secctx_compat;
31pub mod success_expr;
32pub mod validate;
33
34pub use backend::{Backend, NfsVolume, PvcVolume, RepoVolume};
35pub use cluster_repository::{
36    AllowedNamespaces, ClusterRepoCredentialProjection, ClusterRepository, ClusterRepositorySpec,
37    ClusterRepositoryStatus,
38};
39pub use common::{
40    CacheDefaults, CacheVolumeMode, CronSpec, DeletionPolicy, IdentityDefaults,
41    InheritSecurityContextFrom, MoverDefaults, NamespaceDeletePolicy, ObjectRef, PhaseLabel,
42    PodSelector, PolicyRef, PvcConsumerInherit, ResolvedMover, SourceColocation,
43    SourceColocationMode, effective_run_as_group, effective_run_as_user, hardened_security_context,
44    merge_context_pair, merge_pod_security_context, merge_resources, merge_security_context,
45    resolve_mover,
46};
47pub use maintenance::{
48    LeaseAction, Maintenance, MaintenanceSchedule, MaintenanceSpec, MaintenanceStatus,
49    ManualRunMode, ManualRunPhase, ManualRunStatus, Ownership, RepositoryMaintenanceSpec,
50    TakeoverPolicy, default_maintenance_schedule, kopia_lease_identity, kopia_owner_for_lease,
51    lease_action, lease_held_by_other, managed_lease, parse_run_annotations,
52};
53pub use repository::{Repository, RepositoryPhase, RepositorySpec, RepositoryStatus};
54pub use repository_replication::{
55    RepositoryReplication, RepositoryReplicationPhase, RepositoryReplicationSpec,
56    RepositoryReplicationStatus,
57};
58pub use restore::{
59    OnMissingSnapshot, PopulatorTarget, ResolutionOutcome, Restore, RestorePhase, RestoreSource,
60    RestoreSpec, RestoreStatus, RestoreTarget,
61};
62pub use server::{
63    ClusterServerSpec, ServerAuth, ServerService, ServerSpec, ServerStatus, ServiceType,
64};
65pub use snapshot::{
66    Origin, Snapshot, SnapshotPhase, SnapshotSpec, SnapshotStats, SnapshotStatus, SnapshotTiming,
67    StagedSources,
68};
69pub use snapshot_policy::{
70    CopyMethod, DeepVerification, GroupBy, Hook, SnapshotPolicy, SnapshotPolicySpec,
71    SnapshotPolicyStatus, SourcePathStrategy, StagingSpec, Verification,
72};
73pub use snapshot_schedule::{
74    ConcurrencyPolicy, ScheduleSpec, SnapshotSchedule, SnapshotScheduleSpec, SnapshotScheduleStatus,
75};
76
77// Shared logic re-exports.
78pub use duration::{parse_go_duration, render_go_duration, resolve_timeout};
79pub use error::{ValidationError, ValidationResult};
80pub use identity::{
81    HostClass, IdentityInputs, classify_hostname, identity_string, resolve_identity,
82    validate_identity_expr,
83};
84pub use jitter::{offset as jitter_offset, substitute_h};
85pub use preflight::{
86    PreflightCheck, PreflightInputs, PreflightSpec, eval_preflight_expr, validate_preflight_expr,
87};
88pub use recorded::{
89    KOPIUR_META_SCHEMA_V1, KOPIUR_META_TAG, MetaTagDecode, RecordedSnapshotMeta, RecordedSrc,
90    decode_meta_tag, encode_meta_tag,
91};
92pub use retention::{KeptSet, SnapshotLike, select_kept};
93pub use success_expr::{
94    RestoredStats, SuccessExprInputs, VerifyStats, eval_success_expr, validate_success_expr,
95};
96
97/// The CRD API group for all kopiur resources.
98pub const GROUP: &str = "kopiur.home-operations.com";
99/// The current (and only, per ADR §8) API version.
100pub const VERSION: &str = "v1alpha1";
101
102/// Shared test helper: parse a YAML manifest the way the cluster does
103/// (YAML → JSON value → typed), reused by every CRD module's round-trip tests.
104///
105/// `kubectl` converts YAML to JSON before sending to the API server, and `kube`
106/// (de)serializes exclusively via `serde_json`. Going straight through `serde_yaml`
107/// would instead exercise its non-standard `!Variant` encoding of externally-tagged
108/// enums, which the real wire format never uses — so this is the representative path.
109#[cfg(test)]
110pub(crate) mod testutil {
111    pub(crate) fn from_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> T {
112        let value: serde_json::Value = serde_yaml::from_str(yaml).expect("yaml -> json value");
113        serde_json::from_value(value).expect("json value -> typed")
114    }
115}
116
117#[cfg(test)]
118mod roundtrip_tests {
119    //! Proves the `CustomResource` derive + schemars-1 + k8s-openapi-type-reuse
120    //! pattern works end to end against the exact YAML shapes in ADR §3.1.
121    use super::*;
122    use crate::testutil::from_yaml;
123    use kube::core::CustomResourceExt;
124
125    #[test]
126    fn repository_crd_metadata_is_correct() {
127        let crd = Repository::crd();
128        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
129        assert_eq!(crd.spec.names.kind, "Repository");
130        assert_eq!(crd.spec.scope, "Namespaced");
131        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
132    }
133
134    #[test]
135    fn repository_s3_roundtrip_matches_adr_shape() {
136        // Mirrors ADR §3.1 / §5.1.
137        let yaml = r#"
138backend:
139  s3:
140    bucket: my-backups
141    prefix: prod/
142    endpoint: s3.us-east-1.amazonaws.com
143    region: us-east-1
144    auth:
145      secretRef:
146        name: nas-primary-creds
147encryption:
148  passwordSecretRef:
149    name: nas-primary-creds
150    key: KOPIA_PASSWORD
151create:
152  enabled: true
153"#;
154        let spec: RepositorySpec = from_yaml(yaml);
155        // The backend is exactly one variant — the type system guarantees it.
156        match &spec.backend {
157            Backend::S3(s3) => {
158                assert_eq!(s3.bucket, "my-backups");
159                assert_eq!(s3.prefix.as_deref(), Some("prod/"));
160            }
161            other => panic!("expected S3 backend, got {}", other.kind_str()),
162        }
163        // Round-trip: serialize back and re-parse, assert structural equality.
164        let json = serde_json::to_value(&spec).expect("serialize");
165        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
166        assert_eq!(spec, reparsed);
167    }
168
169    #[test]
170    fn backend_is_externally_tagged() {
171        let spec: RepositorySpec = from_yaml(
172            "backend:\n  filesystem:\n    path: /repo\nencryption:\n  passwordSecretRef:\n    name: s\n",
173        );
174        assert_eq!(spec.backend.kind_str(), "Filesystem");
175        let v = serde_json::to_value(&spec.backend).unwrap();
176        assert_eq!(v["filesystem"]["path"], "/repo");
177    }
178
179    #[test]
180    fn filesystem_repo_volume_pvc_is_externally_tagged() {
181        // `volume: { pvc: { name } }` — the externally-tagged RepoVolume wire shape.
182        let spec: RepositorySpec = from_yaml(
183            "backend:\n  filesystem:\n    path: /repo\n    volume:\n      pvc:\n        name: nas-repo\nencryption:\n  passwordSecretRef:\n    name: s\n",
184        );
185        let Backend::Filesystem(fs) = &spec.backend else {
186            panic!("expected filesystem backend");
187        };
188        match fs.volume.as_ref().expect("volume present") {
189            RepoVolume::Pvc(p) => assert_eq!(p.name, "nas-repo"),
190            other => panic!("expected pvc volume, got {}", other.kind_str()),
191        }
192        // Round-trips through JSON under the camelCase `pvc` key.
193        let v = serde_json::to_value(&spec.backend).unwrap();
194        assert_eq!(v["filesystem"]["volume"]["pvc"]["name"], "nas-repo");
195    }
196
197    #[test]
198    fn filesystem_repo_volume_nfs_is_externally_tagged() {
199        // `volume: { nfs: { server, path } }` — inline NFS repo, no PVC.
200        let spec: RepositorySpec = from_yaml(
201            "backend:\n  filesystem:\n    path: /repo\n    volume:\n      nfs:\n        server: nas.lan\n        path: /export/kopia\nencryption:\n  passwordSecretRef:\n    name: s\n",
202        );
203        let Backend::Filesystem(fs) = &spec.backend else {
204            panic!("expected filesystem backend");
205        };
206        match fs.volume.as_ref().expect("volume present") {
207            RepoVolume::Nfs(n) => {
208                assert_eq!(n.server, "nas.lan");
209                assert_eq!(n.path, "/export/kopia");
210            }
211            other => panic!("expected nfs volume, got {}", other.kind_str()),
212        }
213        let v = serde_json::to_value(&spec.backend).unwrap();
214        assert_eq!(v["filesystem"]["volume"]["nfs"]["server"], "nas.lan");
215        assert_eq!(v["filesystem"]["volume"]["nfs"]["path"], "/export/kopia");
216    }
217
218    #[test]
219    fn repository_workload_identity_roundtrips() {
220        // The cloud-IAM backends accept `auth.workloadIdentity` instead of a
221        // Secret (ADR §4.11); the wire key is camelCase.
222        for (backend_yaml, kind) in [
223            ("s3:\n    bucket: b", "S3"),
224            (
225                "azure:\n    container: c\n    storageAccount: acct",
226                "Azure",
227            ),
228            ("gcs:\n    bucket: b", "Gcs"),
229        ] {
230            let yaml = format!(
231                "backend:\n  {backend_yaml}\n    auth:\n      workloadIdentity:\n        serviceAccountName: backup-mover\nencryption:\n  passwordSecretRef:\n    name: s\n",
232            );
233            let spec: RepositorySpec = from_yaml(&yaml);
234            assert_eq!(spec.backend.kind_str(), kind);
235            let (wi, _) = crate::creds::backend_workload_identity(&spec.backend)
236                .unwrap_or_else(|| panic!("{kind} carries the workload identity"));
237            assert_eq!(wi.service_account_name, "backup-mover");
238            // Round-trip: serialize back and re-parse, assert structural equality.
239            let json = serde_json::to_value(&spec).expect("serialize");
240            let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
241            assert_eq!(spec, reparsed);
242        }
243    }
244
245    #[test]
246    fn workload_identity_is_unrepresentable_on_secret_only_backends() {
247        // B2/SFTP/WebDAV have no cloud IAM plane, so their `auth` is the
248        // Secret-only type and the generated CRD schema must NOT offer
249        // `workloadIdentity` there (the API server prunes it) while the
250        // cloud-IAM backends must.
251        let crd = Repository::crd();
252        let schema = serde_json::to_value(
253            crd.spec.versions[0]
254                .schema
255                .as_ref()
256                .and_then(|s| s.open_api_v3_schema.as_ref())
257                .expect("repository CRD has a schema"),
258        )
259        .expect("schema serializes");
260        let backend = &schema["properties"]["spec"]["properties"]["backend"]["properties"];
261        for cloud in ["s3", "azure", "gcs"] {
262            assert!(
263                !backend[cloud]["properties"]["auth"]["properties"]["workloadIdentity"].is_null(),
264                "{cloud} must offer auth.workloadIdentity"
265            );
266        }
267        for secret_only in ["b2", "sftp", "webDav"] {
268            assert!(
269                backend[secret_only]["properties"]["auth"]["properties"]["workloadIdentity"]
270                    .is_null(),
271                "{secret_only} must NOT offer auth.workloadIdentity"
272            );
273            assert!(
274                !backend[secret_only]["properties"]["auth"]["properties"]["secretRef"].is_null(),
275                "{secret_only} keeps auth.secretRef"
276            );
277        }
278    }
279
280    #[test]
281    fn backup_config_nfs_source_roundtrips() {
282        use crate::SnapshotPolicySpec;
283        let spec: SnapshotPolicySpec = from_yaml(
284            "repository:\n  name: repo\nsources:\n  - nfs:\n      server: expanse.internal\n      path: /mnt/eros/Media\n",
285        );
286        let src = &spec.sources[0];
287        let nfs = src.nfs.as_ref().expect("nfs source present");
288        assert_eq!(nfs.server, "expanse.internal");
289        assert_eq!(nfs.path, "/mnt/eros/Media");
290        assert!(src.pvc.is_none() && src.pvc_selector.is_none());
291    }
292
293    #[test]
294    fn unknown_backend_variant_is_rejected() {
295        let value: serde_json::Value = serde_yaml::from_str("dropbox:\n  bucket: x\n").unwrap();
296        let err = serde_json::from_value::<Backend>(value);
297        assert!(
298            err.is_err(),
299            "unknown backend variant must fail to deserialize"
300        );
301    }
302}