Skip to content

Ship a second release onto rows that already exist

A backfill that runs against real data, and a history that says it ran once.

What it shows. What a release adds when the database already has a history, and how to tell it did not run twice.

Where it starts. An empty database and a namespace with the lab's Secrets. Each release is a ConfigMap of migration files and a manifest that selects them, the delivery a team uses when its manifests already carry the files; Run a prepared migration sequence shows the same resource reading an artifact.

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

sh · second-release
# The first release carries one migration, and this resource applies it on its own.$ kubectl -n "$NAMESPACE" create configmap shipments-release-1 \  --from-file=demo/migrations/0000000001_create_shipments.up.sql \  --from-file=demo/migrations/0000000001_create_shipments.down.sqlkubectl -n "$NAMESPACE" apply -f demo/manifests/second-release/shipments-release-1.yamlconfigmap/shipments-release-1 createdptahmigration.operator.ptah.run/shipments created # Under apply Always it runs without waiting for anyone.$ kubectl ptah wait migration/shipments -n "$NAMESPACE" --for in-syncptahmigration/shipments in namespace demo reached in-sync.  Phase:      InSync  Generation: 1, observed by the operator  Database:   last read at <time>kubectl ptah migration shipments -n demo shows where it stands. # The table exists and the history records version 1.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration:            demo/shipmentsPhase:                InSyncReady:                True, HistoryMatched: The database has every migration this artifact carriesPolicy:               apply AlwaysSource:               ConfigMap shipments-release-1Snapshot:             ptah-src-b97aa1e5fdfbcaafb7b17613Content:              sha256:f64bb53f8cd38fe8108ea270fc1bb73bcc7c219fca72e81de774914d07955961History read:         <time>Current version:      1Applied:              1Pending:              0 Last run:             Applied, started <time>, finished <time>  Job:                ptah-m-apply-shipments-8948bd967f91f587, UID <uid>  Versions:           1  Message:            1 migrations are recorded applied No plan is published. Approvals: none Run acknowledgments: none Conditions:  TYPE              STATUS  REASON          SINCE                 MESSAGE  Progressing       False   HistoryMatched  <time>                Nothing is pending  Blocked           False   HistoryMatched  <time>                The history continues this artifact  Ready             True    HistoryMatched  <time>                The database has every migration this artifact carries  ApprovalRequired  False   Satisfied       <time>                The plan's approval requirements are satisfied kubectl events -n demo --for ptahmigration/shipments prints its Events. # The application writes rows. These are what the next release has to keep.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c \  "INSERT INTO shipments (id, reference) VALUES (1, 'EU-1001'), (2, 'US-2002'), (3, 'EU-1003')"kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT * FROM shipments ORDER BY id"INSERT 0 3 id | reference----+-----------  1 | EU-1001  2 | US-2002  3 | EU-1003(3 rows) # The second release keeps migration 1 and adds 2, which fills the new column from each row.$ cat demo/migrations/0000000002_add_carrier.up.sqlALTER TABLE shipments ADD COLUMN carrier TEXT; UPDATE shipments   SET carrier = CASE WHEN reference LIKE 'EU-%' THEN 'dhl' ELSE 'ups' END; ALTER TABLE shipments ALTER COLUMN carrier SET NOT NULL; # Point the resource at the second release, and ask for a decision this time.$ diff -u demo/manifests/second-release/shipments-release-1.yaml demo/manifests/second-release/shipments-release-2.yamlkubectl -n "$NAMESPACE" create configmap shipments-release-2 --from-file=demo/migrationskubectl -n "$NAMESPACE" apply -f demo/manifests/second-release/shipments-release-2.yaml--- demo/manifests/second-release/shipments-release-1.yaml	2026-11-02 14:04:31.814510611 +0000+++ demo/manifests/second-release/shipments-release-2.yaml	2026-11-02 14:04:31.814636968 +0000@@ -19,14 +19,18 @@       key: url   artifact:     configMap:-      name: shipments-release-1+      name: shipments-release-2       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: Always+    apply: OnApproval     # The wait for the database's own migration lock, not a Kubernetes Lease.     lockTimeout: 30s   interval: 1mconfigmap/shipments-release-2 createdptahmigration.operator.ptah.run/shipments configured # The operator reads the history and plans what it does not hold yet.$ kubectl ptah wait migration/shipments -n "$NAMESPACE" --for approvalptahmigration/shipments in namespace demo reached approval.  Phase:      AwaitingApproval  Generation: 2, observed by the operator  Database:   last read at <time>  Plan:       ptah-mplan-97cbd22af126c4ea62ede8e2kubectl ptah approve migration/shipments -n demo reviews it. # The plan holds only version 2. Version 1 is in the history, so it is not offered again.$ 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-release-2Snapshot:             ptah-src-8dd6517fc0457dd1ae2379f1Content:              sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read:         <time>Current version:      1Applied:              1Pending:              1 Last run:             Applied, started <time>, finished <time>  Job:                ptah-m-apply-shipments-8948bd967f91f587, UID <uid>  Versions:           1  Message:            1 migrations are recorded applied Plan ptah-mplan-97cbd22af126c4ea62ede8e2, 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  ApprovalRequired  True    AwaitingApproval  <time>                1 planned migrations need an approval naming this plan kubectl events -n demo --for ptahmigration/shipments prints its Events. # Approve that plan. The command shows it again, asks, and waits for the run.$ kubectl ptah approve migration/shipments -n "$NAMESPACE" --name shipments-release-2 --waitPtahMigration: demo/shipments, UID <uid>Plan:          ptah-mplan-97cbd22af126c4ea62ede8e2, UID <uid>Fingerprint:   sha256:97cbd22af126c4ea62ede8e2c25d7f265f024d85fd0403c301eaf34bdb76fe23 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-97cbd22af126c4ea62ede8e2 of migration/shipments in demo? Type yes to approve: yesApproved: PtahMigrationApproval demo/shipments-release-2, recorded by admission as kubernetes-admin at <time>.It binds plan ptah-mplan-97cbd22af126c4ea62ede8e2, fingerprint sha256:97cbd22af126c4ea62ede8e2c25d7f265f024d85fd0403c301eaf34bdb76fe23.Waiting up to 5m0s for ptahmigration/shipments in namespace demo to reach applied.ptahmigration/shipments in namespace demo reached applied.  Phase:      InSync  Generation: 2, observed by the operator  Database:   last read at <time>  Last run:   Applied, Job ptah-m-apply-shipments-a8739b8a509557e9kubectl ptah migration shipments -n demo shows where it stands. # The same three rows, each with a carrier its reference decided.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT id, reference, carrier FROM shipments ORDER BY id" id | reference | carrier----+-----------+---------  1 | EU-1001   | dhl  2 | US-2002   | ups  3 | EU-1003   | dhl(3 rows) # The run applied version 2 alone, and the resource names the Job that ran it.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration:            demo/shipmentsPhase:                InSyncReady:                True, HistoryMatched: The database has every migration this artifact carriesPolicy:               apply OnApprovalSource:               ConfigMap shipments-release-2Snapshot:             ptah-src-8dd6517fc0457dd1ae2379f1Content:              sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read:         <time>Current version:      2Applied:              2Pending:              0 Last run:             Applied, started <time>, finished <time>  Job:                ptah-m-apply-shipments-a8739b8a509557e9, UID <uid>  Versions:           2  Message:            1 migrations are recorded applied No plan is published. Approvals, newest first:  shipments-release-2    approved by kubernetes-admin at <time>    plan ptah-mplan-97cbd22af126c4ea62ede8e2, not the current plan    TYPE      STATUS  REASON             SINCE                 MESSAGE    Consumed  True    DispatchCommitted  <time>                The approved plan was dispatched Run acknowledgments: none Conditions:  TYPE              STATUS  REASON          SINCE                 MESSAGE  Progressing       False   HistoryMatched  <time>                Nothing is pending  Blocked           False   HistoryMatched  <time>                The history continues this artifact  Ready             True    HistoryMatched  <time>                The database has every migration this artifact carries  ApprovalRequired  False   Satisfied       <time>                The plan's approval requirements are satisfied kubectl events -n demo --for ptahmigration/shipments prints its Events. # Ask for another reading of the history, and wait for it.$ kubectl ptah refresh migration/shipments -n "$NAMESPACE" --waitRead requested for ptahmigration/shipments in namespace demo.  Request:  operator.ptah.run/read-requested-at=<time>  Observed: phase InSync, last read at <time>The request is written, not answered. The operator takes it once no operation is running, the resource is not suspended and no retry delay is pending, and a read already under way does not answer it. It starts no Apply and approves nothing.ptahmigration/shipments in namespace demo reached read.  Phase:      InSync  Generation: 2, observed by the operator  Database:   last read at <time>kubectl ptah migration shipments -n demo shows where it stands. # That reading found nothing pending, and the last run is still the one above.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration:            demo/shipmentsPhase:                InSyncReady:                True, HistoryMatched: The database has every migration this artifact carriesPolicy:               apply OnApprovalSource:               ConfigMap shipments-release-2Snapshot:             ptah-src-8dd6517fc0457dd1ae2379f1Content:              sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read:         <time>Current version:      2Applied:              2Pending:              0 Last run:             Applied, started <time>, finished <time>  Job:                ptah-m-apply-shipments-a8739b8a509557e9, UID <uid>  Versions:           2  Message:            1 migrations are recorded applied No plan is published. Approvals, newest first:  shipments-release-2    approved by kubernetes-admin at <time>    plan ptah-mplan-97cbd22af126c4ea62ede8e2, not the current plan    TYPE      STATUS  REASON             SINCE                 MESSAGE    Consumed  True    DispatchCommitted  <time>                The approved plan was dispatched Run acknowledgments: none Conditions:  TYPE              STATUS  REASON          SINCE                 MESSAGE  Progressing       False   HistoryMatched  <time>                Nothing is pending  Blocked           False   HistoryMatched  <time>                The history continues this artifact  Ready             True    HistoryMatched  <time>                The database has every migration this artifact carries  ApprovalRequired  False   Satisfied       <time>                The plan's approval requirements are satisfied kubectl events -n demo --for ptahmigration/shipments prints its Events.

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 "configmap/shipments-release-1 created", prints "shipments created"yes
2exits 0, prints "reached in-sync"yes
2ptahmigration/shipments: Ready=True (HistoryMatched)yes
3exits 0, prints "InSync", prints "Current version: 1", prints "Last run: Applied"yes
4exits 0, prints "INSERT 0 3", prints "EU-1001", prints "US-2002", prints "EU-1003", prints "(3 rows)"yes
5exits 0, prints "ADD COLUMN carrier", prints "UPDATE shipments", prints "LIKE 'EU-%'", prints "SET NOT NULL"yes
6exits 0, prints "+ name: shipments-release-2", prints "+ apply: OnApproval", prints "shipments configured"yes
7exits 0, prints "reached approval"yes
7ptahmigration/shipments: ApprovalRequired=True (AwaitingApproval)yes
8exits 0, prints "Pending: 1", prints "1 migration from version 1", prints "Add Carrier", never prints "Create Shipments"yes
9exits 0, prints "From version 1", prints "Add Carrier", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "reached applied"yes
10exits 0, prints "EU-1001 | dhl", prints "US-2002 | ups", prints "EU-1003 | dhl", prints "(3 rows)"yes
11exits 0, prints "Last run: Applied", prints "Versions: 2", prints "Pending: 0"yes
11ptahmigration/shipments: Ready=True (HistoryMatched), status.lastRun.jobUID is set, kept as release-2-runyes
12exits 0, prints "Read requested for", prints "reached read"yes
13exits 0, prints "Last run: Applied", prints "Versions: 2", prints "Pending: 0"yes
13ptahmigration/shipments: status.lastRun.jobUID is the value kept as release-2-run, status.history.pendingCount=0yes

What it ran against

  • Kubernetes1.37.0
  • Operator42522be3fd5a
  • Ptahv0.17.0
  • Executorsha256:181e27972091
  • Recorded2026-11-02
  • Scenarios at42522be3fd5a

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/second-release.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.