Skip to main content

kopiur_api/
consts.rs

1//! Well-known wire-contract strings: the finalizer, labels, annotations, and
2//! condition types that form kopiur's public Kubernetes surface (ADR §4.5,
3//! ADR-0005 §2/§14(c)).
4//!
5//! These live in `kopiur-api` — not the controller — because they are part of
6//! the API contract itself: external tooling (the `kubectl kopiur` plugin,
7//! GitOps health checks, user automation) must agree on them byte-for-byte
8//! with the operator. Controller-internal reasons/actions/deadlines stay in
9//! `kopiur-controller`'s own `consts` module.
10
11/// The finalizer every `Snapshot` carries so the operator can run snapshot
12/// cleanup before the CR is removed (ADR §4.5 / SKILL "Snapshot lifecycle =
13/// CR lifecycle").
14pub const SNAPSHOT_CLEANUP_FINALIZER: &str = "kopiur.home-operations.com/snapshot-cleanup";
15
16/// Repo-offline escape hatch: when present, the finalizer is removed *without*
17/// contacting the repository, the snapshot is recorded orphaned, and a
18/// `SnapshotOrphaned` event is emitted (ADR §4.5).
19pub const SKIP_SNAPSHOT_CLEANUP_ANNOTATION: &str =
20    "kopiur.home-operations.com/skip-snapshot-cleanup";
21
22/// Label mirroring a `Snapshot`'s origin (`scheduled`/`manual`/`discovered`).
23pub const ORIGIN_LABEL: &str = "kopiur.home-operations.com/origin";
24/// Label keying a discovered `Snapshot` to its kopia snapshot id (dedup, §2.1).
25pub const SNAPSHOT_ID_LABEL: &str = "kopiur.home-operations.com/snapshot-id";
26/// Label keying a discovered `Snapshot` to the owning Repository UID (dedup).
27pub const REPOSITORY_UID_LABEL: &str = "kopiur.home-operations.com/repository-uid";
28/// Label naming the `SnapshotPolicy` a `Snapshot` was produced from.
29pub const CONFIG_LABEL: &str = "kopiur.home-operations.com/config";
30
31/// Label naming the `SnapshotSchedule` that fired a scheduled `Snapshot`
32/// (selector for a schedule's own children, distinct from [`CONFIG_LABEL`]
33/// under `policySelector` fan-out).
34pub const SCHEDULE_LABEL: &str = "kopiur.home-operations.com/schedule";
35
36/// Label naming the `SnapshotReplication` CR that minted an
37/// `origin: replicated` dest-side copy `Snapshot` — the selector a replication
38/// run (and its pruning pass) uses to find its own copies. Stamped at CREATE,
39/// alongside [`ORIGIN_LABEL`]/[`SNAPSHOT_ID_LABEL`]/[`REPOSITORY_UID_LABEL`].
40/// Lives in `kopiur-api` because the CLI and user automation must agree on it
41/// byte-for-byte. (Nothing stamps it yet — reserved by the shared-foundations
42/// milestone of #368.)
43pub const SNAPSHOT_REPLICATION_LABEL: &str = "kopiur.home-operations.com/snapshot-replication";
44
45/// Label tying a blob-replication mover Job back to its owning
46/// `RepositoryReplication` — the single-flight selector the controller uses,
47/// and the selector `kubectl kopiur replication run` prints when a requested
48/// run fails and the user needs the Job's logs. Lives here, next to
49/// [`SNAPSHOT_REPLICATION_LABEL`], because the controller and the CLI must
50/// agree on it byte-for-byte; a copy in either would drift silently.
51pub const REPLICATION_LABEL: &str = "kopiur.home-operations.com/replication";
52
53/// Label naming the shared CSI `VolumeGroupSnapshot` a fanned-out `Snapshot`
54/// stages from — carried by BOTH the member `Snapshot` CRs and the
55/// `VolumeGroupSnapshot` object itself.
56///
57/// This is the group's only join key, which makes it load-bearing rather than
58/// decorative. The `VolumeGroupSnapshot` deliberately has **no**
59/// ownerReferences (see `io::group_staging`): every candidate owner is either
60/// absent for a manual run, long-lived enough that GC never fires, or — for a
61/// "leader" member — liable to be pruned while siblings are still restoring
62/// from the group's member snapshots. So one label selector answers both "who
63/// are my siblings" and "does any live member still need this group", and the
64/// reaper fails CLOSED when that read fails.
65///
66/// Lives in `kopiur-api`, not `kopiur-controller`, because the CLI (to filter a
67/// fan-out) and the webhook (to stamp it on a hand-applied member) both need it
68/// and neither can depend on the controller crate.
69pub const GROUP_LABEL: &str = "kopiur.home-operations.com/group";
70
71/// Label naming the operation a mover `Job` performs, for Jobs whose owning CR
72/// doesn't record the Job name in status (e.g. `Restore`). Values:
73/// [`OP_RESTORE`], [`OP_RESTORE_TARGET`].
74pub const OP_LABEL: &str = "kopiur.home-operations.com/op";
75/// [`OP_LABEL`] value for a `Restore`'s mover Job.
76pub const OP_RESTORE: &str = "restore";
77/// [`OP_LABEL`] value for a `Restore`'s operator-created target PVC.
78pub const OP_RESTORE_TARGET: &str = "restore-target";
79
80/// Label marking a mover `Job` as an interactive data-plane *session* pod
81/// (spawned by `kubectl kopiur browse`/`ls`/`cat`/`download`, not by the
82/// operator). Value: [`SESSION_BROWSE`]. Wire-visible: the CLI finds (and
83/// reuses) a warm session by this selector, and `session end` deletes by it.
84pub const SESSION_LABEL: &str = "kopiur.home-operations.com/session";
85/// [`SESSION_LABEL`] value for a read-only browse session.
86pub const SESSION_BROWSE: &str = "browse";
87/// Label keying a session `Job` to the repository it holds open, as
88/// `<kind>-<name>` (e.g. `Repository-nas`). One warm session per repository:
89/// the CLI selects on this so two snapshots in the same repository share a pod.
90pub const SESSION_REPO_LABEL: &str = "kopiur.home-operations.com/session-repo";
91
92/// Annotation requesting an out-of-band `Maintenance` run NOW (Flux-style
93/// reconcile trigger). Value: an RFC3339 timestamp; a NEW timestamp requests a
94/// new run (re-applying the same value is a no-op once handled). Usable from
95/// bare `kubectl annotate` or `kubectl kopiur maintenance run`.
96pub const RUN_REQUESTED_ANNOTATION: &str = "kopiur.home-operations.com/run-requested";
97/// Companion annotation selecting the run kind: `quick` (default) or `full`
98/// (see `kopiur_api::maintenance::ManualRunMode`).
99pub const RUN_MODE_ANNOTATION: &str = "kopiur.home-operations.com/run-mode";
100
101/// Acknowledges an intentional identity-affecting change on UPDATE. Two surfaces
102/// share it:
103/// - a `SnapshotPolicy`'s own resolved kopia identity (`username@hostname`, or a
104///   source's path) — see `ValidationError::IdentityWouldFork`;
105/// - a `Repository`/`ClusterRepository`'s `identityDefaults` (`cluster`,
106///   `hostnameExpr`, `usernameExpr`), which every consumer policy relying on
107///   those defaults re-resolves against on its next reconcile/backup — see
108///   `ValidationError::RepositoryIdentityWouldFork`.
109///
110/// Without it, the webhook **rejects** the edit when it would re-identify
111/// (a) policy/(consumer policies) with existing snapshot history, because new
112/// snapshots land under a new kopia source: restore/verify/`fromPolicy` resolve
113/// the new identity (old history is reachable only via
114/// `Restore.spec.source.identity`), while old- and new-lineage `Snapshot` CRs
115/// keep competing in the policy's one GFS retention timeline. Any
116/// **non-empty** value acknowledges the re-identification for that admission
117/// (presence-only, mirroring [`SKIP_SNAPSHOT_CLEANUP_ANNOTATION`]; a specific
118/// value isn't required because an edit can change more than one identity
119/// component at once, and the operator-resolved string is not something the
120/// author can pre-compute). Lives here because the operator and any GitOps/user
121/// automation must agree on it byte-for-byte.
122pub const ALLOW_IDENTITY_CHANGE_ANNOTATION: &str =
123    "kopiur.home-operations.com/allow-identity-change";
124
125/// Marks a `Snapshot` the OPERATOR is deleting as part of its own lifecycle,
126/// stamped immediately before the delete call. Values: `PrunedBy` annotation
127/// values (`retention`, `failed-history`, `policy-cascade`,
128/// `replication-retention`). The Snapshot finalizer uses it to
129/// distinguish Kopiur's own prunes from external deletions (GC cascades,
130/// kubectl, third-party controllers); external destructive deletions are
131/// subject to the schedule-cascade guard and the mass-deletion breaker,
132/// operator prunes are not. Any unrecognized value is treated as EXTERNAL
133/// (fail-safe). Wire-visible: users and tooling may read it on terminating CRs.
134pub const PRUNED_BY_ANNOTATION: &str = "kopiur.home-operations.com/pruned-by";
135
136/// Acknowledges a mass-deletion wave on a `Repository`/`ClusterRepository`.
137/// Value: an RFC3339 timestamp. A HELD external deletion is released iff its
138/// Snapshot's `metadata.deletionTimestamp` <= this value — "I approve what is
139/// pending NOW". Deliberately VALUED (unlike the presence-only
140/// allow-identity-change ack, consumed at a single admission instant): this
141/// annotation is read continuously by the controller, so a presence-only ack
142/// left behind (or committed to Git) would disarm the breaker forever. With
143/// the timestamp, a stale ack is inert against any LATER wave, nothing ever
144/// needs to remove it, and the operator never edits user metadata. The
145/// controller clamps the effective value to <= its own now (clock-skew guard);
146/// an unparseable value is ignored (Warning event on the repository).
147pub const ALLOW_MASS_DELETION_ANNOTATION: &str = "kopiur.home-operations.com/allow-mass-deletion";
148
149/// Acknowledges a deliberate RE-INITIALIZATION of a `Repository`/`ClusterRepository`
150/// whose backend was wiped: kopiur refuses to auto-create a fresh kopia repository
151/// over a once-`Ready` one (the pinned `status.uniqueId`), and this annotation is
152/// the human "yes, I know the history is gone — make a new one".
153///
154/// Value: the repository's CURRENT `status.uniqueId`, verbatim. Honored only while
155/// it equals that pin, which is what makes it **self-expiring**: the moment the
156/// re-initialize succeeds a NEW unique id is minted, the annotation no longer
157/// matches, and the ack is inert — so a copy left behind in a GitOps manifest can
158/// never authorize a second wipe. Contrast with
159/// [`ALLOW_MASS_DELETION_ANNOTATION`] (an RFC3339 timestamp, compared against each
160/// pending deletion) and with [`ALLOW_IDENTITY_CHANGE_ANNOTATION`] (presence-only,
161/// consumed at a single admission instant): this one is read continuously by the
162/// controller, so it needs a value that goes stale on its own. kopiur never writes
163/// or removes it — there is no "honored" stamp to keep in sync.
164///
165/// A value that is present but does NOT match the pin is ignored (fail-safe) and
166/// raises one Warning event naming the expected value.
167pub const ALLOW_REINITIALIZE_ANNOTATION: &str = "kopiur.home-operations.com/allow-reinitialize";
168
169/// The API version string for kopiur CRDs (used in mover `TargetRef`s and
170/// `kubectl -o name`-style output).
171pub const API_VERSION: &str = "kopiur.home-operations.com/v1alpha1";
172
173/// Pod label opting a mover pod into the **azure-workload-identity** mutating
174/// webhook: pods carrying `azure.workload.identity/use: "true"` and running as
175/// a federated `ServiceAccount` get `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/
176/// `AZURE_FEDERATED_TOKEN_FILE` (and the projected token volume) injected —
177/// exactly the env kopia's azure backend binds its credential flags to. Stamped
178/// by the operator (and the CLI's browse sessions) on every mover pod for a
179/// repository whose azure backend uses `auth.workloadIdentity`. Lives here
180/// because the operator and `kubectl kopiur` must agree on it byte-for-byte.
181pub const AZURE_WORKLOAD_IDENTITY_LABEL: &str = "azure.workload.identity/use";
182/// The [`AZURE_WORKLOAD_IDENTITY_LABEL`] value opting the pod in.
183pub const AZURE_WORKLOAD_IDENTITY_LABEL_VALUE: &str = "true";
184
185/// The standard `app.kubernetes.io/managed-by` label key. Stamped on **every**
186/// operator-created object (mover Jobs, work-spec ConfigMaps, cache PVC, minted
187/// mover SA/RoleBinding, projected credential Secret, CSI VolumeSnapshots) so
188/// Argo/Flux recognize them as controller-owned and neither prune nor report them
189/// `OutOfSync` (ADR-0005 §14(c)).
190pub const MANAGED_BY_LABEL: &str = "app.kubernetes.io/managed-by";
191/// The [`MANAGED_BY_LABEL`] value identifying kopiur-managed objects.
192pub const MANAGED_BY_VALUE: &str = "kopiur";
193
194/// kstatus-compliant standard condition types (ADR-0005 §2) so `kubectl wait
195/// --for=condition=Ready` and Flux/Argo health checks work natively against every
196/// reconciled kopiur CRD.
197/// The headline readiness condition.
198pub const READY_CONDITION: &str = "Ready";
199/// Set `True` while a reconcile is making progress toward Ready.
200pub const RECONCILING_CONDITION: &str = "Reconciling";
201/// Set `True` when the resource is stuck and won't progress without intervention
202/// (mapped from a terminal `ErrorClass::Terminal` failure).
203pub const STALLED_CONDITION: &str = "Stalled";
204
205/// `Repository`/`ClusterRepository` condition recording whether a `Maintenance`
206/// covers it (ADR §3.7). Wire-visible: GitOps health checks and the kubectl
207/// plugin's `status` read it.
208pub const MAINTENANCE_CONFIGURED_CONDITION: &str = "MaintenanceConfigured";
209
210/// `Repository`/`ClusterRepository` condition reporting content-index-blob
211/// health (ADR-0005 §13). `True` = healthy (count under threshold); `False`
212/// with reason `TooManyIndexBlobs` = the index is growing unbounded because
213/// maintenance isn't compacting. NON-BLOCKING: the repository stays `Ready` and
214/// GitOps health gates are not tripped — it's a degradation warning, not an
215/// outage. Wire-visible (the kubectl plugin's `status` reads it).
216pub const INDEX_BLOB_HEALTH_CONDITION: &str = "IndexBlobHealth";
217
218/// Default `spec.health.indexBlobWarnThreshold`: the index-blob count above which
219/// the reconciler warns that maintenance isn't keeping up. A freshly-compacted
220/// repo sits near zero; a wedged-maintenance repo climbs unbounded (a real one
221/// reached 1448). Conservative so it only fires when maintenance is clearly
222/// behind. Overridable per-repo; `0` disables the warning. Part of the documented
223/// API contract, so it lives here rather than in the controller.
224pub const DEFAULT_INDEX_BLOB_WARN_THRESHOLD: i64 = 1000;
225
226/// Default catalog re-scan cadence when `spec.catalog.refreshInterval` is unset:
227/// how often a `Ready` repository re-lists its kopia snapshots to materialize
228/// (and expire) `origin: discovered` `Snapshot` CRs. Part of the documented API
229/// contract (field-reference), so it lives here rather than in the controller.
230pub const DEFAULT_CATALOG_REFRESH_INTERVAL: std::time::Duration =
231    std::time::Duration::from_secs(3600);
232
233/// Floor for `spec.catalog.refreshInterval`, enforced at admission. Each re-scan
234/// of an object-store repository runs a short mover Job; anything faster than
235/// this is Job churn with no operational value.
236pub const MIN_CATALOG_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
237
238/// Default `spec.health.probe.enabled` when unset: the periodic backend health
239/// probe is ON by default (#345). The probe is the sensor for the repository
240/// circuit breaker — with the default `onFailure: Degrade`, a sustained backend
241/// failure moves the repository to `Degraded` and pauses backups until a
242/// re-connect succeeds — so it must run unless the user explicitly opts out with
243/// `enabled: false`. Part of the documented API contract, so it lives here, not
244/// in the controller.
245pub const DEFAULT_HEALTH_PROBE_ENABLED: bool = true;
246
247/// Default `spec.health.probe.interval` when unset: how often the backend
248/// health probe re-connects a `Ready` repository to confirm the kopia repository
249/// still exists at the backend. Conservative — a vanished/unreachable
250/// repository is rare and the probe runs a short mover Job — so it leans long.
251/// Part of the documented API contract, so it lives here, not in the controller.
252pub const DEFAULT_HEALTH_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1800);
253
254/// Floor for `spec.health.probe.interval`, enforced at admission. Each probe runs
255/// a short mover Job (object-store / volume-backed) or an in-process connect;
256/// anything faster than this is Job churn with no operational value. Shares the
257/// 30s floor with the catalog re-scan for the same reason.
258pub const MIN_HEALTH_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
259
260/// Default `spec.health.probe.failureThreshold`: how many *consecutive* failing
261/// probes must accumulate before the loud `RepositoryVanished` / `BackendReachable=False`
262/// condition is raised, an event fired, and — under the default
263/// `onFailure: Degrade` — the repository moved to `Degraded` (pausing backups).
264/// Debounces a single transient blip (an S3 list-after-delete race, a NAS
265/// reboot, a credential-rotation moment) from alarming on-call, tripping the
266/// breaker, or nudging a destructive manual recreate.
267pub const DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD: i64 = 3;
268
269/// Default `SnapshotSchedule.spec.failedJobsHistoryLimit` when unset: how many
270/// `Failed` `Snapshot` CRs from a schedule to retain (the rest are pruned). Bounds
271/// failure history so a schedule firing against a persistently-failing precondition
272/// or backend doesn't accumulate `Failed` CRs forever. GFS retention applies only to
273/// successful snapshots, so this is the *only* bound on failures (ADR-0003). Part of
274/// the documented API contract, so it lives here, not in the controller.
275pub const DEFAULT_FAILED_JOBS_HISTORY_LIMIT: u32 = 10;
276
277/// The effective failed-history limit: `failedJobsHistoryLimit` when set, else
278/// [`DEFAULT_FAILED_JOBS_HISTORY_LIMIT`]. `Some(0)` keeps no failed snapshots.
279pub fn effective_failed_jobs_history_limit(limit: Option<u32>) -> u32 {
280    limit.unwrap_or(DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
281}
282
283/// Default `spec.deletionProtection.threshold` (0 disables). 10 pending
284/// external destructive deletions is far above legitimate manual cleanup but
285/// far below a tooling-driven cascade (the motivating incident was ~600).
286pub const DEFAULT_MASS_DELETION_THRESHOLD: u32 = 10;
287
288/// The effective mass-deletion breaker threshold: `deletionProtection.threshold`
289/// when set, else [`DEFAULT_MASS_DELETION_THRESHOLD`]. `Some(0)` disables the breaker.
290pub fn effective_mass_deletion_threshold(p: Option<&crate::common::DeletionProtectionSpec>) -> u32 {
291    p.and_then(|d| d.threshold)
292        .unwrap_or(DEFAULT_MASS_DELETION_THRESHOLD)
293}
294
295/// The effective per-repository mover-Job concurrency cap:
296/// `concurrency.maxConcurrentJobs` when set to a NON-ZERO value, else `None`
297/// (uncapped). An absent `concurrency` block, an absent `maxConcurrentJobs`, and
298/// an explicit `0` all mean the same thing — no limit — so all three collapse to
299/// `None`.
300///
301/// Deliberately returns an `Option<NonZeroUsize>` rather than the plain scalar
302/// [`effective_mass_deletion_threshold`] returns for its neighbouring breaker.
303/// That resolver can use `0` as its own disable sentinel because the value it
304/// yields is a *count to compare against*; here the value is a *capacity*, and
305/// `Some(0)` would read as "admit nothing" — a repository that never runs a Job
306/// again. Encoding "uncapped" in the `Option` and non-zero-ness in the type makes
307/// that state unrepresentable rather than merely untested, and the caller's
308/// `match`/`if let` is then forced to spell out the uncapped path.
309///
310/// ```
311/// use kopiur_api::common::ConcurrencySpec;
312/// use kopiur_api::consts::effective_max_concurrent_jobs;
313///
314/// // No block at all, no field, and an explicit 0 are the same state: uncapped.
315/// assert_eq!(effective_max_concurrent_jobs(None), None);
316/// assert_eq!(
317///     effective_max_concurrent_jobs(Some(&ConcurrencySpec { max_concurrent_jobs: None })),
318///     None,
319/// );
320/// assert_eq!(
321///     effective_max_concurrent_jobs(Some(&ConcurrencySpec { max_concurrent_jobs: Some(0) })),
322///     None,
323/// );
324/// // A positive value caps the pool.
325/// assert_eq!(
326///     effective_max_concurrent_jobs(Some(&ConcurrencySpec { max_concurrent_jobs: Some(3) }))
327///         .map(|n| n.get()),
328///     Some(3),
329/// );
330/// ```
331pub fn effective_max_concurrent_jobs(
332    spec: Option<&crate::common::ConcurrencySpec>,
333) -> Option<std::num::NonZeroUsize> {
334    spec.and_then(|c| c.max_concurrent_jobs)
335        .and_then(|n| std::num::NonZeroUsize::new(n as usize))
336}
337
338/// Pool-membership label stamped on every mover `Job` that counts toward its
339/// repository's `spec.concurrency.maxConcurrentJobs`: backup, restore, and the
340/// SOURCE side of a `RepositoryReplication`/`SnapshotReplication`. One selector
341/// therefore counts a repository's whole in-flight pool.
342///
343/// The value is `kopiur_controller::naming::repo_label` over the repository's
344/// **normalized** ref — `<=40-char name prefix>-<hash8>`, the same value the
345/// batch-delete single-flight label `kopiur.home-operations.com/delete-repo`
346/// carries. Deliberately NOT the
347/// `<kind>-<name>` shape of [`SESSION_REPO_LABEL`]: that spelling cannot
348/// distinguish two `Repository`s of the same name in different namespaces, and
349/// merging their pools would silently halve each one's cap. The hash is over
350/// `repository:{ns}/{name}` / `clusterrepository:{name}`, so namespace and kind
351/// both discriminate.
352///
353/// Maintenance, verification, pin, snapshot-delete batch, bootstrap/discovery and
354/// browse-session Jobs deliberately do NOT carry it — they are operator-driven
355/// housekeeping (or an interactive session), excluded from the pool so a
356/// saturated backup queue can never starve them.
357pub const REPO_POOL_LABEL: &str = "kopiur.home-operations.com/repo-pool";
358
359/// `Repository`/`ClusterRepository` condition: pending external destructive
360/// deletions for this repository are at/above the breaker threshold and held.
361pub const MASS_DELETION_HELD_CONDITION: &str = "MassDeletionHeld";
362
363/// `reason` for [`MASS_DELETION_HELD_CONDITION`] = `True` on a
364/// `Repository`/`ClusterRepository`: pending external destructive deletions for
365/// this repository are at/above its breaker threshold.
366pub const MASS_DELETION_THRESHOLD_EXCEEDED_REASON: &str = "ThresholdExceeded";
367
368// --- Structural-gate conditions/reasons (shared with `kubectl kopiur doctor`) -
369//
370// These are the condition `type`/`reason` pairs the reconcilers stamp when work
371// is BLOCKED on something only a human can change (a namespace opt-in, a Secret,
372// an acknowledgement annotation). They live here — not in `kopiur-controller` —
373// because the CLI's `doctor` must recognize them byte-for-byte; the typed
374// registry in [`crate::gates`] is the shared enumeration built from them, and
375// the controller re-exports each name so its call sites are unchanged.
376
377/// Namespace annotation a cluster admin sets to allow elevated (root/privileged)
378/// movers in that namespace (ADR §4.11/§G16). Without it, a `SnapshotPolicy` whose
379/// `spec.mover` requests privilege is refused — a tenant could otherwise reuse the
380/// minted mover ServiceAccount at that privilege. Mirrors VolSync's
381/// `volsync.backube/privileged-movers`.
382pub const PRIVILEGED_MOVERS_ANNOTATION: &str = "kopiur.home-operations.com/privileged-movers";
383/// `Snapshot`/`Restore` condition surfaced when a privileged mover is requested in a
384/// namespace that has not opted in — `False` carries the actionable message.
385pub const MOVER_PERMITTED_CONDITION: &str = "MoverPermitted";
386/// `reason`/Event reason for [`MOVER_PERMITTED_CONDITION`] = `False`.
387pub const PRIVILEGED_MOVER_NOT_PERMITTED_REASON: &str = "PrivilegedMoverNotPermitted";
388
389/// `SnapshotSchedule` condition recording whether the schedule is able to fire
390/// its next slot. Set `False` (with [`BLOCKED_ON_UNREADABLE_RUN_REASON`]) when
391/// the concurrency gate is held by a `Snapshot` whose `status.phase` this build
392/// cannot interpret.
393pub const SCHEDULE_RUNNABLE_CONDITION: &str = "ScheduleRunnable";
394/// `reason`/Event reason for [`SCHEDULE_RUNNABLE_CONDITION`] = `False`: a
395/// previous run of this schedule sits at a phase string written by a NEWER
396/// kopiur, so this build can never observe it reach a terminal phase. Under the
397/// default `concurrencyPolicy: Forbid` that stops the schedule permanently, and
398/// nothing about the `SnapshotSchedule` itself would otherwise say so — the
399/// silent-wedge shape of #359, one kind removed.
400pub const BLOCKED_ON_UNREADABLE_RUN_REASON: &str = "BlockedOnUnreadableRun";
401
402/// `Snapshot` condition recording whether its repository accepts writes (§11). Set
403/// `False` (with [`REPOSITORY_READ_ONLY_REASON`]) when a backup is refused because
404/// the repository is `mode: ReadOnly`.
405pub const REPOSITORY_WRITABLE_CONDITION: &str = "RepositoryWritable";
406/// `reason`/Event reason when a backup or maintenance is refused on a `ReadOnly`
407/// repository (ADR-0005 §11).
408pub const REPOSITORY_READ_ONLY_REASON: &str = "RepositoryReadOnly";
409
410/// `Snapshot`/`Restore` condition surfaced when the mover Job's credential Secret is
411/// absent from the workload namespace — `False` carries the actionable message
412/// (which Secret, which namespace, why, and how to fix). ADR §4.12.
413pub const CREDENTIALS_AVAILABLE_CONDITION: &str = "CredentialsAvailable";
414/// `reason`/Event reason for [`CREDENTIALS_AVAILABLE_CONDITION`] = `False`.
415pub const MISSING_CREDENTIALS_REASON: &str = "MissingCredentialsSecret";
416/// `reason`/Event reason for [`CREDENTIALS_AVAILABLE_CONDITION`] = `False` when
417/// the missing dependency is the **workload-identity ServiceAccount** the
418/// backend's `auth.workloadIdentity` names (the user creates it; kopiur never
419/// does — its cloud annotations are the user's federation contract).
420pub const MISSING_SERVICE_ACCOUNT_REASON: &str = "MissingServiceAccount";
421/// `reason`/Event reason for [`CREDENTIALS_AVAILABLE_CONDITION`] = `False` when
422/// the missing dependency is the backend's `tls.caBundleRef` **ConfigMap** (or
423/// its key): the PEM CA bundle the mover needs to verify a private-CA S3
424/// endpoint. The user creates it; a ConfigMap that never appears never
425/// self-heals, so this is a structural gate
426/// ([`crate::gates::MISSING_CA_BUNDLE_GATE`]), not just a transient retry.
427pub const MISSING_CA_BUNDLE_REASON: &str = "MissingCaBundle";
428/// Default key within a `tls.caBundleRef` ConfigMap when `key` is unset — the
429/// conventional filename cert-manager and trust-manager emit CA bundles under.
430/// Lives here (not in the controller) because the kubectl-plugin resolves the
431/// same reference client-side and must agree on the default (centralize-config).
432pub const DEFAULT_CA_BUNDLE_KEY: &str = "ca.crt";
433
434/// `SnapshotPolicy` condition recording whether every repository the policy
435/// targets is `Ready`. Set `False` (with [`REPOSITORY_NOT_READY_REASON`]) when
436/// at least one referenced `Repository`/`ClusterRepository` is not `Ready`:
437/// backups, retention, adoption and verification against the not-ready
438/// subset are deferred (the ready subset keeps processing), and nothing about
439/// the policy's phase-less surface would otherwise say so — the silent-wedge
440/// shape of #359 for the recipe kind. Structural
441/// ([`crate::gates::POLICY_REPOSITORY_NOT_READY_GATE`]): a repository that
442/// never recovers never self-heals this condition.
443pub const REPOSITORIES_READY_CONDITION: &str = "RepositoriesReady";
444/// `reason` for [`REPOSITORIES_READY_CONDITION`] = `False` — the same string
445/// the `Snapshot` reconciler stamps on a child parked behind a not-Ready
446/// repository (one string, both surfaces; the controller re-exports it).
447pub const REPOSITORY_NOT_READY_REASON: &str = "RepositoryNotReady";
448
449/// `SnapshotSchedule` condition recording whether a fired slot minted its full
450/// members × repositories fan-out. `True` (with [`FANOUT_TOO_LARGE_REASON`])
451/// = the cross-product exceeded the fan-out cap and the slot was SKIPPED — and
452/// it will keep skipping every slot until the selector is narrowed or the
453/// repository list shrunk, which is squarely "needs a human". Structural
454/// ([`crate::gates::SCHEDULE_FANOUT_CAPPED_GATE`], promoted here by the #368
455/// M10 gates/doctor checklist from a controller-internal condition).
456pub const SCHEDULE_FANOUT_CAPPED_CONDITION: &str = "FanoutCapped";
457/// `reason` for [`SCHEDULE_FANOUT_CAPPED_CONDITION`] = `True`.
458pub const FANOUT_TOO_LARGE_REASON: &str = "FanoutTooLarge";
459
460/// `Snapshot` condition recording whether the backup's DIRECT source PVC
461/// (`spec.sources[].pvc`) exists. Set `False` (with
462/// [`SOURCE_PVC_MISSING_REASON`]) when the PVC named by the recipe is absent
463/// at launch time: the backup parks (`phase: Pending`) and, after the
464/// controller's deadline, fails terminally — a PVC that never reappears never
465/// self-heals, the silent-wedge shape of #359. Structural
466/// ([`crate::gates::SOURCE_PVC_MISSING_GATE`]); only the direct source PVC
467/// earns this gate — a vanished operator-staged claim is a restage race and
468/// stays a transient retry.
469pub const SOURCE_PVC_AVAILABLE_CONDITION: &str = "SourcePvcAvailable";
470/// `reason`/Event reason for [`SOURCE_PVC_AVAILABLE_CONDITION`] = `False`.
471pub const SOURCE_PVC_MISSING_REASON: &str = "SourcePvcMissing";
472
473/// Condition recording whether this run holds a slot in its repository's
474/// mover-Job pool (`spec.concurrency.maxConcurrentJobs`). `False` with
475/// [`WAITING_FOR_SLOT_REASON`] means the run is parked at `phase: Pending`
476/// because the pool is full; `True` with [`SLOT_ACQUIRED_REASON`] means it was
477/// admitted and its Job launched.
478///
479/// **Written by exactly three kinds — `Snapshot`, `RepositoryReplication` and
480/// `SnapshotReplication` — and each carries BOTH arms.** Those are the run kinds
481/// the gate can hold, so each can be parked at `False` and later healed to
482/// `True`.
483///
484/// **A `Restore` never carries this condition at all**, in either arm. A restore
485/// is a recovery in progress, so it is ALWAYS admitted — holding one behind a
486/// queue of routine backups is exactly backwards. Its mover Job is still
487/// labelled into the pool and still COUNTS against the cap (so a restore
488/// displaces backups rather than adding to them), but the restore reconciler
489/// never consults the gate and writes no slot condition of its own.
490///
491/// **Deliberately NOT a [`crate::gates::StructuralGate`].** The registry's
492/// contract (see the `gates` module doc) is "blocked on something only a human
493/// can change" — a park that never self-heals. This one always does: the pool
494/// drains as in-flight Jobs finish, and the parked run is admitted on a later
495/// pass with no human action at all. Registering it would make `kubectl kopiur
496/// doctor` report a wedge every time a busy repository is merely doing its job,
497/// which is the exact inverse of the false-green defect the registry exists to
498/// prevent. A pool that never drains is a *stuck Job* problem, and doctor's
499/// existing stuck/failure checks are what surface that.
500pub const REPOSITORY_SLOT_AVAILABLE_CONDITION: &str = "RepositorySlotAvailable";
501/// `reason` for [`REPOSITORY_SLOT_AVAILABLE_CONDITION`] = `False`: the
502/// repository's mover-Job pool is at its cap and this run is queued behind it.
503pub const WAITING_FOR_SLOT_REASON: &str = "WaitingForSlot";
504/// `reason` for [`REPOSITORY_SLOT_AVAILABLE_CONDITION`] = `True`: this run holds
505/// a slot in the repository's mover-Job pool (or the pool is uncapped).
506pub const SLOT_ACQUIRED_REASON: &str = "SlotAcquired";
507
508/// `SnapshotSchedule` condition recording that `concurrencyPolicy: Replace` is
509/// holding a due slot because the run it would replace is itself parked behind
510/// its repository's mover-Job concurrency cap
511/// ([`REPOSITORY_SLOT_AVAILABLE_CONDITION`] = `False`). Cancelling a queued run
512/// frees no capacity and the replacement would re-queue behind it, so `Replace`
513/// degrades to `Forbid`-like waiting until the pool drains.
514///
515/// Deliberately **not** a registered structural gate
516/// ([`crate::gates::STRUCTURAL_GATES`]): unlike `ScheduleRunnable=False`, this
517/// state needs no human — it clears by itself the moment a slot frees up. It
518/// exists so the wait is visible in `status` and so the accompanying Normal
519/// event fires once on entering the hold rather than on every requeue.
520pub const SCHEDULE_REPLACEMENT_HELD_CONDITION: &str = "ReplacementHeld";
521/// `reason`/Event reason for [`SCHEDULE_REPLACEMENT_HELD_CONDITION`] = `True`.
522pub const WAITING_FOR_REPOSITORY_SLOT_REASON: &str = "WaitingForRepositorySlot";
523/// `reason` for [`SCHEDULE_REPLACEMENT_HELD_CONDITION`] = `False` (not held).
524pub const REPLACEMENT_NOT_HELD_REASON: &str = "ReplacementNotHeld";
525
526/// `Restore` condition recording whether the object this restore's repository
527/// is DERIVED from exists. Set `False` (with
528/// [`RESTORE_REFERENT_MISSING_REASON`]) when the readiness gate cannot even
529/// look up the repository because a referent is absent: the explicit
530/// `spec.repository` object itself, or the `source.fromPolicy` `SnapshotPolicy`
531/// the repository ref is read from (issue #393).
532///
533/// Its own condition rather than the `Ready` one: the registry's coarse
534/// [`crate::gates::StructuralGate::trips`] filter keys on condition+polarity,
535/// so registering a `Ready=False` row would make every unrelated `Ready=False`
536/// reason on a `Snapshot`/`Restore` read as an unknown gate from a newer
537/// operator. Only this gate ever writes this condition, so it cannot misfire.
538///
539/// Structural ([`crate::gates::RESTORE_REFERENT_MISSING_GATE`]): a referent
540/// that is never created never self-heals, and while it is missing the restore
541/// sits at `phase: Pending` with its `policy.waitTimeout` window deliberately
542/// NOT started — the phase-invisible park shape of #359.
543pub const RESTORE_REFERENT_AVAILABLE_CONDITION: &str = "ReferentAvailable";
544/// `reason`/Event reason for [`RESTORE_REFERENT_AVAILABLE_CONDITION`] =
545/// `False`. Distinct from [`REPOSITORY_NOT_READY_REASON`] on purpose: that one
546/// means "the repository object exists and its backend is unreachable", which
547/// would be a lie for a `SnapshotPolicy` that was never applied.
548pub const RESTORE_REFERENT_MISSING_REASON: &str = "RestoreReferentMissing";
549/// `reason` for [`RESTORE_REFERENT_AVAILABLE_CONDITION`] = `True`: the referent
550/// appeared on a later pass (the clear-side counterpart of
551/// [`RESTORE_REFERENT_MISSING_REASON`]; controller-internal remediation copy,
552/// like the `Snapshot` gate's `SourcePvcFound`). Written ONLY when the
553/// condition already exists — the healthy wire never grows the condition.
554pub const RESTORE_REFERENT_FOUND_REASON: &str = "RestoreReferentFound";
555
556/// `Snapshot` condition: this deletion is HELD by the mass-deletion breaker
557/// (`Repository`/`ClusterRepository` `spec.deletionProtection.threshold`)
558/// until acknowledged via [`ALLOW_MASS_DELETION_ANNOTATION`] on the
559/// repository.
560pub const DELETION_HELD_CONDITION: &str = "DeletionHeld";
561/// `reason` for [`DELETION_HELD_CONDITION`] = `True`.
562pub const MASS_DELETION_BREAKER_REASON: &str = "MassDeletionBreaker";
563
564/// `Repository`/`ClusterRepository` condition reporting the state of
565/// `spec.seed` — initializing a brand-new repository from an existing replica
566/// (issue #380).
567///
568/// `True` means the repository holds its seeded content (reason
569/// [`SEEDED_REASON`]) or never needed seeding because it was already
570/// initialized (reason [`ALREADY_INITIALIZED_REASON`]). `False` means the seed
571/// is in flight ([`SEEDING_REASON`]), parked on a source that is not usable yet
572/// ([`WAITING_FOR_SEED_SOURCE_REASON`]) or on a workload-identity conflict
573/// between the two backends ([`SEED_SOURCE_AUTH_CONFLICT_REASON`]), or failed
574/// (the mover's failure class).
575///
576/// Wire-visible: GitOps health checks, `kubectl kopiur status`/`doctor` and
577/// user automation read it, so it lives in `kopiur-api` beside the other
578/// contract strings rather than controller-side.
579pub const SEEDED_CONDITION: &str = "Seeded";
580/// `reason` for [`SEEDED_CONDITION`] = `True` when this repository's content
581/// was actually copied in from `spec.seed.from`.
582pub const SEEDED_REASON: &str = "Seeded";
583/// `reason` for [`SEEDED_CONDITION`] = `True` when `spec.seed` was a standing
584/// no-op: the repository was already initialized, so nothing was copied. This
585/// is the steady state of a seed block left in a GitOps manifest forever.
586pub const ALREADY_INITIALIZED_REASON: &str = "AlreadyInitialized";
587/// `reason` for [`SEEDED_CONDITION`] = `False` while the seeding bootstrap Job
588/// is in flight. Bounded by `spec.seed.failurePolicy` (default 24h), but a seed
589/// legitimately runs for hours, so anything reporting on a repository must be
590/// able to say "seeding" rather than "stuck".
591pub const SEEDING_REASON: &str = "Seeding";
592/// `reason` for [`SEEDED_CONDITION`] = `False` when a migrate-mode seed is
593/// parked because its source `Repository`/`ClusterRepository` is not `Ready`
594/// (or does not exist). The repository stays `Pending` and re-checks until the
595/// source comes up — an out-of-band change nothing in kopiur can make.
596pub const WAITING_FOR_SEED_SOURCE_REASON: &str = "WaitingForSeedSource";
597/// `reason` for [`SEEDED_CONDITION`] = `False` when a migrate-mode seed's
598/// LOCAL backend and its resolved SOURCE repository disagree on workload
599/// identity: one seeding pod runs as exactly one ServiceAccount, so one of the
600/// two backends would authenticate as the wrong identity (or not at all).
601///
602/// The blob arm of this rule is an admission rejection
603/// (`validate_replication_auth`, reached through `validate_seed_blob_source`),
604/// but admission cannot follow a `seed.from.repository` reference to the source
605/// CR's backend — so the migrate arm is a controller-side park instead, and it
606/// needs its own reason for `kubectl kopiur doctor` to explain rather than
607/// misreport it.
608pub const SEED_SOURCE_AUTH_CONFLICT_REASON: &str = "SeedSourceAuthConflict";
609
610// The FAILURE reasons for [`SEEDED_CONDITION`] = `False`. Each is byte-identical
611// to the sentinel `kopia_error_class` the seeding mover writes
612// (`kopiur_mover::bootstrap::SEED_*_CLASS`) — one vocabulary across the mover's
613// result, the condition, the Warning Event and the CLI. They live HERE, not in
614// the mover, because `gates.rs` registers them and `kopiur-api` cannot depend on
615// `kopiur-mover`; the equality is pinned by the controller's
616// `io::tests::every_seed_class_maps_to_a_typed_failure_and_routes_by_retryability`,
617// which is the one crate that sees both sides.
618//
619// Registering ALL of them matters: a `Seeded=False` reason with no registry row
620// trips `StructuralGate::trips` but matches no row, and `kubectl kopiur doctor`
621// then reports it as "the operator is newer than the plugin" — a false diagnosis
622// in exactly the disaster-recovery flow these reasons exist for.
623
624/// `reason` for [`SEEDED_CONDITION`] = `False` when the seed SOURCE answered but
625/// holds no kopia repository (a mis-pointed bucket/prefix, or a mirror that was
626/// never written). Retried automatically.
627pub const SEED_SOURCE_NOT_FOUND_REASON: &str = "SeedSourceNotFound";
628/// `reason` for [`SEEDED_CONDITION`] = `False` when the seed source IS a kopia
629/// repository but holds zero snapshots and `spec.seed.allowEmptySource` is
630/// `false`. Retried automatically, so a mirror that fills up later seeds itself.
631pub const SEED_SOURCE_EMPTY_REASON: &str = "SeedSourceEmpty";
632/// `reason` for [`SEEDED_CONDITION`] = `False` when a migrate-mode seed's
633/// post-verify found snapshots missing at the destination (`kopia snapshot
634/// migrate` exits 0 on a per-source failure, so the destination listing is the
635/// only honest success signal). The next attempt resumes the copy.
636pub const SEED_INCOMPLETE_REASON: &str = "SeedIncomplete";
637/// `reason` for [`SEEDED_CONDITION`] = `False` when a seed was armed and the
638/// repository ended the bootstrap holding ZERO snapshots — an attempt that
639/// initialized the backend and then died. The next attempt resumes the copy;
640/// nothing at the backend should be deleted.
641pub const SEED_LEFT_EMPTY_REASON: &str = "SeedLeftEmpty";
642/// `reason` for [`SEEDED_CONDITION`] = `False` when the running mover image
643/// predates `spec.seed`: it dropped the unknown field, fell into the create
644/// fallback, and initialized an EMPTY repository. Terminal — only an image
645/// upgrade changes it.
646pub const SEED_MOVER_TOO_OLD_REASON: &str = "MoverImageTooOldForSeed";
647
648/// Every `Seeded=False` reason that means "the last seed attempt FAILED", as
649/// opposed to the park/progress reasons ([`WAITING_FOR_SEED_SOURCE_REASON`],
650/// [`SEEDING_REASON`]).
651///
652/// Exists so a writer can ask "has a previous attempt already recorded a
653/// failure here?" without restating the list — the seeding-progress writer uses
654/// it to leave a recorded failure standing rather than overwriting it with
655/// `Seeding` on every ~2-minute retry, which would make every retry cycle a
656/// fresh status transition (and so a fresh Event and metric increment).
657pub const SEED_FAILURE_REASONS: &[&str] = &[
658    SEED_SOURCE_NOT_FOUND_REASON,
659    SEED_SOURCE_EMPTY_REASON,
660    SEED_INCOMPLETE_REASON,
661    SEED_LEFT_EMPTY_REASON,
662    SEED_MOVER_TOO_OLD_REASON,
663];
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn api_version_is_group_slash_version() {
671        // The duplicated literal must never drift from the canonical pair.
672        assert_eq!(API_VERSION, format!("{}/{}", crate::GROUP, crate::VERSION));
673    }
674
675    #[test]
676    fn well_known_strings_are_group_prefixed() {
677        // Finalizers/labels/annotations on the kopiur API surface live under the
678        // API group domain; a typo'd prefix would silently break selectors.
679        for s in [
680            SNAPSHOT_CLEANUP_FINALIZER,
681            SKIP_SNAPSHOT_CLEANUP_ANNOTATION,
682            ORIGIN_LABEL,
683            SNAPSHOT_ID_LABEL,
684            REPOSITORY_UID_LABEL,
685            CONFIG_LABEL,
686            REPLICATION_LABEL,
687            SCHEDULE_LABEL,
688            SNAPSHOT_REPLICATION_LABEL,
689            GROUP_LABEL,
690            OP_LABEL,
691            SESSION_LABEL,
692            SESSION_REPO_LABEL,
693            RUN_REQUESTED_ANNOTATION,
694            RUN_MODE_ANNOTATION,
695            ALLOW_IDENTITY_CHANGE_ANNOTATION,
696            PRUNED_BY_ANNOTATION,
697            ALLOW_MASS_DELETION_ANNOTATION,
698            ALLOW_REINITIALIZE_ANNOTATION,
699            PRIVILEGED_MOVERS_ANNOTATION,
700            REPO_POOL_LABEL,
701        ] {
702            assert!(s.starts_with(crate::GROUP), "{s} must be group-prefixed");
703        }
704    }
705
706    #[test]
707    fn effective_max_concurrent_jobs_truth_table() {
708        use crate::common::ConcurrencySpec;
709
710        let cap = |n: Option<u32>| {
711            effective_max_concurrent_jobs(Some(&ConcurrencySpec {
712                max_concurrent_jobs: n,
713            }))
714            .map(|v| v.get())
715        };
716
717        // All three spellings of "unlimited" collapse to the same `None`: no
718        // `concurrency` block, a block with no field, and an explicit `0`.
719        assert_eq!(effective_max_concurrent_jobs(None), None);
720        assert_eq!(cap(None), None);
721        assert_eq!(cap(Some(0)), None);
722
723        // A positive value is the cap, verbatim — 1 (fully serialized) included.
724        assert_eq!(cap(Some(1)), Some(1));
725        assert_eq!(cap(Some(4)), Some(4));
726        assert_eq!(cap(Some(1000)), Some(1000));
727        assert_eq!(cap(Some(u32::MAX)), Some(u32::MAX as usize));
728    }
729
730    #[test]
731    fn the_slot_condition_is_deliberately_not_a_structural_gate() {
732        // A parked-for-a-slot run self-heals as the pool drains, so registering it
733        // would make `doctor` cry wedge over a merely busy repository — the inverse
734        // of the false-green defect the registry exists for. Pinned so a later
735        // "every condition should be a gate" tidy-up has to read the reasoning.
736        assert!(
737            !crate::gates::STRUCTURAL_GATES
738                .iter()
739                .any(|g| g.condition == REPOSITORY_SLOT_AVAILABLE_CONDITION),
740            "RepositorySlotAvailable must not be a structural gate: it self-heals"
741        );
742    }
743}