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, 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
97pub 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
119pub const GROUP: &str = "kopiur.home-operations.com";
121pub const VERSION: &str = "v1alpha1";
123
124#[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 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 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 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 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 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 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 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 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 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 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}