Skip to main content

kopiur_api/
backup_config.rs

1//! The `BackupConfig` CRD — the *recipe*. Idempotent; runs nothing on its own.
2//! ADR-0001 §3.3, ADR-0003 §4.8.
3
4use crate::common::{
5    DeletionPolicy, Identity, MoverSpec, PodSelector, RepositoryRef, ResolvedIdentity, Retention,
6};
7use k8s_openapi::api::batch::v1::JobSpec;
8use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
9use kube::CustomResource;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// *What* to back up: sources, identity, retention, policy, hooks. ADR §3.3.
14///
15/// Not `Eq`: transitively embeds k8s-openapi types via `mover` and `hooks` (`JobSpec`).
16#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
17#[kube(
18    group = "kopiur.home-operations.com",
19    version = "v1alpha1",
20    kind = "BackupConfig",
21    namespaced,
22    status = "BackupConfigStatus",
23    shortname = "kopiabc",
24    category = "kopiur",
25    printcolumn = r#"{"name":"Repository","type":"string","jsonPath":".spec.repository.name"}"#,
26    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
27)]
28#[serde(rename_all = "camelCase")]
29pub struct BackupConfigSpec {
30    /// Discriminated reference to a `Repository` or `ClusterRepository`. ADR §3.2.
31    pub repository: RepositoryRef,
32    /// Identity overrides — what kopia records as `username@hostname:path`. ADR §3.3/§4.2.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub identity: Option<Identity>,
35    /// What to back up. At least one source; webhook-enforced. ADR §3.3.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub sources: Vec<Source>,
38    /// How the source volume is captured before kopia reads it: `Snapshot`
39    /// (point-in-time CSI snapshot, default), `Clone`, or `Direct`. ADR §3.3.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub copy_method: Option<CopyMethod>,
42    /// `VolumeSnapshotClass` used when `copyMethod` snapshots/clones the source.
43    /// ADR §3.3.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub volume_snapshot_class_name: Option<String>,
46    /// Default `VolumeGroupSnapshot` for multi-PVC sources; `None` opts into per-PVC. ADR §4.9.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub group_by: Option<GroupBy>,
49    /// GFS retention — enforced by the operator pruning `Backup` CRs. ADR §4.4.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub retention: Option<Retention>,
52    /// Default `deletionPolicy` for `Backup` CRs created against this config. ADR §3.3/§4.5.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub default_deletion_policy: Option<DeletionPolicy>,
55    /// Typed kopia policy + `extraArgs` escape hatch. ADR §3.3 (G12).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub policy: Option<Policy>,
58    /// Pre/post snapshot hooks that run in the workload, not the mover. ADR §4.8 (G13).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub hooks: Option<Hooks>,
61    /// Per-recipe mover overrides (resources, cache, security context). ADR §3.3.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub mover: Option<MoverSpec>,
64}
65
66/// A single backup source. `pvc` and `pvcSelector` are mutually exclusive
67/// (webhook-enforced — NOT an enum, because both forms share the sibling
68/// `sourcePath*` keys and YAML lists them as optional siblings). ADR §3.3.
69#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
70#[serde(rename_all = "camelCase")]
71pub struct Source {
72    /// Single PVC by name. Mutually exclusive with `pvcSelector`. ADR §3.3.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub pvc: Option<PvcSource>,
75    /// Label/namespace selector matching many PVCs (multi-PVC sources).
76    /// Mutually exclusive with `pvc`. ADR §3.3/§5.4.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub pvc_selector: Option<PvcSelector>,
79    /// What kopia records as the source path (default `/pvc/<name>`). ADR §3.3/§4.2.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub source_path_override: Option<String>,
82    /// How a selector-matched PVC's source path is derived (`pvcName` vs
83    /// `pvcNamespacedName`). Applies to `pvcSelector` sources. ADR §3.3/§4.2.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub source_path_strategy: Option<SourcePathStrategy>,
86}
87
88/// A single backup source addressed by PVC name. ADR §3.3.
89#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
90#[serde(rename_all = "camelCase")]
91pub struct PvcSource {
92    /// Name of the `PersistentVolumeClaim` to back up (in the `BackupConfig`'s
93    /// namespace). ADR §3.3.
94    pub name: String,
95}
96
97/// Selects PVCs across namespaces by label. ADR §3.3/§5.4.
98///
99/// Not `Eq`: embeds `LabelSelector` (k8s-openapi, `PartialEq` only).
100#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
101#[serde(rename_all = "camelCase")]
102pub struct PvcSelector {
103    /// Restricts the search to specific namespaces; absent means the
104    /// `BackupConfig`'s own namespace. ADR §3.3/§5.4.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub namespace_selector: Option<NamespaceSelector>,
107    /// Standard Kubernetes label selector matching the PVCs to include. ADR §3.3/§5.4.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub label_selector: Option<LabelSelector>,
110}
111
112/// Restricts a [`PvcSelector`] to an explicit set of namespaces. ADR §3.3/§5.4.
113#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
114#[serde(rename_all = "camelCase")]
115pub struct NamespaceSelector {
116    /// Exact namespace names to search; empty means the `BackupConfig`'s own
117    /// namespace. ADR §3.3/§5.4.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub match_names: Vec<String>,
120}
121
122/// Volume snapshot copy method. Closed enum. ADR §3.3.
123///
124/// ```
125/// use kopiur_api::CopyMethod;
126///
127/// // Defaults to a point-in-time CSI snapshot.
128/// assert_eq!(CopyMethod::default(), CopyMethod::Snapshot);
129/// // Serializes as a bare PascalCase string (no external tagging — it has no payload).
130/// assert_eq!(serde_json::to_value(CopyMethod::Snapshot).unwrap(), "Snapshot");
131/// assert_eq!(serde_json::to_value(CopyMethod::Direct).unwrap(), "Direct");
132/// ```
133#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
134pub enum CopyMethod {
135    /// Point-in-time CSI volume snapshot (default).
136    #[default]
137    Snapshot,
138    /// CSI volume clone of the source, mounted read-only for the snapshot.
139    Clone,
140    /// Read the live PVC directly with no intermediate snapshot/clone (no
141    /// point-in-time guarantee). ADR §3.3.
142    Direct,
143}
144
145/// Multi-PVC grouping strategy. Closed enum. ADR §4.9.
146///
147/// Defaults to a consistent group snapshot; `None` must be set *explicitly* to
148/// accept independent per-PVC snapshots, because a silent per-PVC fallback would
149/// produce inconsistent backups (the data-integrity hazard ADR §4.9 guards against).
150///
151/// ```
152/// use kopiur_api::GroupBy;
153///
154/// assert_eq!(GroupBy::default(), GroupBy::VolumeGroupSnapshot);
155/// assert_eq!(serde_json::to_value(GroupBy::None).unwrap(), "None");
156/// ```
157#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
158pub enum GroupBy {
159    /// Consistent group snapshot across all PVCs (default for multi-PVC).
160    #[default]
161    VolumeGroupSnapshot,
162    /// Opt into independent per-PVC snapshots. ADR §4.9.
163    None,
164}
165
166/// How a selector-matched PVC's source path is derived. Closed enum. ADR §3.3/§4.2.
167///
168/// Only relevant for `pvcSelector` sources, where one recipe expands to many PVCs
169/// and each needs a distinct kopia source path.
170///
171/// ```
172/// use kopiur_api::SourcePathStrategy;
173///
174/// assert_eq!(SourcePathStrategy::default(), SourcePathStrategy::PvcName);
175/// assert_eq!(
176///     serde_json::to_value(SourcePathStrategy::PvcNamespacedName).unwrap(),
177///     "PvcNamespacedName"
178/// );
179/// ```
180#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
181pub enum SourcePathStrategy {
182    /// Path derived from the PVC name alone (default). ADR §3.3.
183    #[default]
184    PvcName,
185    /// Path derived from `<namespace>/<name>` to disambiguate same-named PVCs
186    /// across namespaces. ADR §3.3.
187    PvcNamespacedName,
188}
189
190/// Typed kopia policy fields plus an `extraArgs` escape hatch. ADR §3.3 (G12).
191#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
192#[serde(rename_all = "camelCase")]
193pub struct Policy {
194    /// Compression algorithm + per-extension opt-outs. ADR §3.3.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub compression: Option<Compression>,
197    /// kopia object splitter (e.g. `DYNAMIC-4M-BUZHASH`). ADR §3.3.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub splitter: Option<String>,
200    /// Paths/patterns kopia should skip while snapshotting. ADR §3.3.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub ignore: Option<IgnorePolicy>,
203    /// Escape hatch for kopia flags not yet modeled. ADR §3.3.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub extra_args: Vec<String>,
206}
207
208/// Compression policy for a [`Policy`]. ADR §3.3.
209#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
210#[serde(rename_all = "camelCase")]
211pub struct Compression {
212    /// kopia compressor name (e.g. `zstd`); absent leaves kopia's default. ADR §3.3.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub compressor: Option<String>,
215    /// Filename globs to leave uncompressed (e.g. already-compressed media). ADR §3.3.
216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
217    pub never_compress: Vec<String>,
218}
219
220/// Path-ignore policy for a [`Policy`]. ADR §3.3.
221#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
222#[serde(rename_all = "camelCase")]
223pub struct IgnorePolicy {
224    /// Filename/path globs to exclude from the snapshot (e.g. `*.tmp`,
225    /// `*/cache/*`, `lost+found`). ADR §3.3.
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub paths: Vec<String>,
228    /// Honor `CACHEDIR.TAG`.
229    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
230    pub cache_dirs: bool,
231    /// fork issue #13.
232    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
233    pub ignore_identical_snapshots: bool,
234}
235
236/// Pre/post snapshot hook lists. ADR §3.3/§4.8.
237///
238/// Not `Eq`: `Hook::RunJob` embeds `JobSpec`.
239#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
240#[serde(rename_all = "camelCase")]
241pub struct Hooks {
242    /// Hooks run (in order) before the snapshot is taken — e.g. quiescing a
243    /// database. ADR §4.8.
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub before_snapshot: Vec<Hook>,
246    /// Hooks run (in order) after the snapshot completes — e.g. resuming the
247    /// workload. ADR §4.8.
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub after_snapshot: Vec<Hook>,
250}
251
252/// One of three hook forms. ADR §4.8.
253///
254/// Externally-tagged: wire shape is `{ workloadExec: {...} }`, `{ runJob: {...} }`,
255/// or `{ httpRequest: {...} }`. Exactly one variant by construction.
256///
257/// Not `Eq`: `RunJob` embeds `JobSpec` (k8s-openapi, `PartialEq` only).
258///
259/// ```
260/// use kopiur_api::backup_config::{Hook, HttpRequestHook};
261///
262/// // Construct directly — the type system guarantees exactly one variant.
263/// let hook = Hook::HttpRequest(HttpRequestHook {
264///     url: "https://example/notify".into(),
265///     method: Some("POST".into()),
266///     body: None,
267///     timeout: None,
268///     continue_on_failure: false,
269/// });
270/// assert_eq!(hook.kind_str(), "HttpRequest");
271///
272/// // Externally tagged on the wire: `{ httpRequest: { url: ... } }`.
273/// let json = serde_json::to_value(&hook).unwrap();
274/// assert_eq!(json["httpRequest"]["url"], "https://example/notify");
275/// ```
276#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
277#[serde(rename_all = "camelCase")]
278pub enum Hook {
279    /// `kubectl exec`-style into a matched workload pod/container (the default form,
280    /// fork #22).
281    WorkloadExec(WorkloadExecHook),
282    /// Full `JobSpec` run as a one-shot Job (k8up `PreBackupPod` analog).
283    ///
284    /// Boxed: `RunJobHook` embeds a `JobSpec` (~2 KB), which would otherwise bloat
285    /// every `Hook` (incl. the common `WorkloadExec`). `Box<T>` is transparent to
286    /// serde, so the externally-tagged `{ runJob: {...} }` wire shape is unchanged.
287    RunJob(Box<RunJobHook>),
288    /// Typed POST to a URL for cross-system orchestration.
289    HttpRequest(HttpRequestHook),
290}
291
292impl Hook {
293    /// Stable discriminant string for status/metrics — one of `"WorkloadExec"`,
294    /// `"RunJob"`, or `"HttpRequest"`.
295    ///
296    /// ```
297    /// use kopiur_api::backup_config::{Hook, HttpRequestHook};
298    ///
299    /// let hook = Hook::HttpRequest(HttpRequestHook {
300    ///     url: "https://example/notify".into(),
301    ///     method: None,
302    ///     body: None,
303    ///     timeout: None,
304    ///     continue_on_failure: false,
305    /// });
306    /// assert_eq!(hook.kind_str(), "HttpRequest");
307    /// ```
308    pub fn kind_str(&self) -> &'static str {
309        match self {
310            Hook::WorkloadExec(_) => "WorkloadExec",
311            Hook::RunJob(_) => "RunJob",
312            Hook::HttpRequest(_) => "HttpRequest",
313        }
314    }
315}
316
317/// Hook failures abort the backup by default; `continueOnFailure: true` is opt-in. ADR §4.8.
318///
319/// Not `Eq`: embeds `LabelSelector` via `PodSelector`.
320#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
321#[serde(rename_all = "camelCase")]
322pub struct WorkloadExecHook {
323    /// Selects the workload pod/container to exec into (flattened onto the hook).
324    /// ADR §4.8.
325    #[serde(flatten)]
326    pub selector: PodSelector,
327    /// Command + args to run inside the selected container. ADR §4.8.
328    #[serde(default, skip_serializing_if = "Vec::is_empty")]
329    pub command: Vec<String>,
330    /// Max time to wait for the command (Go duration string, e.g. `2m`). ADR §4.8.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub timeout: Option<String>,
333    /// If `true`, a failed hook does not abort the backup (default: abort). ADR §4.8.
334    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
335    pub continue_on_failure: bool,
336}
337
338/// A hook that materializes a full one-shot Job (k8up `PreBackupPod` analog). ADR §4.8.
339///
340/// Not `Eq`: embeds `JobSpec`.
341#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
342#[serde(rename_all = "camelCase")]
343pub struct RunJobHook {
344    /// The full Kubernetes `JobSpec` to run. ADR §4.8.
345    pub job_spec: JobSpec,
346    /// Max time to wait for the Job to complete (Go duration string). ADR §4.8.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub timeout: Option<String>,
349    /// If `true`, a failed Job does not abort the backup (default: abort). ADR §4.8.
350    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
351    pub continue_on_failure: bool,
352}
353
354/// A hook that issues an HTTP request for cross-system orchestration. ADR §4.8.
355#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
356#[serde(rename_all = "camelCase")]
357pub struct HttpRequestHook {
358    /// Target URL to call. ADR §4.8.
359    pub url: String,
360    /// HTTP method (default `POST`). ADR §4.8.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub method: Option<String>,
363    /// Optional request body. ADR §4.8.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub body: Option<String>,
366    /// Max time to wait for the response (Go duration string). ADR §4.8.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub timeout: Option<String>,
369    /// If `true`, a failed request does not abort the backup (default: abort). ADR §4.8.
370    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
371    pub continue_on_failure: bool,
372}
373
374/// Observed state of a [`BackupConfig`]. ADR §3.3 status.
375#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
376#[serde(rename_all = "camelCase")]
377pub struct BackupConfigStatus {
378    /// `metadata.generation` last reconciled, for staleness detection. ADR §3.3 status.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub observed_generation: Option<i64>,
381    /// What would be passed to kopia — pinned at admission. ADR §3.3/§4.2.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub resolved: Option<ResolvedConfig>,
384    /// Summary of GFS retention pruning against this config's `Backup` CRs.
385    /// ADR §3.3 status/§4.4.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub retention: Option<RetentionSummary>,
388    /// Standard Kubernetes conditions (e.g. `RepositoryReachable`,
389    /// `GroupSnapshotSupported`). ADR §3.3 status.
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub conditions: Vec<Condition>,
392}
393
394/// The recipe as kopia would see it, pinned at admission and never re-rendered
395/// (ADR §4.2). ADR §3.3 status.
396#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
397#[serde(rename_all = "camelCase")]
398pub struct ResolvedConfig {
399    /// The resolved `username@hostname` identity. ADR §3.3/§4.2.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub identity: Option<ResolvedIdentity>,
402    /// The concrete PVCs + source paths after selector expansion. ADR §3.3 status.
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub sources: Vec<ResolvedConfigSource>,
405}
406
407/// One resolved source — a concrete PVC and the path kopia records for it. ADR §3.3 status.
408#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
409#[serde(rename_all = "camelCase")]
410pub struct ResolvedConfigSource {
411    /// `namespace/name` of the PVC, as kopia sees it. ADR §3.3 status.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub pvc: Option<String>,
414    /// The source path kopia records for this PVC. ADR §3.3/§4.2.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub source_path: Option<String>,
417}
418
419/// Summary of the most recent GFS retention prune for a [`BackupConfig`]. ADR §3.3 status/§4.4.
420#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
421#[serde(rename_all = "camelCase")]
422pub struct RetentionSummary {
423    /// CRs currently inside the GFS window. ADR §3.3 status.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub active_backup_count: Option<i64>,
426    /// RFC3339 timestamp of the last prune pass. ADR §3.3 status.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub last_prune_at: Option<String>,
429    /// Number of `Backup` CRs deleted by the last prune pass. ADR §3.3 status/§4.4.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub last_prune_deleted: Option<i64>,
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::common::RepositoryKind;
438    use crate::testutil::from_yaml;
439    use kube::core::CustomResourceExt;
440
441    #[test]
442    fn backup_config_crd_metadata_is_correct() {
443        let crd = BackupConfig::crd();
444        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
445        assert_eq!(crd.spec.names.kind, "BackupConfig");
446        assert_eq!(crd.spec.scope, "Namespaced");
447        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
448    }
449
450    #[test]
451    fn backup_config_roundtrip_matches_adr_shape() {
452        // Mirrors ADR-0001 §3.3.
453        let yaml = r#"
454repository:
455  kind: Repository
456  name: nas-primary
457  namespace: backups
458identity:
459  username: "postgres-data"
460  hostname: "billing"
461sources:
462  - pvc: { name: postgres-data }
463    sourcePathOverride: /data
464copyMethod: Snapshot
465volumeSnapshotClassName: csi-snap-class
466groupBy: VolumeGroupSnapshot
467retention:
468  keepLatest: 10
469  keepDaily: 14
470defaultDeletionPolicy: Delete
471policy:
472  compression:
473    compressor: zstd
474    neverCompress: ["*.zip", "*.gz", "*.mp4"]
475  splitter: DYNAMIC-4M-BUZHASH
476  ignore:
477    paths: ["*.tmp", "*/cache/*", "lost+found"]
478    cacheDirs: true
479    ignoreIdenticalSnapshots: true
480  extraArgs: []
481hooks:
482  beforeSnapshot:
483    - workloadExec:
484        podSelector: { matchLabels: { app: postgres } }
485        container: postgres
486        command: ["pg_start_backup", "snap"]
487        timeout: 2m
488  afterSnapshot:
489    - workloadExec:
490        podSelector: { matchLabels: { app: postgres } }
491        container: postgres
492        command: ["pg_stop_backup"]
493        timeout: 2m
494mover:
495  resources:
496    requests: { cpu: 250m, memory: 512Mi }
497    limits: { cpu: "2", memory: 4Gi }
498  cache:
499    capacity: 16Gi
500    storageClassName: fast-ssd
501"#;
502        let spec: BackupConfigSpec = from_yaml(yaml);
503        assert_eq!(spec.repository.kind, RepositoryKind::Repository);
504        assert_eq!(spec.repository.name, "nas-primary");
505        assert_eq!(spec.sources.len(), 1);
506        assert_eq!(spec.sources[0].pvc.as_ref().unwrap().name, "postgres-data");
507        assert_eq!(
508            spec.sources[0].source_path_override.as_deref(),
509            Some("/data")
510        );
511        assert_eq!(spec.copy_method, Some(CopyMethod::Snapshot));
512        assert_eq!(spec.group_by, Some(GroupBy::VolumeGroupSnapshot));
513        assert_eq!(spec.default_deletion_policy, Some(DeletionPolicy::Delete));
514        let comp = spec.policy.as_ref().unwrap().compression.as_ref().unwrap();
515        assert_eq!(comp.compressor.as_deref(), Some("zstd"));
516        let hooks = spec.hooks.as_ref().unwrap();
517        assert_eq!(hooks.before_snapshot.len(), 1);
518        assert_eq!(hooks.before_snapshot[0].kind_str(), "WorkloadExec");
519
520        let json = serde_json::to_value(&spec).expect("serialize");
521        let reparsed: BackupConfigSpec = serde_json::from_value(json).expect("reparse");
522        assert_eq!(spec, reparsed);
523    }
524
525    #[test]
526    fn backup_config_minimal_selector_source() {
527        // Mirrors ADR-0001 §5.4 (multi-PVC selector).
528        let yaml = r#"
529repository: { kind: Repository, name: nas-primary, namespace: backups }
530identity: { username: app-bundle, hostname: billing }
531sources:
532  - pvcSelector:
533      labelSelector: { matchLabels: { backup: include } }
534    sourcePathStrategy: PvcName
535groupBy: VolumeGroupSnapshot
536retention: { keepDaily: 14 }
537"#;
538        let spec: BackupConfigSpec = from_yaml(yaml);
539        let src = &spec.sources[0];
540        assert!(src.pvc.is_none());
541        assert!(src.pvc_selector.is_some());
542        assert_eq!(src.source_path_strategy, Some(SourcePathStrategy::PvcName));
543
544        let json = serde_json::to_value(&spec).unwrap();
545        let reparsed: BackupConfigSpec = serde_json::from_value(json).unwrap();
546        assert_eq!(spec, reparsed);
547    }
548
549    #[test]
550    fn hook_run_job_variant_with_job_spec() {
551        // RunJob embeds a full k8s-openapi JobSpec (so the struct is not Eq).
552        let yaml = r#"
553runJob:
554  jobSpec:
555    template:
556      spec:
557        restartPolicy: Never
558        containers:
559          - name: pre
560            image: busybox
561            command: ["sh", "-c", "echo hi"]
562  timeout: 5m
563  continueOnFailure: true
564"#;
565        let hook: Hook = from_yaml(yaml);
566        assert_eq!(hook.kind_str(), "RunJob");
567        match &hook {
568            Hook::RunJob(j) => {
569                assert!(j.continue_on_failure);
570                assert_eq!(j.timeout.as_deref(), Some("5m"));
571                assert_eq!(
572                    j.job_spec
573                        .template
574                        .spec
575                        .as_ref()
576                        .unwrap()
577                        .restart_policy
578                        .as_deref(),
579                    Some("Never")
580                );
581            }
582            other => panic!("expected RunJob, got {}", other.kind_str()),
583        }
584        let json = serde_json::to_value(&hook).unwrap();
585        assert!(json.get("runJob").is_some());
586    }
587
588    #[test]
589    fn hook_http_request_variant() {
590        let hook: Hook = from_yaml("httpRequest:\n  url: https://example/notify\n  method: POST\n");
591        assert_eq!(hook.kind_str(), "HttpRequest");
592        let json = serde_json::to_value(&hook).unwrap();
593        assert_eq!(json["httpRequest"]["url"], "https://example/notify");
594    }
595
596    #[test]
597    fn hook_unknown_variant_is_rejected() {
598        let value: serde_json::Value = serde_yaml::from_str("teleport:\n  url: x\n").unwrap();
599        assert!(serde_json::from_value::<Hook>(value).is_err());
600    }
601}