Skip to main content

kopiur_api/
repository.rs

1//! The `Repository` CRD — a namespaced kopia repository. ADR-0003 §3.1.
2
3use crate::backend::Backend;
4use crate::common::{
5    CatalogBounds, ConcurrencySpec, CreateBehavior, DeletionProtectionSpec, Encryption,
6    FailurePolicy, IdentityDefaults, MoverDefaults, NamespaceDeletePolicy, RepositoryMode,
7    ScheduleDefaults, default_namespace_delete_policy, default_repository_mode,
8};
9use crate::maintenance::RepositoryMaintenanceSpec;
10use crate::seed::{SeedSpec, SeedStatus};
11use crate::server::{ServerSpec, ServerStatus};
12use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
13use kube::CustomResource;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// A kopia repository owned by one namespace, referenced by `SnapshotPolicy`s and `Restore`s.
18#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
19#[kube(
20    group = "kopiur.home-operations.com",
21    version = "v1alpha1",
22    kind = "Repository",
23    namespaced,
24    status = "RepositoryStatus",
25    shortname = "kopiarepo",
26    category = "kopiur",
27    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
28    printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
29    printcolumn = r#"{"name":"Server","type":"string","jsonPath":".status.server.endpoint"}"#,
30    printcolumn = r#"{"name":"IndexBlobs","type":"integer","jsonPath":".status.storageStats.indexBlobCount","priority":1}"#,
31    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
32)]
33// §7/§15: create-time-immutability transition rules in the CRD schema (apiserver +
34// CI), complementing the webhook checks. The `create.*` rules only bite when `create`
35// is present on both sides. `encryption` (the password Secret reference) is deliberately
36// NOT locked: kopia fixes only the resolved password value in the repo format, never the
37// Secret name/key, and the reference is not a reliable proxy (a rename with identical
38// content must not be rejected — that broke GitOps). See `validate::diff_immutable_repo_fields`.
39// Each leaf is `has()`-guarded: CEL field access on an absent optional key raises a
40// "no such key" error (which fails the WHOLE rule → 422 on *every* update, blocking
41// the controller's finalizer/status writes), so we compare presence first and only
42// dereference when set — the common `create: {enabled: true}` case (no splitter/
43// hash/encryption/ecc) must reconcile, not wedge. Mirrors the webhook's None-vs-Some
44// semantics in `validate::diff_immutable_repo_fields`.
45#[schemars(extend("x-kubernetes-validations" = [
46    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.splitter) == has(oldSelf.create.splitter) && (!has(self.create.splitter) || self.create.splitter == oldSelf.create.splitter))", "message": "create.splitter is immutable after creation"},
47    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.hash) == has(oldSelf.create.hash) && (!has(self.create.hash) || self.create.hash == oldSelf.create.hash))", "message": "create.hash is immutable after creation"},
48    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.encryption) == has(oldSelf.create.encryption) && (!has(self.create.encryption) || self.create.encryption == oldSelf.create.encryption))", "message": "create.encryption is immutable after creation"},
49    {"rule": "!has(self.create) || !has(oldSelf.create) || (has(self.create.ecc) == has(oldSelf.create.ecc) && (!has(self.create.ecc) || self.create.ecc == oldSelf.create.ecc))", "message": "create.ecc is immutable after creation"}
50]))]
51#[serde(rename_all = "camelCase")]
52pub struct RepositorySpec {
53    /// Exactly one storage backend.
54    pub backend: Backend,
55    /// Repository password (a Secret reference).
56    pub encryption: Encryption,
57    /// What to do when the repository does not yet exist (absent means it must already exist).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub create: Option<CreateBehavior>,
60    /// Initialize this repository from an existing replica on its FIRST
61    /// bootstrap (issue #380) — a disaster-recovery entry point.
62    ///
63    /// Armed only while the repository has never been initialized
64    /// (`status.uniqueId` unset) **and** the mover's connect reports the backend
65    /// uninitialized; on an already-initialized repository it is a documented
66    /// no-op (`Seeded=True`, reason `AlreadyInitialized`), so it is safe to
67    /// leave standing in a GitOps manifest. When armed it also replaces
68    /// `spec.create`'s fallback: the repository is seeded or the bootstrap
69    /// fails, never silently created empty.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub seed: Option<SeedSpec>,
72    /// Tuning for the bootstrap/discovery mover Job (`<name>-discovery`) that
73    /// connects/creates an object-store repository the operator cannot reach
74    /// in-process (and re-runs for catalog re-scans).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub bootstrap: Option<BootstrapSpec>,
77    /// Base mover configuration inherited by every mover this repository spawns.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub mover_defaults: Option<MoverDefaults>,
80    /// Scheduling defaults (`timezone`, `jitter`) inherited by consumers that don't
81    /// set their own equivalent field — backup, verification, replication, and
82    /// maintenance schedules; set once here instead of repeating it on every cron.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub schedule_defaults: Option<ScheduleDefaults>,
85    /// Bounds materialization of `origin: discovered` `Snapshot` CRs from the kopia catalog.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub catalog: Option<CatalogBounds>,
88    /// Identity defaults (CEL `*Expr`) applied when consumers don't override.
89    /// Set `cluster` when this repository's backend is shared across clusters
90    /// (e.g. one bucket backed up from more than one Kubernetes cluster) — the
91    /// default kopia identity hostname then becomes `<namespace>.<cluster>`
92    /// instead of bare `<namespace>`, so same-named namespaces on different
93    /// clusters never collide. Same semantics as `ClusterRepository`'s field of
94    /// the same name (ADR-0004 §5); a consumer's explicit `spec.identity` still
95    /// wins over anything here.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub identity_defaults: Option<IdentityDefaults>,
98    /// Optional kopia web-UI server, exposed via a `Service` in this namespace.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub server: Option<ServerSpec>,
101    /// Maintenance control; when absent or enabled the reconciler creates and owns a `Maintenance` CR.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub maintenance: Option<RepositoryMaintenanceSpec>,
104    /// What happens to this repository's snapshots when a consuming namespace is deleted.
105    #[serde(default = "default_namespace_delete_policy")]
106    #[schemars(default = "default_namespace_delete_policy")]
107    pub on_namespace_delete: NamespaceDeletePolicy,
108    /// Mass-deletion circuit breaker for this repository's Snapshots.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub deletion_protection: Option<DeletionProtectionSpec>,
111    /// Concurrency limits for mover Jobs against this repository (absent = unlimited).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub concurrency: Option<ConcurrencySpec>,
114    /// Access mode: `ReadWrite` (default) or `ReadOnly` (serves restores only).
115    #[serde(default = "default_repository_mode")]
116    #[schemars(default = "default_repository_mode")]
117    pub mode: RepositoryMode,
118    /// Pause this repository: skip connect/bootstrap and maintenance projection.
119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120    pub suspend: bool,
121    /// Repository health thresholds (tunes the index-blob-count warning).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub health: Option<RepositoryHealthSpec>,
124    /// Mutable kopia repository parameters, re-applied on bootstrap whenever they drift.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub parameters: Option<RepositoryParameters>,
127}
128
129/// Tuning for the bootstrap/discovery mover Job, shared by `Repository` and
130/// `ClusterRepository`. Bootstrap connects (or, with `create`, creates) an
131/// object-store repository the operator cannot reach in-process.
132#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
133#[serde(rename_all = "camelCase")]
134pub struct BootstrapSpec {
135    /// Failure policy for the bootstrap Job. `activeDeadlineSeconds` caps how long
136    /// a connect may run before the Job is marked failed (default 120s); raise it
137    /// for a slow backend — e.g. an rclone remote whose repository metadata and
138    /// indexes load through kopia's embedded `rclone serve`/WebDAV bridge, or a
139    /// large repository whose cold-cache connect outgrows the default. The value
140    /// is a BASE, not a ceiling: after consecutive deadline-killed attempts the
141    /// operator escalates the effective deadline itself (doubling per attempt, up
142    /// to 30 minutes or the configured value, whichever is larger — never below
143    /// it), so a slow-but-alive backend self-heals without a spec edit.
144    /// `backoffLimit` bounds retries. `podStartupDeadlineSeconds` is accepted for
145    /// shape parity but is not honored by the bootstrap Job.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub failure_policy: Option<FailurePolicy>,
148}
149
150/// Mutable kopia repository parameters (`kopia repository set-parameters`), shared by
151/// `Repository` and `ClusterRepository`.
152///
153/// Deliberately NOT part of `spec.create` ([`crate::common::CreateBehavior`]): that block's
154/// whole contract is create-time-fixed-and-immutable, enforced by CEL and the webhook, and
155/// these are mutable on a live repository — re-applying them to an existing repo is the
156/// entire point.
157///
158/// Every field is optional with no kopiur-side default: **absent means "don't touch it"**,
159/// so declaring nothing here changes nothing about how a repository behaves. Note the
160/// consequence for GitOps — *removing* a value you previously set does not restore kopia's
161/// default, it leaves the repository at whatever you last applied. Set it back explicitly.
162#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
163#[serde(rename_all = "camelCase")]
164pub struct RepositoryParameters {
165    /// Epoch-manager tuning — how fast kopia closes epochs and therefore how fast index
166    /// blobs get compacted.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub epoch: Option<EpochParameters>,
169    /// Object-lock blob retention (S3/Azure/GCS) — ransomware protection. Absent means
170    /// "don't touch it"; use `disabled: true` to actively turn it off.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub blob_retention: Option<BlobRetention>,
173}
174
175/// Object-lock blob retention (`kopia repository set-parameters --retention-mode/--retention-period`).
176///
177/// Externally tagged, so exactly one variant exists by construction. That is load-bearing
178/// rather than stylistic: kopia rejects a mode without a period and a period without a mode
179/// ("both retention mode and period must be provided when setting blob retention
180/// properties"), and pairing the period *with* the mode in the type makes that error
181/// unrepresentable instead of merely validated.
182///
183/// Requires a backend that supports object lock — S3, Azure, or GCS — **and** a bucket with
184/// object lock enabled at creation time (it cannot be turned on afterwards). kopia's flags do
185/// not create it. On an unsupported backend `set-parameters` hard-fails with
186/// `blob-retention: unsupported put-blob option`, so admission rejects those backends.
187///
188/// # What this does and does not protect
189///
190/// The lock is applied when a blob is **written**. Kopiur does not enable kopia's
191/// `maintenance set --extend-object-locks`, so locks on blobs that are still needed are never
192/// extended: a blob written today under `period: 720h` becomes deletable again in 30 days.
193/// Treat it as a rolling floor, not cumulative immutability, and size the period to exceed
194/// your longest recovery window. Blobs written *before* retention was enabled — including the
195/// repository format blob — are never retroactively locked.
196///
197/// ```
198/// use kopiur_api::repository::{BlobRetention, RetentionWindow};
199///
200/// // Externally tagged: the wire form is `{ "governance": { "period": "720h" } }`.
201/// let r: BlobRetention = serde_json::from_value(serde_json::json!({
202///     "governance": { "period": "720h" }
203/// }))
204/// .unwrap();
205/// assert_eq!(r, BlobRetention::Governance(RetentionWindow { period: "720h".into() }));
206/// assert_eq!(r.kind_str(), "Governance");
207///
208/// // Disabling carries no period — kopia ignores it, and the type says so.
209/// let off: BlobRetention = serde_json::from_value(serde_json::json!({ "disabled": true }))
210///     .unwrap();
211/// assert_eq!(off.kind_str(), "Disabled");
212/// ```
213#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
214#[serde(rename_all = "camelCase")]
215pub enum BlobRetention {
216    /// `GOVERNANCE` — locked against ordinary deletes, but a sufficiently privileged
217    /// identity can still shorten or remove the lock. The safe default for most clusters.
218    Governance(RetentionWindow),
219    /// `COMPLIANCE` — **nobody can shorten or remove the lock before it expires**, including
220    /// the account root. An oversized period is an unfixable storage-cost commitment; there
221    /// is no recovery path short of deleting the bucket after expiry.
222    Compliance(RetentionWindow),
223    /// Actively disable retention (`--retention-mode=none`). Must be `true`.
224    ///
225    /// A `bool` rather than a unit variant because an externally-tagged unit variant
226    /// serializes as the bare string `"Disabled"`, mixing string and object forms in one
227    /// `oneOf` and breaking the structural schema. Same shape, and same reason, as
228    /// [`crate::cluster_repository::AllowedNamespaces::All`].
229    ///
230    /// This is distinct from omitting `blobRetention` entirely: absent means "leave the
231    /// repository alone", so deleting the block from a manifest can never silently strip
232    /// ransomware protection someone configured deliberately.
233    Disabled(bool),
234}
235
236impl BlobRetention {
237    /// Stable discriminant string for status/metrics/printcolumns.
238    pub fn kind_str(&self) -> &'static str {
239        match self {
240            BlobRetention::Governance(_) => "Governance",
241            BlobRetention::Compliance(_) => "Compliance",
242            BlobRetention::Disabled(_) => "Disabled",
243        }
244    }
245
246    /// The declared retention window, or `None` when retention is being disabled.
247    pub fn window(&self) -> Option<&RetentionWindow> {
248        match self {
249            BlobRetention::Governance(w) | BlobRetention::Compliance(w) => Some(w),
250            BlobRetention::Disabled(_) => None,
251        }
252    }
253}
254
255/// How long a blob stays locked once written.
256#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
257#[serde(rename_all = "camelCase")]
258pub struct RetentionWindow {
259    /// Go-style duration; kopia's minimum is `24h` and there is no maximum.
260    ///
261    /// Accepts `h`/`m`/`s` (or a bare number of seconds) — **not `d`**. Note that kopia's own
262    /// CLI *does* take `30d`, so a period copied from kopia's documentation is rejected here;
263    /// write 30 days as `720h`. Kopiur deliberately keeps one duration grammar across every
264    /// CRD field rather than matching kopia's per-flag variations.
265    pub period: String,
266}
267
268/// kopia epoch-manager parameters. Absent fields are left at whatever the repository
269/// already has (kopia's defaults, noted per field).
270///
271/// An epoch may only advance once it is **older than `minDuration`** AND has accumulated
272/// `advanceOnCount` blobs or `advanceOnSizeMB` of index data. `minDuration` is a floor, so
273/// kopia's 24h default means a busy fleet — say 17 hourly policies at ~60 index blobs/hour
274/// — is forced to ~1700 blobs before an epoch may close, and kopia only compacts two epochs
275/// behind. The repository then permanently carries thousands of uncompacted index blobs,
276/// tripping `IndexBlobHealth` and slowing every mover's connect. Lowering `minDuration` is
277/// the lever for that (#258).
278///
279/// `cleanupSafetyMargin` is deliberately **observable but not settable**: its job is to stop
280/// kopia deleting index blobs a concurrent writer still needs, and there is no safe generic
281/// advice for lowering it.
282#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
283#[serde(rename_all = "camelCase")]
284pub struct EpochParameters {
285    /// Minimum epoch age before it may advance (kopia default `24h`). A Go-style duration
286    /// (`6h`, `90m`). The advance **gate** — no blob count closes an epoch younger than this.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub min_duration: Option<String>,
289    /// How often clients re-read epoch state (kopia default `20m`). Go-style duration.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub refresh_frequency: Option<String>,
292    /// Index blobs in an epoch that trigger an advance, once older than `minDuration`
293    /// (kopia default `20`).
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub advance_on_count: Option<i64>,
296    /// Total index size in an epoch that triggers an advance, once older than `minDuration`
297    /// (kopia default `10` MiB).
298    ///
299    /// Named `MiB`, not `MB`, with an explicit rename rather than the derived camelCase
300    /// (`advanceOnSizeMb`, which reads as *megabit*). The unit is genuinely mebibytes —
301    /// kopia's `--epoch-advance-on-size-mb` multiplies by 1048576, so `10` is 10485760
302    /// bytes — even though kopia's own log renders the result as "MB". That ambiguity is
303    /// this field's main hazard; the API surface should not reproduce it.
304    #[serde(
305        default,
306        rename = "advanceOnSizeMiB",
307        skip_serializing_if = "Option::is_none"
308    )]
309    pub advance_on_size_mb: Option<i64>,
310    /// Epochs between full index checkpoints (kopia default `7`).
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub checkpoint_frequency: Option<i64>,
313    /// Parallelism for epoch cleanup deletions (kopia default `4`).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub delete_parallelism: Option<i64>,
316}
317
318/// The epoch parameters the repository ACTUALLY reports, mirrored into `status` from
319/// `kopia repository status` at the last bootstrap.
320///
321/// A separate type from [`EpochParameters`] rather than a reuse, for three reasons: the CRD
322/// spec type cannot hold kopia's full output (it omits `enabled` and `cleanupSafetyMargin`);
323/// `Option` would mean opposite things in the two positions (spec `None` = "don't touch",
324/// status `None` = "kopia didn't report it"); and non-`Option` fields encode "observed means
325/// complete" in the type. Same reasoning as [`StorageStats`] being its own type.
326///
327/// This is what makes the apply honest: it is best-effort, so a failed apply stays visible
328/// here as drift from `spec` instead of silently doing nothing.
329#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
330#[serde(rename_all = "camelCase")]
331pub struct ObservedEpochParameters {
332    /// Whether kopia's epoch manager is enabled on this repository at all.
333    pub enabled: bool,
334    /// Observed minimum epoch age, as a Go-style duration.
335    pub min_duration: String,
336    /// Observed epoch-state refresh frequency, as a Go-style duration.
337    pub refresh_frequency: String,
338    /// Observed cleanup safety margin, as a Go-style duration. Reported for diagnosis;
339    /// not settable through `spec.parameters`.
340    pub cleanup_safety_margin: String,
341    /// Observed index-blob count that triggers an epoch advance.
342    pub advance_on_count: i64,
343    /// Observed total index size (MiB) that triggers an epoch advance.
344    #[serde(rename = "advanceOnSizeMiB")]
345    pub advance_on_size_mb: i64,
346    /// Observed epochs between full index checkpoints.
347    pub checkpoint_frequency: i64,
348    /// Observed epoch-cleanup delete parallelism.
349    pub delete_parallelism: i64,
350}
351
352/// Observed kopia repository parameters, mirrored into `status`.
353#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
354#[serde(rename_all = "camelCase")]
355pub struct ObservedRepositoryParameters {
356    /// The epoch parameters the repository reports.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub epoch: Option<ObservedEpochParameters>,
359    /// The blob retention the repository reports.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub blob_retention: Option<ObservedBlobRetention>,
362}
363
364/// The blob retention the repository ACTUALLY reports, mirrored into `status` from
365/// `kopia repository status` at the last bootstrap.
366///
367/// A separate type from [`BlobRetention`] for the same reasons [`ObservedEpochParameters`] is
368/// separate from [`EpochParameters`]: the spec type is a closed union that cannot express
369/// "kopia reported nothing", and non-`Option` fields encode "observed means complete".
370///
371/// `mode` is a plain `String`, not the spec enum, because kopia reports an empty mode when
372/// retention is off — a value the union deliberately cannot hold. Reporting kopia's own word
373/// verbatim also keeps the mirror honest if kopia ever adds a mode kopiur doesn't model.
374#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
375#[serde(rename_all = "camelCase")]
376pub struct ObservedBlobRetention {
377    /// Whether the repository currently has blob retention in force (kopia's
378    /// `IsRetentionEnabled()`: a non-empty mode AND a non-zero period).
379    pub enabled: bool,
380    /// Observed retention mode exactly as kopia reports it (`GOVERNANCE`, `COMPLIANCE`, or
381    /// empty when off).
382    pub mode: String,
383    /// Observed retention period, as a Go-style duration (`720h`). Empty-equivalent (`0s`)
384    /// when retention is off.
385    pub period: String,
386}
387
388/// Repository health thresholds, shared by `Repository` and `ClusterRepository`.
389#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
390#[serde(rename_all = "camelCase")]
391pub struct RepositoryHealthSpec {
392    /// Index-blob count above which the reconciler raises the `IndexBlobHealth` warning (`0` disables).
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    #[schemars(default = "default_index_blob_warn_threshold")]
395    pub index_blob_warn_threshold: Option<i64>,
396    /// Periodic backend health probe (on by default): re-connect the repository
397    /// on a timer to confirm the kopia repository still exists at the backend.
398    /// With the default `onFailure: Degrade` it doubles as the repository
399    /// circuit breaker — see `probe.onFailure`. Disable with
400    /// `probe.enabled: false`.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub probe: Option<RepositoryHealthProbeSpec>,
403}
404
405/// schemars default for [`RepositoryHealthSpec::index_blob_warn_threshold`] —
406/// [`DEFAULT_INDEX_BLOB_WARN_THRESHOLD`](crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD).
407/// Returns the field's `Option` type so schemars 1 emits the schema `default:`
408/// (which the apiserver materializes on admission). Safe because
409/// `resolve_index_blob_warn_threshold` resolves an absent field to exactly this
410/// constant — server-side defaulting changes the stored shape, not behavior.
411fn default_index_blob_warn_threshold() -> Option<i64> {
412    Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
413}
414
415/// Backend health probe, shared by `Repository` and `ClusterRepository`. On by
416/// default.
417///
418/// Once a repository reaches `Ready`, the operator trusts that pinned status and
419/// — for object-store / volume-backed backends — never re-checks the backend on
420/// its steady-state heartbeat. If the kopia repository is wiped or becomes
421/// unreachable, nothing notices until a backup runs and fails. The probe
422/// re-connects the backend every [`interval`](Self::interval) and surfaces the
423/// result as the `BackendReachable` condition + a Warning event.
424///
425/// What happens past [`failureThreshold`](Self::failure_threshold) consecutive
426/// failures depends on [`onFailure`](Self::on_failure): with the default
427/// `Degrade`, the repository moves to `Degraded` and backups, maintenance, and
428/// replication are **paused** until a re-connect succeeds (the repository
429/// circuit breaker); with `Alert`, the repository stays `Ready` and only the
430/// condition + event + metric fire.
431///
432/// **Never destructive.** A wiped repository and a transient outage look alike,
433/// and silently recreating an empty repository over a real one destroys
434/// restorability — so the probe never auto-recreates. Acting on a
435/// `RepositoryVanished` alert (a deliberate re-create) is a human decision.
436#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
437#[serde(rename_all = "camelCase")]
438pub struct RepositoryHealthProbeSpec {
439    /// Whether the probe runs (default `true`). `false` disables probing — and
440    /// with it the circuit breaker, since the probe is its only sensor: a wiped
441    /// or unreachable backend then goes unnoticed until the next backup fails.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    #[schemars(default = "default_health_probe_enabled")]
444    pub enabled: Option<bool>,
445    /// How often to re-probe the backend (Go-style duration like `30m` or `1h`;
446    /// minimum `30s`, default `30m`). Inert when `enabled: false`.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    #[schemars(default = "default_health_probe_interval")]
449    pub interval: Option<String>,
450    /// How many *consecutive* failing probes to require before the failure is
451    /// acted on (default `3`): the loud `BackendReachable=False` condition +
452    /// event fire, and — under `onFailure: Degrade` — the repository moves to
453    /// `Degraded`. Debounces a single transient blip from alarming, tripping
454    /// the breaker, or nudging a destructive manual recreate. Any success
455    /// resets it.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    #[schemars(default = "default_health_probe_failure_threshold")]
458    pub failure_threshold: Option<i64>,
459    /// What sustained probe failure (past `failureThreshold`) does to the
460    /// repository (default `Degrade`). `Degrade` moves it to `Degraded`,
461    /// pausing backups and replication until a re-connect succeeds — recovery
462    /// is automatic. Maintenance also pauses when the backend is confirmed
463    /// unreachable or the repository vanished, but keeps running when the
464    /// degradation is a probe deadline kill (`ProbeDeadlineExceeded` — index
465    /// compaction is often the cure). `Alert` keeps the repository `Ready`
466    /// and only raises the condition + Warning event + metric (the pre-breaker
467    /// behavior); backups keep running against the failing backend.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    #[schemars(default = "default_probe_on_failure")]
470    pub on_failure: Option<ProbeOnFailure>,
471}
472
473/// What sustained backend-probe failure (past `failureThreshold`) does to the
474/// repository (default `Degrade`). `Degrade` moves it to `Degraded`, pausing
475/// backups and replication until a re-connect succeeds — recovery is
476/// automatic (maintenance also pauses for a confirmed-unreachable/vanished
477/// backend, but keeps running for a probe deadline kill, where index
478/// compaction is often the cure). `Alert` keeps the repository `Ready` and
479/// only raises the `BackendReachable` condition + Warning event + metric;
480/// backups keep running against the failing backend. Neither ever
481/// auto-recreates.
482#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
483pub enum ProbeOnFailure {
484    /// Past `failureThreshold` consecutive failing probes, move the repository
485    /// to `Degraded` and pause backups and replication until a re-connect
486    /// succeeds (the repository circuit breaker; maintenance pauses only for a
487    /// confirmed-unreachable/vanished backend, not a deadline kill). Recovery
488    /// is automatic — any successful connect returns the repository to `Ready`.
489    #[default]
490    Degrade,
491    /// Alert-only: the repository stays `Ready` and only the `BackendReachable`
492    /// condition, a Warning event, and the failure metric are raised. Backups
493    /// keep running (and failing) against the unhealthy backend.
494    Alert,
495}
496
497/// schemars default for [`RepositoryHealthProbeSpec::enabled`] —
498/// [`DEFAULT_HEALTH_PROBE_ENABLED`](crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
499/// (`true`, #345). Returns the field's `Option` type so schemars 1 emits the
500/// schema `default:`. Safe to materialize server-side because
501/// [`RepositoryHealthProbeSpec::enabled`] resolves an absent field to exactly
502/// this constant. Note the schema default only materializes when the
503/// `health.probe` object is *present*; the absent-parent case is resolved by
504/// the same `enabled()` seam, so the two paths agree.
505fn default_health_probe_enabled() -> Option<bool> {
506    Some(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
507}
508
509/// schemars default for [`RepositoryHealthProbeSpec::on_failure`] —
510/// [`ProbeOnFailure::Degrade`], matching `effective_on_failure`'s absent →
511/// `Degrade` resolution. Returns the field's `Option` type so schemars 1 emits
512/// the schema `default:` (`"Degrade"` on the wire).
513fn default_probe_on_failure() -> Option<ProbeOnFailure> {
514    Some(ProbeOnFailure::Degrade)
515}
516
517/// schemars default for [`RepositoryHealthProbeSpec::interval`] — the string
518/// form of [`DEFAULT_HEALTH_PROBE_INTERVAL`](crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
519/// (`30m`). `effective_interval` resolves an absent/unparseable value to that
520/// same duration, so materializing `30m` is behavior-preserving; the field is
521/// inert unless `probe.enabled`. A unit test pins `"30m"` to the constant.
522fn default_health_probe_interval() -> Option<String> {
523    Some("30m".to_string())
524}
525
526/// schemars default for [`RepositoryHealthProbeSpec::failure_threshold`] —
527/// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`](crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
528/// (`3`), matching `effective_failure_threshold`'s absent→CONST resolution.
529fn default_health_probe_failure_threshold() -> Option<i64> {
530    Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
531}
532
533impl RepositoryHealthProbeSpec {
534    /// Whether the backend health probe runs (`spec.health.probe.enabled`).
535    ///
536    /// **This resolver is the load-bearing default (#345), not the CRD schema
537    /// `default:`** — a nested schema default only materializes when its parent
538    /// object is present, and most specs omit `spec.health`/`probe` entirely.
539    /// Absent spec/probe/field ⇒
540    /// [`DEFAULT_HEALTH_PROBE_ENABLED`](crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
541    /// (`true`); an explicit `Some(v)` ⇒ `v`. Every caller resolves through this
542    /// single seam, so `enabled: false` is the one way to opt out.
543    pub fn enabled(health: Option<&RepositoryHealthSpec>) -> bool {
544        health
545            .and_then(|h| h.probe.as_ref())
546            .and_then(|p| p.enabled)
547            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
548    }
549
550    /// The effective `onFailure` policy: `spec.health.probe.onFailure` when set,
551    /// else [`ProbeOnFailure::Degrade`] — the circuit breaker is the default,
552    /// matching the schema `default:` (which only materializes when the `probe`
553    /// object is present; this resolver covers the absent-parent case).
554    pub fn effective_on_failure(health: Option<&RepositoryHealthSpec>) -> ProbeOnFailure {
555        health
556            .and_then(|h| h.probe.as_ref())
557            .and_then(|p| p.on_failure)
558            .unwrap_or_default()
559    }
560
561    /// The effective probe cadence used **when the probe is enabled**:
562    /// `interval` when set and parseable, else [`DEFAULT_HEALTH_PROBE_INTERVAL`].
563    /// (The webhook rejects an unparseable value, so the fallback only covers
564    /// objects admitted before the validator existed.)
565    ///
566    /// [`DEFAULT_HEALTH_PROBE_INTERVAL`]: crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL
567    pub fn effective_interval(health: Option<&RepositoryHealthSpec>) -> std::time::Duration {
568        health
569            .and_then(|h| h.probe.as_ref())
570            .and_then(|p| p.interval.as_deref())
571            .and_then(crate::duration::parse_go_duration)
572            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
573    }
574
575    /// The effective consecutive-failure threshold before the loud condition is
576    /// raised: `failureThreshold` when set (clamped to at least 1), else
577    /// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`].
578    ///
579    /// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`]: crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
580    pub fn effective_failure_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
581        health
582            .and_then(|h| h.probe.as_ref())
583            .and_then(|p| p.failure_threshold)
584            .map(|t| t.max(1))
585            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
586    }
587}
588
589/// Resolve the effective index-blob warning threshold from an optional
590/// `spec.health`. Pure, so it's shared by the admission webhook, the controller,
591/// and tests without forking the default/disable semantics:
592///
593/// * absent spec or unset field ⇒
594///   [`DEFAULT_INDEX_BLOB_WARN_THRESHOLD`](crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD),
595/// * `Some(0)` ⇒ `0` (the sentinel that disables the warning),
596/// * `Some(n)` ⇒ `n`.
597///
598/// ```
599/// use kopiur_api::repository::{resolve_index_blob_warn_threshold, RepositoryHealthSpec};
600/// use kopiur_api::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD;
601///
602/// assert_eq!(resolve_index_blob_warn_threshold(None), DEFAULT_INDEX_BLOB_WARN_THRESHOLD);
603/// let h = RepositoryHealthSpec { index_blob_warn_threshold: Some(0), ..Default::default() };
604/// assert_eq!(resolve_index_blob_warn_threshold(Some(&h)), 0); // disabled
605/// let h = RepositoryHealthSpec { index_blob_warn_threshold: Some(250), ..Default::default() };
606/// assert_eq!(resolve_index_blob_warn_threshold(Some(&h)), 250);
607/// ```
608pub fn resolve_index_blob_warn_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
609    health
610        .and_then(|h| h.index_blob_warn_threshold)
611        .unwrap_or(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
612}
613
614/// Lifecycle phase of a repository. A freshly admitted CR starts in `Pending`.
615///
616/// ```
617/// use kopiur_api::repository::RepositoryPhase;
618///
619/// assert_eq!(serde_json::to_value(RepositoryPhase::Ready).unwrap(), "Ready");
620/// // An unrecognized phase from a newer operator decodes instead of erroring.
621/// let p: RepositoryPhase = serde_json::from_value(serde_json::json!("Upgrading")).unwrap();
622/// assert_eq!(p, RepositoryPhase::Unknown("Upgrading".into()));
623/// assert_eq!(serde_json::to_value(&p).unwrap(), "Upgrading");
624/// ```
625#[derive(Clone, Debug, PartialEq, Eq, Default)]
626pub enum RepositoryPhase {
627    /// Accepted by the API server but not yet reconciled.
628    #[default]
629    Pending,
630    /// Connecting to (or creating) the kopia repository.
631    Initializing,
632    /// Connected and healthy.
633    Ready,
634    /// Temporarily not fully operational, and self-healing: a retryable
635    /// bootstrap/connect failure is being retried, or the backend health probe
636    /// exceeded its `failureThreshold` (the circuit breaker is open — backups
637    /// and replication are paused until a re-connect succeeds; maintenance
638    /// still runs for an already-bootstrapped repository unless the backend is
639    /// confirmed unreachable or the repository vanished, because index
640    /// compaction is often the cure). See the `BackendReachable` and `Ready`
641    /// conditions for the cause.
642    Degraded,
643    /// Connect/create failed; see conditions for the actionable reason.
644    Failed,
645    /// A phase string this build does not recognize (newer operator, or legacy
646    /// stored data). Decode-compat only — hidden from the CRD schema, never
647    /// produced by this build. Never treated as `Ready`: consumers that gate on
648    /// "is the repository usable" must hold, not proceed.
649    Unknown(String),
650}
651
652crate::common::phase_serde!(
653    RepositoryPhase,
654    "Lifecycle phase of a repository. A freshly admitted CR starts in `Pending`."
655);
656
657impl crate::common::PhaseLabel for RepositoryPhase {
658    const ALL: &'static [Self] = &[
659        Self::Pending,
660        Self::Initializing,
661        Self::Ready,
662        Self::Degraded,
663        Self::Failed,
664    ];
665    fn label(&self) -> &str {
666        match self {
667            Self::Pending => "Pending",
668            Self::Initializing => "Initializing",
669            Self::Ready => "Ready",
670            Self::Degraded => "Degraded",
671            Self::Failed => "Failed",
672            Self::Unknown(s) => s,
673        }
674    }
675    fn unknown(raw: String) -> Self {
676        Self::Unknown(raw)
677    }
678}
679
680/// Observed state of a `Repository`, carrying resolved values pinned by the reconciler.
681#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
682#[serde(rename_all = "camelCase")]
683pub struct RepositoryStatus {
684    /// Current lifecycle phase.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub phase: Option<RepositoryPhase>,
687    /// `metadata.generation` of the `spec` last reconciled; drives staleness detection.
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub observed_generation: Option<i64>,
690    /// `resourceVersion` of the password Secret observed at the last connect attempt.
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub resolved_credential_version: Option<String>,
693    /// Kopia repository unique ID, pinned on the first successful bootstrap.
694    ///
695    /// Its presence is the "this repository has been Ready" flag that makes
696    /// auto-create one-way in time: `spec.create.enabled` governs the FIRST
697    /// bootstrap only, and once this is set kopiur will never create a fresh
698    /// empty repository over the backend, however empty the backend goes. A
699    /// wiped backend therefore parks at `Failed` with reason
700    /// `RepositoryReinitializeBlocked` instead of being silently re-created.
701    ///
702    /// To deliberately re-initialize a wiped repository, annotate it with
703    /// `kopiur.home-operations.com/allow-reinitialize` set to THIS value; the
704    /// ack is honored only while it matches, so the new ID minted by a
705    /// successful re-initialize makes it inert. This discards the history the
706    /// old repository held.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub unique_id: Option<String>,
709    /// What the last seed attempt did (`spec.seed`); absent on a repository that
710    /// was never seeded.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub seed: Option<SeedStatus>,
713    /// Mirror of `spec.backend` discriminant for the print column.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub backend: Option<String>,
716    /// Repository size and snapshot counts from the last catalog scan.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub storage_stats: Option<StorageStats>,
719    /// Catalog-materialization status (how many discovered `Snapshot`s, last refresh).
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub catalog: Option<CatalogStatus>,
722    /// Resolved kopia server endpoint/auth, pinned by the reconciler.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub server: Option<ServerStatus>,
725    /// Last reverify-request token honored from a `Snapshot`'s re-probe nudge
726    /// (RFC3339); the loop guard that keeps each request a one-shot.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub last_reverify_at: Option<String>,
729    /// Backend health-probe state (`spec.health.probe`), when enabled.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub health: Option<RepositoryHealthStatus>,
732    /// The kopia repository parameters actually observed at the last bootstrap. Compare
733    /// against `spec.parameters` to see whether a declared value landed.
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub parameters: Option<ObservedRepositoryParameters>,
736    /// Standard Kubernetes conditions (e.g. `Connected`, `MaintenanceOwned`).
737    #[serde(default, skip_serializing_if = "Vec::is_empty")]
738    pub conditions: Vec<Condition>,
739}
740
741/// Backend health-probe state, shared by `Repository` and `ClusterRepository`.
742/// Pinned by the reconciler when `spec.health.probe` is enabled so the next
743/// reconcile can tell whether a probe is due and how many consecutive failures
744/// have accrued (the debounce that keeps a transient blip from raising the loud
745/// `RepositoryVanished` / `BackendReachable=False` condition).
746#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
747#[serde(rename_all = "camelCase")]
748pub struct RepositoryHealthStatus {
749    /// RFC 3339 timestamp of the last completed probe (success or failure); drives
750    /// the `health_probe_due` timer so the probe re-fires on cadence.
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub last_probe_at: Option<String>,
753    /// RFC 3339 timestamp of the last *successful* probe (backend reachable, repo present).
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub last_healthy_at: Option<String>,
756    /// Consecutive failing probes accrued; reset to zero on any success. The loud
757    /// condition is raised only once this reaches the failure threshold.
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub consecutive_probe_failures: Option<i64>,
760    /// RFC 3339 timestamp of the first failure in the current failing streak.
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub first_failure_at: Option<String>,
763    /// RFC 3339 timestamp at which the last backend health probe was *launched*
764    /// (the bootstrap Job created for it), cleared when its result is finalized.
765    ///
766    /// This is the **launch-side** rate limit, and it is what makes the probe
767    /// terminate. `lastProbeAt` is written only at *finalize*, so gating the
768    /// launch on that alone recycles the bootstrap Job forever: the gate destroys
769    /// every Job whose completion would have cleared it (#273). Never compared
770    /// against `lastProbeAt` — see `kopiur_controller::health::probe_action`.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub probe_attempt_at: Option<String>,
773}
774
775/// Aggregate repository storage figures from the last catalog scan.
776#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
777#[serde(rename_all = "camelCase")]
778pub struct StorageStats {
779    /// Total snapshots present in the repository (across all identities).
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub snapshot_count: Option<i64>,
782    /// Human-readable total on-disk size (e.g. `412Gi`).
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub total_size: Option<String>,
785    /// Logical bytes under management (the integer form of `total_size`): the sum,
786    /// over each distinct snapshot source, of the most-recent snapshot's logical
787    /// size. Exposed to backup preflight as `repository.sizeBytes`. This is
788    /// repository *total size*, not backend free space (object stores don't report
789    /// remaining capacity).
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub total_size_bytes: Option<i64>,
792    /// RFC 3339 timestamp these stats were last observed.
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub last_observed_at: Option<String>,
795    /// Number of content-index blobs (`kopia index list`) observed at the last bootstrap.
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub index_blob_count: Option<i64>,
798}
799
800/// Status of catalog materialization for `origin: discovered` `Snapshot` CRs.
801#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
802#[serde(rename_all = "camelCase")]
803pub struct CatalogStatus {
804    /// How many `Snapshot` CRs were materialized from the catalog scan.
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub discovered_backup_count: Option<i64>,
807    /// RFC 3339 timestamp of the last catalog refresh.
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub last_refresh_at: Option<String>,
810    /// Snapshots in the last complete listing classified as another cluster's
811    /// (see `catalog.foreignSnapshots`); never materialized under `Ignore`.
812    /// As of `catalog.lastRefreshAt` — enable `periodicRefresh` to keep it
813    /// current.
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub foreign_snapshot_count: Option<i64>,
816    /// The RFC3339 token VALUE of the `kopiur.home-operations.com/catalog-scan-requested-at`
817    /// annotation last honored by a completed catalog scan. Compared by
818    /// **equality** against the live annotation to decide whether a requested
819    /// on-demand scan is still pending — deliberately NOT a timestamp comparison
820    /// against `lastRefreshAt` (a periodic refresh completing after the request
821    /// was made would otherwise look like it honored the request even though it
822    /// started before the annotation was set).
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub scan_request_honored: Option<String>,
825    /// RFC 3339 timestamp of the last bootstrap/scan attempt initiated BECAUSE OF
826    /// a pending `catalog-scan-requested-at` token (i.e. the token arm was the
827    /// reason the attempt fired). Used only to rate-limit token-driven attempts
828    /// on a Ready-but-unreachable repository — it is never compared against the
829    /// token for retirement (that's `scanRequestHonored`, by equality).
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub scan_request_attempt_at: Option<String>,
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use crate::common::{PhaseLabel, RepositoryMode};
838    use crate::testutil::from_yaml;
839    use kube::core::CustomResourceExt;
840
841    #[test]
842    fn repository_phase_all_covers_every_variant_uniquely() {
843        // Mirrors the `SnapshotPhase` tripwire: every variant is in ALL with a
844        // unique, non-empty label, so the metrics reset set and any consumer
845        // iterating phases can never silently miss one.
846        let labels: Vec<&str> = RepositoryPhase::ALL.iter().map(|p| p.label()).collect();
847        assert_eq!(RepositoryPhase::ALL.len(), 5);
848        assert!(labels.iter().all(|l| !l.is_empty()));
849        let mut sorted = labels.clone();
850        sorted.sort_unstable();
851        sorted.dedup();
852        assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
853        assert!(RepositoryPhase::ALL.contains(&RepositoryPhase::default()));
854    }
855
856    #[test]
857    fn repository_schema_emits_context_free_defaults() {
858        // brume's complaint: "everything is usually null for the defaults" in the
859        // CRD/JSON schema. These context-free constants must surface as a schema
860        // `default:` (visible in kubectl explain / the YAML language server, and
861        // consumed by the generated field reference). The apiserver materializes
862        // them server-side, which is safe ONLY because each field's resolver maps
863        // absent → exactly this value (see the paired default fns).
864        let crd = Repository::crd();
865        let json = serde_json::to_value(&crd).unwrap();
866        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
867        assert_eq!(
868            spec["properties"]["health"]["properties"]["indexBlobWarnThreshold"]["default"],
869            serde_json::json!(1000)
870        );
871        assert_eq!(
872            spec["properties"]["health"]["properties"]["probe"]["properties"]["enabled"]["default"],
873            serde_json::json!(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED),
874            "the probe must be default-ON in the schema (#345)"
875        );
876        assert_eq!(
877            spec["properties"]["health"]["properties"]["probe"]["properties"]["interval"]["default"],
878            serde_json::json!("30m")
879        );
880        assert_eq!(
881            spec["properties"]["health"]["properties"]["probe"]["properties"]["failureThreshold"]["default"],
882            serde_json::json!(3)
883        );
884        assert_eq!(
885            spec["properties"]["health"]["properties"]["probe"]["properties"]["onFailure"]["default"],
886            serde_json::json!("Degrade"),
887            "the breaker (Degrade) must be the schema default for onFailure (#345)"
888        );
889        assert_eq!(
890            spec["properties"]["catalog"]["properties"]["refreshInterval"]["default"],
891            serde_json::json!("1h")
892        );
893        assert_eq!(
894            spec["properties"]["server"]["properties"]["service"]["properties"]["port"]["default"],
895            serde_json::json!(51515)
896        );
897    }
898
899    #[test]
900    fn health_probe_interval_schema_default_matches_the_duration_constant() {
901        // The schema default is a STRING ("30m") but the controller resolves an
902        // absent value to the Duration constant. If someone changes the constant
903        // without the string (or vice-versa), server-side defaulting would
904        // materialize a value that no longer equals the resolver's fallback.
905        let s = default_health_probe_interval().expect("some");
906        assert_eq!(
907            crate::duration::parse_go_duration(&s),
908            Some(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL),
909            "default_health_probe_interval() string must parse to DEFAULT_HEALTH_PROBE_INTERVAL"
910        );
911        assert_eq!(
912            default_health_probe_failure_threshold(),
913            Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
914        );
915        assert_eq!(
916            default_index_blob_warn_threshold(),
917            Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
918        );
919        assert_eq!(
920            default_health_probe_enabled(),
921            Some(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
922        );
923        assert_eq!(default_probe_on_failure(), Some(ProbeOnFailure::Degrade));
924    }
925
926    #[test]
927    fn mode_suspend_and_ecc_roundtrip() {
928        // ADR-0005 §11/§14(e)/§13(a): mode, suspend, and create.ecc parse the
929        // cluster's way and round-trip.
930        let yaml = r#"
931backend: { filesystem: { path: /repo } }
932encryption: { passwordSecretRef: { name: s } }
933create:
934  enabled: true
935  encryption: AES256-GCM-HMAC-SHA256
936  ecc:
937    algorithm: REED-SOLOMON-CRC32
938    overheadPercent: 2
939mode: ReadOnly
940suspend: true
941"#;
942        let spec: RepositorySpec = from_yaml(yaml);
943        assert_eq!(spec.mode, RepositoryMode::ReadOnly);
944        assert!(!spec.mode.allows_writes());
945        assert!(spec.suspend);
946        let ecc = spec.create.as_ref().unwrap().ecc.as_ref().expect("ecc");
947        assert_eq!(ecc.algorithm.as_deref(), Some("REED-SOLOMON-CRC32"));
948        assert_eq!(ecc.overhead_percent, Some(2));
949
950        let json = serde_json::to_value(&spec).expect("serialize");
951        assert_eq!(json["mode"], "ReadOnly");
952        assert_eq!(json["suspend"], true);
953        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
954        assert_eq!(spec, reparsed);
955    }
956
957    #[test]
958    fn bootstrap_failure_policy_round_trips() {
959        let spec: RepositorySpec = from_yaml(
960            r#"
961backend: { rclone: { remotePath: "mydrive:backups", startupTimeout: 2m } }
962encryption: { passwordSecretRef: { name: s } }
963bootstrap:
964  failurePolicy:
965    activeDeadlineSeconds: 600
966    backoffLimit: 1
967"#,
968        );
969        let fp = spec
970            .bootstrap
971            .as_ref()
972            .and_then(|b| b.failure_policy.as_ref())
973            .expect("bootstrap.failurePolicy");
974        assert_eq!(fp.active_deadline_seconds, Some(600));
975        assert_eq!(fp.backoff_limit, Some(1));
976        // Absent bootstrap stays None.
977        let bare: RepositorySpec = from_yaml(
978            r#"
979backend: { filesystem: { path: /repo } }
980encryption: { passwordSecretRef: { name: s } }
981"#,
982        );
983        assert!(bare.bootstrap.is_none());
984    }
985
986    #[test]
987    fn health_threshold_parses_and_resolver_honors_default_and_disable() {
988        // Absent spec.health → default threshold.
989        let bare: RepositorySpec = from_yaml(
990            r#"
991backend: { filesystem: { path: /repo } }
992encryption: { passwordSecretRef: { name: s } }
993"#,
994        );
995        assert!(bare.health.is_none());
996        assert_eq!(
997            resolve_index_blob_warn_threshold(bare.health.as_ref()),
998            crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD
999        );
1000
1001        // Explicit override parses the cluster's way and resolves verbatim.
1002        let tuned: RepositorySpec = from_yaml(
1003            r#"
1004backend: { filesystem: { path: /repo } }
1005encryption: { passwordSecretRef: { name: s } }
1006health:
1007  indexBlobWarnThreshold: 250
1008"#,
1009        );
1010        assert_eq!(
1011            resolve_index_blob_warn_threshold(tuned.health.as_ref()),
1012            250
1013        );
1014
1015        // 0 is the disable sentinel (not "fall back to default").
1016        let disabled: RepositorySpec = from_yaml(
1017            r#"
1018backend: { filesystem: { path: /repo } }
1019encryption: { passwordSecretRef: { name: s } }
1020health:
1021  indexBlobWarnThreshold: 0
1022"#,
1023        );
1024        assert_eq!(
1025            resolve_index_blob_warn_threshold(disabled.health.as_ref()),
1026            0
1027        );
1028    }
1029
1030    #[test]
1031    fn deletion_protection_threshold_schema_default_matches_the_constant() {
1032        // Mirrors repository_schema_emits_context_free_defaults: a context-free
1033        // default is safe to server-side-materialize because
1034        // effective_mass_deletion_threshold maps absent → this same value.
1035        let crd = Repository::crd();
1036        let json = serde_json::to_value(&crd).unwrap();
1037        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1038        assert_eq!(
1039            spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
1040            serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
1041        );
1042        assert_eq!(
1043            crate::consts::effective_mass_deletion_threshold(None),
1044            crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
1045        );
1046    }
1047
1048    #[test]
1049    fn deletion_protection_round_trips_and_zero_disables() {
1050        use crate::common::DeletionProtectionSpec;
1051
1052        let spec: RepositorySpec = from_yaml(
1053            "backend: { filesystem: { path: /repo } }\n\
1054             encryption: { passwordSecretRef: { name: s } }\n\
1055             deletionProtection:\n  threshold: 25\n",
1056        );
1057        assert_eq!(
1058            spec.deletion_protection.as_ref().and_then(|d| d.threshold),
1059            Some(25)
1060        );
1061        assert_eq!(
1062            crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
1063            25
1064        );
1065        let json = serde_json::to_value(&spec).expect("serialize");
1066        assert_eq!(json["deletionProtection"]["threshold"], 25);
1067        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1068        assert_eq!(spec, reparsed);
1069
1070        // `Some(0)` is the disable sentinel — it passes through, not "fall back to default".
1071        let disabled = DeletionProtectionSpec { threshold: Some(0) };
1072        assert_eq!(
1073            crate::consts::effective_mass_deletion_threshold(Some(&disabled)),
1074            0
1075        );
1076
1077        // Absent deletionProtection stays None and is elided (no stored-object churn).
1078        let bare: RepositorySpec = from_yaml(
1079            "backend: { filesystem: { path: /repo } }\n\
1080             encryption: { passwordSecretRef: { name: s } }\n",
1081        );
1082        assert!(bare.deletion_protection.is_none());
1083        assert!(
1084            serde_json::to_value(&bare)
1085                .unwrap()
1086                .get("deletionProtection")
1087                .is_none(),
1088            "absent deletionProtection must be elided"
1089        );
1090    }
1091
1092    #[test]
1093    fn concurrency_max_concurrent_jobs_emits_no_schema_default() {
1094        // The §4a inverse of `deletion_protection_threshold_schema_default_matches_the_constant`.
1095        // A schema `default:` is materialized SERVER-SIDE at admission, so emitting
1096        // one here would stamp `{maxConcurrentJobs: 0}` onto every stored repository
1097        // — GitOps diff noise for a value that is definitionally identical to the
1098        // field being absent. Absent ≡ 0 ≡ unlimited, so there is nothing for a
1099        // default to disambiguate. Pinned so a later "every field should have a
1100        // default" pass has to read this reasoning first.
1101        let json = serde_json::to_value(Repository::crd()).unwrap();
1102        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1103        let field = &spec["properties"]["concurrency"]["properties"]["maxConcurrentJobs"];
1104        assert!(
1105            !field.is_null(),
1106            "the field itself must exist in the schema: {spec}"
1107        );
1108        assert!(
1109            field.get("default").is_none(),
1110            "maxConcurrentJobs must NOT carry a schema default: {field}"
1111        );
1112        // And the resolver agrees that all three spellings mean the same thing.
1113        assert_eq!(crate::consts::effective_max_concurrent_jobs(None), None);
1114    }
1115
1116    #[test]
1117    fn concurrency_round_trips_and_zero_is_unlimited() {
1118        use crate::common::ConcurrencySpec;
1119        use crate::consts::effective_max_concurrent_jobs;
1120
1121        let head = "backend: { filesystem: { path: /repo } }\n\
1122                    encryption: { passwordSecretRef: { name: s } }\n";
1123
1124        let spec: RepositorySpec =
1125            from_yaml(&format!("{head}concurrency:\n  maxConcurrentJobs: 2\n"));
1126        assert_eq!(
1127            spec.concurrency,
1128            Some(ConcurrencySpec {
1129                max_concurrent_jobs: Some(2)
1130            })
1131        );
1132        assert_eq!(
1133            effective_max_concurrent_jobs(spec.concurrency.as_ref()).map(|n| n.get()),
1134            Some(2)
1135        );
1136        let json = serde_json::to_value(&spec).expect("serialize");
1137        assert_eq!(json["concurrency"]["maxConcurrentJobs"], 2);
1138        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1139        assert_eq!(spec, reparsed);
1140
1141        // Explicit 0 = unlimited; it survives the round trip as written (nothing
1142        // normalizes it away) and resolves to uncapped, NOT to a zero-capacity pool.
1143        let zero: RepositorySpec =
1144            from_yaml(&format!("{head}concurrency:\n  maxConcurrentJobs: 0\n"));
1145        assert_eq!(
1146            zero.concurrency.and_then(|c| c.max_concurrent_jobs),
1147            Some(0)
1148        );
1149        assert_eq!(
1150            effective_max_concurrent_jobs(zero.concurrency.as_ref()),
1151            None
1152        );
1153
1154        // An empty `concurrency: {}` block is legal and also means unlimited.
1155        let empty: RepositorySpec = from_yaml(&format!("{head}concurrency: {{}}\n"));
1156        assert_eq!(
1157            empty.concurrency,
1158            Some(ConcurrencySpec {
1159                max_concurrent_jobs: None
1160            })
1161        );
1162        assert_eq!(
1163            effective_max_concurrent_jobs(empty.concurrency.as_ref()),
1164            None
1165        );
1166
1167        // Absent stays None and is elided.
1168        let bare: RepositorySpec = from_yaml(head);
1169        assert!(bare.concurrency.is_none());
1170        assert!(
1171            serde_json::to_value(&bare)
1172                .unwrap()
1173                .get("concurrency")
1174                .is_none(),
1175            "absent concurrency must be elided"
1176        );
1177    }
1178
1179    #[test]
1180    fn schedule_defaults_jitter_round_trips() {
1181        let head = "backend: { filesystem: { path: /repo } }\n\
1182                    encryption: { passwordSecretRef: { name: s } }\n";
1183        let spec: RepositorySpec = from_yaml(&format!(
1184            "{head}scheduleDefaults:\n  timezone: America/Chicago\n  jitter: 10m\n"
1185        ));
1186        let sd = spec.schedule_defaults.as_ref().expect("scheduleDefaults");
1187        assert_eq!(sd.jitter.as_deref(), Some("10m"));
1188        assert_eq!(sd.timezone.as_deref(), Some("America/Chicago"));
1189        let json = serde_json::to_value(&spec).expect("serialize");
1190        assert_eq!(json["scheduleDefaults"]["jitter"], "10m");
1191        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1192        assert_eq!(spec, reparsed);
1193
1194        // A jitter-only scheduleDefaults is legal (the two knobs are independent).
1195        let jitter_only: RepositorySpec =
1196            from_yaml(&format!("{head}scheduleDefaults:\n  jitter: 1h\n"));
1197        let sd = jitter_only
1198            .schedule_defaults
1199            .as_ref()
1200            .expect("scheduleDefaults");
1201        assert_eq!(sd.jitter.as_deref(), Some("1h"));
1202        assert!(sd.timezone.is_none());
1203        assert!(
1204            serde_json::to_value(&jitter_only).unwrap()["scheduleDefaults"]
1205                .get("timezone")
1206                .is_none(),
1207            "absent timezone must be elided"
1208        );
1209    }
1210
1211    #[test]
1212    fn mover_defaults_pod_metadata_round_trips() {
1213        let spec: RepositorySpec = from_yaml(
1214            "backend: { filesystem: { path: /repo } }\n\
1215             encryption: { passwordSecretRef: { name: s } }\n\
1216             moverDefaults:\n\
1217             \x20 podLabels:\n\
1218             \x20   kueue.x-k8s.io/queue-name: backups\n\
1219             \x20   team: platform\n\
1220             \x20 podAnnotations:\n\
1221             \x20   sidecar.istio.io/inject: \"false\"\n",
1222        );
1223        let md = spec.mover_defaults.as_ref().expect("moverDefaults");
1224        let labels = md.pod_labels.as_ref().expect("podLabels");
1225        assert_eq!(labels.len(), 2);
1226        assert_eq!(
1227            labels.get("kueue.x-k8s.io/queue-name").map(String::as_str),
1228            Some("backups")
1229        );
1230        assert_eq!(labels.get("team").map(String::as_str), Some("platform"));
1231        assert_eq!(
1232            md.pod_annotations
1233                .as_ref()
1234                .and_then(|m| m.get("sidecar.istio.io/inject"))
1235                .map(String::as_str),
1236            Some("false")
1237        );
1238        let json = serde_json::to_value(&spec).expect("serialize");
1239        assert_eq!(json["moverDefaults"]["podLabels"]["team"], "platform");
1240        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1241        assert_eq!(spec, reparsed);
1242
1243        // Absent stays absent and is elided on both keys.
1244        let bare: RepositorySpec = from_yaml(
1245            "backend: { filesystem: { path: /repo } }\n\
1246             encryption: { passwordSecretRef: { name: s } }\n\
1247             moverDefaults: { ttlSecondsAfterFinished: 60 }\n",
1248        );
1249        let md = bare.mover_defaults.as_ref().expect("moverDefaults");
1250        assert!(md.pod_labels.is_none() && md.pod_annotations.is_none());
1251        let json = serde_json::to_value(&bare).unwrap();
1252        assert!(json["moverDefaults"].get("podLabels").is_none());
1253        assert!(json["moverDefaults"].get("podAnnotations").is_none());
1254    }
1255
1256    #[test]
1257    fn storage_stats_index_blob_count_roundtrips() {
1258        let stats = StorageStats {
1259            snapshot_count: Some(12),
1260            total_size: None,
1261            total_size_bytes: Some(442_000_000),
1262            last_observed_at: None,
1263            index_blob_count: Some(1448),
1264        };
1265        let json = serde_json::to_value(&stats).unwrap();
1266        assert_eq!(json["indexBlobCount"], 1448);
1267        assert_eq!(json["totalSizeBytes"], 442_000_000_i64);
1268        let back: StorageStats = serde_json::from_value(json).unwrap();
1269        assert_eq!(back, stats);
1270    }
1271
1272    #[test]
1273    fn catalog_status_foreign_snapshot_count_roundtrips() {
1274        let status: CatalogStatus = from_yaml(
1275            "discoveredBackupCount: 42\nlastRefreshAt: 2026-06-01T00:00:00Z\nforeignSnapshotCount: 7\n",
1276        );
1277        assert_eq!(status.foreign_snapshot_count, Some(7));
1278        let json = serde_json::to_value(&status).unwrap();
1279        assert_eq!(json["foreignSnapshotCount"], 7);
1280        let back: CatalogStatus = serde_json::from_value(json).unwrap();
1281        assert_eq!(back, status);
1282
1283        // Absent stays None and is elided (no stored-object churn).
1284        let bare: CatalogStatus = from_yaml("{}\n");
1285        assert!(bare.foreign_snapshot_count.is_none());
1286        assert!(
1287            serde_json::to_value(&bare)
1288                .unwrap()
1289                .get("foreignSnapshotCount")
1290                .is_none(),
1291            "absent foreignSnapshotCount must be elided"
1292        );
1293    }
1294
1295    #[test]
1296    fn catalog_status_scan_request_honored_roundtrips() {
1297        let status: CatalogStatus = from_yaml(
1298            "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestHonored: 2026-06-01T00:00:00Z\n",
1299        );
1300        assert_eq!(
1301            status.scan_request_honored.as_deref(),
1302            Some("2026-06-01T00:00:00Z")
1303        );
1304        let json = serde_json::to_value(&status).unwrap();
1305        assert_eq!(json["scanRequestHonored"], "2026-06-01T00:00:00Z");
1306        let back: CatalogStatus = serde_json::from_value(json).unwrap();
1307        assert_eq!(back, status);
1308
1309        // Absent stays None and is elided.
1310        let bare: CatalogStatus = from_yaml("{}\n");
1311        assert!(bare.scan_request_honored.is_none());
1312        assert!(
1313            serde_json::to_value(&bare)
1314                .unwrap()
1315                .get("scanRequestHonored")
1316                .is_none(),
1317            "absent scanRequestHonored must be elided"
1318        );
1319    }
1320
1321    #[test]
1322    fn catalog_status_scan_request_attempt_at_roundtrips() {
1323        let status: CatalogStatus = from_yaml(
1324            "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestAttemptAt: 2026-06-01T00:05:00Z\n",
1325        );
1326        assert_eq!(
1327            status.scan_request_attempt_at.as_deref(),
1328            Some("2026-06-01T00:05:00Z")
1329        );
1330        let json = serde_json::to_value(&status).unwrap();
1331        assert_eq!(json["scanRequestAttemptAt"], "2026-06-01T00:05:00Z");
1332        let back: CatalogStatus = serde_json::from_value(json).unwrap();
1333        assert_eq!(back, status);
1334
1335        // Absent stays None and is elided.
1336        let bare: CatalogStatus = from_yaml("{}\n");
1337        assert!(bare.scan_request_attempt_at.is_none());
1338        assert!(
1339            serde_json::to_value(&bare)
1340                .unwrap()
1341                .get("scanRequestAttemptAt")
1342                .is_none(),
1343            "absent scanRequestAttemptAt must be elided"
1344        );
1345    }
1346
1347    #[test]
1348    fn repository_crd_exposes_index_blobs_print_column() {
1349        let crd = Repository::crd();
1350        let json = serde_json::to_value(&crd).unwrap();
1351        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1352            .as_array()
1353            .expect("printer columns present");
1354        assert!(
1355            cols.iter().any(|c| c["name"] == "IndexBlobs"
1356                && c["jsonPath"] == ".status.storageStats.indexBlobCount"),
1357            "Repository must surface the IndexBlobs print column"
1358        );
1359    }
1360
1361    #[test]
1362    fn repository_crd_carries_immutability_transition_rules() {
1363        // §7/§15: the spec schema carries the create.{splitter,hash,encryption,ecc}
1364        // immutability transition rules — but NOT an `encryption` (password Secret ref)
1365        // rule: the reference is mutable (kopia fixes only the resolved value, so a
1366        // rename with identical content must pass).
1367        let crd = Repository::crd();
1368        let json = serde_json::to_value(&crd).unwrap();
1369        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1370            ["x-kubernetes-validations"]
1371            .as_array()
1372            .expect("spec.x-kubernetes-validations present");
1373        let has = |needle: &str| {
1374            rules
1375                .iter()
1376                .any(|r| r["rule"].as_str().is_some_and(|s| s.contains(needle)))
1377        };
1378        assert!(
1379            !has("self.encryption == oldSelf.encryption"),
1380            "the password Secret ref must NOT be locked (a rename must be allowed)"
1381        );
1382        assert!(has("self.create.splitter == oldSelf.create.splitter"));
1383        assert!(has("self.create.hash == oldSelf.create.hash"));
1384        assert!(has("self.create.ecc == oldSelf.create.ecc"));
1385    }
1386
1387    #[test]
1388    fn create_immutability_rules_guard_each_optional_leaf_with_has() {
1389        // Regression (e2e): a `create.*` immutability rule that dereferences the leaf
1390        // without a `has()` guard (`self.create.splitter == oldSelf.create.splitter`)
1391        // raises a CEL "no such key" error whenever `create` is present but the
1392        // optional leaf is absent — the common `create: {enabled: true}` case. That
1393        // error fails the WHOLE rule → the apiserver 422s *every* update, so the
1394        // controller can never add its finalizer or write status and the Repository
1395        // wedges below Ready. Each `create.*` leaf must therefore be `has()`-guarded.
1396        let crd = Repository::crd();
1397        let json = serde_json::to_value(&crd).unwrap();
1398        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1399            ["x-kubernetes-validations"]
1400            .as_array()
1401            .expect("spec.x-kubernetes-validations present");
1402        for leaf in ["splitter", "hash", "encryption", "ecc"] {
1403            let rule = rules
1404                .iter()
1405                .find_map(|r| {
1406                    let s = r["rule"].as_str()?;
1407                    s.contains(&format!("self.create.{leaf} == oldSelf.create.{leaf}"))
1408                        .then_some(s)
1409                })
1410                .unwrap_or_else(|| panic!("missing create.{leaf} immutability rule"));
1411            assert!(
1412                rule.contains(&format!("has(self.create.{leaf})"))
1413                    && rule.contains(&format!("has(oldSelf.create.{leaf})")),
1414                "create.{leaf} immutability rule must `has()`-guard the leaf on BOTH sides \
1415                 (else `create: {{enabled: true}}` 422s every update); got: {rule}"
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn mode_defaults_to_readwrite_and_emits_openapi_default() {
1422        // Absent ⇒ ReadWrite (parses) and the schema carries `default: ReadWrite`.
1423        let spec: RepositorySpec = from_yaml(
1424            "backend: { filesystem: { path: /repo } }\nencryption: { passwordSecretRef: { name: s } }\n",
1425        );
1426        assert_eq!(spec.mode, RepositoryMode::ReadWrite);
1427        assert!(!spec.suspend);
1428        // Materialized (not skip-elided), so it round-trips into the stored object.
1429        assert_eq!(serde_json::to_value(&spec).unwrap()["mode"], "ReadWrite");
1430
1431        let crd = Repository::crd();
1432        let json = serde_json::to_value(&crd).unwrap();
1433        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1434            ["properties"]["mode"]["default"];
1435        assert_eq!(default, "ReadWrite");
1436    }
1437
1438    #[test]
1439    fn health_probe_helpers_default_and_parse() {
1440        use crate::consts::{
1441            DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD, DEFAULT_HEALTH_PROBE_INTERVAL,
1442        };
1443        // Absent spec / absent probe ⇒ ENABLED (#345: the probe is default-on;
1444        // this resolver — not the nested schema default, which cannot fire when
1445        // the parent object is absent — is the load-bearing default), with the
1446        // interval/threshold defaults.
1447        assert!(RepositoryHealthProbeSpec::enabled(None));
1448        assert_eq!(
1449            RepositoryHealthProbeSpec::effective_interval(None),
1450            DEFAULT_HEALTH_PROBE_INTERVAL
1451        );
1452        assert_eq!(
1453            RepositoryHealthProbeSpec::effective_failure_threshold(None),
1454            DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
1455        );
1456        assert_eq!(
1457            RepositoryHealthProbeSpec::effective_on_failure(None),
1458            ProbeOnFailure::Degrade,
1459            "absent onFailure must resolve to the breaker default"
1460        );
1461
1462        // A present-but-empty probe object resolves identically (the `Option`
1463        // layers must agree with the absent-parent path).
1464        let empty = RepositoryHealthSpec {
1465            probe: Some(RepositoryHealthProbeSpec::default()),
1466            ..Default::default()
1467        };
1468        assert!(RepositoryHealthProbeSpec::enabled(Some(&empty)));
1469        assert_eq!(
1470            RepositoryHealthProbeSpec::effective_on_failure(Some(&empty)),
1471            ProbeOnFailure::Degrade
1472        );
1473
1474        // Parses Go-duration string from the wire, NOT a {secs,nanos} object.
1475        let spec: RepositorySpec = from_yaml(
1476            "backend: { filesystem: { path: /repo } }\n\
1477             encryption: { passwordSecretRef: { name: s } }\n\
1478             health:\n  probe:\n    enabled: true\n    interval: 45m\n    failureThreshold: 5\n",
1479        );
1480        assert!(RepositoryHealthProbeSpec::enabled(spec.health.as_ref()));
1481        assert_eq!(
1482            RepositoryHealthProbeSpec::effective_interval(spec.health.as_ref()),
1483            std::time::Duration::from_secs(45 * 60)
1484        );
1485        assert_eq!(
1486            RepositoryHealthProbeSpec::effective_failure_threshold(spec.health.as_ref()),
1487            5
1488        );
1489
1490        // An explicit `enabled: false` is THE opt-out: it resolves false and
1491        // SURVIVES serialization (an Option<bool> elides only None — eliding
1492        // false would silently re-enable the probe on the next round-trip).
1493        let disabled: RepositorySpec = from_yaml(
1494            "backend: { filesystem: { path: /repo } }\n\
1495             encryption: { passwordSecretRef: { name: s } }\n\
1496             health:\n  probe:\n    enabled: false\n    interval: 1h\n",
1497        );
1498        assert!(!RepositoryHealthProbeSpec::enabled(
1499            disabled.health.as_ref()
1500        ));
1501        let json = serde_json::to_value(&disabled).unwrap();
1502        assert_eq!(
1503            json["health"]["probe"]["enabled"],
1504            serde_json::json!(false),
1505            "enabled: false must survive serialization"
1506        );
1507        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1508        assert_eq!(disabled, reparsed);
1509        assert!(!RepositoryHealthProbeSpec::enabled(
1510            reparsed.health.as_ref()
1511        ));
1512
1513        // Absent `enabled` inside a present probe stays None and is elided.
1514        let tuned_only: RepositorySpec = from_yaml(
1515            "backend: { filesystem: { path: /repo } }\n\
1516             encryption: { passwordSecretRef: { name: s } }\n\
1517             health:\n  probe:\n    interval: 1h\n",
1518        );
1519        assert!(RepositoryHealthProbeSpec::enabled(
1520            tuned_only.health.as_ref()
1521        ));
1522        assert!(
1523            serde_json::to_value(&tuned_only).unwrap()["health"]["probe"]
1524                .get("enabled")
1525                .is_none(),
1526            "absent enabled must be elided (no stored-object churn)"
1527        );
1528    }
1529
1530    #[test]
1531    fn probe_on_failure_round_trips_and_rejects_unknown_variants() {
1532        // Explicit `Alert` parses the cluster's way and round-trips as a string.
1533        let alert: RepositorySpec = from_yaml(
1534            "backend: { filesystem: { path: /repo } }\n\
1535             encryption: { passwordSecretRef: { name: s } }\n\
1536             health:\n  probe:\n    onFailure: Alert\n",
1537        );
1538        assert_eq!(
1539            RepositoryHealthProbeSpec::effective_on_failure(alert.health.as_ref()),
1540            ProbeOnFailure::Alert
1541        );
1542        let json = serde_json::to_value(&alert).unwrap();
1543        assert_eq!(json["health"]["probe"]["onFailure"], "Alert");
1544        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1545        assert_eq!(alert, reparsed);
1546
1547        // Explicit `Degrade` round-trips too.
1548        let degrade: RepositorySpec = from_yaml(
1549            "backend: { filesystem: { path: /repo } }\n\
1550             encryption: { passwordSecretRef: { name: s } }\n\
1551             health:\n  probe:\n    onFailure: Degrade\n",
1552        );
1553        assert_eq!(
1554            serde_json::to_value(&degrade).unwrap()["health"]["probe"]["onFailure"],
1555            "Degrade"
1556        );
1557
1558        // Absent onFailure stays None (elided; the resolver supplies Degrade).
1559        let absent: RepositorySpec = from_yaml(
1560            "backend: { filesystem: { path: /repo } }\n\
1561             encryption: { passwordSecretRef: { name: s } }\n\
1562             health:\n  probe:\n    enabled: true\n",
1563        );
1564        assert!(
1565            serde_json::to_value(&absent).unwrap()["health"]["probe"]
1566                .get("onFailure")
1567                .is_none(),
1568            "absent onFailure must be elided"
1569        );
1570        assert_eq!(
1571            RepositoryHealthProbeSpec::effective_on_failure(absent.health.as_ref()),
1572            ProbeOnFailure::Degrade
1573        );
1574
1575        // Unknown variant is rejected at decode (the closed enum IS the
1576        // validator; the CRD schema enforces the same set at admission).
1577        let v: serde_json::Value = serde_yaml::from_str(
1578            "backend: { filesystem: { path: /repo } }\n\
1579             encryption: { passwordSecretRef: { name: s } }\n\
1580             health:\n  probe:\n    onFailure: Recreate\n",
1581        )
1582        .unwrap();
1583        assert!(
1584            serde_json::from_value::<RepositorySpec>(v).is_err(),
1585            "unknown onFailure variant must be rejected"
1586        );
1587    }
1588
1589    #[test]
1590    fn schedule_defaults_timezone_round_trips() {
1591        let spec: RepositorySpec = from_yaml(
1592            "backend: { filesystem: { path: /repo } }\n\
1593             encryption: { passwordSecretRef: { name: s } }\n\
1594             scheduleDefaults:\n  timezone: America/New_York\n",
1595        );
1596        assert_eq!(
1597            spec.schedule_defaults
1598                .as_ref()
1599                .and_then(|d| d.timezone.as_deref()),
1600            Some("America/New_York")
1601        );
1602        let json = serde_json::to_value(&spec).expect("serialize");
1603        assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
1604        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1605        assert_eq!(spec, reparsed);
1606
1607        // Absent scheduleDefaults stays None and is elided (no stored-object churn).
1608        let bare: RepositorySpec = from_yaml(
1609            "backend: { filesystem: { path: /repo } }\n\
1610             encryption: { passwordSecretRef: { name: s } }\n",
1611        );
1612        assert!(bare.schedule_defaults.is_none());
1613        assert!(
1614            serde_json::to_value(&bare)
1615                .unwrap()
1616                .get("scheduleDefaults")
1617                .is_none(),
1618            "absent scheduleDefaults must be elided"
1619        );
1620    }
1621
1622    #[test]
1623    fn identity_defaults_cluster_round_trips_on_repository() {
1624        // M5: `RepositorySpec.identityDefaults` mirrors `ClusterRepositorySpec`'s
1625        // field of the same name — same shape, same round-trip behavior (see
1626        // `cluster_repository::tests::identity_defaults_cluster_round_trips`).
1627        let spec: RepositorySpec = from_yaml(
1628            "backend: { filesystem: { path: /repo } }\n\
1629             encryption: { passwordSecretRef: { name: s } }\n\
1630             identityDefaults:\n  cluster: east\n  hostnameExpr: namespace\n",
1631        );
1632        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
1633        assert_eq!(id.cluster.as_deref(), Some("east"));
1634        assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
1635        assert!(id.username_expr.is_none());
1636
1637        let json = serde_json::to_value(&spec).expect("serialize");
1638        assert_eq!(json["identityDefaults"]["cluster"], "east");
1639        assert_eq!(json["identityDefaults"]["hostnameExpr"], "namespace");
1640        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1641        assert_eq!(spec, reparsed);
1642
1643        // Absent identityDefaults stays None and is elided (no stored-object churn).
1644        let bare: RepositorySpec = from_yaml(
1645            "backend: { filesystem: { path: /repo } }\n\
1646             encryption: { passwordSecretRef: { name: s } }\n",
1647        );
1648        assert!(bare.identity_defaults.is_none());
1649        assert!(
1650            serde_json::to_value(&bare)
1651                .unwrap()
1652                .get("identityDefaults")
1653                .is_none(),
1654            "absent identityDefaults must be elided"
1655        );
1656    }
1657
1658    #[test]
1659    fn catalog_foreign_snapshots_round_trips_on_repository() {
1660        use crate::common::ForeignSnapshots;
1661
1662        let spec: RepositorySpec = from_yaml(
1663            "backend: { filesystem: { path: /repo } }\n\
1664             encryption: { passwordSecretRef: { name: s } }\n\
1665             catalog:\n  foreignSnapshots: Fallback\n",
1666        );
1667        assert_eq!(
1668            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1669            Some(ForeignSnapshots::Fallback)
1670        );
1671        let json = serde_json::to_value(&spec).expect("serialize");
1672        assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
1673        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1674        assert_eq!(spec, reparsed);
1675
1676        let spec: RepositorySpec = from_yaml(
1677            "backend: { filesystem: { path: /repo } }\n\
1678             encryption: { passwordSecretRef: { name: s } }\n\
1679             catalog:\n  foreignSnapshots: Ignore\n",
1680        );
1681        assert_eq!(
1682            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1683            Some(ForeignSnapshots::Ignore)
1684        );
1685
1686        // Absent stays None and is elided.
1687        let bare: RepositorySpec = from_yaml(
1688            "backend: { filesystem: { path: /repo } }\n\
1689             encryption: { passwordSecretRef: { name: s } }\n\
1690             catalog: {}\n",
1691        );
1692        assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
1693        assert!(
1694            serde_json::to_value(&bare).unwrap()["catalog"]
1695                .get("foreignSnapshots")
1696                .is_none(),
1697            "absent catalog.foreignSnapshots must be elided"
1698        );
1699    }
1700
1701    #[test]
1702    fn catalog_adoption_round_trips_on_repository() {
1703        use crate::common::SnapshotAdoption;
1704
1705        let spec: RepositorySpec = from_yaml(
1706            "backend: { filesystem: { path: /repo } }\n\
1707             encryption: { passwordSecretRef: { name: s } }\n\
1708             catalog:\n  adoption: Ignore\n",
1709        );
1710        assert_eq!(
1711            spec.catalog.as_ref().and_then(|c| c.adoption),
1712            Some(SnapshotAdoption::Ignore)
1713        );
1714        let json = serde_json::to_value(&spec).expect("serialize");
1715        assert_eq!(json["catalog"]["adoption"], "Ignore");
1716        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1717        assert_eq!(spec, reparsed);
1718
1719        // Absent stays None and is elided; no schema default (context-dependent).
1720        let bare: RepositorySpec = from_yaml(
1721            "backend: { filesystem: { path: /repo } }\n\
1722             encryption: { passwordSecretRef: { name: s } }\n\
1723             catalog: {}\n",
1724        );
1725        assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
1726        assert!(
1727            serde_json::to_value(&bare).unwrap()["catalog"]
1728                .get("adoption")
1729                .is_none(),
1730            "absent catalog.adoption must be elided"
1731        );
1732
1733        let crd = Repository::crd();
1734        let crd_json = serde_json::to_value(&crd).unwrap();
1735        let prop = &crd_json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1736            ["properties"]["catalog"]["properties"]["adoption"];
1737        assert!(
1738            prop.get("default").is_none(),
1739            "catalog.adoption must NOT carry a schema default: {prop}"
1740        );
1741    }
1742}