Scenario 05 — Adopt an existing kopia repository¶
You already have a kopia repo — created by hand, by a cron job running the kopia CLI, or by another tool — and you want Kopiur to take it over: see the old snapshots, restore from them, run maintenance, and back up going forward without re-uploading data or stranding the existing snapshots.
What adoption does, step by step¶
flowchart TB
REPO[Repository<br/>connect: create.enabled=false] --> DISC[Kopiur discovers foreign snapshots<br/>origin=discovered, Retain]
REPO --> MNT[Maintenance<br/>take over the lease]
DISC --> RST[Restore one discovered snapshot<br/>prove it works]
REPO --> BC[SnapshotPolicy<br/>identity matched to the foreign writer]
BC --> SCH[SnapshotSchedule]
Discovery is automatic, repeating — and Retain-forced
Once the Repository connects, Kopiur materializes snapshots it didn't create as
Snapshot CRs with origin=discovered, in the repository's namespace. Discovered
backups are forced to deletionPolicy: Retain — Kopiur never deletes data it
didn't create. The initial scan runs as soon as it connects; set
catalog.periodicRefresh: true (off by default) to keep re-scanning every
catalog.refreshInterval (default 1h) so snapshots the old tooling keeps writing
during the migration window show up too;
bound the rows with catalog.retain for very large histories (see
The catalog). List them:
The four deliberate moves in the bundle:
- Connect, don't create.
create.enabled: false— adopt the existing repo, never re-initialize it. TheKOPIA_PASSWORDis the existing one. - Take over maintenance. A standalone
Maintenancewith an explicitownershiplease, so Kopiur and the old tooling don't both runkopia maintenance. We disable theRepository's default-managed maintenance (spec.maintenance.enabled: false) so the takeover is deliberate, not automatic. - Prove it. Restore one discovered snapshot into a throwaway PVC.
- Back up going forward, matching identity. Pin the new
SnapshotPolicy'sidentityto the foreign writer'susername@hostname:pathso new snapshots dedup against — and extend — the existing timeline.
Taking the maintenance lease
ownership.takeoverPolicy is a closed enum: Never (default — refuses to touch
a lease another writer holds), PromptCondition (surfaces the conflict on
conditions and waits for you), or Force (seizes it immediately). The bundle uses
PromptCondition; switch to Force only after you've stopped the old
maintenance job, so two processes never compact the repo at once.
# Scenario 05 — Adopt an existing kopia repository
#
# You already have a kopia repository — created by hand, by k8up, or by a cron
# job running the kopia CLI — and you want Kopiur to take it over: see the old
# snapshots, restore from them, run maintenance, and back up going forward
# WITHOUT re-uploading data or stranding the old snapshots.
#
# The bundle:
# 1. Repository — CONNECTS to the existing repo (create.enabled: false).
# 2. (automatic) — Kopiur DISCOVERS the foreign snapshots and materializes them
# as Snapshot CRs with origin=discovered, forced
# deletionPolicy: Retain (it never deletes data it didn't
# create). List them:
# kubectl get snapshots -n adopt \
# -l kopiur.home-operations.com/origin=discovered
# 3. Maintenance — TAKE OVER the maintenance lease from the old tooling, so two
# writers don't both run `kopia maintenance`.
# 4. Restore — restore one discovered snapshot (proves adoption worked).
# 5. SnapshotPolicy+Schedule — back up going forward. Pin `identity` to MATCH the
# foreign writer's username@hostname:path so new snapshots
# dedup against, and extend, the existing timeline.
#
# Field shapes verified against crates/api. takeover_policy is a closed enum:
# Never (default) | PromptCondition | Force.
---
apiVersion: v1
kind: Secret
metadata:
name: legacy-repo-creds
namespace: adopt
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
# The EXISTING repo's password — exactly as the old tooling set it.
KOPIA_PASSWORD: "REPLACE_ME_with_the_EXISTING_repo_password"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: legacy-primary
namespace: adopt
spec:
backend:
s3:
bucket: my-existing-kopia-bucket
prefix: "" # match wherever the old tooling put the repo (often the root)
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
auth:
secretRef:
name: legacy-repo-creds
encryption:
passwordSecretRef:
name: legacy-repo-creds
key: KOPIA_PASSWORD
create:
enabled: false # adopt: the repo already exists, never re-initialize it
# Discovery knobs (all optional). An initial scan always runs on connect; here we
# opt in to PERIODIC re-scans so snapshots the OLD tooling keeps writing during the
# migration window keep appearing (off by default — each re-scan re-runs the
# bootstrap Job). retain bounds the discovered Snapshot CR ROWS for very large
# histories (expiring a row never deletes the kopia snapshot behind it).
catalog:
periodicRefresh: true # opt in to repeated re-scans (default false = scan once)
refreshInterval: 1h # cadence when periodicRefresh is on (default 1h, minimum 30s)
retain:
perIdentity: 100 # newest N rows per username@hostname:path
maxAgeDays: 90 # no rows for snapshots older than this
# Don't let the default-managed Maintenance auto-claim the lease here — we want
# the deliberate, explicit takeover below instead. (An externally-authored
# Maintenance like the one in this file is always honored and never duplicated.)
maintenance:
enabled: false
---
# Deliberately take over maintenance from the previous owner.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Maintenance
metadata:
name: legacy-primary-maintenance
namespace: adopt
spec:
repository:
kind: Repository
name: legacy-primary
schedule:
quick:
cron: "0 */6 * * *"
jitter: 30m
full:
cron: "0 3 * * 0"
jitter: 1h
timezone: UTC
ownership:
owner: "kopiur/legacy-primary"
# PromptCondition surfaces the conflict on status/conditions and waits for you
# to confirm; switch to Force to seize the lease immediately once you've
# stopped the old maintenance job. Never (the default) would refuse to take a
# lease another writer holds.
takeoverPolicy: PromptCondition
---
# Prove adoption: restore one discovered snapshot into a throwaway PVC. Replace
# the name with one from the `origin=discovered` list above.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
name: adopt-smoke-test
namespace: adopt
spec:
source:
snapshotRef:
name: kopia-disc-9c2a1f # a discovered Snapshot CR (see the kubectl get above)
target:
pvc:
name: adopt-smoke-test
storageClassName: fast-ssd
capacity: 10Gi
accessModes:
- ReadWriteOnce
policy:
onMissingSnapshot: Fail
---
# Back up going forward, CONTINUING the foreign writer's identity so kopia dedups
# against the existing data instead of re-uploading a fresh copy.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: adopt
spec:
repository:
name: legacy-primary
sources:
- pvc:
name: app-data
# Match EXACTLY what the old tooling recorded (inspect a discovered Snapshot's
# status, or `kopia snapshot list`, to read the existing username@hostname:path).
identity:
username: app-data
hostname: legacy-host
retention:
keepDaily: 14
keepWeekly: 6
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
name: app-data-nightly
namespace: adopt
spec:
policyRef:
name: app-data
schedule:
cron: "H 2 * * *"
jitter: 30m
runOnCreate: false
Matching the foreign identity¶
This is the field most likely to trip you up. New backups only dedup against the
old data if Kopiur writes under the same identity the previous tool used.
Inspect a discovered Snapshot's status (or kopia snapshot list against the repo)
to read the existing username@hostname:path, then set:
identity:
username: app-data # the existing snapshot's user
hostname: legacy-host # the existing snapshot's host
If you don't match it, backups still succeed — but they start a brand-new lineage and re-upload a full copy instead of an incremental one.
Adopting a perfectra1n/volsync kopia repo? Use migrate volsync, don't hand-write this
Kopiur's default identity (<policyName>@<namespace>:/pvc/<pvc>) does not match what the volsync fork records (<sanitized-name>@<sanitized-namespace>:/data) — even the source path differs. Hand-adopting a fork repo without pinning the exact identity silently forks the history. kubectl kopiur migrate volsync computes the fork's identity for you (a bug-for-bug port of its sanitizer) and pins it, so history continues seamlessly. Reach for the manual identity match here only for a non-volsync writer.
Verify adoption¶
$ kubectl get repository legacy-primary -n adopt
NAME PHASE AGE
legacy-primary Ready 25s
$ kubectl get maintenance legacy-primary-maintenance -n adopt
NAME REPOSITORY OWNED AGE
legacy-primary-maintenance legacy-primary true 25s
$ kubectl get restore adopt-smoke-test -n adopt
NAME PHASE AGE
adopt-smoke-test Completed 45s
A Ready repo, an OWNED maintenance lease, and a Completed smoke-test restore
mean the repository is fully adopted.
Delete a policy, then recreate it¶
Adoption isn't only for a repository you're onboarding for the first time — it's
also how Kopiur heals itself after you delete a SnapshotPolicy and bring it
back. This is the scenario end-to-end, continuing from the postgres-data
recipe above once it's been backing up for a while:
flowchart LR
DEL[Delete SnapshotSchedule + SnapshotPolicy] --> CLEAN[Snapshot CRs removed<br/>kopia data kept, Retain]
CLEAN --> REAPPLY[kubectl apply the SAME SnapshotPolicy]
REAPPLY --> SCAN[No history yet -> requests<br/>an on-demand catalog scan]
SCAN --> DISC[Repository re-scans -><br/>origin=discovered rows]
DISC --> ADOPT[Exact identity match -><br/>origin=adopted, config-labeled]
ADOPT --> RET[GFS retention resumes]
- Delete the schedule, then the policy (or let a GitOps prune remove both at
once — either order drains to the same outcome; see
Backups → Retain-wins-ties).
With the default
onPolicyDelete: Retain, thepolicy-cleanupfinalizer removes everySnapshotCR carrying the policy's config label — but every kopia snapshot stays in the repository. Nothing is deleted kopia-side.
$ kubectl delete snapshotschedule postgres-data-nightly -n billing
$ kubectl delete snapshotpolicy postgres-data -n billing
- Re-apply the same
SnapshotPolicy(same name, sameidentity/sources — shown below). A freshly-created policy has noSnapshotCRs carrying its config label yet — no history. Its first reconcile finds nothing to adopt (the old snapshots haven't been rediscovered yet) and, because it has no history and hasn't already asked for this exact identity, requests an on-demand catalog scan on the repository instead of waiting for a spec change or the (off-by-default) periodic-refresh timer. AnAdoptionScanRequestedNormal Event fires on theSnapshotPolicynaming the identity it's waiting on. - The repository honors the scan request and re-lists the backend, which
re-materializes the kept kopia snapshots as
origin: discoveredrows — exactly like the very first adoption above, just triggered on demand instead of by the initial bootstrap. - The policy adopts them on its next reconcile: it matches discovered rows
by exact structured identity —
usernameANDhostnameANDsourcePathmust ALL match its own resolved identity, never a partial/fuzzy match — creates anorigin: adoptedSnapshotCR (carrying the config label) for each one, and removes the matching discovered rows. ASnapshotsAdoptedNormal Event fires naming the count and identity. - Retention resumes. Adopted rows are GFS-governed exactly like produced
ones —
spec.retentionstarts pruning them the moment they age out of the window. What happens to history outside a narrower window depends on the policy's effectivedefaultDeletionPolicy: Delete(the default): everything matching is adopted, and the out-of-window rows are pruned on the very next reconcile — kopia data included. That's by design, not a bug (see Troubleshooting if this surprises you).Retain/Orphan: pruning would delete only the CR while the kopia snapshot survives to be re-discovered and re-adopted forever, so Kopiur adopts only the in-window candidates and deliberately leaves the rest asdiscoveredrows.status.adoption.skippedByRetentioncounts them and anAdoptionSkippedByRetentionNormal Event on the policy names the levers.
# Example 36 — Recreating a deleted SnapshotPolicy: catalog.adoption + the
# automatic delete -> recreate -> adopt -> retention-resumes flow
#
# Pairs with example 35 (SnapshotPolicy.spec.deletion.onPolicyDelete: Retain,
# the default): after that cascade removes a policy's Snapshot CRs but keeps
# every kopia snapshot in the repository, re-applying THIS SAME manifest is
# how the history comes back and retention resumes pruning it. Step by step:
#
# 1. `kubectl delete snapshotschedule postgres-data-nightly` then
# `kubectl delete snapshotpolicy postgres-data` (or a GitOps prune removing
# both at once — see docs/backups.md "Retain-wins-ties"). The policy's
# Snapshot CRs are cleaned up; every kopia snapshot survives, un-cataloged.
# 2. `kubectl apply -f` this file again (a genuinely fresh SnapshotPolicy has
# no Snapshot CRs carrying its config label — no history yet). Its first
# reconcile finds zero adoption candidates for its resolved identity
# (nothing has been rediscovered yet) and, because it has no history and
# hasn't already asked for this identity, requests an on-demand catalog
# scan on the repository (an AdoptionScanRequested Normal Event fires on
# the SnapshotPolicy) instead of waiting for a spec change or the
# (off-by-default) periodicRefresh timer.
# 3. The repository reconciler honors that request and re-scans, which
# re-materializes the kept kopia snapshots as `origin: discovered` rows
# (status.catalog.scanRequestHonored advances to match the request).
# 4. The SnapshotPolicy's next reconcile sees those discovered rows, matches
# them by EXACT identity (username + hostname + sourcePath — never a
# fuzzy/partial match), and adopts them: a SnapshotsAdopted Normal Event
# fires, each becomes an `origin: adopted` Snapshot CR carrying this
# policy's config label, and the matching `origin: discovered` rows are
# removed (recreate-then-delete, never a re-label, so a crash mid-wave
# just re-runs next pass).
# 5. Adopted rows are now GFS-governed exactly like produced ones — the SAME
# spec.retention below prunes them once they age out of the window. This
# is by design, not a bug: see docs/backups.md "Retention" and
# "troubleshooting.md" if backups you didn't expect to lose disappear
# right after a recreate. (That destructive prune applies under the
# default `defaultDeletionPolicy: Delete`. Under Retain/Orphan, out-of-
# window history is never adopted at all — it stays `discovered`, counted
# in status.adoption.skippedByRetention — because a CR-only prune would
# just re-discover and re-adopt forever.)
#
# On a repository SHARED by many namespaces/policies, this scan-request is
# bounded: many policies recreated at once each stamp the SAME repository
# annotation, but the repository only ever needs to run ONE scan to satisfy
# all of them (a fresh scan re-materializes everything at once) — it is not
# one scan per policy.
#
# Field shapes verified against crates/api (externally-tagged backend:
# `backend.s3`, not `backend: { kind: S3 }`).
#
# See docs/repositories.md ("The catalog — discovered snapshots"),
# docs/backups.md ("What happens when the policy is deleted"), and
# docs/scenarios/adopt-existing-repo.md ("Delete a policy, then recreate it").
---
apiVersion: v1
kind: Secret
metadata:
name: nas-primary-creds
namespace: billing
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: nas-primary
namespace: billing
spec:
backend:
s3:
bucket: my-backups
prefix: prod/
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
auth:
secretRef:
name: nas-primary-creds
encryption:
passwordSecretRef:
name: nas-primary-creds
key: KOPIA_PASSWORD
create:
enabled: true
catalog:
# Whether a discovered snapshot whose identity matches a live
# SnapshotPolicy is automatically re-attached to it. Adopt is the
# effective default even with this whole `catalog` block omitted; shown
# explicitly here since it's the field this example is about. A
# per-policy `spec.adoption` (see the SnapshotPolicy below) overrides
# this per-repository setting.
adoption: Adopt # Adopt (default) | Ignore
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: postgres-data
namespace: billing
spec:
repository:
kind: Repository
name: nas-primary
sources:
- pvc:
name: postgres-data
# GFS retention — applies identically to produced AND adopted Snapshots.
# Recreating this policy and letting adoption run is only useful if this
# window is wide enough to actually keep the history you're recovering.
retention:
keepDaily: 14
keepWeekly: 4
# Per-policy override of automatic adoption for discovered snapshots whose
# resolved identity matches THIS recipe. Absent (the normal case) inherits
# the repository's `catalog.adoption` above. Set explicitly here only if
# this one recipe should behave differently from its repository's default
# — e.g. a policy you deliberately want to start a fresh lineage under
# rather than re-absorb a foreign/prior history:
# adoption: Ignore
Opting out, and the one case adoption never touches
Automatic adoption is on by default at both levels — turn it off with
SnapshotPolicy.spec.adoption: Ignore (this recipe only) or
Repository/ClusterRepository spec.catalog.adoption: Ignore (every policy
against this repository); the per-policy field wins when both are set. Either
way, origin: discovered rows keep accumulating and are never auto-attached —
you restore from them directly instead (as in the section above).
On a repository shared across clusters (identityDefaults.cluster set),
adoption never crosses cluster boundaries: a discovered row whose hostname
classifies as another cluster's is refused even on an otherwise-exact identity
match — the same foreign-snapshot rule
that keeps two clusters' maintenance leases from fighting also keeps one
cluster from silently absorbing another's backup history.
See also¶
- Restores → discovered snapshots and example 07 — the two ways to restore foreign snapshots.
- Maintenance and example 08 — ownership leases and takeover policy in full.
- Backups → identity — matching the foreign writer's identity.
- Backups → What happens when the policy is deleted — the
onPolicyDeletecascade this scenario relies on. - Repositories → The catalog — the
catalog.adoptionknob and scan-request mechanics in full.