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 operation a mover `Job` performs, for Jobs whose owning CR
37/// doesn't record the Job name in status (e.g. `Restore`). Values:
38/// [`OP_RESTORE`], [`OP_RESTORE_TARGET`].
39pub const OP_LABEL: &str = "kopiur.home-operations.com/op";
40/// [`OP_LABEL`] value for a `Restore`'s mover Job.
41pub const OP_RESTORE: &str = "restore";
42/// [`OP_LABEL`] value for a `Restore`'s operator-created target PVC.
43pub const OP_RESTORE_TARGET: &str = "restore-target";
44
45/// Label marking a mover `Job` as an interactive data-plane *session* pod
46/// (spawned by `kubectl kopiur browse`/`ls`/`cat`/`download`, not by the
47/// operator). Value: [`SESSION_BROWSE`]. Wire-visible: the CLI finds (and
48/// reuses) a warm session by this selector, and `session end` deletes by it.
49pub const SESSION_LABEL: &str = "kopiur.home-operations.com/session";
50/// [`SESSION_LABEL`] value for a read-only browse session.
51pub const SESSION_BROWSE: &str = "browse";
52/// Label keying a session `Job` to the repository it holds open, as
53/// `<kind>-<name>` (e.g. `Repository-nas`). One warm session per repository:
54/// the CLI selects on this so two snapshots in the same repository share a pod.
55pub const SESSION_REPO_LABEL: &str = "kopiur.home-operations.com/session-repo";
56
57/// Annotation requesting an out-of-band `Maintenance` run NOW (Flux-style
58/// reconcile trigger). Value: an RFC3339 timestamp; a NEW timestamp requests a
59/// new run (re-applying the same value is a no-op once handled). Usable from
60/// bare `kubectl annotate` or `kubectl kopiur maintenance run`.
61pub const RUN_REQUESTED_ANNOTATION: &str = "kopiur.home-operations.com/run-requested";
62/// Companion annotation selecting the run kind: `quick` (default) or `full`
63/// (see `kopiur_api::maintenance::ManualRunMode`).
64pub const RUN_MODE_ANNOTATION: &str = "kopiur.home-operations.com/run-mode";
65
66/// Acknowledges an intentional identity-affecting change on UPDATE. Two surfaces
67/// share it:
68/// - a `SnapshotPolicy`'s own resolved kopia identity (`username@hostname`, or a
69/// source's path) — see `ValidationError::IdentityWouldFork`;
70/// - a `Repository`/`ClusterRepository`'s `identityDefaults` (`cluster`,
71/// `hostnameExpr`, `usernameExpr`), which every consumer policy relying on
72/// those defaults re-resolves against on its next reconcile/backup — see
73/// `ValidationError::RepositoryIdentityWouldFork`.
74///
75/// Without it, the webhook **rejects** the edit when it would re-identify
76/// (a) policy/(consumer policies) with existing snapshot history, because new
77/// snapshots land under a new kopia source: restore/verify/`fromPolicy` resolve
78/// the new identity (old history is reachable only via
79/// `Restore.spec.source.identity`), while old- and new-lineage `Snapshot` CRs
80/// keep competing in the policy's one GFS retention timeline. Any
81/// **non-empty** value acknowledges the re-identification for that admission
82/// (presence-only, mirroring [`SKIP_SNAPSHOT_CLEANUP_ANNOTATION`]; a specific
83/// value isn't required because an edit can change more than one identity
84/// component at once, and the operator-resolved string is not something the
85/// author can pre-compute). Lives here because the operator and any GitOps/user
86/// automation must agree on it byte-for-byte.
87pub const ALLOW_IDENTITY_CHANGE_ANNOTATION: &str =
88 "kopiur.home-operations.com/allow-identity-change";
89
90/// Marks a `Snapshot` the OPERATOR is deleting as part of its own lifecycle,
91/// stamped immediately before the delete call. Values: `PrunedBy` annotation
92/// values (`retention`, `failed-history`). The Snapshot finalizer uses it to
93/// distinguish Kopiur's own prunes from external deletions (GC cascades,
94/// kubectl, third-party controllers); external destructive deletions are
95/// subject to the schedule-cascade guard and the mass-deletion breaker,
96/// operator prunes are not. Any unrecognized value is treated as EXTERNAL
97/// (fail-safe). Wire-visible: users and tooling may read it on terminating CRs.
98pub const PRUNED_BY_ANNOTATION: &str = "kopiur.home-operations.com/pruned-by";
99
100/// Acknowledges a mass-deletion wave on a `Repository`/`ClusterRepository`.
101/// Value: an RFC3339 timestamp. A HELD external deletion is released iff its
102/// Snapshot's `metadata.deletionTimestamp` <= this value — "I approve what is
103/// pending NOW". Deliberately VALUED (unlike the presence-only
104/// allow-identity-change ack, consumed at a single admission instant): this
105/// annotation is read continuously by the controller, so a presence-only ack
106/// left behind (or committed to Git) would disarm the breaker forever. With
107/// the timestamp, a stale ack is inert against any LATER wave, nothing ever
108/// needs to remove it, and the operator never edits user metadata. The
109/// controller clamps the effective value to <= its own now (clock-skew guard);
110/// an unparseable value is ignored (Warning event on the repository).
111pub const ALLOW_MASS_DELETION_ANNOTATION: &str = "kopiur.home-operations.com/allow-mass-deletion";
112
113/// The API version string for kopiur CRDs (used in mover `TargetRef`s and
114/// `kubectl -o name`-style output).
115pub const API_VERSION: &str = "kopiur.home-operations.com/v1alpha1";
116
117/// Pod label opting a mover pod into the **azure-workload-identity** mutating
118/// webhook: pods carrying `azure.workload.identity/use: "true"` and running as
119/// a federated `ServiceAccount` get `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/
120/// `AZURE_FEDERATED_TOKEN_FILE` (and the projected token volume) injected —
121/// exactly the env kopia's azure backend binds its credential flags to. Stamped
122/// by the operator (and the CLI's browse sessions) on every mover pod for a
123/// repository whose azure backend uses `auth.workloadIdentity`. Lives here
124/// because the operator and `kubectl kopiur` must agree on it byte-for-byte.
125pub const AZURE_WORKLOAD_IDENTITY_LABEL: &str = "azure.workload.identity/use";
126/// The [`AZURE_WORKLOAD_IDENTITY_LABEL`] value opting the pod in.
127pub const AZURE_WORKLOAD_IDENTITY_LABEL_VALUE: &str = "true";
128
129/// The standard `app.kubernetes.io/managed-by` label key. Stamped on **every**
130/// operator-created object (mover Jobs, work-spec ConfigMaps, cache PVC, minted
131/// mover SA/RoleBinding, projected credential Secret, CSI VolumeSnapshots) so
132/// Argo/Flux recognize them as controller-owned and neither prune nor report them
133/// `OutOfSync` (ADR-0005 §14(c)).
134pub const MANAGED_BY_LABEL: &str = "app.kubernetes.io/managed-by";
135/// The [`MANAGED_BY_LABEL`] value identifying kopiur-managed objects.
136pub const MANAGED_BY_VALUE: &str = "kopiur";
137
138/// kstatus-compliant standard condition types (ADR-0005 §2) so `kubectl wait
139/// --for=condition=Ready` and Flux/Argo health checks work natively against every
140/// reconciled kopiur CRD.
141/// The headline readiness condition.
142pub const READY_CONDITION: &str = "Ready";
143/// Set `True` while a reconcile is making progress toward Ready.
144pub const RECONCILING_CONDITION: &str = "Reconciling";
145/// Set `True` when the resource is stuck and won't progress without intervention
146/// (mapped from a terminal `ErrorClass::Terminal` failure).
147pub const STALLED_CONDITION: &str = "Stalled";
148
149/// `Repository`/`ClusterRepository` condition recording whether a `Maintenance`
150/// covers it (ADR §3.7). Wire-visible: GitOps health checks and the kubectl
151/// plugin's `status` read it.
152pub const MAINTENANCE_CONFIGURED_CONDITION: &str = "MaintenanceConfigured";
153
154/// `Repository`/`ClusterRepository` condition reporting content-index-blob
155/// health (ADR-0005 §13). `True` = healthy (count under threshold); `False`
156/// with reason `TooManyIndexBlobs` = the index is growing unbounded because
157/// maintenance isn't compacting. NON-BLOCKING: the repository stays `Ready` and
158/// GitOps health gates are not tripped — it's a degradation warning, not an
159/// outage. Wire-visible (the kubectl plugin's `status` reads it).
160pub const INDEX_BLOB_HEALTH_CONDITION: &str = "IndexBlobHealth";
161
162/// Default `spec.health.indexBlobWarnThreshold`: the index-blob count above which
163/// the reconciler warns that maintenance isn't keeping up. A freshly-compacted
164/// repo sits near zero; a wedged-maintenance repo climbs unbounded (a real one
165/// reached 1448). Conservative so it only fires when maintenance is clearly
166/// behind. Overridable per-repo; `0` disables the warning. Part of the documented
167/// API contract, so it lives here rather than in the controller.
168pub const DEFAULT_INDEX_BLOB_WARN_THRESHOLD: i64 = 1000;
169
170/// Default catalog re-scan cadence when `spec.catalog.refreshInterval` is unset:
171/// how often a `Ready` repository re-lists its kopia snapshots to materialize
172/// (and expire) `origin: discovered` `Snapshot` CRs. Part of the documented API
173/// contract (field-reference), so it lives here rather than in the controller.
174pub const DEFAULT_CATALOG_REFRESH_INTERVAL: std::time::Duration =
175 std::time::Duration::from_secs(3600);
176
177/// Floor for `spec.catalog.refreshInterval`, enforced at admission. Each re-scan
178/// of an object-store repository runs a short mover Job; anything faster than
179/// this is Job churn with no operational value.
180pub const MIN_CATALOG_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
181
182/// Default `spec.health.probe.interval` when unset: how often an opt-in backend
183/// health probe re-connects a `Ready` repository to confirm the kopia repository
184/// still exists at the backend. Off by default; only meaningful once
185/// `spec.health.probe.enabled: true`. Conservative — a vanished/unreachable
186/// repository is rare and the probe runs a short mover Job — so it leans long.
187/// Part of the documented API contract, so it lives here, not in the controller.
188pub const DEFAULT_HEALTH_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1800);
189
190/// Floor for `spec.health.probe.interval`, enforced at admission. Each probe runs
191/// a short mover Job (object-store / volume-backed) or an in-process connect;
192/// anything faster than this is Job churn with no operational value. Shares the
193/// 30s floor with the catalog re-scan for the same reason.
194pub const MIN_HEALTH_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
195
196/// Default `spec.health.probe.failureThreshold`: how many *consecutive* failing
197/// probes must accumulate before the loud `RepositoryVanished` / `BackendReachable=False`
198/// condition is raised and an event fired. Debounces a single transient blip
199/// (an S3 list-after-delete race, a NAS reboot, a credential-rotation moment)
200/// from alarming on-call or nudging a destructive manual recreate.
201pub const DEFAULT_HEALTH_PROBE_FAILURE_THRESHOLD: i64 = 3;
202
203/// Default `SnapshotSchedule.spec.failedJobsHistoryLimit` when unset: how many
204/// `Failed` `Snapshot` CRs from a schedule to retain (the rest are pruned). Bounds
205/// failure history so a schedule firing against a persistently-failing precondition
206/// or backend doesn't accumulate `Failed` CRs forever. GFS retention applies only to
207/// successful snapshots, so this is the *only* bound on failures (ADR-0003). Part of
208/// the documented API contract, so it lives here, not in the controller.
209pub const DEFAULT_FAILED_JOBS_HISTORY_LIMIT: u32 = 10;
210
211/// The effective failed-history limit: `failedJobsHistoryLimit` when set, else
212/// [`DEFAULT_FAILED_JOBS_HISTORY_LIMIT`]. `Some(0)` keeps no failed snapshots.
213pub fn effective_failed_jobs_history_limit(limit: Option<u32>) -> u32 {
214 limit.unwrap_or(DEFAULT_FAILED_JOBS_HISTORY_LIMIT)
215}
216
217/// Default `spec.deletionProtection.threshold` (0 disables). 10 pending
218/// external destructive deletions is far above legitimate manual cleanup but
219/// far below a tooling-driven cascade (the motivating incident was ~600).
220pub const DEFAULT_MASS_DELETION_THRESHOLD: u32 = 10;
221
222/// The effective mass-deletion breaker threshold: `deletionProtection.threshold`
223/// when set, else [`DEFAULT_MASS_DELETION_THRESHOLD`]. `Some(0)` disables the breaker.
224pub fn effective_mass_deletion_threshold(p: Option<&crate::common::DeletionProtectionSpec>) -> u32 {
225 p.and_then(|d| d.threshold)
226 .unwrap_or(DEFAULT_MASS_DELETION_THRESHOLD)
227}
228
229/// `Repository`/`ClusterRepository` condition: pending external destructive
230/// deletions for this repository are at/above the breaker threshold and held.
231pub const MASS_DELETION_HELD_CONDITION: &str = "MassDeletionHeld";
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn api_version_is_group_slash_version() {
239 // The duplicated literal must never drift from the canonical pair.
240 assert_eq!(API_VERSION, format!("{}/{}", crate::GROUP, crate::VERSION));
241 }
242
243 #[test]
244 fn well_known_strings_are_group_prefixed() {
245 // Finalizers/labels/annotations on the kopiur API surface live under the
246 // API group domain; a typo'd prefix would silently break selectors.
247 for s in [
248 SNAPSHOT_CLEANUP_FINALIZER,
249 SKIP_SNAPSHOT_CLEANUP_ANNOTATION,
250 ORIGIN_LABEL,
251 SNAPSHOT_ID_LABEL,
252 REPOSITORY_UID_LABEL,
253 CONFIG_LABEL,
254 SCHEDULE_LABEL,
255 OP_LABEL,
256 SESSION_LABEL,
257 SESSION_REPO_LABEL,
258 RUN_REQUESTED_ANNOTATION,
259 RUN_MODE_ANNOTATION,
260 ALLOW_IDENTITY_CHANGE_ANNOTATION,
261 PRUNED_BY_ANNOTATION,
262 ALLOW_MASS_DELETION_ANNOTATION,
263 ] {
264 assert!(s.starts_with(crate::GROUP), "{s} must be group-prefixed");
265 }
266 }
267}