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(°rade).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}