Skip to main content

kopiur_api/
error.rs

1//! Typed validation errors shared by the admission webhook and the controller.
2//!
3//! Per ADR-0003 §2.2 (principle 8) and the SKILL "one validator, two callers"
4//! rule, cross-field validation lives in [`crate::validate`] as pure functions
5//! returning these typed errors. The webhook rejects at admission; the controller
6//! calls the same functions defensively before reconcile. The error type is the
7//! contract between them, so messages must be **actionable** — they end up in a
8//! `kubectl apply` rejection and in controller logs verbatim.
9//!
10//! ## Accumulation vs. fail-fast
11//!
12//! Per-field helpers (e.g. [`crate::validate::validate_repository_ref`]) are
13//! **fail-fast**: they return the first problem they find as `ValidationResult`.
14//! The per-CRD aggregate validators (`validate_backup_config`, …) **accumulate**
15//! every independent problem into a `Vec<ValidationError>` so a user fixing one
16//! manifest sees all issues at once rather than playing whack-a-mole across
17//! re-applies. Both styles share this one error enum.
18//!
19//! ```
20//! use kopiur_api::ValidationError;
21//!
22//! // Messages are written for a human reading a rejected `kubectl apply` — they
23//! // say what is wrong and why, embedding the offending value.
24//! let err = ValidationError::DiscoveredMustRetain { got: "Delete".to_string() };
25//! assert!(err.to_string().contains("origin: discovered"));
26//! assert!(err.to_string().contains("Delete"));
27//!
28//! // `ValidationResult` defaults its Ok type to `()` for the pass/fail case.
29//! let ok: kopiur_api::ValidationResult = Ok(());
30//! assert!(ok.is_ok());
31//! ```
32
33use thiserror::Error;
34
35/// A single cross-field validation failure. `PartialEq` so tests can assert the
36/// exact variant; messages are written for an end user reading a rejected apply.
37#[derive(Debug, Error, PartialEq, Eq, Clone)]
38pub enum ValidationError {
39    /// 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>;