Skip to content

Adopt a database that already has a schema

Record the history a database already earned, and let the operator carry on from there.

What it shows. How baseline proves a database matches its migrations before recording anything, and what it refuses.

Where it starts. A database an application created before Ptah: a shipments table with three rows, the state migration 1 produces, and no record that it ran. The lab inserted them as setup. Beside it, an empty PostgreSQL the lab stood up as the shadow baseline replays into, with its URL in the Secret demo-shadow-database.

A replay of a verified run, not a live cluster. 18 checks held while it was recorded. Play types it out in the frame below; the transcript is the same session, already there.

sh · adopt-existing-database
# This database already has a shipments table and three rows. An application put them there.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT * FROM shipments ORDER BY id" id | reference----+-----------  1 | EU-1001  2 | US-2002  3 | EU-1003(3 rows) # It has no Ptah history. There is no revision table at all.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt"             List of relations Schema |   Name    | Type  |     Owner--------+-----------+-------+--------------- public | shipments | table | ptah_external(1 row) # The sequence the operator will run holds two migrations, in these files.$ ls demo/migrations0000000001_create_shipments.down.sql0000000001_create_shipments.up.sql0000000002_add_carrier.down.sql0000000002_add_carrier.up.sql # Hand the files to the cluster. Baseline reads them, and so will the resource.$ kubectl -n "$NAMESPACE" create configmap shipments-migrations --from-file=demo/migrationsconfigmap/shipments-migrations created # Baseline runs in the cluster. The target, a disposable shadow, and the version to record up to.$ cat demo/manifests/adopt-existing-database/baseline-v2.yaml# Ptah's own baseline, as a Job, for a database the cluster can reach and a# workstation cannot.## It replays the migration files into an empty shadow database and compares# the schema they produce with the target's, through the version below. Only# when the two agree does it write the history; otherwise it writes nothing.# The image is a name to replace: kubectl set image puts in the executor image# the chart runs, its executorImage value.apiVersion: batch/v1kind: Jobmetadata:  name: baseline-v2  labels:    app.kubernetes.io/name: demo-adoptspec:  backoffLimit: 0  activeDeadlineSeconds: 300  template:    metadata:      labels:        app.kubernetes.io/name: demo-adopt    spec:      restartPolicy: Never      automountServiceAccountToken: false      securityContext:        runAsNonRoot: true        runAsUser: 65532        runAsGroup: 65532        fsGroup: 65532        seccompProfile:          type: RuntimeDefault      containers:        - name: baseline          image: ptah-executor          command: ["/usr/local/bin/ptah"]          args:            - migrations            - baseline            - --migrations-dir=/migrations            - --dir-format=ptah            - --version=2          env:            - {name: HOME, value: /work}            - {name: TMPDIR, value: /work}            # Ptah reads every flag from PTAH_<FLAG> as well. PTAH_DB_URL is            # --db-url, the database to adopt. PTAH_SHADOW_DB is --shadow-db,            # a database that holds nothing else: baseline empties it before            # it replays anything.            - name: PTAH_DB_URL              valueFrom: {secretKeyRef: {name: demo-database, key: url}}            - name: PTAH_SHADOW_DB              valueFrom: {secretKeyRef: {name: demo-shadow-database, key: url}}          resources:            requests: {cpu: 50m, memory: 64Mi}            limits: {cpu: 500m, memory: 256Mi}          securityContext:            allowPrivilegeEscalation: false            readOnlyRootFilesystem: true            capabilities: {drop: ["ALL"]}          # One mount per file: a ConfigMap volume is a directory of symlinks,          # and a reader that walks into it finds every file twice.          volumeMounts:            - {name: migrations, mountPath: /migrations/0000000001_create_shipments.up.sql, subPath: 0000000001_create_shipments.up.sql}            - {name: migrations, mountPath: /migrations/0000000001_create_shipments.down.sql, subPath: 0000000001_create_shipments.down.sql}            - {name: migrations, mountPath: /migrations/0000000002_add_carrier.up.sql, subPath: 0000000002_add_carrier.up.sql}            - {name: migrations, mountPath: /migrations/0000000002_add_carrier.down.sql, subPath: 0000000002_add_carrier.down.sql}            - {name: work, mountPath: /work}      volumes:        - name: migrations          configMap: {name: shipments-migrations}        - name: work          emptyDir: {sizeLimit: 64Mi} # Run it through version 2.$ kubectl set image --local -f demo/manifests/adopt-existing-database/baseline-v2.yaml \  baseline="$EXECUTOR_IMAGE" -o yaml | kubectl -n "$NAMESPACE" apply -f -job.batch/baseline-v2 created # Refused. Replayed into the shadow, the files make a carrier column this database lacks.$ kubectl -n "$NAMESPACE" wait --for=condition=Failed job/baseline-v2 --timeout=3mkubectl -n "$NAMESPACE" logs job/baseline-v2job.batch/baseline-v2 condition met2026/11/06 15:54:03 INFO Migrating up currentVersion=0 totalMigrations=22026/11/06 15:54:03 INFO Applying migration version=1 description="Create Shipments"2026/11/06 15:54:03 INFO Applied migration version=1 description="Create Shipments"2026/11/06 15:54:03 INFO Applying migration version=2 description="Add Carrier"2026/11/06 15:54:03 INFO Applied migration version=2 description="Add Carrier"2026/11/06 15:54:03 INFO All migrations applied successfullyerror: baseline shadow check failed: missing column shipments.carrier # A refused baseline records nothing. There is still no revision table.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt"             List of relations Schema |   Name    | Type  |     Owner--------+-----------+-------+--------------- public | shipments | table | ptah_external(1 row) # The database is at release 1, so baseline through version 1. The name and version change.$ diff -u demo/manifests/adopt-existing-database/baseline-v2.yaml demo/manifests/adopt-existing-database/baseline-v1.yamlkubectl set image --local -f demo/manifests/adopt-existing-database/baseline-v1.yaml \  baseline="$EXECUTOR_IMAGE" -o yaml | kubectl -n "$NAMESPACE" apply -f ---- demo/manifests/adopt-existing-database/baseline-v2.yaml	2026-11-06 15:43:50.129352363 +0000+++ demo/manifests/adopt-existing-database/baseline-v1.yaml	2026-11-06 15:43:50.129308016 +0000@@ -9,7 +9,7 @@ apiVersion: batch/v1 kind: Job metadata:-  name: baseline-v2+  name: baseline-v1   labels:     app.kubernetes.io/name: demo-adopt spec:@@ -38,7 +38,7 @@             - baseline             - --migrations-dir=/migrations             - --dir-format=ptah-            - --version=2+            - --version=1           env:             - {name: HOME, value: /work}             - {name: TMPDIR, value: /work}job.batch/baseline-v1 created # The replay matches this time, so it records version 1, and says so.$ kubectl -n "$NAMESPACE" wait --for=condition=Complete job/baseline-v1 --timeout=3mkubectl -n "$NAMESPACE" logs job/baseline-v1job.batch/baseline-v1 condition met2026/11/06 15:54:10 INFO Migrating up currentVersion=0 totalMigrations=12026/11/06 15:54:10 INFO Applying migration version=1 description="Create Shipments"2026/11/06 15:54:10 INFO Applied migration version=1 description="Create Shipments"2026/11/06 15:54:10 INFO All migrations applied successfullyBaselined 1 migration(s) through version 1 in "schema_migrations" # The history records version 1, and the rows are the rows it had.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT version, state FROM schema_migrations ORDER BY version"kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT * FROM shipments ORDER BY id" version |  state---------+---------       1 | applied(1 row)  id | reference----+-----------  1 | EU-1001  2 | US-2002  3 | EU-1003(3 rows) # Now the resource, which reads the same ConfigMap. It asks for a person.$ cat demo/manifests/adopt-existing-database/shipments.yamlkubectl -n "$NAMESPACE" apply -f demo/manifests/adopt-existing-database/shipments.yaml# The migration sequence the adopted database carries on from, read from the# same ConfigMap baseline read.## Created after baseline, never before: a resource that existed during# adoption could have an approved plan dispatch the whole sequence against a# database that already has the schema.apiVersion: operator.ptah.run/v1alpha1kind: PtahMigrationmetadata:  name: shipmentsspec:  target:    engine: PostgreSQL    coordinationKey: demo/storefront/primary    urlFrom:      name: demo-database      key: url  artifact:    configMap:      name: shipments-migrations      items:        - key: 0000000001_create_shipments.up.sql          path: 0000000001_create_shipments.up.sql        - key: 0000000001_create_shipments.down.sql          path: 0000000001_create_shipments.down.sql        - key: 0000000002_add_carrier.up.sql          path: 0000000002_add_carrier.up.sql        - key: 0000000002_add_carrier.down.sql          path: 0000000002_add_carrier.down.sql  policy:    apply: OnApproval    lockTimeout: 30s  interval: 1m  execution:    activeDeadlineSeconds: 300    failureRetryInterval: 10s    connectTimeout: 30sptahmigration.operator.ptah.run/shipments created # It reads the history, and plans what the history does not hold.$ kubectl ptah wait migration/shipments -n "$NAMESPACE" --for approvalptahmigration/shipments in namespace demo reached approval.  Phase:      AwaitingApproval  Generation: 1, observed by the operator  Database:   last read at <time>  Plan:       ptah-mplan-d6a4d397d300de3cdbf71642kubectl ptah approve migration/shipments -n demo reviews it. # Version 1 is history, so it is not offered. The plan is version 2 alone.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration:            demo/shipmentsPhase:                AwaitingApprovalReady:                False, AwaitingApproval: The plan is waiting for the approval its policy requiresPolicy:               apply OnApprovalSource:               ConfigMap shipments-migrationsSnapshot:             ptah-src-9d6168dd59ca5f4d99da0580Content:              sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read:         <time>Current version:      1Applied:              1Pending:              1 Plan ptah-mplan-d6a4d397d300de3cdbf71642, 1 migration from version 1:  2                    Add Carrier  ApprovalRequired: True, AwaitingApproval: 1 planned migrations need an approval naming this plan Approvals: none Run acknowledgments: none Conditions:  TYPE               STATUS  REASON            SINCE                 MESSAGE  Progressing        False   AwaitingApproval  <time>                Nothing runs until a person writes the approval  Blocked            False   HistoryMatched    <time>                The history continues this artifact  Ready              False   AwaitingApproval  <time>                The plan is waiting for the approval its policy requires  DatabaseReachable  True    Observed          <time>                The migration history was read  ApprovalRequired   True    AwaitingApproval  <time>                1 planned migrations need an approval naming this plan kubectl events -n demo --for ptahmigration/shipments prints its Events. # Approve it. The command shows the plan again, asks, and waits for the run.$ kubectl ptah approve migration/shipments -n "$NAMESPACE" --name shipments-adopted --waitPtahMigration: demo/shipments, UID <uid>Plan:          ptah-mplan-d6a4d397d300de3cdbf71642, UID <uid>Fingerprint:   sha256:d6a4d397d300de3cdbf716427a052732e0b8e975f9072d47767a2656afec34c9 From version 1, in this order:  2                Add Carrier                   checksum f4c0c8bd4d760d1f9c810fb82c3916496d363882584efb8cc789ca84c6656357 A migration plan records versions and checksums. Its SQL is read from thesource the plan binds, and it is not shown here:  Source:          ConfigMap  Revision:        <uid>  Content:         sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6 Approve plan ptah-mplan-d6a4d397d300de3cdbf71642 of migration/shipments in demo? Type yes to approve: yesApproved: PtahMigrationApproval demo/shipments-adopted, recorded by admission as kubernetes-admin at <time>.It binds plan ptah-mplan-d6a4d397d300de3cdbf71642, fingerprint sha256:d6a4d397d300de3cdbf716427a052732e0b8e975f9072d47767a2656afec34c9.Waiting up to 5m0s for ptahmigration/shipments in namespace demo to reach applied.ptahmigration/shipments in namespace demo reached applied.  Phase:      InSync  Generation: 1, observed by the operator  Database:   last read at <time>  Last run:   Applied, Job ptah-m-apply-shipments-0bbce50c6d44e028kubectl ptah migration shipments -n demo shows where it stands. # Version 2 ran on the rows the application wrote, and the history carries on from there.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT version, state FROM schema_migrations ORDER BY version"kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT id, reference, carrier FROM shipments ORDER BY id" version |  state---------+---------       1 | applied       2 | applied(2 rows)  id | reference | carrier----+-----------+---------  1 | EU-1001   | dhl  2 | US-2002   | ups  3 | EU-1003   | dhl(3 rows)

What was checked

Every step states what has to hold before its output may be published. A condition is read as its type, status and reason on the live object, never as a phrase in a message.

StepClaimHeld
1exits 0, prints "EU-1001", prints "US-2002", prints "EU-1003", prints "(3 rows)"yes
2exits 0, prints "shipments", never prints "schema_migrations"yes
3exits 0, prints "0000000001_create_shipments.up.sql", prints "0000000002_add_carrier.up.sql"yes
4exits 0, prints "configmap/shipments-migrations created"yes
5exits 0, prints "migrations", prints "baseline", prints "PTAH_DB_URL", prints "PTAH_SHADOW_DB", prints "--version=2"yes
6exits 0, prints "job.batch/baseline-v2 created"yes
7exits 0, prints "condition met", prints "baseline shadow check failed", prints "missing column", prints "carrier"yes
8exits 0, never prints "schema_migrations"yes
9exits 0, prints "+ name: baseline-v1", prints "+ - --version=1", prints "job.batch/baseline-v1 created"yes
10exits 0, prints "condition met", prints "Baselined 1 migration(s) through version 1"yes
11exits 0, prints "applied", prints "(1 row)", prints "EU-1001", prints "US-2002", prints "EU-1003", prints "(3 rows)"yes
12exits 0, prints "apply: OnApproval", prints "name: shipments-migrations", prints "shipments created"yes
13exits 0, prints "reached approval"yes
13ptahmigration/shipments: ApprovalRequired=True (AwaitingApproval)yes
14exits 0, prints "Current version: 1", prints "1 migration from version 1", prints "Add Carrier", never prints "Create Shipments"yes
15exits 0, prints "From version 1", prints "Add Carrier", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "reached applied"yes
15ptahmigration/shipments: status.lastRun.outcome=Appliedyes
16exits 0, prints "(2 rows)", prints "EU-1001 | dhl", prints "US-2002 | ups", prints "EU-1003 | dhl"yes

What it ran against

  • Kubernetes1.37.0
  • Operatore41a04412bb1
  • Ptahv0.18.0
  • Executorsha256:9f8797986124
  • Recorded2026-11-06
  • Scenarios ate41a04412bb1

What the commands read

A session is repeated, not admired, so every name in it is one your own environment can carry. There is nothing else: no helper of ours stands between a command and the cluster, and the recorder refuses a step that reads a name this list does not hold. The commands arekubectl, ptah and kubectl ptah, each of which you install once; the last one is the operator’s own client. They run from the root of a clone of this repository, which holds the files they name: the SQL under demo/schemas, demo/migrations,demo/migrations-checkpoint and demo/mysql, the reference data underdemo/reference, and the manifests under demo/manifests.

  • KUBECONFIGthe cluster the commands run against
  • NAMESPACEthe namespace the resources and their database credentials live in
  • OPERATOR_NAMESPACEwhere the chart installed the operator
  • CONTROLLERthe controller Deployment’s name, from that release
  • EXECUTOR_IMAGEthe executor image the chart runs, its executorImage value
  • REGISTRY_IN_CLUSTERthe registry address a Pod in the cluster resolves
  • PTAH_OCI_REGISTRYthe registry address the push goes to
  • PTAH_OCI_USERNAMEthe account that push uses, read by ptah itself
  • PTAH_OCI_PASSWORDits password, which no command prints

The last two are how a CI job hands ptah a registry credential. On a workstation,ptah oci login stores it once instead, reading the password from a prompt or from standard input rather than from the command line.

The registry has two addresses, the way a registry in an ordinary deployment does: a push goes to PTAH_OCI_REGISTRY, and the resource names REGISTRY_IN_CLUSTER with the same repository and digest. The registry Secret a resource names says which address its credential is for, and the operator sends it to no other.

The lab itself appears in one command, demo/bin/lab versions in Stand up the lab, which says what the lab is made of. What a run starts from is its preparation, which is not recorded; Where it starts above says what that is and which run ends there. Rundemo/acceptance/reproduce.sh to watch Approve a plan repeated from a directory where the lab’s scripts do not exist, by an account that may not create a Job, with the plan approved by a second account that may create neither a schema nor a Job, and then the same schema published with ptah schema push and moved to its next revision withkubectl ptah set-source.

The same changes, as edits

Each kubectl ptah command that changes a resource changes one field of it, so the same change is an ordinary edit, with kubectl edit ptahschema storefront -n "$NAMESPACE"or in a manifest you apply; the runs that keep their resources as files underdemo/manifests make their changes that way. What the commands add is checking: set-source takes only a reference pinned by digest, changes nothing while an Apply may be running, and changes nothing another writer changed since it read the resource.

CommandThe line it changes
kubectl ptah suspendspec.suspend: true
kubectl ptah resumespec.suspend: false
kubectl ptah set-source --ocispec.desired.ociRef of a schema, spec.artifact.ociRef of a migration
kubectl ptah refreshthe annotation operator.ptah.run/read-requested-at, set to the time of the request

An approval is not one of them. It is an object of its own, created once by the person who decided: kubectl ptah approve --dry-run client prints it as YAML for a repository, to be created with kubectl create, and never edited afterwards.

The scenario is demo/scenarios/adopt-existing-database.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.