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 privileged;
14mod secctx;
15
16pub use cache::*;
17pub use mover::*;
18pub use privileged::*;
19pub use secctx::*;
20
21/// serde `default` for a `bool` field whose absent value is `true`. Used by
22/// "enabled by default, opt out explicitly" surfaces (e.g.
23/// `RepositoryMaintenanceSpec.enabled`). `bool::default()` is `false`, so a
24/// default-true field cannot lean on `#[serde(default)]` alone.
25pub(crate) fn default_true() -> bool {
26 true
27}
28
29/// A lifecycle-phase enum that can be rendered as a metric label.
30///
31/// The single source of truth for a CRD's phase labels: [`PhaseLabel::ALL`]
32/// enumerates every **canonical** variant and [`PhaseLabel::label`] is an
33/// exhaustive match. The controller's `kopiur_resource_phase` gauge uses these
34/// to set the active phase to 1 and the rest to 0 (and to clear all on
35/// deletion), so both the label string and the reset set come from the enum
36/// itself rather than a stringly-typed table that can silently drift
37/// (ADR §5.5 type-safety thesis).
38///
39/// Every phase enum also carries an `Unknown(String)` decode-compat variant
40/// (see the crate-internal `phase_serde!` macro below). It is deliberately **absent**
41/// from [`PhaseLabel::ALL`]: `ALL` is the CRD schema's admissible set and the
42/// metric label domain, whereas `Unknown` only ever comes back off the wire
43/// from a newer operator's write. `label()` therefore returns `&str`, not
44/// `&'static str` — `Unknown` echoes the stored string verbatim.
45pub trait PhaseLabel: Clone + PartialEq + 'static {
46 /// Every canonical variant, in declaration order. Never contains `Unknown`.
47 const ALL: &'static [Self];
48
49 /// The stable metric/wire label string for this variant (exhaustive
50 /// `match`); the `Unknown` arm echoes the stored value verbatim so a
51 /// read-modify-write never mutates a phase this build does not understand.
52 fn label(&self) -> &str;
53
54 /// Build the decode-compat fallback for a non-canonical stored string.
55 /// Implemented by the `phase_serde!` macro's host enum.
56 fn unknown(raw: String) -> Self;
57
58 /// Parse a **canonical** label; `None` for anything else (including a value
59 /// that would decode to `Unknown`). Derived from `ALL` + `label()` so a new
60 /// variant is parseable the moment it is declared.
61 fn parse(s: &str) -> Option<Self> {
62 Self::ALL.iter().find(|v| v.label() == s).cloned()
63 }
64
65 /// The canonical label set, for the CRD schema `enum` and "valid values"
66 /// messages. One definition, derived from `ALL`.
67 fn canonical() -> Vec<&'static str> {
68 Self::ALL.iter().map(PhaseLabel::label).collect()
69 }
70}
71
72/// Give a phase enum the `Unknown`-tolerant wire contract: `Serialize` echoes
73/// [`PhaseLabel::label`], `Deserialize` falls back to `Unknown(raw)` instead of
74/// erroring, and `JsonSchema` publishes **only** the canonical values.
75///
76/// Why the fallback exists (the graceful-decode convention, mirroring
77/// [`PvcAccessMode`]): a phase string this build does not know — written by a
78/// newer operator during a rolling upgrade, or by a future controller into a CR
79/// an older CLI then lists — must never fail the typed watch/list for the whole
80/// Kind. One un-decodable object would otherwise wedge every other object's
81/// reconciliation (and, for `kubectl kopiur doctor`, turn a real problem into a
82/// silent green).
83///
84/// # What consumers do with `Unknown` — a deliberate three-way split
85///
86/// `Unknown` is never terminal, never schedulable, never reapable, and never a
87/// success. What follows from that is NOT uniform, and the differences are the
88/// point rather than an oversight:
89///
90/// 1. **Read-only classifications HOLD.** Anything deciding "is this finished /
91/// reapable / retention-eligible / a success" answers *no*, and stays out of
92/// every set whose members get deleted or whose absence silences an alert.
93/// The CLI's `--wait` paths keep waiting rather than exiting 0 or 1 on an
94/// outcome they cannot substantiate.
95/// 2. **Reconcilers whose re-drive is IDEMPOTENT self-heal by overwriting.**
96/// `Restore` (its source is pinned in `status.resolved` and never
97/// re-resolved, so re-driving restores the same snapshot to the same
98/// target), `Repository`/`ClusterRepository` (connect is idempotent), and a
99/// `Maintenance` manual run (its Job name is keyed on the request timestamp)
100/// all re-derive the phase from observed state and write it, replacing the
101/// value they could not read. Parking instead would strand the object with
102/// no way out — remember the fallback also catches legacy stored values that
103/// no future upgrade will ever explain. Each names the phase first via the
104/// controller's `io::warn_unreadable_phase`: deliberate, never silent.
105/// 3. **Reconcilers whose re-drive would DUPLICATE irreversible work hold.**
106/// `Snapshot` is the one: a `Snapshot` IS its run, and re-driving one mints a
107/// second mover Job and a second kopia snapshot. That is the exact hazard the
108/// one-shot discipline exists for, so the reconciler holds (log + slow
109/// requeue) instead. Idempotence, not read-vs-write, is what separates (2)
110/// from (3).
111///
112/// A fourth case is created by (3) composing with a fail-closed gate: a
113/// `SnapshotSchedule` whose concurrency gate is held by an `Unknown`-phase run
114/// stops firing permanently under `concurrencyPolicy: Forbid`, and neither the
115/// schedule nor the run looks unhealthy. That one is not resolved by weakening
116/// the hold (the hold is right) but by SURFACING it: a registered structural
117/// gate ([`crate::gates::STRUCTURAL_GATES`], `ScheduleRunnable=False`) plus a
118/// Warning Event — a condition rather than a log, because unlike a per-pass
119/// warning it has a real transition to record and a diagnostic to feed.
120///
121/// # `$desc`
122///
123/// The CRD-schema `description`. It is spelled out here rather than taken from
124/// the doc comment because a manual `JsonSchema` impl cannot see doc comments;
125/// keep it byte-identical to what the derive used to emit or `mise run
126/// gen-check` will (correctly) fail. It therefore deliberately DIVERGES from
127/// the enum's rustdoc: the rustdoc is free to explain `Unknown` to Rust
128/// readers, while `$desc` must stay frozen at the pre-`Unknown` wording,
129/// because changing it would rewrite the published CRD for no behavioral
130/// reason. Treat `$desc` as a schema artifact, not documentation.
131///
132/// Crate-internal on purpose (`pub(crate) use` below, not `#[macro_export]`):
133/// it expands to impls of THIS crate's traits for THIS crate's enums and would
134/// commit `kopiur-api` to a public macro contract nothing outside needs.
135macro_rules! phase_serde {
136 ($ty:ty, $desc:literal) => {
137 impl ::serde::Serialize for $ty {
138 fn serialize<S: ::serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
139 serializer.serialize_str($crate::common::PhaseLabel::label(self))
140 }
141 }
142
143 impl<'de> ::serde::Deserialize<'de> for $ty {
144 fn deserialize<D: ::serde::Deserializer<'de>>(
145 deserializer: D,
146 ) -> Result<Self, D::Error> {
147 let s = String::deserialize(deserializer)?;
148 Ok(<Self as $crate::common::PhaseLabel>::parse(&s)
149 .unwrap_or_else(|| <Self as $crate::common::PhaseLabel>::unknown(s)))
150 }
151 }
152
153 impl ::schemars::JsonSchema for $ty {
154 fn schema_name() -> ::std::borrow::Cow<'static, str> {
155 stringify!($ty).into()
156 }
157 fn json_schema(_: &mut ::schemars::SchemaGenerator) -> ::schemars::Schema {
158 // Canonical values only: `Unknown` is a decode-compat artifact,
159 // never an admissible write. The apiserver keeps rejecting
160 // anything outside this set.
161 //
162 // Shape matters: this is a `oneOf` of `const`s, NOT a flat
163 // `enum`, because that is exactly what `#[derive(JsonSchema)]`
164 // emits for a documented unit-only enum — and the two are not
165 // interchangeable downstream. `Option<Self>` runs schemars'
166 // `allow_null`, which appends a literal `null` to a flat `enum`
167 // but wraps a `oneOf` in `anyOf[.., null]`; kube's CRD rewriter
168 // then folds that back into `enum: [..] + nullable: true` with
169 // no bogus `null` member. Flattening this to `"enum"` silently
170 // changes every generated CRD (`mise run gen-check` catches it).
171 ::schemars::json_schema!({
172 "description": $desc,
173 "oneOf": <Self as $crate::common::PhaseLabel>::canonical()
174 .into_iter()
175 .map(|v| ::serde_json::json!({ "type": "string", "const": v }))
176 .collect::<Vec<_>>(),
177 })
178 }
179 }
180 };
181}
182
183pub(crate) use phase_serde;
184
185/// Reference to a key within a `Secret`.
186#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
187#[serde(rename_all = "camelCase")]
188pub struct SecretKeyRef {
189 /// Name of the `Secret`.
190 pub name: String,
191 /// Namespace of the `Secret`; absent = same namespace as the referrer. A
192 /// `ClusterRepository` is cluster-scoped and has no namespace of its own, so when IT
193 /// reads the `Secret` (to connect, to bootstrap, or to run its repository server) an
194 /// absent namespace means the operator's namespace (`KOPIUR_NAMESPACE`). A workload
195 /// mover (Snapshot/Restore/Maintenance) still needs the `Secret` in its OWN namespace —
196 /// `envFrom` is namespace-local — so put it there, or use `credentialProjection`, which
197 /// needs this namespace set EXPLICITLY to know what to copy. Set it whenever anything
198 /// other than the operator itself reads the `Secret`.
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub namespace: Option<String>,
201 /// Which key inside the `Secret` to read.
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub key: Option<String>,
204}
205
206/// Reference to an entire `Secret` (the operator reads well-known keys from it).
207#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
208#[serde(rename_all = "camelCase")]
209pub struct SecretRef {
210 /// Name of the `Secret`.
211 pub name: String,
212 /// Namespace of the `Secret`; absent = same namespace as the referrer. A
213 /// `ClusterRepository` is cluster-scoped and has no namespace of its own, so when IT
214 /// reads the `Secret` (to connect, to bootstrap, or to run its repository server) an
215 /// absent namespace means the operator's namespace (`KOPIUR_NAMESPACE`). A workload
216 /// mover (Snapshot/Restore/Maintenance) still needs the `Secret` in its OWN namespace —
217 /// `envFrom` is namespace-local — so put it there, or use `credentialProjection`, which
218 /// needs this namespace set EXPLICITLY to know what to copy. Set it whenever anything
219 /// other than the operator itself reads the `Secret`.
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub namespace: Option<String>,
222}
223
224/// Reference to a key within a `ConfigMap` (e.g. a CA bundle). The ref carries no
225/// `namespace` field: it always resolves in the referrer's namespace for a namespaced
226/// `Repository`. A `ClusterRepository` is cluster-scoped and has no namespace of its own,
227/// so for it the ref resolves in the operator's namespace (`KOPIUR_NAMESPACE`) — put the
228/// `ConfigMap` there.
229#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
230#[serde(rename_all = "camelCase")]
231pub struct ConfigMapKeyRef {
232 /// Name of the `ConfigMap` holding the value (e.g. a CA bundle). Resolved in the
233 /// referrer's namespace for a namespaced `Repository`, and in the operator's
234 /// namespace (`KOPIUR_NAMESPACE`) for a `ClusterRepository` (cluster-scoped, no
235 /// namespace of its own).
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub config_map_name: Option<String>,
238 /// Which key inside the `ConfigMap` to read; defaults to `ca.crt` when unset.
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub key: Option<String>,
241}
242
243/// TLS settings for object-store backends.
244#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct TlsConfig {
247 /// CA bundle (PEM) used to verify the endpoint's certificate, sourced from a `ConfigMap`.
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub ca_bundle_ref: Option<ConfigMapKeyRef>,
250 /// Skip TLS certificate verification (still uses TLS); maps to kopia's `--disable-tls-verification`.
251 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
252 pub insecure_skip_verify: bool,
253 /// Disable TLS entirely and talk plain HTTP; maps to kopia's `--disable-tls`.
254 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255 pub disable_tls: bool,
256}
257
258/// Which kind of repository a consumer CR references (`Repository` or `ClusterRepository`).
259///
260/// ```
261/// use kopiur_api::common::RepositoryKind;
262///
263/// // Defaults to the namespaced `Repository`, so a same-namespace ref needs no `kind`.
264/// assert_eq!(RepositoryKind::default(), RepositoryKind::Repository);
265/// // Serializes to the bare CRD kind name (no payload — a plain string).
266/// assert_eq!(
267/// serde_json::to_value(RepositoryKind::ClusterRepository).unwrap(),
268/// "ClusterRepository"
269/// );
270/// ```
271#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
272pub enum RepositoryKind {
273 /// The namespaced `Repository` CRD; the default when `kind` is omitted.
274 #[default]
275 Repository,
276 /// The cluster-scoped `ClusterRepository` CRD; namespace is meaningless for it.
277 ClusterRepository,
278}
279
280impl RepositoryKind {
281 /// The CRD kind name, for messages and status rendering. Exhaustive, so a
282 /// new repository kind cannot compile until its label is decided.
283 ///
284 /// ```
285 /// use kopiur_api::common::RepositoryKind;
286 ///
287 /// assert_eq!(RepositoryKind::Repository.kind_str(), "Repository");
288 /// assert_eq!(RepositoryKind::ClusterRepository.kind_str(), "ClusterRepository");
289 /// ```
290 pub fn kind_str(self) -> &'static str {
291 match self {
292 RepositoryKind::Repository => "Repository",
293 RepositoryKind::ClusterRepository => "ClusterRepository",
294 }
295 }
296}
297
298/// Reference from a consumer CR to a `Repository` or `ClusterRepository`.
299#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
300#[serde(rename_all = "camelCase")]
301pub struct RepositoryRef {
302 /// Which repository CRD this points at; defaults to [`RepositoryKind::Repository`].
303 #[serde(default)]
304 pub kind: RepositoryKind,
305 /// Name of the referenced `Repository`/`ClusterRepository`.
306 pub name: String,
307 /// Cross-namespace `Repository` reference; ignored/forbidden for `ClusterRepository`.
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub namespace: Option<String>,
310}
311
312/// A normalized, comparable repository key for a consumer's [`RepositoryRef`]
313/// resolved from `owner_namespace` (the consuming CR's namespace). Two
314/// references are "the same repository" only when their keys match. Pure +
315/// exhaustive over [`RepositoryKind`]. Hoisted from the webhook's
316/// identity-collision module so the validator, the webhook, and child-naming
317/// all normalize identically (the webhook re-exports this).
318///
319/// - `Repository` → `"Repository/<effective-ns>/<name>"` (effective-ns is
320/// `ref.namespace` or the owner's namespace).
321/// - `ClusterRepository` → `"ClusterRepository/<name>"` (namespace-free).
322///
323/// ```
324/// use kopiur_api::common::{RepositoryKind, RepositoryRef, repo_key};
325///
326/// let r = RepositoryRef { kind: RepositoryKind::Repository, name: "nas".into(), namespace: None };
327/// assert_eq!(repo_key(&r, "backups"), "Repository/backups/nas");
328/// let c = RepositoryRef { kind: RepositoryKind::ClusterRepository, name: "shared".into(), namespace: None };
329/// assert_eq!(repo_key(&c, "backups"), "ClusterRepository/shared");
330/// ```
331pub fn repo_key(repo: &RepositoryRef, owner_namespace: &str) -> String {
332 match repo.kind {
333 RepositoryKind::Repository => {
334 let ns = repo.namespace.as_deref().unwrap_or(owner_namespace);
335 format!("Repository/{ns}/{}", repo.name)
336 }
337 RepositoryKind::ClusterRepository => format!("ClusterRepository/{}", repo.name),
338 }
339}
340
341/// Normalize a [`RepositoryRef`] against the namespace it resolves relative to
342/// (`owner_namespace`, the consuming CR's namespace): a namespaced `Repository`
343/// ref carries its EFFECTIVE namespace explicitly (so a later reader can
344/// re-resolve it from anywhere — e.g. after the owning recipe is gone); a
345/// cluster-scoped `ClusterRepository` ref carries none (the webhook forbids
346/// one). This is the one normal form every pin uses — `status.resolved.repository`
347/// (the controller's run-time pin) and `Snapshot.spec.repository` (the
348/// mint-time pin a multi-repo fan-out child or replication copy CR carries) —
349/// so [`repo_key`] over a normalized ref is namespace-independent. Exhaustive
350/// over [`RepositoryKind`].
351pub fn normalized_repository_ref(r: &RepositoryRef, owner_namespace: &str) -> RepositoryRef {
352 match r.kind {
353 RepositoryKind::Repository => RepositoryRef {
354 kind: RepositoryKind::Repository,
355 name: r.name.clone(),
356 namespace: Some(
357 r.namespace
358 .clone()
359 .unwrap_or_else(|| owner_namespace.to_string()),
360 ),
361 },
362 RepositoryKind::ClusterRepository => RepositoryRef {
363 kind: RepositoryKind::ClusterRepository,
364 name: r.name.clone(),
365 namespace: None,
366 },
367 }
368}
369
370/// Repository encryption settings.
371#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
372#[serde(rename_all = "camelCase")]
373pub struct Encryption {
374 /// Repository password, always a Secret reference (never inline).
375 pub password_secret_ref: SecretKeyRef,
376}
377
378/// Opt-in projection of a repository's credential `Secret`(s) into each mover Job's namespace.
379#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
380#[serde(rename_all = "camelCase")]
381pub struct CredentialProjection {
382 /// Copy the repository's credential Secret(s) into the namespace of each mover Job; off by default.
383 #[serde(default)]
384 pub enabled: bool,
385}
386
387/// Behavior when the repository does not yet exist.
388#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
389#[serde(rename_all = "camelCase")]
390pub struct CreateBehavior {
391 /// Create the repository if it does not exist yet — on by default. Repository
392 /// create/connect is idempotent: pointing `create` at an already-initialized
393 /// repository just connects to it (it never re-creates or clobbers), so the
394 /// only effect of the default is that a genuinely-absent repository is
395 /// bootstrapped instead of erroring. Set `false` for a strictly read-only or
396 /// externally-managed repository the operator must never create.
397 #[serde(default = "default_true")]
398 #[schemars(default = "default_true")]
399 pub enabled: bool,
400 /// kopia encryption algorithm for a freshly-created repository (creation-time only).
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub encryption: Option<String>,
403 /// kopia object splitter for a freshly-created repository (creation-time only).
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub splitter: Option<String>,
406 /// kopia content hash algorithm for a freshly-created repository (creation-time only).
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub hash: Option<String>,
409 /// Reed-Solomon ECC parity for a freshly-created repository (creation-time only, immutable after).
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub ecc: Option<Ecc>,
412}
413
414impl Default for CreateBehavior {
415 /// Mirrors the serde/schema default: create-on-first-use is on.
416 fn default() -> Self {
417 CreateBehavior {
418 enabled: true,
419 encryption: None,
420 splitter: None,
421 hash: None,
422 ecc: None,
423 }
424 }
425}
426
427/// Whether the operator should create the repository when it does not yet exist.
428///
429/// Pure resolver shared by the controller and tests so the "absent means create"
430/// default cannot fork: an absent `spec.create` resolves to `true` (create on
431/// first use), and an explicit `create.enabled` is honored as written. Repository
432/// create/connect is idempotent, so create-on is the least-surprise default; set
433/// `create.enabled: false` to opt out.
434///
435/// ```
436/// use kopiur_api::common::{create_enabled, CreateBehavior};
437///
438/// assert!(create_enabled(None)); // absent → create on
439/// assert!(create_enabled(Some(&CreateBehavior::default())));
440/// let off = CreateBehavior { enabled: false, ..CreateBehavior::default() };
441/// assert!(!create_enabled(Some(&off)));
442/// ```
443pub fn create_enabled(create: Option<&CreateBehavior>) -> bool {
444 create.map(|c| c.enabled).unwrap_or(true)
445}
446
447/// Reed-Solomon error-correcting-code parity for a freshly-created repository.
448#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
449#[serde(rename_all = "camelCase")]
450pub struct Ecc {
451 /// ECC algorithm, e.g. `REED-SOLOMON-CRC32` (`--ecc`).
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub algorithm: Option<String>,
454 /// Parity overhead as a percentage (`--ecc-overhead-percent`).
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub overhead_percent: Option<i64>,
457}
458
459/// GFS retention policy — how many snapshots to keep per time bucket.
460#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
461#[serde(rename_all = "camelCase")]
462pub struct Retention {
463 /// Keep the N most-recent snapshots regardless of age.
464 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub keep_latest: Option<u32>,
466 /// Keep one snapshot per hour for the most-recent N hours.
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub keep_hourly: Option<u32>,
469 /// Keep one snapshot per day for the most-recent N days.
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub keep_daily: Option<u32>,
472 /// Keep one snapshot per week for the most-recent N weeks.
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub keep_weekly: Option<u32>,
475 /// Keep one snapshot per month for the most-recent N months.
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub keep_monthly: Option<u32>,
478 /// Keep one snapshot per year for the most-recent N years.
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub keep_annual: Option<u32>,
481}
482
483/// Identity overrides — what kopia records as `username@hostname:path`.
484#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
485#[serde(rename_all = "camelCase")]
486pub struct Identity {
487 /// Override the `username` portion of `username@hostname:path`; absent uses the resolved default.
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub username: Option<String>,
490 /// Override the `hostname` portion of `username@hostname:path`; absent uses the resolved default.
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub hostname: Option<String>,
493}
494
495/// Byte cap for `status.logTail` (and the stderr tail inside
496/// [`FailureBlock`]): the mover truncates to the LAST `MAX_LOG_TAIL_BYTES`
497/// bytes before patching status, so a noisy kopia run can't bloat etcd. Full
498/// logs live in the mover Job's pod. ADR §3.4/§4.10.
499pub const MAX_LOG_TAIL_BYTES: usize = 4096;
500
501/// A structured terminal-failure block written by the mover to `status.failure`.
502#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
503#[serde(rename_all = "camelCase")]
504pub struct FailureBlock {
505 /// kopia error class (e.g. `RepositoryUnavailable`, `AuthFailure`).
506 pub kopia_error_class: String,
507 /// A short human-readable message: what failed, why, and how to fix it.
508 pub message: String,
509 /// The last lines of kopia's stderr, if any were captured (bounded by
510 /// [`MAX_LOG_TAIL_BYTES`]).
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub stderr_tail: Option<String>,
513 /// The process exit code, if one was reported.
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub exit_code: Option<i32>,
516 /// Whether retrying the same operation unchanged could succeed.
517 pub retry_recommended: bool,
518 /// The mover operation that failed, as a stable label (e.g.
519 /// `repository connect`, `snapshot create`) — the values of the mover's
520 /// `KopiaOp::as_str()`. Distinguishes a repository-level connect failure
521 /// from a source-level failure (a broken PVC), which share
522 /// `kopiaErrorClass` values (e.g. `NotFound`). Absent on failures that
523 /// occurred outside a kopia invocation.
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub op: Option<String>,
526}
527
528/// CEL expressions evaluated at admission to derive consumer identity when a
529/// `SnapshotPolicy` doesn't override. Shared by `Repository` and
530/// `ClusterRepository` (M5 gave the namespaced kind the same surface the
531/// cluster-scoped kind has had since M1) — both mean the same thing: this
532/// repository's backend is (or may be) shared, so its consumers need a
533/// hostname/username recipe beyond the bare per-namespace default.
534#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
535#[serde(rename_all = "camelCase")]
536pub struct IdentityDefaults {
537 /// This cluster's identity suffix for repositories shared across clusters
538 /// (an RFC 1123 label, at most 32 characters; dots are rejected — the first
539 /// `.` in a hostname is the namespace/cluster delimiter). When set, the
540 /// default kopia identity hostname becomes `<namespace>.<cluster>` instead
541 /// of `<namespace>`, so two clusters backing up same-named namespaces write
542 /// distinct identities (and one cluster's retention prune can no longer
543 /// touch the other's snapshots). Also exposed to `hostnameExpr`/
544 /// `usernameExpr` as the CEL variable `cluster`.
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub cluster: Option<String>,
547 /// CEL expression for the kopia identity hostname (e.g. `"namespace"`).
548 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub hostname_expr: Option<String>,
550 /// CEL expression for the kopia identity username (e.g. `"namespace + '-' + policyName"`).
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub username_expr: Option<String>,
553}
554
555/// Fully-resolved identity pinned into status; never re-rendered after admission.
556#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
557#[serde(rename_all = "camelCase")]
558pub struct ResolvedIdentity {
559 /// The final `username` kopia records, fixed at admission.
560 pub username: String,
561 /// The final `hostname` kopia records, fixed at admission.
562 pub hostname: String,
563 /// The resolved snapshot source path, when applicable (`username@hostname:path`).
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub source_path: Option<String>,
566}
567
568/// Per-run failure controls passed through to the mover `Job`.
569#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
570#[serde(rename_all = "camelCase")]
571pub struct FailurePolicy {
572 /// Mover `Job.spec.backoffLimit` — retries before a failed run is marked failed.
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub backoff_limit: Option<i32>,
575 /// Mover `Job.spec.activeDeadlineSeconds` — wall-clock cap after which a running run is killed.
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub active_deadline_seconds: Option<i64>,
578 /// Seconds a non-starting (wedged) mover pod may sit before the run is failed; default 300s.
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub pod_startup_deadline_seconds: Option<i64>,
581}
582
583/// Default grace before a non-starting (wedged) mover pod fails its run — 5 minutes.
584/// Long enough to absorb a slow image pull or a brief `Unschedulable` while an RWO volume
585/// detaches from another node, short enough that a genuinely-broken pod (e.g. an impossible
586/// securityContext, a missing image) surfaces as `Failed` fast instead of hanging for hours.
587pub const DEFAULT_POD_STARTUP_DEADLINE_SECONDS: i64 = 300;
588
589/// The effective pod-startup deadline (seconds) for a mover Job: the recipe's
590/// `failurePolicy.podStartupDeadlineSeconds`, or [`DEFAULT_POD_STARTUP_DEADLINE_SECONDS`]
591/// when unset. Shared by **every** reconciler that fast-fails a wedged mover (Snapshot,
592/// Restore, Maintenance) so the same default is applied identically on all three.
593pub fn pod_startup_deadline_seconds(failure_policy: Option<&FailurePolicy>) -> i64 {
594 failure_policy
595 .and_then(|fp| fp.pod_startup_deadline_seconds)
596 .unwrap_or(DEFAULT_POD_STARTUP_DEADLINE_SECONDS)
597}
598
599/// Reference to a `SnapshotPolicy` CR.
600#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
601#[serde(rename_all = "camelCase")]
602pub struct PolicyRef {
603 /// Name of the referenced `SnapshotPolicy`.
604 pub name: String,
605 /// Namespace of the `SnapshotPolicy`; absent = same namespace as the referrer.
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub namespace: Option<String>,
608}
609
610/// Generic name/namespace reference to another namespaced object (e.g. a `Snapshot` CR or PVC).
611#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
612#[serde(rename_all = "camelCase")]
613pub struct ObjectRef {
614 /// Name of the referenced object.
615 pub name: String,
616 /// Namespace of the referenced object; absent = same namespace as the referrer.
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub namespace: Option<String>,
619}
620
621/// A PersistentVolumeClaim access mode as a closed set — `ReadWriteOnce`,
622/// `ReadOnlyMany`, `ReadWriteMany`, `ReadWriteOncePod` — so a typo is rejected by
623/// the CRD schema itself instead of surfacing as a provisioner error at the first
624/// backup or restore run.
625///
626/// Deliberately **no `Default`** (unlike other unit enums here): everywhere this
627/// type appears, an absent/empty list means "inherit from context" — the source
628/// PVC's modes for a staged PVC, `ReadWriteOnce` for a restore-created PVC — so
629/// there is no context-free default value to name.
630///
631/// The extra [`PvcAccessMode::Unknown`] variant exists ONLY so values persisted
632/// before this field was schema-enforced still **deserialize** instead of erroring
633/// the typed watch stream for the whole Kind (one legacy CR must never wedge every
634/// other CR's reconciliation). It is hidden from the CRD schema — the apiserver
635/// rejects non-canonical strings on every new write — and
636/// [`crate::validate::validate_access_modes`] rejects it loudly per-CR with the
637/// offending value quoted.
638///
639/// ```
640/// use kopiur_api::common::PvcAccessMode;
641///
642/// // Canonical values round-trip as bare k8s strings.
643/// assert_eq!(serde_json::to_value(PvcAccessMode::ReadOnlyMany).unwrap(), "ReadOnlyMany");
644/// let m: PvcAccessMode = serde_json::from_value(serde_json::json!("ReadWriteOnce")).unwrap();
645/// assert_eq!(m, PvcAccessMode::ReadWriteOnce);
646///
647/// // A legacy/bogus stored string decodes (never a watcher-poisoning error) into
648/// // `Unknown`, preserving the value verbatim for the rejection message — and it
649/// // re-serializes to the same string, so a read-modify-write never mutates it.
650/// let m: PvcAccessMode = serde_json::from_value(serde_json::json!("ReadWriteOnze")).unwrap();
651/// assert_eq!(m, PvcAccessMode::Unknown("ReadWriteOnze".into()));
652/// assert_eq!(serde_json::to_value(&m).unwrap(), "ReadWriteOnze");
653/// ```
654#[derive(Clone, Debug, PartialEq, Eq)]
655pub enum PvcAccessMode {
656 /// Mounted read-write by a single node (`RWO`).
657 ReadWriteOnce,
658 /// Mounted read-only by many nodes (`ROX`) — e.g. a CephFS `backingSnapshot`
659 /// shallow-clone staged PVC.
660 ReadOnlyMany,
661 /// Mounted read-write by many nodes (`RWX`).
662 ReadWriteMany,
663 /// Mounted read-write by a single **pod** (`RWOP`); the apiserver requires it
664 /// to be the sole mode on a PVC.
665 ReadWriteOncePod,
666 /// A non-canonical stored value (pre-schema-enforcement legacy data). Never
667 /// admissible on a new write (not in the CRD schema); consumers reject it via
668 /// [`crate::validate::validate_access_modes`] with the value quoted, instead
669 /// of a serde error that would poison the typed watcher.
670 Unknown(String),
671}
672
673impl PvcAccessMode {
674 /// The four canonical Kubernetes access-mode strings — the CRD schema `enum`
675 /// and the "valid values" list in rejection messages, from one source.
676 pub const CANONICAL: [&'static str; 4] = [
677 "ReadWriteOnce",
678 "ReadOnlyMany",
679 "ReadWriteMany",
680 "ReadWriteOncePod",
681 ];
682
683 /// The k8s wire string (exhaustive; `Unknown` echoes the stored value verbatim).
684 pub fn mode_str(&self) -> &str {
685 match self {
686 PvcAccessMode::ReadWriteOnce => "ReadWriteOnce",
687 PvcAccessMode::ReadOnlyMany => "ReadOnlyMany",
688 PvcAccessMode::ReadWriteMany => "ReadWriteMany",
689 PvcAccessMode::ReadWriteOncePod => "ReadWriteOncePod",
690 PvcAccessMode::Unknown(s) => s,
691 }
692 }
693
694 /// Parse a **canonical** k8s access-mode string; `None` for anything else.
695 /// The migrate tool uses this to refuse a non-canonical VolSync value up
696 /// front instead of passing it through to a doomed `kubectl apply`.
697 pub fn parse(s: &str) -> Option<Self> {
698 match s {
699 "ReadWriteOnce" => Some(PvcAccessMode::ReadWriteOnce),
700 "ReadOnlyMany" => Some(PvcAccessMode::ReadOnlyMany),
701 "ReadWriteMany" => Some(PvcAccessMode::ReadWriteMany),
702 "ReadWriteOncePod" => Some(PvcAccessMode::ReadWriteOncePod),
703 _ => None,
704 }
705 }
706}
707
708impl Serialize for PvcAccessMode {
709 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
710 serializer.serialize_str(self.mode_str())
711 }
712}
713
714impl<'de> Deserialize<'de> for PvcAccessMode {
715 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
716 let s = String::deserialize(deserializer)?;
717 Ok(PvcAccessMode::parse(&s).unwrap_or(PvcAccessMode::Unknown(s)))
718 }
719}
720
721impl JsonSchema for PvcAccessMode {
722 fn schema_name() -> std::borrow::Cow<'static, str> {
723 "PvcAccessMode".into()
724 }
725 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
726 // Only the canonical values: `Unknown` is a decode-compat artifact for
727 // legacy stored data, never an admissible write.
728 schemars::json_schema!({
729 "type": "string",
730 "description": "A Kubernetes PersistentVolumeClaim access mode.",
731 "enum": PvcAccessMode::CANONICAL,
732 })
733 }
734}
735
736/// Lifecycle of the underlying kopia snapshot when its `Snapshot` CR is deleted.
737/// Produced backups default to `Delete`; discovered snapshots are forced to `Retain`.
738#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
739pub enum DeletionPolicy {
740 /// Finalizer runs `kopia snapshot delete <id>` then removes the finalizer; default for produced snapshots.
741 #[default]
742 Delete,
743 /// CR is removed; the kopia snapshot stays. Forced for discovered snapshots.
744 Retain,
745 /// CR is removed without contacting the repository at all (escape hatch).
746 Orphan,
747}
748
749/// What happens to a repository's snapshots when a consuming **namespace** is deleted; default `Orphan`.
750///
751/// ```
752/// use kopiur_api::common::NamespaceDeletePolicy;
753///
754/// // Fail-safe: a deleted namespace orphans (keeps) snapshots by default.
755/// assert_eq!(NamespaceDeletePolicy::default(), NamespaceDeletePolicy::Orphan);
756/// // Bare PascalCase strings (plain unit enum).
757/// assert_eq!(serde_json::to_value(NamespaceDeletePolicy::Delete).unwrap(), "Delete");
758/// ```
759#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
760pub enum NamespaceDeletePolicy {
761 /// Release ownership without deleting the kopia snapshots; the fail-safe default.
762 #[default]
763 Orphan,
764 /// Cascade: each `Snapshot`'s own `deletionPolicy` applies when the namespace is deleted.
765 Delete,
766}
767
768/// What the deletion of a `SnapshotSchedule` does to the `Snapshot` CRs it
769/// produced (which Kubernetes GC cascade-deletes via their ownerReference).
770/// Default `Retain`: the CRs are removed but their kopia snapshots survive and
771/// the catalog rediscovers them as `origin: discovered`. `Delete` opts into the
772/// cascade: each Snapshot's own `deletionPolicy` applies.
773///
774/// Deliberately 2-variant (not reusing [`DeletionPolicy`]): an `Orphan` in
775/// cascade position would differ from `Retain` only in per-CR event/metric
776/// bookkeeping — an invalid state made unrepresentable. The guard's `Retain`
777/// is exactly `DeletionPolicy::Retain`'s semantics (CR removed, kopia snapshot
778/// stays, catalog rediscovers it), deliberately NOT the `Orphan` event storm
779/// (no per-CR "orphaned" event/metric for every produced Snapshot).
780#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
781pub enum ScheduleDeletePolicy {
782 /// Keep the kopia snapshots: a produced Snapshot whose effective
783 /// deletionPolicy is `Delete` is downgraded to retain when its owning
784 /// schedule is gone (the fail-safe default).
785 #[default]
786 Retain,
787 /// Cascade: each produced Snapshot's own `deletionPolicy` applies even when
788 /// the owning schedule is gone (subject to the mass-deletion breaker).
789 Delete,
790}
791
792/// What the deletion of a `SnapshotPolicy` does to the `Snapshot` CRs carrying
793/// its config label (the recipe's produced/adopted rows — NOT its kopia
794/// snapshot history in the abstract, which is exactly what `Retain` preserves).
795/// Default `Retain`: the CRs are removed but every kopia snapshot survives
796/// (rediscoverable/adoptable by a future `SnapshotPolicy`, including this one
797/// re-created). `Delete` opts into the cascade: each CR's own `deletionPolicy`
798/// applies, as EXTERNAL deletions subject to the per-repository mass-deletion
799/// breaker (`deletionProtection.threshold`).
800///
801/// Deliberately 2-variant (not reusing [`DeletionPolicy`]), mirroring
802/// [`ScheduleDeletePolicy`]: an `Orphan` in cascade position would differ from
803/// `Retain` only in per-CR event/metric bookkeeping — an invalid state made
804/// unrepresentable. The guard's `Retain` is exactly `DeletionPolicy::Retain`'s
805/// semantics (CR removed, kopia snapshot stays, catalog rediscovers it),
806/// deliberately NOT the `Orphan` event storm (no per-CR "orphaned" event/metric
807/// for every one of a deleted policy's Snapshots). Not [`ScheduleDeletePolicy`]
808/// itself: that type's doc contract is schedule-specific (its `Delete` arm talks
809/// about a *schedule* being gone/replaced), and a `SnapshotPolicy` deletion is a
810/// distinct trigger with its own semantics worth documenting on its own type.
811#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
812pub enum PolicyDeletePolicy {
813 /// Keep the kopia snapshots: a Snapshot whose effective deletionPolicy is
814 /// `Delete` is downgraded to retain when its owning `SnapshotPolicy` is gone
815 /// (the fail-safe default).
816 #[default]
817 Retain,
818 /// Cascade: each Snapshot's own `deletionPolicy` applies even though the
819 /// owning `SnapshotPolicy` is gone (subject to the mass-deletion breaker).
820 Delete,
821}
822
823/// Mass-deletion circuit breaker for this repository's Snapshots.
824#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
825#[serde(rename_all = "camelCase")]
826pub struct DeletionProtectionSpec {
827 /// Pending EXTERNAL destructive Snapshot deletions (deletionTimestamp set,
828 /// effective deletionPolicy Delete, not operator-pruned) that trip the
829 /// breaker for this repository: at or above this, those deletions are HELD
830 /// (finalizers wait) until acknowledged via the
831 /// `kopiur.home-operations.com/allow-mass-deletion` annotation.
832 /// `0` disables the breaker. Default 10.
833 #[serde(default, skip_serializing_if = "Option::is_none")]
834 #[schemars(default = "default_mass_deletion_threshold")]
835 pub threshold: Option<u32>,
836}
837
838/// schemars default for [`DeletionProtectionSpec::threshold`] —
839/// [`DEFAULT_MASS_DELETION_THRESHOLD`](crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
840/// (`10`), matching `effective_mass_deletion_threshold`'s absent→CONST
841/// resolution. Returns the field's `Option` type so schemars 1 emits the
842/// schema `default:`.
843fn default_mass_deletion_threshold() -> Option<u32> {
844 Some(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
845}
846
847/// Concurrency limits for mover Jobs against this repository.
848#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
849#[serde(rename_all = "camelCase")]
850pub struct ConcurrencySpec {
851 /// Ceiling on how many of this repository's mover Jobs may be in flight at
852 /// once. Backup snapshots, restores, and replication runs that READ FROM this
853 /// repository all draw from ONE pool — a repository's backend (and the
854 /// bandwidth to it) is the shared resource, so splitting the budget per work
855 /// kind would let three "safe" limits still saturate it.
856 ///
857 /// Restores are **always admitted** and never parked: a restore is a recovery
858 /// in progress, and holding one behind a queue of routine backups is exactly
859 /// backwards. An in-flight restore still COUNTS toward the pool, so it
860 /// displaces backups rather than adding to them.
861 ///
862 /// Excluded from the pool entirely: maintenance (already single-flight per
863 /// repository), verification, pin, and snapshot-delete batch Jobs. These are
864 /// operator-driven housekeeping that must not be starved by a saturated
865 /// backup pool. Delete batches have their own cap, `maxConcurrentDeleteJobs`.
866 ///
867 /// Absent or `0` means unlimited — the default, and today's behavior.
868 ///
869 /// No schema `default:` is emitted for this field (api-conventions §4a):
870 /// absent and `0` are the SAME state (unlimited), so a materialized default
871 /// would stamp `{maxConcurrentJobs: 0}` onto every stored repository — GitOps
872 /// diff noise for a value that changes nothing.
873 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub max_concurrent_jobs: Option<u32>,
875
876 /// Ceiling on how many snapshot-delete batch Jobs may run against this
877 /// repository at once. **Absent means 1**: one batch at a time, and the next
878 /// batch starts when the current one finishes.
879 ///
880 /// A batch Job deletes hundreds of snapshots in a single kopia call. kopia
881 /// serializes manifest deletes on the repository's index anyway, so running
882 /// several batches in parallel adds no throughput. Each extra Job only loads
883 /// the full index again against the same backend (issue #477). Raise it only
884 /// for a fast local backend with a very large deletion backlog.
885 ///
886 /// Must be at least 1. There is no "unlimited" value. Omit the field for the
887 /// default. The controller-wide `maxConcurrentDeleteJobs` Helm value still
888 /// caps delete Jobs across ALL repositories on top of this.
889 ///
890 /// No schema `default:` is emitted (api-conventions §4a); the controller
891 /// resolves absent to 1, so stored repositories carry no diff noise.
892 #[serde(default, skip_serializing_if = "Option::is_none")]
893 #[schemars(range(min = 1))]
894 pub max_concurrent_delete_jobs: Option<u32>,
895}
896
897/// Repository access mode; `ReadOnly` serves restores only (no backups, no maintenance).
898///
899/// ```
900/// use kopiur_api::common::RepositoryMode;
901///
902/// assert_eq!(RepositoryMode::default(), RepositoryMode::ReadWrite);
903/// assert_eq!(serde_json::to_value(RepositoryMode::ReadOnly).unwrap(), "ReadOnly");
904/// // ReadOnly forbids writes (backups + maintenance); restores are allowed.
905/// assert!(!RepositoryMode::ReadOnly.allows_writes());
906/// assert!(RepositoryMode::ReadWrite.allows_writes());
907/// ```
908#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
909pub enum RepositoryMode {
910 /// Normal read-write repository (default): backups, restores, maintenance.
911 #[default]
912 ReadWrite,
913 /// Read-only: restores only. Backup Jobs and maintenance are refused.
914 ReadOnly,
915}
916
917impl RepositoryMode {
918 /// Whether this mode permits write operations (backup Jobs + maintenance).
919 /// Pure + exhaustive so the single definition lives in one tested place.
920 pub fn allows_writes(&self) -> bool {
921 match self {
922 RepositoryMode::ReadWrite => true,
923 RepositoryMode::ReadOnly => false,
924 }
925 }
926}
927
928/// serde/schemars `default` for the repository `mode` field — `ReadWrite`
929/// (ADR-0005 §11). Named fn so it backs BOTH serde + schemars defaults.
930pub(crate) fn default_repository_mode() -> RepositoryMode {
931 RepositoryMode::ReadWrite
932}
933
934/// serde/schemars `default` for the repository `on_namespace_delete` field —
935/// `Orphan` (ADR-0005 §5). A named fn so it backs BOTH `#[serde(default = ...)]`
936/// and `#[schemars(default = ...)]`, emitting a real OpenAPI `default:`.
937pub(crate) fn default_namespace_delete_policy() -> NamespaceDeletePolicy {
938 NamespaceDeletePolicy::Orphan
939}
940
941/// A single cron entry with optional deterministic jitter.
942#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
943#[serde(rename_all = "camelCase")]
944pub struct CronSpec {
945 /// The cron expression, parsed by `croner`; may contain an `H` placeholder for deterministic jitter.
946 pub cron: String,
947 /// Optional deterministic jitter window as a Go-style duration string (e.g. `30m`).
948 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub jitter: Option<String>,
950 /// IANA timezone the cron is evaluated in (e.g. `America/Chicago`); absent uses
951 /// the enclosing schedule's timezone, else the controller default (UTC).
952 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub timezone: Option<String>,
954}
955
956/// Resolve an optional IANA timezone name to a concrete zone, defaulting to UTC.
957/// An unparseable name falls back to UTC defensively — the admission webhook rejects
958/// bad names up front via `validate::validate_timezone`, so reconcile-time resolution
959/// should never see one.
960pub fn resolve_tz(name: Option<&str>) -> chrono_tz::Tz {
961 name.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
962 .unwrap_or(chrono_tz::Tz::UTC)
963}
964
965/// Repo-level scheduling defaults, inherited at reconcile time by consumers that
966/// don't set their own equivalent field (ADR §2.2 principle 10: sub-object, not a
967/// leaf field, so a new default slots in without API breakage — which is exactly
968/// how `jitter` joined `timezone` here).
969///
970/// Consumed by `SnapshotPolicy` verification, `RepositoryReplication`,
971/// `Maintenance` scheduling, and `SnapshotSchedule` (the recurring-backup cron) —
972/// all of which resolve their repository in-reconciler via
973/// [`crate::common::RepositoryRef`]. The `SnapshotSchedule` consumer resolves its
974/// target policy's repository default at slot-computation time (see
975/// [`effective_timezone`] / [`effective_schedule_jitter`]) and is re-triggered by
976/// a repository referent watch.
977#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
978#[serde(rename_all = "camelCase")]
979pub struct ScheduleDefaults {
980 /// IANA timezone name applied to every consuming cron that doesn't set its own
981 /// `timezone` (e.g. `America/New_York`). Set once here instead of repeating it
982 /// on every `SnapshotPolicy.verification`, `RepositoryReplication.schedule`, and
983 /// `Maintenance.schedule` cron.
984 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub timezone: Option<String>,
986 /// Deterministic jitter window (Go-style duration, e.g. `10m`) applied to every
987 /// consuming cron that doesn't set its own `jitter` — `SnapshotSchedule`,
988 /// `Maintenance` (quick and full), `SnapshotPolicy` verification (quick and
989 /// deep), and both replication kinds. Set once here instead of repeating it on
990 /// every cron, so a whole repository's schedules spread their load off the
991 /// top-of-the-hour thundering herd.
992 ///
993 /// The spread is deterministic per `(scheduleUID, slot)`, not random: the same
994 /// slot always lands on the same instant, so a restart never re-rolls it.
995 /// Capped at 24h by validation — jitter is a spread WITHIN a cron period, not a
996 /// schedule offset.
997 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub jitter: Option<String>,
999}
1000
1001/// **Pure.** Resolve a consuming cron's own optional `jitter` against a
1002/// repository-level `scheduleDefaults.jitter`: `own` wins when set, else
1003/// `repo_default`, else `None` (no jitter). Mirrors [`resolve_tz_with_default`]'s
1004/// shape for the timezone half of `scheduleDefaults`, minus a built-in fallback —
1005/// there is no "default jitter", absent simply means no spread.
1006///
1007/// The returned string is still the user's raw text; the scheduler parses it with
1008/// `crate::duration::parse_go_duration` exactly as it does an own-set value, and
1009/// the admission webhook has already rejected an unparseable or over-24h value at
1010/// BOTH levels (`validate_jitter` + `validate_jitter_bounds`).
1011///
1012/// ```
1013/// use kopiur_api::common::effective_jitter;
1014///
1015/// // The schedule's own jitter wins, even over a repo default.
1016/// assert_eq!(effective_jitter(Some("5m"), Some("1h")).as_deref(), Some("5m"));
1017/// // Absent own jitter inherits the repo default.
1018/// assert_eq!(effective_jitter(None, Some("1h")).as_deref(), Some("1h"));
1019/// // Both absent → no jitter.
1020/// assert_eq!(effective_jitter(None, None), None);
1021/// ```
1022pub fn effective_jitter(own: Option<&str>, repo_default: Option<&str>) -> Option<String> {
1023 own.or(repo_default).map(str::to_string)
1024}
1025
1026/// **Pure.** Decide the effective jitter window for a `SnapshotSchedule`, given the
1027/// schedule's own `spec.schedule.jitter` (`own`) and the `scheduleDefaults.jitter`
1028/// of each *matched* target policy's repository (`candidates`, one entry per
1029/// matched policy; `None` = that repo sets no default).
1030///
1031/// The jitter counterpart of [`effective_timezone`], and deliberately the same
1032/// fan-out shape — but it returns a bare `Option<String>` rather than a
1033/// `(value, ambiguity)` pair, because there is no fallback value to report an
1034/// ambiguity *against*: disagreement resolves to "no jitter", which is exactly what
1035/// `None` already means. The caller logs the warn; this function just resolves.
1036///
1037/// Rules (the reconciler does the GETs and passes the data in):
1038/// - `own` set → that window wins, no lookups.
1039/// - `own` unset, **no** matched policies → `None`.
1040/// - `own` unset, all matched policies agree → that value (which may itself be
1041/// `None`, i.e. no repo sets a default).
1042/// - `own` unset, matched policies disagree → `None` (no jitter). Mixing "a window"
1043/// with "no default" is a genuine disagreement, same as [`effective_timezone`].
1044///
1045/// A single `policyRef` therefore never disagrees (one repository).
1046///
1047/// ```
1048/// use kopiur_api::common::effective_schedule_jitter;
1049///
1050/// // Own jitter wins outright.
1051/// assert_eq!(effective_schedule_jitter(Some("5m"), &[]).as_deref(), Some("5m"));
1052///
1053/// // Unset own, one agreeing default across matched policies.
1054/// let defs = [Some("1h".to_string()), Some("1h".to_string())];
1055/// assert_eq!(effective_schedule_jitter(None, &defs).as_deref(), Some("1h"));
1056///
1057/// // Unset own, disagreeing defaults → no jitter.
1058/// let defs = [Some("1h".to_string()), None];
1059/// assert_eq!(effective_schedule_jitter(None, &defs), None);
1060/// ```
1061pub fn effective_schedule_jitter(
1062 own: Option<&str>,
1063 candidates: &[Option<String>],
1064) -> Option<String> {
1065 match resolve_schedule_jitter(own, candidates) {
1066 ScheduleJitterResolution::Agreed(window) => window,
1067 ScheduleJitterResolution::Disagreed { .. } => None,
1068 }
1069}
1070
1071/// The outcome of [`resolve_schedule_jitter`] — the fan-out jitter resolution
1072/// WITH the ambiguity signal [`effective_schedule_jitter`] throws away.
1073///
1074/// `effective_schedule_jitter` collapses a disagreement to `None`, which is
1075/// indistinguishable from "everyone agreed there is no jitter" — so a caller that
1076/// wants to warn an operator about the disagreement cannot see it. This enum is
1077/// the resolver's real return type; the `Option`-returning function delegates to
1078/// it for the callers that genuinely don't care. Exhaustive `match` at the call
1079/// site is what makes "we resolved to no jitter" and "we gave up on disagreeing
1080/// defaults" two decisions instead of one.
1081#[derive(Debug, Clone, PartialEq, Eq)]
1082pub enum ScheduleJitterResolution {
1083 /// Every candidate agreed (or `own` won outright, or there was nothing to
1084 /// inherit): this is the effective window, `None` meaning no jitter.
1085 Agreed(Option<String>),
1086 /// Matched policies' repositories set *differing* `scheduleDefaults.jitter`
1087 /// values, so no window can be chosen unambiguously — the effective window is
1088 /// `None` (no jitter) and the caller should warn, recommending an explicit
1089 /// `spec.schedule.jitter`.
1090 Disagreed {
1091 /// The distinct candidates (sorted) that disagreed, for the log message.
1092 /// A repository setting no default renders as `(none)` — mixing "a window"
1093 /// with "no default" is a genuine disagreement, so it must be visible.
1094 candidates: Vec<String>,
1095 },
1096}
1097
1098/// **Pure.** The jitter counterpart of [`effective_timezone`], reporting
1099/// disagreement instead of silently swallowing it (see
1100/// [`ScheduleJitterResolution`]). `own` is the schedule's own
1101/// `spec.schedule.jitter`; `candidates` carries one entry per matched target
1102/// policy repository (`None` = that repository sets no default).
1103///
1104/// Rules (the reconciler does the GETs and passes the data in):
1105/// - `own` set → that window wins, no lookups, never ambiguous.
1106/// - `own` unset, **no** candidates → `Agreed(None)`.
1107/// - `own` unset, all candidates equal → `Agreed(that value)` (possibly `None`).
1108/// - `own` unset, candidates differ → `Disagreed` (effective window: no jitter).
1109///
1110/// A single `policyRef` over a single-repository policy therefore never disagrees.
1111///
1112/// ```
1113/// use kopiur_api::common::{ScheduleJitterResolution, resolve_schedule_jitter};
1114///
1115/// // Own jitter wins outright.
1116/// assert_eq!(
1117/// resolve_schedule_jitter(Some("5m"), &[]),
1118/// ScheduleJitterResolution::Agreed(Some("5m".to_string())),
1119/// );
1120/// // Unset own, one agreeing default across matched policies.
1121/// let defs = [Some("1h".to_string()), Some("1h".to_string())];
1122/// assert_eq!(
1123/// resolve_schedule_jitter(None, &defs),
1124/// ScheduleJitterResolution::Agreed(Some("1h".to_string())),
1125/// );
1126/// // Unset own, disagreeing defaults → reported, not silently dropped.
1127/// let defs = [Some("1h".to_string()), None];
1128/// assert_eq!(
1129/// resolve_schedule_jitter(None, &defs),
1130/// ScheduleJitterResolution::Disagreed {
1131/// candidates: vec!["(none)".to_string(), "1h".to_string()],
1132/// },
1133/// );
1134/// ```
1135pub fn resolve_schedule_jitter(
1136 own: Option<&str>,
1137 candidates: &[Option<String>],
1138) -> ScheduleJitterResolution {
1139 if let Some(own) = own {
1140 return ScheduleJitterResolution::Agreed(Some(own.to_string()));
1141 }
1142 let Some(first) = candidates.first() else {
1143 // Nothing matched → nothing to inherit.
1144 return ScheduleJitterResolution::Agreed(None);
1145 };
1146 if candidates.iter().all(|c| c == first) {
1147 return ScheduleJitterResolution::Agreed(first.clone());
1148 }
1149 let mut distinct: Vec<String> = candidates
1150 .iter()
1151 .map(|c| {
1152 c.clone()
1153 .unwrap_or_else(|| JITTER_NONE_CANDIDATE.to_string())
1154 })
1155 .collect();
1156 distinct.sort();
1157 distinct.dedup();
1158 ScheduleJitterResolution::Disagreed {
1159 candidates: distinct,
1160 }
1161}
1162
1163/// How "this repository sets no `scheduleDefaults.jitter`" renders in a
1164/// [`ScheduleJitterResolution::Disagreed`] candidate list.
1165const JITTER_NONE_CANDIDATE: &str = "(none)";
1166
1167/// Resolve a consuming cron's own optional IANA timezone against a repository-level
1168/// default, falling back to UTC (mirrors [`resolve_tz`]): `own` wins when set, else
1169/// `repo_default` (typically `Repository`/`ClusterRepository`
1170/// `spec.scheduleDefaults.timezone`), else UTC. An unparseable name at whichever
1171/// level is selected falls back to UTC defensively, same as `resolve_tz` — the
1172/// admission webhook rejects bad names up front for both levels via
1173/// `validate::validate_timezone`, so reconcile-time resolution should never see one.
1174///
1175/// ```
1176/// use kopiur_api::common::resolve_tz_with_default;
1177///
1178/// // The schedule's own timezone wins, even over a repo default.
1179/// assert_eq!(
1180/// resolve_tz_with_default(Some("America/Chicago"), Some("UTC")),
1181/// "America/Chicago".parse::<chrono_tz::Tz>().unwrap(),
1182/// );
1183/// // Absent own timezone falls through to the repo default.
1184/// assert_eq!(
1185/// resolve_tz_with_default(None, Some("America/New_York")),
1186/// "America/New_York".parse::<chrono_tz::Tz>().unwrap(),
1187/// );
1188/// // Both absent → UTC.
1189/// assert_eq!(resolve_tz_with_default(None, None), chrono_tz::Tz::UTC);
1190/// ```
1191pub fn resolve_tz_with_default(own: Option<&str>, repo_default: Option<&str>) -> chrono_tz::Tz {
1192 resolve_tz(own.or(repo_default))
1193}
1194
1195/// Matched `SnapshotSchedule` target policies disagreed on their repositories'
1196/// `scheduleDefaults.timezone`, so [`effective_timezone`] could not pick one
1197/// unambiguously and fell back to UTC. The controller surfaces this as a status
1198/// condition recommending an explicit `spec.schedule.timezone`.
1199///
1200/// This case only arises for the `policySelector` fan-out form (a single
1201/// `policyRef` has exactly one repository, so it can never disagree with itself).
1202#[derive(Debug, Clone, PartialEq, Eq)]
1203pub struct TimezoneAmbiguity {
1204 /// The distinct candidate zones (IANA names, sorted) that disagreed, for the
1205 /// human-readable condition message.
1206 pub candidates: Vec<String>,
1207}
1208
1209/// **Pure.** Decide the effective timezone a `SnapshotSchedule`'s cron is
1210/// evaluated in, given the schedule's own `spec.schedule.timezone` (`own`) and the
1211/// `scheduleDefaults.timezone` of each *matched* target policy's repository
1212/// (`policy_repo_defaults`, one entry per matched policy; `None` = that repo sets
1213/// no default). Mirrors [`resolve_tz`] fallback semantics (an unparseable name
1214/// degrades to UTC — the webhook rejects bad names up front).
1215///
1216/// Rules (the reconciler does the GETs and passes the data in):
1217/// - `own` set → that zone wins, no lookups, never ambiguous.
1218/// - `own` unset, **no** matched policies → UTC, not ambiguous.
1219/// - `own` unset, all matched policies resolve to **one** zone → that zone.
1220/// - `own` unset, matched policies resolve to **differing** zones → UTC plus a
1221/// [`TimezoneAmbiguity`] (recommend an explicit `spec.schedule.timezone`).
1222///
1223/// A single `policyRef` therefore never yields ambiguity (one repository). Repos
1224/// with no default resolve to UTC, so mixing "a zone" with "no default" is a
1225/// genuine disagreement and is reported.
1226///
1227/// ```
1228/// use kopiur_api::common::effective_timezone;
1229///
1230/// // Own timezone wins outright.
1231/// let (tz, amb) = effective_timezone(Some("America/Chicago"), &[]);
1232/// assert_eq!(tz.name(), "America/Chicago");
1233/// assert!(amb.is_none());
1234///
1235/// // Unset own, one agreeing default across matched policies.
1236/// let defs = [Some("Europe/Berlin".to_string()), Some("Europe/Berlin".to_string())];
1237/// let (tz, amb) = effective_timezone(None, &defs);
1238/// assert_eq!(tz.name(), "Europe/Berlin");
1239/// assert!(amb.is_none());
1240///
1241/// // Unset own, disagreeing defaults → UTC + ambiguity signal.
1242/// let defs = [Some("Europe/Berlin".to_string()), None];
1243/// let (tz, amb) = effective_timezone(None, &defs);
1244/// assert_eq!(tz, chrono_tz::Tz::UTC);
1245/// assert!(amb.is_some());
1246/// ```
1247pub fn effective_timezone(
1248 own: Option<&str>,
1249 policy_repo_defaults: &[Option<String>],
1250) -> (chrono_tz::Tz, Option<TimezoneAmbiguity>) {
1251 if own.is_some() {
1252 return (resolve_tz(own), None);
1253 }
1254 // No matched policies → nothing to inherit from.
1255 if policy_repo_defaults.is_empty() {
1256 return (chrono_tz::Tz::UTC, None);
1257 }
1258 // Resolve each matched policy's repo default to a concrete zone, then reduce to
1259 // the distinct set. A repo with no default resolves to UTC (via `resolve_tz`),
1260 // so it can legitimately disagree with a repo that sets one.
1261 let mut zones: Vec<chrono_tz::Tz> = policy_repo_defaults
1262 .iter()
1263 .map(|d| resolve_tz(d.as_deref()))
1264 .collect();
1265 zones.sort_by(|a, b| a.name().cmp(b.name()));
1266 zones.dedup();
1267 if zones.len() == 1 {
1268 (zones[0], None)
1269 } else {
1270 let candidates = zones.iter().map(|z| z.name().to_string()).collect();
1271 (chrono_tz::Tz::UTC, Some(TimezoneAmbiguity { candidates }))
1272 }
1273}
1274
1275impl RepositoryRef {
1276 /// True if this reference points at the given repository.
1277 ///
1278 /// `owner_namespace` is the namespace of the resource that holds the ref
1279 /// (e.g. the `Maintenance` CR's own namespace), used to resolve a namespaced
1280 /// `Repository` reference that omits `namespace`. The match is exhaustive over
1281 /// [`RepositoryKind`] (ADR §5.5):
1282 ///
1283 /// - [`RepositoryKind::Repository`]: kind+name must match AND the effective
1284 /// namespace (`self.namespace` or `owner_namespace`) must equal
1285 /// `target_namespace`.
1286 /// - [`RepositoryKind::ClusterRepository`]: kind+name must match; namespace is
1287 /// ignored on both sides (cluster-scoped).
1288 ///
1289 /// `target_namespace` is `None` for a `ClusterRepository` target.
1290 ///
1291 /// ```
1292 /// use kopiur_api::common::{RepositoryKind, RepositoryRef};
1293 ///
1294 /// // A namespaced ref that omits `namespace` resolves against the owner's namespace.
1295 /// let r = RepositoryRef { kind: RepositoryKind::Repository, name: "nas".into(), namespace: None };
1296 /// assert!(r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("apps")));
1297 /// assert!(!r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("other")));
1298 ///
1299 /// // A cluster-scoped target ignores namespace entirely.
1300 /// let cr = RepositoryRef {
1301 /// kind: RepositoryKind::ClusterRepository,
1302 /// name: "hetzner".into(),
1303 /// namespace: None,
1304 /// };
1305 /// assert!(cr.resolves_to("apps", RepositoryKind::ClusterRepository, "hetzner", None));
1306 /// // Kind must match even when names collide.
1307 /// assert!(!r.resolves_to("apps", RepositoryKind::ClusterRepository, "nas", None));
1308 /// ```
1309 pub fn resolves_to(
1310 &self,
1311 owner_namespace: &str,
1312 target_kind: RepositoryKind,
1313 target_name: &str,
1314 target_namespace: Option<&str>,
1315 ) -> bool {
1316 if self.kind != target_kind || self.name != target_name {
1317 return false;
1318 }
1319 match self.kind {
1320 RepositoryKind::Repository => {
1321 Some(self.namespace.as_deref().unwrap_or(owner_namespace)) == target_namespace
1322 }
1323 RepositoryKind::ClusterRepository => true,
1324 }
1325 }
1326}
1327
1328/// Parse the `run-requested` annotation into the pinned request instant.
1329///
1330/// `Ok(None)` = no request; `Err` = the annotation is present but not an
1331/// RFC3339 timestamp (the message says how to fix it). The one timestamp
1332/// parser behind every "run it now" surface — `Maintenance`'s
1333/// [`crate::maintenance::parse_run_annotations`] delegates here, and both
1334/// replication kinds call it directly — so admission and the reconcilers can
1335/// never disagree about what a request *is* (SKILL "one validator, two
1336/// callers").
1337///
1338/// `run_command` is the kind's own `kubectl kopiur … run` invocation, quoted
1339/// verbatim in the fix hint: the annotation is identical across kinds but the
1340/// command that stamps it is not, and a fix hint naming the wrong command is
1341/// worse than none.
1342///
1343/// ```
1344/// use kopiur_api::common::parse_run_requested_at;
1345/// use std::collections::BTreeMap;
1346///
1347/// assert_eq!(parse_run_requested_at(None, "kubectl kopiur replication run"), Ok(None));
1348///
1349/// let mut a = BTreeMap::new();
1350/// a.insert(
1351/// kopiur_api::consts::RUN_REQUESTED_ANNOTATION.to_string(),
1352/// "2026-06-11T12:00:00Z".to_string(),
1353/// );
1354/// let at = parse_run_requested_at(Some(&a), "kubectl kopiur replication run")
1355/// .unwrap()
1356/// .unwrap();
1357/// assert_eq!(at.to_rfc3339(), "2026-06-11T12:00:00+00:00");
1358///
1359/// a.insert(
1360/// kopiur_api::consts::RUN_REQUESTED_ANNOTATION.to_string(),
1361/// "yesterday".to_string(),
1362/// );
1363/// let err = parse_run_requested_at(Some(&a), "kubectl kopiur replication run").unwrap_err();
1364/// assert!(err.contains("must be an RFC3339 timestamp"));
1365/// assert!(err.contains("kubectl kopiur replication run"));
1366/// ```
1367pub fn parse_run_requested_at(
1368 annotations: Option<&std::collections::BTreeMap<String, String>>,
1369 run_command: &str,
1370) -> Result<Option<chrono::DateTime<chrono::Utc>>, String> {
1371 let Some(raw) = annotations.and_then(|a| a.get(crate::consts::RUN_REQUESTED_ANNOTATION)) else {
1372 return Ok(None);
1373 };
1374 let at = chrono::DateTime::parse_from_rfc3339(raw)
1375 .map_err(|e| {
1376 format!(
1377 "annotation {} must be an RFC3339 timestamp (got {raw:?}): {e}. \
1378 Fix: re-annotate with e.g. $(date -u +%Y-%m-%dT%H:%M:%SZ), or use \
1379 `{run_command}`",
1380 crate::consts::RUN_REQUESTED_ANNOTATION
1381 )
1382 })?
1383 .with_timezone(&chrono::Utc);
1384 Ok(Some(at))
1385}
1386
1387/// Lifecycle of an annotation-requested (on-demand) replication run, shared by
1388/// `RepositoryReplication` and `SnapshotReplication`.
1389///
1390/// **Closed on the wire, open on decode** — the same contract every kopiur
1391/// phase carries (see the `phase_serde!` macro): the CRD schema admits exactly
1392/// `Pending`/`Running`/`Succeeded`/`Failed`, and [`Self::Unknown`] exists only
1393/// so a value written by a NEWER kopiur decodes instead of failing the typed
1394/// watch for the whole Kind.
1395///
1396/// It has a `Pending` variant that [`crate::ManualRunPhase`] does not: a
1397/// replication can be `suspend: true` when the request lands, and a request
1398/// that cannot start yet must be VISIBLE rather than silently queued.
1399///
1400/// ```
1401/// use kopiur_api::common::ReplicationManualRunPhase;
1402///
1403/// assert_eq!(serde_json::to_value(ReplicationManualRunPhase::Pending).unwrap(), "Pending");
1404/// // An unrecognized phase from a newer operator decodes instead of erroring.
1405/// let p: ReplicationManualRunPhase = serde_json::from_value(serde_json::json!("Queued")).unwrap();
1406/// assert_eq!(p, ReplicationManualRunPhase::Unknown("Queued".into()));
1407/// assert_eq!(serde_json::to_value(&p).unwrap(), "Queued");
1408/// ```
1409#[derive(Clone, Debug, PartialEq, Eq)]
1410pub enum ReplicationManualRunPhase {
1411 /// The request is recorded but no Job can start yet (the replication is
1412 /// `suspend: true`); it runs when the block clears.
1413 Pending,
1414 /// The mover Job for this request is in flight.
1415 Running,
1416 /// The requested run completed successfully.
1417 Succeeded,
1418 /// The requested run's Job failed; conditions carry the detail.
1419 Failed,
1420 /// A phase string this build does not recognize (newer operator, or legacy
1421 /// stored data). Decode-compat only — hidden from the CRD schema, never
1422 /// produced by this build. Never counted as a finished run, so a re-run
1423 /// request is never deduped against it.
1424 Unknown(String),
1425}
1426
1427crate::common::phase_serde!(
1428 ReplicationManualRunPhase,
1429 "Lifecycle of a manual replication run. Closed enum."
1430);
1431
1432impl PhaseLabel for ReplicationManualRunPhase {
1433 const ALL: &'static [Self] = &[Self::Pending, Self::Running, Self::Succeeded, Self::Failed];
1434 fn label(&self) -> &str {
1435 match self {
1436 Self::Pending => "Pending",
1437 Self::Running => "Running",
1438 Self::Succeeded => "Succeeded",
1439 Self::Failed => "Failed",
1440 Self::Unknown(s) => s,
1441 }
1442 }
1443 fn unknown(raw: String) -> Self {
1444 Self::Unknown(raw)
1445 }
1446}
1447
1448impl ReplicationManualRunPhase {
1449 /// Whether this is the decode-compat [`Self::Unknown`] sentinel. The
1450 /// exhaustive `match` lives here so a variant added later cannot be
1451 /// silently classified by an `if let … Unknown(_)` probe elsewhere.
1452 pub fn is_unknown(&self) -> bool {
1453 match self {
1454 Self::Unknown(_) => true,
1455 Self::Pending | Self::Running | Self::Succeeded | Self::Failed => false,
1456 }
1457 }
1458
1459 /// Whether this phase says a mover Job for the request EXISTS OR EXISTED.
1460 ///
1461 /// The controller's "the Job vanished before its outcome was observed"
1462 /// check: `Running` with no Job present means a TTL reap beat the
1463 /// reconcile, and re-running side-effectful work silently would be worse
1464 /// than reporting the lost outcome. Exhaustive rather than an equality so a
1465 /// phase added later must state whether it implies a Job was launched —
1466 /// under `==` the new variant would silently take the "no Job ever ran"
1467 /// branch.
1468 pub fn implies_job_launched(&self) -> bool {
1469 match self {
1470 Self::Running => true,
1471 Self::Pending | Self::Succeeded | Self::Failed | Self::Unknown(_) => false,
1472 }
1473 }
1474
1475 /// Whether this phase ANSWERS the request it is recorded against — i.e. the
1476 /// run reached a terminal outcome, so re-applying the same `run-requested`
1477 /// value is a no-op.
1478 ///
1479 /// Exhaustive on purpose: this predicate decides whether a user's run
1480 /// request is DROPPED, so a new phase must state whether it counts as an
1481 /// answer. `Unknown` is deliberately NOT an answer — re-driving is
1482 /// idempotent (the Job name is keyed on the request timestamp), whereas
1483 /// dropping the request loses the run.
1484 pub fn answers_request(&self) -> bool {
1485 match self {
1486 Self::Succeeded | Self::Failed => true,
1487 Self::Pending | Self::Running | Self::Unknown(_) => false,
1488 }
1489 }
1490}
1491
1492/// Bookkeeping for the most recent annotation-requested replication run, shared
1493/// by `RepositoryReplication` and `SnapshotReplication` (`status.manualRun`).
1494///
1495/// Deliberately narrower than `Maintenance`'s [`crate::ManualRunStatus`]: a
1496/// replication has no `run-mode` (there is exactly one kind of run), so there
1497/// is no `mode` field to record.
1498#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
1499#[serde(rename_all = "camelCase")]
1500pub struct ReplicationManualRunStatus {
1501 /// The `run-requested` annotation value this status reflects (RFC3339),
1502 /// verbatim as the user wrote it — it pins WHICH request is answered.
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1504 pub requested_at: Option<String>,
1505 /// Where the requested run is in its lifecycle.
1506 #[serde(default, skip_serializing_if = "Option::is_none")]
1507 pub phase: Option<ReplicationManualRunPhase>,
1508 /// RFC3339 instant the run reached a terminal phase.
1509 // Deliberately serialized EVEN WHEN `None` — no `skip_serializing_if`, unlike
1510 // every other optional in this struct. A non-terminal phase must emit
1511 // `"completedAt": null` so the merge-patch the controller sends CLEARS the
1512 // previous run's stamp. On receiving that null the apiserver either deletes
1513 // the key (plain RFC-7386) or stores the null verbatim — a nullable CRD
1514 // field on k8s 1.33 was observed doing the latter — and BOTH converge here:
1515 // an absent key decodes to `None`, an explicit null decodes to `None`, and
1516 // re-serializing either yields `null` again.
1517 //
1518 // Omitting the key instead leaves the old instant standing, which is both a
1519 // lie in `kubectl get -o yaml` and — because the replication controllers
1520 // build the noop guard's `current` by re-serializing this very struct — a
1521 // guard that never converges: `desired` would omit the key while `current`
1522 // carries the stale value, so every queued pass re-fires a PATCH the
1523 // apiserver no-ops (#394).
1524 //
1525 // This depends on `patch_status` sending `kube::api::Patch::Merge`
1526 // (`crates/controller/src/io/apply.rs`). Under `Patch::Apply` an explicit
1527 // null does NOT clear the field, and this contract silently breaks.
1528 //
1529 // Kept a plain comment rather than rustdoc on purpose: doc comments become
1530 // the CRD `description` (`kubectl explain`, docs/src/content/docs/field-reference.md), and
1531 // serde mechanics are not the user's business.
1532 #[serde(default)]
1533 pub completed_at: Option<String>,
1534}
1535
1536impl ReplicationManualRunStatus {
1537 /// Does this status terminally answer the request annotated as `raw`?
1538 /// Both halves matter: the pinned `requestedAt` must be exactly this
1539 /// request, AND its phase must answer it.
1540 pub fn answers(&self, raw: &str) -> bool {
1541 self.requested_at.as_deref() == Some(raw)
1542 && self
1543 .phase
1544 .as_ref()
1545 .is_some_and(ReplicationManualRunPhase::answers_request)
1546 }
1547}
1548
1549#[cfg(test)]
1550mod tests;