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