Skip to main content

kopiur_api/
restore.rs

1//! The `Restore` CRD — a restore from a snapshot/identity to a PVC, or a passive
2//! populator source. ADR-0001 §3.6, ADR-0003 §4.6.
3
4use crate::common::{
5    CredentialProjection, FailurePolicy, MoverSpec, ObjectRef, PvcAccessMode, RepositoryRef,
6    ResolvedIdentity,
7};
8use crate::snapshot_policy::StreamExec;
9use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
10use kube::CustomResource;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14/// A restore operation from a snapshot/identity into a PVC, or a passive populator source.
15#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
16#[kube(
17    group = "kopiur.home-operations.com",
18    version = "v1alpha1",
19    kind = "Restore",
20    namespaced,
21    status = "RestoreStatus",
22    shortname = "kopiarestore",
23    category = "kopiur",
24    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
25    printcolumn = r#"{"name":"Source","type":"string","jsonPath":".status.sourceKind"}"#,
26    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
27)]
28// §15: operator-authored CEL in the CRD schema — exactly one of
29// target.pvc/target.pvcRef/target.populator/target.streamExec. Validates in the
30// apiserver + CI (`kubeconform`), complementing the webhook. `target` is required so
31// `has(self.target)` is always true; the rule counts the present sub-keys.
32//
33// MUST list every `RestoreTarget` variant. The Rust enum being exhaustive does NOT
34// keep this in step — a variant added there but not here is rejected by the API
35// SERVER before the webhook or any reconciler ever sees it, with a message naming
36// only the old variants. (That is exactly how `streamExec` first failed on a real
37// cluster.) `restore_target_cel_rule_lists_every_variant` is the guard.
38#[schemars(extend("x-kubernetes-validations" = [{
39    "rule": "[has(self.target.pvc), has(self.target.pvcRef), has(self.target.populator), has(self.target.streamExec)].filter(x, x).size() == 1",
40    "message": "exactly one of target.pvc, target.pvcRef, target.populator, target.streamExec"
41}]))]
42#[serde(rename_all = "camelCase")]
43/// Desired state of a Restore: where to read from, where to write to, and how to behave when the snapshot is missing.
44pub struct RestoreSpec {
45    /// The repository to read from; derived from `source` when omitted, required only with `source.identity`.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub repository: Option<RepositoryRef>,
48    /// Where to read data from (snapshotRef, fromPolicy, or identity).
49    pub source: RestoreSource,
50    /// Where to write the restored data (pvc, pvcRef, or populator).
51    pub target: RestoreTarget,
52    /// kopia restore behavior (file deletion, permission/atomicity handling).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub options: Option<RestoreOptions>,
55    /// What to do when the referenced snapshot doesn't exist yet, and how long to wait.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub policy: Option<RestorePolicy>,
58    /// Opt-in copying of the repository's credential Secret(s) into the mover's namespace (default off).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub credential_projection: Option<CredentialProjection>,
61    /// Per-run mover overrides for this restore's Job (resources, cache, `securityContext`).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub mover: Option<MoverSpec>,
64    /// Mover `Job` retry/deadline limits (`backoffLimit`, `activeDeadlineSeconds`).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub failure_policy: Option<FailurePolicy>,
67}
68
69/// Where to restore from; exactly one variant.
70#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub enum RestoreSource {
73    /// A `Snapshot` CR (scheduled, manual, or discovered).
74    SnapshotRef(ObjectRef),
75    /// A `SnapshotPolicy` CR, resolved via identity even with no `Snapshot` CR present (deploy-or-restore).
76    FromPolicy(FromPolicy),
77    /// A raw kopia identity (foreign writers / aged-out catalog); requires `spec.repository`.
78    Identity(IdentitySource),
79}
80
81impl RestoreSource {
82    /// Stable discriminant string for status/metrics.
83    ///
84    /// ```
85    /// use kopiur_api::common::ObjectRef;
86    /// use kopiur_api::restore::RestoreSource;
87    ///
88    /// let src = RestoreSource::SnapshotRef(ObjectRef { name: "pg-20260524".into(), namespace: None });
89    /// assert_eq!(src.kind_str(), "SnapshotRef");
90    ///
91    /// // Externally tagged: each variant deserializes under its own camelCase key.
92    /// let from_cfg: RestoreSource =
93    ///     serde_json::from_value(serde_json::json!({ "fromPolicy": { "name": "pg" } })).unwrap();
94    /// assert_eq!(from_cfg.kind_str(), "FromPolicy");
95    /// ```
96    pub fn kind_str(&self) -> &'static str {
97        match self {
98            RestoreSource::SnapshotRef(_) => "SnapshotRef",
99            RestoreSource::FromPolicy(_) => "FromPolicy",
100            RestoreSource::Identity(_) => "Identity",
101        }
102    }
103}
104
105/// The `fromPolicy` source: resolve a snapshot via a `SnapshotPolicy`'s identity, even when no `Snapshot` CR exists yet.
106#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
107#[serde(rename_all = "camelCase")]
108pub struct FromPolicy {
109    /// Name of the `SnapshotPolicy` whose identity selects the snapshot.
110    pub name: String,
111    /// Namespace of the `SnapshotPolicy`; absent = the `Restore`'s own namespace.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub namespace: Option<String>,
114    /// Restore the newest snapshot at or before this RFC3339 timestamp (point-in-time).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub as_of: Option<String>,
117    /// Which snapshot to pick: 0 = latest, 1 = previous, and so on.
118    #[serde(default = "default_offset")]
119    #[schemars(default = "default_offset")]
120    pub offset: i64,
121    /// The kopia source path to restore FROM, overriding the path kopiur derives
122    /// from the policy.
123    ///
124    /// Normally the path is derived: a policy with one plain `pvc:`/`nfs` source
125    /// contributes its own path, and a `pvcSelector` policy contributes the path
126    /// its `sourcePathStrategy` would have produced for the PVC being restored
127    /// (`/pvc/<name>` or `/pvc/<namespace>/<name>`) — the same rule the backup
128    /// side used when it wrote the snapshot, so a fan-out restore fills each PVC
129    /// from ITS OWN snapshot instead of the newest snapshot of any member.
130    ///
131    /// Set this when the derivation is ambiguous or wrong: selector sources that
132    /// disagree on `sourcePathStrategy`/`sourcePathOverride` (kopiur fails closed
133    /// rather than guess), a policy whose selector sources share one
134    /// `sourcePathOverride`, or a cross-namespace `target.pvcRef` whose derived
135    /// `/pvc/<namespace>/<name>` names a namespace the repository never saw.
136    ///
137    /// Mirrors `IdentitySource::source_path`: it selects which kopia source to
138    /// READ, it does not change where the data is written.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    #[schemars(length(max = 4096))]
141    pub source_path: Option<String>,
142}
143
144/// serde/schemars `default` for [`FromPolicy::offset`] — `0`, the latest snapshot
145/// (ADR-0005 §1). A named fn so it backs BOTH `#[serde(default = ...)]` and
146/// `#[schemars(default = ...)]`, which is what makes schemars 1 emit the OpenAPI
147/// `default:` in the generated CRD schema.
148fn default_offset() -> i64 {
149    0
150}
151
152/// The `identity` source: a raw kopia `username@hostname:path` identity; requires `spec.repository`.
153#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
154#[serde(rename_all = "camelCase")]
155pub struct IdentitySource {
156    /// The kopia `username` to match.
157    pub username: String,
158    /// The kopia `hostname` to match.
159    pub hostname: String,
160    /// The kopia source path to match; absent matches any path for the identity.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub source_path: Option<String>,
163    /// Pin an exact kopia snapshot by ID.
164    #[serde(
165        default,
166        rename = "snapshotID",
167        skip_serializing_if = "Option::is_none"
168    )]
169    pub snapshot_id: Option<String>,
170    /// Restore the newest snapshot at or before this RFC3339 timestamp (point-in-time).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub as_of: Option<String>,
173    /// Which snapshot to pick: 0 = latest, 1 = previous, and so on.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub offset: Option<i64>,
176}
177
178/// Where to restore to; exactly one variant.
179#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
180#[serde(rename_all = "camelCase")]
181pub enum RestoreTarget {
182    /// Operator creates the PVC.
183    Pvc(PvcTemplate),
184    /// Write into an existing PVC.
185    PvcRef(ObjectRef),
186    /// Passive populator mode: the restore is claimed by a PVC's `spec.dataSourceRef`.
187    Populator(PopulatorTarget),
188    /// Stream one virtual file out of the snapshot into a command's stdin in a
189    /// running Pod — the companion of a `stream` backup source (e.g. feeding a
190    /// `pg_dumpall` artifact back through `psql`). Writes no PVC.
191    StreamExec(StreamExecTarget),
192}
193
194impl RestoreTarget {
195    /// Stable discriminant string for status/metrics.
196    ///
197    /// ```
198    /// use kopiur_api::common::ObjectRef;
199    /// use kopiur_api::restore::RestoreTarget;
200    ///
201    /// let into_existing = RestoreTarget::PvcRef(ObjectRef { name: "data".into(), namespace: None });
202    /// assert_eq!(into_existing.kind_str(), "PvcRef");
203    ///
204    /// // Externally tagged: `{ pvc: {...} }` selects the create-PVC variant.
205    /// let created: RestoreTarget =
206    ///     serde_json::from_value(serde_json::json!({ "pvc": { "name": "restored" } })).unwrap();
207    /// assert_eq!(created.kind_str(), "Pvc");
208    /// ```
209    pub fn kind_str(&self) -> &'static str {
210        match self {
211            RestoreTarget::Pvc(_) => "Pvc",
212            RestoreTarget::PvcRef(_) => "PvcRef",
213            RestoreTarget::Populator(_) => "Populator",
214            RestoreTarget::StreamExec(_) => "StreamExec",
215        }
216    }
217}
218
219/// Feed one virtual file from the snapshot into a command's stdin, in exactly one
220/// running Pod.
221///
222/// The reverse of a `stream` backup source. Deliberately opt-in and deliberately
223/// blunt: it runs whatever you name against whatever the selector matches, so point
224/// it at a scratch or otherwise prepared database, never at a live production Pod
225/// you are not willing to overwrite.
226#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct StreamExecTarget {
229    /// Which virtual file inside the snapshot to read back — the `fileName` the
230    /// backup's stream source used (e.g. `postgres.sql`).
231    #[schemars(length(min = 1, max = 255))]
232    pub file_name: String,
233    /// Where to send it: the command receives the file's bytes on stdin.
234    pub workload_exec: StreamExec,
235}
236
237/// Passive-populator target marker; its presence selects populator mode.
238#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
239#[serde(rename_all = "camelCase")]
240pub struct PopulatorTarget {}
241
242/// Template for a PVC the operator creates as the restore target.
243#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct PvcTemplate {
246    /// Name of the PVC to create.
247    pub name: String,
248    /// StorageClass for the new PVC; absent uses the cluster default.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub storage_class_name: Option<String>,
251    /// Requested size of the new PVC (e.g. `100Gi`).
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub capacity: Option<String>,
254    /// Access modes for the new PVC; empty defaults to `[ReadWriteOnce]`. Closed
255    /// enum in the schema; a non-canonical value persisted before enforcement
256    /// decodes as [`PvcAccessMode::Unknown`] and is rejected per-CR with the
257    /// value quoted (webhook + reconciler, via `validate_access_modes`) instead
258    /// of poisoning the typed watcher.
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    pub access_modes: Vec<PvcAccessMode>,
261}
262
263/// kopia restore behavior knobs (M2 flag sweep). Every `Option` field's `None`
264/// reproduces kopia's own default — an all-`None`, `enableFileDeletion: false`
265/// instance yields the exact same `restore_args` argv produced before these
266/// fields existed. The tri-state booleans map to kopia's `--[no-]flag` grammar
267/// (`Some(true)` → `--flag`, `Some(false)` → `--no-flag`, `None` → omit).
268#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
269#[serde(rename_all = "camelCase")]
270pub struct RestoreOptions {
271    /// Delete files in the target that are not present in the snapshot (exact mirror); off by default.
272    /// Wired to kopia's `--[no-]delete-extra` (previously a silent no-op — see issue #216 gap sweep).
273    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
274    pub enable_file_deletion: bool,
275    /// Continue past permission errors during restore (default true).
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub ignore_permission_errors: Option<bool>,
278    /// Write files atomically via a temp file + rename (default true).
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub write_files_atomically: Option<bool>,
281    /// `--parallel`: restore parallelism (kopia default `8`; `1` disables parallelism).
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub parallel: Option<u32>,
284    /// `--[no-]write-sparse-files`: attempt to write files sparsely, allocating the
285    /// minimum disk space needed (kopia default `false`).
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub write_sparse_files: Option<bool>,
288    /// `--[no-]skip-owners`: skip restoring file owners (kopia default `false`).
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub skip_owners: Option<bool>,
291    /// `--[no-]skip-permissions`: skip restoring file permissions (kopia default `false`).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub skip_permissions: Option<bool>,
294    /// `--[no-]skip-times`: skip restoring file modification times (kopia default `false`).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub skip_times: Option<bool>,
297    /// `--[no-]overwrite-files`: overwrite existing files in the target (kopia default `true`).
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub overwrite_files: Option<bool>,
300    /// `--[no-]overwrite-directories`: overwrite existing directories in the target
301    /// (kopia default `true`).
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub overwrite_directories: Option<bool>,
304    /// `--[no-]overwrite-symlinks`: overwrite existing symlinks in the target
305    /// (kopia default `true`).
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub overwrite_symlinks: Option<bool>,
308    /// `--[no-]ignore-errors`: ignore all restore errors and continue (kopia default `false`).
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub ignore_errors: Option<bool>,
311    /// `--[no-]skip-existing`: skip files/symlinks that already exist in the target
312    /// (kopia default `false`).
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub skip_existing: Option<bool>,
315}
316
317/// How the restore reacts to a missing snapshot and how long it waits.
318#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
319#[serde(rename_all = "camelCase")]
320pub struct RestorePolicy {
321    /// What to do when the resolved source matches no snapshot (`Fail`/`Continue`).
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub on_missing_snapshot: Option<OnMissingSnapshot>,
324    /// How long to wait for the source snapshot to appear before giving up (e.g. `5m`).
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub wait_timeout: Option<String>,
327}
328
329/// What to do when the resolved source matches no snapshot. Defaults to `Fail`
330/// (fail-closed) so an explicit restore can never silently no-op; choose
331/// `Continue` to provision an empty volume instead (deploy-or-restore).
332#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
333pub enum OnMissingSnapshot {
334    /// Fail-closed; the default for explicit `snapshotRef`/`identity` sources.
335    #[default]
336    Fail,
337    /// Proceed with an empty volume (deploy-or-restore); the default for `fromPolicy`.
338    Continue,
339}
340
341/// Lifecycle phase of a restore.
342///
343/// ```
344/// use kopiur_api::RestorePhase;
345///
346/// assert_eq!(serde_json::to_value(RestorePhase::Restoring).unwrap(), "Restoring");
347/// // An unrecognized phase from a newer operator decodes instead of erroring.
348/// let p: RestorePhase = serde_json::from_value(serde_json::json!("Staging")).unwrap();
349/// assert_eq!(p, RestorePhase::Unknown("Staging".into()));
350/// assert_eq!(serde_json::to_value(&p).unwrap(), "Staging");
351/// assert!(!p.is_terminal());
352/// ```
353#[derive(Clone, Debug, PartialEq, Eq, Default)]
354pub enum RestorePhase {
355    /// Admitted but not yet acted on; the default initial phase.
356    #[default]
357    Pending,
358    /// Resolving the source to a concrete snapshot and pinning it to status.
359    Resolving,
360    /// The mover `Job` is actively writing data into the target.
361    Restoring,
362    /// The restore finished successfully.
363    Completed,
364    /// The restore terminally failed; see `conditions` for the reason.
365    Failed,
366    /// A phase string this build does not recognize (newer operator, or legacy
367    /// stored data). Decode-compat only — hidden from the CRD schema, never
368    /// produced by this build, never terminal.
369    Unknown(String),
370}
371
372crate::common::phase_serde!(RestorePhase, "Lifecycle phase of a restore.");
373
374impl RestorePhase {
375    /// Whether this phase is **terminal**: the restore reached an end state and
376    /// the operator will do no further work on it of its own accord.
377    ///
378    /// `Pending`, `Resolving`, and `Restoring` are all in-flight — including a
379    /// `Pending` restore parked on a structural gate (see
380    /// [`crate::gates::STRUCTURAL_GATES`]), which never progresses but is
381    /// emphatically not finished. A `Restore` has no deletion phase of its own,
382    /// so unlike `SnapshotPhase` there is no wedged-finalizer case to exclude.
383    ///
384    /// Pure + exhaustive so the single definition lives in one tested place.
385    ///
386    /// ```
387    /// use kopiur_api::RestorePhase;
388    ///
389    /// assert!(RestorePhase::Completed.is_terminal());
390    /// assert!(RestorePhase::Failed.is_terminal());
391    /// assert!(!RestorePhase::Pending.is_terminal());
392    /// assert!(!RestorePhase::Resolving.is_terminal());
393    /// assert!(!RestorePhase::Restoring.is_terminal());
394    /// assert!(!RestorePhase::Unknown("Staging".into()).is_terminal());
395    /// ```
396    pub fn is_terminal(&self) -> bool {
397        match self {
398            Self::Completed | Self::Failed => true,
399            Self::Pending | Self::Resolving | Self::Restoring => false,
400            // Conservative surface-it policy: a phase this build cannot
401            // interpret is never reported as finished, so a newer operator's
402            // in-flight restore stays visible to an older CLI/reconciler.
403            Self::Unknown(_) => false,
404        }
405    }
406
407    /// Whether this phase is the **decode sentinel** — a value the running build
408    /// cannot interpret, kept verbatim by [`Unknown`](Self::Unknown) instead of
409    /// failing the whole typed `list()`/watch (#359, defect 3).
410    ///
411    /// Same narrow contract as [`SnapshotPhase::is_unknown`](crate::SnapshotPhase::is_unknown):
412    /// `true` means only "this string is not a phase this binary knows". A
413    /// canonical variant added later is not the sentinel, which is why the
414    /// `match` is written out exhaustively rather than left as a `matches!`.
415    ///
416    /// ```
417    /// use kopiur_api::RestorePhase;
418    ///
419    /// assert!(RestorePhase::Unknown("Staging".into()).is_unknown());
420    /// assert!(!RestorePhase::Completed.is_unknown());
421    /// assert!(!RestorePhase::Failed.is_unknown());
422    /// ```
423    pub fn is_unknown(&self) -> bool {
424        match self {
425            Self::Unknown(_) => true,
426            Self::Pending | Self::Resolving | Self::Restoring | Self::Completed | Self::Failed => {
427                false
428            }
429        }
430    }
431}
432
433impl crate::common::PhaseLabel for RestorePhase {
434    const ALL: &'static [Self] = &[
435        Self::Pending,
436        Self::Resolving,
437        Self::Restoring,
438        Self::Completed,
439        Self::Failed,
440    ];
441    fn label(&self) -> &str {
442        match self {
443            Self::Pending => "Pending",
444            Self::Resolving => "Resolving",
445            Self::Restoring => "Restoring",
446            Self::Completed => "Completed",
447            Self::Failed => "Failed",
448            Self::Unknown(s) => s,
449        }
450    }
451    fn unknown(raw: String) -> Self {
452        Self::Unknown(raw)
453    }
454}
455
456/// Observed state of a Restore, written by the controller/mover.
457#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
458#[serde(rename_all = "camelCase")]
459pub struct RestoreStatus {
460    /// Current lifecycle phase.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub phase: Option<RestorePhase>,
463    /// The pinned source kind (`SnapshotRef`/`FromPolicy`/`Identity`); backs the `SOURCE` printer column.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub source_kind: Option<String>,
466    /// `metadata.generation` last reconciled, so stale status is detectable.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub observed_generation: Option<i64>,
469    /// The source resolved and pinned at admission; never re-resolved.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub resolved: Option<ResolvedRestore>,
472    /// Resolved target details (the PVC written to / populator handshake).
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub target: Option<RestoreTargetStatus>,
475    /// Start/end timestamps for the restore run.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub timing: Option<RestoreTiming>,
478    /// When the `policy.waitTimeout` window OPENED (RFC3339) — the first reconcile on which
479    /// the restore could actually proceed (its repository reached `Ready`, and for a
480    /// `target.populator` a PVC already claims it), NOT when the Restore was created. The
481    /// window does NOT open while the referenced `Repository` object or `fromPolicy`
482    /// `SnapshotPolicy` doesn't exist: the restore parks in `Pending`
483    /// (`ReferentAvailable=False`, reason `RestoreReferentMissing`) and stays unstamped. It
484    /// DOES still open for a `snapshotRef` whose `Snapshot` row doesn't exist yet (so
485    /// `onMissingSnapshot` can fire for a ref that never appears) and for a restore whose
486    /// mover Job already launched. Stamped once and then honored verbatim, so the window
487    /// survives controller restarts and Job pod retries; cleared when a populator re-opens
488    /// resolution for a re-created claim, so that claim gets the full window again. Absent
489    /// means the window has not opened yet (or no `policy.waitTimeout` is configured, in
490    /// which case there is no window to anchor).
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub wait_started_at: Option<String>,
493    /// Bytes/files restored so far, patched periodically by the mover.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub progress: Option<RestoreProgress>,
496    /// Standard Kubernetes conditions carrying the human-readable status/reason.
497    #[serde(default, skip_serializing_if = "Vec::is_empty")]
498    pub conditions: Vec<Condition>,
499    /// The last lines of the run's output, written by the mover at the terminal transition.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub log_tail: Option<String>,
502    /// Structured terminal-failure detail (kopia error class, stderr tail, retry hint).
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub failure: Option<crate::common::FailureBlock>,
505    /// Per-claimant state for a `target.populator` restore, keyed by the claiming
506    /// PVC's name (#443).
507    ///
508    /// A populator `Restore` is claimed by EVERY PVC whose `spec.dataSourceRef`
509    /// names it, not just the first, and each claimant gets its own prime PVC, its
510    /// own mover `Job` and its own kopia source path — so each needs its own state.
511    /// A **map**, not a list, because an RFC-7386 merge patch merges map keys but
512    /// REPLACES arrays: N concurrent populate movers each patch only their own key
513    /// and can never clobber a sibling (the same reason as
514    /// `SnapshotPolicyStatus.verificationStamps`).
515    ///
516    /// Absent for a direct `target.pvc`/`target.pvcRef` restore, which keeps using
517    /// the top-level `resolved`/`target`/`waitStartedAt`/`logTail`/`failure`.
518    ///
519    /// The schema renders as an object with `additionalProperties`, which PRUNES
520    /// unknown keys — so every field any writer (controller or mover) puts under
521    /// `claims.<pvc>` must exist on `RestoreClaimStatus`.
522    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
523    pub claims: std::collections::BTreeMap<String, RestoreClaimStatus>,
524}
525
526/// The state of ONE claiming PVC of a `target.populator` restore (#443).
527///
528/// Written by the controller (every field except `resolved`/`observedAt`/
529/// `logTail`/`failure`) and by that claim's mover `Job`, which nests its status
530/// patch under `status.claims.<pvc>` and deliberately omits `phase` — the
531/// controller owns claim phase exactly as it owns the top-level one.
532#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
533#[serde(rename_all = "camelCase")]
534pub struct RestoreClaimStatus {
535    /// `metadata.uid` of the claiming PVC this record describes. A record whose
536    /// uid no longer matches the live claimant is STALE — the PVC was deleted and
537    /// re-created — so the claim re-arms and the old claim's prime PVC / Job / PV
538    /// are reaped under the recorded uid.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub uid: Option<String>,
541    /// Lifecycle phase of this claim; absent means "not observed yet".
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub phase: Option<RestoreClaimPhase>,
544    /// Machine-readable reason for the current phase (`PopulatingPrimePvc`,
545    /// `MoverJobFailed`, `SourcePathAmbiguous`, …).
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub reason: Option<String>,
548    /// Human-readable what / why / fix for the current phase.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub message: Option<String>,
551    /// The kopia source path this claim restores FROM, derived from the policy's
552    /// `sourcePathStrategy` (or pinned from `source.fromPolicy.sourcePath`).
553    /// Recorded so a fan-out restore is auditable: each claim shows which member's
554    /// data it read.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub source_path: Option<String>,
557    /// The source this claim resolved and pinned; never re-resolved.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub resolved: Option<ResolvedRestore>,
560    /// The prime PVC provisioned for this claim's populate handshake.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub pvc_prime: Option<String>,
563    /// The mover `Job` populating this claim's prime PVC.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub job: Option<String>,
566    /// When THIS claim's `policy.waitTimeout` window opened (RFC3339) — the first
567    /// pass on which this claim could actually proceed. Per-claim because
568    /// claimants appear at different times: a sibling created an hour later gets
569    /// its own full window.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub wait_started_at: Option<String>,
572    /// When this claim's mover last patched (RFC3339). Mover-owned: every
573    /// `StatusUpdate` carries it, and the field must exist here or the schema's
574    /// `additionalProperties` pruning would silently drop the mover's whole patch.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub observed_at: Option<String>,
577    /// The last lines of this claim's mover output, written once at its terminal
578    /// transition. Mover-owned.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub log_tail: Option<String>,
581    /// Structured terminal-failure detail for this claim. Mover-owned.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub failure: Option<crate::common::FailureBlock>,
584}
585
586/// Lifecycle phase of ONE claiming PVC of a populator `Restore` (#443).
587///
588/// Deliberately a separate closed enum from [`RestorePhase`]: a claim has states
589/// the Restore does not (`Rebinding`, `AlreadyBound`), and the Restore-level
590/// phase is the AGGREGATE over these. Named `*Phase` on purpose, so
591/// `cargo xtask check-phases` polices every branch on it.
592///
593/// ```
594/// use kopiur_api::RestoreClaimPhase;
595///
596/// assert_eq!(serde_json::to_value(RestoreClaimPhase::Populating).unwrap(), "Populating");
597/// // An unrecognized phase from a newer operator decodes instead of erroring.
598/// let p: RestoreClaimPhase = serde_json::from_value(serde_json::json!("Staging")).unwrap();
599/// assert_eq!(p, RestoreClaimPhase::Unknown("Staging".into()));
600/// assert_eq!(serde_json::to_value(&p).unwrap(), "Staging");
601/// assert!(!p.is_terminal());
602/// ```
603#[derive(Clone, Debug, PartialEq, Eq, Default)]
604pub enum RestoreClaimPhase {
605    /// Observed, but nothing has run for it yet — waiting on a scheduling hint
606    /// (`WaitForFirstConsumer`), on the repository, or on the source snapshot.
607    #[default]
608    Pending,
609    /// A mover `Job` is writing this claim's prime PVC.
610    Populating,
611    /// The prime volume is written and its `PersistentVolume` was rebound to the
612    /// claim; waiting for the PV controller to complete the bind.
613    Rebinding,
614    /// The claim is bound to the volume this restore populated. Terminal.
615    Populated,
616    /// The claim was ALREADY bound when first observed, so there was nothing to
617    /// populate — a CSI volume-populator only fills an UNBOUND claim. A truthful
618    /// terminal no-op (#233): no prime PVC, no mover run.
619    AlreadyBound,
620    /// This claim terminally failed; `reason`/`message` say why. Terminal for the
621    /// CLAIM only — siblings keep going, and re-creating this claiming PVC
622    /// re-arms it.
623    Failed,
624    /// A phase string this build does not recognize (newer operator, or legacy
625    /// stored data). Decode-compat only — hidden from the CRD schema, never
626    /// produced by this build, never terminal.
627    Unknown(String),
628}
629
630crate::common::phase_serde!(
631    RestoreClaimPhase,
632    "Lifecycle phase of one claiming PVC of a populator restore."
633);
634
635impl RestoreClaimPhase {
636    /// Whether this claim reached an end state and the operator will do no
637    /// further work on it of its own accord.
638    ///
639    /// `Failed` IS terminal here, and that is what lets the fan-out stop
640    /// short-circuiting the WHOLE `Restore` on `Failed`: the failure is scoped to
641    /// this claim, its siblings keep going, and what re-arms it is deleting and
642    /// re-creating the claiming PVC (which mints a new uid, hence a new record) —
643    /// never a re-drive of the old one.
644    ///
645    /// Pure + exhaustive, so the single definition lives in one tested place.
646    ///
647    /// ```
648    /// use kopiur_api::RestoreClaimPhase;
649    ///
650    /// assert!(RestoreClaimPhase::Populated.is_terminal());
651    /// assert!(RestoreClaimPhase::AlreadyBound.is_terminal());
652    /// assert!(RestoreClaimPhase::Failed.is_terminal());
653    /// assert!(!RestoreClaimPhase::Pending.is_terminal());
654    /// assert!(!RestoreClaimPhase::Populating.is_terminal());
655    /// assert!(!RestoreClaimPhase::Rebinding.is_terminal());
656    /// assert!(!RestoreClaimPhase::Unknown("Staging".into()).is_terminal());
657    /// ```
658    pub fn is_terminal(&self) -> bool {
659        match self {
660            Self::Populated | Self::AlreadyBound | Self::Failed => true,
661            Self::Pending | Self::Populating | Self::Rebinding => false,
662            // Conservative surface-it policy, same as `RestorePhase::is_terminal`:
663            // a phase this build cannot interpret is never reported as finished.
664            Self::Unknown(_) => false,
665        }
666    }
667
668    /// Whether this phase is the **decode sentinel** — a value the running build
669    /// cannot interpret, kept verbatim by [`Unknown`](Self::Unknown) instead of
670    /// failing the whole typed `list()`/watch for the Kind.
671    ///
672    /// ```
673    /// use kopiur_api::RestoreClaimPhase;
674    ///
675    /// assert!(RestoreClaimPhase::Unknown("Staging".into()).is_unknown());
676    /// assert!(!RestoreClaimPhase::Populated.is_unknown());
677    /// ```
678    pub fn is_unknown(&self) -> bool {
679        match self {
680            Self::Unknown(_) => true,
681            Self::Pending
682            | Self::Populating
683            | Self::Rebinding
684            | Self::Populated
685            | Self::AlreadyBound
686            | Self::Failed => false,
687        }
688    }
689}
690
691impl crate::common::PhaseLabel for RestoreClaimPhase {
692    const ALL: &'static [Self] = &[
693        Self::Pending,
694        Self::Populating,
695        Self::Rebinding,
696        Self::Populated,
697        Self::AlreadyBound,
698        Self::Failed,
699    ];
700    fn label(&self) -> &str {
701        match self {
702            Self::Pending => "Pending",
703            Self::Populating => "Populating",
704            Self::Rebinding => "Rebinding",
705            Self::Populated => "Populated",
706            Self::AlreadyBound => "AlreadyBound",
707            Self::Failed => "Failed",
708            Self::Unknown(s) => s,
709        }
710    }
711    fn unknown(raw: String) -> Self {
712        Self::Unknown(raw)
713    }
714}
715
716/// Which outcome the source resolution pinned, once and never re-resolved.
717#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
718pub enum ResolutionOutcome {
719    /// The source resolved to a concrete kopia snapshot (see `kopiaSnapshotID`).
720    Snapshot,
721    /// The source matched no snapshot; `Continue` chose an empty (deploy-or-restore) volume.
722    NoSnapshot,
723}
724
725/// The source resolved and pinned at admission, so a restore never silently retargets.
726#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
727#[serde(rename_all = "camelCase")]
728pub struct ResolvedRestore {
729    /// Which outcome the resolution pinned (`Snapshot`/`NoSnapshot`).
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub resolution: Option<ResolutionOutcome>,
732    /// The exact kopia snapshot manifest id the source resolved to; pinned once.
733    #[serde(
734        default,
735        rename = "kopiaSnapshotID",
736        skip_serializing_if = "Option::is_none"
737    )]
738    pub kopia_snapshot_id: Option<String>,
739    /// The concrete `Snapshot` CR the source resolved to, when applicable.
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub snapshot_ref: Option<ObjectRef>,
742    /// The repository the snapshot lives in, resolved from the source.
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub repository: Option<RepositoryRef>,
745    /// Timestamp at which the source was pinned (RFC3339).
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub pinned_at: Option<String>,
748    /// The resolved kopia identity (`username@hostname:path`) of the snapshot.
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub identity: Option<ResolvedIdentity>,
751}
752
753/// Resolved restore target details written to status.
754#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
755#[serde(rename_all = "camelCase")]
756pub struct RestoreTargetStatus {
757    /// Populator handshake (passive / pvc-create modes).
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub pvc_prime: Option<String>,
760    /// The PVC actually written to (created or pre-existing).
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub pvc_ref: Option<ObjectRef>,
763}
764
765/// Start/end timestamps of a restore run.
766#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
767#[serde(rename_all = "camelCase")]
768pub struct RestoreTiming {
769    /// When the mover began restoring (RFC3339).
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub start_time: Option<String>,
772    /// When the restore reached a terminal phase (RFC3339).
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub end_time: Option<String>,
775}
776
777/// Live progress counters patched by the mover during a restore.
778#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
779#[serde(rename_all = "camelCase")]
780pub struct RestoreProgress {
781    /// Total bytes restored so far.
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    pub bytes_restored: Option<i64>,
784    /// Total files restored so far.
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub files_restored: Option<i64>,
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::common::PhaseLabel;
793    use crate::testutil::from_yaml;
794    use kube::core::CustomResourceExt;
795
796    #[test]
797    fn restore_phase_all_covers_every_variant_uniquely() {
798        // Mirrors the `SnapshotPhase` tripwire: every variant is in ALL with a
799        // unique, non-empty label. A variant added without updating ALL fails
800        // here (and `label`'s exhaustive match won't compile at all).
801        let labels: Vec<&str> = RestorePhase::ALL.iter().map(|p| p.label()).collect();
802        assert_eq!(RestorePhase::ALL.len(), 5);
803        assert!(labels.iter().all(|l| !l.is_empty()));
804        let mut sorted = labels.clone();
805        sorted.sort_unstable();
806        sorted.dedup();
807        assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
808        assert!(RestorePhase::ALL.contains(&RestorePhase::default()));
809    }
810
811    #[test]
812    fn restore_terminal_set_is_pinned() {
813        // Driven off ALL so a new variant must be classified deliberately.
814        let terminal: Vec<&str> = RestorePhase::ALL
815            .iter()
816            .filter(|p| p.is_terminal())
817            .map(|p| p.label())
818            .collect();
819        assert_eq!(terminal, ["Completed", "Failed"]);
820        let in_flight: Vec<&str> = RestorePhase::ALL
821            .iter()
822            .filter(|p| !p.is_terminal())
823            .map(|p| p.label())
824            .collect();
825        assert_eq!(in_flight, ["Pending", "Resolving", "Restoring"]);
826    }
827
828    #[test]
829    fn restore_crd_metadata_is_correct() {
830        let crd = Restore::crd();
831        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
832        assert_eq!(crd.spec.names.kind, "Restore");
833        assert_eq!(crd.spec.scope, "Namespaced");
834        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
835    }
836
837    #[test]
838    fn restore_crd_carries_target_xor_x_kubernetes_validation() {
839        // §15: the generated CRD spec schema must carry the operator-authored
840        // x-kubernetes-validations rule (exactly-one-of target.*) at the spec level,
841        // surviving kube's structural-schema rewriter.
842        let crd = Restore::crd();
843        let json = serde_json::to_value(&crd).expect("serialize CRD");
844        let spec_schema =
845            &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
846        let rules = spec_schema["x-kubernetes-validations"]
847            .as_array()
848            .expect("spec.x-kubernetes-validations present");
849        assert!(
850            rules.iter().any(|r| r["rule"]
851                .as_str()
852                .is_some_and(|s| s.contains("target.populator"))),
853            "expected the target XOR rule; got {rules:?}"
854        );
855    }
856
857    #[test]
858    fn from_policy_offset_carries_static_openapi_default_in_crd() {
859        // ADR-0005 §1: source.fromPolicy.offset must carry a real schema `default: 0`.
860        let crd = Restore::crd();
861        let json = serde_json::to_value(&crd).expect("serialize CRD");
862        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
863            ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["offset"]["default"];
864        assert_eq!(
865            default, 0,
866            "fromPolicy.offset must emit `default: 0` in the CRD schema; got {default:?}"
867        );
868    }
869
870    #[test]
871    fn from_policy_offset_defaults_to_zero_when_absent() {
872        let spec: RestoreSpec =
873            from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
874        match &spec.source {
875            RestoreSource::FromPolicy(c) => assert_eq!(c.offset, 0),
876            other => panic!("expected FromPolicy, got {}", other.kind_str()),
877        }
878    }
879
880    #[test]
881    fn restore_backup_ref_roundtrip_matches_adr_shape() {
882        // Mirrors ADR-0001 §3.6 / §5.3.
883        let yaml = r#"
884source:
885  snapshotRef: { name: postgres-data-20260524-021300, namespace: billing }
886target:
887  pvc:
888    name: postgres-data-restored
889    storageClassName: fast-ssd
890    capacity: 100Gi
891    accessModes: [ReadWriteOnce]
892options:
893  enableFileDeletion: false
894  ignorePermissionErrors: true
895  writeFilesAtomically: true
896policy:
897  onMissingSnapshot: Fail
898  waitTimeout: 5m
899"#;
900        let spec: RestoreSpec = from_yaml(yaml);
901        assert_eq!(spec.source.kind_str(), "SnapshotRef");
902        match &spec.source {
903            RestoreSource::SnapshotRef(r) => {
904                assert_eq!(r.name, "postgres-data-20260524-021300");
905                assert_eq!(r.namespace.as_deref(), Some("billing"));
906            }
907            other => panic!("expected SnapshotRef, got {}", other.kind_str()),
908        }
909        let target = &spec.target;
910        assert_eq!(target.kind_str(), "Pvc");
911        match target {
912            RestoreTarget::Pvc(t) => {
913                assert_eq!(t.name, "postgres-data-restored");
914                assert_eq!(t.access_modes, vec![PvcAccessMode::ReadWriteOnce]);
915            }
916            other => panic!("expected Pvc, got {}", other.kind_str()),
917        }
918        assert_eq!(
919            spec.policy.as_ref().unwrap().on_missing_snapshot,
920            Some(OnMissingSnapshot::Fail)
921        );
922
923        let json = serde_json::to_value(&spec).expect("serialize");
924        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
925        assert_eq!(spec, reparsed);
926    }
927
928    #[test]
929    fn restore_pvc_access_modes_render_a_closed_enum_in_the_crd_schema() {
930        // The Vec<String> → Vec<PvcAccessMode> migration must surface in the CRD:
931        // items are a closed string enum (apiserver rejects typos on new writes),
932        // and the legacy-decode `Unknown` variant never appears.
933        let crd = Restore::crd();
934        let json = serde_json::to_value(&crd).expect("serialize CRD");
935        let items = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
936            ["properties"]["target"]["properties"]["pvc"]["properties"]["accessModes"]["items"];
937        assert_eq!(
938            items["type"], "string",
939            "items must be strings; got {items}"
940        );
941        assert_eq!(
942            items["enum"],
943            serde_json::json!([
944                "ReadWriteOnce",
945                "ReadOnlyMany",
946                "ReadWriteMany",
947                "ReadWriteOncePod"
948            ]),
949            "items enum must be exactly the canonical modes; got {items}"
950        );
951    }
952
953    #[test]
954    fn restore_legacy_access_mode_decodes_to_unknown_not_an_error() {
955        // A pre-enforcement stored Restore with a bogus mode must still
956        // deserialize (a serde error would poison the typed watch stream for the
957        // whole Kind) — the value lands in `Unknown`, verbatim, and the shared
958        // validator rejects it per-CR with the value quoted.
959        let spec: RestoreSpec = from_yaml(
960            "source: { snapshotRef: { name: b } }\n\
961             target: { pvc: { name: restored, capacity: 10Gi, accessModes: [ReadWriteOnze] } }\n",
962        );
963        match &spec.target {
964            RestoreTarget::Pvc(t) => assert_eq!(
965                t.access_modes,
966                vec![PvcAccessMode::Unknown("ReadWriteOnze".into())]
967            ),
968            other => panic!("expected Pvc, got {}", other.kind_str()),
969        }
970        // And it re-serializes unchanged (read-modify-write never mutates it).
971        let json = serde_json::to_value(&spec).unwrap();
972        assert_eq!(
973            json["target"]["pvc"]["accessModes"],
974            serde_json::json!(["ReadWriteOnze"])
975        );
976    }
977
978    #[test]
979    fn restore_options_full_flag_sweep_roundtrip() {
980        // M2 flag sweep: every new `options` knob round-trips through the cluster's
981        // YAML → serde_json::Value → typed path.
982        let yaml = r#"
983source: { snapshotRef: { name: b } }
984target: { pvcRef: { name: d } }
985options:
986  enableFileDeletion: true
987  ignorePermissionErrors: false
988  writeFilesAtomically: true
989  parallel: 4
990  writeSparseFiles: true
991  skipOwners: true
992  skipPermissions: false
993  skipTimes: true
994  overwriteFiles: false
995  overwriteDirectories: false
996  overwriteSymlinks: true
997  ignoreErrors: false
998  skipExisting: true
999"#;
1000        let spec: RestoreSpec = from_yaml(yaml);
1001        let o = spec.options.as_ref().expect("options set");
1002        assert!(o.enable_file_deletion);
1003        assert_eq!(o.ignore_permission_errors, Some(false));
1004        assert_eq!(o.write_files_atomically, Some(true));
1005        assert_eq!(o.parallel, Some(4));
1006        assert_eq!(o.write_sparse_files, Some(true));
1007        assert_eq!(o.skip_owners, Some(true));
1008        assert_eq!(o.skip_permissions, Some(false));
1009        assert_eq!(o.skip_times, Some(true));
1010        assert_eq!(o.overwrite_files, Some(false));
1011        assert_eq!(o.overwrite_directories, Some(false));
1012        assert_eq!(o.overwrite_symlinks, Some(true));
1013        assert_eq!(o.ignore_errors, Some(false));
1014        assert_eq!(o.skip_existing, Some(true));
1015
1016        let json = serde_json::to_value(&spec).expect("serialize");
1017        assert_eq!(json["options"]["parallel"], 4);
1018        assert_eq!(json["options"]["skipExisting"], true);
1019        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1020        assert_eq!(spec, reparsed);
1021    }
1022
1023    #[test]
1024    fn restore_options_omits_unset_leaf_fields() {
1025        // A minimal `options` block that only sets `enableFileDeletion` must not
1026        // serialize the other (unset) knobs.
1027        let yaml = r#"
1028source: { snapshotRef: { name: b } }
1029target: { pvcRef: { name: d } }
1030options:
1031  enableFileDeletion: true
1032"#;
1033        let spec: RestoreSpec = from_yaml(yaml);
1034        let json = serde_json::to_value(&spec).unwrap();
1035        let opts_json = &json["options"];
1036        assert_eq!(opts_json["enableFileDeletion"], true);
1037        for key in [
1038            "ignorePermissionErrors",
1039            "writeFilesAtomically",
1040            "parallel",
1041            "writeSparseFiles",
1042            "skipOwners",
1043            "skipPermissions",
1044            "skipTimes",
1045            "overwriteFiles",
1046            "overwriteDirectories",
1047            "overwriteSymlinks",
1048            "ignoreErrors",
1049            "skipExisting",
1050        ] {
1051            assert!(opts_json.get(key).is_none(), "{key} should be absent");
1052        }
1053    }
1054
1055    #[test]
1056    fn restore_passive_populator_mode_uses_explicit_populator_target() {
1057        // ADR-0005 §9: passive populator mode is now an EXPLICIT `target.populator: {}`
1058        // (the empty-`target` form is removed). Mirrors ADR-0001 §5.5
1059        // deploy-or-restore: fromPolicy + Continue + populator.
1060        let yaml = r#"
1061source: { fromPolicy: { name: postgres-data, offset: 0 } }
1062target: { populator: {} }
1063policy: { onMissingSnapshot: Continue }
1064"#;
1065        let spec: RestoreSpec = from_yaml(yaml);
1066        assert_eq!(spec.source.kind_str(), "FromPolicy");
1067        assert_eq!(spec.target.kind_str(), "Populator");
1068        assert!(matches!(spec.target, RestoreTarget::Populator(_)));
1069        match &spec.source {
1070            RestoreSource::FromPolicy(c) => {
1071                assert_eq!(c.name, "postgres-data");
1072                assert_eq!(c.offset, 0);
1073            }
1074            other => panic!("expected FromPolicy, got {}", other.kind_str()),
1075        }
1076
1077        // Externally tagged: `{ populator: {} }`.
1078        let json = serde_json::to_value(&spec).unwrap();
1079        assert!(json["target"]["populator"].is_object());
1080        let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
1081        assert_eq!(spec, reparsed);
1082    }
1083
1084    #[test]
1085    fn restore_without_target_fails_to_deserialize() {
1086        // ADR-0005 §9 breaking change: a Restore with no `target` is invalid.
1087        let value: serde_json::Value =
1088            serde_saphyr::from_str("source: { snapshotRef: { name: b } }\n").unwrap();
1089        assert!(
1090            serde_json::from_value::<RestoreSpec>(value).is_err(),
1091            "an absent target must be rejected (no empty-target form, ADR-0005 §9)"
1092        );
1093    }
1094
1095    #[test]
1096    fn restore_populator_rejects_inherit_security_context() {
1097        // ADR-0005 §9: inheritSecurityContextFrom is meaningless with a populator
1098        // target (no workload pod exists at provision time) — the validator rejects it.
1099        use crate::common::{InheritSecurityContextFrom, MoverSpec, PodSelector};
1100        use crate::validate::validate_restore;
1101        use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
1102        let spec = RestoreSpec {
1103            repository: None,
1104            source: RestoreSource::FromPolicy(FromPolicy {
1105                name: "pg".into(),
1106                namespace: None,
1107                as_of: None,
1108                offset: 0,
1109                source_path: None,
1110            }),
1111            target: RestoreTarget::Populator(PopulatorTarget {}),
1112            options: None,
1113            policy: None,
1114            credential_projection: None,
1115            mover: Some(MoverSpec {
1116                inherit_security_context_from: Some(InheritSecurityContextFrom::WorkloadSelector(
1117                    PodSelector {
1118                        pod_selector: LabelSelector::default(),
1119                        container: None,
1120                    },
1121                )),
1122                ..Default::default()
1123            }),
1124            failure_policy: None,
1125        };
1126        assert!(matches!(
1127            validate_restore(&spec),
1128            Err(crate::error::ValidationError::InvalidFieldValue { .. })
1129        ));
1130
1131        // The same populator target WITHOUT inherit is fine.
1132        let ok = RestoreSpec {
1133            mover: None,
1134            ..spec
1135        };
1136        assert!(validate_restore(&ok).is_ok());
1137    }
1138
1139    #[test]
1140    fn restore_identity_source_requires_repository_in_practice() {
1141        // The `identity` source variant; spec.repository is webhook-required (not type-required).
1142        let yaml = r#"
1143repository: { kind: Repository, name: nas-primary, namespace: backups }
1144source:
1145  identity:
1146    username: postgres-data
1147    hostname: billing
1148    sourcePath: /data
1149    snapshotID: k1f1ec0a8
1150target:
1151  pvcRef: { name: postgres-data-restored }
1152"#;
1153        let spec: RestoreSpec = from_yaml(yaml);
1154        assert_eq!(spec.source.kind_str(), "Identity");
1155        assert!(spec.repository.is_some());
1156        match &spec.source {
1157            RestoreSource::Identity(i) => {
1158                assert_eq!(i.username, "postgres-data");
1159                assert_eq!(i.snapshot_id.as_deref(), Some("k1f1ec0a8"));
1160            }
1161            other => panic!("expected Identity, got {}", other.kind_str()),
1162        }
1163        assert_eq!(spec.target.kind_str(), "PvcRef");
1164
1165        let json = serde_json::to_value(&spec).unwrap();
1166        let reparsed: RestoreSpec = serde_json::from_value(json).unwrap();
1167        assert_eq!(spec, reparsed);
1168    }
1169
1170    #[test]
1171    fn restore_mover_and_failure_policy_roundtrip() {
1172        // Restore carries the same mover surface a backup gets (resources +
1173        // securityContext for UID/GID match + cache) plus a failurePolicy.
1174        let yaml = r#"
1175source: { snapshotRef: { name: app-data-backup } }
1176target: { pvcRef: { name: app-data-restored } }
1177mover:
1178  resources:
1179    requests: { cpu: 250m, memory: 512Mi }
1180    limits: { cpu: "2", memory: 4Gi }
1181  cache:
1182    capacity: 16Gi
1183    storageClassName: fast-ssd
1184  securityContext:
1185    runAsUser: 1000
1186    runAsGroup: 1000
1187    runAsNonRoot: true
1188    allowPrivilegeEscalation: false
1189    capabilities: { drop: ["ALL"] }
1190    seccompProfile: { type: RuntimeDefault }
1191  podSecurityContext:
1192    fsGroup: 1000
1193    fsGroupChangePolicy: OnRootMismatch
1194failurePolicy:
1195  backoffLimit: 4
1196  activeDeadlineSeconds: 3600
1197"#;
1198        let spec: RestoreSpec = from_yaml(yaml);
1199        let mover = spec.mover.as_ref().expect("mover");
1200        assert!(mover.resources.is_some());
1201        assert_eq!(
1202            mover.cache.as_ref().and_then(|c| c.capacity.as_deref()),
1203            Some("16Gi")
1204        );
1205        assert_eq!(
1206            mover.security_context.as_ref().and_then(|s| s.run_as_user),
1207            Some(1000)
1208        );
1209        // fsGroup is carried on the pod-level securityContext (makes a fresh restore
1210        // volume group-writable for an unprivileged mover).
1211        assert_eq!(
1212            mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
1213            Some(1000)
1214        );
1215        // A hardened non-root container + fsGroup is NOT privileged: the gate lets it run.
1216        assert!(!mover.requires_privilege());
1217        let fp = spec.failure_policy.as_ref().expect("failurePolicy");
1218        assert_eq!(fp.backoff_limit, Some(4));
1219        assert_eq!(fp.active_deadline_seconds, Some(3600));
1220
1221        let json = serde_json::to_value(&spec).expect("serialize");
1222        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1223        assert_eq!(spec, reparsed);
1224    }
1225
1226    #[test]
1227    fn restore_mover_root_context_is_privileged() {
1228        // `runAsUser: 0` on a restore mover trips the same privileged-mover gate as a
1229        // backup — the controller refuses it unless the namespace opts in.
1230        let yaml = r#"
1231source: { snapshotRef: { name: app-data-backup } }
1232target: { pvcRef: { name: app-data-restored } }
1233mover:
1234  securityContext:
1235    runAsUser: 0
1236    runAsNonRoot: false
1237"#;
1238        let spec: RestoreSpec = from_yaml(yaml);
1239        assert!(spec.mover.as_ref().unwrap().requires_privilege());
1240    }
1241
1242    #[test]
1243    fn restore_crd_schema_carries_the_snapshot_inherit_variant() {
1244        // `Restore::crd()`/`SnapshotPolicy::crd()` smoke for the new externally-tagged
1245        // variant: schema generation must not panic, and the Restore schema must
1246        // surface `inheritSecurityContextFrom.snapshot` as an object property (the
1247        // SnapshotPolicy schema carries it too — the restriction is webhook-level,
1248        // not structural).
1249        let crd = Restore::crd();
1250        let json = serde_json::to_value(&crd).expect("serialize CRD");
1251        let inherit = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1252            ["properties"]["mover"]["properties"]["inheritSecurityContextFrom"]["properties"];
1253        assert!(
1254            inherit["snapshot"].is_object(),
1255            "inheritSecurityContextFrom must carry the `snapshot` variant; got {inherit}"
1256        );
1257        assert_eq!(inherit["snapshot"]["type"], "object");
1258        // The other variants are untouched.
1259        assert!(inherit["workloadSelector"].is_object());
1260        assert!(inherit["pvcConsumer"].is_object());
1261        let _ = crate::SnapshotPolicy::crd();
1262    }
1263
1264    #[test]
1265    fn restore_snapshot_inherit_mover_roundtrip() {
1266        // The restore-only recorded-identity inherit mode, parsed the cluster's way.
1267        use crate::common::{InheritSecurityContextFrom, SnapshotInherit};
1268        let yaml = r#"
1269source: { snapshotRef: { name: app-data-backup } }
1270target: { pvcRef: { name: app-data-restored } }
1271mover:
1272  inheritSecurityContextFrom:
1273    snapshot: {}
1274"#;
1275        let spec: RestoreSpec = from_yaml(yaml);
1276        assert!(matches!(
1277            spec.mover
1278                .as_ref()
1279                .and_then(|m| m.inherit_security_context_from.as_ref()),
1280            Some(InheritSecurityContextFrom::Snapshot(SnapshotInherit {})),
1281        ));
1282        let json = serde_json::to_value(&spec).expect("serialize");
1283        assert!(json["mover"]["inheritSecurityContextFrom"]["snapshot"].is_object());
1284        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1285        assert_eq!(spec, reparsed);
1286    }
1287
1288    #[test]
1289    fn restore_source_unknown_variant_is_rejected() {
1290        let value: serde_json::Value = serde_saphyr::from_str("snapshotUrl:\n  url: x\n").unwrap();
1291        assert!(serde_json::from_value::<RestoreSource>(value).is_err());
1292    }
1293
1294    #[test]
1295    fn on_missing_snapshot_and_phase_serialize_to_expected_strings() {
1296        assert_eq!(
1297            serde_json::to_value(OnMissingSnapshot::Fail).unwrap(),
1298            "Fail"
1299        );
1300        assert_eq!(
1301            serde_json::to_value(OnMissingSnapshot::Continue).unwrap(),
1302            "Continue"
1303        );
1304        assert_eq!(
1305            serde_json::to_value(RestorePhase::Restoring).unwrap(),
1306            "Restoring"
1307        );
1308        assert_eq!(
1309            serde_json::to_value(RestorePhase::Completed).unwrap(),
1310            "Completed"
1311        );
1312    }
1313
1314    // --- #443: per-claim status ------------------------------------------
1315
1316    #[test]
1317    fn restore_claim_phase_all_covers_every_variant_uniquely() {
1318        // Same tripwire as `RestorePhase`: every canonical variant is in ALL with
1319        // a unique, non-empty label. A variant added without updating ALL fails
1320        // here (and `label`'s exhaustive match won't compile at all).
1321        let labels: Vec<&str> = RestoreClaimPhase::ALL.iter().map(|p| p.label()).collect();
1322        assert_eq!(RestoreClaimPhase::ALL.len(), 6);
1323        assert!(labels.iter().all(|l| !l.is_empty()));
1324        let mut sorted = labels.clone();
1325        sorted.sort_unstable();
1326        sorted.dedup();
1327        assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
1328        assert!(RestoreClaimPhase::ALL.contains(&RestoreClaimPhase::default()));
1329    }
1330
1331    #[test]
1332    fn restore_claim_terminal_set_is_pinned() {
1333        // Driven off ALL so a new variant must be classified deliberately.
1334        let terminal: Vec<&str> = RestoreClaimPhase::ALL
1335            .iter()
1336            .filter(|p| p.is_terminal())
1337            .map(|p| p.label())
1338            .collect();
1339        assert_eq!(terminal, ["Populated", "AlreadyBound", "Failed"]);
1340        let in_flight: Vec<&str> = RestoreClaimPhase::ALL
1341            .iter()
1342            .filter(|p| !p.is_terminal())
1343            .map(|p| p.label())
1344            .collect();
1345        assert_eq!(in_flight, ["Pending", "Populating", "Rebinding"]);
1346        // The decode sentinel is never in ALL and never terminal.
1347        assert!(!RestoreClaimPhase::ALL.iter().any(|p| p.is_unknown()));
1348    }
1349
1350    #[test]
1351    fn restore_claim_phase_round_trips_and_tolerates_an_unknown_value() {
1352        for p in RestoreClaimPhase::ALL {
1353            let json = serde_json::to_value(p).expect("serialize");
1354            assert_eq!(json, p.label());
1355            let back: RestoreClaimPhase = serde_json::from_value(json).expect("decode");
1356            assert_eq!(&back, p);
1357        }
1358        // A phase written by a newer operator must not poison the typed watch.
1359        let unknown: RestoreClaimPhase =
1360            serde_json::from_value(serde_json::json!("Quiescing")).expect("decodes");
1361        assert_eq!(unknown, RestoreClaimPhase::Unknown("Quiescing".into()));
1362        assert!(unknown.is_unknown());
1363        assert!(!unknown.is_terminal());
1364        // …and is echoed back verbatim, so a read-modify-write never mutates it.
1365        assert_eq!(serde_json::to_value(&unknown).unwrap(), "Quiescing");
1366    }
1367
1368    #[test]
1369    fn restore_claim_phase_schema_publishes_only_canonical_values() {
1370        // `Unknown` is a decode-compat artifact, never an admissible write.
1371        let crd = Restore::crd();
1372        let json = serde_json::to_value(&crd).expect("serialize CRD");
1373        let phase = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
1374            ["properties"]["claims"]["additionalProperties"]["properties"]["phase"];
1375        let values: Vec<String> = phase["enum"]
1376            .as_array()
1377            .map(|a| {
1378                a.iter()
1379                    .map(|v| v.as_str().unwrap_or_default().to_string())
1380                    .collect()
1381            })
1382            .unwrap_or_default();
1383        assert_eq!(
1384            values,
1385            RestoreClaimPhase::canonical(),
1386            "claim phase schema must be exactly the canonical set; got {phase}"
1387        );
1388    }
1389
1390    #[test]
1391    fn restore_status_claims_render_as_an_additional_properties_map() {
1392        // The map schema is what makes N concurrent movers safe (merge-patch
1393        // merges map keys, replaces arrays). `additionalProperties` PRUNES unknown
1394        // keys, so the per-claim object must declare every field any writer sets —
1395        // including the mover-owned `observedAt`, which every `StatusUpdate`
1396        // carries and whose absence would drop the mover's whole patch.
1397        let crd = Restore::crd();
1398        let json = serde_json::to_value(&crd).expect("serialize CRD");
1399        let claims = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
1400            ["properties"]["claims"];
1401        assert_eq!(claims["type"], "object", "got {claims}");
1402        let props = claims["additionalProperties"]["properties"]
1403            .as_object()
1404            .expect("per-claim properties");
1405        for key in [
1406            "uid",
1407            "phase",
1408            "reason",
1409            "message",
1410            "sourcePath",
1411            "resolved",
1412            "pvcPrime",
1413            "job",
1414            "waitStartedAt",
1415            "observedAt",
1416            "logTail",
1417            "failure",
1418        ] {
1419            assert!(props.contains_key(key), "missing `{key}` in {claims}");
1420        }
1421    }
1422
1423    #[test]
1424    fn restore_status_claims_round_trip_through_the_apiserver_shape() {
1425        let status: RestoreStatus = from_yaml(
1426            r#"
1427phase: Restoring
1428claims:
1429  data-0:
1430    uid: 11111111-2222-3333-4444-555555555555
1431    phase: Populated
1432    reason: RestoreSucceeded
1433    sourcePath: /pvc/data-0
1434    pvcPrime: prime-1111
1435    job: r-populate-deadbeef
1436    observedAt: "2026-09-01T00:00:00Z"
1437    resolved:
1438      resolution: Snapshot
1439      kopiaSnapshotID: k1
1440  data-1:
1441    uid: 66666666-2222-3333-4444-555555555555
1442    phase: Failed
1443    reason: MoverJobFailed
1444"#,
1445        );
1446        assert_eq!(status.claims.len(), 2);
1447        let a = &status.claims["data-0"];
1448        assert_eq!(a.phase, Some(RestoreClaimPhase::Populated));
1449        assert_eq!(a.source_path.as_deref(), Some("/pvc/data-0"));
1450        assert_eq!(a.job.as_deref(), Some("r-populate-deadbeef"));
1451        assert_eq!(
1452            a.resolved
1453                .as_ref()
1454                .and_then(|r| r.kopia_snapshot_id.as_deref()),
1455            Some("k1")
1456        );
1457        assert_eq!(
1458            status.claims["data-1"].phase,
1459            Some(RestoreClaimPhase::Failed)
1460        );
1461
1462        // Structural round-trip: what we serialize decodes back identically.
1463        let json = serde_json::to_value(&status).expect("serialize");
1464        let reparsed: RestoreStatus = serde_json::from_value(json).expect("reparse");
1465        assert_eq!(status, reparsed);
1466
1467        // An empty map is omitted entirely, so a DIRECT restore's status is
1468        // byte-identical to what it was before #443.
1469        let direct = RestoreStatus::default();
1470        let json = serde_json::to_value(&direct).expect("serialize");
1471        assert!(
1472            json.get("claims").is_none(),
1473            "an empty claims map must not be written: {json}"
1474        );
1475    }
1476
1477    #[test]
1478    fn from_policy_round_trips_with_and_without_source_path() {
1479        let with: RestoreSpec = from_yaml(
1480            "source: { fromPolicy: { name: pg, sourcePath: /pvc/pgdata } }\n\
1481             target: { populator: {} }\n",
1482        );
1483        match &with.source {
1484            RestoreSource::FromPolicy(c) => {
1485                assert_eq!(c.source_path.as_deref(), Some("/pvc/pgdata"));
1486                assert_eq!(c.offset, 0);
1487            }
1488            other => panic!("expected FromPolicy, got {}", other.kind_str()),
1489        }
1490        let json = serde_json::to_value(&with).expect("serialize");
1491        assert_eq!(json["source"]["fromPolicy"]["sourcePath"], "/pvc/pgdata");
1492        let reparsed: RestoreSpec = serde_json::from_value(json).expect("reparse");
1493        assert_eq!(with, reparsed);
1494
1495        // Absent stays absent on the wire — no new key on an existing object.
1496        let without: RestoreSpec =
1497            from_yaml("source: { fromPolicy: { name: pg } }\ntarget: { populator: {} }\n");
1498        match &without.source {
1499            RestoreSource::FromPolicy(c) => assert_eq!(c.source_path, None),
1500            other => panic!("expected FromPolicy, got {}", other.kind_str()),
1501        }
1502        let json = serde_json::to_value(&without).expect("serialize");
1503        assert!(
1504            json["source"]["fromPolicy"].get("sourcePath").is_none(),
1505            "{json}"
1506        );
1507    }
1508
1509    #[test]
1510    fn from_policy_source_path_is_a_bounded_string_in_the_crd_schema() {
1511        let crd = Restore::crd();
1512        let json = serde_json::to_value(&crd).expect("serialize CRD");
1513        let field = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1514            ["properties"]["source"]["properties"]["fromPolicy"]["properties"]["sourcePath"];
1515        assert_eq!(field["type"], "string", "got {field}");
1516        assert_eq!(field["maxLength"], 4096, "got {field}");
1517    }
1518}
1519
1520#[cfg(test)]
1521mod cel_guard_tests {
1522    use super::*;
1523    use kube::CustomResourceExt;
1524
1525    /// The CRD's operator-authored CEL rule must name EVERY `RestoreTarget` variant.
1526    ///
1527    /// This is not redundant with the enum being exhaustive. The CEL rule lives in the
1528    /// generated schema and is enforced by the API SERVER, before the webhook or any
1529    /// reconciler runs — so a variant added to the Rust enum but missing from the rule
1530    /// compiles, passes every unit test, and is then rejected at `kubectl apply` with a
1531    /// message listing only the old variants. That is precisely how `streamExec` failed
1532    /// the first time it was applied to a real cluster.
1533    ///
1534    /// Driving the expectation off `kind_str()` means a new variant fails HERE, with an
1535    /// instruction, rather than in someone's cluster.
1536    #[test]
1537    fn restore_target_cel_rule_lists_every_variant() {
1538        use crate::common::ObjectRef;
1539
1540        // One value per variant, so adding a variant makes this match fail to compile
1541        // and forces the author to extend the list.
1542        let all = [
1543            RestoreTarget::Pvc(PvcTemplate {
1544                name: "p".into(),
1545                capacity: None,
1546                storage_class_name: None,
1547                access_modes: Vec::new(),
1548            }),
1549            RestoreTarget::PvcRef(ObjectRef {
1550                name: "p".into(),
1551                namespace: None,
1552            }),
1553            RestoreTarget::Populator(PopulatorTarget {}),
1554            RestoreTarget::StreamExec(StreamExecTarget {
1555                file_name: "f.sql".into(),
1556                workload_exec: crate::snapshot_policy::StreamExec {
1557                    pod_selector: Default::default(),
1558                    container: None,
1559                    command: vec!["true".into()],
1560                    timeout: None,
1561                },
1562            }),
1563        ];
1564
1565        let crd = serde_json::to_value(Restore::crd()).expect("CRD serializes");
1566        let schema = &crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1567        let rules = schema["x-kubernetes-validations"]
1568            .as_array()
1569            .expect("the spec carries operator-authored CEL validations");
1570        let rule = rules
1571            .iter()
1572            .find_map(|r| r["rule"].as_str())
1573            .expect("a CEL rule is present");
1574
1575        for target in &all {
1576            // `kind_str` is PascalCase; the wire key is camelCase.
1577            let kind = target.kind_str();
1578            let mut wire = kind.to_string();
1579            wire[..1].make_ascii_lowercase();
1580            assert!(
1581                rule.contains(&format!("has(self.target.{wire})")),
1582                "the RestoreSpec CEL rule does not mention `target.{wire}`, so the API server \
1583                 will REJECT that target before the webhook ever sees it. Add \
1584                 `has(self.target.{wire})` to the rule (and its message) in restore.rs, then \
1585                 re-run `mise run gen`.\nrule: {rule}"
1586            );
1587        }
1588    }
1589}