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