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
19pub 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
98pub 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
123pub const GROUP: &str = "kopiur.home-operations.com";
125pub const VERSION: &str = "v1alpha1";
127
128#[cfg(any(test, feature = "testutil"))]
140pub mod testutil {
141 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 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 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 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 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 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 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 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 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 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 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}