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/// Label on a browse session `Job` that loads copies of credential Secrets
92/// living in another namespace, which the operator writes for it. The
93/// operator watches only Jobs carrying it, with [`SESSION_CREDS_PROJECTED`].
94pub const SESSION_CREDS_LABEL: &str = "kopiur.home-operations.com/session-creds";
95/// [`SESSION_CREDS_LABEL`] value: the session's credentials are projected.
96pub const SESSION_CREDS_PROJECTED: &str = "projected";
97/// Annotation naming the `Snapshot` (in the Job's own namespace) a browse
98/// session was started for. The operator re-checks that snapshot's pinned
99/// `credentialProjection` before copying anything, so a Job alone, without a
100/// snapshot that opted in, never gets credentials.
101pub const SESSION_SNAPSHOT_ANNOTATION: &str = "kopiur.home-operations.com/session-snapshot";
102/// Annotation the operator writes on a projected browse session `Job` when it
103/// will not copy the credentials, holding why and what to change; removed once
104/// the copy is written. The person browsing can read a Job but not its Events,
105/// so this is how the session's wait learns of a refusal and says so at once.
106pub const SESSION_CREDS_REFUSED_ANNOTATION: &str =
107    "kopiur.home-operations.com/session-creds-refused";
108/// How often the operator re-checks a browse session it refused, in seconds.
109/// Shared so a session reused after someone fixed a gate knows how long a
110/// refusal still on the Job may be stale.
111pub const SESSION_CREDS_RECHECK_SECS: u64 = 15;
112
113/// Annotation requesting an out-of-band `Maintenance` run NOW (Flux-style
114/// reconcile trigger). Value: an RFC3339 timestamp; a NEW timestamp requests a
115/// new run (re-applying the same value is a no-op once handled). Usable from
116/// bare `kubectl annotate` or `kubectl kopiur maintenance run`.
117pub const RUN_REQUESTED_ANNOTATION: &str = "kopiur.home-operations.com/run-requested";
118/// Companion annotation selecting the run kind: `quick` (default) or `full`
119/// (see `kopiur_api::maintenance::ManualRunMode`).
120pub const RUN_MODE_ANNOTATION: &str = "kopiur.home-operations.com/run-mode";
121
122/// Acknowledges an intentional identity-affecting change on UPDATE. Two surfaces
123/// share it:
124/// - a `SnapshotPolicy`'s own resolved kopia identity (`username@hostname`, or a
125///   source's path) — see `ValidationError::IdentityWouldFork`;
126/// - a `Repository`/`ClusterRepository`'s `identityDefaults` (`cluster`,
127///   `hostnameExpr`, `usernameExpr`), which every consumer policy relying on
128///   those defaults re-resolves against on its next reconcile/backup — see
129///   `ValidationError::RepositoryIdentityWouldFork`.
130///
131/// Without it, the webhook **rejects** the edit when it would re-identify
132/// (a) policy/(consumer policies) with existing snapshot history, because new
133/// snapshots land under a new kopia source: restore/verify/`fromPolicy` resolve
134/// the new identity (old history is reachable only via
135/// `Restore.spec.source.identity`), while old- and new-lineage `Snapshot` CRs
136/// keep competing in the policy's one GFS retention timeline. Any
137/// **non-empty** value acknowledges the re-identification for that admission
138/// (presence-only, mirroring [`SKIP_SNAPSHOT_CLEANUP_ANNOTATION`]; a specific
139/// value isn't required because an edit can change more than one identity
140/// component at once, and the operator-resolved string is not something the
141/// author can pre-compute). Lives here because the operator and any GitOps/user
142/// automation must agree on it byte-for-byte.
143pub const ALLOW_IDENTITY_CHANGE_ANNOTATION: &str =
144    "kopiur.home-operations.com/allow-identity-change";
145
146/// Marks a `Snapshot` the OPERATOR is deleting as part of its own lifecycle,
147/// stamped immediately before the delete call. Values: `PrunedBy` annotation
148/// values (`retention`, `failed-history`, `policy-cascade`,
149/// `replication-retention`). The Snapshot finalizer uses it to
150/// distinguish Kopiur's own prunes from external deletions (GC cascades,
151/// kubectl, third-party controllers); external destructive deletions are
152/// subject to the schedule-cascade guard and the mass-deletion breaker,
153/// operator prunes are not. Any unrecognized value is treated as EXTERNAL
154/// (fail-safe). Wire-visible: users and tooling may read it on terminating CRs.
155pub const PRUNED_BY_ANNOTATION: &str = "kopiur.home-operations.com/pruned-by";
156
157/// Acknowledges a mass-deletion wave on a `Repository`/`ClusterRepository`.
158/// Value: an RFC3339 timestamp. A HELD external deletion is released iff its
159/// Snapshot's `metadata.deletionTimestamp` <= this value — "I approve what is
160/// pending NOW". Deliberately VALUED (unlike the presence-only
161/// allow-identity-change ack, consumed at a single admission instant): this
162/// annotation is read continuously by the controller, so a presence-only ack
163/// left behind (or committed to Git) would disarm the breaker forever. With
164/// the timestamp, a stale ack is inert against any LATER wave, nothing ever
165/// needs to remove it, and the operator never edits user metadata. The
166/// controller clamps the effective value to <= its own now (clock-skew guard);
167/// an unparseable value is ignored (Warning event on the repository).
168pub const ALLOW_MASS_DELETION_ANNOTATION: &str = "kopiur.home-operations.com/allow-mass-deletion";
169
170/// Acknowledges a deliberate RE-INITIALIZATION of a `Repository`/`ClusterRepository`
171/// whose backend was wiped: kopiur refuses to auto-create a fresh kopia repository
172/// over a once-`Ready` one (the pinned `status.uniqueId`), and this annotation is
173/// the human "yes, I know the history is gone — make a new one".
174///
175/// Value: the repository's CURRENT `status.uniqueId`, verbatim. Honored only while
176/// it equals that pin, which is what makes it **self-expiring**: the moment the
177/// re-initialize succeeds a NEW unique id is minted, the annotation no longer
178/// matches, and the ack is inert — so a copy left behind in a GitOps manifest can
179/// never authorize a second wipe. Contrast with
180/// [`ALLOW_MASS_DELETION_ANNOTATION`] (an RFC3339 timestamp, compared against each
181/// pending deletion) and with [`ALLOW_IDENTITY_CHANGE_ANNOTATION`] (presence-only,
182/// consumed at a single admission instant): this one is read continuously by the
183/// controller, so it needs a value that goes stale on its own. kopiur never writes
184/// or removes it — there is no "honored" stamp to keep in sync.
185///
186/// A value that is present but does NOT match the pin is ignored (fail-safe) and
187/// raises one Warning event naming the expected value.
188pub const ALLOW_REINITIALIZE_ANNOTATION: &str = "kopiur.home-operations.com/allow-reinitialize";
189
190/// Annotation stamped on a `Repository`/`ClusterRepository` (writer: the policy
191/// reconciler, M6) to REQUEST an on-demand catalog scan — e.g. after adopting a
192/// delete-then-recreated repository, so its discovered snapshots materialize
193/// immediately instead of waiting for the next spec change or (opt-in) periodic
194/// refresh. The RFC3339 timestamp VALUE is an opaque token: honored once via
195/// `status.catalog.scanRequestHonored` (equality, never a `lastRefreshAt`
196/// comparison), and rate-limited via `status.catalog.scanRequestAttemptAt` so a
197/// pending token against an unreachable backend cannot recreate bootstrap Jobs on
198/// every reconcile.
199pub const CATALOG_SCAN_REQUESTED_ANNOTATION: &str =
200    "kopiur.home-operations.com/catalog-scan-requested-at";
201
202/// The API version string for kopiur CRDs (used in mover `TargetRef`s and
203/// `kubectl -o name`-style output).
204pub const API_VERSION: &str = "kopiur.home-operations.com/v1alpha1";
205
206/// Pod label opting a mover pod into the **azure-workload-identity** mutating
207/// webhook: pods carrying `azure.workload.identity/use: "true"` and running as
208/// a federated `ServiceAccount` get `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/
209/// `AZURE_FEDERATED_TOKEN_FILE` (and the projected token volume) injected —
210/// exactly the env kopia's azure backend binds its credential flags to. Stamped
211/// by the operator (and the CLI's browse sessions) on every mover pod for a
212/// repository whose azure backend uses `auth.workloadIdentity`. Lives here
213/// because the operator and `kubectl kopiur` must agree on it byte-for-byte.
214pub const AZURE_WORKLOAD_IDENTITY_LABEL: &str = "azure.workload.identity/use";
215/// The [`AZURE_WORKLOAD_IDENTITY_LABEL`] value opting the pod in.
216pub const AZURE_WORKLOAD_IDENTITY_LABEL_VALUE: &str = "true";
217
218/// The standard `app.kubernetes.io/managed-by` label key. Stamped on **every**
219/// operator-created object (mover Jobs, work-spec ConfigMaps, cache PVC, minted
220/// mover SA/RoleBinding, projected credential Secret, CSI VolumeSnapshots) so
221/// Argo/Flux recognize them as controller-owned and neither prune nor report them
222/// `OutOfSync` (ADR-0005 §14(c)).
223pub const MANAGED_BY_LABEL: &str = "app.kubernetes.io/managed-by";
224/// The [`MANAGED_BY_LABEL`] value identifying kopiur-managed objects.
225pub const MANAGED_BY_VALUE: &str = "kopiur";
226
227/// kstatus-compliant standard condition types (ADR-0005 §2) so `kubectl wait
228/// --for=condition=Ready` and Flux/Argo health checks work natively against every
229/// reconciled kopiur CRD.
230/// The headline readiness condition.
231pub const READY_CONDITION: &str = "Ready";
232/// Set `True` while a reconcile is making progress toward Ready.
233pub const RECONCILING_CONDITION: &str = "Reconciling";
234/// Set `True` when the resource is stuck and won't progress without intervention
235/// (mapped from a terminal `ErrorClass::Terminal` failure).
236pub const STALLED_CONDITION: &str = "Stalled";
237
238/// `Repository`/`ClusterRepository` condition recording whether a `Maintenance`
239/// covers it (ADR §3.7). Wire-visible: GitOps health checks and the kubectl
240/// plugin's `status` read it.
241pub const MAINTENANCE_CONFIGURED_CONDITION: &str = "MaintenanceConfigured";
242
243/// `Repository`/`ClusterRepository` condition reporting content-index-blob
244/// health (ADR-0005 §13). `True` = healthy (count under threshold); `False`
245/// with reason `TooManyIndexBlobs` = the index is growing unbounded because
246/// maintenance isn't compacting. NON-BLOCKING: the repository stays `Ready` and
247/// GitOps health gates are not tripped — it's a degradation warning, not an
248/// outage. Wire-visible (the kubectl plugin's `status` reads it).
249pub const INDEX_BLOB_HEALTH_CONDITION: &str = "IndexBlobHealth";
250
251/// Default `spec.health.indexBlobWarnThreshold`: the index-blob count above which
252/// the reconciler warns that maintenance isn't keeping up. A freshly-compacted
253/// repo sits near zero; a wedged-maintenance repo climbs unbounded (a real one
254/// reached 1448). Conservative so it only fires when maintenance is clearly
255/// behind. Overridable per-repo; `0` disables the warning. Part of the documented
256/// API contract, so it lives here rather than in the controller.
257pub const DEFAULT_INDEX_BLOB_WARN_THRESHOLD: i64 = 1000;
258
259/// Default catalog re-scan cadence when `spec.catalog.refreshInterval` is unset:
260/// how often a `Ready` repository re-lists its kopia snapshots to materialize
261/// (and expire) `origin: discovered` `Snapshot` CRs. Part of the documented API
262/// contract (field-reference), so it lives here rather than in the controller.
263pub const DEFAULT_CATALOG_REFRESH_INTERVAL: std::time::Duration =
264    std::time::Duration::from_secs(3600);
265
266/// Floor for `spec.catalog.refreshInterval`, enforced at admission. Each re-scan
267/// of an object-store repository runs a short mover Job; anything faster than
268/// this is Job churn with no operational value.
269pub const MIN_CATALOG_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
270
271/// Default `spec.health.probe.enabled` when unset: the periodic backend health
272/// probe is ON by default (#345). The probe is the sensor for the repository
273/// circuit breaker — with the default `onFailure: Degrade`, a sustained backend
274/// failure moves the repository to `Degraded` and pauses backups until a
275/// re-connect succeeds — so it must run unless the user explicitly opts out with
276/// `enabled: false`. Part of the documented API contract, so it lives here, not
277/// in the controller.
278pub const DEFAULT_HEALTH_PROBE_ENABLED: bool = true;
279
280/// Default `spec.health.probe.interval` when unset: how often the backend
281/// health probe re-connects a `Ready` repository to confirm the kopia repository
282/// still exists at the backend. Conservative — a vanished/unreachable
283/// repository is rare and the probe runs a short mover Job — so it leans long.
284/// Part of the documented API contract, so it lives here, not in the controller.
285pub const DEFAULT_HEALTH_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1800);
286
287/// Floor for `spec.health.probe.interval`, enforced at admission. Each probe runs
288/// a short mover Job (object-store / volume-backed) or an in-process connect;
289/// anything faster than this is Job churn with no operational value. Shares the
290/// 30s floor with the catalog re-scan for the same reason.
291pub const MIN_HEALTH_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
292
293/// Default `spec.health.probe.failureThreshold`: how many *consecutive* failing
294/// probes must accumulate before the loud `RepositoryVanished` / `BackendReachable=False`
295/// condition is raised, an event fired, and — under the default
296/// `onFailure: Degrade` — the repository moved to `Degraded` (pausing backups).
297/// Debounces a single transient blip (an S3 list-after-delete race, a NAS
298/// reboot, a credential-rotation moment) from alarming on-call, tripping the
299/// breaker, or nudging a destructive manual recreate.
300pub const DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD: i64 = 3;
301
302/// Default `SnapshotSchedule.spec.failedJobsHistoryLimit` when unset: how many
303/// `Failed` `Snapshot` CRs from a schedule to retain (the rest are pruned). Bounds
304/// failure history so a schedule firing against a persistently-failing precondition
305/// or backend doesn't accumulate `Failed` CRs forever. GFS retention applies only to
306/// successful snapshots, so this is the *only* bound on failures (ADR-0003). Part of
307/// the documented API contract, so it lives here, not in the controller.
308pub const DEFAULT_FAILED_JOBS_HISTORY_LIMIT: u32 = 10;
309
310/// The effective failed-history limit: `failedJobsHistoryLimit` when set, else
311/// [`DEFAULT_FAILED_JOBS_HISTORY_LIMIT`]. `Some(0)` keeps no failed snapshots.
312pub fn effective_failed_jobs_history_limit(limit: Option<u32>) -> u32 {
313    limit.unwrap_or(DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
314}
315
316/// Default bound on a stream producer/consumer command (a `stream` source's
317/// `workloadExec.timeout`, or a `streamExec` restore target's) when `timeout` is
318/// unset. Generous enough for a large logical dump, finite so a wedged command
319/// cannot pin a mover Job forever. Both resolution sites (backup Job build and
320/// restore Job build) map absent-or-unparseable → exactly this value, which is
321/// what makes emitting it as a schema `default:` behavior-preserving. Part of the
322/// documented API contract (field-reference), so it lives here rather than in the
323/// controller.
324pub const DEFAULT_STREAM_TIMEOUT_SECS: u64 = 3600;
325
326/// Default `spec.deletionProtection.threshold` (0 disables). 10 pending
327/// external destructive deletions is far above legitimate manual cleanup but
328/// far below a tooling-driven cascade (the motivating incident was ~600).
329pub const DEFAULT_MASS_DELETION_THRESHOLD: u32 = 10;
330
331/// The effective mass-deletion breaker threshold: `deletionProtection.threshold`
332/// when set, else [`DEFAULT_MASS_DELETION_THRESHOLD`]. `Some(0)` disables the breaker.
333pub fn effective_mass_deletion_threshold(p: Option<&crate::common::DeletionProtectionSpec>) -> u32 {
334    p.and_then(|d| d.threshold)
335        .unwrap_or(DEFAULT_MASS_DELETION_THRESHOLD)
336}
337
338/// The effective per-repository mover-Job concurrency cap:
339/// `concurrency.maxConcurrentJobs` when set to a NON-ZERO value, else `None`
340/// (uncapped). An absent `concurrency` block, an absent `maxConcurrentJobs`, and
341/// an explicit `0` all mean the same thing — no limit — so all three collapse to
342/// `None`.
343///
344/// Deliberately returns an `Option<NonZeroUsize>` rather than the plain scalar
345/// [`effective_mass_deletion_threshold`] returns for its neighbouring breaker.
346/// That resolver can use `0` as its own disable sentinel because the value it
347/// yields is a *count to compare against*; here the value is a *capacity*, and
348/// `Some(0)` would read as "admit nothing" — a repository that never runs a Job
349/// again. Encoding "uncapped" in the `Option` and non-zero-ness in the type makes
350/// that state unrepresentable rather than merely untested, and the caller's
351/// `match`/`if let` is then forced to spell out the uncapped path.
352///
353/// ```
354/// use kopiur_api::common::ConcurrencySpec;
355/// use kopiur_api::consts::effective_max_concurrent_jobs;
356///
357/// // No block at all, no field, and an explicit 0 are the same state: uncapped.
358/// assert_eq!(effective_max_concurrent_jobs(None), None);
359/// assert_eq!(
360///     effective_max_concurrent_jobs(Some(&ConcurrencySpec { max_concurrent_jobs: None, max_concurrent_delete_jobs: None })),
361///     None,
362/// );
363/// assert_eq!(
364///     effective_max_concurrent_jobs(Some(&ConcurrencySpec { max_concurrent_jobs: Some(0), max_concurrent_delete_jobs: None })),
365///     None,
366/// );
367/// // A positive value caps the pool.
368/// assert_eq!(
369///     effective_max_concurrent_jobs(Some(&ConcurrencySpec { max_concurrent_jobs: Some(3), max_concurrent_delete_jobs: None }))
370///         .map(|n| n.get()),
371///     Some(3),
372/// );
373/// ```
374pub fn effective_max_concurrent_jobs(
375    spec: Option<&crate::common::ConcurrencySpec>,
376) -> Option<std::num::NonZeroUsize> {
377    spec.and_then(|c| c.max_concurrent_jobs)
378        .and_then(|n| std::num::NonZeroUsize::new(n as usize))
379}
380
381/// The longest `Job` name that stays valid once Kubernetes derives child object
382/// names from it (#547): **57**, not 63.
383///
384/// Kubernetes 1.37's `WorkloadWithJob` creates a pod-group template named
385/// `<job>-pgt-0` for each Job, and that name must itself fit the 63-character
386/// DNS-label limit, so a Job name may use at most `63 - len("-pgt-0")` = 57.
387/// (Pod names are not the constraint: they come from `generateName`, which the
388/// apiserver truncates.) Generated Job names are capped to this; a Job whose name
389/// IS a user-chosen CR name (a `Snapshot`'s backup Job, a `Restore`'s Job) cannot
390/// be renamed, so admission warns when such a name is longer.
391pub const JOB_NAME_MAX: usize = 57;
392
393/// The per-repository snapshot-delete batch default when
394/// `concurrency.maxConcurrentDeleteJobs` is absent: one batch at a time
395/// (single-flight). See [`effective_max_concurrent_delete_jobs`].
396pub const DEFAULT_MAX_CONCURRENT_DELETE_JOBS: std::num::NonZeroUsize = std::num::NonZeroUsize::MIN;
397
398/// The effective per-repository cap on live snapshot-delete batch Jobs:
399/// `concurrency.maxConcurrentDeleteJobs` when set, else
400/// [`DEFAULT_MAX_CONCURRENT_DELETE_JOBS`] (1). Unlike
401/// [`effective_max_concurrent_jobs`] there is NO uncapped state: parallel bulk
402/// deletes against one repository only multiply index loads (issue #477). An
403/// explicit `0` is refused at admission; should one reach the controller anyway
404/// it also resolves to the default rather than to "delete nothing, forever".
405///
406/// ```
407/// use kopiur_api::common::ConcurrencySpec;
408/// use kopiur_api::consts::effective_max_concurrent_delete_jobs;
409///
410/// assert_eq!(effective_max_concurrent_delete_jobs(None).get(), 1);
411/// let two = ConcurrencySpec { max_concurrent_delete_jobs: Some(2), ..Default::default() };
412/// assert_eq!(effective_max_concurrent_delete_jobs(Some(&two)).get(), 2);
413/// ```
414pub fn effective_max_concurrent_delete_jobs(
415    spec: Option<&crate::common::ConcurrencySpec>,
416) -> std::num::NonZeroUsize {
417    spec.and_then(|c| c.max_concurrent_delete_jobs)
418        .and_then(|n| std::num::NonZeroUsize::new(n as usize))
419        .unwrap_or(DEFAULT_MAX_CONCURRENT_DELETE_JOBS)
420}
421
422/// Pool-membership label stamped on every mover `Job` that counts toward its
423/// repository's `spec.concurrency.maxConcurrentJobs`: backup, restore, and the
424/// SOURCE side of a `RepositoryReplication`/`SnapshotReplication`. One selector
425/// therefore counts a repository's whole in-flight pool.
426///
427/// The value is `kopiur_controller::naming::repo_label` over the repository's
428/// **normalized** ref — `<=40-char name prefix>-<hash8>`, the same value the
429/// batch-delete single-flight label `kopiur.home-operations.com/delete-repo`
430/// carries. Deliberately NOT the
431/// `<kind>-<name>` shape of [`SESSION_REPO_LABEL`]: that spelling cannot
432/// distinguish two `Repository`s of the same name in different namespaces, and
433/// merging their pools would silently halve each one's cap. The hash is over
434/// `repository:{ns}/{name}` / `clusterrepository:{name}`, so namespace and kind
435/// both discriminate.
436///
437/// Maintenance, verification, pin, snapshot-delete batch, bootstrap/discovery and
438/// browse-session Jobs deliberately do NOT carry it — they are operator-driven
439/// housekeeping (or an interactive session), excluded from the pool so a
440/// saturated backup queue can never starve them.
441pub const REPO_POOL_LABEL: &str = "kopiur.home-operations.com/repo-pool";
442
443/// `Repository`/`ClusterRepository` condition: pending external destructive
444/// deletions for this repository are at/above the breaker threshold and held.
445pub const MASS_DELETION_HELD_CONDITION: &str = "MassDeletionHeld";
446
447/// `reason` for [`MASS_DELETION_HELD_CONDITION`] = `True` on a
448/// `Repository`/`ClusterRepository`: pending external destructive deletions for
449/// this repository are at/above its breaker threshold.
450pub const MASS_DELETION_THRESHOLD_EXCEEDED_REASON: &str = "ThresholdExceeded";
451
452// --- Structural-gate conditions/reasons (shared with `kubectl kopiur doctor`) -
453//
454// These are the condition `type`/`reason` pairs the reconcilers stamp when work
455// is BLOCKED on something only a human can change (a namespace opt-in, a Secret,
456// an acknowledgement annotation). They live here — not in `kopiur-controller` —
457// because the CLI's `doctor` must recognize them byte-for-byte; the typed
458// registry in [`crate::gates`] is the shared enumeration built from them, and
459// the controller re-exports each name so its call sites are unchanged.
460
461/// Namespace annotation a cluster admin sets to allow elevated (root/privileged)
462/// movers in that namespace (ADR §4.11/§G16). Without it, a `SnapshotPolicy` whose
463/// `spec.mover` requests privilege is refused — a tenant could otherwise reuse the
464/// minted mover ServiceAccount at that privilege. Mirrors VolSync's
465/// `volsync.backube/privileged-movers`.
466pub const PRIVILEGED_MOVERS_ANNOTATION: &str = "kopiur.home-operations.com/privileged-movers";
467/// Namespace annotation a cluster admin sets to allow `stream` backup sources in
468/// that namespace.
469///
470/// Separate from [`PRIVILEGED_MOVERS_ANNOTATION`] because it gates a different
471/// capability: not an elevated container, but `pods/exec` — the ability to run
472/// commands inside OTHER pods in the namespace. Kubernetes separates that verb from
473/// ordinary write access deliberately, and without this gate anyone who can create a
474/// `SnapshotPolicy` in a namespace would effectively acquire it.
475pub const STREAM_EXEC_ANNOTATION: &str = "kopiur.home-operations.com/stream-exec-movers";
476/// `Snapshot`/`Restore` condition surfaced when a privileged mover is requested in a
477/// namespace that has not opted in — `False` carries the actionable message.
478pub const MOVER_PERMITTED_CONDITION: &str = "MoverPermitted";
479/// `reason`/Event reason for [`MOVER_PERMITTED_CONDITION`] = `False`.
480pub const PRIVILEGED_MOVER_NOT_PERMITTED_REASON: &str = "PrivilegedMoverNotPermitted";
481
482/// `reason`/Event reason for [`MOVER_PERMITTED_CONDITION`] = `False` when a
483/// `stream` source is used in a namespace that has not opted in to
484/// [`STREAM_EXEC_ANNOTATION`].
485pub const STREAM_EXEC_NOT_PERMITTED_REASON: &str = "StreamExecNotPermitted";
486
487/// `SnapshotSchedule` condition recording whether the schedule is able to fire
488/// its next slot. Set `False` (with [`BLOCKED_ON_UNREADABLE_RUN_REASON`]) when
489/// the concurrency gate is held by a `Snapshot` whose `status.phase` this build
490/// cannot interpret.
491pub const SCHEDULE_RUNNABLE_CONDITION: &str = "ScheduleRunnable";
492/// `reason`/Event reason for [`SCHEDULE_RUNNABLE_CONDITION`] = `False`: a
493/// previous run of this schedule sits at a phase string written by a NEWER
494/// kopiur, so this build can never observe it reach a terminal phase. Under the
495/// default `concurrencyPolicy: Forbid` that stops the schedule permanently, and
496/// nothing about the `SnapshotSchedule` itself would otherwise say so — the
497/// silent-wedge shape of #359, one kind removed.
498pub const BLOCKED_ON_UNREADABLE_RUN_REASON: &str = "BlockedOnUnreadableRun";
499
500/// `Snapshot` condition recording whether its repository accepts writes (§11). Set
501/// `False` (with [`REPOSITORY_READ_ONLY_REASON`]) when a backup is refused because
502/// the repository is `mode: ReadOnly`.
503pub const REPOSITORY_WRITABLE_CONDITION: &str = "RepositoryWritable";
504/// `reason`/Event reason when a backup or maintenance is refused on a `ReadOnly`
505/// repository (ADR-0005 §11).
506pub const REPOSITORY_READ_ONLY_REASON: &str = "RepositoryReadOnly";
507
508/// `Snapshot`/`Restore` condition surfaced when the mover Job's credential Secret is
509/// absent from the workload namespace — `False` carries the actionable message
510/// (which Secret, which namespace, why, and how to fix). ADR §4.12.
511pub const CREDENTIALS_AVAILABLE_CONDITION: &str = "CredentialsAvailable";
512/// `reason`/Event reason for [`CREDENTIALS_AVAILABLE_CONDITION`] = `False`.
513pub const MISSING_CREDENTIALS_REASON: &str = "MissingCredentialsSecret";
514/// `reason`/Event reason for [`CREDENTIALS_AVAILABLE_CONDITION`] = `False` when
515/// the missing dependency is the **workload-identity ServiceAccount** the
516/// backend's `auth.workloadIdentity` names (the user creates it; kopiur never
517/// does — its cloud annotations are the user's federation contract).
518pub const MISSING_SERVICE_ACCOUNT_REASON: &str = "MissingServiceAccount";
519/// `reason`/Event reason for [`CREDENTIALS_AVAILABLE_CONDITION`] = `False` when
520/// the missing dependency is the backend's `tls.caBundleRef` **ConfigMap** (or
521/// its key): the PEM CA bundle the mover needs to verify a private-CA S3
522/// endpoint. The user creates it; a ConfigMap that never appears never
523/// self-heals, so this is a structural gate
524/// ([`crate::gates::MISSING_CA_BUNDLE_GATE`]), not just a transient retry.
525pub const MISSING_CA_BUNDLE_REASON: &str = "MissingCaBundle";
526/// Default key within a `tls.caBundleRef` ConfigMap when `key` is unset — the
527/// conventional filename cert-manager and trust-manager emit CA bundles under.
528/// Lives here (not in the controller) because the kubectl-plugin resolves the
529/// same reference client-side and must agree on the default (centralize-config).
530pub const DEFAULT_CA_BUNDLE_KEY: &str = "ca.crt";
531
532/// `SnapshotPolicy` condition recording whether every repository the policy
533/// targets is `Ready`. Set `False` (with [`REPOSITORY_NOT_READY_REASON`]) when
534/// at least one referenced `Repository`/`ClusterRepository` is not `Ready`:
535/// backups, retention, adoption and verification against the not-ready
536/// subset are deferred (the ready subset keeps processing), and nothing about
537/// the policy's phase-less surface would otherwise say so — the silent-wedge
538/// shape of #359 for the recipe kind. Structural
539/// ([`crate::gates::POLICY_REPOSITORY_NOT_READY_GATE`]): a repository that
540/// never recovers never self-heals this condition.
541pub const REPOSITORIES_READY_CONDITION: &str = "RepositoriesReady";
542/// `reason` for [`REPOSITORIES_READY_CONDITION`] = `False` — the same string
543/// the `Snapshot` reconciler stamps on a child parked behind a not-Ready
544/// repository (one string, both surfaces; the controller re-exports it).
545pub const REPOSITORY_NOT_READY_REASON: &str = "RepositoryNotReady";
546
547/// `SnapshotSchedule` condition recording whether a fired slot minted its full
548/// members × repositories fan-out. `True` (with [`FANOUT_TOO_LARGE_REASON`])
549/// = the cross-product exceeded the fan-out cap and the slot was SKIPPED — and
550/// it will keep skipping every slot until the selector is narrowed or the
551/// repository list shrunk, which is squarely "needs a human". Structural
552/// ([`crate::gates::SCHEDULE_FANOUT_CAPPED_GATE`], promoted here by the #368
553/// M10 gates/doctor checklist from a controller-internal condition).
554pub const SCHEDULE_FANOUT_CAPPED_CONDITION: &str = "FanoutCapped";
555/// `reason` for [`SCHEDULE_FANOUT_CAPPED_CONDITION`] = `True`.
556pub const FANOUT_TOO_LARGE_REASON: &str = "FanoutTooLarge";
557
558/// `Snapshot` condition recording whether the backup's DIRECT source PVC
559/// (`spec.sources[].pvc`) exists. Set `False` (with
560/// [`SOURCE_PVC_MISSING_REASON`]) when the PVC named by the recipe is absent
561/// at launch time: the backup parks (`phase: Pending`) and, after the
562/// controller's deadline, fails terminally — a PVC that never reappears never
563/// self-heals, the silent-wedge shape of #359. Structural
564/// ([`crate::gates::SOURCE_PVC_MISSING_GATE`]); only the direct source PVC
565/// earns this gate — a vanished operator-staged claim is a restage race and
566/// stays a transient retry.
567pub const SOURCE_PVC_AVAILABLE_CONDITION: &str = "SourcePvcAvailable";
568/// `reason`/Event reason for [`SOURCE_PVC_AVAILABLE_CONDITION`] = `False`.
569pub const SOURCE_PVC_MISSING_REASON: &str = "SourcePvcMissing";
570
571/// `Snapshot`/`Restore` condition recording whether the mover's securityContext
572/// could be RESOLVED at all — the hold, as opposed to the advisory
573/// `SecurityContextInherited` report of what inheritance achieved on a run that
574/// still went ahead.
575///
576/// Set `False` (with [`INHERIT_SOURCE_MISSING_REASON`]) when
577/// `mover.inheritSecurityContextFrom` names a live-pod source that resolved
578/// NOTHING — no pod matched the selector, the named container is absent, or the
579/// matched pod sets neither a container nor a pod-level context — AND the recipe
580/// pins no fallback identity. The run parks at `phase: Pending` on the slow
581/// structural cadence rather than being backed up (or restored) as the wrong UID.
582///
583/// Deliberately its OWN condition rather than a reason on
584/// `SecurityContextInherited`: that condition is advisory and carries five
585/// unregistered reasons, and registering one of them would make
586/// `kubectl kopiur doctor` classify the other four as "the operator is newer than
587/// the plugin" (its `first_gate` reports a registered condition+status whose
588/// reason matches no row as UNREGISTERED). A separate condition keeps the hold
589/// visible to `doctor` and the advisory report advisory.
590/// Structural ([`crate::gates::INHERIT_SOURCE_MISSING_GATE`]).
591pub const SECURITY_CONTEXT_RESOLVED_CONDITION: &str = "SecurityContextResolved";
592/// `reason`/Event reason for [`SECURITY_CONTEXT_RESOLVED_CONDITION`] = `False`:
593/// a live-pod `mover.inheritSecurityContextFrom` resolved no securityContext and
594/// the recipe pins no fallback `runAsUser` to proceed on. Clears when the
595/// workload comes up, the selector is corrected, or an explicit
596/// `mover.securityContext.runAsUser` is set.
597pub const INHERIT_SOURCE_MISSING_REASON: &str = "InheritSourceMissing";
598/// `reason` for [`SECURITY_CONTEXT_RESOLVED_CONDITION`] = `True`: the hold above
599/// has CLEARED — the workload came back, the selector was corrected, or an
600/// explicit `runAsUser` was pinned — and the run is proceeding.
601///
602/// Written only when a non-`True` condition is already standing, so a run that
603/// was never held stays byte-identical and cannot hot-loop. Healing matters as
604/// much as holding: `kubectl kopiur doctor` suppresses a stale gate only on a
605/// TERMINAL phase, so a `Running` Snapshot still carrying the `False` would be
606/// reported as "blocked … will wait forever" for the whole mover run — the exact
607/// inverse of the false diagnosis this condition exists to prevent.
608/// [`crate::gates::StructuralGate::trips`] requires the blocked status, so a
609/// `True` matches no row and can never be read as an unregistered trip.
610pub const INHERIT_SOURCE_RESOLVED_REASON: &str = "InheritSourceResolved";
611
612/// Condition recording whether this run holds a slot in its repository's
613/// mover-Job pool (`spec.concurrency.maxConcurrentJobs`). `False` with
614/// [`WAITING_FOR_SLOT_REASON`] means the run is parked at `phase: Pending`
615/// because the pool is full; `True` with [`SLOT_ACQUIRED_REASON`] means it was
616/// admitted and its Job launched.
617///
618/// **Written by exactly three kinds — `Snapshot`, `RepositoryReplication` and
619/// `SnapshotReplication` — and each carries BOTH arms.** Those are the run kinds
620/// the gate can hold, so each can be parked at `False` and later healed to
621/// `True`.
622///
623/// **A `Restore` never carries this condition at all**, in either arm. A restore
624/// is a recovery in progress, so it is ALWAYS admitted — holding one behind a
625/// queue of routine backups is exactly backwards. Its mover Job is still
626/// labelled into the pool and still COUNTS against the cap (so a restore
627/// displaces backups rather than adding to them), but the restore reconciler
628/// never consults the gate and writes no slot condition of its own.
629///
630/// **Deliberately NOT a [`crate::gates::StructuralGate`].** The registry's
631/// contract (see the `gates` module doc) is "blocked on something only a human
632/// can change" — a park that never self-heals. This one always does: the pool
633/// drains as in-flight Jobs finish, and the parked run is admitted on a later
634/// pass with no human action at all. Registering it would make `kubectl kopiur
635/// doctor` report a wedge every time a busy repository is merely doing its job,
636/// which is the exact inverse of the false-green defect the registry exists to
637/// prevent. A pool that never drains is a *stuck Job* problem, and doctor's
638/// existing stuck/failure checks are what surface that.
639pub const REPOSITORY_SLOT_AVAILABLE_CONDITION: &str = "RepositorySlotAvailable";
640/// `reason` for [`REPOSITORY_SLOT_AVAILABLE_CONDITION`] = `False`: the
641/// repository's mover-Job pool is at its cap and this run is queued behind it.
642pub const WAITING_FOR_SLOT_REASON: &str = "WaitingForSlot";
643/// `reason` for [`REPOSITORY_SLOT_AVAILABLE_CONDITION`] = `True`: this run holds
644/// a slot in the repository's mover-Job pool (or the pool is uncapped).
645pub const SLOT_ACQUIRED_REASON: &str = "SlotAcquired";
646
647/// `SnapshotSchedule` condition recording that `concurrencyPolicy: Replace` is
648/// holding a due slot because the run it would replace is itself parked behind
649/// its repository's mover-Job concurrency cap
650/// ([`REPOSITORY_SLOT_AVAILABLE_CONDITION`] = `False`). Cancelling a queued run
651/// frees no capacity and the replacement would re-queue behind it, so `Replace`
652/// degrades to `Forbid`-like waiting until the pool drains.
653///
654/// Deliberately **not** a registered structural gate
655/// ([`crate::gates::STRUCTURAL_GATES`]): unlike `ScheduleRunnable=False`, this
656/// state needs no human — it clears by itself the moment a slot frees up. It
657/// exists so the wait is visible in `status` and so the accompanying Normal
658/// event fires once on entering the hold rather than on every requeue.
659pub const SCHEDULE_REPLACEMENT_HELD_CONDITION: &str = "ReplacementHeld";
660/// `reason`/Event reason for [`SCHEDULE_REPLACEMENT_HELD_CONDITION`] = `True`.
661pub const WAITING_FOR_REPOSITORY_SLOT_REASON: &str = "WaitingForRepositorySlot";
662/// `reason` for [`SCHEDULE_REPLACEMENT_HELD_CONDITION`] = `False` (not held).
663pub const REPLACEMENT_NOT_HELD_REASON: &str = "ReplacementNotHeld";
664
665/// `SnapshotSchedule` condition recording that `concurrencyPolicy: Forbid` (or
666/// `Replace`) is holding a due slot because a run this schedule did NOT start —
667/// a manual `kubectl kopiur snapshot now`, another schedule's run, or a
668/// hand-written `Snapshot` — is still in flight for one of the slot's target
669/// policies (#584). The slot fires once that run finishes; `Replace` never
670/// cancels a run it does not own.
671///
672/// Deliberately **not** a registered structural gate: it needs no human and
673/// clears on its own. It exists so the wait is visible in `status`, and so the
674/// accompanying Normal event fires once on entering the hold.
675pub const SCHEDULE_HELD_BY_OTHER_RUN_CONDITION: &str = "HeldByOtherRun";
676/// `reason`/Event reason for [`SCHEDULE_HELD_BY_OTHER_RUN_CONDITION`] = `True`.
677pub const OTHER_RUN_ACTIVE_REASON: &str = "OtherRunActive";
678/// `reason` for [`SCHEDULE_HELD_BY_OTHER_RUN_CONDITION`] = `False` (not held).
679pub const NO_OTHER_RUN_ACTIVE_REASON: &str = "NoOtherRunActive";
680
681/// `Restore` condition recording whether the object this restore's repository
682/// is DERIVED from exists. Set `False` (with
683/// [`RESTORE_REFERENT_MISSING_REASON`]) when the readiness gate cannot even
684/// look up the repository because a referent is absent: the explicit
685/// `spec.repository` object itself, or the `source.fromPolicy` `SnapshotPolicy`
686/// the repository ref is read from (issue #393).
687///
688/// Its own condition rather than the `Ready` one: the registry's coarse
689/// [`crate::gates::StructuralGate::trips`] filter keys on condition+polarity,
690/// so registering a `Ready=False` row would make every unrelated `Ready=False`
691/// reason on a `Snapshot`/`Restore` read as an unknown gate from a newer
692/// operator. Only this gate ever writes this condition, so it cannot misfire.
693///
694/// Structural ([`crate::gates::RESTORE_REFERENT_MISSING_GATE`]): a referent
695/// that is never created never self-heals, and while it is missing the restore
696/// sits at `phase: Pending` with its `policy.waitTimeout` window deliberately
697/// NOT started — the phase-invisible park shape of #359.
698pub const RESTORE_REFERENT_AVAILABLE_CONDITION: &str = "ReferentAvailable";
699/// `reason`/Event reason for [`RESTORE_REFERENT_AVAILABLE_CONDITION`] =
700/// `False`. Distinct from [`REPOSITORY_NOT_READY_REASON`] on purpose: that one
701/// means "the repository object exists and its backend is unreachable", which
702/// would be a lie for a `SnapshotPolicy` that was never applied.
703pub const RESTORE_REFERENT_MISSING_REASON: &str = "RestoreReferentMissing";
704/// `reason` for [`RESTORE_REFERENT_AVAILABLE_CONDITION`] = `True`: the referent
705/// appeared on a later pass (the clear-side counterpart of
706/// [`RESTORE_REFERENT_MISSING_REASON`]; controller-internal remediation copy,
707/// like the `Snapshot` gate's `SourcePvcFound`). Written ONLY when the
708/// condition already exists — the healthy wire never grows the condition.
709pub const RESTORE_REFERENT_FOUND_REASON: &str = "RestoreReferentFound";
710
711/// `Snapshot` condition: this deletion is HELD by the mass-deletion breaker
712/// (`Repository`/`ClusterRepository` `spec.deletionProtection.threshold`)
713/// until acknowledged via [`ALLOW_MASS_DELETION_ANNOTATION`] on the
714/// repository.
715pub const DELETION_HELD_CONDITION: &str = "DeletionHeld";
716/// `reason` for [`DELETION_HELD_CONDITION`] = `True`.
717pub const MASS_DELETION_BREAKER_REASON: &str = "MassDeletionBreaker";
718
719/// `Repository`/`ClusterRepository` condition reporting the state of
720/// `spec.seed` — initializing a brand-new repository from an existing replica
721/// (issue #380).
722///
723/// `True` means the repository holds its seeded content (reason
724/// [`SEEDED_REASON`]) or never needed seeding because it was already
725/// initialized (reason [`ALREADY_INITIALIZED_REASON`]). `False` means the seed
726/// is in flight ([`SEEDING_REASON`]), parked on a source that is not usable yet
727/// ([`WAITING_FOR_SEED_SOURCE_REASON`]) or on a workload-identity conflict
728/// between the two backends ([`SEED_SOURCE_AUTH_CONFLICT_REASON`]), or failed
729/// (the mover's failure class).
730///
731/// Wire-visible: GitOps health checks, `kubectl kopiur status`/`doctor` and
732/// user automation read it, so it lives in `kopiur-api` beside the other
733/// contract strings rather than controller-side.
734pub const SEEDED_CONDITION: &str = "Seeded";
735/// `reason` for [`SEEDED_CONDITION`] = `True` when this repository's content
736/// was actually copied in from `spec.seed.from`.
737pub const SEEDED_REASON: &str = "Seeded";
738/// `reason` for [`SEEDED_CONDITION`] = `True` when `spec.seed` was a standing
739/// no-op: the repository was already initialized, so nothing was copied. This
740/// is the steady state of a seed block left in a GitOps manifest forever.
741pub const ALREADY_INITIALIZED_REASON: &str = "AlreadyInitialized";
742/// `reason` for [`SEEDED_CONDITION`] = `False` while the seeding bootstrap Job
743/// is in flight. Bounded by `spec.seed.failurePolicy` (default 24h), but a seed
744/// legitimately runs for hours, so anything reporting on a repository must be
745/// able to say "seeding" rather than "stuck".
746pub const SEEDING_REASON: &str = "Seeding";
747/// `reason` for [`SEEDED_CONDITION`] = `False` when a migrate-mode seed is
748/// parked because its source `Repository`/`ClusterRepository` is not `Ready`
749/// (or does not exist). The repository stays `Pending` and re-checks until the
750/// source comes up — an out-of-band change nothing in kopiur can make.
751pub const WAITING_FOR_SEED_SOURCE_REASON: &str = "WaitingForSeedSource";
752/// `reason` for [`SEEDED_CONDITION`] = `False` when a migrate-mode seed's
753/// LOCAL backend and its resolved SOURCE repository disagree on workload
754/// identity: one seeding pod runs as exactly one ServiceAccount, so one of the
755/// two backends would authenticate as the wrong identity (or not at all).
756///
757/// The blob arm of this rule is an admission rejection
758/// (`validate_replication_auth`, reached through `validate_seed_blob_source`),
759/// but admission cannot follow a `seed.from.repository` reference to the source
760/// CR's backend — so the migrate arm is a controller-side park instead, and it
761/// needs its own reason for `kubectl kopiur doctor` to explain rather than
762/// misreport it.
763pub const SEED_SOURCE_AUTH_CONFLICT_REASON: &str = "SeedSourceAuthConflict";
764
765// The FAILURE reasons for [`SEEDED_CONDITION`] = `False`. Each is byte-identical
766// to the sentinel `kopia_error_class` the seeding mover writes
767// (`kopiur_mover::bootstrap::SEED_*_CLASS`) — one vocabulary across the mover's
768// result, the condition, the Warning Event and the CLI. They live HERE, not in
769// the mover, because `gates.rs` registers them and `kopiur-api` cannot depend on
770// `kopiur-mover`; the equality is pinned by the controller's
771// `io::tests::every_seed_class_maps_to_a_typed_failure_and_routes_by_retryability`,
772// which is the one crate that sees both sides.
773//
774// Registering ALL of them matters: a `Seeded=False` reason with no registry row
775// trips `StructuralGate::trips` but matches no row, and `kubectl kopiur doctor`
776// then reports it as "the operator is newer than the plugin" — a false diagnosis
777// in exactly the disaster-recovery flow these reasons exist for.
778
779/// `reason` for [`SEEDED_CONDITION`] = `False` when the seed SOURCE answered but
780/// holds no kopia repository (a mis-pointed bucket/prefix, or a mirror that was
781/// never written). Retried automatically.
782pub const SEED_SOURCE_NOT_FOUND_REASON: &str = "SeedSourceNotFound";
783/// `reason` for [`SEEDED_CONDITION`] = `False` when the seed source IS a kopia
784/// repository but holds zero snapshots and `spec.seed.allowEmptySource` is
785/// `false`. Retried automatically, so a mirror that fills up later seeds itself.
786pub const SEED_SOURCE_EMPTY_REASON: &str = "SeedSourceEmpty";
787/// `reason` for [`SEEDED_CONDITION`] = `False` when a migrate-mode seed's
788/// post-verify found snapshots missing at the destination (`kopia snapshot
789/// migrate` exits 0 on a per-source failure, so the destination listing is the
790/// only honest success signal). The next attempt resumes the copy.
791pub const SEED_INCOMPLETE_REASON: &str = "SeedIncomplete";
792/// `reason` for [`SEEDED_CONDITION`] = `False` when a seed was armed and the
793/// repository ended the bootstrap holding ZERO snapshots — an attempt that
794/// initialized the backend and then died. The next attempt resumes the copy;
795/// nothing at the backend should be deleted.
796pub const SEED_LEFT_EMPTY_REASON: &str = "SeedLeftEmpty";
797/// `reason` for [`SEEDED_CONDITION`] = `False` when the running mover image
798/// predates `spec.seed`: it dropped the unknown field, fell into the create
799/// fallback, and initialized an EMPTY repository. Terminal — only an image
800/// upgrade changes it.
801pub const SEED_MOVER_TOO_OLD_REASON: &str = "MoverImageTooOldForSeed";
802
803/// Every `Seeded=False` reason that means "the last seed attempt FAILED", as
804/// opposed to the park/progress reasons ([`WAITING_FOR_SEED_SOURCE_REASON`],
805/// [`SEEDING_REASON`]).
806///
807/// Exists so a writer can ask "has a previous attempt already recorded a
808/// failure here?" without restating the list — the seeding-progress writer uses
809/// it to leave a recorded failure standing rather than overwriting it with
810/// `Seeding` on every ~2-minute retry, which would make every retry cycle a
811/// fresh status transition (and so a fresh Event and metric increment).
812pub const SEED_FAILURE_REASONS: &[&str] = &[
813    SEED_SOURCE_NOT_FOUND_REASON,
814    SEED_SOURCE_EMPTY_REASON,
815    SEED_INCOMPLETE_REASON,
816    SEED_LEFT_EMPTY_REASON,
817    SEED_MOVER_TOO_OLD_REASON,
818];
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    #[test]
825    fn api_version_is_group_slash_version() {
826        // The duplicated literal must never drift from the canonical pair.
827        assert_eq!(API_VERSION, format!("{}/{}", crate::GROUP, crate::VERSION));
828    }
829
830    #[test]
831    fn well_known_strings_are_group_prefixed() {
832        // Finalizers/labels/annotations on the kopiur API surface live under the
833        // API group domain; a typo'd prefix would silently break selectors.
834        for s in [
835            SNAPSHOT_CLEANUP_FINALIZER,
836            SKIP_SNAPSHOT_CLEANUP_ANNOTATION,
837            ORIGIN_LABEL,
838            SNAPSHOT_ID_LABEL,
839            REPOSITORY_UID_LABEL,
840            CONFIG_LABEL,
841            REPLICATION_LABEL,
842            SCHEDULE_LABEL,
843            SNAPSHOT_REPLICATION_LABEL,
844            GROUP_LABEL,
845            OP_LABEL,
846            SESSION_LABEL,
847            SESSION_REPO_LABEL,
848            SESSION_CREDS_LABEL,
849            SESSION_SNAPSHOT_ANNOTATION,
850            SESSION_CREDS_REFUSED_ANNOTATION,
851            RUN_REQUESTED_ANNOTATION,
852            RUN_MODE_ANNOTATION,
853            ALLOW_IDENTITY_CHANGE_ANNOTATION,
854            PRUNED_BY_ANNOTATION,
855            ALLOW_MASS_DELETION_ANNOTATION,
856            ALLOW_REINITIALIZE_ANNOTATION,
857            PRIVILEGED_MOVERS_ANNOTATION,
858            CATALOG_SCAN_REQUESTED_ANNOTATION,
859            REPO_POOL_LABEL,
860        ] {
861            assert!(s.starts_with(crate::GROUP), "{s} must be group-prefixed");
862        }
863    }
864
865    #[test]
866    fn effective_max_concurrent_jobs_truth_table() {
867        use crate::common::ConcurrencySpec;
868
869        let cap = |n: Option<u32>| {
870            effective_max_concurrent_jobs(Some(&ConcurrencySpec {
871                max_concurrent_jobs: n,
872                max_concurrent_delete_jobs: None,
873            }))
874            .map(|v| v.get())
875        };
876
877        // All three spellings of "unlimited" collapse to the same `None`: no
878        // `concurrency` block, a block with no field, and an explicit `0`.
879        assert_eq!(effective_max_concurrent_jobs(None), None);
880        assert_eq!(cap(None), None);
881        assert_eq!(cap(Some(0)), None);
882
883        // A positive value is the cap, verbatim — 1 (fully serialized) included.
884        assert_eq!(cap(Some(1)), Some(1));
885        assert_eq!(cap(Some(4)), Some(4));
886        assert_eq!(cap(Some(1000)), Some(1000));
887        assert_eq!(cap(Some(u32::MAX)), Some(u32::MAX as usize));
888    }
889
890    #[test]
891    fn the_slot_condition_is_deliberately_not_a_structural_gate() {
892        // A parked-for-a-slot run self-heals as the pool drains, so registering it
893        // would make `doctor` cry wedge over a merely busy repository — the inverse
894        // of the false-green defect the registry exists for. Pinned so a later
895        // "every condition should be a gate" tidy-up has to read the reasoning.
896        assert!(
897            !crate::gates::STRUCTURAL_GATES
898                .iter()
899                .any(|g| g.condition == REPOSITORY_SLOT_AVAILABLE_CONDITION),
900            "RepositorySlotAvailable must not be a structural gate: it self-heals"
901        );
902    }
903}