kopiur_api/maintenance.rs
1//! The `Maintenance` CRD — schedules `kopia maintenance run` quick + full and
2//! manages the ownership lease. At most one per repository. ADR-0001 §3.7.
3
4use crate::common::{CredentialProjection, CronSpec, FailurePolicy, MoverSpec, RepositoryRef};
5use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
6use kube::CustomResource;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// The schedule an operator-managed `Maintenance` uses when the owning
11/// `Repository`/`ClusterRepository` does not override it: quick every 6h (30m
12/// jitter), full daily at 03:00 (1h jitter). Shared by the webhook (defaulting),
13/// the controller (projection), and tests, so the default lives in exactly one
14/// place. ADR §3.7.
15///
16/// ```
17/// use kopiur_api::default_maintenance_schedule;
18///
19/// let s = default_maintenance_schedule();
20/// assert_eq!(s.quick.cron, "0 */6 * * *");
21/// assert_eq!(s.quick.jitter.as_deref(), Some("30m"));
22/// assert_eq!(s.full.cron, "0 3 * * *");
23/// assert_eq!(s.full.jitter.as_deref(), Some("1h"));
24/// assert!(s.timezone.is_none());
25/// ```
26pub fn default_maintenance_schedule() -> MaintenanceSchedule {
27 MaintenanceSchedule {
28 quick: CronSpec {
29 cron: "0 */6 * * *".to_string(),
30 jitter: Some("30m".to_string()),
31 timezone: None,
32 },
33 full: CronSpec {
34 cron: "0 3 * * *".to_string(),
35 jitter: Some("1h".to_string()),
36 timezone: None,
37 },
38 timezone: None,
39 }
40}
41
42/// Maintenance schedule and ownership lease for one `Repository`/`ClusterRepository`.
43///
44/// Not `Eq`: `mover` transitively embeds k8s-openapi types.
45#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
46#[kube(
47 group = "kopiur.home-operations.com",
48 version = "v1alpha1",
49 kind = "Maintenance",
50 namespaced,
51 status = "MaintenanceStatus",
52 shortname = "kopiamaint",
53 category = "kopiur",
54 printcolumn = r#"{"name":"Repository","type":"string","jsonPath":".spec.repository.name"}"#,
55 printcolumn = r#"{"name":"Owner","type":"string","jsonPath":".status.ownership.owner"}"#,
56 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
57)]
58#[serde(rename_all = "camelCase")]
59pub struct MaintenanceSpec {
60 /// Reference to the `Repository` or `ClusterRepository` to maintain.
61 pub repository: RepositoryRef,
62 /// quick (cheap) and full (`--full`, reclamation) maintenance crons.
63 pub schedule: MaintenanceSchedule,
64 /// Maintenance ownership lease holder and takeover policy.
65 pub ownership: Ownership,
66 /// Mover (Job pod) overrides for the maintenance run.
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub mover: Option<MoverSpec>,
69 /// How a failed maintenance run is retried and bounded (backoff, deadline).
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub failure_policy: Option<FailurePolicy>,
72 /// Opt-in projection of the repository's credential Secret(s) into this run's namespace (default off).
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub credential_projection: Option<CredentialProjection>,
75}
76
77/// quick (cheap) and full (`--full`, reclamation) maintenance crons and a shared timezone.
78#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
79#[serde(rename_all = "camelCase")]
80pub struct MaintenanceSchedule {
81 /// Cron and jitter for `kopia maintenance run` (quick, cheap index/log work).
82 pub quick: CronSpec,
83 /// Cron and jitter for `kopia maintenance run --full` (content reclamation).
84 pub full: CronSpec,
85 /// IANA timezone both crons are evaluated in; absent means the controller default.
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub timezone: Option<String>,
88}
89
90/// Maintenance ownership lease holder and takeover policy.
91#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
92#[serde(rename_all = "camelCase")]
93pub struct Ownership {
94 /// Stable lease holder identity (e.g. `kopia-operator/nas-primary`).
95 pub owner: String,
96 /// Previous lease strings still recognized as SELF (a migration path):
97 /// when kopia's currently-recorded maintenance owner matches the owner
98 /// derived from one of these aliases, a run treats the lease as its own —
99 /// it claims it and re-stamps `owner`, upgrading the recorded owner to the
100 /// current format. The operator populates this when a repository's managed
101 /// Maintenance moves to a cluster-qualified lease (identityDefaults.cluster),
102 /// so the transition never yields the lease to what merely looks like a
103 /// foreign owner.
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub owner_aliases: Vec<String>,
106 /// What to do when the lease is already held by a different `owner`.
107 #[serde(default)]
108 pub takeover_policy: TakeoverPolicy,
109}
110
111/// What to do when another owner already holds the lease. Defaults to `Never`
112/// (the safest: never seize a lease another owner holds).
113#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
114pub enum TakeoverPolicy {
115 /// Never take over a lease another owner holds (default, safest).
116 #[default]
117 Never,
118 /// Surface a condition prompting an operator to decide.
119 PromptCondition,
120 /// Forcibly claim the lease.
121 Force,
122}
123
124/// What to do about the ownership lease, decided from the takeover policy and
125/// whether another owner currently holds it (ADR §3.7). Exhaustive over
126/// [`TakeoverPolicy`].
127///
128/// Lives in `kopiur-api` (not the controller) because the lease decision is made
129/// in the mover for object-store repositories — only something with repo access
130/// can read `kopia maintenance info` to learn the current holder. Keeping the
131/// pure decision here gives the controller (filesystem) and the mover
132/// (object-store) one shared, exhaustively-matched source of truth.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum LeaseAction {
135 /// Claim the lease (we hold it or it is free).
136 Claim,
137 /// Forcibly take the lease from the current holder.
138 Takeover,
139 /// Surface a condition prompting a human to decide; do not claim.
140 Prompt,
141 /// Another owner holds it and policy is `Never`: do nothing, requeue.
142 Yield,
143}
144
145/// Decide the lease action. `held_by_other` is true when a *different* owner
146/// currently holds the maintenance lease for this repository.
147///
148/// ```
149/// use kopiur_api::{lease_action, LeaseAction, TakeoverPolicy};
150///
151/// // Free (or already ours) → always claim, regardless of policy.
152/// assert_eq!(lease_action(TakeoverPolicy::Never, false), LeaseAction::Claim);
153/// // Held by another → dispatch on policy.
154/// assert_eq!(lease_action(TakeoverPolicy::Never, true), LeaseAction::Yield);
155/// assert_eq!(lease_action(TakeoverPolicy::Force, true), LeaseAction::Takeover);
156/// ```
157pub fn lease_action(policy: TakeoverPolicy, held_by_other: bool) -> LeaseAction {
158 if !held_by_other {
159 // Free or already ours → just (re)claim.
160 return LeaseAction::Claim;
161 }
162 match policy {
163 TakeoverPolicy::Never => LeaseAction::Yield,
164 TakeoverPolicy::PromptCondition => LeaseAction::Prompt,
165 TakeoverPolicy::Force => LeaseAction::Takeover,
166 }
167}
168
169/// The logical maintenance-lease string the operator uses for a repository's
170/// DEFAULT-MANAGED `Maintenance` (ADR §3.7). Single derivation, shared by the
171/// managed-Maintenance projection and the bootstrap mover's initial kopia
172/// owner stamp, so they cannot drift.
173///
174/// `cluster` is `Repository`/`ClusterRepository.spec.identityDefaults.cluster`
175/// (M1/M5): `None` keeps the original, pre-multi-cluster format (a same-named
176/// `ClusterRepository`/`Repository` in two clusters would otherwise derive the
177/// SAME lease and race each other's maintenance on a shared repo — kopia has no
178/// cross-host maintenance lock of its own, only this owner lease); `Some(c)`
179/// inserts the cluster as the second path segment so each cluster claims a
180/// distinct lease (and, via [`kopia_lease_identity`], a distinct kopia owner)
181/// for what is otherwise the same repository name.
182///
183/// ```
184/// use kopiur_api::common::RepositoryKind;
185/// use kopiur_api::maintenance::managed_lease;
186///
187/// assert_eq!(
188/// managed_lease(RepositoryKind::Repository, "media", "nas", None),
189/// "kopiur/media/nas"
190/// );
191/// assert_eq!(
192/// managed_lease(RepositoryKind::Repository, "media", "nas", Some("east")),
193/// "kopiur/east/media/nas"
194/// );
195/// assert_eq!(
196/// managed_lease(RepositoryKind::ClusterRepository, "ignored", "shared", None),
197/// "kopiur/clusterrepository/shared"
198/// );
199/// assert_eq!(
200/// managed_lease(RepositoryKind::ClusterRepository, "ignored", "shared", Some("east")),
201/// "kopiur/east/clusterrepository/shared"
202/// );
203/// ```
204pub fn managed_lease(
205 kind: crate::common::RepositoryKind,
206 namespace: &str,
207 name: &str,
208 cluster: Option<&str>,
209) -> String {
210 use crate::common::RepositoryKind;
211 match (kind, cluster) {
212 (RepositoryKind::Repository, None) => format!("kopiur/{namespace}/{name}"),
213 (RepositoryKind::Repository, Some(c)) => format!("kopiur/{c}/{namespace}/{name}"),
214 (RepositoryKind::ClusterRepository, None) => {
215 format!("kopiur/clusterrepository/{name}")
216 }
217 (RepositoryKind::ClusterRepository, Some(c)) => {
218 format!("kopiur/{c}/clusterrepository/{name}")
219 }
220 }
221}
222
223/// The mover-owned condition recording lease state on `Maintenance.status`:
224/// `True` (lease claimed, run proceeded) or `False` with one of the reasons
225/// below. Written by the mover, matched by the controller (Ready degradation)
226/// and the kubectl plugin — one definition so the producers and readers cannot
227/// drift.
228pub const LEASE_OWNED_CONDITION: &str = "LeaseOwned";
229/// `LeaseOwned=False` reason: a foreign owner holds the lease and
230/// `takeoverPolicy: Never` — the run yielded.
231pub const LEASE_HELD_BY_OTHER_REASON: &str = "LeaseHeldByOther";
232/// `LeaseOwned=False` reason: a foreign owner holds the lease and
233/// `takeoverPolicy: PromptCondition` — the run yielded, prompting the operator
234/// to set `Force`.
235pub const LEASE_TAKEOVER_PROMPT_REASON: &str = "LeaseTakeoverPrompt";
236
237/// Hostname-unsafe-character rule shared by every `kopia_lease_identity` path:
238/// lowercase, `[a-z0-9-]` kept, everything else collapses to `-`, repeated `-`
239/// collapsed, then trimmed from both ends. Pulled out so the character class
240/// itself can never drift between the legacy (whole-string) and
241/// cluster-qualified (per-segment) sanitizers below.
242fn sanitize_lease_fragment(s: &str) -> String {
243 let mut out: String = s
244 .to_ascii_lowercase()
245 .chars()
246 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
247 .collect();
248 while out.contains("--") {
249 out = out.replace("--", "-");
250 }
251 out.trim_matches('-').to_string()
252}
253
254/// A single DNS label's worth of cap for the legacy (pre-multi-cluster)
255/// hostname derivation. Unchanged from the original implementation — every
256/// existing doctest/behavior for a non-cluster-qualified lease must stay
257/// byte-identical.
258const LEGACY_HOSTNAME_MAX: usize = 63;
259
260/// Cap for the cluster-qualified (dot-joined) hostname derivation below —
261/// kopia's identity hostname has no real limit, but this mirrors DNS's overall
262/// name ceiling as a defensive backstop (see [`kopia_lease_identity`] doc).
263const CLUSTER_HOSTNAME_MAX: usize = 253;
264
265/// The STABLE kopia client identity a maintenance mover assumes for `lease`
266/// (`(username, hostname)`); the mover sets it with `kopia repository
267/// set-client` so kopia's designated-owner check compares something stable —
268/// the pod's own identity is ephemeral (a new hostname every run), which is
269/// why comparing kopia's recorded owner against it can never work.
270///
271/// The derivation forks on the lease's own shape (the mover only ever sees the
272/// lease STRING, never the CR it came from, so the rule must be derivable from
273/// the string alone):
274///
275/// * **Exactly 4 `/`-separated segments, AND the first is literally `"kopiur"`**
276/// — [`managed_lease`]'s two cluster-qualified formats
277/// (`kopiur/{cluster}/{namespace}/{name}`,
278/// `kopiur/{cluster}/clusterrepository/{name}`) — sanitize each segment
279/// INDEPENDENTLY (the same character rule, but with no per-segment cap and
280/// without ever crossing a segment boundary) and join with `.`. Every
281/// generated segment is already a lowercase, dot-free RFC 1123 label (the
282/// cluster name is validated as such; Kubernetes namespace/resource names
283/// are too), so for a generated lease this is verbatim
284/// `kopiur.{cluster}.{namespace}.{name}` — and CRITICALLY, injective: two
285/// leases can only produce the same hostname if they were `/`-split
286/// identically, because a `-` inside one segment can no longer be forged
287/// into a fake `.` boundary the way collapsing everything to `-` allowed
288/// (`kopiur/east-prod/db/x` and `kopiur/east/prod-db/x` used to collide).
289/// No per-segment cap also means a long cluster/namespace/name no longer
290/// collides via truncation — capped defensively only on the TOTAL, at
291/// [`CLUSTER_HOSTNAME_MAX`] (253, the identity-hostname byte cap enforced by
292/// [`crate::validate::validate_identity_component`]), which cluster (≤32) +
293/// two Kubernetes names + `"kopiur"` cannot reach in practice.
294///
295/// The `"kopiur"`-first-segment check matters because [`managed_lease`] is
296/// NOT the only source of lease strings: `Ownership.owner` is a free-form
297/// field a user can hand-author, and a hand-authored value that HAPPENS to
298/// have 4 `/`-separated segments (e.g. `a/b/c/d`) is not one of our
299/// generated formats at all. Gating the dot-join on the reserved `"kopiur"`
300/// prefix — which [`managed_lease`] always emits and a user has no reason to
301/// — guarantees a hand-authored owner's derivation can never change across
302/// an operator upgrade merely because it happens to split into 4 segments;
303/// it always falls to the legacy whole-string sanitizer below, exactly as it
304/// did pre-M6. Without this gate, such an owner would silently switch from
305/// its `a-b-c-d` identity to `a.b.c.d`, and with `takeoverPolicy: Never` the
306/// repository's maintenance would then yield forever.
307/// * **Any other shape** (the legacy 3-segment formats, a 4-segment lease NOT
308/// `"kopiur"`-prefixed, or any other hand-authored `Ownership.owner`/alias)
309/// — the ORIGINAL whole-string sanitizer: collapse the entire lease through
310/// the same character rule and cap at [`LEGACY_HOSTNAME_MAX`] (63, a DNS
311/// label). Byte-identical to every pre-M6 lease this function has ever
312/// produced.
313///
314/// ```
315/// use kopiur_api::maintenance::kopia_lease_identity;
316///
317/// // Legacy (3-segment / hand-authored): unchanged.
318/// assert_eq!(
319/// kopia_lease_identity("kopiur/media/nas"),
320/// ("kopiur".to_string(), "kopiur-media-nas".to_string())
321/// );
322///
323/// // Cluster-qualified (4-segment, "kopiur"-prefixed): dot-joined, segment-preserving.
324/// assert_eq!(
325/// kopia_lease_identity("kopiur/east/media/nas"),
326/// ("kopiur".to_string(), "kopiur.east.media.nas".to_string())
327/// );
328///
329/// // A hand-authored 4-segment owner that is NOT "kopiur"-prefixed: legacy
330/// // sanitization, byte-identical to pre-M6 — never dot-joined.
331/// assert_eq!(
332/// kopia_lease_identity("a/b/c/d"),
333/// ("kopiur".to_string(), "a-b-c-d".to_string())
334/// );
335///
336/// // Injective: a '-' inside a segment can no longer masquerade as a boundary.
337/// assert_ne!(
338/// kopia_lease_identity("kopiur/east-prod/db/x").1,
339/// kopia_lease_identity("kopiur/east/prod-db/x").1
340/// );
341/// ```
342pub fn kopia_lease_identity(lease: &str) -> (String, String) {
343 let segments: Vec<&str> = lease.split('/').collect();
344 let host = match segments.as_slice() {
345 [a, b, c, d] if *a == "kopiur" => {
346 let joined = [a, b, c, d]
347 .iter()
348 .map(|s| sanitize_lease_fragment(s))
349 .collect::<Vec<_>>()
350 .join(".");
351 let capped: String = joined.chars().take(CLUSTER_HOSTNAME_MAX).collect();
352 capped.trim_end_matches(['.', '-']).to_string()
353 }
354 _ => {
355 let host = sanitize_lease_fragment(lease);
356 let capped: String = host.chars().take(LEGACY_HOSTNAME_MAX).collect();
357 capped.trim_end_matches('-').to_string()
358 }
359 };
360 ("kopiur".to_string(), host)
361}
362
363/// The full `user@hostname` owner string kopia records for `lease` — what the
364/// mover compares `maintenance info`'s owner against, and what the bootstrap
365/// stamps on a repository it CREATES.
366///
367/// ```
368/// use kopiur_api::maintenance::kopia_owner_for_lease;
369///
370/// assert_eq!(kopia_owner_for_lease("kopiur/media/nas"), "kopiur@kopiur-media-nas");
371/// ```
372pub fn kopia_owner_for_lease(lease: &str) -> String {
373 let (user, host) = kopia_lease_identity(lease);
374 format!("{user}@{host}")
375}
376
377/// Whether kopia's currently-recorded maintenance owner is a DIFFERENT owner
378/// than us — i.e. neither our own lease's owner nor one of our recognized
379/// [`Ownership::owner_aliases`] (the migration path: a repo whose managed
380/// `Maintenance` moved to a new lease format still recognizes the owner it
381/// used to stamp as itself, so the transition claims and re-stamps rather than
382/// yielding to what would otherwise look like a foreign owner).
383///
384/// `current` is empty for a never-run repository (kopia's own "no owner set"
385/// state) — never "held by another".
386///
387/// ```
388/// use kopiur_api::maintenance::{kopia_owner_for_lease, lease_held_by_other};
389///
390/// let lease = "kopiur/east/media/nas";
391/// let alias = "kopiur/media/nas"; // the pre-cluster lease this repo used to use
392/// let mine = kopia_owner_for_lease(lease);
393/// let legacy = kopia_owner_for_lease(alias);
394///
395/// // Never-run repository: empty owner is never "held by another".
396/// assert!(!lease_held_by_other("", lease, &[]));
397/// // Already ours: not held by another.
398/// assert!(!lease_held_by_other(&mine, lease, &[]));
399/// // The recognized alias's owner: treated as self (migration path).
400/// assert!(!lease_held_by_other(&legacy, lease, &[alias.to_string()]));
401/// // The SAME string but the alias isn't registered: a genuine foreign owner.
402/// assert!(lease_held_by_other(&legacy, lease, &[]));
403/// // Any other owner: foreign.
404/// assert!(lease_held_by_other("someone@else", lease, &[alias.to_string()]));
405/// ```
406pub fn lease_held_by_other(current: &str, lease: &str, aliases: &[String]) -> bool {
407 if current.is_empty() {
408 return false;
409 }
410 if current == kopia_owner_for_lease(lease) {
411 return false;
412 }
413 !aliases
414 .iter()
415 .any(|alias| current == kopia_owner_for_lease(alias))
416}
417
418/// The `kubectl kopiur` invocation that stamps a `Maintenance` run request,
419/// quoted in the fix hint when the annotation does not parse.
420const MAINTENANCE_RUN_COMMAND: &str = "kubectl kopiur maintenance run";
421
422/// Parse the `run-requested`/`run-mode` annotations into a manual-run request.
423/// `Ok(None)` = no request; `Err` = the annotations are present but malformed
424/// (the messages say how to fix). Shared by the admission webhook and the
425/// controller so validation cannot fork (SKILL "one validator, two callers").
426///
427/// The timestamp half is [`crate::common::parse_run_requested_at`] — the one
428/// parser every "run it now" surface shares (both replication kinds call it
429/// directly); only the `run-mode` companion is maintenance-specific.
430pub fn parse_run_annotations(
431 annotations: Option<&std::collections::BTreeMap<String, String>>,
432) -> Result<Option<(chrono::DateTime<chrono::Utc>, ManualRunMode)>, String> {
433 let Some(at) = crate::common::parse_run_requested_at(annotations, MAINTENANCE_RUN_COMMAND)?
434 else {
435 return Ok(None);
436 };
437 let mode = match annotations.and_then(|a| a.get(crate::consts::RUN_MODE_ANNOTATION)) {
438 None => ManualRunMode::Quick,
439 Some(raw_mode) => ManualRunMode::parse(raw_mode).ok_or_else(|| {
440 format!(
441 "annotation {} must be `quick` or `full` (got {raw_mode:?}). \
442 Fix: re-annotate with a valid mode",
443 crate::consts::RUN_MODE_ANNOTATION
444 )
445 })?,
446 };
447 Ok(Some((at, mode)))
448}
449
450/// Inline maintenance control on a `Repository`/`ClusterRepository` (`spec.maintenance`).
451///
452/// Not `Eq`: `mover` transitively embeds k8s-openapi types.
453#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
454#[serde(rename_all = "camelCase")]
455pub struct RepositoryMaintenanceSpec {
456 /// Whether the operator manages a `Maintenance` CR for this repository (default `true`).
457 #[serde(default = "crate::common::default_true")]
458 pub enabled: bool,
459 /// Schedule override; absent uses the default quick-6h / full-daily schedule.
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub schedule: Option<MaintenanceSchedule>,
462 /// Mover overrides for the managed `Maintenance`.
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub mover: Option<MoverSpec>,
465 /// Failure handling (backoff/deadline) for the managed `Maintenance` run.
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub failure_policy: Option<FailurePolicy>,
468 /// Lease takeover policy for the managed `Maintenance` (default `Never`).
469 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub takeover_policy: Option<TakeoverPolicy>,
471 /// ClusterRepository only: namespace the managed `Maintenance` CR is created in (default the operator's namespace).
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub namespace: Option<String>,
474}
475
476impl Default for RepositoryMaintenanceSpec {
477 /// Default-on with no overrides. `enabled` is `true` here to match the serde
478 /// `default_true` so a constructed default and a deserialized `{}` agree.
479 fn default() -> Self {
480 Self {
481 enabled: true,
482 schedule: None,
483 mover: None,
484 failure_policy: None,
485 takeover_policy: None,
486 namespace: None,
487 }
488 }
489}
490
491/// Observed maintenance state: lease holder and per-kind run results.
492#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
493#[serde(rename_all = "camelCase")]
494pub struct MaintenanceStatus {
495 /// The `metadata.generation` this status reflects, for staleness detection.
496 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub observed_generation: Option<i64>,
498 /// Current lease holder, if the lease has been claimed.
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub ownership: Option<OwnershipStatus>,
501 /// Last/next-run state for the quick maintenance schedule.
502 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub quick: Option<RunStatus>,
504 /// Last/next-run state for the full maintenance schedule.
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub full: Option<RunStatus>,
507 /// Standard Kubernetes conditions surfacing maintenance health.
508 #[serde(default, skip_serializing_if = "Vec::is_empty")]
509 pub conditions: Vec<Condition>,
510 /// State of the most recent annotation-requested out-of-band run; absent until one is requested.
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub manual_run: Option<ManualRunStatus>,
513 /// Content-index blob count re-counted right after the most recent successful run.
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub observed_index_blobs: Option<ObservedIndexBlobs>,
516}
517
518/// The repository's content-index blob count as re-counted immediately after a
519/// successful maintenance run, with the instant it was taken.
520///
521/// Why this lives on `Maintenance` and not only on the repository: the
522/// repository's own `status.storageStats.indexBlobCount` is written by the
523/// bootstrap path, which runs on the catalog cadence and can be hours or days
524/// stale. Compaction happens during maintenance, so the count right after a run
525/// is the freshest truth there is — and without it the `IndexBlobHealth`
526/// warning keeps quoting a pre-compaction number long after compaction fixed
527/// it. The repository reconciler reads this and adopts it when it is newer than
528/// its own observation, which is what the timestamp is for.
529///
530/// Written by the **mover**, best-effort (a `kopia index list` on an unhealthy
531/// repository is exactly the slow case), and read by the repository
532/// reconcilers. Absent when the recount did not complete — never `0`, which
533/// would read as a perfectly healthy index.
534#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
535#[serde(rename_all = "camelCase")]
536pub struct ObservedIndexBlobs {
537 /// Number of content-index blobs (`kopia index list`) after the run.
538 #[serde(default)]
539 pub count: i64,
540 /// RFC3339 instant the recount was taken. What makes this observation
541 /// comparable against the repository's own `storageStats.indexBlobCountAt`,
542 /// so whichever is newer wins rather than whichever was written last.
543 #[serde(default)]
544 pub observed_at: String,
545}
546
547/// Which maintenance kind a manual (annotation-requested) run performs; the wire
548/// values are the `run-mode` annotation values. Defaults to `quick`.
549#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
550#[serde(rename_all = "camelCase")]
551pub enum ManualRunMode {
552 /// `kopia maintenance run` (quick); the default when `run-mode` is absent.
553 #[default]
554 Quick,
555 /// `kopia maintenance run --full`.
556 Full,
557}
558
559impl ManualRunMode {
560 /// Parse a `run-mode` annotation value. Exact-match, lowercase — the same
561 /// strings serde uses on the wire.
562 pub fn parse(s: &str) -> Option<Self> {
563 match s {
564 "quick" => Some(Self::Quick),
565 "full" => Some(Self::Full),
566 _ => None,
567 }
568 }
569
570 /// The stable wire/annotation string.
571 pub fn label(self) -> &'static str {
572 match self {
573 Self::Quick => "quick",
574 Self::Full => "full",
575 }
576 }
577}
578
579/// Lifecycle of a manual run.
580///
581/// **Closed on the wire, open on decode.** The CRD schema still admits exactly
582/// `Running`/`Succeeded`/`Failed` — the apiserver rejects anything else on
583/// every write — but [`Self::Unknown`] exists so a value written by a NEWER
584/// kopiur decodes instead of failing the typed watch for the whole Kind. The
585/// schema `description` this type publishes deliberately still reads "Closed
586/// enum." and is frozen there; see `phase_serde!` on why the rustdoc and the
587/// schema text diverge.
588///
589/// ```
590/// use kopiur_api::maintenance::ManualRunPhase;
591///
592/// assert_eq!(serde_json::to_value(ManualRunPhase::Running).unwrap(), "Running");
593/// // An unrecognized phase from a newer operator decodes instead of erroring.
594/// let p: ManualRunPhase = serde_json::from_value(serde_json::json!("Queued")).unwrap();
595/// assert_eq!(p, ManualRunPhase::Unknown("Queued".into()));
596/// assert_eq!(serde_json::to_value(&p).unwrap(), "Queued");
597/// ```
598#[derive(Clone, Debug, PartialEq, Eq)]
599pub enum ManualRunPhase {
600 /// The mover Job for this request is in flight.
601 Running,
602 /// The run finished successfully or yielded the lease cleanly (see the `LeaseOwned` condition).
603 Succeeded,
604 /// The run's Job failed; conditions carry the detail.
605 Failed,
606 /// A phase string this build does not recognize (newer operator, or legacy
607 /// stored data). Decode-compat only — hidden from the CRD schema, never
608 /// produced by this build. Never counted as a finished run, so a
609 /// re-run request is never deduped against it.
610 Unknown(String),
611}
612
613crate::common::phase_serde!(ManualRunPhase, "Lifecycle of a manual run. Closed enum.");
614
615impl crate::common::PhaseLabel for ManualRunPhase {
616 const ALL: &'static [Self] = &[Self::Running, Self::Succeeded, Self::Failed];
617 fn label(&self) -> &str {
618 match self {
619 Self::Running => "Running",
620 Self::Succeeded => "Succeeded",
621 Self::Failed => "Failed",
622 Self::Unknown(s) => s,
623 }
624 }
625 fn unknown(raw: String) -> Self {
626 Self::Unknown(raw)
627 }
628}
629
630/// Bookkeeping for the most recent annotation-requested run.
631#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
632#[serde(rename_all = "camelCase")]
633pub struct ManualRunStatus {
634 /// The `run-requested` annotation value this status reflects (RFC3339).
635 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub requested_at: Option<String>,
637 /// The run kind that was performed.
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub mode: Option<ManualRunMode>,
640 /// Where the run is in its lifecycle.
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub phase: Option<ManualRunPhase>,
643 /// RFC3339 instant the run reached a terminal phase.
644 // Deliberately serialized EVEN WHEN `None` (no `skip_serializing_if`), for
645 // the same reason as `common::ReplicationManualRunStatus::completed_at`: a
646 // non-terminal phase emits `"completedAt": null` so the merge-patch CLEARS
647 // the previous run's stamp instead of leaving it standing over a fresh
648 // `Running` (#394). The apiserver answers that null by deleting the key
649 // (plain RFC-7386) or by storing the null verbatim — a nullable CRD field on
650 // k8s 1.33 was observed doing the latter — and both decode back to `None`,
651 // so no stale timestamp survives either way. Maintenance patches `manualRun`
652 // unconditionally (no noop guard), so here the stake is a truthful status
653 // rather than a non-converging write loop.
654 //
655 // This depends on `patch_status` sending `kube::api::Patch::Merge`
656 // (`crates/controller/src/io/apply.rs`). Under `Patch::Apply` an explicit
657 // null does NOT clear the field, and this contract silently breaks.
658 //
659 // Kept a plain comment rather than rustdoc on purpose: doc comments become
660 // the CRD `description` (`kubectl explain`, docs/src/content/docs/field-reference.md).
661 #[serde(default)]
662 pub completed_at: Option<String>,
663}
664
665/// Observed ownership-lease state: who holds it and since when.
666#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
667#[serde(rename_all = "camelCase")]
668pub struct OwnershipStatus {
669 /// The current lease holder's identity (matches `Ownership.owner`).
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub owner: Option<String>,
672 /// RFC3339 instant the lease was claimed.
673 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub claimed_at: Option<String>,
675}
676
677/// Per-kind (quick/full) run status.
678#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
679#[serde(rename_all = "camelCase")]
680pub struct RunStatus {
681 /// RFC3339 instant of the most recent run of this kind.
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub last_run_at: Option<String>,
684 /// RFC3339 instant of the next scheduled run of this kind (cron + jitter, pinned).
685 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub next_scheduled_at: Option<String>,
687 /// RFC3339 instant the controller last observed this kind's per-slot Job reach terminal success.
688 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub last_handled_at: Option<String>,
690 /// Count of back-to-back failed runs of this kind; resets on success.
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub consecutive_failures: Option<i64>,
693 /// Bytes of backend storage the most recent run of this kind actually freed:
694 /// the blobs kopia deleted (unreferenced packs, superseded epoch indexes and
695 /// expired logs), summed from the run history `kopia maintenance info`
696 /// reports. It deliberately EXCLUDES kopia's snapshot-GC figure, which only
697 /// marks contents deleted in the index and frees no storage until a later run
698 /// removes the blobs. Absent when the run reclaimed nothing measurable — a
699 /// quick run on an epoch-enabled repository only advances and compacts
700 /// epochs, so there is no figure to report; `0` always means a measured zero,
701 /// never "unknown".
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub last_content_reclaimed_bytes: Option<i64>,
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use crate::common::{PhaseLabel, RepositoryKind};
710 use crate::testutil::from_yaml;
711 use kube::core::CustomResourceExt;
712
713 #[test]
714 fn manual_run_phase_all_covers_every_variant_uniquely() {
715 // `ManualRunPhase` was the one phase enum with no `PhaseLabel` impl, so
716 // nothing could enumerate it. Same tripwire as the other phases: every
717 // variant in ALL, unique non-empty labels, and the label string equal to
718 // the serde encoding (this phase is written to `status.manualRun.phase`,
719 // so a label that drifts from the wire value would mislabel metrics and
720 // any CLI rendering).
721 let labels: Vec<&str> = ManualRunPhase::ALL.iter().map(|p| p.label()).collect();
722 assert_eq!(ManualRunPhase::ALL.len(), 3);
723 assert!(labels.iter().all(|l| !l.is_empty()));
724 let mut sorted = labels.clone();
725 sorted.sort_unstable();
726 sorted.dedup();
727 assert_eq!(sorted.len(), labels.len(), "phase labels must be unique");
728 for p in ManualRunPhase::ALL {
729 assert_eq!(
730 serde_json::to_value(p).expect("serialize"),
731 p.label(),
732 "{p:?}"
733 );
734 }
735 }
736
737 #[test]
738 fn manual_run_status_roundtrips_camel_case_and_nulls_a_missing_completion() {
739 // Parsed the cluster's way (YAML -> serde_json::Value -> typed), which
740 // is the only path that proves the camelCase wire names land.
741 let status: MaintenanceStatus = from_yaml(
742 "manualRun:\n requestedAt: 2026-06-11T12:00:00Z\n mode: full\n phase: Succeeded\n completedAt: 2026-06-11T12:01:42Z\n",
743 );
744 let manual = status.manual_run.expect("manualRun decodes");
745 assert_eq!(manual.requested_at.as_deref(), Some("2026-06-11T12:00:00Z"));
746 assert_eq!(manual.mode, Some(ManualRunMode::Full));
747 assert_eq!(manual.phase, Some(ManualRunPhase::Succeeded));
748 assert_eq!(manual.completed_at.as_deref(), Some("2026-06-11T12:01:42Z"));
749 let json = serde_json::to_value(&manual).unwrap();
750 assert_eq!(json["requestedAt"], "2026-06-11T12:00:00Z");
751 assert_eq!(json["mode"], "full");
752 assert_eq!(json["phase"], "Succeeded");
753 assert_eq!(json["completedAt"], "2026-06-11T12:01:42Z");
754
755 // #394: a non-terminal run emits an EXPLICIT null completedAt, so the
756 // merge-patch clears the previous run's stamp rather than leaving it
757 // standing over a fresh `Running`. Whether the apiserver deletes the key
758 // or stores the null, it decodes back to `None`.
759 let running = serde_json::to_value(ManualRunStatus {
760 requested_at: Some("2026-06-11T13:00:00Z".into()),
761 mode: Some(ManualRunMode::Quick),
762 phase: Some(ManualRunPhase::Running),
763 completed_at: None,
764 })
765 .unwrap();
766 assert_eq!(
767 running,
768 serde_json::json!({
769 "requestedAt": "2026-06-11T13:00:00Z",
770 "mode": "quick",
771 "phase": "Running",
772 "completedAt": null,
773 })
774 );
775 // The explicit null reads back as "no completion instant".
776 let back: ManualRunStatus = serde_json::from_value(running).unwrap();
777 assert!(back.completed_at.is_none());
778 assert_eq!(
779 serde_json::to_value(ManualRunStatus::default()).unwrap(),
780 serde_json::json!({ "completedAt": null })
781 );
782
783 // A status that never requested a run still has NO manualRun key —
784 // the parent field keeps its own skip_serializing_if.
785 let never: MaintenanceStatus = from_yaml("observedGeneration: 3\n");
786 assert!(never.manual_run.is_none());
787 assert!(
788 serde_json::to_value(&never)
789 .unwrap()
790 .get("manualRun")
791 .is_none()
792 );
793 }
794
795 #[test]
796 fn lease_identity_is_hostname_safe_and_stable() {
797 let (user, host) = kopia_lease_identity("kopiur/media/My_App.x");
798 assert_eq!(user, "kopiur");
799 assert_eq!(host, "kopiur-media-my-app-x");
800 // Long leases cap at a DNS label and never end with '-'.
801 let (_, host) = kopia_lease_identity(&format!("kopiur/{}/x", "n".repeat(100)));
802 assert!(host.len() <= 63, "{host}");
803 assert!(!host.ends_with('-'), "{host}");
804 // Deterministic.
805 assert_eq!(
806 kopia_owner_for_lease("kopiur/media/nas"),
807 kopia_owner_for_lease("kopiur/media/nas")
808 );
809 }
810
811 #[test]
812 fn parse_run_annotations_covers_ok_default_and_garbage() {
813 use std::collections::BTreeMap;
814 assert_eq!(parse_run_annotations(None), Ok(None));
815 let mut a = BTreeMap::new();
816 a.insert(
817 crate::consts::RUN_REQUESTED_ANNOTATION.to_string(),
818 "2026-06-11T12:00:00Z".to_string(),
819 );
820 let (_, mode) = parse_run_annotations(Some(&a)).unwrap().unwrap();
821 assert_eq!(mode, ManualRunMode::Quick, "mode defaults to quick");
822 a.insert(
823 crate::consts::RUN_MODE_ANNOTATION.to_string(),
824 "full".to_string(),
825 );
826 let (_, mode) = parse_run_annotations(Some(&a)).unwrap().unwrap();
827 assert_eq!(mode, ManualRunMode::Full);
828 a.insert(
829 crate::consts::RUN_REQUESTED_ANNOTATION.to_string(),
830 "yesterday".to_string(),
831 );
832 let err = parse_run_annotations(Some(&a)).unwrap_err();
833 assert!(err.contains("must be an RFC3339 timestamp"), "{err}");
834 assert!(err.contains("kubectl kopiur maintenance run"), "{err}");
835 }
836
837 #[test]
838 fn maintenance_crd_metadata_is_correct() {
839 let crd = Maintenance::crd();
840 assert_eq!(crd.spec.group, "kopiur.home-operations.com");
841 assert_eq!(crd.spec.names.kind, "Maintenance");
842 assert_eq!(crd.spec.scope, "Namespaced");
843 assert_eq!(crd.spec.versions[0].name, "v1alpha1");
844 }
845
846 #[test]
847 fn maintenance_roundtrip_matches_adr_shape() {
848 // Mirrors ADR-0001 §3.7.
849 let yaml = r#"
850repository:
851 kind: Repository
852 name: nas-primary
853schedule:
854 quick: { cron: "0 */6 * * *", jitter: 30m }
855 full: { cron: "0 3 * * 0", jitter: 1h }
856 timezone: UTC
857ownership:
858 owner: "kopia-operator/nas-primary"
859 takeoverPolicy: PromptCondition
860mover:
861 resources: { requests: { cpu: 250m, memory: 1Gi }, limits: { cpu: "2", memory: 4Gi } }
862 securityContext: { runAsUser: 1000, runAsNonRoot: true }
863 podSecurityContext: { fsGroup: 1000 }
864failurePolicy:
865 backoffLimit: 1
866 activeDeadlineSeconds: 14400
867"#;
868 let spec: MaintenanceSpec = from_yaml(yaml);
869 assert_eq!(spec.repository.kind, RepositoryKind::Repository);
870 // The mover security contexts (container + pod) round-trip on Maintenance too.
871 let mover = spec.mover.as_ref().expect("mover");
872 assert_eq!(
873 mover.security_context.as_ref().and_then(|s| s.run_as_user),
874 Some(1000)
875 );
876 assert_eq!(
877 mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
878 Some(1000)
879 );
880 assert_eq!(spec.schedule.quick.cron, "0 */6 * * *");
881 assert_eq!(spec.schedule.quick.jitter.as_deref(), Some("30m"));
882 assert_eq!(spec.schedule.full.cron, "0 3 * * 0");
883 assert_eq!(spec.schedule.timezone.as_deref(), Some("UTC"));
884 assert_eq!(spec.ownership.owner, "kopia-operator/nas-primary");
885 assert_eq!(
886 spec.ownership.takeover_policy,
887 TakeoverPolicy::PromptCondition
888 );
889 assert_eq!(
890 spec.failure_policy
891 .as_ref()
892 .unwrap()
893 .active_deadline_seconds,
894 Some(14400)
895 );
896
897 let json = serde_json::to_value(&spec).expect("serialize");
898 let reparsed: MaintenanceSpec = serde_json::from_value(json).expect("reparse");
899 assert_eq!(spec, reparsed);
900 }
901
902 #[test]
903 fn maintenance_status_roundtrips() {
904 // Mirrors ADR-0001 §3.7 status block.
905 let yaml = r#"
906ownership:
907 owner: "kopia-operator/nas-primary"
908 claimedAt: 2026-05-12T08:14:02Z
909quick:
910 lastRunAt: 2026-05-24T12:00:11Z
911 nextScheduledAt: 2026-05-24T18:00:00Z
912 consecutiveFailures: 0
913 lastContentReclaimedBytes: 1234567
914full:
915 lastRunAt: 2026-05-19T03:01:42Z
916 nextScheduledAt: 2026-05-26T03:00:00Z
917 consecutiveFailures: 0
918 lastContentReclaimedBytes: 89456789012
919"#;
920 let status: MaintenanceStatus = from_yaml(yaml);
921 assert_eq!(
922 status.ownership.as_ref().unwrap().owner.as_deref(),
923 Some("kopia-operator/nas-primary")
924 );
925 assert_eq!(
926 status.quick.as_ref().unwrap().last_content_reclaimed_bytes,
927 Some(1234567)
928 );
929 assert_eq!(
930 status.full.as_ref().unwrap().last_content_reclaimed_bytes,
931 Some(89456789012)
932 );
933
934 let json = serde_json::to_value(&status).unwrap();
935 let reparsed: MaintenanceStatus = serde_json::from_value(json).unwrap();
936 assert_eq!(status, reparsed);
937 }
938
939 /// #458: the post-maintenance index-blob recount, parsed the cluster's way.
940 #[test]
941 fn observed_index_blobs_roundtrips_camel_case() {
942 let status: MaintenanceStatus =
943 from_yaml("observedIndexBlobs:\n count: 312\n observedAt: 2026-06-02T09:30:00Z\n");
944 let obs = status.observed_index_blobs.as_ref().expect("decodes");
945 assert_eq!(obs.count, 312);
946 assert_eq!(obs.observed_at, "2026-06-02T09:30:00Z");
947 let json = serde_json::to_value(&status).unwrap();
948 assert_eq!(json["observedIndexBlobs"]["count"], 312);
949 assert_eq!(
950 json["observedIndexBlobs"]["observedAt"],
951 "2026-06-02T09:30:00Z"
952 );
953 let reparsed: MaintenanceStatus = serde_json::from_value(json).unwrap();
954 assert_eq!(status, reparsed);
955
956 // Absent stays elided: a run that could not recount must not publish a
957 // zero count, which would read as a perfectly healthy index.
958 let empty: MaintenanceStatus = from_yaml("{}\n");
959 assert!(empty.observed_index_blobs.is_none());
960 assert!(
961 serde_json::to_value(&empty)
962 .unwrap()
963 .get("observedIndexBlobs")
964 .is_none()
965 );
966 }
967
968 /// The CRD must actually carry the recount sub-object, with both keys — the
969 /// repository reconciler reads them by name.
970 #[test]
971 fn maintenance_crd_exposes_observed_index_blobs() {
972 let crd = serde_json::to_value(Maintenance::crd()).expect("crd");
973 let props = &crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["status"]
974 ["properties"]["observedIndexBlobs"]["properties"];
975 assert_eq!(props["count"]["type"], "integer");
976 assert_eq!(props["observedAt"]["type"], "string");
977 }
978
979 #[test]
980 fn repository_maintenance_defaults_to_enabled() {
981 // An empty `spec.maintenance: {}` is default-on with no overrides.
982 let m: RepositoryMaintenanceSpec = from_yaml("{}\n");
983 assert!(
984 m.enabled,
985 "absent `enabled` must default to true (default-on)"
986 );
987 assert!(m.schedule.is_none());
988 assert!(m.namespace.is_none());
989 assert!(m.takeover_policy.is_none());
990 // The constructed Default agrees with the deserialized `{}`.
991 assert_eq!(m, RepositoryMaintenanceSpec::default());
992 }
993
994 #[test]
995 fn repository_maintenance_roundtrip_with_overrides() {
996 let yaml = r#"
997enabled: false
998schedule:
999 quick: { cron: "0 */4 * * *", jitter: 20m }
1000 full: { cron: "30 2 * * *", jitter: 45m }
1001 timezone: America/Chicago
1002takeoverPolicy: Force
1003namespace: kopia-system
1004failurePolicy:
1005 backoffLimit: 2
1006"#;
1007 let m: RepositoryMaintenanceSpec = from_yaml(yaml);
1008 assert!(!m.enabled);
1009 let s = m.schedule.as_ref().expect("schedule");
1010 assert_eq!(s.quick.cron, "0 */4 * * *");
1011 assert_eq!(s.full.jitter.as_deref(), Some("45m"));
1012 assert_eq!(s.timezone.as_deref(), Some("America/Chicago"));
1013 assert_eq!(m.takeover_policy, Some(TakeoverPolicy::Force));
1014 assert_eq!(m.namespace.as_deref(), Some("kopia-system"));
1015 assert_eq!(m.failure_policy.as_ref().unwrap().backoff_limit, Some(2));
1016
1017 let json = serde_json::to_value(&m).expect("serialize");
1018 let reparsed: RepositoryMaintenanceSpec = serde_json::from_value(json).expect("reparse");
1019 assert_eq!(m, reparsed);
1020 }
1021
1022 #[test]
1023 fn default_maintenance_schedule_is_quick_6h_full_daily() {
1024 let s = default_maintenance_schedule();
1025 assert_eq!(s.quick.cron, "0 */6 * * *");
1026 assert_eq!(s.quick.jitter.as_deref(), Some("30m"));
1027 assert_eq!(s.full.cron, "0 3 * * *");
1028 assert_eq!(s.full.jitter.as_deref(), Some("1h"));
1029 assert!(s.timezone.is_none());
1030 }
1031
1032 #[test]
1033 fn free_lease_is_claimed_regardless_of_policy() {
1034 for p in [
1035 TakeoverPolicy::Never,
1036 TakeoverPolicy::PromptCondition,
1037 TakeoverPolicy::Force,
1038 ] {
1039 assert_eq!(lease_action(p, false), LeaseAction::Claim);
1040 }
1041 }
1042
1043 #[test]
1044 fn held_lease_dispatches_by_policy() {
1045 assert_eq!(
1046 lease_action(TakeoverPolicy::Never, true),
1047 LeaseAction::Yield
1048 );
1049 assert_eq!(
1050 lease_action(TakeoverPolicy::PromptCondition, true),
1051 LeaseAction::Prompt
1052 );
1053 assert_eq!(
1054 lease_action(TakeoverPolicy::Force, true),
1055 LeaseAction::Takeover
1056 );
1057 }
1058
1059 #[test]
1060 fn takeover_policy_serializes_to_expected_strings() {
1061 assert_eq!(
1062 serde_json::to_value(TakeoverPolicy::Never).unwrap(),
1063 "Never"
1064 );
1065 assert_eq!(
1066 serde_json::to_value(TakeoverPolicy::PromptCondition).unwrap(),
1067 "PromptCondition"
1068 );
1069 assert_eq!(
1070 serde_json::to_value(TakeoverPolicy::Force).unwrap(),
1071 "Force"
1072 );
1073 assert_eq!(TakeoverPolicy::default(), TakeoverPolicy::Never);
1074 }
1075
1076 #[test]
1077 fn manual_run_mode_parses_exact_lowercase_and_defaults_to_quick() {
1078 assert_eq!(ManualRunMode::default(), ManualRunMode::Quick);
1079 assert_eq!(ManualRunMode::parse("quick"), Some(ManualRunMode::Quick));
1080 assert_eq!(ManualRunMode::parse("full"), Some(ManualRunMode::Full));
1081 assert_eq!(ManualRunMode::parse("FULL"), None); // exact, lowercase only
1082 assert_eq!(serde_json::to_value(ManualRunMode::Quick).unwrap(), "quick");
1083 }
1084
1085 // --- M6: cluster-qualified maintenance lease -----------------------------
1086
1087 #[test]
1088 fn managed_lease_covers_all_four_arms() {
1089 assert_eq!(
1090 managed_lease(RepositoryKind::Repository, "media", "nas", None),
1091 "kopiur/media/nas"
1092 );
1093 assert_eq!(
1094 managed_lease(RepositoryKind::Repository, "media", "nas", Some("east")),
1095 "kopiur/east/media/nas"
1096 );
1097 assert_eq!(
1098 managed_lease(RepositoryKind::ClusterRepository, "ignored", "shared", None),
1099 "kopiur/clusterrepository/shared"
1100 );
1101 assert_eq!(
1102 managed_lease(
1103 RepositoryKind::ClusterRepository,
1104 "ignored",
1105 "shared",
1106 Some("east")
1107 ),
1108 "kopiur/east/clusterrepository/shared"
1109 );
1110 }
1111
1112 #[test]
1113 fn legacy_lease_shapes_are_byte_identical_to_pre_m6() {
1114 // 3-segment namespaced-Repository format: unchanged.
1115 assert_eq!(
1116 kopia_lease_identity("kopiur/media/nas"),
1117 ("kopiur".to_string(), "kopiur-media-nas".to_string())
1118 );
1119 // 3-segment ClusterRepository format: unchanged.
1120 assert_eq!(
1121 kopia_lease_identity("kopiur/clusterrepository/shared"),
1122 (
1123 "kopiur".to_string(),
1124 "kopiur-clusterrepository-shared".to_string()
1125 )
1126 );
1127 // 2-segment hand-authored owner: unchanged (falls through to legacy).
1128 assert_eq!(
1129 kopia_lease_identity("kopia-operator/nas-primary"),
1130 (
1131 "kopiur".to_string(),
1132 "kopia-operator-nas-primary".to_string()
1133 )
1134 );
1135 // Mixed-case/punctuation whole-string sanitization: unchanged.
1136 let (user, host) = kopia_lease_identity("kopiur/media/My_App.x");
1137 assert_eq!(user, "kopiur");
1138 assert_eq!(host, "kopiur-media-my-app-x");
1139 // Long legacy leases still cap at a DNS label and never end with '-'.
1140 let (_, host) = kopia_lease_identity(&format!("kopiur/{}/x", "n".repeat(100)));
1141 assert!(host.len() <= 63, "{host}");
1142 assert!(!host.ends_with('-'), "{host}");
1143 }
1144
1145 /// Hardening (fix round 1): a hand-authored `Ownership.owner` that HAPPENS
1146 /// to be 4 `/`-separated segments must NOT be mistaken for one of
1147 /// `managed_lease`'s generated cluster-qualified formats — those are always
1148 /// `"kopiur"`-first-segment. Without this gate, an owner like `a/b/c/d`
1149 /// would silently change derivation from the legacy `a-b-c-d` to the
1150 /// dot-joined `a.b.c.d` across an operator upgrade, and with
1151 /// `takeoverPolicy: Never` maintenance would then yield forever.
1152 #[test]
1153 fn four_segment_non_kopiur_lease_is_legacy_byte_identical_to_pre_m6() {
1154 assert_eq!(
1155 kopia_lease_identity("a/b/c/d"),
1156 ("kopiur".to_string(), "a-b-c-d".to_string())
1157 );
1158 // Still legacy even when the segments individually look plausible.
1159 assert_eq!(
1160 kopia_lease_identity("east/media/nas/extra"),
1161 ("kopiur".to_string(), "east-media-nas-extra".to_string())
1162 );
1163 }
1164
1165 #[test]
1166 fn cluster_qualified_lease_is_dot_joined_and_injective() {
1167 // Verbatim dot-join for already-clean generated segments.
1168 assert_eq!(
1169 kopia_lease_identity("kopiur/east/media/nas"),
1170 ("kopiur".to_string(), "kopiur.east.media.nas".to_string())
1171 );
1172 assert_eq!(
1173 kopia_lease_identity("kopiur/east/clusterrepository/shared"),
1174 (
1175 "kopiur".to_string(),
1176 "kopiur.east.clusterrepository.shared".to_string()
1177 )
1178 );
1179
1180 // Adversarial: a '-' inside a segment must not be forgeable into a
1181 // fake '.' boundary (the whole-string collapse this replaces would
1182 // make these two leases collide).
1183 assert_ne!(
1184 kopia_lease_identity("kopiur/east-prod/db/x").1,
1185 kopia_lease_identity("kopiur/east/prod-db/x").1
1186 );
1187
1188 // Two 32-char clusters sharing a 31-char prefix must stay distinct —
1189 // the old cap-at-63 truncation would have collided these.
1190 let cluster_a = format!("{}x", "a".repeat(31));
1191 let cluster_b = format!("{}y", "a".repeat(31));
1192 assert_eq!(cluster_a.len(), 32);
1193 assert_eq!(cluster_b.len(), 32);
1194 let lease_a = format!("kopiur/{cluster_a}/media/nas");
1195 let lease_b = format!("kopiur/{cluster_b}/media/nas");
1196 assert_ne!(
1197 kopia_lease_identity(&lease_a).1,
1198 kopia_lease_identity(&lease_b).1
1199 );
1200
1201 // Dots land in the hostname, and the shared identity-shape validator
1202 // (kopia's username/hostname contract) accepts them: dots are
1203 // explicitly permitted, only '@'/':'/whitespace/control chars/length
1204 // are rejected.
1205 let (_, host) = kopia_lease_identity("kopiur/east/media/nas");
1206 assert!(host.contains('.'));
1207 assert!(crate::validate::validate_identity_component("hostname", &host).is_ok());
1208 }
1209
1210 #[test]
1211 fn lease_held_by_other_table() {
1212 let lease = "kopiur/east/media/nas";
1213 let alias = "kopiur/media/nas";
1214 let mine = kopia_owner_for_lease(lease);
1215 let alias_owner = kopia_owner_for_lease(alias);
1216
1217 // empty current: never-run repo, never "held by another".
1218 assert!(!lease_held_by_other("", lease, &[]));
1219 assert!(!lease_held_by_other("", lease, &[alias.to_string()]));
1220 // own owner: not held by another, with or without aliases configured.
1221 assert!(!lease_held_by_other(&mine, lease, &[]));
1222 assert!(!lease_held_by_other(&mine, lease, &[alias.to_string()]));
1223 // a registered alias's owner: treated as self (migration path).
1224 assert!(!lease_held_by_other(
1225 &alias_owner,
1226 lease,
1227 &[alias.to_string()]
1228 ));
1229 // the SAME owner string, but the alias isn't registered: foreign.
1230 assert!(lease_held_by_other(&alias_owner, lease, &[]));
1231 // a genuinely foreign owner, with an unrelated alias configured: foreign.
1232 assert!(lease_held_by_other(
1233 "someone@else",
1234 lease,
1235 &[alias.to_string()]
1236 ));
1237 }
1238}