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