Skip to main content

kopiur_api/
error.rs

1//! Typed validation errors shared by the admission webhook and the controller.
2//!
3//! Per ADR-0003 §2.2 (principle 8) and the SKILL "one validator, two callers"
4//! rule, cross-field validation lives in [`crate::validate`] as pure functions
5//! returning these typed errors. The webhook rejects at admission; the controller
6//! calls the same functions defensively before reconcile. The error type is the
7//! contract between them, so messages must be **actionable** — they end up in a
8//! `kubectl apply` rejection and in controller logs verbatim.
9//!
10//! ## Accumulation vs. fail-fast
11//!
12//! Per-field helpers (e.g. [`crate::validate::validate_repository_ref`]) are
13//! **fail-fast**: they return the first problem they find as `ValidationResult`.
14//! The per-CRD aggregate validators (`validate_backup_config`, …) **accumulate**
15//! every independent problem into a `Vec<ValidationError>` so a user fixing one
16//! manifest sees all issues at once rather than playing whack-a-mole across
17//! re-applies. Both styles share this one error enum.
18//!
19//! ```
20//! use kopiur_api::ValidationError;
21//!
22//! // Messages are written for a human reading a rejected `kubectl apply` — they
23//! // say what is wrong and why, embedding the offending value.
24//! let err = ValidationError::DiscoveredMustRetain { got: "Delete".to_string() };
25//! assert!(err.to_string().contains("origin: discovered"));
26//! assert!(err.to_string().contains("Delete"));
27//!
28//! // `ValidationResult` defaults its Ok type to `()` for the pass/fail case.
29//! let ok: kopiur_api::ValidationResult = Ok(());
30//! assert!(ok.is_ok());
31//! ```
32
33use thiserror::Error;
34
35/// A single cross-field validation failure. `PartialEq` so tests can assert the
36/// exact variant; messages are written for an end user reading a rejected apply.
37#[derive(Debug, Error, PartialEq, Eq, Clone)]
38pub enum ValidationError {
39    /// The maintenance `run-requested`/`run-mode` annotations are malformed
40    /// (message produced by [`crate::maintenance::parse_run_annotations`],
41    /// already what/why/fix).
42    #[error("invalid maintenance run annotation: {message}")]
43    InvalidRunAnnotation {
44        /// The shared parser's actionable message.
45        message: String,
46    },
47
48    /// A `Repository`/`ClusterRepository`'s own credential refs, or a consumer's
49    /// `repository.namespace`, set a namespace that the variant forbids.
50    /// For `kind: ClusterRepository`, `repository.namespace` MUST be absent
51    /// (ADR §3.2/§3.3) — the reference is cluster-scoped by name alone.
52    #[error(
53        "repository.namespace must not be set when repository.kind is ClusterRepository \
54         (a ClusterRepository is referenced by name only; got namespace {namespace:?})"
55    )]
56    ClusterRepoNamespaceForbidden {
57        /// The forbidden namespace that was set on the reference.
58        namespace: String,
59    },
60
61    /// A consumer namespace is not permitted by the target `ClusterRepository`'s
62    /// `allowedNamespaces` tenancy gate (ADR §3.2/§4.3).
63    #[error(
64        "namespace {namespace:?} is not in the allowedNamespaces of ClusterRepository {repo:?}"
65    )]
66    ConsumerNamespaceNotAllowed {
67        /// The consumer namespace that was denied.
68        namespace: String,
69        /// The `ClusterRepository` whose tenancy gate denied it.
70        repo: String,
71    },
72
73    /// A `Snapshot` with `origin: discovered` tried to set a `deletionPolicy` other
74    /// than `Retain`. Discovered snapshots are forced `Retain` so the operator
75    /// never deletes data it did not create (ADR §4.5).
76    #[error(
77        "origin: discovered snapshots must use deletionPolicy: Retain (got {got:?}); \
78         the operator never deletes snapshots it did not create"
79    )]
80    DiscoveredMustRetain {
81        /// The rejected `deletionPolicy` that was set (anything but `Retain`).
82        got: String,
83    },
84
85    /// A `Snapshot` with `origin: discovered` or `origin: adopted` set
86    /// `spec.onScheduleDelete`. Neither has an owning `SnapshotSchedule` for the
87    /// field to apply to: a `discovered` snapshot's owner is a repository, and an
88    /// `adopted` snapshot's owner is the `SnapshotPolicy` it was re-attached to —
89    /// a stamped cascade policy on either is meaningless, so it is forbidden,
90    /// exactly like a non-`Retain` `deletionPolicy` ([`Self::DiscoveredMustRetain`]).
91    #[error(
92        "origin: {origin} snapshots must not set onScheduleDelete (got {got:?}); a {origin} \
93         snapshot has no owning SnapshotSchedule for this field to apply to. Remove \
94         spec.onScheduleDelete"
95    )]
96    DiscoveredCannotSetOnScheduleDelete {
97        /// The origin that forbids the field (`"discovered"` or `"adopted"`).
98        origin: &'static str,
99        /// The rejected `onScheduleDelete` value that was set.
100        got: String,
101    },
102
103    /// A `Restore` with `source.identity` did not set `spec.repository`. Identity
104    /// sources cannot derive a repository, so it is required (ADR §3.6/§4.6).
105    #[error(
106        "restore source.identity requires spec.repository to be set (no Snapshot/SnapshotPolicy to derive it from)"
107    )]
108    RestoreSourceRepositoryRequired,
109
110    /// A `Repository`/`ClusterRepository` spec carried kopia-side (repo-level)
111    /// retention policy fields, which conflict with CR-driven GFS retention and
112    /// risk double-deletion (ADR §4.4 exclusivity).
113    #[error(
114        "inline kopia-side retention policy on a Repository spec is unsupported (field {field:?}); retention is driven exclusively by SnapshotPolicy.spec.retention (ADR §4.4)"
115    )]
116    InlineRetentionForbidden {
117        /// The offending repo-level retention field that was set.
118        field: String,
119    },
120
121    /// A cron expression failed to parse with the same parser the controller uses
122    /// at runtime, so it is rejected at apply time rather than at first reconcile
123    /// (ADR §4.1).
124    #[error("invalid cron expression {expr:?}: {reason}")]
125    InvalidCron {
126        /// The cron expression that failed to parse.
127        expr: String,
128        /// The parser's reason for rejecting it.
129        reason: String,
130    },
131
132    /// A schedule's `timezone` is not a recognized IANA timezone name (e.g. a typo
133    /// like `America/Chicgo`), rejected at apply time rather than silently falling
134    /// back to UTC at reconcile.
135    #[error("invalid timezone {name:?}: not a recognized IANA timezone name")]
136    InvalidTimezone {
137        /// The timezone string that failed to parse.
138        name: String,
139    },
140
141    /// Two fields that may not both be set were both set (e.g. a `Source` with
142    /// both `pvc` and `pvcSelector`).
143    #[error("fields {a:?} and {b:?} are mutually exclusive but both were set ({context})")]
144    MutuallyExclusive {
145        /// The first of the two conflicting fields.
146        a: String,
147        /// The second of the two conflicting fields.
148        b: String,
149        /// Where the conflict occurred (e.g. `"snapshot source"`), for the message.
150        context: String,
151    },
152
153    /// A required field (or "at least one of" surface) was empty.
154    #[error("missing required field: {field}")]
155    MissingRequiredField {
156        /// The required field (or "at least one of" surface) that was empty.
157        field: String,
158    },
159
160    /// A field was set but its value is malformed (e.g. an NFS export path that is
161    /// not absolute). The schema can't express the constraint, so the webhook does.
162    #[error("invalid value for {field}: {reason}")]
163    InvalidFieldValue {
164        /// The offending field (e.g. `"snapshot source nfs.path"`).
165        field: String,
166        /// What's wrong and how to fix it (e.g. `"must be an absolute path"`).
167        reason: String,
168    },
169
170    /// A `Repository`/`ClusterRepository` `identityDefaults` CEL expression
171    /// (`hostnameExpr` / `usernameExpr`) failed to **compile** (a syntax error, or
172    /// it exceeds the length budget). Surfaced at admission so a bad expression
173    /// never reaches status (ADR-0004 §5).
174    #[error("identity CEL expression {expr:?} failed to compile: {reason} (check the CEL syntax)")]
175    IdentityExprCompile {
176        /// The offending CEL expression.
177        expr: String,
178        /// The parser's reason (or the length-budget message).
179        reason: String,
180    },
181
182    /// A `Repository`/`ClusterRepository` `identityDefaults` CEL expression
183    /// referenced a variable outside its environment (e.g. a typo), or otherwise
184    /// failed to evaluate at admission (ADR-0004 §5). The environment is
185    /// `namespace`, `policyName`, `labels`, `annotations`, `cluster`.
186    #[error(
187        "identity CEL expression {expr:?} failed to evaluate: {reason} \
188         (available variables: namespace, policyName, labels, annotations, cluster)"
189    )]
190    IdentityExprEval {
191        /// The offending CEL expression.
192        expr: String,
193        /// The evaluation error (e.g. an undeclared-variable reference).
194        reason: String,
195    },
196
197    /// A `Repository`/`ClusterRepository` `identityDefaults` CEL expression
198    /// evaluated to a non-string value. `hostnameExpr`/`usernameExpr` must return
199    /// a string (ADR-0004 §5).
200    #[error(
201        "identity CEL expression {expr:?} must return a string, got {got} \
202         (hostnameExpr/usernameExpr must evaluate to a string)"
203    )]
204    IdentityExprType {
205        /// The offending CEL expression.
206        expr: String,
207        /// The CEL value type it returned instead of a string.
208        got: String,
209    },
210
211    /// `spec.server.auth.insecure` was selected without `acknowledgeInsecure: true`.
212    /// The no-auth server exposes full read/write/delete of the repository with no
213    /// login, so it must be explicitly acknowledged (server addendum).
214    #[error(
215        "spec.server.auth.insecure requires acknowledgeInsecure: true — a no-auth kopia \
216         server exposes full read/write/delete of every backup with no login"
217    )]
218    InsecureServerNotAcknowledged,
219
220    /// `spec.server.service.port` was set to an invalid value (0).
221    #[error("spec.server.service.port {port} is invalid (must be 1–65535)")]
222    InvalidServerPort {
223        /// The rejected port value (always `0` today).
224        port: u16,
225    },
226
227    /// A `ClusterRepository.spec.server` did not set the required target `namespace`.
228    #[error(
229        "spec.server.namespace is required for a ClusterRepository server (cluster-scoped \
230         resources have no implicit namespace)"
231    )]
232    ServerNamespaceRequired,
233
234    /// A label selector was supplied as the tenancy gate but the caller could not
235    /// provide the consumer namespace's labels to match against. We fail closed
236    /// (deny) rather than guess (ADR §3.2 — the webhook never trusts unfiltered
237    /// input).
238    #[error(
239        "ClusterRepository {repo:?} gates by label selector but namespace {namespace:?} labels \
240         were not available to evaluate; denying (fail-closed)"
241    )]
242    SelectorLabelsUnavailable {
243        /// The consumer namespace whose labels could not be evaluated.
244        namespace: String,
245        /// The `ClusterRepository` gating by label selector.
246        repo: String,
247    },
248
249    /// An UPDATE changed a repository field that is fixed at repository-creation
250    /// time (`encryption`, `create.splitter`, `create.hash`, `create.encryption`).
251    /// Kopia bakes these into the repository's on-disk format, so they cannot change
252    /// after creation — the webhook rejects the edit rather than silently ignoring it
253    /// (ADR-0005 §7).
254    #[error(
255        "{field} is immutable after repository creation (it is fixed in the kopia repository \
256         format); create a new Repository/ClusterRepository instead of editing this field"
257    )]
258    Immutable {
259        /// The immutable field that an UPDATE attempted to change.
260        field: String,
261    },
262
263    /// A `SnapshotPolicy`'s resolved kopia identity (`username@hostname[:path]`)
264    /// collides with an already-admitted `SnapshotPolicy`'s identity in the **same**
265    /// repository. Two recipes interleaving snapshots into one kopia identity corrupts
266    /// the snapshot history, so the webhook rejects the second one (ADR-0005 §6).
267    #[error(
268        "resolved identity {identity:?} collides with existing SnapshotPolicy {conflict:?} in \
269         repository {repo}; two policies must not share a kopia identity in the same repository \
270         (give this policy a distinct spec.identity, or target a different repository)"
271    )]
272    IdentityCollision {
273        /// The resolved `username@hostname[:path]` identity that collided.
274        identity: String,
275        /// `namespace/name` of the already-admitted conflicting `SnapshotPolicy`.
276        conflict: String,
277        /// Normalized key (`Kind[/namespace]/name`) of the repository BOTH
278        /// policies resolve that identity in — for a multi-repository policy
279        /// this names WHICH member pair collided (the other members may be
280        /// perfectly fine).
281        repo: String,
282    },
283
284    /// A kopia identity component (`username` or `hostname`) — whether an explicit
285    /// `spec.identity` override or the value an `identityDefaults` CEL expression
286    /// resolved to — contains a character that breaks kopia's
287    /// `username@hostname:path` contract. kopia parses a source on the **first** `@`
288    /// and **first** `:` with no escaping, so an embedded `@`, `:`, ASCII whitespace,
289    /// or control character silently misparses the identity into a *different* one
290    /// (or makes the snapshot un-findable on `snapshot list --source`). Rejected at
291    /// admission/resolution so it never reaches a mover Job. Shape-only — every other
292    /// character (dots, dashes, slashes, unicode letters) is allowed.
293    #[error(
294        "{field} {value:?} is not a valid kopia identity component: {reason} \
295         (kopia parses username@hostname:path on the first @ and first :, with no escaping)"
296    )]
297    IdentityComponentInvalid {
298        /// The offending field (e.g. `"spec.identity.username"` or `"resolved hostname"`).
299        field: String,
300        /// The rejected value.
301        value: String,
302        /// What's wrong (e.g. `"must not contain ':'"`).
303        reason: String,
304    },
305
306    /// A kopia identity `sourcePath` is malformed — empty, or it contains a newline
307    /// or an ASCII control character. The path is everything after the first `:` in
308    /// `username@hostname:path`; it may legitimately contain spaces and further `:`,
309    /// but not control characters, and must be non-empty when set.
310    #[error("{field} {value:?} is not a valid kopia source path: {reason}")]
311    IdentitySourcePathInvalid {
312        /// The offending field (e.g. `"spec.sources[0].sourcePathOverride"`).
313        field: String,
314        /// The rejected value.
315        value: String,
316        /// What's wrong and how to fix it.
317        reason: String,
318    },
319
320    /// A `Repository`/`ClusterRepository` `identityDefaults.cluster` is not a
321    /// valid RFC 1123 label, or contains a `.`. `cluster` is appended onto the namespace as
322    /// `<namespace>.<cluster>` for the default hostname, and
323    /// [`crate::identity::classify_hostname`] splits that hostname back apart at
324    /// the FIRST `.` — a `cluster` value with an embedded dot would just shift
325    /// which suffix classifies as "own cluster" rather than error, so the value is
326    /// rejected outright at admission instead of letting classification silently
327    /// disagree with intent.
328    #[error("identityDefaults.cluster {value:?} is not a valid cluster identity suffix: {reason}")]
329    ClusterNameInvalid {
330        /// The rejected cluster name.
331        value: String,
332        /// What's wrong and how to fix it (e.g. the RFC 1123 shape, or the dot-as-delimiter rule).
333        reason: String,
334    },
335
336    /// An UPDATE to a `SnapshotPolicy` would change its resolved kopia identity
337    /// (`username@hostname`, or a source's path) while the policy already has snapshot
338    /// history. New snapshots would land under the new kopia source: Kopiur's own GFS
339    /// retention pools ALL of a policy's `Snapshot` CRs regardless of identity, so the
340    /// old and new lineages don't get independent retention — they compete for the same
341    /// `keepLatest`/`keepDaily`/etc. buckets in one merged timeline, and restore/verify/
342    /// `fromPolicy` resolve only the new identity (the old lineage stays reachable via
343    /// `Restore.source.identity`). Rejected unless the change is acknowledged with the
344    /// `kopiur.home-operations.com/allow-identity-change` annotation
345    /// ([`crate::consts::ALLOW_IDENTITY_CHANGE_ANNOTATION`]).
346    #[error(
347        "this edit changes the policy's resolved kopia identity ({old:?} → {new:?}) but it has \
348         snapshot history — new snapshots fork to a competing kopia lineage sharing the old one's \
349         GFS retention timeline (not independent retention), and restore/verify resolve only the \
350         new identity. Fix: acknowledge with annotation \
351         kopiur.home-operations.com/allow-identity-change (any non-empty value)"
352    )]
353    IdentityWouldFork {
354        /// The previously-pinned identity (or source path).
355        old: String,
356        /// The new identity (or source path) this edit would resolve to.
357        new: String,
358    },
359
360    /// The multi-repository analogue of [`Self::IdentityWouldFork`]: an UPDATE
361    /// to a `SnapshotPolicy` would change the kopia identity it resolves to
362    /// **in one of its member repositories** (each member resolves its own
363    /// identity under that repository's `identityDefaults`) while the policy
364    /// already has snapshot history. The message names WHICH repository's
365    /// lineage would fork — with N members, the other N-1 may be unaffected.
366    /// Same acknowledgement release as the single-repo variant.
367    #[error(
368        "this edit changes the policy's resolved kopia identity in repository {repo} ({old:?} → \
369         {new:?}) but has snapshot history — new snapshots fork to a competing kopia lineage \
370         sharing the old one's GFS retention timeline, and restore/verify resolve only the new \
371         identity. Fix: acknowledge with annotation \
372         kopiur.home-operations.com/allow-identity-change (any non-empty value)"
373    )]
374    IdentityWouldForkInRepository {
375        /// Normalized key (`Kind[/namespace]/name`) of the member repository
376        /// whose lineage this edit would fork.
377        repo: String,
378        /// The previously-resolved `username@hostname` in that repository.
379        old: String,
380        /// The `username@hostname` this edit would resolve to in that repository.
381        new: String,
382    },
383
384    /// An UPDATE to a `Repository`/`ClusterRepository`'s `identityDefaults`
385    /// (`cluster`, `hostnameExpr`, or `usernameExpr`) would silently re-identify
386    /// every consumer `SnapshotPolicy` that resolves through those defaults —
387    /// identity is re-resolved from the LIVE repository on every reconcile/backup
388    /// (nothing about a *repository's* defaults is pinned the way a policy's own
389    /// `spec.identity` is), so this edit changes what each affected policy
390    /// resolves to on its very next backup with **no per-policy edit** to
391    /// acknowledge it. Exactly like [`Self::IdentityWouldFork`], new snapshots
392    /// would land under a new kopia lineage while the old lineage's `Snapshot`
393    /// CRs keep competing with it in the same merged GFS retention timeline
394    /// (Kopiur pools a policy's CRs regardless of identity, so nothing is
395    /// independently retained) — but here it happens fleet-wide in one apply.
396    /// Rejected unless the repository carries the
397    /// `kopiur.home-operations.com/allow-identity-change` annotation
398    /// ([`crate::consts::ALLOW_IDENTITY_CHANGE_ANNOTATION`]).
399    #[error(
400        "this edit to identityDefaults would re-identify {} — new snapshots fork to a competing \
401         kopia lineage sharing the old one's GFS retention timeline, and restore/verify resolve \
402         only the new identity. Fix: acknowledge with annotation \
403         kopiur.home-operations.com/allow-identity-change (any non-empty value), or pin an \
404         explicit spec.identity (both username AND hostname) on those policies first",
405        describe_identity_change_consumers(consumers)
406    )]
407    RepositoryIdentityWouldFork {
408        /// `namespace/name` of every consumer `SnapshotPolicy` with existing
409        /// snapshot history that this edit would re-identify (the message
410        /// truncates the rendered list to 5 names; this field carries all of
411        /// them).
412        consumers: Vec<String>,
413    },
414
415    /// A verification `successExpr` (ADR-0005 §4/§15) failed to **compile** (a
416    /// syntax error, or it exceeds the length budget). Surfaced at admission.
417    #[error("successExpr {expr:?} failed to compile: {reason} (check the CEL syntax)")]
418    SuccessExprCompile {
419        /// The offending CEL expression.
420        expr: String,
421        /// The parser's reason (or the length-budget message).
422        reason: String,
423    },
424
425    /// A verification `successExpr` referenced a variable outside its environment
426    /// (e.g. a typo), or otherwise failed to evaluate (ADR-0005 §4/§15). The
427    /// environment is `stats{files,bytes,errors}`, `snapshot`, `restored`.
428    #[error(
429        "successExpr {expr:?} failed to evaluate: {reason} \
430         (available variables: stats, snapshot, restored)"
431    )]
432    SuccessExprEval {
433        /// The offending CEL expression.
434        expr: String,
435        /// The evaluation error (e.g. an undeclared-variable reference).
436        reason: String,
437    },
438
439    /// A verification `successExpr` evaluated to a non-bool value. A `successExpr`
440    /// is a pass/fail predicate and must return a bool (ADR-0005 §4/§15).
441    #[error("successExpr {expr:?} must return a bool, got {got} (it is a pass/fail predicate)")]
442    SuccessExprType {
443        /// The offending CEL expression.
444        expr: String,
445        /// The CEL value type it returned instead of a bool.
446        got: String,
447    },
448
449    /// A `SnapshotPolicy.spec.preflight` check expression failed to compile (CEL
450    /// syntax error, or it exceeds the length budget). Surfaced at admission.
451    #[error(
452        "preflight check expression {expr:?} failed to compile: {reason} (check the CEL syntax)"
453    )]
454    PreflightExprCompile {
455        /// The offending CEL expression.
456        expr: String,
457        /// The parser's reason (or the length-budget message).
458        reason: String,
459    },
460
461    /// A preflight check expression referenced a variable outside its environment
462    /// (e.g. a typo), or otherwise failed to evaluate. The environment is the
463    /// `repository` and `maintenance` maps.
464    #[error(
465        "preflight check expression {expr:?} failed to evaluate: {reason} \
466         (available variables: repository.{{phase,ready,backendReachable,snapshotCountKnown,\
467         snapshotCount,indexBlobCountKnown,indexBlobCount,sizeBytesKnown,sizeBytes,\
468         lastHealthyKnown,lastHealthyAgeSeconds,lastReverifyKnown,lastReverifyAgeSeconds}}, \
469         maintenance.{{hasRun,lastSuccessAgeSeconds}})"
470    )]
471    PreflightExprEval {
472        /// The offending CEL expression.
473        expr: String,
474        /// The evaluation error (e.g. an undeclared-variable reference).
475        reason: String,
476    },
477
478    /// A preflight check expression evaluated to a non-bool value. A preflight
479    /// check is a pass/fail predicate and must return a bool.
480    #[error(
481        "preflight check expression {expr:?} must return a bool, got {got} \
482         (it is a pass/fail predicate)"
483    )]
484    PreflightExprType {
485        /// The offending CEL expression.
486        expr: String,
487        /// The CEL value type it returned instead of a bool.
488        got: String,
489    },
490
491    /// A `RepositoryReplication`'s `destination` backend is identical to its
492    /// source repository's backend (ADR-0005 §13(d)) — replicating a repository to
493    /// itself is a no-op (or worse, a loop). The webhook rejects it.
494    #[error(
495        "RepositoryReplication destination must differ from the source repository's backend \
496         (both resolved to the same {backend} target); pick a distinct destination backend"
497    )]
498    ReplicationDestinationSameAsSource {
499        /// The backend kind that both source and destination resolved to.
500        backend: String,
501    },
502
503    /// Two distinct filesystem repositories in one replication share the same
504    /// in-pod `backend.path`, so the mover Job would carry two volumeMounts at
505    /// one `mountPath` — an invalid pod spec that otherwise fails only at
506    /// Job-create time. Different volumes make them pass the self-target check;
507    /// the mount topology is the problem.
508    #[error(
509        "the source and destination filesystem repositories both mount at {path:?} inside the \
510         replication mover pod; two volumes cannot share one mountPath. Give one of them a \
511         distinct backend.path (e.g. /repo-dst) — the path is where the volume mounts inside \
512         kopiur's pods, so changing it does not move any data"
513    )]
514    ReplicationMountPathCollision {
515        /// The shared in-pod mount path.
516        path: String,
517    },
518
519    /// A `SnapshotReplication`'s `sourceRef` and `destinationRef` are the same
520    /// reference (same kind, name, and effective namespace) — copying a
521    /// repository's snapshots into itself is a no-op at best and duplicates
522    /// every manifest at worst. The pure validator catches the literal same-ref
523    /// case; the webhook additionally rejects two *different* refs that resolve
524    /// to the same storage target (`backend_target_key`).
525    #[error(
526        "SnapshotReplication sourceRef and destinationRef point at the same {kind} {name:?} — \
527         a replication cannot copy a repository's snapshots into itself. Point destinationRef \
528         at a different repository (typically the off-site one)"
529    )]
530    SnapshotReplicationSelfTarget {
531        /// The shared repository kind (`Repository` or `ClusterRepository`).
532        kind: String,
533        /// The shared repository name both refs point at.
534        name: String,
535    },
536
537    /// A `SnapshotReplication`'s `sourceRef` and `destinationRef` are two
538    /// *different* references that resolve to the **same storage target**
539    /// (`backend_target_key` — e.g. a namespaced `Repository` and a
540    /// `ClusterRepository` both pointing at one bucket+prefix). The pure
541    /// validator's [`Self::SnapshotReplicationSelfTarget`] catches the literal
542    /// same-ref case; this is the webhook's resolved-backend backstop.
543    #[error(
544        "SnapshotReplication sourceRef ({source_ref}) and destinationRef ({destination_ref}) \
545         resolve to the same {backend} storage target — a replication cannot copy a \
546         repository's snapshots into its own storage (source and destination would be one \
547         repository). Point destinationRef at a repository backed by different storage \
548         (typically the off-site one)"
549    )]
550    SnapshotReplicationSameStorage {
551        /// The source reference, rendered as `Kind name` (with namespace when set).
552        source_ref: String,
553        /// The destination reference, rendered the same way.
554        destination_ref: String,
555        /// The backend kind both refs resolved to (e.g. `s3`).
556        backend: String,
557    },
558
559    /// A `SnapshotReplication` combines `pruning: mirrorSource` with a
560    /// `spec.selection` that overlaps kopia identities the DESTINATION's own
561    /// `SnapshotPolicy`s write directly. Replicated copies would interleave
562    /// with directly-written snapshots in those identities' histories, and
563    /// mirror-source pruning deletes any copy whose `(identity, startTime)`
564    /// vanished from the source — so a source-side deletion cascades into
565    /// identities the destination does NOT merely mirror. Rejected as a
566    /// data-loss combination.
567    #[error(
568        "spec.selection overlaps {} that the destination writes directly — pruning: mirrorSource \
569         deletes any copy whose (identity, startTime) vanished from the source, so a source-side \
570         deletion cascades into them (data loss). Fix: exclude them via \
571         spec.selection.identities.exclude, or drop pruning: mirrorSource",
572        describe_overlapping_identities(identities)
573    )]
574    SnapshotReplicationOverlapMirrorSource {
575        /// Every overlapping `username@hostname[:path]` identity (the message
576        /// truncates the rendered list to 5; this field carries all of them).
577        identities: Vec<String>,
578    },
579
580    /// A `SnapshotReplication` identity matcher set none of
581    /// `username`/`hostname`/`sourcePath`. An empty matcher constrains nothing —
582    /// in an `include` list it would silently select EVERY identity, and in an
583    /// `exclude` list it would silently exclude everything — so the intent must
584    /// be spelled out instead.
585    #[error(
586        "identity matcher {field} sets none of username/hostname/sourcePath — an empty matcher \
587         constrains nothing (it would match every identity). Set at least one component \
588         (globs allowed, e.g. username: \"pg-*\"), or remove the matcher"
589    )]
590    EmptyIdentityMatcher {
591        /// The offending matcher's field path (e.g.
592        /// `"SnapshotReplication spec.selection.identities.include[0]"`).
593        field: String,
594    },
595
596    /// A `SnapshotReplication` `pruning.retention` block set no `keep*` bucket.
597    /// A retention that keeps nothing would prune every replicated copy on the
598    /// next run — almost certainly a typo'd field name, so it is rejected
599    /// rather than honored.
600    #[error(
601        "spec.pruning.retention sets no keep* bucket (keepLatest/keepHourly/keepDaily/\
602         keepWeekly/keepMonthly/keepAnnual) — a retention that keeps nothing would prune \
603         every replicated snapshot on the next run. Set at least one keep* count, or use \
604         `pruning: {{ none: {{}} }}` (or omit pruning) to keep copies forever"
605    )]
606    RetentionKeepsNothing,
607
608    /// A `SnapshotPolicy` set neither or both of `spec.repository` /
609    /// `spec.repositories`. Exactly one of the two shapes must be present —
610    /// neither leaves the recipe with no target at all, and both leaves it
611    /// ambiguous whether the single ref is a ninth member or a leftover.
612    /// Mirrors the spec-level CEL rule on `SnapshotPolicySpec`.
613    #[error(
614        "exactly one of spec.repository and spec.repositories must be set (got {got}); \
615         set spec.repository to name the single target repository, or spec.repositories \
616         to list 1-8 targets for multi-repository fan-out"
617    )]
618    PolicyRepositoryExactlyOne {
619        /// Which invalid shape was found: `"neither"` or `"both"`.
620        got: &'static str,
621    },
622
623    /// `SnapshotPolicy.spec.repositories` lists the same repository twice
624    /// (after normalizing kind + effective namespace + name). Each run would
625    /// back the source into that repository twice under one kopia identity —
626    /// two interleaved writers corrupting one snapshot history, exactly the
627    /// hazard the identity-collision guard exists to prevent.
628    #[error(
629        "spec.repositories[{first}] and spec.repositories[{second}] both name {key} — each \
630         listed repository must be distinct, or the two fan-out children would interleave \
631         writes into one kopia identity in that repository. Remove the duplicate entry"
632    )]
633    PolicyRepositoriesDuplicate {
634        /// The normalized repository key both entries resolve to
635        /// (`Kind[/namespace]/name`).
636        key: String,
637        /// Index of the first occurrence in `spec.repositories`.
638        first: usize,
639        /// Index of the duplicate occurrence in `spec.repositories`.
640        second: usize,
641    },
642
643    /// A code path that genuinely requires a SINGLE repository (the
644    /// [`single_repository_ref`](crate::snapshot_policy::single_repository_ref)
645    /// accessor's `Multi` arm) was handed a multi-repository policy. This is
646    /// NOT an admission refusal — `spec.repositories` is fully supported; a
647    /// multi-repo policy's per-repository work is addressed through each
648    /// child `Snapshot`'s `spec.repository` pin, never through a policy-level
649    /// "the one repository" read, so any consumer still asking for one fails
650    /// loudly here instead of silently picking repository #1.
651    #[error(
652        "this operation reads a policy-level single repository, but the SnapshotPolicy \
653         uses spec.repositories (multi-repository fan-out) — select the repository \
654         explicitly (the per-child Snapshot spec.repository pin, or the operation's own \
655         repository selector) instead of relying on a single policy repository"
656    )]
657    PolicySingleRepositoryRequired,
658
659    /// A `SnapshotPolicy` combines `spec.hooks` with `spec.repositories`.
660    /// Hooks quiesce the workload around ONE capture; with N concurrent
661    /// fan-out children the first finisher runs the after-snapshot (thaw)
662    /// hooks while the other N-1 movers are still reading — voiding the
663    /// quiesce guarantee — and serializing the children would multiply the
664    /// freeze window by N. Refused as an unsatisfiable consistency contract.
665    #[error(
666        "spec.hooks cannot be combined with spec.repositories: the first fan-out child to \
667         finish would run the after-snapshot (thaw) hooks while the other children's movers \
668         are still reading, so the quiesce contract cannot be honored. Use a single-repo \
669         policy (spec.repository) with hooks, plus a SnapshotReplication to copy its \
670         snapshots into the second repository"
671    )]
672    PolicyHooksWithRepositories,
673
674    /// A `Snapshot`'s repository pin (`spec.repository`) names a repository
675    /// that is not in its `SnapshotPolicy`'s repository set — either the pin is
676    /// wrong (a hand-written CREATE with a typo, refused at admission) or the
677    /// recipe was edited out from under an existing Snapshot's mint-time pin
678    /// (terminal for that CR). Proceeding against any OTHER repository would
679    /// silently act on the wrong backend, and guessing is the one thing a
680    /// backup operator must never do.
681    #[error(
682        "Snapshot spec.repository pins {pin}, but SnapshotPolicy `{policy}` does not list \
683         that repository (current set: {valid}). Fix the pin to a listed member, restore the \
684         repository entry on the policy, or — for an existing Snapshot whose recipe was \
685         edited out from under it — delete it and let the schedule re-fire against the \
686         current recipe"
687    )]
688    SnapshotPinNotInPolicy {
689        /// Normalized key of the pinned repository (`Kind[/namespace]/name`).
690        pin: String,
691        /// The referenced `SnapshotPolicy`'s name.
692        policy: String,
693        /// Comma-joined normalized keys of the policy's current repository set.
694        valid: String,
695    },
696
697    /// A `Snapshot` referencing a MULTI-repository `SnapshotPolicy` carries no
698    /// `spec.repository` pin, so there is no way to know which of the N
699    /// repositories this run targets. Raised both at admission (refusing to
700    /// CREATE such a child) and as the controller-side backstop for stored
701    /// rows; picking repository #1 silently is never an option.
702    #[error(
703        "Snapshot has no spec.repository pin, but SnapshotPolicy `{policy}` lists multiple \
704         repositories (spec.repositories) — a multi-repo child must pin exactly one member \
705         at mint time. Let a SnapshotSchedule fire it, or use `kubectl kopiur snapshot now` \
706         — both stamp the repository (an already-created unpinned Snapshot must be deleted \
707         and re-minted)"
708    )]
709    MultiRepoSnapshotUnpinned {
710        /// The referenced `SnapshotPolicy`'s name.
711        policy: String,
712    },
713
714    /// A `Snapshot` with no `policyRef` (e.g. a `SnapshotReplication` copy CR
715    /// or a discovered row) has no derivable repository: neither a
716    /// `status.resolved.repository` pin, nor a `spec.repository` pin, nor a
717    /// `Repository`/`ClusterRepository` owner reference.
718    #[error(
719        "cannot determine the repository for Snapshot `{snapshot}`: it has no policyRef and \
720         carries neither a status.resolved.repository pin, a spec.repository pin, nor a \
721         Repository/ClusterRepository owner reference"
722    )]
723    SnapshotRepositoryUnresolvable {
724        /// The `Snapshot`'s name.
725        snapshot: String,
726    },
727
728    /// A `fromPolicy` restore names an explicit `spec.repository` that is not a
729    /// member of the referenced `SnapshotPolicy`'s repository set — most likely
730    /// a typo, and honoring it would silently read a repository the recipe
731    /// never wrote to.
732    #[error(
733        "restore.spec.repository names {given}, which is not a repository of SnapshotPolicy \
734         `{policy}` — a fromPolicy restore must read one of the policy's own repositories \
735         (set restore.spec.repository to one of: {valid}), or use a snapshotRef/identity \
736         source to restore from elsewhere"
737    )]
738    RestoreRepositoryNotInPolicy {
739        /// Normalized key of the repository the restore named.
740        given: String,
741        /// The referenced `SnapshotPolicy`'s name.
742        policy: String,
743        /// Comma-joined normalized keys of the policy's repository set.
744        valid: String,
745    },
746
747    /// A `fromPolicy` restore references a MULTI-repository `SnapshotPolicy`
748    /// without selecting which repository to read — the operator must never
749    /// guess (the N repositories are independent captures that can diverge).
750    #[error(
751        "SnapshotPolicy `{policy}` lists multiple repositories (spec.repositories), so a \
752         fromPolicy restore must say which one to read: set restore.spec.repository to one \
753         of: {valid}"
754    )]
755    RestoreRepositorySelectionRequired {
756        /// The referenced `SnapshotPolicy`'s name.
757        policy: String,
758        /// Comma-joined normalized keys of the policy's repository set.
759        valid: String,
760    },
761
762    /// A namespaced `Repository` set `spec.maintenance.namespace`, which only
763    /// applies to a cluster-scoped `ClusterRepository` (a namespaced
764    /// `Repository`'s managed `Maintenance` always lives in the repository's own
765    /// namespace). ADR §3.7.
766    #[error(
767        "spec.maintenance.namespace ({namespace:?}) is only valid on a ClusterRepository; \
768         a namespaced Repository's managed Maintenance always lives in the repository's namespace"
769    )]
770    MaintenanceNamespaceOnNamespacedRepo {
771        /// The `spec.maintenance.namespace` value set on the namespaced `Repository`.
772        namespace: String,
773    },
774
775    /// `catalog.foreignSnapshots` is set, but there is no cluster identity to
776    /// classify a snapshot's origin against — `Ignore`/`Fallback` decide what
777    /// to do with a snapshot [`crate::identity::classify_hostname`] classifies
778    /// as another cluster's, and that classification is undecidable without
779    /// `identityDefaults.cluster`. Fires on either repository kind (`Repository`
780    /// or `ClusterRepository`) whose `identityDefaults.cluster` is unset —
781    /// kind-neutral wording, since the rule is identical either way.
782    #[error(
783        "catalog.foreignSnapshots is set, but classifying a snapshot as \"foreign\" requires a \
784         cluster identity (`identityDefaults.cluster`); without one there is nothing to compare \
785         it against. Fix: set identityDefaults.cluster, or remove catalog.foreignSnapshots"
786    )]
787    ForeignSnapshotsRequiresCluster,
788
789    /// A `ClusterRepository` sets both `identityDefaults.cluster` and
790    /// `catalog.fallbackNamespace` but leaves `catalog.foreignSnapshots`
791    /// unset. Both being set at once is a strong signal the fallback
792    /// collector is actually relied upon, so adopting a cluster identity must
793    /// never silently switch it off by defaulting to `Ignore` — the choice is
794    /// forced explicit instead.
795    #[error(
796        "catalog.foreignSnapshots must be set explicitly — identityDefaults.cluster and \
797         catalog.fallbackNamespace are both set, so adopting a cluster identity must not silently \
798         change the fallback collector: set `Ignore` (stop materializing foreign snapshots; \
799         existing fallbackNamespace rows age out under catalog.retain) or `Fallback` (keep \
800         collecting them there)"
801    )]
802    ForeignSnapshotsChoiceRequired,
803
804    /// `spec.seed` on a repository whose own backend is a **bare-path**
805    /// filesystem (`filesystem` with no `volume`). Seeding runs in a mover Job;
806    /// a bare path is the one backend the CONTROLLER connects to in-process,
807    /// and the Job would have nothing mounted at it (issue #380).
808    #[error(
809        "spec.seed needs a repository the seeding mover Job can reach, but backend.filesystem has \
810         no `volume` — a bare path {path:?} is connected in-process by the controller and nothing \
811         would be mounted at it inside the Job, so the seed would fail as a confusing \
812         \"repository not found\". Fix: back the filesystem repository with \
813         backend.filesystem.volume (a PVC or an NFS export), or use an object-store backend"
814    )]
815    SeedRequiresMountableRepository {
816        /// The bare in-pod path the repository declares.
817        path: String,
818    },
819
820    /// `spec.seed.from.backend` is itself a **bare-path** filesystem backend.
821    /// Same mover-topology problem as [`Self::SeedRequiresMountableRepository`],
822    /// one field over: nothing would be mounted at the source path either.
823    #[error(
824        "spec.seed.from.backend is a filesystem backend with no `volume` ({path:?}) — the seeding \
825         mover Job mounts a volume per backend, so nothing would exist at that path and the seed \
826         would fail as a confusing \"repository not found\". Fix: give the seed source a `volume` \
827         (the PVC or NFS export holding the mirror), or point it at an object-store backend"
828    )]
829    SeedSourceRequiresMountableBackend {
830        /// The bare in-pod path the seed source declares.
831        path: String,
832    },
833
834    /// `spec.seed` on a `mode: ReadOnly` repository. Seeding is the largest
835    /// write a repository ever takes, so the two are contradictory.
836    #[error(
837        "spec.seed writes this repository's initial contents, but spec.mode is ReadOnly — a \
838         read-only repository refuses every write, so the seed could never complete and the \
839         repository would never become Ready. Fix: seed with mode: ReadWrite and switch to \
840         ReadOnly once status.seed is stamped, or remove spec.seed"
841    )]
842    SeedOnReadOnlyRepository,
843
844    /// A blob-mode seed (`seed.from.backend`) alongside explicit
845    /// `spec.create.{splitter,hash,encryption,ecc}`. `kopia repository sync-to`
846    /// copies the mirror's repository-format blob verbatim, so the declared
847    /// algorithms are never applied — kopiur does not accept inert fields.
848    #[error(
849        "spec.create sets {} alongside a blob-mode spec.seed (from.backend): the seed copies the \
850         mirror's repository format verbatim, so these create-time algorithms are never applied \
851         and the seeded repository keeps the SOURCE's format. Fix: remove them (they are inert \
852         here), or seed in migrate mode (spec.seed.from.repository), which creates a local \
853         repository with the format you declare",
854        fields.join(", ")
855    )]
856    SeedCreateOptionsInert {
857        /// The `create.*` field paths that would be ignored, e.g.
858        /// `["create.splitter", "create.hash"]`.
859        fields: Vec<String>,
860    },
861
862    /// A mode-specific `spec.seed` tuning block paired with the other mode's
863    /// source (`seed.sync` with `from.repository`, `seed.migrate` or
864    /// `seed.credentialProjection.enabled` with `from.backend`). Honoring it
865    /// silently would make it an inert field.
866    #[error(
867        "spec.seed.{field} is only honored when spec.seed.from sets `{expected_source}`, but this \
868         seed reads from `{actual_source}` — the block would be silently ignored, and kopiur does \
869         not accept inert fields. Fix: remove spec.seed.{field}, or point spec.seed.from at a \
870         `{expected_source}` source"
871    )]
872    SeedTuningNotApplicable {
873        /// The offending `spec.seed` sub-field (e.g. `sync`, `migrate`).
874        field: String,
875        /// The `spec.seed.from` variant key the field belongs to.
876        expected_source: String,
877        /// The `spec.seed.from` variant key actually set.
878        actual_source: String,
879    },
880
881    /// `spec.seed.from.backend` resolves to the same storage target as the
882    /// repository's own `spec.backend` (same `backend_target_key`) — the seed
883    /// would read and write one location.
884    #[error(
885        "spec.seed.from.backend resolves to the same {backend} storage target as this \
886         repository's own spec.backend — a repository cannot be seeded from itself (the seed \
887         would read and write one location). Fix: point spec.seed.from.backend at the surviving \
888         off-site mirror's storage"
889    )]
890    SeedSourceSameAsRepository {
891        /// The backend kind both sides resolved to.
892        backend: String,
893    },
894
895    /// The repository's filesystem backend and its filesystem seed source share
896    /// one in-pod `path`, so the seeding mover Job would carry two volumeMounts
897    /// at a single `mountPath` — an invalid pod spec.
898    #[error(
899        "this repository's filesystem backend and spec.seed.from.backend both mount at {path:?} \
900         inside the seeding mover pod; two volumes cannot share one mountPath. Fix: give the seed \
901         source a distinct backend.path (e.g. /seed-source) — the path is where the volume mounts \
902         inside kopiur's pods, so changing it does not move any data"
903    )]
904    SeedMountPathCollision {
905        /// The shared in-pod mount path.
906        path: String,
907    },
908
909    /// `spec.seed.from.repository` points at the repository being defined.
910    #[error(
911        "spec.seed.from.repository points at this same {kind} {name:?} — a repository cannot be \
912         seeded from itself. Fix: point it at the surviving replica (typically the off-site \
913         ClusterRepository or a Repository in another namespace)"
914    )]
915    SeedSourceSelfReference {
916        /// The repository kind both sides name.
917        kind: String,
918        /// The repository name both sides name.
919        name: String,
920    },
921
922    /// A `ClusterRepository`'s `spec.seed.from.backend` credential Secret pins a
923    /// namespace. A cluster-scoped repository's movers resolve their Secrets in
924    /// the namespace the bootstrap Job runs in — the operator's own namespace
925    /// unless `encryption.passwordSecretRef.namespace` pins another — which the
926    /// spec cannot name here, so a pinned namespace is a dead reference.
927    #[error(
928        "spec.seed.from.backend auth.secretRef {secret:?} pins namespace {namespace:?}, but a \
929         ClusterRepository's seeding mover reads it in the operator's own namespace (unless \
930         encryption.passwordSecretRef.namespace pins another) — a namespace pinned here is a dead \
931         reference the Job hangs on (CreateContainerConfigError). Fix: omit `namespace` and put \
932         the Secret alongside encryption.passwordSecretRef"
933    )]
934    SeedSourceSecretNamespaceForbidden {
935        /// The referenced Secret name.
936        secret: String,
937        /// The forbidden namespace it pinned.
938        namespace: String,
939    },
940
941    /// A namespaced `Repository`'s `spec.seed.from.backend` credential Secret is
942    /// pinned to some OTHER namespace. The seeding Job runs in the repository's
943    /// namespace and loads the Secret via `envFrom`, which is namespace-local.
944    #[error(
945        "spec.seed.from.backend auth.secretRef {secret:?} is pinned to namespace {namespace:?}, \
946         but the seeding mover Job runs in {repository_namespace:?} and loads it via envFrom, \
947         which is namespace-local — the Job could never read it. Fix: put the Secret in \
948         {repository_namespace:?} (omit `namespace`, or set it to {repository_namespace:?})"
949    )]
950    SeedSourceSecretNamespaceMismatch {
951        /// The referenced Secret name.
952        secret: String,
953        /// The namespace it was pinned to.
954        namespace: String,
955        /// The repository's own namespace, where the seeding Job runs.
956        repository_namespace: String,
957    },
958}
959
960/// Render the consumer list for [`ValidationError::RepositoryIdentityWouldFork`]:
961/// the count, then up to [`CONSUMER_LIST_SHOWN`] `namespace/name`s, then
962/// `"and N more"` for the rest. Also reused verbatim by the webhook to build the
963/// admission WARNING when the same change is acknowledged, so the deny message
964/// and the warning always name the same policies the same way.
965///
966/// ```
967/// use kopiur_api::error::describe_identity_change_consumers;
968///
969/// assert_eq!(
970///     describe_identity_change_consumers(&["billing/pg".to_string()]),
971///     "1 SnapshotPolicy consumer(s) with existing snapshot history (billing/pg)",
972/// );
973/// let many: Vec<String> = (0..7).map(|i| format!("ns/pg-{i}")).collect();
974/// let rendered = describe_identity_change_consumers(&many);
975/// assert!(rendered.starts_with("7 SnapshotPolicy consumer(s)"));
976/// assert!(rendered.ends_with("and 2 more)"), "{rendered}");
977/// ```
978pub fn describe_identity_change_consumers(consumers: &[String]) -> String {
979    let total = consumers.len();
980    let mut names = consumers
981        .iter()
982        .take(CONSUMER_LIST_SHOWN)
983        .cloned()
984        .collect::<Vec<_>>()
985        .join(", ");
986    if total > CONSUMER_LIST_SHOWN {
987        names.push_str(&format!(", and {} more", total - CONSUMER_LIST_SHOWN));
988    }
989    format!("{total} SnapshotPolicy consumer(s) with existing snapshot history ({names})")
990}
991
992/// How many consumer names [`describe_identity_change_consumers`] spells out
993/// before collapsing the rest into `"and N more"`.
994const CONSUMER_LIST_SHOWN: usize = 5;
995
996/// Render the overlap list for
997/// [`ValidationError::SnapshotReplicationOverlapMirrorSource`]: the count, then
998/// up to [`CONSUMER_LIST_SHOWN`] identities, then `"and N more"`. Also reused
999/// verbatim by the webhook to build the non-blocking admission WARNING for the
1000/// same overlap without `mirrorSource`, so the deny message and the warning
1001/// always name the same identities the same way.
1002///
1003/// ```
1004/// use kopiur_api::error::describe_overlapping_identities;
1005///
1006/// assert_eq!(
1007///     describe_overlapping_identities(&["pg@billing:/pvc/data".to_string()]),
1008///     "1 destination-side SnapshotPolicy identity(ies) (pg@billing:/pvc/data)",
1009/// );
1010/// let many: Vec<String> = (0..7).map(|i| format!("pg-{i}@ns:/p")).collect();
1011/// let rendered = describe_overlapping_identities(&many);
1012/// assert!(rendered.starts_with("7 destination-side"));
1013/// assert!(rendered.ends_with("and 2 more)"), "{rendered}");
1014/// ```
1015pub fn describe_overlapping_identities(identities: &[String]) -> String {
1016    let total = identities.len();
1017    let mut names = identities
1018        .iter()
1019        .take(CONSUMER_LIST_SHOWN)
1020        .cloned()
1021        .collect::<Vec<_>>()
1022        .join(", ");
1023    if total > CONSUMER_LIST_SHOWN {
1024        names.push_str(&format!(", and {} more", total - CONSUMER_LIST_SHOWN));
1025    }
1026    format!("{total} destination-side SnapshotPolicy identity(ies) ({names})")
1027}
1028
1029/// Result alias for validators. Defaults to `()` for the common "pass/fail with no
1030/// value" case.
1031pub type ValidationResult<T = ()> = Result<T, ValidationError>;