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
17pub 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
77pub 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
97pub const GROUP: &str = "kopiur.home-operations.com";
99pub const VERSION: &str = "v1alpha1";
101
102#[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 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 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 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 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 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 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 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 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 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 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}