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 /// A `Repository`/`ClusterRepository`'s own credential refs, or a consumer's
40 /// `repository.namespace`, set a namespace that the variant forbids.
41 /// For `kind: ClusterRepository`, `repository.namespace` MUST be absent
42 /// (ADR §3.2/§3.3) — the reference is cluster-scoped by name alone.
43 #[error(
44 "repository.namespace must not be set when repository.kind is ClusterRepository \
45 (a ClusterRepository is referenced by name only; got namespace {namespace:?})"
46 )]
47 ClusterRepoNamespaceForbidden {
48 /// The forbidden namespace that was set on the reference.
49 namespace: String,
50 },
51
52 /// A consumer namespace is not permitted by the target `ClusterRepository`'s
53 /// `allowedNamespaces` tenancy gate (ADR §3.2/§4.3).
54 #[error(
55 "namespace {namespace:?} is not in the allowedNamespaces of ClusterRepository {repo:?}"
56 )]
57 ConsumerNamespaceNotAllowed {
58 /// The consumer namespace that was denied.
59 namespace: String,
60 /// The `ClusterRepository` whose tenancy gate denied it.
61 repo: String,
62 },
63
64 /// A `Backup` with `origin: discovered` tried to set a `deletionPolicy` other
65 /// than `Retain`. Discovered snapshots are forced `Retain` so the operator
66 /// never deletes data it did not create (ADR §4.5).
67 #[error(
68 "origin: discovered backups must use deletionPolicy: Retain (got {got:?}); \
69 the operator never deletes snapshots it did not create"
70 )]
71 DiscoveredMustRetain {
72 /// The rejected `deletionPolicy` that was set (anything but `Retain`).
73 got: String,
74 },
75
76 /// A `Restore` with `source.identity` did not set `spec.repository`. Identity
77 /// sources cannot derive a repository, so it is required (ADR §3.6/§4.6).
78 #[error(
79 "restore source.identity requires spec.repository to be set (no Backup/BackupConfig to derive it from)"
80 )]
81 RestoreSourceRepositoryRequired,
82
83 /// A `Repository`/`ClusterRepository` spec carried kopia-side (repo-level)
84 /// retention policy fields, which conflict with CR-driven GFS retention and
85 /// risk double-deletion (ADR §4.4 exclusivity).
86 #[error(
87 "inline kopia-side retention policy on a Repository spec is unsupported (field {field:?}); retention is driven exclusively by BackupConfig.spec.retention (ADR §4.4)"
88 )]
89 InlineRetentionForbidden {
90 /// The offending repo-level retention field that was set.
91 field: String,
92 },
93
94 /// A cron expression failed to parse with the same parser the controller uses
95 /// at runtime, so it is rejected at apply time rather than at first reconcile
96 /// (ADR §4.1).
97 #[error("invalid cron expression {expr:?}: {reason}")]
98 InvalidCron {
99 /// The cron expression that failed to parse.
100 expr: String,
101 /// The parser's reason for rejecting it.
102 reason: String,
103 },
104
105 /// Two fields that may not both be set were both set (e.g. a `Source` with
106 /// both `pvc` and `pvcSelector`).
107 #[error("fields {a:?} and {b:?} are mutually exclusive but both were set ({context})")]
108 MutuallyExclusive {
109 /// The first of the two conflicting fields.
110 a: String,
111 /// The second of the two conflicting fields.
112 b: String,
113 /// Where the conflict occurred (e.g. `"backup source"`), for the message.
114 context: String,
115 },
116
117 /// A required field (or "at least one of" surface) was empty.
118 #[error("missing required field: {field}")]
119 MissingRequiredField {
120 /// The required field (or "at least one of" surface) that was empty.
121 field: String,
122 },
123
124 /// Rendering a `ClusterRepository.identityDefaults` template with `tera` failed
125 /// (ADR §4.2). Surfaced at admission so a bad template never reaches status.
126 #[error("failed to render identity template: {reason}")]
127 IdentityTemplateRender {
128 /// The underlying `tera` render error, surfaced for the user.
129 reason: String,
130 },
131
132 /// A label selector was supplied as the tenancy gate but the caller could not
133 /// provide the consumer namespace's labels to match against. We fail closed
134 /// (deny) rather than guess (ADR §3.2 — the webhook never trusts unfiltered
135 /// input).
136 #[error(
137 "ClusterRepository {repo:?} gates by label selector but namespace {namespace:?} labels \
138 were not available to evaluate; denying (fail-closed)"
139 )]
140 SelectorLabelsUnavailable {
141 /// The consumer namespace whose labels could not be evaluated.
142 namespace: String,
143 /// The `ClusterRepository` gating by label selector.
144 repo: String,
145 },
146
147 /// A namespaced `Repository` set `spec.maintenance.namespace`, which only
148 /// applies to a cluster-scoped `ClusterRepository` (a namespaced
149 /// `Repository`'s managed `Maintenance` always lives in the repository's own
150 /// namespace). ADR §3.7.
151 #[error(
152 "spec.maintenance.namespace ({namespace:?}) is only valid on a ClusterRepository; \
153 a namespaced Repository's managed Maintenance always lives in the repository's namespace"
154 )]
155 MaintenanceNamespaceOnNamespacedRepo {
156 /// The `spec.maintenance.namespace` value set on the namespaced `Repository`.
157 namespace: String,
158 },
159}
160
161/// Result alias for validators. Defaults to `()` for the common "pass/fail with no
162/// value" case.
163pub type ValidationResult<T = ()> = Result<T, ValidationError>;