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>;