Skip to content

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:

$ kubectl get snapshots -n adopt -l kopiur.home-operations.com/origin=discovered

The four deliberate moves in the bundle:

  1. Connect, don't create. create.enabled: false — adopt the existing repo, never re-initialize it. The KOPIA_PASSWORD is the existing one.
  2. Take over maintenance. A standalone Maintenance with an explicit ownership lease, so Kopiur and the old tooling don't both run kopia maintenance. We disable the Repository's default-managed maintenance (spec.maintenance.enabled: false) so the takeover is deliberate, not automatic.
  3. Prove it. Restore one discovered snapshot into a throwaway PVC.
  4. Back up going forward, matching identity. Pin the new SnapshotPolicy's identity to the foreign writer's username@hostname:path so 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]
  1. 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, the policy-cleanup finalizer removes every Snapshot CR 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
  1. Re-apply the same SnapshotPolicy (same name, same identity/sources — shown below). A freshly-created policy has no Snapshot CRs 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. An AdoptionScanRequested Normal Event fires on the SnapshotPolicy naming the identity it's waiting on.
  2. The repository honors the scan request and re-lists the backend, which re-materializes the kept kopia snapshots as origin: discovered rows — exactly like the very first adoption above, just triggered on demand instead of by the initial bootstrap.
  3. The policy adopts them on its next reconcile: it matches discovered rows by exact structured identityusername AND hostname AND sourcePath must ALL match its own resolved identity, never a partial/fuzzy match — creates an origin: adopted Snapshot CR (carrying the config label) for each one, and removes the matching discovered rows. A SnapshotsAdopted Normal Event fires naming the count and identity.
  4. Retention resumes. Adopted rows are GFS-governed exactly like produced ones — spec.retention starts pruning them the moment they age out of the window. What happens to history outside a narrower window depends on the policy's effective defaultDeletionPolicy:
  5. 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).
  6. 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 as discovered rows. status.adoption.skippedByRetention counts them and an AdoptionSkippedByRetention Normal 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