Skip to main content

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