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