Skip to main content

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/// Parse the `run-requested`/`run-mode` annotations into a manual-run request.
419/// `Ok(None)` = no request; `Err` = the annotations are present but malformed
420/// (the messages say how to fix). Shared by the admission webhook and the
421/// controller so validation cannot fork (SKILL "one validator, two callers").
422pub fn parse_run_annotations(
423    annotations: Option<&std::collections::BTreeMap<String, String>>,
424) -> Result<Option<(chrono::DateTime<chrono::Utc>, ManualRunMode)>, String> {
425    let Some(raw) = annotations.and_then(|a| a.get(crate::consts::RUN_REQUESTED_ANNOTATION)) else {
426        return Ok(None);
427    };
428    let at = chrono::DateTime::parse_from_rfc3339(raw)
429        .map_err(|e| {
430            format!(
431                "annotation {} must be an RFC3339 timestamp (got {raw:?}): {e}. \
432                 Fix: re-annotate with e.g. $(date -u +%Y-%m-%dT%H:%M:%SZ), or use \
433                 `kubectl kopiur maintenance run`",
434                crate::consts::RUN_REQUESTED_ANNOTATION
435            )
436        })?
437        .with_timezone(&chrono::Utc);
438    let mode = match annotations.and_then(|a| a.get(crate::consts::RUN_MODE_ANNOTATION)) {
439        None => ManualRunMode::Quick,
440        Some(raw_mode) => ManualRunMode::parse(raw_mode).ok_or_else(|| {
441            format!(
442                "annotation {} must be `quick` or `full` (got {raw_mode:?}). \
443                 Fix: re-annotate with a valid mode",
444                crate::consts::RUN_MODE_ANNOTATION
445            )
446        })?,
447    };
448    Ok(Some((at, mode)))
449}
450
451/// Inline maintenance control on a `Repository`/`ClusterRepository` (`spec.maintenance`).
452///
453/// Not `Eq`: `mover` transitively embeds k8s-openapi types.
454#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
455#[serde(rename_all = "camelCase")]
456pub struct RepositoryMaintenanceSpec {
457    /// Whether the operator manages a `Maintenance` CR for this repository (default `true`).
458    #[serde(default = "crate::common::default_true")]
459    pub enabled: bool,
460    /// Schedule override; absent uses the default quick-6h / full-daily schedule.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub schedule: Option<MaintenanceSchedule>,
463    /// Mover overrides for the managed `Maintenance`.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub mover: Option<MoverSpec>,
466    /// Failure handling (backoff/deadline) for the managed `Maintenance` run.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub failure_policy: Option<FailurePolicy>,
469    /// Lease takeover policy for the managed `Maintenance` (default `Never`).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub takeover_policy: Option<TakeoverPolicy>,
472    /// ClusterRepository only: namespace the managed `Maintenance` CR is created in (default the operator's namespace).
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub namespace: Option<String>,
475}
476
477impl Default for RepositoryMaintenanceSpec {
478    /// Default-on with no overrides. `enabled` is `true` here to match the serde
479    /// `default_true` so a constructed default and a deserialized `{}` agree.
480    fn default() -> Self {
481        Self {
482            enabled: true,
483            schedule: None,
484            mover: None,
485            failure_policy: None,
486            takeover_policy: None,
487            namespace: None,
488        }
489    }
490}
491
492/// Observed maintenance state: lease holder and per-kind run results.
493#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
494#[serde(rename_all = "camelCase")]
495pub struct MaintenanceStatus {
496    /// The `metadata.generation` this status reflects, for staleness detection.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub observed_generation: Option<i64>,
499    /// Current lease holder, if the lease has been claimed.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub ownership: Option<OwnershipStatus>,
502    /// Last/next-run state for the quick maintenance schedule.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub quick: Option<RunStatus>,
505    /// Last/next-run state for the full maintenance schedule.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub full: Option<RunStatus>,
508    /// Standard Kubernetes conditions surfacing maintenance health.
509    #[serde(default, skip_serializing_if = "Vec::is_empty")]
510    pub conditions: Vec<Condition>,
511    /// State of the most recent annotation-requested out-of-band run; absent until one is requested.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub manual_run: Option<ManualRunStatus>,
514}
515
516/// Which maintenance kind a manual (annotation-requested) run performs; the wire
517/// values are the `run-mode` annotation values. Defaults to `quick`.
518#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
519#[serde(rename_all = "camelCase")]
520pub enum ManualRunMode {
521    /// `kopia maintenance run` (quick); the default when `run-mode` is absent.
522    #[default]
523    Quick,
524    /// `kopia maintenance run --full`.
525    Full,
526}
527
528impl ManualRunMode {
529    /// Parse a `run-mode` annotation value. Exact-match, lowercase — the same
530    /// strings serde uses on the wire.
531    pub fn parse(s: &str) -> Option<Self> {
532        match s {
533            "quick" => Some(Self::Quick),
534            "full" => Some(Self::Full),
535            _ => None,
536        }
537    }
538
539    /// The stable wire/annotation string.
540    pub fn label(self) -> &'static str {
541        match self {
542            Self::Quick => "quick",
543            Self::Full => "full",
544        }
545    }
546}
547
548/// Lifecycle of a manual run. Closed enum.
549#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
550pub enum ManualRunPhase {
551    /// The mover Job for this request is in flight.
552    Running,
553    /// The run finished successfully or yielded the lease cleanly (see the `LeaseOwned` condition).
554    Succeeded,
555    /// The run's Job failed; conditions carry the detail.
556    Failed,
557}
558
559/// Bookkeeping for the most recent annotation-requested run.
560#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
561#[serde(rename_all = "camelCase")]
562pub struct ManualRunStatus {
563    /// The `run-requested` annotation value this status reflects (RFC3339).
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub requested_at: Option<String>,
566    /// The run kind that was performed.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub mode: Option<ManualRunMode>,
569    /// Where the run is in its lifecycle.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub phase: Option<ManualRunPhase>,
572    /// RFC3339 instant the run reached a terminal phase.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub completed_at: Option<String>,
575}
576
577/// Observed ownership-lease state: who holds it and since when.
578#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
579#[serde(rename_all = "camelCase")]
580pub struct OwnershipStatus {
581    /// The current lease holder's identity (matches `Ownership.owner`).
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub owner: Option<String>,
584    /// RFC3339 instant the lease was claimed.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub claimed_at: Option<String>,
587}
588
589/// Per-kind (quick/full) run status.
590#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
591#[serde(rename_all = "camelCase")]
592pub struct RunStatus {
593    /// RFC3339 instant of the most recent run of this kind.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub last_run_at: Option<String>,
596    /// RFC3339 instant of the next scheduled run of this kind (cron + jitter, pinned).
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub next_scheduled_at: Option<String>,
599    /// RFC3339 instant the controller last observed this kind's per-slot Job reach terminal success.
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub last_handled_at: Option<String>,
602    /// Count of back-to-back failed runs of this kind; resets on success.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub consecutive_failures: Option<i64>,
605    /// Bytes of storage reclaimed by the most recent run of this kind.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub last_content_reclaimed_bytes: Option<i64>,
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::common::RepositoryKind;
614    use crate::testutil::from_yaml;
615    use kube::core::CustomResourceExt;
616
617    #[test]
618    fn lease_identity_is_hostname_safe_and_stable() {
619        let (user, host) = kopia_lease_identity("kopiur/media/My_App.x");
620        assert_eq!(user, "kopiur");
621        assert_eq!(host, "kopiur-media-my-app-x");
622        // Long leases cap at a DNS label and never end with '-'.
623        let (_, host) = kopia_lease_identity(&format!("kopiur/{}/x", "n".repeat(100)));
624        assert!(host.len() <= 63, "{host}");
625        assert!(!host.ends_with('-'), "{host}");
626        // Deterministic.
627        assert_eq!(
628            kopia_owner_for_lease("kopiur/media/nas"),
629            kopia_owner_for_lease("kopiur/media/nas")
630        );
631    }
632
633    #[test]
634    fn parse_run_annotations_covers_ok_default_and_garbage() {
635        use std::collections::BTreeMap;
636        assert_eq!(parse_run_annotations(None), Ok(None));
637        let mut a = BTreeMap::new();
638        a.insert(
639            crate::consts::RUN_REQUESTED_ANNOTATION.to_string(),
640            "2026-06-11T12:00:00Z".to_string(),
641        );
642        let (_, mode) = parse_run_annotations(Some(&a)).unwrap().unwrap();
643        assert_eq!(mode, ManualRunMode::Quick, "mode defaults to quick");
644        a.insert(
645            crate::consts::RUN_MODE_ANNOTATION.to_string(),
646            "full".to_string(),
647        );
648        let (_, mode) = parse_run_annotations(Some(&a)).unwrap().unwrap();
649        assert_eq!(mode, ManualRunMode::Full);
650        a.insert(
651            crate::consts::RUN_REQUESTED_ANNOTATION.to_string(),
652            "yesterday".to_string(),
653        );
654        let err = parse_run_annotations(Some(&a)).unwrap_err();
655        assert!(err.contains("must be an RFC3339 timestamp"), "{err}");
656        assert!(err.contains("kubectl kopiur maintenance run"), "{err}");
657    }
658
659    #[test]
660    fn maintenance_crd_metadata_is_correct() {
661        let crd = Maintenance::crd();
662        assert_eq!(crd.spec.group, "kopiur.home-operations.com");
663        assert_eq!(crd.spec.names.kind, "Maintenance");
664        assert_eq!(crd.spec.scope, "Namespaced");
665        assert_eq!(crd.spec.versions[0].name, "v1alpha1");
666    }
667
668    #[test]
669    fn maintenance_roundtrip_matches_adr_shape() {
670        // Mirrors ADR-0001 §3.7.
671        let yaml = r#"
672repository:
673  kind: Repository
674  name: nas-primary
675schedule:
676  quick: { cron: "0 */6 * * *", jitter: 30m }
677  full:  { cron: "0 3 * * 0", jitter: 1h }
678  timezone: UTC
679ownership:
680  owner: "kopia-operator/nas-primary"
681  takeoverPolicy: PromptCondition
682mover:
683  resources: { requests: { cpu: 250m, memory: 1Gi }, limits: { cpu: "2", memory: 4Gi } }
684  securityContext: { runAsUser: 1000, runAsNonRoot: true }
685  podSecurityContext: { fsGroup: 1000 }
686failurePolicy:
687  backoffLimit: 1
688  activeDeadlineSeconds: 14400
689"#;
690        let spec: MaintenanceSpec = from_yaml(yaml);
691        assert_eq!(spec.repository.kind, RepositoryKind::Repository);
692        // The mover security contexts (container + pod) round-trip on Maintenance too.
693        let mover = spec.mover.as_ref().expect("mover");
694        assert_eq!(
695            mover.security_context.as_ref().and_then(|s| s.run_as_user),
696            Some(1000)
697        );
698        assert_eq!(
699            mover.pod_security_context.as_ref().and_then(|p| p.fs_group),
700            Some(1000)
701        );
702        assert_eq!(spec.schedule.quick.cron, "0 */6 * * *");
703        assert_eq!(spec.schedule.quick.jitter.as_deref(), Some("30m"));
704        assert_eq!(spec.schedule.full.cron, "0 3 * * 0");
705        assert_eq!(spec.schedule.timezone.as_deref(), Some("UTC"));
706        assert_eq!(spec.ownership.owner, "kopia-operator/nas-primary");
707        assert_eq!(
708            spec.ownership.takeover_policy,
709            TakeoverPolicy::PromptCondition
710        );
711        assert_eq!(
712            spec.failure_policy
713                .as_ref()
714                .unwrap()
715                .active_deadline_seconds,
716            Some(14400)
717        );
718
719        let json = serde_json::to_value(&spec).expect("serialize");
720        let reparsed: MaintenanceSpec = serde_json::from_value(json).expect("reparse");
721        assert_eq!(spec, reparsed);
722    }
723
724    #[test]
725    fn maintenance_status_roundtrips() {
726        // Mirrors ADR-0001 §3.7 status block.
727        let yaml = r#"
728ownership:
729  owner: "kopia-operator/nas-primary"
730  claimedAt: 2026-05-12T08:14:02Z
731quick:
732  lastRunAt: 2026-05-24T12:00:11Z
733  nextScheduledAt: 2026-05-24T18:00:00Z
734  consecutiveFailures: 0
735  lastContentReclaimedBytes: 1234567
736full:
737  lastRunAt: 2026-05-19T03:01:42Z
738  nextScheduledAt: 2026-05-26T03:00:00Z
739  consecutiveFailures: 0
740  lastContentReclaimedBytes: 89456789012
741"#;
742        let status: MaintenanceStatus = from_yaml(yaml);
743        assert_eq!(
744            status.ownership.as_ref().unwrap().owner.as_deref(),
745            Some("kopia-operator/nas-primary")
746        );
747        assert_eq!(
748            status.quick.as_ref().unwrap().last_content_reclaimed_bytes,
749            Some(1234567)
750        );
751        assert_eq!(
752            status.full.as_ref().unwrap().last_content_reclaimed_bytes,
753            Some(89456789012)
754        );
755
756        let json = serde_json::to_value(&status).unwrap();
757        let reparsed: MaintenanceStatus = serde_json::from_value(json).unwrap();
758        assert_eq!(status, reparsed);
759    }
760
761    #[test]
762    fn repository_maintenance_defaults_to_enabled() {
763        // An empty `spec.maintenance: {}` is default-on with no overrides.
764        let m: RepositoryMaintenanceSpec = from_yaml("{}\n");
765        assert!(
766            m.enabled,
767            "absent `enabled` must default to true (default-on)"
768        );
769        assert!(m.schedule.is_none());
770        assert!(m.namespace.is_none());
771        assert!(m.takeover_policy.is_none());
772        // The constructed Default agrees with the deserialized `{}`.
773        assert_eq!(m, RepositoryMaintenanceSpec::default());
774    }
775
776    #[test]
777    fn repository_maintenance_roundtrip_with_overrides() {
778        let yaml = r#"
779enabled: false
780schedule:
781  quick: { cron: "0 */4 * * *", jitter: 20m }
782  full:  { cron: "30 2 * * *", jitter: 45m }
783  timezone: America/Chicago
784takeoverPolicy: Force
785namespace: kopia-system
786failurePolicy:
787  backoffLimit: 2
788"#;
789        let m: RepositoryMaintenanceSpec = from_yaml(yaml);
790        assert!(!m.enabled);
791        let s = m.schedule.as_ref().expect("schedule");
792        assert_eq!(s.quick.cron, "0 */4 * * *");
793        assert_eq!(s.full.jitter.as_deref(), Some("45m"));
794        assert_eq!(s.timezone.as_deref(), Some("America/Chicago"));
795        assert_eq!(m.takeover_policy, Some(TakeoverPolicy::Force));
796        assert_eq!(m.namespace.as_deref(), Some("kopia-system"));
797        assert_eq!(m.failure_policy.as_ref().unwrap().backoff_limit, Some(2));
798
799        let json = serde_json::to_value(&m).expect("serialize");
800        let reparsed: RepositoryMaintenanceSpec = serde_json::from_value(json).expect("reparse");
801        assert_eq!(m, reparsed);
802    }
803
804    #[test]
805    fn default_maintenance_schedule_is_quick_6h_full_daily() {
806        let s = default_maintenance_schedule();
807        assert_eq!(s.quick.cron, "0 */6 * * *");
808        assert_eq!(s.quick.jitter.as_deref(), Some("30m"));
809        assert_eq!(s.full.cron, "0 3 * * *");
810        assert_eq!(s.full.jitter.as_deref(), Some("1h"));
811        assert!(s.timezone.is_none());
812    }
813
814    #[test]
815    fn free_lease_is_claimed_regardless_of_policy() {
816        for p in [
817            TakeoverPolicy::Never,
818            TakeoverPolicy::PromptCondition,
819            TakeoverPolicy::Force,
820        ] {
821            assert_eq!(lease_action(p, false), LeaseAction::Claim);
822        }
823    }
824
825    #[test]
826    fn held_lease_dispatches_by_policy() {
827        assert_eq!(
828            lease_action(TakeoverPolicy::Never, true),
829            LeaseAction::Yield
830        );
831        assert_eq!(
832            lease_action(TakeoverPolicy::PromptCondition, true),
833            LeaseAction::Prompt
834        );
835        assert_eq!(
836            lease_action(TakeoverPolicy::Force, true),
837            LeaseAction::Takeover
838        );
839    }
840
841    #[test]
842    fn takeover_policy_serializes_to_expected_strings() {
843        assert_eq!(
844            serde_json::to_value(TakeoverPolicy::Never).unwrap(),
845            "Never"
846        );
847        assert_eq!(
848            serde_json::to_value(TakeoverPolicy::PromptCondition).unwrap(),
849            "PromptCondition"
850        );
851        assert_eq!(
852            serde_json::to_value(TakeoverPolicy::Force).unwrap(),
853            "Force"
854        );
855        assert_eq!(TakeoverPolicy::default(), TakeoverPolicy::Never);
856    }
857
858    #[test]
859    fn manual_run_mode_parses_exact_lowercase_and_defaults_to_quick() {
860        assert_eq!(ManualRunMode::default(), ManualRunMode::Quick);
861        assert_eq!(ManualRunMode::parse("quick"), Some(ManualRunMode::Quick));
862        assert_eq!(ManualRunMode::parse("full"), Some(ManualRunMode::Full));
863        assert_eq!(ManualRunMode::parse("FULL"), None); // exact, lowercase only
864        assert_eq!(serde_json::to_value(ManualRunMode::Quick).unwrap(), "quick");
865    }
866
867    // --- M6: cluster-qualified maintenance lease -----------------------------
868
869    #[test]
870    fn managed_lease_covers_all_four_arms() {
871        assert_eq!(
872            managed_lease(RepositoryKind::Repository, "media", "nas", None),
873            "kopiur/media/nas"
874        );
875        assert_eq!(
876            managed_lease(RepositoryKind::Repository, "media", "nas", Some("east")),
877            "kopiur/east/media/nas"
878        );
879        assert_eq!(
880            managed_lease(RepositoryKind::ClusterRepository, "ignored", "shared", None),
881            "kopiur/clusterrepository/shared"
882        );
883        assert_eq!(
884            managed_lease(
885                RepositoryKind::ClusterRepository,
886                "ignored",
887                "shared",
888                Some("east")
889            ),
890            "kopiur/east/clusterrepository/shared"
891        );
892    }
893
894    #[test]
895    fn legacy_lease_shapes_are_byte_identical_to_pre_m6() {
896        // 3-segment namespaced-Repository format: unchanged.
897        assert_eq!(
898            kopia_lease_identity("kopiur/media/nas"),
899            ("kopiur".to_string(), "kopiur-media-nas".to_string())
900        );
901        // 3-segment ClusterRepository format: unchanged.
902        assert_eq!(
903            kopia_lease_identity("kopiur/clusterrepository/shared"),
904            (
905                "kopiur".to_string(),
906                "kopiur-clusterrepository-shared".to_string()
907            )
908        );
909        // 2-segment hand-authored owner: unchanged (falls through to legacy).
910        assert_eq!(
911            kopia_lease_identity("kopia-operator/nas-primary"),
912            (
913                "kopiur".to_string(),
914                "kopia-operator-nas-primary".to_string()
915            )
916        );
917        // Mixed-case/punctuation whole-string sanitization: unchanged.
918        let (user, host) = kopia_lease_identity("kopiur/media/My_App.x");
919        assert_eq!(user, "kopiur");
920        assert_eq!(host, "kopiur-media-my-app-x");
921        // Long legacy leases still cap at a DNS label and never end with '-'.
922        let (_, host) = kopia_lease_identity(&format!("kopiur/{}/x", "n".repeat(100)));
923        assert!(host.len() <= 63, "{host}");
924        assert!(!host.ends_with('-'), "{host}");
925    }
926
927    /// Hardening (fix round 1): a hand-authored `Ownership.owner` that HAPPENS
928    /// to be 4 `/`-separated segments must NOT be mistaken for one of
929    /// `managed_lease`'s generated cluster-qualified formats — those are always
930    /// `"kopiur"`-first-segment. Without this gate, an owner like `a/b/c/d`
931    /// would silently change derivation from the legacy `a-b-c-d` to the
932    /// dot-joined `a.b.c.d` across an operator upgrade, and with
933    /// `takeoverPolicy: Never` maintenance would then yield forever.
934    #[test]
935    fn four_segment_non_kopiur_lease_is_legacy_byte_identical_to_pre_m6() {
936        assert_eq!(
937            kopia_lease_identity("a/b/c/d"),
938            ("kopiur".to_string(), "a-b-c-d".to_string())
939        );
940        // Still legacy even when the segments individually look plausible.
941        assert_eq!(
942            kopia_lease_identity("east/media/nas/extra"),
943            ("kopiur".to_string(), "east-media-nas-extra".to_string())
944        );
945    }
946
947    #[test]
948    fn cluster_qualified_lease_is_dot_joined_and_injective() {
949        // Verbatim dot-join for already-clean generated segments.
950        assert_eq!(
951            kopia_lease_identity("kopiur/east/media/nas"),
952            ("kopiur".to_string(), "kopiur.east.media.nas".to_string())
953        );
954        assert_eq!(
955            kopia_lease_identity("kopiur/east/clusterrepository/shared"),
956            (
957                "kopiur".to_string(),
958                "kopiur.east.clusterrepository.shared".to_string()
959            )
960        );
961
962        // Adversarial: a '-' inside a segment must not be forgeable into a
963        // fake '.' boundary (the whole-string collapse this replaces would
964        // make these two leases collide).
965        assert_ne!(
966            kopia_lease_identity("kopiur/east-prod/db/x").1,
967            kopia_lease_identity("kopiur/east/prod-db/x").1
968        );
969
970        // Two 32-char clusters sharing a 31-char prefix must stay distinct —
971        // the old cap-at-63 truncation would have collided these.
972        let cluster_a = format!("{}x", "a".repeat(31));
973        let cluster_b = format!("{}y", "a".repeat(31));
974        assert_eq!(cluster_a.len(), 32);
975        assert_eq!(cluster_b.len(), 32);
976        let lease_a = format!("kopiur/{cluster_a}/media/nas");
977        let lease_b = format!("kopiur/{cluster_b}/media/nas");
978        assert_ne!(
979            kopia_lease_identity(&lease_a).1,
980            kopia_lease_identity(&lease_b).1
981        );
982
983        // Dots land in the hostname, and the shared identity-shape validator
984        // (kopia's username/hostname contract) accepts them: dots are
985        // explicitly permitted, only '@'/':'/whitespace/control chars/length
986        // are rejected.
987        let (_, host) = kopia_lease_identity("kopiur/east/media/nas");
988        assert!(host.contains('.'));
989        assert!(crate::validate::validate_identity_component("hostname", &host).is_ok());
990    }
991
992    #[test]
993    fn lease_held_by_other_table() {
994        let lease = "kopiur/east/media/nas";
995        let alias = "kopiur/media/nas";
996        let mine = kopia_owner_for_lease(lease);
997        let alias_owner = kopia_owner_for_lease(alias);
998
999        // empty current: never-run repo, never "held by another".
1000        assert!(!lease_held_by_other("", lease, &[]));
1001        assert!(!lease_held_by_other("", lease, &[alias.to_string()]));
1002        // own owner: not held by another, with or without aliases configured.
1003        assert!(!lease_held_by_other(&mine, lease, &[]));
1004        assert!(!lease_held_by_other(&mine, lease, &[alias.to_string()]));
1005        // a registered alias's owner: treated as self (migration path).
1006        assert!(!lease_held_by_other(
1007            &alias_owner,
1008            lease,
1009            &[alias.to_string()]
1010        ));
1011        // the SAME owner string, but the alias isn't registered: foreign.
1012        assert!(lease_held_by_other(&alias_owner, lease, &[]));
1013        // a genuinely foreign owner, with an unrelated alias configured: foreign.
1014        assert!(lease_held_by_other(
1015            "someone@else",
1016            lease,
1017            &[alias.to_string()]
1018        ));
1019    }
1020}