Skip to main content

kopiur_api/
repository.rs

1//! The `Repository` CRD — a namespaced kopia repository. ADR-0003 §3.1.
2
3use crate::backend::Backend;
4use crate::common::{
5    CatalogBounds, CreateBehavior, DeletionProtectionSpec, Encryption, FailurePolicy,
6    IdentityDefaults, MoverDefaults, NamespaceDeletePolicy, RepositoryMode, ScheduleDefaults,
7    default_namespace_delete_policy, default_repository_mode,
8};
9use crate::maintenance::RepositoryMaintenanceSpec;
10use crate::server::{ServerSpec, ServerStatus};
11use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
12use kube::CustomResource;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16/// A kopia repository owned by one namespace, referenced by `SnapshotPolicy`s and `Restore`s.
17#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
18#[kube(
19    group = "kopiur.home-operations.com",
20    version = "v1alpha1",
21    kind = "Repository",
22    namespaced,
23    status = "RepositoryStatus",
24    shortname = "kopiarepo",
25    category = "kopiur",
26    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
27    printcolumn = r#"{"name":"Backend","type":"string","jsonPath":".status.backend"}"#,
28    printcolumn = r#"{"name":"Server","type":"string","jsonPath":".status.server.endpoint"}"#,
29    printcolumn = r#"{"name":"IndexBlobs","type":"integer","jsonPath":".status.storageStats.indexBlobCount","priority":1}"#,
30    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
31)]
32// §7/§15: create-time-immutability transition rules in the CRD schema (apiserver +
33// CI), complementing the webhook checks. The `create.*` rules only bite when `create`
34// is present on both sides. `encryption` (the password Secret reference) is deliberately
35// NOT locked: kopia fixes only the resolved password value in the repo format, never the
36// Secret name/key, and the reference is not a reliable proxy (a rename with identical
37// content must not be rejected — that broke GitOps). See `validate::diff_immutable_repo_fields`.
38// Each leaf is `has()`-guarded: CEL field access on an absent optional key raises a
39// "no such key" error (which fails the WHOLE rule → 422 on *every* update, blocking
40// the controller's finalizer/status writes), so we compare presence first and only
41// dereference when set — the common `create: {enabled: true}` case (no splitter/
42// hash/encryption/ecc) must reconcile, not wedge. Mirrors the webhook's None-vs-Some
43// semantics in `validate::diff_immutable_repo_fields`.
44#[schemars(extend("x-kubernetes-validations" = [
45    {"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"},
46    {"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"},
47    {"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"},
48    {"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"}
49]))]
50#[serde(rename_all = "camelCase")]
51pub struct RepositorySpec {
52    /// Exactly one storage backend.
53    pub backend: Backend,
54    /// Repository password (a Secret reference).
55    pub encryption: Encryption,
56    /// What to do when the repository does not yet exist (absent means it must already exist).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub create: Option<CreateBehavior>,
59    /// Tuning for the bootstrap/discovery mover Job (`<name>-discovery`) that
60    /// connects/creates an object-store repository the operator cannot reach
61    /// in-process (and re-runs for catalog re-scans).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub bootstrap: Option<BootstrapSpec>,
64    /// Base mover configuration inherited by every mover this repository spawns.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub mover_defaults: Option<MoverDefaults>,
67    /// Scheduling defaults (e.g. `timezone`) inherited by consumers that don't set
68    /// their own equivalent field — verification, replication, and maintenance
69    /// schedules today; set once here instead of repeating it on every cron.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub schedule_defaults: Option<ScheduleDefaults>,
72    /// Bounds materialization of `origin: discovered` `Snapshot` CRs from the kopia catalog.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub catalog: Option<CatalogBounds>,
75    /// Identity defaults (CEL `*Expr`) applied when consumers don't override.
76    /// Set `cluster` when this repository's backend is shared across clusters
77    /// (e.g. one bucket backed up from more than one Kubernetes cluster) — the
78    /// default kopia identity hostname then becomes `<namespace>.<cluster>`
79    /// instead of bare `<namespace>`, so same-named namespaces on different
80    /// clusters never collide. Same semantics as `ClusterRepository`'s field of
81    /// the same name (ADR-0004 §5); a consumer's explicit `spec.identity` still
82    /// wins over anything here.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub identity_defaults: Option<IdentityDefaults>,
85    /// Optional kopia web-UI server, exposed via a `Service` in this namespace.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub server: Option<ServerSpec>,
88    /// Maintenance control; when absent or enabled the reconciler creates and owns a `Maintenance` CR.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub maintenance: Option<RepositoryMaintenanceSpec>,
91    /// What happens to this repository's snapshots when a consuming namespace is deleted.
92    #[serde(default = "default_namespace_delete_policy")]
93    #[schemars(default = "default_namespace_delete_policy")]
94    pub on_namespace_delete: NamespaceDeletePolicy,
95    /// Mass-deletion circuit breaker for this repository's Snapshots.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub deletion_protection: Option<DeletionProtectionSpec>,
98    /// Access mode: `ReadWrite` (default) or `ReadOnly` (serves restores only).
99    #[serde(default = "default_repository_mode")]
100    #[schemars(default = "default_repository_mode")]
101    pub mode: RepositoryMode,
102    /// Pause this repository: skip connect/bootstrap and maintenance projection.
103    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
104    pub suspend: bool,
105    /// Repository health thresholds (tunes the index-blob-count warning).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub health: Option<RepositoryHealthSpec>,
108    /// Mutable kopia repository parameters, re-applied on bootstrap whenever they drift.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub parameters: Option<RepositoryParameters>,
111}
112
113/// Tuning for the bootstrap/discovery mover Job, shared by `Repository` and
114/// `ClusterRepository`. Bootstrap connects (or, with `create`, creates) an
115/// object-store repository the operator cannot reach in-process.
116#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
117#[serde(rename_all = "camelCase")]
118pub struct BootstrapSpec {
119    /// Failure policy for the bootstrap Job. `activeDeadlineSeconds` caps how long
120    /// a connect may run before the Job is marked failed (default 120s); raise it
121    /// for a slow backend — e.g. an rclone remote whose repository metadata and
122    /// indexes load through kopia's embedded `rclone serve`/WebDAV bridge.
123    /// `backoffLimit` bounds retries. `podStartupDeadlineSeconds` is accepted for
124    /// shape parity but is not honored by the bootstrap Job.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub failure_policy: Option<FailurePolicy>,
127}
128
129/// Mutable kopia repository parameters (`kopia repository set-parameters`), shared by
130/// `Repository` and `ClusterRepository`.
131///
132/// Deliberately NOT part of `spec.create` ([`crate::common::CreateBehavior`]): that block's
133/// whole contract is create-time-fixed-and-immutable, enforced by CEL and the webhook, and
134/// these are mutable on a live repository — re-applying them to an existing repo is the
135/// entire point.
136///
137/// Every field is optional with no kopiur-side default: **absent means "don't touch it"**,
138/// so declaring nothing here changes nothing about how a repository behaves. Note the
139/// consequence for GitOps — *removing* a value you previously set does not restore kopia's
140/// default, it leaves the repository at whatever you last applied. Set it back explicitly.
141#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
142#[serde(rename_all = "camelCase")]
143pub struct RepositoryParameters {
144    /// Epoch-manager tuning — how fast kopia closes epochs and therefore how fast index
145    /// blobs get compacted.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub epoch: Option<EpochParameters>,
148}
149
150/// kopia epoch-manager parameters. Absent fields are left at whatever the repository
151/// already has (kopia's defaults, noted per field).
152///
153/// An epoch may only advance once it is **older than `minDuration`** AND has accumulated
154/// `advanceOnCount` blobs or `advanceOnSizeMB` of index data. `minDuration` is a floor, so
155/// kopia's 24h default means a busy fleet — say 17 hourly policies at ~60 index blobs/hour
156/// — is forced to ~1700 blobs before an epoch may close, and kopia only compacts two epochs
157/// behind. The repository then permanently carries thousands of uncompacted index blobs,
158/// tripping `IndexBlobHealth` and slowing every mover's connect. Lowering `minDuration` is
159/// the lever for that (#258).
160///
161/// `cleanupSafetyMargin` is deliberately **observable but not settable**: its job is to stop
162/// kopia deleting index blobs a concurrent writer still needs, and there is no safe generic
163/// advice for lowering it.
164#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
165#[serde(rename_all = "camelCase")]
166pub struct EpochParameters {
167    /// Minimum epoch age before it may advance (kopia default `24h`). A Go-style duration
168    /// (`6h`, `90m`). The advance **gate** — no blob count closes an epoch younger than this.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub min_duration: Option<String>,
171    /// How often clients re-read epoch state (kopia default `20m`). Go-style duration.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub refresh_frequency: Option<String>,
174    /// Index blobs in an epoch that trigger an advance, once older than `minDuration`
175    /// (kopia default `20`).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub advance_on_count: Option<i64>,
178    /// Total index size in an epoch that triggers an advance, once older than `minDuration`
179    /// (kopia default `10` MiB).
180    ///
181    /// Named `MiB`, not `MB`, with an explicit rename rather than the derived camelCase
182    /// (`advanceOnSizeMb`, which reads as *megabit*). The unit is genuinely mebibytes —
183    /// kopia's `--epoch-advance-on-size-mb` multiplies by 1048576, so `10` is 10485760
184    /// bytes — even though kopia's own log renders the result as "MB". That ambiguity is
185    /// this field's main hazard; the API surface should not reproduce it.
186    #[serde(
187        default,
188        rename = "advanceOnSizeMiB",
189        skip_serializing_if = "Option::is_none"
190    )]
191    pub advance_on_size_mb: Option<i64>,
192    /// Epochs between full index checkpoints (kopia default `7`).
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub checkpoint_frequency: Option<i64>,
195    /// Parallelism for epoch cleanup deletions (kopia default `4`).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub delete_parallelism: Option<i64>,
198}
199
200/// The epoch parameters the repository ACTUALLY reports, mirrored into `status` from
201/// `kopia repository status` at the last bootstrap.
202///
203/// A separate type from [`EpochParameters`] rather than a reuse, for three reasons: the CRD
204/// spec type cannot hold kopia's full output (it omits `enabled` and `cleanupSafetyMargin`);
205/// `Option` would mean opposite things in the two positions (spec `None` = "don't touch",
206/// status `None` = "kopia didn't report it"); and non-`Option` fields encode "observed means
207/// complete" in the type. Same reasoning as [`StorageStats`] being its own type.
208///
209/// This is what makes the apply honest: it is best-effort, so a failed apply stays visible
210/// here as drift from `spec` instead of silently doing nothing.
211#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
212#[serde(rename_all = "camelCase")]
213pub struct ObservedEpochParameters {
214    /// Whether kopia's epoch manager is enabled on this repository at all.
215    pub enabled: bool,
216    /// Observed minimum epoch age, as a Go-style duration.
217    pub min_duration: String,
218    /// Observed epoch-state refresh frequency, as a Go-style duration.
219    pub refresh_frequency: String,
220    /// Observed cleanup safety margin, as a Go-style duration. Reported for diagnosis;
221    /// not settable through `spec.parameters`.
222    pub cleanup_safety_margin: String,
223    /// Observed index-blob count that triggers an epoch advance.
224    pub advance_on_count: i64,
225    /// Observed total index size (MiB) that triggers an epoch advance.
226    #[serde(rename = "advanceOnSizeMiB")]
227    pub advance_on_size_mb: i64,
228    /// Observed epochs between full index checkpoints.
229    pub checkpoint_frequency: i64,
230    /// Observed epoch-cleanup delete parallelism.
231    pub delete_parallelism: i64,
232}
233
234/// Observed kopia repository parameters, mirrored into `status`.
235#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
236#[serde(rename_all = "camelCase")]
237pub struct ObservedRepositoryParameters {
238    /// The epoch parameters the repository reports.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub epoch: Option<ObservedEpochParameters>,
241}
242
243/// Repository health thresholds, shared by `Repository` and `ClusterRepository`.
244#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct RepositoryHealthSpec {
247    /// Index-blob count above which the reconciler raises the `IndexBlobHealth` warning (`0` disables).
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    #[schemars(default = "default_index_blob_warn_threshold")]
250    pub index_blob_warn_threshold: Option<i64>,
251    /// Opt-in periodic backend health probe: re-connect a `Ready` repository on a
252    /// timer to confirm the kopia repository still exists at the backend.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub probe: Option<RepositoryHealthProbeSpec>,
255}
256
257/// schemars default for [`RepositoryHealthSpec::index_blob_warn_threshold`] —
258/// [`DEFAULT_INDEX_BLOB_WARN_THRESHOLD`](crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD).
259/// Returns the field's `Option` type so schemars 1 emits the schema `default:`
260/// (which the apiserver materializes on admission). Safe because
261/// `resolve_index_blob_warn_threshold` resolves an absent field to exactly this
262/// constant — server-side defaulting changes the stored shape, not behavior.
263fn default_index_blob_warn_threshold() -> Option<i64> {
264    Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
265}
266
267/// Opt-in backend health probe, shared by `Repository` and `ClusterRepository`.
268///
269/// Once a repository reaches `Ready`, the operator trusts that pinned status and
270/// — for object-store / volume-backed backends — never re-checks the backend on
271/// its steady-state heartbeat. If the kopia repository is wiped or becomes
272/// unreachable, nothing notices until a backup runs and fails. Enabling this
273/// probe re-connects the backend every [`interval`](Self::interval) and surfaces
274/// the result as a condition + Warning event (the repository **stays `Ready`** —
275/// this is alert-only; it never auto-recreates and never pauses backups).
276///
277/// **Alert-only by design.** A wiped repository and a transient outage look alike,
278/// and silently recreating an empty repository over a real one destroys
279/// restorability — so the probe only *reports*. Acting on the alert (a deliberate
280/// re-create) is a human decision.
281#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
282#[serde(rename_all = "camelCase")]
283pub struct RepositoryHealthProbeSpec {
284    /// Turn the probe on. Off by default — existing repositories keep their
285    /// current behavior until a user opts in.
286    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
287    pub enabled: bool,
288    /// How often to re-probe the backend (Go-style duration like `30m` or `1h`;
289    /// minimum `30s`, default `30m`). Inert unless `enabled`.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    #[schemars(default = "default_health_probe_interval")]
292    pub interval: Option<String>,
293    /// How many *consecutive* failing probes to require before raising the loud
294    /// condition + event (default `3`). Debounces a single transient blip from
295    /// alarming or nudging a destructive manual recreate. Any success resets it.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    #[schemars(default = "default_health_probe_failure_threshold")]
298    pub failure_threshold: Option<i64>,
299}
300
301/// schemars default for [`RepositoryHealthProbeSpec::interval`] — the string
302/// form of [`DEFAULT_HEALTH_PROBE_INTERVAL`](crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
303/// (`30m`). `effective_interval` resolves an absent/unparseable value to that
304/// same duration, so materializing `30m` is behavior-preserving; the field is
305/// inert unless `probe.enabled`. A unit test pins `"30m"` to the constant.
306fn default_health_probe_interval() -> Option<String> {
307    Some("30m".to_string())
308}
309
310/// schemars default for [`RepositoryHealthProbeSpec::failure_threshold`] —
311/// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`](crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
312/// (`3`), matching `effective_failure_threshold`'s absent→CONST resolution.
313fn default_health_probe_failure_threshold() -> Option<i64> {
314    Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
315}
316
317impl RepositoryHealthProbeSpec {
318    /// Whether the backend health probe is opted in (`spec.health.probe.enabled`).
319    /// Off by default, so an existing `Ready` repository keeps its behavior.
320    pub fn enabled(health: Option<&RepositoryHealthSpec>) -> bool {
321        health
322            .and_then(|h| h.probe.as_ref())
323            .is_some_and(|p| p.enabled)
324    }
325
326    /// The effective probe cadence used **when the probe is enabled**:
327    /// `interval` when set and parseable, else [`DEFAULT_HEALTH_PROBE_INTERVAL`].
328    /// (The webhook rejects an unparseable value, so the fallback only covers
329    /// objects admitted before the validator existed.)
330    ///
331    /// [`DEFAULT_HEALTH_PROBE_INTERVAL`]: crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL
332    pub fn effective_interval(health: Option<&RepositoryHealthSpec>) -> std::time::Duration {
333        health
334            .and_then(|h| h.probe.as_ref())
335            .and_then(|p| p.interval.as_deref())
336            .and_then(crate::duration::parse_go_duration)
337            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL)
338    }
339
340    /// The effective consecutive-failure threshold before the loud condition is
341    /// raised: `failureThreshold` when set (clamped to at least 1), else
342    /// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`].
343    ///
344    /// [`DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD`]: crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
345    pub fn effective_failure_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
346        health
347            .and_then(|h| h.probe.as_ref())
348            .and_then(|p| p.failure_threshold)
349            .map(|t| t.max(1))
350            .unwrap_or(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
351    }
352}
353
354/// Resolve the effective index-blob warning threshold from an optional
355/// `spec.health`. Pure, so it's shared by the admission webhook, the controller,
356/// and tests without forking the default/disable semantics:
357///
358/// * absent spec or unset field ⇒
359///   [`DEFAULT_INDEX_BLOB_WARN_THRESHOLD`](crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD),
360/// * `Some(0)` ⇒ `0` (the sentinel that disables the warning),
361/// * `Some(n)` ⇒ `n`.
362///
363/// ```
364/// use kopiur_api::repository::{resolve_index_blob_warn_threshold, RepositoryHealthSpec};
365/// use kopiur_api::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD;
366///
367/// assert_eq!(resolve_index_blob_warn_threshold(None), DEFAULT_INDEX_BLOB_WARN_THRESHOLD);
368/// let h = RepositoryHealthSpec { index_blob_warn_threshold: Some(0), ..Default::default() };
369/// assert_eq!(resolve_index_blob_warn_threshold(Some(&h)), 0); // disabled
370/// let h = RepositoryHealthSpec { index_blob_warn_threshold: Some(250), ..Default::default() };
371/// assert_eq!(resolve_index_blob_warn_threshold(Some(&h)), 250);
372/// ```
373pub fn resolve_index_blob_warn_threshold(health: Option<&RepositoryHealthSpec>) -> i64 {
374    health
375        .and_then(|h| h.index_blob_warn_threshold)
376        .unwrap_or(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
377}
378
379/// Lifecycle phase of a repository. A freshly admitted CR starts in `Pending`.
380#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
381pub enum RepositoryPhase {
382    /// Accepted by the API server but not yet reconciled.
383    #[default]
384    Pending,
385    /// Connecting to (or creating) the kopia repository.
386    Initializing,
387    /// Connected and healthy.
388    Ready,
389    /// Reachable, but a sub-operation (e.g. maintenance) is failing; see conditions.
390    Degraded,
391    /// Connect/create failed; see conditions for the actionable reason.
392    Failed,
393}
394
395impl crate::common::PhaseLabel for RepositoryPhase {
396    const ALL: &'static [Self] = &[
397        Self::Pending,
398        Self::Initializing,
399        Self::Ready,
400        Self::Degraded,
401        Self::Failed,
402    ];
403    fn label(&self) -> &'static str {
404        match self {
405            Self::Pending => "Pending",
406            Self::Initializing => "Initializing",
407            Self::Ready => "Ready",
408            Self::Degraded => "Degraded",
409            Self::Failed => "Failed",
410        }
411    }
412}
413
414/// Observed state of a `Repository`, carrying resolved values pinned by the reconciler.
415#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
416#[serde(rename_all = "camelCase")]
417pub struct RepositoryStatus {
418    /// Current lifecycle phase.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub phase: Option<RepositoryPhase>,
421    /// `metadata.generation` of the `spec` last reconciled; drives staleness detection.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub observed_generation: Option<i64>,
424    /// `resourceVersion` of the password Secret observed at the last connect attempt.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub resolved_credential_version: Option<String>,
427    /// Kopia repository unique ID.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub unique_id: Option<String>,
430    /// Mirror of `spec.backend` discriminant for the print column.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub backend: Option<String>,
433    /// Repository size and snapshot counts from the last catalog scan.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub storage_stats: Option<StorageStats>,
436    /// Catalog-materialization status (how many discovered `Snapshot`s, last refresh).
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub catalog: Option<CatalogStatus>,
439    /// Resolved kopia server endpoint/auth, pinned by the reconciler.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub server: Option<ServerStatus>,
442    /// Last reverify-request token honored from a `Snapshot`'s re-probe nudge
443    /// (RFC3339); the loop guard that keeps each request a one-shot.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub last_reverify_at: Option<String>,
446    /// Backend health-probe state (`spec.health.probe`), when enabled.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub health: Option<RepositoryHealthStatus>,
449    /// The kopia repository parameters actually observed at the last bootstrap. Compare
450    /// against `spec.parameters` to see whether a declared value landed.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub parameters: Option<ObservedRepositoryParameters>,
453    /// Standard Kubernetes conditions (e.g. `Connected`, `MaintenanceOwned`).
454    #[serde(default, skip_serializing_if = "Vec::is_empty")]
455    pub conditions: Vec<Condition>,
456}
457
458/// Backend health-probe state, shared by `Repository` and `ClusterRepository`.
459/// Pinned by the reconciler when `spec.health.probe` is enabled so the next
460/// reconcile can tell whether a probe is due and how many consecutive failures
461/// have accrued (the debounce that keeps a transient blip from raising the loud
462/// `RepositoryVanished` / `BackendReachable=False` condition).
463#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
464#[serde(rename_all = "camelCase")]
465pub struct RepositoryHealthStatus {
466    /// RFC 3339 timestamp of the last completed probe (success or failure); drives
467    /// the `health_probe_due` timer so the probe re-fires on cadence.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub last_probe_at: Option<String>,
470    /// RFC 3339 timestamp of the last *successful* probe (backend reachable, repo present).
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub last_healthy_at: Option<String>,
473    /// Consecutive failing probes accrued; reset to zero on any success. The loud
474    /// condition is raised only once this reaches the failure threshold.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub consecutive_probe_failures: Option<i64>,
477    /// RFC 3339 timestamp of the first failure in the current failing streak.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub first_failure_at: Option<String>,
480    /// RFC 3339 timestamp at which the last backend health probe was *launched*
481    /// (the bootstrap Job created for it), cleared when its result is finalized.
482    ///
483    /// This is the **launch-side** rate limit, and it is what makes the probe
484    /// terminate. `lastProbeAt` is written only at *finalize*, so gating the
485    /// launch on that alone recycles the bootstrap Job forever: the gate destroys
486    /// every Job whose completion would have cleared it (#273). Never compared
487    /// against `lastProbeAt` — see `kopiur_controller::health::probe_action`.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub probe_attempt_at: Option<String>,
490}
491
492/// Aggregate repository storage figures from the last catalog scan.
493#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
494#[serde(rename_all = "camelCase")]
495pub struct StorageStats {
496    /// Total snapshots present in the repository (across all identities).
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub snapshot_count: Option<i64>,
499    /// Human-readable total on-disk size (e.g. `412Gi`).
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub total_size: Option<String>,
502    /// Logical bytes under management (the integer form of `total_size`): the sum,
503    /// over each distinct snapshot source, of the most-recent snapshot's logical
504    /// size. Exposed to backup preflight as `repository.sizeBytes`. This is
505    /// repository *total size*, not backend free space (object stores don't report
506    /// remaining capacity).
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub total_size_bytes: Option<i64>,
509    /// RFC 3339 timestamp these stats were last observed.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub last_observed_at: Option<String>,
512    /// Number of content-index blobs (`kopia index list`) observed at the last bootstrap.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub index_blob_count: Option<i64>,
515}
516
517/// Status of catalog materialization for `origin: discovered` `Snapshot` CRs.
518#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
519#[serde(rename_all = "camelCase")]
520pub struct CatalogStatus {
521    /// How many `Snapshot` CRs were materialized from the catalog scan.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub discovered_backup_count: Option<i64>,
524    /// RFC 3339 timestamp of the last catalog refresh.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub last_refresh_at: Option<String>,
527    /// Snapshots in the last complete listing classified as another cluster's
528    /// (see `catalog.foreignSnapshots`); never materialized under `Ignore`.
529    /// As of `catalog.lastRefreshAt` — enable `periodicRefresh` to keep it
530    /// current.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub foreign_snapshot_count: Option<i64>,
533    /// The RFC3339 token VALUE of the `kopiur.home-operations.com/catalog-scan-requested-at`
534    /// annotation last honored by a completed catalog scan. Compared by
535    /// **equality** against the live annotation to decide whether a requested
536    /// on-demand scan is still pending — deliberately NOT a timestamp comparison
537    /// against `lastRefreshAt` (a periodic refresh completing after the request
538    /// was made would otherwise look like it honored the request even though it
539    /// started before the annotation was set).
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub scan_request_honored: Option<String>,
542    /// RFC 3339 timestamp of the last bootstrap/scan attempt initiated BECAUSE OF
543    /// a pending `catalog-scan-requested-at` token (i.e. the token arm was the
544    /// reason the attempt fired). Used only to rate-limit token-driven attempts
545    /// on a Ready-but-unreachable repository — it is never compared against the
546    /// token for retirement (that's `scanRequestHonored`, by equality).
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub scan_request_attempt_at: Option<String>,
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::common::RepositoryMode;
555    use crate::testutil::from_yaml;
556    use kube::core::CustomResourceExt;
557
558    #[test]
559    fn repository_schema_emits_context_free_defaults() {
560        // brume's complaint: "everything is usually null for the defaults" in the
561        // CRD/JSON schema. These context-free constants must surface as a schema
562        // `default:` (visible in kubectl explain / the YAML language server, and
563        // consumed by the generated field reference). The apiserver materializes
564        // them server-side, which is safe ONLY because each field's resolver maps
565        // absent → exactly this value (see the paired default fns).
566        let crd = Repository::crd();
567        let json = serde_json::to_value(&crd).unwrap();
568        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
569        assert_eq!(
570            spec["properties"]["health"]["properties"]["indexBlobWarnThreshold"]["default"],
571            serde_json::json!(1000)
572        );
573        assert_eq!(
574            spec["properties"]["health"]["properties"]["probe"]["properties"]["interval"]["default"],
575            serde_json::json!("30m")
576        );
577        assert_eq!(
578            spec["properties"]["health"]["properties"]["probe"]["properties"]["failureThreshold"]["default"],
579            serde_json::json!(3)
580        );
581        assert_eq!(
582            spec["properties"]["catalog"]["properties"]["refreshInterval"]["default"],
583            serde_json::json!("1h")
584        );
585        assert_eq!(
586            spec["properties"]["server"]["properties"]["service"]["properties"]["port"]["default"],
587            serde_json::json!(51515)
588        );
589    }
590
591    #[test]
592    fn health_probe_interval_schema_default_matches_the_duration_constant() {
593        // The schema default is a STRING ("30m") but the controller resolves an
594        // absent value to the Duration constant. If someone changes the constant
595        // without the string (or vice-versa), server-side defaulting would
596        // materialize a value that no longer equals the resolver's fallback.
597        let s = default_health_probe_interval().expect("some");
598        assert_eq!(
599            crate::duration::parse_go_duration(&s),
600            Some(crate::consts::DEFAULT_HEALTH_PROBE_INTERVAL),
601            "default_health_probe_interval() string must parse to DEFAULT_HEALTH_PROBE_INTERVAL"
602        );
603        assert_eq!(
604            default_health_probe_failure_threshold(),
605            Some(crate::consts::DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD)
606        );
607        assert_eq!(
608            default_index_blob_warn_threshold(),
609            Some(crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD)
610        );
611    }
612
613    #[test]
614    fn mode_suspend_and_ecc_roundtrip() {
615        // ADR-0005 §11/§14(e)/§13(a): mode, suspend, and create.ecc parse the
616        // cluster's way and round-trip.
617        let yaml = r#"
618backend: { filesystem: { path: /repo } }
619encryption: { passwordSecretRef: { name: s } }
620create:
621  enabled: true
622  encryption: AES256-GCM-HMAC-SHA256
623  ecc:
624    algorithm: REED-SOLOMON-CRC32
625    overheadPercent: 2
626mode: ReadOnly
627suspend: true
628"#;
629        let spec: RepositorySpec = from_yaml(yaml);
630        assert_eq!(spec.mode, RepositoryMode::ReadOnly);
631        assert!(!spec.mode.allows_writes());
632        assert!(spec.suspend);
633        let ecc = spec.create.as_ref().unwrap().ecc.as_ref().expect("ecc");
634        assert_eq!(ecc.algorithm.as_deref(), Some("REED-SOLOMON-CRC32"));
635        assert_eq!(ecc.overhead_percent, Some(2));
636
637        let json = serde_json::to_value(&spec).expect("serialize");
638        assert_eq!(json["mode"], "ReadOnly");
639        assert_eq!(json["suspend"], true);
640        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
641        assert_eq!(spec, reparsed);
642    }
643
644    #[test]
645    fn bootstrap_failure_policy_round_trips() {
646        let spec: RepositorySpec = from_yaml(
647            r#"
648backend: { rclone: { remotePath: "mydrive:backups", startupTimeout: 2m } }
649encryption: { passwordSecretRef: { name: s } }
650bootstrap:
651  failurePolicy:
652    activeDeadlineSeconds: 600
653    backoffLimit: 1
654"#,
655        );
656        let fp = spec
657            .bootstrap
658            .as_ref()
659            .and_then(|b| b.failure_policy.as_ref())
660            .expect("bootstrap.failurePolicy");
661        assert_eq!(fp.active_deadline_seconds, Some(600));
662        assert_eq!(fp.backoff_limit, Some(1));
663        // Absent bootstrap stays None.
664        let bare: RepositorySpec = from_yaml(
665            r#"
666backend: { filesystem: { path: /repo } }
667encryption: { passwordSecretRef: { name: s } }
668"#,
669        );
670        assert!(bare.bootstrap.is_none());
671    }
672
673    #[test]
674    fn health_threshold_parses_and_resolver_honors_default_and_disable() {
675        // Absent spec.health → default threshold.
676        let bare: RepositorySpec = from_yaml(
677            r#"
678backend: { filesystem: { path: /repo } }
679encryption: { passwordSecretRef: { name: s } }
680"#,
681        );
682        assert!(bare.health.is_none());
683        assert_eq!(
684            resolve_index_blob_warn_threshold(bare.health.as_ref()),
685            crate::consts::DEFAULT_INDEX_BLOB_WARN_THRESHOLD
686        );
687
688        // Explicit override parses the cluster's way and resolves verbatim.
689        let tuned: RepositorySpec = from_yaml(
690            r#"
691backend: { filesystem: { path: /repo } }
692encryption: { passwordSecretRef: { name: s } }
693health:
694  indexBlobWarnThreshold: 250
695"#,
696        );
697        assert_eq!(
698            resolve_index_blob_warn_threshold(tuned.health.as_ref()),
699            250
700        );
701
702        // 0 is the disable sentinel (not "fall back to default").
703        let disabled: RepositorySpec = from_yaml(
704            r#"
705backend: { filesystem: { path: /repo } }
706encryption: { passwordSecretRef: { name: s } }
707health:
708  indexBlobWarnThreshold: 0
709"#,
710        );
711        assert_eq!(
712            resolve_index_blob_warn_threshold(disabled.health.as_ref()),
713            0
714        );
715    }
716
717    #[test]
718    fn deletion_protection_threshold_schema_default_matches_the_constant() {
719        // Mirrors repository_schema_emits_context_free_defaults: a context-free
720        // default is safe to server-side-materialize because
721        // effective_mass_deletion_threshold maps absent → this same value.
722        let crd = Repository::crd();
723        let json = serde_json::to_value(&crd).unwrap();
724        let spec = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"];
725        assert_eq!(
726            spec["properties"]["deletionProtection"]["properties"]["threshold"]["default"],
727            serde_json::json!(crate::consts::DEFAULT_MASS_DELETION_THRESHOLD)
728        );
729        assert_eq!(
730            crate::consts::effective_mass_deletion_threshold(None),
731            crate::consts::DEFAULT_MASS_DELETION_THRESHOLD
732        );
733    }
734
735    #[test]
736    fn deletion_protection_round_trips_and_zero_disables() {
737        use crate::common::DeletionProtectionSpec;
738
739        let spec: RepositorySpec = from_yaml(
740            "backend: { filesystem: { path: /repo } }\n\
741             encryption: { passwordSecretRef: { name: s } }\n\
742             deletionProtection:\n  threshold: 25\n",
743        );
744        assert_eq!(
745            spec.deletion_protection.as_ref().and_then(|d| d.threshold),
746            Some(25)
747        );
748        assert_eq!(
749            crate::consts::effective_mass_deletion_threshold(spec.deletion_protection.as_ref()),
750            25
751        );
752        let json = serde_json::to_value(&spec).expect("serialize");
753        assert_eq!(json["deletionProtection"]["threshold"], 25);
754        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
755        assert_eq!(spec, reparsed);
756
757        // `Some(0)` is the disable sentinel — it passes through, not "fall back to default".
758        let disabled = DeletionProtectionSpec { threshold: Some(0) };
759        assert_eq!(
760            crate::consts::effective_mass_deletion_threshold(Some(&disabled)),
761            0
762        );
763
764        // Absent deletionProtection stays None and is elided (no stored-object churn).
765        let bare: RepositorySpec = from_yaml(
766            "backend: { filesystem: { path: /repo } }\n\
767             encryption: { passwordSecretRef: { name: s } }\n",
768        );
769        assert!(bare.deletion_protection.is_none());
770        assert!(
771            serde_json::to_value(&bare)
772                .unwrap()
773                .get("deletionProtection")
774                .is_none(),
775            "absent deletionProtection must be elided"
776        );
777    }
778
779    #[test]
780    fn storage_stats_index_blob_count_roundtrips() {
781        let stats = StorageStats {
782            snapshot_count: Some(12),
783            total_size: None,
784            total_size_bytes: Some(442_000_000),
785            last_observed_at: None,
786            index_blob_count: Some(1448),
787        };
788        let json = serde_json::to_value(&stats).unwrap();
789        assert_eq!(json["indexBlobCount"], 1448);
790        assert_eq!(json["totalSizeBytes"], 442_000_000_i64);
791        let back: StorageStats = serde_json::from_value(json).unwrap();
792        assert_eq!(back, stats);
793    }
794
795    #[test]
796    fn catalog_status_foreign_snapshot_count_roundtrips() {
797        let status: CatalogStatus = from_yaml(
798            "discoveredBackupCount: 42\nlastRefreshAt: 2026-06-01T00:00:00Z\nforeignSnapshotCount: 7\n",
799        );
800        assert_eq!(status.foreign_snapshot_count, Some(7));
801        let json = serde_json::to_value(&status).unwrap();
802        assert_eq!(json["foreignSnapshotCount"], 7);
803        let back: CatalogStatus = serde_json::from_value(json).unwrap();
804        assert_eq!(back, status);
805
806        // Absent stays None and is elided (no stored-object churn).
807        let bare: CatalogStatus = from_yaml("{}\n");
808        assert!(bare.foreign_snapshot_count.is_none());
809        assert!(
810            serde_json::to_value(&bare)
811                .unwrap()
812                .get("foreignSnapshotCount")
813                .is_none(),
814            "absent foreignSnapshotCount must be elided"
815        );
816    }
817
818    #[test]
819    fn catalog_status_scan_request_honored_roundtrips() {
820        let status: CatalogStatus = from_yaml(
821            "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestHonored: 2026-06-01T00:00:00Z\n",
822        );
823        assert_eq!(
824            status.scan_request_honored.as_deref(),
825            Some("2026-06-01T00:00:00Z")
826        );
827        let json = serde_json::to_value(&status).unwrap();
828        assert_eq!(json["scanRequestHonored"], "2026-06-01T00:00:00Z");
829        let back: CatalogStatus = serde_json::from_value(json).unwrap();
830        assert_eq!(back, status);
831
832        // Absent stays None and is elided.
833        let bare: CatalogStatus = from_yaml("{}\n");
834        assert!(bare.scan_request_honored.is_none());
835        assert!(
836            serde_json::to_value(&bare)
837                .unwrap()
838                .get("scanRequestHonored")
839                .is_none(),
840            "absent scanRequestHonored must be elided"
841        );
842    }
843
844    #[test]
845    fn catalog_status_scan_request_attempt_at_roundtrips() {
846        let status: CatalogStatus = from_yaml(
847            "lastRefreshAt: 2026-06-01T00:00:00Z\nscanRequestAttemptAt: 2026-06-01T00:05:00Z\n",
848        );
849        assert_eq!(
850            status.scan_request_attempt_at.as_deref(),
851            Some("2026-06-01T00:05:00Z")
852        );
853        let json = serde_json::to_value(&status).unwrap();
854        assert_eq!(json["scanRequestAttemptAt"], "2026-06-01T00:05:00Z");
855        let back: CatalogStatus = serde_json::from_value(json).unwrap();
856        assert_eq!(back, status);
857
858        // Absent stays None and is elided.
859        let bare: CatalogStatus = from_yaml("{}\n");
860        assert!(bare.scan_request_attempt_at.is_none());
861        assert!(
862            serde_json::to_value(&bare)
863                .unwrap()
864                .get("scanRequestAttemptAt")
865                .is_none(),
866            "absent scanRequestAttemptAt must be elided"
867        );
868    }
869
870    #[test]
871    fn repository_crd_exposes_index_blobs_print_column() {
872        let crd = Repository::crd();
873        let json = serde_json::to_value(&crd).unwrap();
874        let cols = json["spec"]["versions"][0]["additionalPrinterColumns"]
875            .as_array()
876            .expect("printer columns present");
877        assert!(
878            cols.iter().any(|c| c["name"] == "IndexBlobs"
879                && c["jsonPath"] == ".status.storageStats.indexBlobCount"),
880            "Repository must surface the IndexBlobs print column"
881        );
882    }
883
884    #[test]
885    fn repository_crd_carries_immutability_transition_rules() {
886        // §7/§15: the spec schema carries the create.{splitter,hash,encryption,ecc}
887        // immutability transition rules — but NOT an `encryption` (password Secret ref)
888        // rule: the reference is mutable (kopia fixes only the resolved value, so a
889        // rename with identical content must pass).
890        let crd = Repository::crd();
891        let json = serde_json::to_value(&crd).unwrap();
892        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
893            ["x-kubernetes-validations"]
894            .as_array()
895            .expect("spec.x-kubernetes-validations present");
896        let has = |needle: &str| {
897            rules
898                .iter()
899                .any(|r| r["rule"].as_str().is_some_and(|s| s.contains(needle)))
900        };
901        assert!(
902            !has("self.encryption == oldSelf.encryption"),
903            "the password Secret ref must NOT be locked (a rename must be allowed)"
904        );
905        assert!(has("self.create.splitter == oldSelf.create.splitter"));
906        assert!(has("self.create.hash == oldSelf.create.hash"));
907        assert!(has("self.create.ecc == oldSelf.create.ecc"));
908    }
909
910    #[test]
911    fn create_immutability_rules_guard_each_optional_leaf_with_has() {
912        // Regression (e2e): a `create.*` immutability rule that dereferences the leaf
913        // without a `has()` guard (`self.create.splitter == oldSelf.create.splitter`)
914        // raises a CEL "no such key" error whenever `create` is present but the
915        // optional leaf is absent — the common `create: {enabled: true}` case. That
916        // error fails the WHOLE rule → the apiserver 422s *every* update, so the
917        // controller can never add its finalizer or write status and the Repository
918        // wedges below Ready. Each `create.*` leaf must therefore be `has()`-guarded.
919        let crd = Repository::crd();
920        let json = serde_json::to_value(&crd).unwrap();
921        let rules = json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
922            ["x-kubernetes-validations"]
923            .as_array()
924            .expect("spec.x-kubernetes-validations present");
925        for leaf in ["splitter", "hash", "encryption", "ecc"] {
926            let rule = rules
927                .iter()
928                .find_map(|r| {
929                    let s = r["rule"].as_str()?;
930                    s.contains(&format!("self.create.{leaf} == oldSelf.create.{leaf}"))
931                        .then_some(s)
932                })
933                .unwrap_or_else(|| panic!("missing create.{leaf} immutability rule"));
934            assert!(
935                rule.contains(&format!("has(self.create.{leaf})"))
936                    && rule.contains(&format!("has(oldSelf.create.{leaf})")),
937                "create.{leaf} immutability rule must `has()`-guard the leaf on BOTH sides \
938                 (else `create: {{enabled: true}}` 422s every update); got: {rule}"
939            );
940        }
941    }
942
943    #[test]
944    fn mode_defaults_to_readwrite_and_emits_openapi_default() {
945        // Absent ⇒ ReadWrite (parses) and the schema carries `default: ReadWrite`.
946        let spec: RepositorySpec = from_yaml(
947            "backend: { filesystem: { path: /repo } }\nencryption: { passwordSecretRef: { name: s } }\n",
948        );
949        assert_eq!(spec.mode, RepositoryMode::ReadWrite);
950        assert!(!spec.suspend);
951        // Materialized (not skip-elided), so it round-trips into the stored object.
952        assert_eq!(serde_json::to_value(&spec).unwrap()["mode"], "ReadWrite");
953
954        let crd = Repository::crd();
955        let json = serde_json::to_value(&crd).unwrap();
956        let default = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
957            ["properties"]["mode"]["default"];
958        assert_eq!(default, "ReadWrite");
959    }
960
961    #[test]
962    fn health_probe_helpers_default_and_parse() {
963        use crate::consts::{
964            DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD, DEFAULT_HEALTH_PROBE_INTERVAL,
965        };
966        // Absent spec / absent probe ⇒ disabled, defaults.
967        assert!(!RepositoryHealthProbeSpec::enabled(None));
968        assert_eq!(
969            RepositoryHealthProbeSpec::effective_interval(None),
970            DEFAULT_HEALTH_PROBE_INTERVAL
971        );
972        assert_eq!(
973            RepositoryHealthProbeSpec::effective_failure_threshold(None),
974            DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD
975        );
976
977        // Parses Go-duration string from the wire, NOT a {secs,nanos} object.
978        let spec: RepositorySpec = from_yaml(
979            "backend: { filesystem: { path: /repo } }\n\
980             encryption: { passwordSecretRef: { name: s } }\n\
981             health:\n  probe:\n    enabled: true\n    interval: 45m\n    failureThreshold: 5\n",
982        );
983        assert!(RepositoryHealthProbeSpec::enabled(spec.health.as_ref()));
984        assert_eq!(
985            RepositoryHealthProbeSpec::effective_interval(spec.health.as_ref()),
986            std::time::Duration::from_secs(45 * 60)
987        );
988        assert_eq!(
989            RepositoryHealthProbeSpec::effective_failure_threshold(spec.health.as_ref()),
990            5
991        );
992        // `enabled: false` is skip-serialized (no stored-object churn).
993        let disabled: RepositorySpec = from_yaml(
994            "backend: { filesystem: { path: /repo } }\n\
995             encryption: { passwordSecretRef: { name: s } }\n\
996             health:\n  probe:\n    interval: 1h\n",
997        );
998        assert!(!RepositoryHealthProbeSpec::enabled(
999            disabled.health.as_ref()
1000        ));
1001        let json = serde_json::to_value(&disabled).unwrap();
1002        assert!(
1003            json["health"]["probe"].get("enabled").is_none(),
1004            "enabled: false must be elided"
1005        );
1006    }
1007
1008    #[test]
1009    fn schedule_defaults_timezone_round_trips() {
1010        let spec: RepositorySpec = from_yaml(
1011            "backend: { filesystem: { path: /repo } }\n\
1012             encryption: { passwordSecretRef: { name: s } }\n\
1013             scheduleDefaults:\n  timezone: America/New_York\n",
1014        );
1015        assert_eq!(
1016            spec.schedule_defaults
1017                .as_ref()
1018                .and_then(|d| d.timezone.as_deref()),
1019            Some("America/New_York")
1020        );
1021        let json = serde_json::to_value(&spec).expect("serialize");
1022        assert_eq!(json["scheduleDefaults"]["timezone"], "America/New_York");
1023        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1024        assert_eq!(spec, reparsed);
1025
1026        // Absent scheduleDefaults stays None and is elided (no stored-object churn).
1027        let bare: RepositorySpec = from_yaml(
1028            "backend: { filesystem: { path: /repo } }\n\
1029             encryption: { passwordSecretRef: { name: s } }\n",
1030        );
1031        assert!(bare.schedule_defaults.is_none());
1032        assert!(
1033            serde_json::to_value(&bare)
1034                .unwrap()
1035                .get("scheduleDefaults")
1036                .is_none(),
1037            "absent scheduleDefaults must be elided"
1038        );
1039    }
1040
1041    #[test]
1042    fn identity_defaults_cluster_round_trips_on_repository() {
1043        // M5: `RepositorySpec.identityDefaults` mirrors `ClusterRepositorySpec`'s
1044        // field of the same name — same shape, same round-trip behavior (see
1045        // `cluster_repository::tests::identity_defaults_cluster_round_trips`).
1046        let spec: RepositorySpec = from_yaml(
1047            "backend: { filesystem: { path: /repo } }\n\
1048             encryption: { passwordSecretRef: { name: s } }\n\
1049             identityDefaults:\n  cluster: east\n  hostnameExpr: namespace\n",
1050        );
1051        let id = spec.identity_defaults.as_ref().expect("identityDefaults");
1052        assert_eq!(id.cluster.as_deref(), Some("east"));
1053        assert_eq!(id.hostname_expr.as_deref(), Some("namespace"));
1054        assert!(id.username_expr.is_none());
1055
1056        let json = serde_json::to_value(&spec).expect("serialize");
1057        assert_eq!(json["identityDefaults"]["cluster"], "east");
1058        assert_eq!(json["identityDefaults"]["hostnameExpr"], "namespace");
1059        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1060        assert_eq!(spec, reparsed);
1061
1062        // Absent identityDefaults stays None and is elided (no stored-object churn).
1063        let bare: RepositorySpec = from_yaml(
1064            "backend: { filesystem: { path: /repo } }\n\
1065             encryption: { passwordSecretRef: { name: s } }\n",
1066        );
1067        assert!(bare.identity_defaults.is_none());
1068        assert!(
1069            serde_json::to_value(&bare)
1070                .unwrap()
1071                .get("identityDefaults")
1072                .is_none(),
1073            "absent identityDefaults must be elided"
1074        );
1075    }
1076
1077    #[test]
1078    fn catalog_foreign_snapshots_round_trips_on_repository() {
1079        use crate::common::ForeignSnapshots;
1080
1081        let spec: RepositorySpec = from_yaml(
1082            "backend: { filesystem: { path: /repo } }\n\
1083             encryption: { passwordSecretRef: { name: s } }\n\
1084             catalog:\n  foreignSnapshots: Fallback\n",
1085        );
1086        assert_eq!(
1087            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1088            Some(ForeignSnapshots::Fallback)
1089        );
1090        let json = serde_json::to_value(&spec).expect("serialize");
1091        assert_eq!(json["catalog"]["foreignSnapshots"], "Fallback");
1092        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1093        assert_eq!(spec, reparsed);
1094
1095        let spec: RepositorySpec = from_yaml(
1096            "backend: { filesystem: { path: /repo } }\n\
1097             encryption: { passwordSecretRef: { name: s } }\n\
1098             catalog:\n  foreignSnapshots: Ignore\n",
1099        );
1100        assert_eq!(
1101            spec.catalog.as_ref().and_then(|c| c.foreign_snapshots),
1102            Some(ForeignSnapshots::Ignore)
1103        );
1104
1105        // Absent stays None and is elided.
1106        let bare: RepositorySpec = from_yaml(
1107            "backend: { filesystem: { path: /repo } }\n\
1108             encryption: { passwordSecretRef: { name: s } }\n\
1109             catalog: {}\n",
1110        );
1111        assert!(bare.catalog.as_ref().unwrap().foreign_snapshots.is_none());
1112        assert!(
1113            serde_json::to_value(&bare).unwrap()["catalog"]
1114                .get("foreignSnapshots")
1115                .is_none(),
1116            "absent catalog.foreignSnapshots must be elided"
1117        );
1118    }
1119
1120    #[test]
1121    fn catalog_adoption_round_trips_on_repository() {
1122        use crate::common::SnapshotAdoption;
1123
1124        let spec: RepositorySpec = from_yaml(
1125            "backend: { filesystem: { path: /repo } }\n\
1126             encryption: { passwordSecretRef: { name: s } }\n\
1127             catalog:\n  adoption: Ignore\n",
1128        );
1129        assert_eq!(
1130            spec.catalog.as_ref().and_then(|c| c.adoption),
1131            Some(SnapshotAdoption::Ignore)
1132        );
1133        let json = serde_json::to_value(&spec).expect("serialize");
1134        assert_eq!(json["catalog"]["adoption"], "Ignore");
1135        let reparsed: RepositorySpec = serde_json::from_value(json).expect("reparse");
1136        assert_eq!(spec, reparsed);
1137
1138        // Absent stays None and is elided; no schema default (context-dependent).
1139        let bare: RepositorySpec = from_yaml(
1140            "backend: { filesystem: { path: /repo } }\n\
1141             encryption: { passwordSecretRef: { name: s } }\n\
1142             catalog: {}\n",
1143        );
1144        assert!(bare.catalog.as_ref().unwrap().adoption.is_none());
1145        assert!(
1146            serde_json::to_value(&bare).unwrap()["catalog"]
1147                .get("adoption")
1148                .is_none(),
1149            "absent catalog.adoption must be elided"
1150        );
1151
1152        let crd = Repository::crd();
1153        let crd_json = serde_json::to_value(&crd).unwrap();
1154        let prop = &crd_json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
1155            ["properties"]["catalog"]["properties"]["adoption"];
1156        assert!(
1157            prop.get("default").is_none(),
1158            "catalog.adoption must NOT carry a schema default: {prop}"
1159        );
1160    }
1161}