ADR-0004 — Breaking CRD & Field Renames¶
- Status: Accepted / Implemented
- Date: 2026-06-09
- Deciders: kopiur maintainers
- Supersedes: ADR-0003 §3.1 (cache defaults), §4.2 (identity templating)
- Companion: ADR-0005 (feature improvements built on this renamed surface)
- Scope:
kopiur.home-operations.com/v1alpha1— breaking renames & the reshaping they require
Pre-release principle. kopiur is
v1alpha1with no compatibility guarantee. We take breaking changes in a single cut — no aliases, deprecation windows, or fallbacks. This ADR is the reshape: kind/field renames and the minimal restructuring they require. Net-new capabilities live in ADR-0005. Per the split rule, where a 0005 feature is a prerequisite for a rename here, the minimal form of it is included below and its further development stays in 0005.
Context¶
Two reshape needs surfaced, independent of any new capability:
- Mover config is per-recipe, repetitive, and replace-not-merge.
securityContext/podSecurityContext/resources/cacheare set on everySnapshotPolicy/Restore/Maintenance, are identical per repository, and drift (observed: a maintenance mover withseccompProfile, a sibling restore mover without). The connect/create (bootstrap) Job has no override path (Repositoryexposes onlymaintenance.mover), so a filesystem/NFS repo on a non-65532-owned directory can't bootstrap. Andmover.securityContextreplaces the hardened default (build_jobdoesunwrap_or_else(default_security_context)), so a partial override silently dropscapabilities.drop:[ALL]/seccompProfile— the doc comment claims "merged," but the code doesn't. - Names and field layout diverged from Kopia. Three kinds already use Kopia's vocabulary (
Repository/Restore/Maintenance); theBackup*kinds are generic. Reference fields (configRef/backupRef/fromConfig) name the old kinds.BackupConfignestscompression/ignore/splitterunder an innerpolicyblock (a futurepolicy.policy) and carries apolicy.splitterwith no Kopia equivalent (the object splitter is repository-global). Identity templating uses a bespoke Jinja2 engine (ADR-0003 §4.2) rather than CEL.
Decision¶
A. Mover configuration restructure¶
§1 — moverDefaults (rename moverDefaults.cache → moverDefaults + inheritable mover config)¶
Repository.spec.moverDefaults.cache is removed and replaced by a moverDefaults block on Repository/ClusterRepository:
spec:
moverDefaults:
securityContext: {...} # container
podSecurityContext: {...} # pod (fsGroup, …)
resources: {...}
cache: {...} # the former moverDefaults.cache
nodeSelector / tolerations / affinity: {...}
moverDefaults is the base for every mover the repository spawns — bootstrap, backup, restore, maintenance — overridable per-recipe via mover. This closes the bootstrap gap (the connect/create Job inherits it, so a filesystem repo on a non-65532-owned directory is bootstrappable with no special-case knob). It is the minimal feature the moverDefaults.cache rename requires to be meaningful; further moverDefaults fields (throttle, upload parallelism) are ADR-0005.
§2 — Layered, field-wise merge of mover security contexts¶
securityContext/podSecurityContext resolve by field-wise merge, lowest→highest: built-in hardened default ⊂ repo.moverDefaults ⊂ recipe.mover. Each explicitly-set field wins; unset fields inherit the layer below; the privileged-mover gate (ADR-0003 §4.11/§G16) runs on the merged result. This is required for §1's inheritance to compose — under replace, a recipe setting one SC field would wipe moverDefaults. The unwrap_or_else(default_security_context) path is deleted and the doc comment made accurate.
Amended 2026-07-24: a purely per-dimension field-wise merge inverted this ladder for the mover's identity: the kubelet resolves the effective UID/GID as
container ?? podacross dimensions, so a lower layer's container-levelrunAsUsersilently shadowed a higher layer's pod-level one. Layers now merge as(container, pod)pairs (merge_context_pair) with the winning layer's effective identity promoted onto the mover container, so the effective UID/GID always belongs to the highest layer that pins one. Non-identity fields keep the plain field-wise rule.
B. Naming¶
§3 — CRD kind names follow Kopia nomenclature¶
kopiur already names three kinds after Kopia (Repository, Restore, Maintenance); the Backup* kinds are generic. Align them so the surface speaks one language and maps onto the kopia CLI:
| Current | Kopia term | New kind |
|---|---|---|
Repository |
repository | Repository (unchanged) |
ClusterRepository |
repository (+ k8s cluster scope) | ClusterRepository (unchanged) |
BackupConfig |
policy | SnapshotPolicy |
Backup |
snapshot | Snapshot |
BackupSchedule |
(Kopia folds schedule into policy) | SnapshotSchedule |
Restore |
restore | Restore (unchanged) |
Maintenance |
maintenance | Maintenance (unchanged) |
kubectl get snapshots ↔ kopia snapshot list, kubectl get snapshotpolicies ↔ kopia policy list.
Snapshotvs CSIVolumeSnapshot— no API collision (snapshots.kopiur.home-operations.comvsvolumesnapshots.snapshot.storage.k8s.io), only colloquial overlap. We own the term;KopiaSnapshotwas rejected as uglier for no benefit.SnapshotPolicy, not barePolicy—Policyis overloaded in Kubernetes;SnapshotPolicydisambiguates.- Keep the schedule split — Kopia embeds scheduling in the policy; kopiur's recipe/invocation/schedule split (ADR-0003) is kept, so
SnapshotSchedulestays its own kind.
Trade-off: this optimizes for Kopia-fluent users over the Velero/k8up Backup convention — the right bet for a Kopia-native operator.
§4 — Field names follow the renamed kinds & Kopia's policy layout¶
A full api-crate pass confirms most fields already match Kopia and stay unchanged — retention.keep*, cache.{content,metadata}CacheSizeMb, the eight backends and their fields, maintenance.schedule.{quick,full}, tags/kopiaSnapshotID, restore writeFilesAtomically/ignorePermissionErrors, identity username/hostname/sourcePath/snapshotID, repo encryption/splitter/hash. Two groups change:
(a) Reference fields follow the renamed kinds (§3). configRef/ConfigRef → policyRef/PolicyRef; Restore.source.backupRef → snapshotRef; Restore.source.fromConfig/FromConfig → fromPolicy/FromPolicy. Status mirrors move too: ScheduleRef.backupRef, ResolvedRestore.backupRef → snapshotRef. (The configSelector → policySelector rename rides the policySelector feature in ADR-0005 — that field doesn't exist yet, so it's named correctly at birth there, not renamed here.)
(b) SnapshotPolicy mirrors Kopia's flat policy sub-structure. The inner policy block (which would read as policy.policy) is flattened to siblings of retention: policy.compression → compression, policy.ignore → files (with paths → ignoreRules, cacheDirs → ignoreCacheDirs — Kopia's files policy), policy.extraArgs → extraArgs. policy.splitter is removed — the object splitter is a repository property (fixed at creation, global) and already lives at Repository.create.splitter; a per-policy splitter has no Kopia equivalent and can't take effect.
§5 — Identity templating moves to CEL (the *Expr convention)¶
ClusterRepository.identityDefaults.{hostnameExpr, usernameExpr} (today Jinja2, ADR-0003 §4.2) become {hostnameExpr, usernameExpr} — CEL evaluated in-controller via cel-rust, suffixed *Expr (the kromgo valueExpr/colorExpr convention):
A SnapshotPolicy named atuin in namespace default then resolves to the kopia identity default-atuin@default:/pvc/atuin. Conditionals and label access come for free:
identityDefaults:
hostnameExpr: "'team' in labels ? labels['team'] : namespace"
usernameExpr: "namespace + '-' + policyName + (labels['env'] == 'prod' ? '-prod' : '')"
This drops the Jinja2 dependency / injection surface. It establishes the minimal CEL foundation: each *Expr returns a typed value (here string) and documents its CEL environment — namespace, policyName, the policy's labels/annotations — validated at admission so a typo or out-of-scope variable is rejected on kubectl apply, with evaluation bounded by CEL's cost budget; CEL is sandboxed (no I/O, no arbitrary code). Broader CEL uses — successExpr, *MatchExpr selectors, x-kubernetes-validations — are ADR-0005, per the split rule: the foundation the identity rename needs is here; its further development is in 0005.
Breaking changes (single cut)¶
All land together; no aliases or fallbacks.
moverDefaults.cacheremoved →moverDefaults.cache(§1).mover.securityContext/podSecurityContextnow merge over the hardened baseline instead of replacing it (§2) — can only tighten.- Kinds renamed:
BackupConfig/Backup/BackupSchedule→SnapshotPolicy/Snapshot/SnapshotSchedule(§3). - Reference fields renamed:
configRef → policyRef,source.backupRef → snapshotRef,source.fromConfig → fromPolicy(§4). SnapshotPolicyinnerpolicyflattened:compression/files(wasignore:paths→ignoreRules,cacheDirs→ignoreCacheDirs)/extraArgstop-level;policy.splitterremoved (§4).- Identity templating Jinja2 → CEL:
identityDefaults.{hostnameExpr→hostnameExpr, usernameExpr→usernameExpr}(§5).
Consequences¶
Positive. One place per repository defines mover identity/hardening/resources/cache (§1); hardening is safe-by-default and can only tighten (§2); the bootstrap-mover gap closes with no bespoke field; the surface speaks Kopia end-to-end with CLI-mirroring ergonomics (§3–§4); and §5 establishes the cel-rust/*Expr foundation ADR-0005 builds on.
Costs. Everything here is breaking — acceptable pre-v1, landed in one cut. §5 adds a cel-rust dependency.
Neutral. moverDefaults is structurally a generalization of moverDefaults.cache; recipes that set everything inline keep working (modulo the merge in §2).
Alternatives considered¶
- Keep per-recipe mover config / replace semantics + an admission warning. Rejected — drift, the un-fixable bootstrap mover, and silent de-hardening are the motivation; merge makes the safe outcome the default.
- Deprecated aliases for
moverDefaults.cache/the old kinds. Rejected on the pre-release principle — break cleanly now rather than carry two spellings intov1. - Bare
Policy; fold schedule into the policy (Kopia-internal). Rejected —Policyis overloaded; the recipe/invocation/schedule split is a deliberate improvement over Kopia. - Keep Jinja2 identity templating. Rejected — one sandboxed, k8s-native expression language (CEL) over a bespoke template engine.