kopiur_api/common/mod.rs
1//! Shared sub-objects reused across multiple CRDs.
2//!
3//! Per ADR-0003 §2.2 (principle 10) and §4.11, every credential, policy, and
4//! identity surface is modeled as a sub-object so future fields slot in without
5//! API breakage. Leaf Kubernetes types (`LabelSelector`, `ResourceRequirements`,
6//! `PodSecurityContext`) are reused from `k8s-openapi` rather than re-invented.
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11mod cache;
12mod mover;
13mod secctx;
14
15pub use cache::*;
16pub use mover::*;
17pub use secctx::*;
18
19/// serde `default` for a `bool` field whose absent value is `true`. Used by
20/// "enabled by default, opt out explicitly" surfaces (e.g.
21/// `RepositoryMaintenanceSpec.enabled`). `bool::default()` is `false`, so a
22/// default-true field cannot lean on `#[serde(default)]` alone.
23pub(crate) fn default_true() -> bool {
24 true
25}
26
27/// A lifecycle-phase enum that can be rendered as a metric label.
28///
29/// The single source of truth for a CRD's phase labels: [`PhaseLabel::ALL`]
30/// enumerates every variant and [`PhaseLabel::label`] is an exhaustive match.
31/// The controller's `kopiur_resource_phase` gauge uses these to set the active
32/// phase to 1 and the rest to 0 (and to clear all on deletion), so both the
33/// label string and the reset set come from the enum itself rather than a
34/// stringly-typed table that can silently drift (ADR §5.5 type-safety thesis).
35pub trait PhaseLabel: Copy + PartialEq + 'static {
36 /// Every variant, in declaration order.
37 const ALL: &'static [Self];
38 /// The stable metric label string for this variant (exhaustive `match`).
39 fn label(&self) -> &'static str;
40}
41
42/// Reference to a key within a `Secret`.
43#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
44#[serde(rename_all = "camelCase")]
45pub struct SecretKeyRef {
46 /// Name of the `Secret`.
47 pub name: String,
48 /// Namespace of the `Secret`; absent = same namespace as the referrer. A
49 /// `ClusterRepository` is cluster-scoped and has no namespace of its own, so when IT
50 /// reads the `Secret` (to connect, to bootstrap, or to run its repository server) an
51 /// absent namespace means the operator's namespace (`KOPIUR_NAMESPACE`). A workload
52 /// mover (Snapshot/Restore/Maintenance) still needs the `Secret` in its OWN namespace —
53 /// `envFrom` is namespace-local — so put it there, or use `credentialProjection`, which
54 /// needs this namespace set EXPLICITLY to know what to copy. Set it whenever anything
55 /// other than the operator itself reads the `Secret`.
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub namespace: Option<String>,
58 /// Which key inside the `Secret` to read.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub key: Option<String>,
61}
62
63/// Reference to an entire `Secret` (the operator reads well-known keys from it).
64#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
65#[serde(rename_all = "camelCase")]
66pub struct SecretRef {
67 /// Name of the `Secret`.
68 pub name: String,
69 /// Namespace of the `Secret`; absent = same namespace as the referrer. A
70 /// `ClusterRepository` is cluster-scoped and has no namespace of its own, so when IT
71 /// reads the `Secret` (to connect, to bootstrap, or to run its repository server) an
72 /// absent namespace means the operator's namespace (`KOPIUR_NAMESPACE`). A workload
73 /// mover (Snapshot/Restore/Maintenance) still needs the `Secret` in its OWN namespace —
74 /// `envFrom` is namespace-local — so put it there, or use `credentialProjection`, which
75 /// needs this namespace set EXPLICITLY to know what to copy. Set it whenever anything
76 /// other than the operator itself reads the `Secret`.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub namespace: Option<String>,
79}
80
81/// Reference to a key within a `ConfigMap` (e.g. a CA bundle).
82#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
83#[serde(rename_all = "camelCase")]
84pub struct ConfigMapKeyRef {
85 /// Name of the `ConfigMap` holding the value (e.g. a CA bundle).
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub config_map_name: Option<String>,
88 /// Which key inside the `ConfigMap` to read.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub key: Option<String>,
91}
92
93/// TLS settings for object-store backends.
94#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
95#[serde(rename_all = "camelCase")]
96pub struct TlsConfig {
97 /// CA bundle (PEM) used to verify the endpoint's certificate, sourced from a `ConfigMap`.
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub ca_bundle_ref: Option<ConfigMapKeyRef>,
100 /// Skip TLS certificate verification (still uses TLS); maps to kopia's `--disable-tls-verification`.
101 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
102 pub insecure_skip_verify: bool,
103 /// Disable TLS entirely and talk plain HTTP; maps to kopia's `--disable-tls`.
104 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
105 pub disable_tls: bool,
106}
107
108/// Which kind of repository a consumer CR references (`Repository` or `ClusterRepository`).
109///
110/// ```
111/// use kopiur_api::common::RepositoryKind;
112///
113/// // Defaults to the namespaced `Repository`, so a same-namespace ref needs no `kind`.
114/// assert_eq!(RepositoryKind::default(), RepositoryKind::Repository);
115/// // Serializes to the bare CRD kind name (no payload — a plain string).
116/// assert_eq!(
117/// serde_json::to_value(RepositoryKind::ClusterRepository).unwrap(),
118/// "ClusterRepository"
119/// );
120/// ```
121#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
122pub enum RepositoryKind {
123 /// The namespaced `Repository` CRD; the default when `kind` is omitted.
124 #[default]
125 Repository,
126 /// The cluster-scoped `ClusterRepository` CRD; namespace is meaningless for it.
127 ClusterRepository,
128}
129
130/// Reference from a consumer CR to a `Repository` or `ClusterRepository`.
131#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
132#[serde(rename_all = "camelCase")]
133pub struct RepositoryRef {
134 /// Which repository CRD this points at; defaults to [`RepositoryKind::Repository`].
135 #[serde(default)]
136 pub kind: RepositoryKind,
137 /// Name of the referenced `Repository`/`ClusterRepository`.
138 pub name: String,
139 /// Cross-namespace `Repository` reference; ignored/forbidden for `ClusterRepository`.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub namespace: Option<String>,
142}
143
144/// Repository encryption settings.
145#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
146#[serde(rename_all = "camelCase")]
147pub struct Encryption {
148 /// Repository password, always a Secret reference (never inline).
149 pub password_secret_ref: SecretKeyRef,
150}
151
152/// Opt-in projection of a repository's credential `Secret`(s) into each mover Job's namespace.
153#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
154#[serde(rename_all = "camelCase")]
155pub struct CredentialProjection {
156 /// Copy the repository's credential Secret(s) into the namespace of each mover Job; off by default.
157 #[serde(default)]
158 pub enabled: bool,
159}
160
161/// Behavior when the repository does not yet exist.
162#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
163#[serde(rename_all = "camelCase")]
164pub struct CreateBehavior {
165 /// Create the repository if it does not exist yet — on by default. Repository
166 /// create/connect is idempotent: pointing `create` at an already-initialized
167 /// repository just connects to it (it never re-creates or clobbers), so the
168 /// only effect of the default is that a genuinely-absent repository is
169 /// bootstrapped instead of erroring. Set `false` for a strictly read-only or
170 /// externally-managed repository the operator must never create.
171 #[serde(default = "default_true")]
172 #[schemars(default = "default_true")]
173 pub enabled: bool,
174 /// kopia encryption algorithm for a freshly-created repository (creation-time only).
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub encryption: Option<String>,
177 /// kopia object splitter for a freshly-created repository (creation-time only).
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub splitter: Option<String>,
180 /// kopia content hash algorithm for a freshly-created repository (creation-time only).
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub hash: Option<String>,
183 /// Reed-Solomon ECC parity for a freshly-created repository (creation-time only, immutable after).
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub ecc: Option<Ecc>,
186}
187
188impl Default for CreateBehavior {
189 /// Mirrors the serde/schema default: create-on-first-use is on.
190 fn default() -> Self {
191 CreateBehavior {
192 enabled: true,
193 encryption: None,
194 splitter: None,
195 hash: None,
196 ecc: None,
197 }
198 }
199}
200
201/// Whether the operator should create the repository when it does not yet exist.
202///
203/// Pure resolver shared by the controller and tests so the "absent means create"
204/// default cannot fork: an absent `spec.create` resolves to `true` (create on
205/// first use), and an explicit `create.enabled` is honored as written. Repository
206/// create/connect is idempotent, so create-on is the least-surprise default; set
207/// `create.enabled: false` to opt out.
208///
209/// ```
210/// use kopiur_api::common::{create_enabled, CreateBehavior};
211///
212/// assert!(create_enabled(None)); // absent → create on
213/// assert!(create_enabled(Some(&CreateBehavior::default())));
214/// let off = CreateBehavior { enabled: false, ..CreateBehavior::default() };
215/// assert!(!create_enabled(Some(&off)));
216/// ```
217pub fn create_enabled(create: Option<&CreateBehavior>) -> bool {
218 create.map(|c| c.enabled).unwrap_or(true)
219}
220
221/// Reed-Solomon error-correcting-code parity for a freshly-created repository.
222#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
223#[serde(rename_all = "camelCase")]
224pub struct Ecc {
225 /// ECC algorithm, e.g. `REED-SOLOMON-CRC32` (`--ecc`).
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub algorithm: Option<String>,
228 /// Parity overhead as a percentage (`--ecc-overhead-percent`).
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub overhead_percent: Option<i64>,
231}
232
233/// GFS retention policy — how many snapshots to keep per time bucket.
234#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
235#[serde(rename_all = "camelCase")]
236pub struct Retention {
237 /// Keep the N most-recent snapshots regardless of age.
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub keep_latest: Option<u32>,
240 /// Keep one snapshot per hour for the most-recent N hours.
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub keep_hourly: Option<u32>,
243 /// Keep one snapshot per day for the most-recent N days.
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub keep_daily: Option<u32>,
246 /// Keep one snapshot per week for the most-recent N weeks.
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub keep_weekly: Option<u32>,
249 /// Keep one snapshot per month for the most-recent N months.
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub keep_monthly: Option<u32>,
252 /// Keep one snapshot per year for the most-recent N years.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub keep_annual: Option<u32>,
255}
256
257/// Identity overrides — what kopia records as `username@hostname:path`.
258#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
259#[serde(rename_all = "camelCase")]
260pub struct Identity {
261 /// Override the `username` portion of `username@hostname:path`; absent uses the resolved default.
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub username: Option<String>,
264 /// Override the `hostname` portion of `username@hostname:path`; absent uses the resolved default.
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub hostname: Option<String>,
267}
268
269/// Byte cap for `status.logTail` (and the stderr tail inside
270/// [`FailureBlock`]): the mover truncates to the LAST `MAX_LOG_TAIL_BYTES`
271/// bytes before patching status, so a noisy kopia run can't bloat etcd. Full
272/// logs live in the mover Job's pod. ADR §3.4/§4.10.
273pub const MAX_LOG_TAIL_BYTES: usize = 4096;
274
275/// A structured terminal-failure block written by the mover to `status.failure`.
276#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
277#[serde(rename_all = "camelCase")]
278pub struct FailureBlock {
279 /// kopia error class (e.g. `RepositoryUnavailable`, `AuthFailure`).
280 pub kopia_error_class: String,
281 /// A short human-readable message: what failed, why, and how to fix it.
282 pub message: String,
283 /// The last lines of kopia's stderr, if any were captured (bounded by
284 /// [`MAX_LOG_TAIL_BYTES`]).
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub stderr_tail: Option<String>,
287 /// The process exit code, if one was reported.
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub exit_code: Option<i32>,
290 /// Whether retrying the same operation unchanged could succeed.
291 pub retry_recommended: bool,
292}
293
294/// CEL expressions evaluated at admission to derive consumer identity when a
295/// `SnapshotPolicy` doesn't override. Shared by `Repository` and
296/// `ClusterRepository` (M5 gave the namespaced kind the same surface the
297/// cluster-scoped kind has had since M1) — both mean the same thing: this
298/// repository's backend is (or may be) shared, so its consumers need a
299/// hostname/username recipe beyond the bare per-namespace default.
300#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
301#[serde(rename_all = "camelCase")]
302pub struct IdentityDefaults {
303 /// This cluster's identity suffix for repositories shared across clusters
304 /// (an RFC 1123 label, at most 32 characters; dots are rejected — the first
305 /// `.` in a hostname is the namespace/cluster delimiter). When set, the
306 /// default kopia identity hostname becomes `<namespace>.<cluster>` instead
307 /// of `<namespace>`, so two clusters backing up same-named namespaces write
308 /// distinct identities (and one cluster's retention prune can no longer
309 /// touch the other's snapshots). Also exposed to `hostnameExpr`/
310 /// `usernameExpr` as the CEL variable `cluster`.
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub cluster: Option<String>,
313 /// CEL expression for the kopia identity hostname (e.g. `"namespace"`).
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub hostname_expr: Option<String>,
316 /// CEL expression for the kopia identity username (e.g. `"namespace + '-' + policyName"`).
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub username_expr: Option<String>,
319}
320
321/// Fully-resolved identity pinned into status; never re-rendered after admission.
322#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
323#[serde(rename_all = "camelCase")]
324pub struct ResolvedIdentity {
325 /// The final `username` kopia records, fixed at admission.
326 pub username: String,
327 /// The final `hostname` kopia records, fixed at admission.
328 pub hostname: String,
329 /// The resolved snapshot source path, when applicable (`username@hostname:path`).
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub source_path: Option<String>,
332}
333
334/// Per-run failure controls passed through to the mover `Job`.
335#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
336#[serde(rename_all = "camelCase")]
337pub struct FailurePolicy {
338 /// Mover `Job.spec.backoffLimit` — retries before a failed run is marked failed.
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub backoff_limit: Option<i32>,
341 /// Mover `Job.spec.activeDeadlineSeconds` — wall-clock cap after which a running run is killed.
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub active_deadline_seconds: Option<i64>,
344 /// Seconds a non-starting (wedged) mover pod may sit before the run is failed; default 300s.
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub pod_startup_deadline_seconds: Option<i64>,
347}
348
349/// Default grace before a non-starting (wedged) mover pod fails its run — 5 minutes.
350/// Long enough to absorb a slow image pull or a brief `Unschedulable` while an RWO volume
351/// detaches from another node, short enough that a genuinely-broken pod (e.g. an impossible
352/// securityContext, a missing image) surfaces as `Failed` fast instead of hanging for hours.
353pub const DEFAULT_POD_STARTUP_DEADLINE_SECONDS: i64 = 300;
354
355/// The effective pod-startup deadline (seconds) for a mover Job: the recipe's
356/// `failurePolicy.podStartupDeadlineSeconds`, or [`DEFAULT_POD_STARTUP_DEADLINE_SECONDS`]
357/// when unset. Shared by **every** reconciler that fast-fails a wedged mover (Snapshot,
358/// Restore, Maintenance) so the same default is applied identically on all three.
359pub fn pod_startup_deadline_seconds(failure_policy: Option<&FailurePolicy>) -> i64 {
360 failure_policy
361 .and_then(|fp| fp.pod_startup_deadline_seconds)
362 .unwrap_or(DEFAULT_POD_STARTUP_DEADLINE_SECONDS)
363}
364
365/// Reference to a `SnapshotPolicy` CR.
366#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
367#[serde(rename_all = "camelCase")]
368pub struct PolicyRef {
369 /// Name of the referenced `SnapshotPolicy`.
370 pub name: String,
371 /// Namespace of the `SnapshotPolicy`; absent = same namespace as the referrer.
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub namespace: Option<String>,
374}
375
376/// Generic name/namespace reference to another namespaced object (e.g. a `Snapshot` CR or PVC).
377#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
378#[serde(rename_all = "camelCase")]
379pub struct ObjectRef {
380 /// Name of the referenced object.
381 pub name: String,
382 /// Namespace of the referenced object; absent = same namespace as the referrer.
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub namespace: Option<String>,
385}
386
387/// A PersistentVolumeClaim access mode as a closed set — `ReadWriteOnce`,
388/// `ReadOnlyMany`, `ReadWriteMany`, `ReadWriteOncePod` — so a typo is rejected by
389/// the CRD schema itself instead of surfacing as a provisioner error at the first
390/// backup or restore run.
391///
392/// Deliberately **no `Default`** (unlike other unit enums here): everywhere this
393/// type appears, an absent/empty list means "inherit from context" — the source
394/// PVC's modes for a staged PVC, `ReadWriteOnce` for a restore-created PVC — so
395/// there is no context-free default value to name.
396///
397/// The extra [`PvcAccessMode::Unknown`] variant exists ONLY so values persisted
398/// before this field was schema-enforced still **deserialize** instead of erroring
399/// the typed watch stream for the whole Kind (one legacy CR must never wedge every
400/// other CR's reconciliation). It is hidden from the CRD schema — the apiserver
401/// rejects non-canonical strings on every new write — and
402/// [`crate::validate::validate_access_modes`] rejects it loudly per-CR with the
403/// offending value quoted.
404///
405/// ```
406/// use kopiur_api::common::PvcAccessMode;
407///
408/// // Canonical values round-trip as bare k8s strings.
409/// assert_eq!(serde_json::to_value(PvcAccessMode::ReadOnlyMany).unwrap(), "ReadOnlyMany");
410/// let m: PvcAccessMode = serde_json::from_value(serde_json::json!("ReadWriteOnce")).unwrap();
411/// assert_eq!(m, PvcAccessMode::ReadWriteOnce);
412///
413/// // A legacy/bogus stored string decodes (never a watcher-poisoning error) into
414/// // `Unknown`, preserving the value verbatim for the rejection message — and it
415/// // re-serializes to the same string, so a read-modify-write never mutates it.
416/// let m: PvcAccessMode = serde_json::from_value(serde_json::json!("ReadWriteOnze")).unwrap();
417/// assert_eq!(m, PvcAccessMode::Unknown("ReadWriteOnze".into()));
418/// assert_eq!(serde_json::to_value(&m).unwrap(), "ReadWriteOnze");
419/// ```
420#[derive(Clone, Debug, PartialEq, Eq)]
421pub enum PvcAccessMode {
422 /// Mounted read-write by a single node (`RWO`).
423 ReadWriteOnce,
424 /// Mounted read-only by many nodes (`ROX`) — e.g. a CephFS `backingSnapshot`
425 /// shallow-clone staged PVC.
426 ReadOnlyMany,
427 /// Mounted read-write by many nodes (`RWX`).
428 ReadWriteMany,
429 /// Mounted read-write by a single **pod** (`RWOP`); the apiserver requires it
430 /// to be the sole mode on a PVC.
431 ReadWriteOncePod,
432 /// A non-canonical stored value (pre-schema-enforcement legacy data). Never
433 /// admissible on a new write (not in the CRD schema); consumers reject it via
434 /// [`crate::validate::validate_access_modes`] with the value quoted, instead
435 /// of a serde error that would poison the typed watcher.
436 Unknown(String),
437}
438
439impl PvcAccessMode {
440 /// The four canonical Kubernetes access-mode strings — the CRD schema `enum`
441 /// and the "valid values" list in rejection messages, from one source.
442 pub const CANONICAL: [&'static str; 4] = [
443 "ReadWriteOnce",
444 "ReadOnlyMany",
445 "ReadWriteMany",
446 "ReadWriteOncePod",
447 ];
448
449 /// The k8s wire string (exhaustive; `Unknown` echoes the stored value verbatim).
450 pub fn mode_str(&self) -> &str {
451 match self {
452 PvcAccessMode::ReadWriteOnce => "ReadWriteOnce",
453 PvcAccessMode::ReadOnlyMany => "ReadOnlyMany",
454 PvcAccessMode::ReadWriteMany => "ReadWriteMany",
455 PvcAccessMode::ReadWriteOncePod => "ReadWriteOncePod",
456 PvcAccessMode::Unknown(s) => s,
457 }
458 }
459
460 /// Parse a **canonical** k8s access-mode string; `None` for anything else.
461 /// The migrate tool uses this to refuse a non-canonical VolSync value up
462 /// front instead of passing it through to a doomed `kubectl apply`.
463 pub fn parse(s: &str) -> Option<Self> {
464 match s {
465 "ReadWriteOnce" => Some(PvcAccessMode::ReadWriteOnce),
466 "ReadOnlyMany" => Some(PvcAccessMode::ReadOnlyMany),
467 "ReadWriteMany" => Some(PvcAccessMode::ReadWriteMany),
468 "ReadWriteOncePod" => Some(PvcAccessMode::ReadWriteOncePod),
469 _ => None,
470 }
471 }
472}
473
474impl Serialize for PvcAccessMode {
475 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
476 serializer.serialize_str(self.mode_str())
477 }
478}
479
480impl<'de> Deserialize<'de> for PvcAccessMode {
481 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
482 let s = String::deserialize(deserializer)?;
483 Ok(PvcAccessMode::parse(&s).unwrap_or(PvcAccessMode::Unknown(s)))
484 }
485}
486
487impl JsonSchema for PvcAccessMode {
488 fn schema_name() -> std::borrow::Cow<'static, str> {
489 "PvcAccessMode".into()
490 }
491 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
492 // Only the canonical values: `Unknown` is a decode-compat artifact for
493 // legacy stored data, never an admissible write.
494 schemars::json_schema!({
495 "type": "string",
496 "description": "A Kubernetes PersistentVolumeClaim access mode.",
497 "enum": PvcAccessMode::CANONICAL,
498 })
499 }
500}
501
502/// Lifecycle of the underlying kopia snapshot when its `Snapshot` CR is deleted.
503/// Produced backups default to `Delete`; discovered snapshots are forced to `Retain`.
504#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
505pub enum DeletionPolicy {
506 /// Finalizer runs `kopia snapshot delete <id>` then removes the finalizer; default for produced snapshots.
507 #[default]
508 Delete,
509 /// CR is removed; the kopia snapshot stays. Forced for discovered snapshots.
510 Retain,
511 /// CR is removed without contacting the repository at all (escape hatch).
512 Orphan,
513}
514
515/// What happens to a repository's snapshots when a consuming **namespace** is deleted; default `Orphan`.
516///
517/// ```
518/// use kopiur_api::common::NamespaceDeletePolicy;
519///
520/// // Fail-safe: a deleted namespace orphans (keeps) snapshots by default.
521/// assert_eq!(NamespaceDeletePolicy::default(), NamespaceDeletePolicy::Orphan);
522/// // Bare PascalCase strings (plain unit enum).
523/// assert_eq!(serde_json::to_value(NamespaceDeletePolicy::Delete).unwrap(), "Delete");
524/// ```
525#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
526pub enum NamespaceDeletePolicy {
527 /// Release ownership without deleting the kopia snapshots; the fail-safe default.
528 #[default]
529 Orphan,
530 /// Cascade: each `Snapshot`'s own `deletionPolicy` applies when the namespace is deleted.
531 Delete,
532}
533
534/// What the deletion of a `SnapshotSchedule` does to the `Snapshot` CRs it
535/// produced (which Kubernetes GC cascade-deletes via their ownerReference).
536/// Default `Retain`: the CRs are removed but their kopia snapshots survive and
537/// the catalog rediscovers them as `origin: discovered`. `Delete` opts into the
538/// cascade: each Snapshot's own `deletionPolicy` applies.
539///
540/// Deliberately 2-variant (not reusing [`DeletionPolicy`]): an `Orphan` in
541/// cascade position would differ from `Retain` only in per-CR event/metric
542/// bookkeeping — an invalid state made unrepresentable. The guard's `Retain`
543/// is exactly `DeletionPolicy::Retain`'s semantics (CR removed, kopia snapshot
544/// stays, catalog rediscovers it), deliberately NOT the `Orphan` event storm
545/// (no per-CR "orphaned" event/metric for every produced Snapshot).
546#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
547pub enum ScheduleDeletePolicy {
548 /// Keep the kopia snapshots: a produced Snapshot whose effective
549 /// deletionPolicy is `Delete` is downgraded to retain when its owning
550 /// schedule is gone (the fail-safe default).
551 #[default]
552 Retain,
553 /// Cascade: each produced Snapshot's own `deletionPolicy` applies even when
554 /// the owning schedule is gone (subject to the mass-deletion breaker).
555 Delete,
556}
557
558/// What the deletion of a `SnapshotPolicy` does to the `Snapshot` CRs carrying
559/// its config label (the recipe's produced/adopted rows — NOT its kopia
560/// snapshot history in the abstract, which is exactly what `Retain` preserves).
561/// Default `Retain`: the CRs are removed but every kopia snapshot survives
562/// (rediscoverable/adoptable by a future `SnapshotPolicy`, including this one
563/// re-created). `Delete` opts into the cascade: each CR's own `deletionPolicy`
564/// applies, as EXTERNAL deletions subject to the per-repository mass-deletion
565/// breaker (`deletionProtection.threshold`).
566///
567/// Deliberately 2-variant (not reusing [`DeletionPolicy`]), mirroring
568/// [`ScheduleDeletePolicy`]: an `Orphan` in cascade position would differ from
569/// `Retain` only in per-CR event/metric bookkeeping — an invalid state made
570/// unrepresentable. The guard's `Retain` is exactly `DeletionPolicy::Retain`'s
571/// semantics (CR removed, kopia snapshot stays, catalog rediscovers it),
572/// deliberately NOT the `Orphan` event storm (no per-CR "orphaned" event/metric
573/// for every one of a deleted policy's Snapshots). Not [`ScheduleDeletePolicy`]
574/// itself: that type's doc contract is schedule-specific (its `Delete` arm talks
575/// about a *schedule* being gone/replaced), and a `SnapshotPolicy` deletion is a
576/// distinct trigger with its own semantics worth documenting on its own type.
577#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
578pub enum PolicyDeletePolicy {
579 /// Keep the kopia snapshots: a Snapshot whose effective deletionPolicy is
580 /// `Delete` is downgraded to retain when its owning `SnapshotPolicy` is gone
581 /// (the fail-safe default).
582 #[default]
583 Retain,
584 /// Cascade: each Snapshot's own `deletionPolicy` applies even though the
585 /// owning `SnapshotPolicy` is gone (subject to the mass-deletion breaker).
586 Delete,
587}
588
589/// Mass-deletion circuit breaker for this repository's Snapshots.
590#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
591#[serde(rename_all = "camelCase")]
592pub struct DeletionProtectionSpec {
593 /// Pending EXTERNAL destructive Snapshot deletions (deletionTimestamp set,
594 /// effective deletionPolicy Delete, not operator-pruned) that trip the
595 /// breaker for this repository: at or above this, those deletions are HELD
596 /// (finalizers wait) until acknowledged via the
597 /// `kopiur.home-operations.com/allow-mass-deletion` annotation.
598 /// `0` disables the breaker. Default 10.
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 #[schemars(default = "default_mass_deletion_threshold")]
601 pub threshold: Option<u32>,
602}
603
604/// schemars default for [`DeletionProtectionSpec::threshold`] —
605/// [`DEFAULT_MASS_DELETION_THRESHOLD`](crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
606/// (`10`), matching `effective_mass_deletion_threshold`'s absent→CONST
607/// resolution. Returns the field's `Option` type so schemars 1 emits the
608/// schema `default:`.
609fn default_mass_deletion_threshold() -> Option<u32> {
610 Some(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
611}
612
613/// Repository access mode; `ReadOnly` serves restores only (no backups, no maintenance).
614///
615/// ```
616/// use kopiur_api::common::RepositoryMode;
617///
618/// assert_eq!(RepositoryMode::default(), RepositoryMode::ReadWrite);
619/// assert_eq!(serde_json::to_value(RepositoryMode::ReadOnly).unwrap(), "ReadOnly");
620/// // ReadOnly forbids writes (backups + maintenance); restores are allowed.
621/// assert!(!RepositoryMode::ReadOnly.allows_writes());
622/// assert!(RepositoryMode::ReadWrite.allows_writes());
623/// ```
624#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
625pub enum RepositoryMode {
626 /// Normal read-write repository (default): backups, restores, maintenance.
627 #[default]
628 ReadWrite,
629 /// Read-only: restores only. Backup Jobs and maintenance are refused.
630 ReadOnly,
631}
632
633impl RepositoryMode {
634 /// Whether this mode permits write operations (backup Jobs + maintenance).
635 /// Pure + exhaustive so the single definition lives in one tested place.
636 pub fn allows_writes(&self) -> bool {
637 match self {
638 RepositoryMode::ReadWrite => true,
639 RepositoryMode::ReadOnly => false,
640 }
641 }
642}
643
644/// serde/schemars `default` for the repository `mode` field — `ReadWrite`
645/// (ADR-0005 §11). Named fn so it backs BOTH serde + schemars defaults.
646pub(crate) fn default_repository_mode() -> RepositoryMode {
647 RepositoryMode::ReadWrite
648}
649
650/// serde/schemars `default` for the repository `on_namespace_delete` field —
651/// `Orphan` (ADR-0005 §5). A named fn so it backs BOTH `#[serde(default = ...)]`
652/// and `#[schemars(default = ...)]`, emitting a real OpenAPI `default:`.
653pub(crate) fn default_namespace_delete_policy() -> NamespaceDeletePolicy {
654 NamespaceDeletePolicy::Orphan
655}
656
657/// A single cron entry with optional deterministic jitter.
658#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
659#[serde(rename_all = "camelCase")]
660pub struct CronSpec {
661 /// The cron expression, parsed by `croner`; may contain an `H` placeholder for deterministic jitter.
662 pub cron: String,
663 /// Optional deterministic jitter window as a Go-style duration string (e.g. `30m`).
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub jitter: Option<String>,
666 /// IANA timezone the cron is evaluated in (e.g. `America/Chicago`); absent uses
667 /// the enclosing schedule's timezone, else the controller default (UTC).
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub timezone: Option<String>,
670}
671
672/// Resolve an optional IANA timezone name to a concrete zone, defaulting to UTC.
673/// An unparseable name falls back to UTC defensively — the admission webhook rejects
674/// bad names up front via `validate::validate_timezone`, so reconcile-time resolution
675/// should never see one.
676pub fn resolve_tz(name: Option<&str>) -> chrono_tz::Tz {
677 name.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
678 .unwrap_or(chrono_tz::Tz::UTC)
679}
680
681/// Repo-level scheduling defaults, inherited at reconcile time by consumers that
682/// don't set their own equivalent field (ADR §2.2 principle 10: sub-object, not a
683/// leaf field, so future defaults — e.g. jitter — slot in without API breakage).
684///
685/// Consumed by `SnapshotPolicy` verification, `RepositoryReplication`,
686/// `Maintenance` scheduling, and `SnapshotSchedule` (the recurring-backup cron) —
687/// all of which resolve their repository in-reconciler via
688/// [`crate::common::RepositoryRef`]. The `SnapshotSchedule` consumer resolves its
689/// target policy's repository default at slot-computation time (see
690/// [`effective_timezone`]) and is re-triggered by a repository referent watch.
691#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
692#[serde(rename_all = "camelCase")]
693pub struct ScheduleDefaults {
694 /// IANA timezone name applied to every consuming cron that doesn't set its own
695 /// `timezone` (e.g. `America/New_York`). Set once here instead of repeating it
696 /// on every `SnapshotPolicy.verification`, `RepositoryReplication.schedule`, and
697 /// `Maintenance.schedule` cron.
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub timezone: Option<String>,
700}
701
702/// Resolve a consuming cron's own optional IANA timezone against a repository-level
703/// default, falling back to UTC (mirrors [`resolve_tz`]): `own` wins when set, else
704/// `repo_default` (typically `Repository`/`ClusterRepository`
705/// `spec.scheduleDefaults.timezone`), else UTC. An unparseable name at whichever
706/// level is selected falls back to UTC defensively, same as `resolve_tz` — the
707/// admission webhook rejects bad names up front for both levels via
708/// `validate::validate_timezone`, so reconcile-time resolution should never see one.
709///
710/// ```
711/// use kopiur_api::common::resolve_tz_with_default;
712///
713/// // The schedule's own timezone wins, even over a repo default.
714/// assert_eq!(
715/// resolve_tz_with_default(Some("America/Chicago"), Some("UTC")),
716/// "America/Chicago".parse::<chrono_tz::Tz>().unwrap(),
717/// );
718/// // Absent own timezone falls through to the repo default.
719/// assert_eq!(
720/// resolve_tz_with_default(None, Some("America/New_York")),
721/// "America/New_York".parse::<chrono_tz::Tz>().unwrap(),
722/// );
723/// // Both absent → UTC.
724/// assert_eq!(resolve_tz_with_default(None, None), chrono_tz::Tz::UTC);
725/// ```
726pub fn resolve_tz_with_default(own: Option<&str>, repo_default: Option<&str>) -> chrono_tz::Tz {
727 resolve_tz(own.or(repo_default))
728}
729
730/// Matched `SnapshotSchedule` target policies disagreed on their repositories'
731/// `scheduleDefaults.timezone`, so [`effective_timezone`] could not pick one
732/// unambiguously and fell back to UTC. The controller surfaces this as a status
733/// condition recommending an explicit `spec.schedule.timezone`.
734///
735/// This case only arises for the `policySelector` fan-out form (a single
736/// `policyRef` has exactly one repository, so it can never disagree with itself).
737#[derive(Debug, Clone, PartialEq, Eq)]
738pub struct TimezoneAmbiguity {
739 /// The distinct candidate zones (IANA names, sorted) that disagreed, for the
740 /// human-readable condition message.
741 pub candidates: Vec<String>,
742}
743
744/// **Pure.** Decide the effective timezone a `SnapshotSchedule`'s cron is
745/// evaluated in, given the schedule's own `spec.schedule.timezone` (`own`) and the
746/// `scheduleDefaults.timezone` of each *matched* target policy's repository
747/// (`policy_repo_defaults`, one entry per matched policy; `None` = that repo sets
748/// no default). Mirrors [`resolve_tz`] fallback semantics (an unparseable name
749/// degrades to UTC — the webhook rejects bad names up front).
750///
751/// Rules (the reconciler does the GETs and passes the data in):
752/// - `own` set → that zone wins, no lookups, never ambiguous.
753/// - `own` unset, **no** matched policies → UTC, not ambiguous.
754/// - `own` unset, all matched policies resolve to **one** zone → that zone.
755/// - `own` unset, matched policies resolve to **differing** zones → UTC plus a
756/// [`TimezoneAmbiguity`] (recommend an explicit `spec.schedule.timezone`).
757///
758/// A single `policyRef` therefore never yields ambiguity (one repository). Repos
759/// with no default resolve to UTC, so mixing "a zone" with "no default" is a
760/// genuine disagreement and is reported.
761///
762/// ```
763/// use kopiur_api::common::effective_timezone;
764///
765/// // Own timezone wins outright.
766/// let (tz, amb) = effective_timezone(Some("America/Chicago"), &[]);
767/// assert_eq!(tz.name(), "America/Chicago");
768/// assert!(amb.is_none());
769///
770/// // Unset own, one agreeing default across matched policies.
771/// let defs = [Some("Europe/Berlin".to_string()), Some("Europe/Berlin".to_string())];
772/// let (tz, amb) = effective_timezone(None, &defs);
773/// assert_eq!(tz.name(), "Europe/Berlin");
774/// assert!(amb.is_none());
775///
776/// // Unset own, disagreeing defaults → UTC + ambiguity signal.
777/// let defs = [Some("Europe/Berlin".to_string()), None];
778/// let (tz, amb) = effective_timezone(None, &defs);
779/// assert_eq!(tz, chrono_tz::Tz::UTC);
780/// assert!(amb.is_some());
781/// ```
782pub fn effective_timezone(
783 own: Option<&str>,
784 policy_repo_defaults: &[Option<String>],
785) -> (chrono_tz::Tz, Option<TimezoneAmbiguity>) {
786 if own.is_some() {
787 return (resolve_tz(own), None);
788 }
789 // No matched policies → nothing to inherit from.
790 if policy_repo_defaults.is_empty() {
791 return (chrono_tz::Tz::UTC, None);
792 }
793 // Resolve each matched policy's repo default to a concrete zone, then reduce to
794 // the distinct set. A repo with no default resolves to UTC (via `resolve_tz`),
795 // so it can legitimately disagree with a repo that sets one.
796 let mut zones: Vec<chrono_tz::Tz> = policy_repo_defaults
797 .iter()
798 .map(|d| resolve_tz(d.as_deref()))
799 .collect();
800 zones.sort_by(|a, b| a.name().cmp(b.name()));
801 zones.dedup();
802 if zones.len() == 1 {
803 (zones[0], None)
804 } else {
805 let candidates = zones.iter().map(|z| z.name().to_string()).collect();
806 (chrono_tz::Tz::UTC, Some(TimezoneAmbiguity { candidates }))
807 }
808}
809
810impl RepositoryRef {
811 /// True if this reference points at the given repository.
812 ///
813 /// `owner_namespace` is the namespace of the resource that holds the ref
814 /// (e.g. the `Maintenance` CR's own namespace), used to resolve a namespaced
815 /// `Repository` reference that omits `namespace`. The match is exhaustive over
816 /// [`RepositoryKind`] (ADR §5.5):
817 ///
818 /// - [`RepositoryKind::Repository`]: kind+name must match AND the effective
819 /// namespace (`self.namespace` or `owner_namespace`) must equal
820 /// `target_namespace`.
821 /// - [`RepositoryKind::ClusterRepository`]: kind+name must match; namespace is
822 /// ignored on both sides (cluster-scoped).
823 ///
824 /// `target_namespace` is `None` for a `ClusterRepository` target.
825 ///
826 /// ```
827 /// use kopiur_api::common::{RepositoryKind, RepositoryRef};
828 ///
829 /// // A namespaced ref that omits `namespace` resolves against the owner's namespace.
830 /// let r = RepositoryRef { kind: RepositoryKind::Repository, name: "nas".into(), namespace: None };
831 /// assert!(r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("apps")));
832 /// assert!(!r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("other")));
833 ///
834 /// // A cluster-scoped target ignores namespace entirely.
835 /// let cr = RepositoryRef {
836 /// kind: RepositoryKind::ClusterRepository,
837 /// name: "hetzner".into(),
838 /// namespace: None,
839 /// };
840 /// assert!(cr.resolves_to("apps", RepositoryKind::ClusterRepository, "hetzner", None));
841 /// // Kind must match even when names collide.
842 /// assert!(!r.resolves_to("apps", RepositoryKind::ClusterRepository, "nas", None));
843 /// ```
844 pub fn resolves_to(
845 &self,
846 owner_namespace: &str,
847 target_kind: RepositoryKind,
848 target_name: &str,
849 target_namespace: Option<&str>,
850 ) -> bool {
851 if self.kind != target_kind || self.name != target_name {
852 return false;
853 }
854 match self.kind {
855 RepositoryKind::Repository => {
856 Some(self.namespace.as_deref().unwrap_or(owner_namespace)) == target_namespace
857 }
858 RepositoryKind::ClusterRepository => true,
859 }
860 }
861}
862
863#[cfg(test)]
864mod tests;