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 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;