CRD design rationale¶
Why the CRD fields are shaped the way they are. This is the why that used to live
in the Rust doc comments (and therefore in the generated CRD description text). The
descriptions themselves are now one-liners; the field-by-field user guidance lives in
the CRD reference, and the cross-cutting design reasons
live here for contributors.
The code is the source of truth for current behavior — this page records intent. ADR
section refs (e.g. ADR-0005 §5) point at the ADRs
for the full decision record.
Recurring patterns¶
Sub-objects over leaf fields¶
Every credential / policy / identity / schedule / health surface is a struct, not a
bare bool/string/enum, so a future field slots in without an API break
(ADR §4.11). Examples: encryption (room for rotation/previousPasswords),
credentialProjection (key remapping, copy-name template, immutability),
sourceColocation (custom hostname label key), health (future health knobs),
create.ecc, server.auth.generate (username/rotation), populator: {} (an empty
object whose mere presence selects the mode, leaving room for future populator knobs).
Externally-tagged enums (discriminated unions)¶
backend, source, target, allowedNamespaces, inheritSecurityContextFrom,
server.auth, hooks, and the cache/repo volume types are externally-tagged enums
(backend: { s3: {...} }), never #[serde(tag = "kind")]. Internally-tagged enums
break Kubernetes structural-schema generation — kube's rewriter hoists the oneOf
branch properties and panics on the differing tag property. External tagging keeps full
type-safety and generates a valid CRD: "exactly one of" is unrepresentable as two,
and reconcilers match exhaustively so a new variant cannot compile until every handler
accounts for it.
Not Eq, only PartialEq¶
Several types are PartialEq but not Eq because they transitively embed k8s-openapi
types that are PartialEq-only: LabelSelector (via pvcSelector, policySelector,
workloadSelector, AllowedNamespaces::Selector), JobSpec (via hooks,
RunJobHook), ResourceRequirements, SecurityContext, PodSecurityContext. Reuse
these types; don't re-invent them, and don't add Eq.
Materialized defaults vs skip_serializing_if¶
Fields like copyMethod (Snapshot), mode (ReadWrite), onNamespaceDelete
(Orphan), concurrencyPolicy (Forbid), runOnCreate (false), and
fromPolicy.offset (0) carry a real OpenAPI default: in the schema rather than
being Option + skip_serializing_if. A named default_* fn backs both
#[serde(default = …)] and #[schemars(default = …)] — that pairing is what makes
schemars 1 emit a real default: in the generated CRD. The value then materializes into
the stored object and kubectl explain, and GitOps engines stop diff-thrashing on a
controller-set value (a bare value, not Option, so skip_serializing_if can never
drop it).
CEL cost budgets (maxItems / maxLength)¶
SnapshotPolicy.spec.sources carries #[schemars(length(max = 100))] and
source.sourcePathOverride carries length(max = 4096) so the apiserver can bound
the cost of the per-item exactly-one-of x-kubernetes-validations rule on Source.
CEL rule cost is rule_cost × maxItems; without a bound the apiserver assumes a huge
array/string and rejects the CRD as over budget. 100 sources / 4096-byte paths are far
past any real use. The exactly-one-of rule is written as an integer sum of
has()-ternaries rather than filter().size() precisely to stay inside that budget.
The hooks jobSpec is x-kubernetes-preserve-unknown-fields¶
schemars inlines the entire structural schema of an embedded k8s-openapi type.
The hooks JobSpec (RunJobHook.jobSpec) drags in the full PodSpec: inlined, it
made a single SnapshotPolicy CRD ~1.2 MB — ~85% of the whole bundle — which bloats
Helm releases and breaks large-CRD apply paths, most sharply client-side
kubectl apply, whose last-applied-configuration annotation has a hard 256 KB
limit (a 1.2 MB CRD can't be applied at all).
So RunJobHook.jobSpec carries
#[schemars(schema_with = "crate::schema::preserve_unknown_object")], which renders
it as { type: object, x-kubernetes-preserve-unknown-fields: true } instead of the
inlined schema. That alone takes the bundle from ~1.9 MB to ~665 KB (gzipped
259 KB → ~92 KB) and snapshotpolicies from ~1.26 MB to ~76 KB.
The trade-off is contained: the Rust field stays a concrete typed JobSpec, so
kube still deserializes into it (scalar type errors are still rejected at admission),
and the apiserver structurally validates the actual hook Job when the controller
creates it. Only apply-time structural validation of the jobSpec is deferred —
a malformed hook spec fails at Job creation rather than at SnapshotPolicy apply.
This is scoped to the jobSpec on purpose. The other embedded core/v1 objects —
mover/server securityContext, podSecurityContext, resources, affinity — are
left inlined so the apiserver keeps validating them at apply time: each adds only
tens of KB (the bundle is fine at ~92 KB gzipped), and for a backup operator the
early, structural validation of security/resource settings is worth more than the
saving. A regression test in crates/xtask/tests/crds.rs
(hooks_job_spec_renders_as_preserve_unknown_not_inlined) guards both the
preserve-unknown rendering of jobSpec and a size ceiling on snapshotpolicies.
Immutability transition rules¶
Repository/ClusterRepository create.{splitter,hash,encryption,ecc} carry CRD
x-kubernetes-validations transition rules (apiserver + CI, §7/§15) complementing the
webhook. Each leaf is has()-guarded on both self and oldSelf: a CEL field
access on an absent optional key raises "no such key", which fails the whole rule →
422 on every update, which would block the controller's own finalizer/status writes.
The common create: {enabled: true} case (no algorithm fields) must reconcile, not
wedge. The encryption password-Secret reference is deliberately not locked: kopia
fixes only the resolved password value into the repo format, never the Secret
name/key, so a rename with identical content must pass (locking it broke GitOps). See
validate::diff_immutable_repo_fields.
Mass-deletion protection (cascade guard + breaker + batching)¶
Three composable mechanisms guard against a bulk-deletion incident (ADR-0006) —
spanning Snapshot, SnapshotSchedule, Repository/ClusterRepository — while
keeping Kopiur's own retention pruning unaffected:
ScheduleDeletePolicyis deliberately 2-variant (Retain/Delete), not a reuse of the 3-variantDeletionPolicy(Delete/Retain/Orphan). AnOrphanin cascade position would differ fromRetainonly in per-CR event/metric bookkeeping — a per-CR "orphaned" event/metric fired for every produced Snapshot in the cascade, vs. one quiet retain — a distinction with no operational value. ReusingDeletionPolicywould make that non-difference representable and force every match arm to decide what anOrphancascade even means; the narrower enum makes it unrepresentable instead.pruned-bydistinguishes operator lifecycle from external deletion, notDeletionPolicyor any other spec field, because the SAMEdeletionPolicy: DeleteSnapshot must be handled differently depending on WHO is deleting it: Kopiur's own GFS retention andfailedJobsHistoryLimitpruning must keep working — unthrottled — during an incident the breaker is actively holding, while an external actor deleting the same shaped CR is exactly what the breaker exists to gate. The annotation is stamped immediately before the operator's own delete call rather than inferred from context, so the finalizer never has to guess; any missing or unrecognized value defaults to EXTERNAL (fail-safe — a bug that fails to stamp it makes a Kopiur prune look external, never the reverse).- The mass-deletion ack is a VALUED timestamp, not a presence-only flag (unlike
allow-identity-change, consumed once at a single admission instant). The breaker's annotation is read on every reconcile of a live repository, so a presence-only ack — set once to release a wave — would silently and permanently disarm the breaker the moment anyone applied it, including a value left in Git. A timestamp instead answers "I approve everything pending as of THIS instant": a later wave has laterdeletionTimestamps the same ack value doesn't cover, so the breaker re-arms for it automatically without anyone removing the old annotation. This is also why a held wave never auto-releases on its own — not when time passes, not when the pending count later drops back below threshold by itself: nothing substitutes for an explicit human ack: it is the only thing that ever clears a hold. - Deletions execute as a per-repository BATCH, CREATEd (never SSA-applied), with NO
ttlSecondsAfterFinished. One mover Job connects once and deletes every member's kopia manifest, replacing an earlier one-Job-per-Snapshot design that let a single cascade turn into hundreds of concurrent connects against one backend (the motivating incident).CREATE, notapply, makes the deterministic member-set-derived Job name double as a single-flight lock: a sibling reconcile'screatefor the same member set 409s harmlessly against the Job already launched, so two racing reconciles can never enroll a member twice. No TTL means the dispatcher reaps a terminal batch Job EXPLICITLY, only once every member has actually drained (a SUCCEEDED Job is deleted only once no covered member still holds its cleanup finalizer) — an unconditional TTL could otherwise reap the Job (and itsdelete-membersaudit trail) before a member's own reconcile observed the success and released its finalizer. - The delete-Job concurrency cap (
KOPIUR_MAX_CONCURRENT_DELETE_JOBS) defaults to UNCAPPED (0), an opt-in backstop, not the primary defense. Batching itself — one Job per repository per accumulation window rather than one per Snapshot — is what keeps a bulk deletion from overwhelming a backend; an operator-wide concurrency cap layered on top would let one slow or failing repository's batch Jobs head-of-line-block every OTHER repository's deletions behind the same global limit — exactly the blast-radius coupling a per-repository mechanism should avoid. The cap exists for an operator who wants an extra global throttle on top, not as the mechanism doing the real work. - The pending COUNT is inclusive; the fire SET is exclusive — two intentionally
opposite polarities over the same pending
Snapshots. The breaker counts a maximally-inclusive set (an unpinned or possibly-cascade-guarded CR is over-counted, never dropped) because over-counting only trips the breaker earlier — the fail-safe direction for a count. The set a reconcile actually FIRES into a batch delete Job is the opposite: maximally exclusive, because an over-included member there is an irreversiblekopia snapshot delete, so the fail-safe direction is UNDER-fire + requeue. A breaker-exempt trigger (an operator prune, or an acked older wave) therefore narrows its fire set past the count — dropping breaker-HELD externals,onNamespaceDelete: Orphanmembers in a terminating namespace, schedule-owned members while the schedule store is still cold, and unpinned PEERS (whose manifest ids must not ride an unrelated repository's batch) — while every one of those still counts toward the breaker. An excluded member is never lost: it drains via its own reconcile's self-fire once it is genuinely eligible.
Per-CRD notes¶
Repository¶
encryption— password Secret ref is not locked by the immutability rules (see above); only the resolved value is fixed in the kopia format.moverDefaults— inherited by every mover (bootstrap/backup/restore/maintenance), overridable per-recipe viamover, merged field-wise (ADR-0004 §1/§2). Absorbed the formercacheDefaults(nowmoverDefaults.cache).onNamespaceDelete— breaking default change (ADR-0005 §5): defaultOrphanmeanskubectl delete nsno longer destroys snapshots. Materializeddefault: Orphan.deletionProtection.threshold— the mass-deletion circuit breaker (ADR-0006); default 10,0disables. See Mass-deletion protection above.mode— ADR-0005 §11; materializeddefault: ReadWrite.health.indexBlobWarnThreshold— absent ⇒DEFAULT_INDEX_BLOB_WARN_THRESHOLD(1000);0is the disable sentinel (not fall-back-to-default); negative rejected by the webhook. The default/disable semantics live in the pureresolve_index_blob_warn_thresholdfn (shared by webhook/controller/tests).status.resolvedCredentialVersion— the password Secret'sresourceVersion; the terminal-failure hard-stop reopens when it changes, so editing Secret content (which doesn't bumpmetadata.generation) re-triggers a connect instead of parking the repoFailedforever.status.storageStats.indexBlobCount— observed at bootstrap; an unbounded climb means maintenance isn't keeping up (raisesIndexBlobHealth).
ClusterRepository¶
- Cluster scope — every Secret/config ref (
backend/encryption/server) MUST carry an explicitnamespace(webhook-enforced; the type system can't express it). allowedNamespaces— externally-tagged (list/selector/all);allmust betrue(falserejected). NotEq(embedsLabelSelector).identityDefaults— CEL*Expr(ADR-0004 §5), evaluated at admission againstnamespace/policyName/labels/annotations; sandboxed, no I/O; a typo / out-of-scope variable is rejected on apply.maintenance.namespace—Maintenanceis namespaced, so this selects where the owned CR lands (defaults to the operator namespace).credentialProjection— repository-owner gate (ADR-0005 §8), breaking, default-off (allowed: false), fail-closed: consumer opt-in + this gate + operator RBAC all required. A sub-object, distinct from the consumer-sidecredentialProjection.enabled. A namespacedRepositoryhas no such gate (same-namespace projection is a no-op).
SnapshotPolicy¶
copyMethoddefaultSnapshot—Snapshot(point-in-time CSIVolumeSnapshotstaging) is the default because it is crash-consistent: kopia reads a frozen capture instead of a live, possibly-mid-write PVC, which matters most for databases and other stateful apps (ADR-0005 §1 originally proposed this default). It requires the CSI snapshot stack + aVolumeSnapshotClassfor the source's driver;Direct(read the live PVC, no CSI required) remains available and is the right choice for non-CSI/static sources — set it explicitly. If the CSI stack is missing under theSnapshotdefault, the operator fails loud with a pointer to install the stack or setcopyMethod: Direct(seecrates/controller/src/io/staging.rs). (Earlier releases defaulted toDirectfor backward-compat with the field's pre-wiring behavior; the reasoning for the flip is preserved in full in thedefault_copy_methodfn doc.)groupBy— must be set explicitly; a silent per-PVC fallback would produce inconsistent backups (a data-integrity hazard, ADR §4.9). Multi-PVC fan-out + VolumeGroupSnapshot is not yet wired (single-PVC staging only today).hooks.runJob— theRunJobHookisBoxed because it embeds aJobSpec(~2 KB);Box<T>is transparent to serde.verification—successExpris a CEL bool predicate overstats{files,bytes,errors}/snapshot/restored{files,checksumMatches}, validated at admission (validate_success_expr).
Snapshot¶
deletionPolicy— origin-aware effective default:Deletefor scheduled/manual, forcedRetainfor discovered (a discoveredSnapshothas an empty spec). ADR §4.5.pin— exempts the snapshot from GFS retention; the reconciler reconciles kopia's pin state againstspec.pinand never spawns a redundant pin op.status.stats.filesFailed— kopia'srootEntry.summ.errors: source entries kopia excluded because it couldn't read them. Present and> 0only when anerrorHandling.ignore*Errorspolicy let the snapshot complete despite unreadable files — i.e. the backup is incomplete. Backs theSecurityContextCompatiblecondition (positive-only, never a heuristic guess).status.hooks— each hook list runs exactly once per Snapshot, across requeues and pod restarts (quiesce/resume has side effects).status.staged— the CSI staging objects are recorded once, reused across retries, reaped on the terminal transition, never double-created.onScheduleDelete(spec) /pruned-by(annotation) — the mass-deletion protection surface (ADR-0006): the stamped cascade policy the finalizer consults when the owning schedule is gone, and the discriminator that tells the finalizer an operator prune from an external deletion. See Mass-deletion protection above.
SnapshotSchedule¶
policyRefXORpolicySelector— exactly one (webhook + CRD validation);policySelectorfans out to many policies in the namespace (mirrorspvcSelector).failedJobsHistoryLimit— bounds failed childSnapshots only. There is nosuccessfulJobsHistoryLimit: retention is GFS-only (ADR-0003 §4.4).deletion.onScheduleDelete— the schedule-cascade guard (ADR-0006), defaultRetain. Propagated to existing producedSnapshots on edit (skipping any child alreadyTerminating). See Mass-deletion protection above.schedule.{runOnCreate,concurrencyPolicy}— materialized OpenAPI defaults (see the pattern above);Forbidskips a firing rather than letting runs pile up.status.lastSchedule.at— accepts thescheduledAtalias on the wire (serdealias).
Restore¶
targetrequired — ADR-0005 §9 removed the empty-targetform; aRestorewith notargetnow fails deserialization. Exactly one ofpvc/pvcRef/populator(operator-authored CELx-kubernetes-validations+ webhook).sourceXOR is webhook-enforced.populator: {}— an empty sub-object whose presence selects passive-populator mode (claimed via a PVC'sspec.dataSourceRef);inheritSecurityContextFromis rejected with it (no workload pod exists at provision time).fromPolicy.offset— materializeddefault: 0(see the defaults pattern).status.resolved—resolutionis a closed enum so the decision is one exhaustively-matched value, pinned once at first resolution and never re-resolved: a later-appearing snapshot can never silently retarget an already-provisioned volume (ADR §4.6). A legacy pin written beforeresolutionexisted leaves itNonewithkopiaSnapshotIDset, read asSnapshot(stale-id self-heal).- source/target semantics —
repositoryis derived fromsourceunlesssource.identity(which has no CR to derive it from, sospec.repositoryis required);onMissingSnapshotdefaultsFailfor explicit sources,ContinueforfromPolicy(deploy-or-restore).fsGrouplives on the pod-level context so a fresh restore volume is group-writable for an unprivileged mover.
Maintenance¶
ownership— at most oneMaintenancemay own a repository at a time (a lease).takeoverPolicy(Never/PromptCondition/Force) governs what a secondMaintenancedoes when it finds a foreign owner. The lease identity can be a stale ephemeral bootstrap-pod identity; a stuck owner is resolved withtakeoverPolicy: Forceonce.RepositoryMaintenanceSpec— default-managed: when absent orenabled: truethe reconciler creates and owns aMaintenanceCR. An externally-authoredMaintenanceis always honored (never duplicated), even withenabled: false.namespaceis ClusterRepository-only.status.full.lastContentReclaimedBytes— the only place storage reclamation is surfaced.status.<kind>.lastHandledAt— records the most recent cron slot whose Job finished, including a yield to a foreign lease holder (which deliberately does not movelastRunAt), so a handled slot never re-fires after its Job self-reaps.
RepositoryReplication¶
destination— externally-taggedBackend, must differ from the source backend (webhook-enforced). Itsauth.secretRefcarries the destination backend's own access credentials.kopia repository sync-tocopies blobs verbatim, so the mirror always shares the source's format and password — there is no destination password knob. Because one mover pod touches both backends, the destination Secret must co-reside in the CR's namespace and is delivered under aKOPIUR_DEST_env prefix, then remapped for thesync-tosubprocess so source/destination keys of the same name (AWS_*, …) never collide.mover— inherits the source repository'smoverDefaults.
Shared types¶
BackendAuth.workloadIdentity— empty--access-key=flags engage kopia's ambient credential chain; the user-created, cloud-federated ServiceAccount (IRSA / EKS Pod Identity / AKS Workload Identity / GKE WI) must pre-exist with the right cloud annotation — the operator preflights and binds the mover role to it, but never creates it. Azure additionally requiresstorageAccount. ARepositoryReplicationwhose source and destination are the same cloud kind must not mix static and workload-identity auth (the replication pod's env would leak the static keys into the ambient chain).MoverSpec/MoverDefaultsmerge —hardened ⊂ moverDefaults ⊂ recipe.mover, merged field-wise (ADR-0004 §2). A partial override can only tighten the hardened baseline (it never dropsdrop:[ALL]/ seccomp). This closes drift between the maintenance/backup/restore movers and the bootstrap-mover gap.inheritSecurityContextFrom— externally-tagged:workloadSelector(copy UID/GID from a label-selected workload; valid on backup or restore) orpvcConsumer(auto-derive from the pod mounting the source PVC; backup-source only — rejected onRestore/Maintenance, which have no backup source).server— the kopia web-UI server is read-write-delete and holds the repository decryption key; read-only is enforced at the connection level (spec.server.readOnly). On filesystem backends the repo PVC must beReadWriteMany;fsGroupis a no-op on NFS.ServerStatus.namespaceis load-bearing (a cluster-scoped owner has no implicit namespace).FailurePolicy.podStartupDeadlineSeconds— fails a mover that can't start (CreateContainerConfigError/ImagePullBackOff/Unschedulable); a wedged pod never reaches a terminal phase andbackoffLimitnever trips, so this is the only backstop.
Controller resilience under API-server outage¶
A production control plane was OOM-killed ~5 times in a row (July 2026); each flap
the controller exhausted its fd table (EMFILE) within ~11s of startup, its
/healthz listener stopped accepting, the kubelet restarted it, and the restart's
re-list re-ran the storm — the election Lease reached leaseTransitions=80. The
amplification chain: watcher re-lists re-drive every primary × referent fan-out
(one ClusterRepository event enqueues every SnapshotSchedule cluster-wide) ×
unbounded reconcile concurrency × ~3–5 API reads per reconcile × one spawned
Warning-Event POST per failure × 30s connect timeouts pinning an fd per blackholed
SYN × no NOFILE handling.
The fix package (see
watch-and-reconcile → API-outage resilience
for the per-defense detail): a bounded-by-default reconcile concurrency cap
(reconcileConcurrency: 8 per controller — unlike maxConcurrentDeleteJobs,
where batching is the primary protection and uncapped is safe, reconcile
concurrency had NO other bound), paired with a kopia subprocess timeout so a hung
backend can't starve a capped controller; transport-error Event suppression plus a
16-permit/10s bound on failure publishes; deterministic [30s, 60s) jitter on the
transient requeue; 5s connect / 305s read client timeouts (exec streams exempt);
a deadline on each leader renew attempt; a fail-closed streamingLists probe; and
an RLIMIT_NOFILE soft→hard raise.
Deliberately unchanged: exit-on-lost-lease (abdicating and restarting beats a
split-brain double-reconcile; bounded concurrency makes each restart's re-list
cheap); hooks staying inline on the reconcile slot (detaching them would stretch
the app's quiesce window across requeues — the slot-hold is bounded by the hook
timeout, the kopia timeout, and cap sizing); and the axum accept loop (axum 0.8
already sleeps 1s and retries on EMFILE, matching the incident logs' cadence).
See also¶
- CRD reference — the per-field user documentation.
- API conventions — the encoding rules these decisions follow.
- ADRs — the full decision records.