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