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    /// [`AllowedNamespaces::All`](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///
283/// **Every field here has an admission floor** (#458), because `kopia repository
284/// set-parameters` merges the flags it is given into the repository's existing parameters and
285/// then validates the whole resulting set — so ONE out-of-range value refuses the entire
286/// call and discards every other parameter in the same apply. The floors:
287///
288/// - `minDuration`: at least `10m`, and at least 3x `refreshFrequency` (kopia's untouched
289///   `20m` default when none is declared — which is why `minDuration: 10m` alone is
290///   rejected, and `60m` is the smallest value that stands on its own).
291/// - `refreshFrequency`: at most `80m`, because kopia needs `cleanupSafetyMargin` to be at
292///   least 3x it and the margin is stuck at kopia's `4h` default (kopiur cannot set it).
293/// - `advanceOnCount`: at least `10`. `advanceOnSizeMiB`: at least `1`.
294/// - `checkpointFrequency`: at least `1`. `deleteParallelism`: at least `1`.
295#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
296#[serde(rename_all = "camelCase")]
297pub struct EpochParameters {
298    /// Minimum epoch age before it may advance (kopia default `24h`). A Go-style duration
299    /// (`6h`, `90m`). The advance **gate** — no blob count closes an epoch younger than this.
300    ///
301    /// Must be at least `10m` (kopia's absolute floor) AND at least 3x `refreshFrequency`.
302    /// When `refreshFrequency` is not declared, that second bound is measured against
303    /// kopia's untouched `20m` default, so `10m` on its own is rejected: use `60m`, or
304    /// declare a `refreshFrequency` of a third of it or less alongside it.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub min_duration: Option<String>,
307    /// How often clients re-read epoch state (kopia default `20m`). Go-style duration.
308    ///
309    /// At most `80m`: kopia requires `cleanupSafetyMargin >= 3x` this value, and
310    /// `cleanupSafetyMargin` is observable-but-not-settable here, so it stays at kopia's
311    /// `4h` default and `4h / 3` is a hard ceiling.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub refresh_frequency: Option<String>,
314    /// Index blobs in an epoch that trigger an advance, once older than `minDuration`
315    /// (kopia default `20`).
316    ///
317    /// At least `10` — kopia refuses anything lower with "epoch advance on count too low".
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub advance_on_count: Option<i64>,
320    /// Total index size in an epoch that triggers an advance, once older than `minDuration`
321    /// (kopia default `10` MiB).
322    ///
323    /// At least `1` — kopia refuses anything lower with "epoch advance on size too low",
324    /// and 1 MiB is the smallest threshold the flag can express.
325    ///
326    /// Named `MiB`, not `MB`, with an explicit rename rather than the derived camelCase
327    /// (`advanceOnSizeMb`, which reads as *megabit*). The unit is genuinely mebibytes —
328    /// kopia's `--epoch-advance-on-size-mb` multiplies by 1048576, so `10` is 10485760
329    /// bytes — even though kopia's own log renders the result as "MB". That ambiguity is
330    /// this field's main hazard; the API surface should not reproduce it.
331    #[serde(
332        default,
333        rename = "advanceOnSizeMiB",
334        skip_serializing_if = "Option::is_none"
335    )]
336    pub advance_on_size_mb: Option<i64>,
337    /// Epochs between full index checkpoints (kopia default `7`).
338    ///
339    /// At least `1` — kopia refuses anything lower with "invalid epoch range compaction
340    /// period".
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub checkpoint_frequency: Option<i64>,
343    /// Parallelism for epoch cleanup deletions (kopia default `4`).
344    ///
345    /// At least `1`. This floor is kopiur's own: kopia does not validate the field, so a
346    /// non-positive value would ask it to run epoch cleanup with no workers.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub delete_parallelism: Option<i64>,
349}
350
351/// The epoch parameters the repository ACTUALLY reports, mirrored into `status` from
352/// `kopia repository status` at the last bootstrap.
353///
354/// A separate type from [`EpochParameters`] rather than a reuse, for three reasons: the CRD
355/// spec type cannot hold kopia's full output (it omits `enabled` and `cleanupSafetyMargin`);
356/// `Option` would mean opposite things in the two positions (spec `None` = "don't touch",
357/// status `None` = "kopia didn't report it"); and non-`Option` fields encode "observed means
358/// complete" in the type. Same reasoning as [`StorageStats`] being its own type.
359///
360/// This is what makes the apply honest: it is best-effort, so a failed apply stays visible
361/// here as drift from `spec` instead of silently doing nothing.
362#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
363#[serde(rename_all = "camelCase")]
364pub struct ObservedEpochParameters {
365    /// Whether kopia's epoch manager is enabled on this repository at all.
366    pub enabled: bool,
367    /// Observed minimum epoch age, as a Go-style duration.
368    pub min_duration: String,
369    /// Observed epoch-state refresh frequency, as a Go-style duration.
370    pub refresh_frequency: String,
371    /// Observed cleanup safety margin, as a Go-style duration. Reported for diagnosis;
372    /// not settable through `spec.parameters`.
373    pub cleanup_safety_margin: String,
374    /// Observed index-blob count that triggers an epoch advance.
375    pub advance_on_count: i64,
376    /// Observed total index size (MiB) that triggers an epoch advance, **rounded up**.
377    ///
378    /// kopia reports this threshold in BYTES and it need not be a whole number of MiB, so
379    /// the mirror rounds up to keep a sub-MiB remainder visible rather than reporting a
380    /// value the repository does not actually hold. Drift against `spec` is computed on the
381    /// exact byte count, never on this rounded mirror.
382    #[serde(rename = "advanceOnSizeMiB")]
383    pub advance_on_size_mb: i64,
384    /// Observed epochs between full index checkpoints.
385    pub checkpoint_frequency: i64,
386    /// Observed epoch-cleanup delete parallelism.
387    pub delete_parallelism: i64,
388}
389
390/// Observed kopia repository parameters, mirrored into `status`.
391#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
392#[serde(rename_all = "camelCase")]
393pub struct ObservedRepositoryParameters {
394    /// The epoch parameters the repository reports.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub epoch: Option<ObservedEpochParameters>,
397    /// The blob retention the repository reports.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub blob_retention: Option<ObservedBlobRetention>,
400}
401
402/// The blob retention the repository ACTUALLY reports, mirrored into `status` from
403/// `kopia repository status` at the last bootstrap.
404///
405/// A separate type from [`BlobRetention`] for the same reasons [`ObservedEpochParameters`] is
406/// separate from [`EpochParameters`]: the spec type is a closed union that cannot express
407/// "kopia reported nothing", and non-`Option` fields encode "observed means complete".
408///
409/// `mode` is a plain `String`, not the spec enum, because kopia reports an empty mode when
410/// retention is off — a value the union deliberately cannot hold. Reporting kopia's own word
411/// verbatim also keeps the mirror honest if kopia ever adds a mode kopiur doesn't model.
412#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
413#[serde(rename_all = "camelCase")]
414pub struct ObservedBlobRetention {
415    /// Whether the repository currently has blob retention in force (kopia's
416    /// `IsRetentionEnabled()`: a non-empty mode AND a non-zero period).
417    pub enabled: bool,
418    /// Observed retention mode exactly as kopia reports it (`GOVERNANCE`, `COMPLIANCE`, or
419    /// empty when off).
420    pub mode: String,
421    /// Observed retention period, as a Go-style duration (`720h`). Empty-equivalent (`0s`)
422    /// when retention is off.
423    pub period: String,
424}
425
426/// Repository health thresholds, shared by `Repository` and `ClusterRepository`.
427#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
428#[serde(rename_all = "camelCase")]
429pub struct RepositoryHealthSpec {
430    /// Index-blob count above which the reconciler raises the `IndexBlobHealth` warning (`0` disables).
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    #[schemars(default = "default_index_blob_warn_threshold")]
433    pub index_blob_warn_threshold: Option<i64>,
434    /// Periodic backend health probe (on by default): re-connect the repository
435    /// on a timer to confirm the kopia repository still exists at the backend.
436    /// With the default `onFailure: Degrade` it doubles as the repository
437    /// circuit breaker — see `probe.onFailure`. Disable with
438    /// `probe.enabled: false`.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub probe: Option<RepositoryHealthProbeSpec>,
441}
442
443/// schemars default for [`RepositoryHealthSpec::index_blob_warn_threshold`] —
444/// [`DEFAULT_INDEX_BLOB_WARN_THRESHOLD`](crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD).
445/// Returns the field's `Option` type so schemars 1 emits the schema `default:`
446/// (which the apiserver materializes on admission). Safe because
447/// `resolve_index_blob_warn_threshold` resolves an absent field to exactly this
448/// constant — server-side defaulting changes the stored shape, not behavior.
449fn default_index_blob_warn_threshold() -> Option<i64> {
450    Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
451}
452
453/// Backend health probe, shared by `Repository` and `ClusterRepository`. On by
454/// default.
455///
456/// Once a repository reaches `Ready`, the operator trusts that pinned status and
457/// — for object-store / volume-backed backends — never re-checks the backend on
458/// its steady-state heartbeat. If the kopia repository is wiped or becomes
459/// unreachable, nothing notices until a backup runs and fails. The probe
460/// re-connects the backend every [`interval`](Self::interval) and surfaces the
461/// result as the `BackendReachable` condition + a Warning event.
462///
463/// What happens past [`failureThreshold`](Self::failure_threshold) consecutive
464/// failures depends on [`onFailure`](Self::on_failure): with the default
465/// `Degrade`, the repository moves to `Degraded` and backups, maintenance, and
466/// replication are **paused** until a re-connect succeeds (the repository
467/// circuit breaker); with `Alert`, the repository stays `Ready` and only the
468/// condition + event + metric fire.
469///
470/// **Never destructive.** A wiped repository and a transient outage look alike,
471/// and silently recreating an empty repository over a real one destroys
472/// restorability — so the probe never auto-recreates. Acting on a
473/// `RepositoryVanished` alert (a deliberate re-create) is a human decision.
474#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
475#[serde(rename_all = "camelCase")]
476pub struct RepositoryHealthProbeSpec {
477    /// Whether the probe runs (default `true`). `false` disables probing — and
478    /// with it the circuit breaker, since the probe is its only sensor: a wiped
479    /// or unreachable backend then goes unnoticed until the next backup fails.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    #[schemars(default = "default_health_probe_enabled")]
482    pub enabled: Option<bool>,
483    /// How often to re-probe the backend (Go-style duration like `30m` or `1h`;
484    /// minimum `30s`, default `30m`). Inert when `enabled: false`.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    #[schemars(default = "default_health_probe_interval")]
487    pub interval: Option<String>,
488    /// How many *consecutive* failing probes to require before the failure is
489    /// acted on (default `3`): the loud `BackendReachable=False` condition +
490    /// event fire, and — under `onFailure: Degrade` — the repository moves to
491    /// `Degraded`. Debounces a single transient blip from alarming, tripping
492    /// the breaker, or nudging a destructive manual recreate. Any success
493    /// resets it.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    #[schemars(default = "default_health_probe_failure_threshold")]
496    pub failure_threshold: Option<i64>,
497    /// What sustained probe failure (past `failureThreshold`) does to the
498    /// repository (default `Degrade`). `Degrade` moves it to `Degraded`,
499    /// pausing backups and replication until a re-connect succeeds — recovery
500    /// is automatic. Maintenance also pauses when the backend is confirmed
501    /// unreachable or the repository vanished, but keeps running when the
502    /// degradation is a probe deadline kill (`ProbeDeadlineExceeded` — index
503    /// compaction is often the cure). `Alert` keeps the repository `Ready`
504    /// and only raises the condition + Warning event + metric (the pre-breaker
505    /// behavior); backups keep running against the failing backend.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    #[schemars(default = "default_probe_on_failure")]
508    pub on_failure: Option<ProbeOnFailure>,
509}
510
511/// What sustained backend-probe failure (past `failureThreshold`) does to the
512/// repository (default `Degrade`). `Degrade` moves it to `Degraded`, pausing
513/// backups and replication until a re-connect succeeds — recovery is
514/// automatic (maintenance also pauses for a confirmed-unreachable/vanished
515/// backend, but keeps running for a probe deadline kill, where index
516/// compaction is often the cure). `Alert` keeps the repository `Ready` and
517/// only raises the `BackendReachable` condition + Warning event + metric;
518/// backups keep running against the failing backend. Neither ever
519/// auto-recreates.
520#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
521pub enum ProbeOnFailure {
522    /// Past `failureThreshold` consecutive failing probes, move the repository
523    /// to `Degraded` and pause backups and replication until a re-connect
524    /// succeeds (the repository circuit breaker; maintenance pauses only for a
525    /// confirmed-unreachable/vanished backend, not a deadline kill). Recovery
526    /// is automatic — any successful connect returns the repository to `Ready`.
527    #[default]
528    Degrade,
529    /// Alert-only: the repository stays `Ready` and only the `BackendReachable`
530    /// condition, a Warning event, and the failure metric are raised. Backups
531    /// keep running (and failing) against the unhealthy backend.
532    Alert,
533}
534
535/// schemars default for [`RepositoryHealthProbeSpec::enabled`] —
536/// [`DEFAULT_HEALTH_PROBE_ENABLED`](crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
537/// (`true`, #345). Returns the field's `Option` type so schemars 1 emits the
538/// schema `default:`. Safe to materialize server-side because
539/// [`RepositoryHealthProbeSpec::enabled`] resolves an absent field to exactly
540/// this constant. Note the schema default only materializes when the
541/// `health.probe` object is *present*; the absent-parent case is resolved by
542/// the same `enabled()` seam, so the two paths agree.
543fn default_health_probe_enabled() -> Option<bool> {
544    Some(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
545}
546
547/// schemars default for [`RepositoryHealthProbeSpec::on_failure`] —
548/// [`ProbeOnFailure::Degrade`], matching `effective_on_failure`'s absent →
549/// `Degrade` resolution. Returns the field's `Option` type so schemars 1 emits
550/// the schema `default:` (`"Degrade"` on the wire).
551fn default_probe_on_failure() -> Option<ProbeOnFailure> {
552    Some(ProbeOnFailure::Degrade)
553}
554
555/// schemars default for [`RepositoryHealthProbeSpec::interval`] — the string
556/// form of [`DEFAULT_HEALTH_PROBE_INTERVAL`](crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
557/// (`30m`). `effective_interval` resolves an absent/unparseable value to that
558/// same duration, so materializing `30m` is behavior-preserving; the field is
559/// inert unless `probe.enabled`. A unit test pins `"30m"` to the constant.
560fn default_health_probe_interval() -> Option<String> {
561    Some("30m".to_string())
562}
563
564/// schemars default for [`RepositoryHealthProbeSpec::failure_threshold`] —
565/// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`](crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
566/// (`3`), matching `effective_failure_threshold`'s absent→CONST resolution.
567fn default_health_probe_failure_threshold() -> Option<i64> {
568    Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
569}
570
571impl RepositoryHealthProbeSpec {
572    /// Whether the backend health probe runs (`spec.health.probe.enabled`).
573    ///
574    /// **This resolver is the load-bearing default (#345), not the CRD schema
575    /// `default:`** — a nested schema default only materializes when its parent
576    /// object is present, and most specs omit `spec.health`/`probe` entirely.
577    /// Absent spec/probe/field ⇒
578    /// [`DEFAULT_HEALTH_PROBE_ENABLED`](crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
579    /// (`true`); an explicit `Some(v)` ⇒ `v`. Every caller resolves through this
580    /// single seam, so `enabled: false` is the one way to opt out.
581    pub fn enabled(health: Option<&RepositoryHealthSpec>) -> bool {
582        health
583            .and_then(|h| h.probe.as_ref())
584            .and_then(|p| p.enabled)
585            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
586    }
587
588    /// The effective `onFailure` policy: `spec.health.probe.onFailure` when set,
589    /// else [`ProbeOnFailure::Degrade`] — the circuit breaker is the default,
590    /// matching the schema `default:` (which only materializes when the `probe`
591    /// object is present; this resolver covers the absent-parent case).
592    pub fn effective_on_failure(health: Option<&RepositoryHealthSpec>) -> ProbeOnFailure {
593        health
594            .and_then(|h| h.probe.as_ref())
595            .and_then(|p| p.on_failure)
596            .unwrap_or_default()
597    }
598
599    /// The effective probe cadence used **when the probe is enabled**:
600    /// `interval` when set and parseable, else [`DEFAULT_HEALTH_PROBE_INTERVAL`].
601    /// (The webhook rejects an unparseable value, so the fallback only covers
602    /// objects admitted before the validator existed.)
603    ///
604    /// [`DEFAULT_HEALTH_PROBE_INTERVAL`]: crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL
605    pub fn effective_interval(health: Option<&RepositoryHealthSpec>) -> std::time::Duration {
606        health
607            .and_then(|h| h.probe.as_ref())
608            .and_then(|p| p.interval.as_deref())
609            .and_then(crate::duration::parse_go_duration)
610            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
611    }
612
613    /// The effective consecutive-failure threshold before the loud condition is
614    /// raised: `failureThreshold` when set (clamped to at least 1), else
615    /// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`].
616    ///
617    /// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`]: crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
618    pub fn effective_failure_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
619        health
620            .and_then(|h| h.probe.as_ref())
621            .and_then(|p| p.failure_threshold)
622            .map(|t| t.max(1))
623            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
624    }
625}
626
627/// Resolve the effective index-blob warning threshold from an optional
628/// `spec.health`. Pure, so it's shared by the admission webhook, the controller,
629/// and tests without forking the default/disable semantics:
630///
631/// * absent spec or unset field ⇒
632///   [`DEFAULT_INDEX_BLOB_WARN_THRESHOLD`](crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD),
633/// * `Some(0)` ⇒ `0` (the sentinel that disables the warning),
634/// * `Some(n)` ⇒ `n`.
635///
636/// ```
637/// use kopiur_api::repository::{resolve_index_blob_warn_threshold, RepositoryHealthSpec};
638/// use kopiur_api::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD;
639///
640/// assert_eq!(resolve_index_blob_warn_threshold(None), DEFAULT_INDEX_BLOB_WARN_THRESHOLD);
641/// let h = RepositoryHealthSpec { index_blob_warn_threshold: Some(0), ..Default::default() };
642/// assert_eq!(resolve_index_blob_warn_threshold(Some(&h)), 0); // disabled
643/// let h = RepositoryHealthSpec { index_blob_warn_threshold: Some(250), ..Default::default() };
644/// assert_eq!(resolve_index_blob_warn_threshold(Some(&h)), 250);
645/// ```
646pub fn resolve_index_blob_warn_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
647    health
648        .and_then(|h| h.index_blob_warn_threshold)
649        .unwrap_or(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
650}
651
652/// Lifecycle phase of a repository. A freshly admitted CR starts in `Pending`.
653///
654/// ```
655/// use kopiur_api::repository::RepositoryPhase;
656///
657/// assert_eq!(serde_json::to_value(RepositoryPhase::Ready).unwrap(), "Ready");
658/// // An unrecognized phase from a newer operator decodes instead of erroring.
659/// let p: RepositoryPhase = serde_json::from_value(serde_json::json!("Upgrading")).unwrap();
660/// assert_eq!(p, RepositoryPhase::Unknown("Upgrading".into()));
661/// assert_eq!(serde_json::to_value(&p).unwrap(), "Upgrading");
662/// ```
663#[derive(Clone, Debug, PartialEq, Eq, Default)]
664pub enum RepositoryPhase {
665    /// Accepted by the API server but not yet reconciled.
666    #[default]
667    Pending,
668    /// Connecting to (or creating) the kopia repository.
669    Initializing,
670    /// Connected and healthy.
671    Ready,
672    /// Temporarily not fully operational, and self-healing: a retryable
673    /// bootstrap/connect failure is being retried, or the backend health probe
674    /// exceeded its `failureThreshold` (the circuit breaker is open — backups
675    /// and replication are paused until a re-connect succeeds; maintenance
676    /// still runs for an already-bootstrapped repository unless the backend is
677    /// confirmed unreachable or the repository vanished, because index
678    /// compaction is often the cure). See the `BackendReachable` and `Ready`
679    /// conditions for the cause.
680    Degraded,
681    /// Connect/create failed; see conditions for the actionable reason.
682    Failed,
683    /// A phase string this build does not recognize (newer operator, or legacy
684    /// stored data). Decode-compat only — hidden from the CRD schema, never
685    /// produced by this build. Never treated as `Ready`: consumers that gate on
686    /// "is the repository usable" must hold, not proceed.
687    Unknown(String),
688}
689
690crate::common::phase_serde!(
691    RepositoryPhase,
692    "Lifecycle phase of a repository. A freshly admitted CR starts in `Pending`."
693);
694
695impl crate::common::PhaseLabel for RepositoryPhase {
696    const ALL: &'static [Self] = &[
697        Self::Pending,
698        Self::Initializing,
699        Self::Ready,
700        Self::Degraded,
701        Self::Failed,
702    ];
703    fn label(&self) -> &str {
704        match self {
705            Self::Pending => "Pending",
706            Self::Initializing => "Initializing",
707            Self::Ready => "Ready",
708            Self::Degraded => "Degraded",
709            Self::Failed => "Failed",
710            Self::Unknown(s) => s,
711        }
712    }
713    fn unknown(raw: String) -> Self {
714        Self::Unknown(raw)
715    }
716}
717
718/// Observed state of a `Repository`, carrying resolved values pinned by the reconciler.
719#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
720#[serde(rename_all = "camelCase")]
721pub struct RepositoryStatus {
722    /// Current lifecycle phase.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub phase: Option<RepositoryPhase>,
725    /// `metadata.generation` of the `spec` last reconciled; drives staleness detection.
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub observed_generation: Option<i64>,
728    /// `resourceVersion` of the password Secret observed at the last connect attempt.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub resolved_credential_version: Option<String>,
731    /// Kopia repository unique ID, pinned on the first successful bootstrap.
732    ///
733    /// Its presence is the "this repository has been Ready" flag that makes
734    /// auto-create one-way in time: `spec.create.enabled` governs the FIRST
735    /// bootstrap only, and once this is set kopiur will never create a fresh
736    /// empty repository over the backend, however empty the backend goes. A
737    /// wiped backend therefore parks at `Failed` with reason
738    /// `RepositoryReinitializeBlocked` instead of being silently re-created.
739    ///
740    /// To deliberately re-initialize a wiped repository, annotate it with
741    /// `kopiur.home-operations.com/allow-reinitialize` set to THIS value; the
742    /// ack is honored only while it matches, so the new ID minted by a
743    /// successful re-initialize makes it inert. This discards the history the
744    /// old repository held.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub unique_id: Option<String>,
747    /// What the last seed attempt did (`spec.seed`); absent on a repository that
748    /// was never seeded.
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub seed: Option<SeedStatus>,
751    /// Mirror of `spec.backend` discriminant for the print column.
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub backend: Option<String>,
754    /// Repository size and snapshot counts from the last catalog scan.
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub storage_stats: Option<StorageStats>,
757    /// Catalog-materialization status (how many discovered `Snapshot`s, last refresh).
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub catalog: Option<CatalogStatus>,
760    /// Resolved kopia server endpoint/auth, pinned by the reconciler.
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub server: Option<ServerStatus>,
763    /// Last reverify-request token honored from a `Snapshot`'s re-probe nudge
764    /// (RFC3339); the loop guard that keeps each request a one-shot.
765    #[serde(default, skip_serializing_if = "Option::is_none")]
766    pub last_reverify_at: Option<String>,
767    /// Backend health-probe state (`spec.health.probe`), when enabled.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub health: Option<RepositoryHealthStatus>,
770    /// The kopia repository parameters actually observed at the last bootstrap. Compare
771    /// against `spec.parameters` to see whether a declared value landed.
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub parameters: Option<ObservedRepositoryParameters>,
774    /// Standard Kubernetes conditions (e.g. `Connected`, `MaintenanceOwned`).
775    #[serde(default, skip_serializing_if = "Vec::is_empty")]
776    pub conditions: Vec<Condition>,
777}
778
779/// Backend health-probe state, shared by `Repository` and `ClusterRepository`.
780/// Pinned by the reconciler when `spec.health.probe` is enabled so the next
781/// reconcile can tell whether a probe is due and how many consecutive failures
782/// have accrued (the debounce that keeps a transient blip from raising the loud
783/// `RepositoryVanished` / `BackendReachable=False` condition).
784#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
785#[serde(rename_all = "camelCase")]
786pub struct RepositoryHealthStatus {
787    /// RFC 3339 timestamp of the last completed probe (success or failure); drives
788    /// the `health_probe_due` timer so the probe re-fires on cadence.
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub last_probe_at: Option<String>,
791    /// RFC 3339 timestamp of the last *successful* probe (backend reachable, repo present).
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub last_healthy_at: Option<String>,
794    /// Consecutive failing probes accrued; reset to zero on any success. The loud
795    /// condition is raised only once this reaches the failure threshold.
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub consecutive_probe_failures: Option<i64>,
798    /// RFC 3339 timestamp of the first failure in the current failing streak.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub first_failure_at: Option<String>,
801    /// RFC 3339 timestamp at which the last backend health probe was *launched*
802    /// (the bootstrap Job created for it), cleared when its result is finalized.
803    ///
804    /// This is the **launch-side** rate limit, and it is what makes the probe
805    /// terminate. `lastProbeAt` is written only at *finalize*, so gating the
806    /// launch on that alone recycles the bootstrap Job forever: the gate destroys
807    /// every Job whose completion would have cleared it (#273). Never compared
808    /// against `lastProbeAt` — see `kopiur_controller::health::probe_action`.
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub probe_attempt_at: Option<String>,
811}
812
813/// Aggregate repository storage figures from the last catalog scan.
814#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
815#[serde(rename_all = "camelCase")]
816pub struct StorageStats {
817    /// Total COMPLETE snapshots present in the repository (across all
818    /// identities). Incomplete checkpoints an interrupted `kopia snapshot
819    /// create` left behind are not snapshots and are not counted.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub snapshot_count: Option<i64>,
822    /// Human-readable total on-disk size (e.g. `412Gi`).
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub total_size: Option<String>,
825    /// Logical bytes under management (the integer form of `total_size`): the sum,
826    /// over each distinct snapshot source, of the most-recent snapshot's logical
827    /// size. Exposed to backup preflight as `repository.sizeBytes`. This is
828    /// repository *total size*, not backend free space (object stores don't report
829    /// remaining capacity).
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub total_size_bytes: Option<i64>,
832    /// RFC 3339 timestamp these stats were last observed.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub last_observed_at: Option<String>,
835    /// Number of content-index blobs (`kopia index list`): the freshest count
836    /// available, from either the last bootstrap or the recount a maintenance
837    /// run takes right after compacting. `indexBlobCountAt` says when.
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub index_blob_count: Option<i64>,
840    /// RFC3339 instant `indexBlobCount` was observed.
841    ///
842    /// Distinct from `lastObservedAt` (the catalog scan's timestamp) and from
843    /// `status.health.lastProbeAt` (the backend probe's): the index-blob count
844    /// has its own writers, and this is what lets the reconciler decide whether
845    /// a post-maintenance recount is fresher than the bootstrap's own count.
846    /// Without it the two observations were incomparable, so the
847    /// `IndexBlobHealth` warning could only ever quote the bootstrap figure —
848    /// which is pre-compaction, and stayed pre-compaction until the next
849    /// bootstrap ran (#458).
850    ///
851    /// The two writers mean subtly different things, deliberately. A bootstrap
852    /// stamps "when this count was FIRST seen at this value", reusing the
853    /// previous stamp while the number is unchanged — that arm re-observes on
854    /// every reconcile, and a stamp that always moved would make the status
855    /// always-changed and the reconcile a hot loop. A post-maintenance recount
856    /// always carries its own instant, because it is a distinct observation
857    /// taken at a known time and its whole purpose is to be comparable.
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub index_blob_count_at: Option<String>,
860}
861
862/// Status of catalog materialization for `origin: discovered` `Snapshot` CRs.
863#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
864#[serde(rename_all = "camelCase")]
865pub struct CatalogStatus {
866    /// How many `Snapshot` CRs were materialized from the catalog scan.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub discovered_backup_count: Option<i64>,
869    /// RFC 3339 timestamp of the last catalog refresh.
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub last_refresh_at: Option<String>,
872    /// Snapshots in the last complete listing classified as another cluster's
873    /// (see `catalog.foreignSnapshots`); never materialized under `Ignore`.
874    /// As of `catalog.lastRefreshAt` — enable `periodicRefresh` to keep it
875    /// current.
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    pub foreign_snapshot_count: Option<i64>,
878    /// The RFC3339 token VALUE of the `kopiur.home-operations.com/catalog-scan-requested-at`
879    /// annotation last honored by a completed catalog scan. Compared by
880    /// **equality** against the live annotation to decide whether a requested
881    /// on-demand scan is still pending — deliberately NOT a timestamp comparison
882    /// against `lastRefreshAt` (a periodic refresh completing after the request
883    /// was made would otherwise look like it honored the request even though it
884    /// started before the annotation was set).
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub scan_request_honored: Option<String>,
887    /// RFC 3339 timestamp of the last bootstrap/scan attempt initiated BECAUSE OF
888    /// a pending `catalog-scan-requested-at` token (i.e. the token arm was the
889    /// reason the attempt fired). Used only to rate-limit token-driven attempts
890    /// on a Ready-but-unreachable repository — it is never compared against the
891    /// token for retirement (that's `scanRequestHonored`, by equality).
892    #[serde(default, skip_serializing_if = "Option::is_none")]
893    pub scan_request_attempt_at: Option<String>,
894    /// How much of the repository the last catalog scan could see, as of
895    /// `lastRefreshAt`. `Complete`: the listing covered every snapshot.
896    /// `Capped`: only the newest window was materialized, but rows whose
897    /// snapshots were deleted repository-side still expire. `Partial`: the
898    /// window was capped AND membership was unknown (a mover older than the
899    /// controller, or a repository beyond the membership digest's ceiling), so
900    /// such stale rows are kept; `catalog.retain.maxAgeDays` still bounds them.
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub coverage: Option<CatalogCoverage>,
903}
904
905/// How much of the repository a catalog scan could see (issue #476). See
906/// [`CatalogStatus::coverage`].
907///
908/// ```
909/// use kopiur_api::repository::CatalogCoverage;
910///
911/// assert_eq!(serde_json::to_value(CatalogCoverage::Partial).unwrap(), "Partial");
912/// let c: CatalogCoverage = serde_json::from_value(serde_json::json!("Sampled")).unwrap();
913/// assert_eq!(c, CatalogCoverage::Unknown("Sampled".into()));
914/// ```
915#[derive(Clone, Debug, PartialEq, Eq)]
916pub enum CatalogCoverage {
917    /// The listing held every snapshot: stale rows expire, retain applies.
918    Complete,
919    /// The materialized window was capped, but the scan knew every listed id,
920    /// so rows whose snapshots were deleted repository-side still expire.
921    Capped,
922    /// The window was capped and membership was unknown: rows whose snapshots
923    /// were deleted repository-side are NOT expired (`retain.maxAgeDays` still
924    /// applies to rows outside the window; `perIdentity` only to the window).
925    Partial,
926    /// A value this build does not recognize (newer operator). Decode-compat
927    /// only — hidden from the CRD schema, never produced by this build.
928    Unknown(String),
929}
930
931crate::common::phase_serde!(
932    CatalogCoverage,
933    "How much of the repository a catalog scan could see."
934);
935
936impl crate::common::PhaseLabel for CatalogCoverage {
937    const ALL: &'static [Self] = &[Self::Complete, Self::Capped, Self::Partial];
938    fn label(&self) -> &str {
939        match self {
940            Self::Complete => "Complete",
941            Self::Capped => "Capped",
942            Self::Partial => "Partial",
943            Self::Unknown(s) => s,
944        }
945    }
946    fn unknown(raw: String) -> Self {
947        Self::Unknown(raw)
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use crate::common::{PhaseLabel, RepositoryMode};
955    use crate::testutil::from_yaml;
956    use kube::core::CustomResourceExt;
957
958    #[test]
959    fn repository_phase_all_covers_every_variant_uniquely() {
960        // Mirrors the `SnapshotPhase` tripwire: every variant is in ALL with a
961        // unique, non-empty label, so the metrics reset set and any consumer
962        // iterating phases can never silently miss one.
963        let labels: Vec<&str> = RepositoryPhase::ALL.iter().map(|p| p.label()).collect();
964        assert_eq!(RepositoryPhase::ALL.len(), 5);
965        assert!(labels.iter().all(|l| !l.is_empty()));
966        let mut sorted = labels.clone();
967        sorted.sort_unstable();
968        sorted.dedup();
969        assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
970        assert!(RepositoryPhase::ALL.contains(&RepositoryPhase::default()));
971    }
972
973    #[test]
974    fn repository_schema_emits_context_free_defaults() {
975        // brume's complaint: "everything is usually null for the defaults" in the
976        // CRD/JSON schema. These context-free constants must surface as a schema
977        // `default:` (visible in kubectl explain / the YAML language server, and
978        // consumed by the generated field reference). The apiserver materializes
979        // them server-side, which is safe ONLY because each field's resolver maps
980        // absent → exactly this value (see the paired default fns).
981        let crd = Repository::crd();
982        let json = serde_json::to_value(&crd).unwrap();
983        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
984        assert_eq!(
985            spec["properties"]["health"]["properties"]["indexBlobWarnThreshold"]["default"],
986            serde_json::json!(1000)
987        );
988        assert_eq!(
989            spec["properties"]["health"]["properties"]["probe"]["properties"]["enabled"]["default"],
990            serde_json::json!(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED),
991            "the probe must be default-ON in the schema (#345)"
992        );
993        assert_eq!(
994            spec["properties"]["health"]["properties"]["probe"]["properties"]["interval"]["default"],
995            serde_json::json!("30m")
996        );
997        assert_eq!(
998            spec["properties"]["health"]["properties"]["probe"]["properties"]["failureThreshold"]["default"],
999            serde_json::json!(3)
1000        );
1001        assert_eq!(
1002            spec["properties"]["health"]["properties"]["probe"]["properties"]["onFailure"]["default"],
1003            serde_json::json!("Degrade"),
1004            "the breaker (Degrade) must be the schema default for onFailure (#345)"
1005        );
1006        assert_eq!(
1007            spec["properties"]["catalog"]["properties"]["refreshInterval"]["default"],
1008            serde_json::json!("1h")
1009        );
1010        assert_eq!(
1011            spec["properties"]["server"]["properties"]["service"]["properties"]["port"]["default"],
1012            serde_json::json!(51515)
1013        );
1014    }
1015
1016    #[test]
1017    fn health_probe_interval_schema_default_matches_the_duration_constant() {
1018        // The schema default is a STRING ("30m") but the controller resolves an
1019        // absent value to the Duration constant. If someone changes the constant
1020        // without the string (or vice-versa), server-side defaulting would
1021        // materialize a value that no longer equals the resolver's fallback.
1022        let s = default_health_probe_interval().expect("some");
1023        assert_eq!(
1024            crate::duration::parse_go_duration(&s),
1025            Some(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL),
1026            "default_health_probe_interval() string must parse to DEFAULT_HEALTH_PROBE_INTERVAL"
1027        );
1028        assert_eq!(
1029            default_health_probe_failure_threshold(),
1030            Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
1031        );
1032        assert_eq!(
1033            default_index_blob_warn_threshold(),
1034            Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
1035        );
1036        assert_eq!(
1037            default_health_probe_enabled(),
1038            Some(crate::consts::DEFAULT_HEALTH_PROBE_ENABLED)
1039        );
1040        assert_eq!(default_probe_on_failure(), Some(ProbeOnFailure::Degrade));
1041    }
1042
1043    #[test]
1044    fn mode_suspend_and_ecc_roundtrip() {
1045        // ADR-0005 §11/§14(e)/§13(a): mode, suspend, and create.ecc parse the
1046        // cluster's way and round-trip.
1047        let yaml = r#"
1048backend: { filesystem: { path: /repo } }
1049encryption: { passwordSecretRef: { name: s } }
1050create:
1051  enabled: true
1052  encryption: AES256-GCM-HMAC-SHA256
1053  ecc:
1054    algorithm: REED-SOLOMON-CRC32
1055    overheadPercent: 2
1056mode: ReadOnly
1057suspend: true
1058"#;
1059        let spec: RepositorySpec = from_yaml(yaml);
1060        assert_eq!(spec.mode, RepositoryMode::ReadOnly);
1061        assert!(!spec.mode.allows_writes());
1062        assert!(spec.suspend);
1063        let ecc = spec.create.as_ref().unwrap().ecc.as_ref().expect("ecc");
1064        assert_eq!(ecc.algorithm.as_deref(), Some("REED-SOLOMON-CRC32"));
1065        assert_eq!(ecc.overhead_percent, Some(2));
1066
1067        let json = serde_json::to_value(&spec).expect("serialize");
1068        assert_eq!(json["mode"], "ReadOnly");
1069        assert_eq!(json["suspend"], true);
1070        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1071        assert_eq!(spec, reparsed);
1072    }
1073
1074    #[test]
1075    fn bootstrap_failure_policy_round_trips() {
1076        let spec: RepositorySpec = from_yaml(
1077            r#"
1078backend: { rclone: { remotePath: "mydrive:backups", startupTimeout: 2m } }
1079encryption: { passwordSecretRef: { name: s } }
1080bootstrap:
1081  failurePolicy:
1082    activeDeadlineSeconds: 600
1083    backoffLimit: 1
1084"#,
1085        );
1086        let fp = spec
1087            .bootstrap
1088            .as_ref()
1089            .and_then(|b| b.failure_policy.as_ref())
1090            .expect("bootstrap.failurePolicy");
1091        assert_eq!(fp.active_deadline_seconds, Some(600));
1092        assert_eq!(fp.backoff_limit, Some(1));
1093        // Absent bootstrap stays None.
1094        let bare: RepositorySpec = from_yaml(
1095            r#"
1096backend: { filesystem: { path: /repo } }
1097encryption: { passwordSecretRef: { name: s } }
1098"#,
1099        );
1100        assert!(bare.bootstrap.is_none());
1101    }
1102
1103    #[test]
1104    fn health_threshold_parses_and_resolver_honors_default_and_disable() {
1105        // Absent spec.health → default threshold.
1106        let bare: RepositorySpec = from_yaml(
1107            r#"
1108backend: { filesystem: { path: /repo } }
1109encryption: { passwordSecretRef: { name: s } }
1110"#,
1111        );
1112        assert!(bare.health.is_none());
1113        assert_eq!(
1114            resolve_index_blob_warn_threshold(bare.health.as_ref()),
1115            crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD
1116        );
1117
1118        // Explicit override parses the cluster's way and resolves verbatim.
1119        let tuned: RepositorySpec = from_yaml(
1120            r#"
1121backend: { filesystem: { path: /repo } }
1122encryption: { passwordSecretRef: { name: s } }
1123health:
1124  indexBlobWarnThreshold: 250
1125"#,
1126        );
1127        assert_eq!(
1128            resolve_index_blob_warn_threshold(tuned.health.as_ref()),
1129            250
1130        );
1131
1132        // 0 is the disable sentinel (not "fall back to default").
1133        let disabled: RepositorySpec = from_yaml(
1134            r#"
1135backend: { filesystem: { path: /repo } }
1136encryption: { passwordSecretRef: { name: s } }
1137health:
1138  indexBlobWarnThreshold: 0
1139"#,
1140        );
1141        assert_eq!(
1142            resolve_index_blob_warn_threshold(disabled.health.as_ref()),
1143            0
1144        );
1145    }
1146
1147    #[test]
1148    fn deletion_protection_threshold_schema_default_matches_the_constant() {
1149        // Mirrors repository_schema_emits_context_free_defaults: a context-free
1150        // default is safe to server-side-materialize because
1151        // effective_mass_deletion_threshold maps absent → this same value.
1152        let crd = Repository::crd();
1153        let json = serde_json::to_value(&crd).unwrap();
1154        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1155        assert_eq!(
1156            spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
1157            serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
1158        );
1159        assert_eq!(
1160            crate::consts::effective_mass_deletion_threshold(None),
1161            crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
1162        );
1163    }
1164
1165    #[test]
1166    fn deletion_protection_round_trips_and_zero_disables() {
1167        use crate::common::DeletionProtectionSpec;
1168
1169        let spec: RepositorySpec = from_yaml(
1170            "backend: { filesystem: { path: /repo } }\n\
1171             encryption: { passwordSecretRef: { name: s } }\n\
1172             deletionProtection:\n  threshold: 25\n",
1173        );
1174        assert_eq!(
1175            spec.deletion_protection.as_ref().and_then(|d| d.threshold),
1176            Some(25)
1177        );
1178        assert_eq!(
1179            crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
1180            25
1181        );
1182        let json = serde_json::to_value(&spec).expect("serialize");
1183        assert_eq!(json["deletionProtection"]["threshold"], 25);
1184        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1185        assert_eq!(spec, reparsed);
1186
1187        // `Some(0)` is the disable sentinel — it passes through, not "fall back to default".
1188        let disabled = DeletionProtectionSpec { threshold: Some(0) };
1189        assert_eq!(
1190            crate::consts::effective_mass_deletion_threshold(Some(&disabled)),
1191            0
1192        );
1193
1194        // Absent deletionProtection stays None and is elided (no stored-object churn).
1195        let bare: RepositorySpec = from_yaml(
1196            "backend: { filesystem: { path: /repo } }\n\
1197             encryption: { passwordSecretRef: { name: s } }\n",
1198        );
1199        assert!(bare.deletion_protection.is_none());
1200        assert!(
1201            serde_json::to_value(&bare)
1202                .unwrap()
1203                .get("deletionProtection")
1204                .is_none(),
1205            "absent deletionProtection must be elided"
1206        );
1207    }
1208
1209    #[test]
1210    fn concurrency_max_concurrent_jobs_emits_no_schema_default() {
1211        // The §4a inverse of `deletion_protection_threshold_schema_default_matches_the_constant`.
1212        // A schema `default:` is materialized SERVER-SIDE at admission, so emitting
1213        // one here would stamp `{maxConcurrentJobs: 0}` onto every stored repository
1214        // — GitOps diff noise for a value that is definitionally identical to the
1215        // field being absent. Absent ≡ 0 ≡ unlimited, so there is nothing for a
1216        // default to disambiguate. Pinned so a later "every field should have a
1217        // default" pass has to read this reasoning first.
1218        let json = serde_json::to_value(Repository::crd()).unwrap();
1219        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
1220        let field = &spec["properties"]["concurrency"]["properties"]["maxConcurrentJobs"];
1221        assert!(
1222            !field.is_null(),
1223            "the field itself must exist in the schema: {spec}"
1224        );
1225        assert!(
1226            field.get("default").is_none(),
1227            "maxConcurrentJobs must NOT carry a schema default: {field}"
1228        );
1229        // And the resolver agrees that all three spellings mean the same thing.
1230        assert_eq!(crate::consts::effective_max_concurrent_jobs(None), None);
1231    }
1232
1233    /// #477: delete-batch Jobs are capped per repository. Absent means ONE
1234    /// (single-flight), never unlimited: parallel bulk deletes on one repository
1235    /// only multiply index loads and write contention. No schema `default:`, so
1236    /// stored repositories carry no diff noise, and the resolver owns the 1.
1237    #[test]
1238    fn concurrency_max_concurrent_delete_jobs_defaults_to_one() {
1239        use crate::common::ConcurrencySpec;
1240        use crate::consts::effective_max_concurrent_delete_jobs;
1241
1242        let head = "backend: { filesystem: { path: /repo } }\n\
1243                    encryption: { passwordSecretRef: { name: s } }\n";
1244        let spec: RepositorySpec = from_yaml(&format!(
1245            "{head}concurrency:\n  maxConcurrentDeleteJobs: 3\n"
1246        ));
1247        assert_eq!(
1248            spec.concurrency.and_then(|c| c.max_concurrent_delete_jobs),
1249            Some(3)
1250        );
1251        assert_eq!(
1252            effective_max_concurrent_delete_jobs(spec.concurrency.as_ref()).get(),
1253            3
1254        );
1255        let json = serde_json::to_value(&spec).expect("serialize");
1256        assert_eq!(json["concurrency"]["maxConcurrentDeleteJobs"], 3);
1257
1258        assert_eq!(effective_max_concurrent_delete_jobs(None).get(), 1);
1259        assert_eq!(
1260            effective_max_concurrent_delete_jobs(Some(&ConcurrencySpec::default())).get(),
1261            1
1262        );
1263
1264        let crd = serde_json::to_value(Repository::crd()).unwrap();
1265        let field = &crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1266            ["properties"]["concurrency"]["properties"]["maxConcurrentDeleteJobs"];
1267        assert!(!field.is_null(), "the field must exist in the schema");
1268        assert!(field.get("default").is_none(), "no schema default: {field}");
1269        // The apiserver enforces the floor too, not only the webhook.
1270        assert_eq!(field["minimum"], 1.0, "{field}");
1271    }
1272
1273    #[test]
1274    fn concurrency_round_trips_and_zero_is_unlimited() {
1275        use crate::common::ConcurrencySpec;
1276        use crate::consts::effective_max_concurrent_jobs;
1277
1278        let head = "backend: { filesystem: { path: /repo } }\n\
1279                    encryption: { passwordSecretRef: { name: s } }\n";
1280
1281        let spec: RepositorySpec =
1282            from_yaml(&format!("{head}concurrency:\n  maxConcurrentJobs: 2\n"));
1283        assert_eq!(
1284            spec.concurrency,
1285            Some(ConcurrencySpec {
1286                max_concurrent_jobs: Some(2),
1287                max_concurrent_delete_jobs: None,
1288            })
1289        );
1290        assert_eq!(
1291            effective_max_concurrent_jobs(spec.concurrency.as_ref()).map(|n| n.get()),
1292            Some(2)
1293        );
1294        let json = serde_json::to_value(&spec).expect("serialize");
1295        assert_eq!(json["concurrency"]["maxConcurrentJobs"], 2);
1296        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1297        assert_eq!(spec, reparsed);
1298
1299        // Explicit 0 = unlimited; it survives the round trip as written (nothing
1300        // normalizes it away) and resolves to uncapped, NOT to a zero-capacity pool.
1301        let zero: RepositorySpec =
1302            from_yaml(&format!("{head}concurrency:\n  maxConcurrentJobs: 0\n"));
1303        assert_eq!(
1304            zero.concurrency.and_then(|c| c.max_concurrent_jobs),
1305            Some(0)
1306        );
1307        assert_eq!(
1308            effective_max_concurrent_jobs(zero.concurrency.as_ref()),
1309            None
1310        );
1311
1312        // An empty `concurrency: {}` block is legal and also means unlimited.
1313        let empty: RepositorySpec = from_yaml(&format!("{head}concurrency: {{}}\n"));
1314        assert_eq!(
1315            empty.concurrency,
1316            Some(ConcurrencySpec {
1317                max_concurrent_jobs: None,
1318                max_concurrent_delete_jobs: None,
1319            })
1320        );
1321        assert_eq!(
1322            effective_max_concurrent_jobs(empty.concurrency.as_ref()),
1323            None
1324        );
1325
1326        // Absent stays None and is elided.
1327        let bare: RepositorySpec = from_yaml(head);
1328        assert!(bare.concurrency.is_none());
1329        assert!(
1330            serde_json::to_value(&bare)
1331                .unwrap()
1332                .get("concurrency")
1333                .is_none(),
1334            "absent concurrency must be elided"
1335        );
1336    }
1337
1338    #[test]
1339    fn schedule_defaults_jitter_round_trips() {
1340        let head = "backend: { filesystem: { path: /repo } }\n\
1341                    encryption: { passwordSecretRef: { name: s } }\n";
1342        let spec: RepositorySpec = from_yaml(&format!(
1343            "{head}scheduleDefaults:\n  timezone: America/Chicago\n  jitter: 10m\n"
1344        ));
1345        let sd = spec.schedule_defaults.as_ref().expect("scheduleDefaults");
1346        assert_eq!(sd.jitter.as_deref(), Some("10m"));
1347        assert_eq!(sd.timezone.as_deref(), Some("America/Chicago"));
1348        let json = serde_json::to_value(&spec).expect("serialize");
1349        assert_eq!(json["scheduleDefaults"]["jitter"], "10m");
1350        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1351        assert_eq!(spec, reparsed);
1352
1353        // A jitter-only scheduleDefaults is legal (the two knobs are independent).
1354        let jitter_only: RepositorySpec =
1355            from_yaml(&format!("{head}scheduleDefaults:\n  jitter: 1h\n"));
1356        let sd = jitter_only
1357            .schedule_defaults
1358            .as_ref()
1359            .expect("scheduleDefaults");
1360        assert_eq!(sd.jitter.as_deref(), Some("1h"));
1361        assert!(sd.timezone.is_none());
1362        assert!(
1363            serde_json::to_value(&jitter_only).unwrap()["scheduleDefaults"]
1364                .get("timezone")
1365                .is_none(),
1366            "absent timezone must be elided"
1367        );
1368    }
1369
1370    #[test]
1371    fn mover_defaults_pod_metadata_round_trips() {
1372        let spec: RepositorySpec = from_yaml(
1373            "backend: { filesystem: { path: /repo } }\n\
1374             encryption: { passwordSecretRef: { name: s } }\n\
1375             moverDefaults:\n\
1376             \x20 podLabels:\n\
1377             \x20   kueue.x-k8s.io/queue-name: backups\n\
1378             \x20   team: platform\n\
1379             \x20 podAnnotations:\n\
1380             \x20   sidecar.istio.io/inject: \"false\"\n",
1381        );
1382        let md = spec.mover_defaults.as_ref().expect("moverDefaults");
1383        let labels = md.pod_labels.as_ref().expect("podLabels");
1384        assert_eq!(labels.len(), 2);
1385        assert_eq!(
1386            labels.get("kueue.x-k8s.io/queue-name").map(String::as_str),
1387            Some("backups")
1388        );
1389        assert_eq!(labels.get("team").map(String::as_str), Some("platform"));
1390        assert_eq!(
1391            md.pod_annotations
1392                .as_ref()
1393                .and_then(|m| m.get("sidecar.istio.io/inject"))
1394                .map(String::as_str),
1395            Some("false")
1396        );
1397        let json = serde_json::to_value(&spec).expect("serialize");
1398        assert_eq!(json["moverDefaults"]["podLabels"]["team"], "platform");
1399        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1400        assert_eq!(spec, reparsed);
1401
1402        // Absent stays absent and is elided on both keys.
1403        let bare: RepositorySpec = from_yaml(
1404            "backend: { filesystem: { path: /repo } }\n\
1405             encryption: { passwordSecretRef: { name: s } }\n\
1406             moverDefaults: { ttlSecondsAfterFinished: 60 }\n",
1407        );
1408        let md = bare.mover_defaults.as_ref().expect("moverDefaults");
1409        assert!(md.pod_labels.is_none() && md.pod_annotations.is_none());
1410        let json = serde_json::to_value(&bare).unwrap();
1411        assert!(json["moverDefaults"].get("podLabels").is_none());
1412        assert!(json["moverDefaults"].get("podAnnotations").is_none());
1413    }
1414
1415    #[test]
1416    fn storage_stats_index_blob_count_roundtrips() {
1417        let stats = StorageStats {
1418            snapshot_count: Some(12),
1419            total_size: None,
1420            total_size_bytes: Some(442_000_000),
1421            last_observed_at: None,
1422            index_blob_count: Some(1448),
1423            index_blob_count_at: Some("2026-06-01T03:00:00Z".into()),
1424        };
1425        let json = serde_json::to_value(&stats).unwrap();
1426        assert_eq!(json["indexBlobCount"], 1448);
1427        assert_eq!(json["indexBlobCountAt"], "2026-06-01T03:00:00Z");
1428        assert_eq!(json["totalSizeBytes"], 442_000_000_i64);
1429        let back: StorageStats = serde_json::from_value(json).unwrap();
1430        assert_eq!(back, stats);
1431    }
1432
1433    /// #458: the count's own timestamp, parsed the cluster's way. It must be
1434    /// separate from `lastObservedAt` (the catalog scan's), because the two have
1435    /// different writers and the reconciler compares only this one against a
1436    /// post-maintenance recount. A pre-upgrade status carrying a count and NO
1437    /// stamp must still decode.
1438    #[test]
1439    fn storage_stats_index_blob_count_at_is_distinct_and_optional() {
1440        let stats: StorageStats = from_yaml(
1441            "snapshotCount: 3\nlastObservedAt: 2026-06-01T00:00:00Z\n\
1442             indexBlobCount: 4200\nindexBlobCountAt: 2026-06-02T09:30:00Z\n",
1443        );
1444        assert_eq!(stats.index_blob_count, Some(4200));
1445        assert_eq!(
1446            stats.index_blob_count_at.as_deref(),
1447            Some("2026-06-02T09:30:00Z")
1448        );
1449        assert_eq!(
1450            stats.last_observed_at.as_deref(),
1451            Some("2026-06-01T00:00:00Z")
1452        );
1453
1454        let legacy: StorageStats = from_yaml("indexBlobCount: 4200\n");
1455        assert_eq!(legacy.index_blob_count, Some(4200));
1456        assert!(legacy.index_blob_count_at.is_none());
1457        assert!(
1458            serde_json::to_value(&legacy)
1459                .unwrap()
1460                .get("indexBlobCountAt")
1461                .is_none(),
1462            "an absent stamp must stay elided"
1463        );
1464    }
1465
1466    #[test]
1467    fn catalog_status_foreign_snapshot_count_roundtrips() {
1468        let status: CatalogStatus = from_yaml(
1469            "discoveredBackupCount: 42\nlastRefreshAt: 2026-06-01T00:00:00Z\nforeignSnapshotCount: 7\n",
1470        );
1471        assert_eq!(status.foreign_snapshot_count, Some(7));
1472        let json = serde_json::to_value(&status).unwrap();
1473        assert_eq!(json["foreignSnapshotCount"], 7);
1474        let back: CatalogStatus = serde_json::from_value(json).unwrap();
1475        assert_eq!(back, status);
1476
1477        // Absent stays None and is elided (no stored-object churn).
1478        let bare: CatalogStatus = from_yaml("{}\n");
1479        assert!(bare.foreign_snapshot_count.is_none());
1480        assert!(
1481            serde_json::to_value(&bare)
1482                .unwrap()
1483                .get("foreignSnapshotCount")
1484                .is_none(),
1485            "absent foreignSnapshotCount must be elided"
1486        );
1487    }
1488
1489    #[test]
1490    fn catalog_status_coverage_roundtrips_and_tolerates_unknown() {
1491        use crate::common::PhaseLabel;
1492        let status: CatalogStatus = from_yaml("coverage: Capped\n");
1493        assert_eq!(status.coverage, Some(CatalogCoverage::Capped));
1494        let json = serde_json::to_value(&status).unwrap();
1495        assert_eq!(json["coverage"], "Capped");
1496        assert_eq!(
1497            serde_json::from_value::<CatalogStatus>(json).unwrap(),
1498            status
1499        );
1500
1501        // A value from a newer operator decodes instead of poisoning the watch.
1502        let newer: CatalogStatus = from_yaml("coverage: Sampled\n");
1503        assert_eq!(
1504            newer.coverage,
1505            Some(CatalogCoverage::Unknown("Sampled".into()))
1506        );
1507        assert_eq!(serde_json::to_value(&newer).unwrap()["coverage"], "Sampled");
1508
1509        assert_eq!(
1510            CatalogCoverage::canonical(),
1511            vec!["Complete", "Capped", "Partial"]
1512        );
1513        let bare: CatalogStatus = from_yaml("{}\n");
1514        assert!(
1515            serde_json::to_value(&bare)
1516                .unwrap()
1517                .get("coverage")
1518                .is_none()
1519        );
1520    }
1521
1522    #[test]
1523    fn catalog_status_scan_request_honored_roundtrips() {
1524        let status: CatalogStatus = from_yaml(
1525            "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestHonored: 2026-06-01T00:00:00Z\n",
1526        );
1527        assert_eq!(
1528            status.scan_request_honored.as_deref(),
1529            Some("2026-06-01T00:00:00Z")
1530        );
1531        let json = serde_json::to_value(&status).unwrap();
1532        assert_eq!(json["scanRequestHonored"], "2026-06-01T00:00:00Z");
1533        let back: CatalogStatus = serde_json::from_value(json).unwrap();
1534        assert_eq!(back, status);
1535
1536        // Absent stays None and is elided.
1537        let bare: CatalogStatus = from_yaml("{}\n");
1538        assert!(bare.scan_request_honored.is_none());
1539        assert!(
1540            serde_json::to_value(&bare)
1541                .unwrap()
1542                .get("scanRequestHonored")
1543                .is_none(),
1544            "absent scanRequestHonored must be elided"
1545        );
1546    }
1547
1548    #[test]
1549    fn catalog_status_scan_request_attempt_at_roundtrips() {
1550        let status: CatalogStatus = from_yaml(
1551            "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestAttemptAt: 2026-06-01T00:05:00Z\n",
1552        );
1553        assert_eq!(
1554            status.scan_request_attempt_at.as_deref(),
1555            Some("2026-06-01T00:05:00Z")
1556        );
1557        let json = serde_json::to_value(&status).unwrap();
1558        assert_eq!(json["scanRequestAttemptAt"], "2026-06-01T00:05:00Z");
1559        let back: CatalogStatus = serde_json::from_value(json).unwrap();
1560        assert_eq!(back, status);
1561
1562        // Absent stays None and is elided.
1563        let bare: CatalogStatus = from_yaml("{}\n");
1564        assert!(bare.scan_request_attempt_at.is_none());
1565        assert!(
1566            serde_json::to_value(&bare)
1567                .unwrap()
1568                .get("scanRequestAttemptAt")
1569                .is_none(),
1570            "absent scanRequestAttemptAt must be elided"
1571        );
1572    }
1573
1574    #[test]
1575    fn repository_crd_exposes_index_blobs_print_column() {
1576        let crd = Repository::crd();
1577        let json = serde_json::to_value(&crd).unwrap();
1578        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
1579            .as_array()
1580            .expect("printer columns present");
1581        assert!(
1582            cols.iter().any(|c| c["name"] == "IndexBlobs"
1583                && c["jsonPath"] == ".status.storageStats.indexBlobCount"),
1584            "Repository must surface the IndexBlobs print column"
1585        );
1586    }
1587
1588    #[test]
1589    fn repository_crd_carries_immutability_transition_rules() {
1590        // §7/§15: the spec schema carries the create.{splitter,hash,encryption,ecc}
1591        // immutability transition rules — but NOT an `encryption` (password Secret ref)
1592        // rule: the reference is mutable (kopia fixes only the resolved value, so a
1593        // rename with identical content must pass).
1594        let crd = Repository::crd();
1595        let json = serde_json::to_value(&crd).unwrap();
1596        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1597            ["x-kubernetes-validations"]
1598            .as_array()
1599            .expect("spec.x-kubernetes-validations present");
1600        let has = |needle: &str| {
1601            rules
1602                .iter()
1603                .any(|r| r["rule"].as_str().is_some_and(|s| s.contains(needle)))
1604        };
1605        assert!(
1606            !has("self.encryption == oldSelf.encryption"),
1607            "the password Secret ref must NOT be locked (a rename must be allowed)"
1608        );
1609        assert!(has("self.create.splitter == oldSelf.create.splitter"));
1610        assert!(has("self.create.hash == oldSelf.create.hash"));
1611        assert!(has("self.create.ecc == oldSelf.create.ecc"));
1612    }
1613
1614    #[test]
1615    fn create_immutability_rules_guard_each_optional_leaf_with_has() {
1616        // Regression (e2e): a `create.*` immutability rule that dereferences the leaf
1617        // without a `has()` guard (`self.create.splitter == oldSelf.create.splitter`)
1618        // raises a CEL "no such key" error whenever `create` is present but the
1619        // optional leaf is absent — the common `create: {enabled: true}` case. That
1620        // error fails the WHOLE rule → the apiserver 422s *every* update, so the
1621        // controller can never add its finalizer or write status and the Repository
1622        // wedges below Ready. Each `create.*` leaf must therefore be `has()`-guarded.
1623        let crd = Repository::crd();
1624        let json = serde_json::to_value(&crd).unwrap();
1625        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1626            ["x-kubernetes-validations"]
1627            .as_array()
1628            .expect("spec.x-kubernetes-validations present");
1629        for leaf in ["splitter", "hash", "encryption", "ecc"] {
1630            let rule = rules
1631                .iter()
1632                .find_map(|r| {
1633                    let s = r["rule"].as_str()?;
1634                    s.contains(&format!("self.create.{leaf} == oldSelf.create.{leaf}"))
1635                        .then_some(s)
1636                })
1637                .unwrap_or_else(|| panic!("missing create.{leaf} immutability rule"));
1638            assert!(
1639                rule.contains(&format!("has(self.create.{leaf})"))
1640                    && rule.contains(&format!("has(oldSelf.create.{leaf})")),
1641                "create.{leaf} immutability rule must `has()`-guard the leaf on BOTH sides \
1642                 (else `create: {{enabled: true}}` 422s every update); got: {rule}"
1643            );
1644        }
1645    }
1646
1647    #[test]
1648    fn mode_defaults_to_readwrite_and_emits_openapi_default() {
1649        // Absent ⇒ ReadWrite (parses) and the schema carries `default: ReadWrite`.
1650        let spec: RepositorySpec = from_yaml(
1651            "backend: { filesystem: { path: /repo } }\nencryption: { passwordSecretRef: { name: s } }\n",
1652        );
1653        assert_eq!(spec.mode, RepositoryMode::ReadWrite);
1654        assert!(!spec.suspend);
1655        // Materialized (not skip-elided), so it round-trips into the stored object.
1656        assert_eq!(serde_json::to_value(&spec).unwrap()["mode"], "ReadWrite");
1657
1658        let crd = Repository::crd();
1659        let json = serde_json::to_value(&crd).unwrap();
1660        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1661            ["properties"]["mode"]["default"];
1662        assert_eq!(default, "ReadWrite");
1663    }
1664
1665    #[test]
1666    fn health_probe_helpers_default_and_parse() {
1667        use crate::consts::{
1668            DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD, DEFAULT_HEALTH_PROBE_INTERVAL,
1669        };
1670        // Absent spec / absent probe ⇒ ENABLED (#345: the probe is default-on;
1671        // this resolver — not the nested schema default, which cannot fire when
1672        // the parent object is absent — is the load-bearing default), with the
1673        // interval/threshold defaults.
1674        assert!(RepositoryHealthProbeSpec::enabled(None));
1675        assert_eq!(
1676            RepositoryHealthProbeSpec::effective_interval(None),
1677            DEFAULT_HEALTH_PROBE_INTERVAL
1678        );
1679        assert_eq!(
1680            RepositoryHealthProbeSpec::effective_failure_threshold(None),
1681            DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
1682        );
1683        assert_eq!(
1684            RepositoryHealthProbeSpec::effective_on_failure(None),
1685            ProbeOnFailure::Degrade,
1686            "absent onFailure must resolve to the breaker default"
1687        );
1688
1689        // A present-but-empty probe object resolves identically (the `Option`
1690        // layers must agree with the absent-parent path).
1691        let empty = RepositoryHealthSpec {
1692            probe: Some(RepositoryHealthProbeSpec::default()),
1693            ..Default::default()
1694        };
1695        assert!(RepositoryHealthProbeSpec::enabled(Some(&empty)));
1696        assert_eq!(
1697            RepositoryHealthProbeSpec::effective_on_failure(Some(&empty)),
1698            ProbeOnFailure::Degrade
1699        );
1700
1701        // Parses Go-duration string from the wire, NOT a {secs,nanos} object.
1702        let spec: RepositorySpec = from_yaml(
1703            "backend: { filesystem: { path: /repo } }\n\
1704             encryption: { passwordSecretRef: { name: s } }\n\
1705             health:\n  probe:\n    enabled: true\n    interval: 45m\n    failureThreshold: 5\n",
1706        );
1707        assert!(RepositoryHealthProbeSpec::enabled(spec.health.as_ref()));
1708        assert_eq!(
1709            RepositoryHealthProbeSpec::effective_interval(spec.health.as_ref()),
1710            std::time::Duration::from_secs(45 * 60)
1711        );
1712        assert_eq!(
1713            RepositoryHealthProbeSpec::effective_failure_threshold(spec.health.as_ref()),
1714            5
1715        );
1716
1717        // An explicit `enabled: false` is THE opt-out: it resolves false and
1718        // SURVIVES serialization (an Option<bool> elides only None — eliding
1719        // false would silently re-enable the probe on the next round-trip).
1720        let disabled: RepositorySpec = from_yaml(
1721            "backend: { filesystem: { path: /repo } }\n\
1722             encryption: { passwordSecretRef: { name: s } }\n\
1723             health:\n  probe:\n    enabled: false\n    interval: 1h\n",
1724        );
1725        assert!(!RepositoryHealthProbeSpec::enabled(
1726            disabled.health.as_ref()
1727        ));
1728        let json = serde_json::to_value(&disabled).unwrap();
1729        assert_eq!(
1730            json["health"]["probe"]["enabled"],
1731            serde_json::json!(false),
1732            "enabled: false must survive serialization"
1733        );
1734        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1735        assert_eq!(disabled, reparsed);
1736        assert!(!RepositoryHealthProbeSpec::enabled(
1737            reparsed.health.as_ref()
1738        ));
1739
1740        // Absent `enabled` inside a present probe stays None and is elided.
1741        let tuned_only: RepositorySpec = from_yaml(
1742            "backend: { filesystem: { path: /repo } }\n\
1743             encryption: { passwordSecretRef: { name: s } }\n\
1744             health:\n  probe:\n    interval: 1h\n",
1745        );
1746        assert!(RepositoryHealthProbeSpec::enabled(
1747            tuned_only.health.as_ref()
1748        ));
1749        assert!(
1750            serde_json::to_value(&tuned_only).unwrap()["health"]["probe"]
1751                .get("enabled")
1752                .is_none(),
1753            "absent enabled must be elided (no stored-object churn)"
1754        );
1755    }
1756
1757    #[test]
1758    fn probe_on_failure_round_trips_and_rejects_unknown_variants() {
1759        // Explicit `Alert` parses the cluster's way and round-trips as a string.
1760        let alert: RepositorySpec = from_yaml(
1761            "backend: { filesystem: { path: /repo } }\n\
1762             encryption: { passwordSecretRef: { name: s } }\n\
1763             health:\n  probe:\n    onFailure: Alert\n",
1764        );
1765        assert_eq!(
1766            RepositoryHealthProbeSpec::effective_on_failure(alert.health.as_ref()),
1767            ProbeOnFailure::Alert
1768        );
1769        let json = serde_json::to_value(&alert).unwrap();
1770        assert_eq!(json["health"]["probe"]["onFailure"], "Alert");
1771        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1772        assert_eq!(alert, reparsed);
1773
1774        // Explicit `Degrade` round-trips too.
1775        let degrade: RepositorySpec = from_yaml(
1776            "backend: { filesystem: { path: /repo } }\n\
1777             encryption: { passwordSecretRef: { name: s } }\n\
1778             health:\n  probe:\n    onFailure: Degrade\n",
1779        );
1780        assert_eq!(
1781            serde_json::to_value(&degrade).unwrap()["health"]["probe"]["onFailure"],
1782            "Degrade"
1783        );
1784
1785        // Absent onFailure stays None (elided; the resolver supplies Degrade).
1786        let absent: RepositorySpec = from_yaml(
1787            "backend: { filesystem: { path: /repo } }\n\
1788             encryption: { passwordSecretRef: { name: s } }\n\
1789             health:\n  probe:\n    enabled: true\n",
1790        );
1791        assert!(
1792            serde_json::to_value(&absent).unwrap()["health"]["probe"]
1793                .get("onFailure")
1794                .is_none(),
1795            "absent onFailure must be elided"
1796        );
1797        assert_eq!(
1798            RepositoryHealthProbeSpec::effective_on_failure(absent.health.as_ref()),
1799            ProbeOnFailure::Degrade
1800        );
1801
1802        // Unknown variant is rejected at decode (the closed enum IS the
1803        // validator; the CRD schema enforces the same set at admission).
1804        let v: serde_json::Value = serde_saphyr::from_str(
1805            "backend: { filesystem: { path: /repo } }\n\
1806             encryption: { passwordSecretRef: { name: s } }\n\
1807             health:\n  probe:\n    onFailure: Recreate\n",
1808        )
1809        .unwrap();
1810        assert!(
1811            serde_json::from_value::<RepositorySpec>(v).is_err(),
1812            "unknown onFailure variant must be rejected"
1813        );
1814    }
1815
1816    #[test]
1817    fn schedule_defaults_timezone_round_trips() {
1818        let spec: RepositorySpec = from_yaml(
1819            "backend: { filesystem: { path: /repo } }\n\
1820             encryption: { passwordSecretRef: { name: s } }\n\
1821             scheduleDefaults:\n  timezone: America/New_York\n",
1822        );
1823        assert_eq!(
1824            spec.schedule_defaults
1825                .as_ref()
1826                .and_then(|d| d.timezone.as_deref()),
1827            Some("America/New_York")
1828        );
1829        let json = serde_json::to_value(&spec).expect("serialize");
1830        assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
1831        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1832        assert_eq!(spec, reparsed);
1833
1834        // Absent scheduleDefaults stays None and is elided (no stored-object churn).
1835        let bare: RepositorySpec = from_yaml(
1836            "backend: { filesystem: { path: /repo } }\n\
1837             encryption: { passwordSecretRef: { name: s } }\n",
1838        );
1839        assert!(bare.schedule_defaults.is_none());
1840        assert!(
1841            serde_json::to_value(&bare)
1842                .unwrap()
1843                .get("scheduleDefaults")
1844                .is_none(),
1845            "absent scheduleDefaults must be elided"
1846        );
1847    }
1848
1849    #[test]
1850    fn identity_defaults_cluster_round_trips_on_repository() {
1851        // M5: `RepositorySpec.identityDefaults` mirrors `ClusterRepositorySpec`'s
1852        // field of the same name — same shape, same round-trip behavior (see
1853        // `cluster_repository::tests::identity_defaults_cluster_round_trips`).
1854        let spec: RepositorySpec = from_yaml(
1855            "backend: { filesystem: { path: /repo } }\n\
1856             encryption: { passwordSecretRef: { name: s } }\n\
1857             identityDefaults:\n  cluster: east\n  hostnameExpr: namespace\n",
1858        );
1859        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
1860        assert_eq!(id.cluster.as_deref(), Some("east"));
1861        assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
1862        assert!(id.username_expr.is_none());
1863
1864        let json = serde_json::to_value(&spec).expect("serialize");
1865        assert_eq!(json["identityDefaults"]["cluster"], "east");
1866        assert_eq!(json["identityDefaults"]["hostnameExpr"], "namespace");
1867        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1868        assert_eq!(spec, reparsed);
1869
1870        // Absent identityDefaults stays None and is elided (no stored-object churn).
1871        let bare: RepositorySpec = from_yaml(
1872            "backend: { filesystem: { path: /repo } }\n\
1873             encryption: { passwordSecretRef: { name: s } }\n",
1874        );
1875        assert!(bare.identity_defaults.is_none());
1876        assert!(
1877            serde_json::to_value(&bare)
1878                .unwrap()
1879                .get("identityDefaults")
1880                .is_none(),
1881            "absent identityDefaults must be elided"
1882        );
1883    }
1884
1885    #[test]
1886    fn catalog_foreign_snapshots_round_trips_on_repository() {
1887        use crate::common::ForeignSnapshots;
1888
1889        let spec: RepositorySpec = from_yaml(
1890            "backend: { filesystem: { path: /repo } }\n\
1891             encryption: { passwordSecretRef: { name: s } }\n\
1892             catalog:\n  foreignSnapshots: Fallback\n",
1893        );
1894        assert_eq!(
1895            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1896            Some(ForeignSnapshots::Fallback)
1897        );
1898        let json = serde_json::to_value(&spec).expect("serialize");
1899        assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
1900        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1901        assert_eq!(spec, reparsed);
1902
1903        let spec: RepositorySpec = from_yaml(
1904            "backend: { filesystem: { path: /repo } }\n\
1905             encryption: { passwordSecretRef: { name: s } }\n\
1906             catalog:\n  foreignSnapshots: Ignore\n",
1907        );
1908        assert_eq!(
1909            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1910            Some(ForeignSnapshots::Ignore)
1911        );
1912
1913        // Absent stays None and is elided.
1914        let bare: RepositorySpec = from_yaml(
1915            "backend: { filesystem: { path: /repo } }\n\
1916             encryption: { passwordSecretRef: { name: s } }\n\
1917             catalog: {}\n",
1918        );
1919        assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
1920        assert!(
1921            serde_json::to_value(&bare).unwrap()["catalog"]
1922                .get("foreignSnapshots")
1923                .is_none(),
1924            "absent catalog.foreignSnapshots must be elided"
1925        );
1926    }
1927
1928    #[test]
1929    fn catalog_adoption_round_trips_on_repository() {
1930        use crate::common::SnapshotAdoption;
1931
1932        let spec: RepositorySpec = from_yaml(
1933            "backend: { filesystem: { path: /repo } }\n\
1934             encryption: { passwordSecretRef: { name: s } }\n\
1935             catalog:\n  adoption: Ignore\n",
1936        );
1937        assert_eq!(
1938            spec.catalog.as_ref().and_then(|c| c.adoption),
1939            Some(SnapshotAdoption::Ignore)
1940        );
1941        let json = serde_json::to_value(&spec).expect("serialize");
1942        assert_eq!(json["catalog"]["adoption"], "Ignore");
1943        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1944        assert_eq!(spec, reparsed);
1945
1946        // Absent stays None and is elided; no schema default (context-dependent).
1947        let bare: RepositorySpec = from_yaml(
1948            "backend: { filesystem: { path: /repo } }\n\
1949             encryption: { passwordSecretRef: { name: s } }\n\
1950             catalog: {}\n",
1951        );
1952        assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
1953        assert!(
1954            serde_json::to_value(&bare).unwrap()["catalog"]
1955                .get("adoption")
1956                .is_none(),
1957            "absent catalog.adoption must be elided"
1958        );
1959
1960        let crd = Repository::crd();
1961        let crd_json = serde_json::to_value(&crd).unwrap();
1962        let prop = &crd_json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1963            ["properties"]["catalog"]["properties"]["adoption"];
1964        assert!(
1965            prop.get("default").is_none(),
1966            "catalog.adoption must NOT carry a schema default: {prop}"
1967        );
1968    }
1969}