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, StagingSpec, Verification,
85    is_multi_repo, policy_repositories, repository_refs, single_repository_ref,
86};
87pub use snapshot_replication::{
88    IdentityMatcher, IdentitySelection, MigrateOptions, MirrorSourcePruning, NoPruning,
89    PolicyCopyMode, Pruning, SelectionSpec, SnapshotReplication, SnapshotReplicationPhase,
90    SnapshotReplicationRunStats, SnapshotReplicationSpec, SnapshotReplicationStatus,
91    component_glob_matches, validate_component_glob,
92};
93pub use snapshot_schedule::{
94    ConcurrencyPolicy, ScheduleSpec, SnapshotSchedule, SnapshotScheduleSpec, SnapshotScheduleStatus,
95};
96
97// Shared logic re-exports.
98pub use duration::{parse_go_duration, render_go_duration, resolve_timeout};
99pub use error::{ValidationError, ValidationResult};
100pub use gates::{GateScope, GateSeverity, STRUCTURAL_GATES, StructuralGate};
101pub use identity::{
102    HostClass, IdentityInputs, classify_hostname, identity_string, resolve_identity,
103    validate_identity_expr,
104};
105pub use jitter::{offset as jitter_offset, substitute_h};
106pub use message::{Diagnostic, message_shape_issue};
107pub use preflight::{
108    PreflightCheck, PreflightInputs, PreflightSpec, eval_preflight_expr, validate_preflight_expr,
109};
110pub use recorded::{
111    KOPIUR_META_SCHEMA_V1, KOPIUR_META_TAG, MetaTagDecode, RecordedSnapshotMeta, RecordedSrc,
112    decode_meta_tag, encode_meta_tag,
113};
114pub use retention::{KeptSet, SnapshotLike, select_kept};
115pub use success_expr::{
116    RestoredStats, SuccessExprInputs, VerifyStats, eval_success_expr, validate_success_expr,
117};
118
119/// The CRD API group for all kopiur resources.
120pub const GROUP: &str = "kopiur.home-operations.com";
121/// The current (and only, per ADR §8) API version.
122pub const VERSION: &str = "v1alpha1";
123
124/// Shared test helper: parse a YAML manifest the way the cluster does
125/// (YAML → JSON value → typed), reused by every CRD module's round-trip tests.
126///
127/// `kubectl` converts YAML to JSON before sending to the API server, and `kube`
128/// (de)serializes exclusively via `serde_json`. Going straight through `serde_yaml`
129/// would instead exercise its non-standard `!Variant` encoding of externally-tagged
130/// enums, which the real wire format never uses — so this is the representative path.
131#[cfg(test)]
132pub(crate) mod testutil {
133    pub(crate) fn from_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> T {
134        let value: serde_json::Value = serde_yaml::from_str(yaml).expect("yaml -> json value");
135        serde_json::from_value(value).expect("json value -> typed")
136    }
137}
138
139#[cfg(test)]
140mod roundtrip_tests {
141    //! Proves the `CustomResource` derive + schemars-1 + k8s-openapi-type-reuse
142    //! pattern works end to end against the exact YAML shapes in ADR §3.1.
143    use super::*;
144    use crate::testutil::from_yaml;
145    use kube::core::CustomResourceExt;
146
147    #[test]
148    fn repository_crd_metadata_is_correct() {
149        let crd = Repository::crd();
150        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
151        assert_eq!(crd.spec.names.kind, "Repository");
152        assert_eq!(crd.spec.scope, "Namespaced");
153        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
154    }
155
156    #[test]
157    fn repository_s3_roundtrip_matches_adr_shape() {
158        // Mirrors ADR §3.1 / §5.1.
159        let yaml = r#"
160backend:
161  s3:
162    bucket: my-backups
163    prefix: prod/
164    endpoint: s3.us-east-1.amazonaws.com
165    region: us-east-1
166    auth:
167      secretRef:
168        name: nas-primary-creds
169encryption:
170  passwordSecretRef:
171    name: nas-primary-creds
172    key: KOPIA_PASSWORD
173create:
174  enabled: true
175"#;
176        let spec: RepositorySpec = from_yaml(yaml);
177        // The backend is exactly one variant — the type system guarantees it.
178        match &spec.backend {
179            Backend::S3(s3) => {
180                assert_eq!(s3.bucket, "my-backups");
181                assert_eq!(s3.prefix.as_deref(), Some("prod/"));
182            }
183            other => panic!("expected S3 backend, got {}", other.kind_str()),
184        }
185        // Round-trip: serialize back and re-parse, assert structural equality.
186        let json = serde_json::to_value(&spec).expect("serialize");
187        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
188        assert_eq!(spec, reparsed);
189    }
190
191    #[test]
192    fn backend_is_externally_tagged() {
193        let spec: RepositorySpec = from_yaml(
194            "backend:\n  filesystem:\n    path: /repo\nencryption:\n  passwordSecretRef:\n    name: s\n",
195        );
196        assert_eq!(spec.backend.kind_str(), "Filesystem");
197        let v = serde_json::to_value(&spec.backend).unwrap();
198        assert_eq!(v["filesystem"]["path"], "/repo");
199    }
200
201    #[test]
202    fn filesystem_repo_volume_pvc_is_externally_tagged() {
203        // `volume: { pvc: { name } }` — the externally-tagged RepoVolume wire shape.
204        let spec: RepositorySpec = from_yaml(
205            "backend:\n  filesystem:\n    path: /repo\n    volume:\n      pvc:\n        name: nas-repo\nencryption:\n  passwordSecretRef:\n    name: s\n",
206        );
207        let Backend::Filesystem(fs) = &spec.backend else {
208            panic!("expected filesystem backend");
209        };
210        match fs.volume.as_ref().expect("volume present") {
211            RepoVolume::Pvc(p) => assert_eq!(p.name, "nas-repo"),
212            other => panic!("expected pvc volume, got {}", other.kind_str()),
213        }
214        // Round-trips through JSON under the camelCase `pvc` key.
215        let v = serde_json::to_value(&spec.backend).unwrap();
216        assert_eq!(v["filesystem"]["volume"]["pvc"]["name"], "nas-repo");
217    }
218
219    #[test]
220    fn filesystem_repo_volume_nfs_is_externally_tagged() {
221        // `volume: { nfs: { server, path } }` — inline NFS repo, no PVC.
222        let spec: RepositorySpec = from_yaml(
223            "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",
224        );
225        let Backend::Filesystem(fs) = &spec.backend else {
226            panic!("expected filesystem backend");
227        };
228        match fs.volume.as_ref().expect("volume present") {
229            RepoVolume::Nfs(n) => {
230                assert_eq!(n.server, "nas.lan");
231                assert_eq!(n.path, "/export/kopia");
232            }
233            other => panic!("expected nfs volume, got {}", other.kind_str()),
234        }
235        let v = serde_json::to_value(&spec.backend).unwrap();
236        assert_eq!(v["filesystem"]["volume"]["nfs"]["server"], "nas.lan");
237        assert_eq!(v["filesystem"]["volume"]["nfs"]["path"], "/export/kopia");
238    }
239
240    #[test]
241    fn repository_workload_identity_roundtrips() {
242        // The cloud-IAM backends accept `auth.workloadIdentity` instead of a
243        // Secret (ADR §4.11); the wire key is camelCase.
244        for (backend_yaml, kind) in [
245            ("s3:\n    bucket: b", "S3"),
246            (
247                "azure:\n    container: c\n    storageAccount: acct",
248                "Azure",
249            ),
250            ("gcs:\n    bucket: b", "Gcs"),
251        ] {
252            let yaml = format!(
253                "backend:\n  {backend_yaml}\n    auth:\n      workloadIdentity:\n        serviceAccountName: backup-mover\nencryption:\n  passwordSecretRef:\n    name: s\n",
254            );
255            let spec: RepositorySpec = from_yaml(&yaml);
256            assert_eq!(spec.backend.kind_str(), kind);
257            let (wi, _) = crate::creds::backend_workload_identity(&spec.backend)
258                .unwrap_or_else(|| panic!("{kind} carries the workload identity"));
259            assert_eq!(wi.service_account_name, "backup-mover");
260            // Round-trip: serialize back and re-parse, assert structural equality.
261            let json = serde_json::to_value(&spec).expect("serialize");
262            let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
263            assert_eq!(spec, reparsed);
264        }
265    }
266
267    #[test]
268    fn workload_identity_is_unrepresentable_on_secret_only_backends() {
269        // B2/SFTP/WebDAV have no cloud IAM plane, so their `auth` is the
270        // Secret-only type and the generated CRD schema must NOT offer
271        // `workloadIdentity` there (the API server prunes it) while the
272        // cloud-IAM backends must.
273        let crd = Repository::crd();
274        let schema = serde_json::to_value(
275            crd.spec.versions[0]
276                .schema
277                .as_ref()
278                .and_then(|s| s.open_api_v3_schema.as_ref())
279                .expect("repository CRD has a schema"),
280        )
281        .expect("schema serializes");
282        let backend = &schema["properties"]["spec"]["properties"]["backend"]["properties"];
283        for cloud in ["s3", "azure", "gcs"] {
284            assert!(
285                !backend[cloud]["properties"]["auth"]["properties"]["workloadIdentity"].is_null(),
286                "{cloud} must offer auth.workloadIdentity"
287            );
288        }
289        for secret_only in ["b2", "sftp", "webDav"] {
290            assert!(
291                backend[secret_only]["properties"]["auth"]["properties"]["workloadIdentity"]
292                    .is_null(),
293                "{secret_only} must NOT offer auth.workloadIdentity"
294            );
295            assert!(
296                !backend[secret_only]["properties"]["auth"]["properties"]["secretRef"].is_null(),
297                "{secret_only} keeps auth.secretRef"
298            );
299        }
300    }
301
302    #[test]
303    fn backup_config_nfs_source_roundtrips() {
304        use crate::SnapshotPolicySpec;
305        let spec: SnapshotPolicySpec = from_yaml(
306            "repository:\n  name: repo\nsources:\n  - nfs:\n      server: expanse.internal\n      path: /mnt/eros/Media\n",
307        );
308        let src = &spec.sources[0];
309        let nfs = src.nfs.as_ref().expect("nfs source present");
310        assert_eq!(nfs.server, "expanse.internal");
311        assert_eq!(nfs.path, "/mnt/eros/Media");
312        assert!(src.pvc.is_none() && src.pvc_selector.is_none());
313    }
314
315    #[test]
316    fn unknown_backend_variant_is_rejected() {
317        let value: serde_json::Value = serde_yaml::from_str("dropbox:\n  bucket: x\n").unwrap();
318        let err = serde_json::from_value::<Backend>(value);
319        assert!(
320            err.is_err(),
321            "unknown backend variant must fail to deserialize"
322        );
323    }
324}