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.
# 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-6f8bb774edbc0011eebdbda9Content: sha256:f64bb53f8cd38fe8108ea270fc1bb73bcc7c219fca72e81de774914d07955961History read: <time>Current version: 1Applied: 1Pending: 0 Last run: Applied, started <time>, finished <time> Job: ptah-m-apply-shipments-ca2cf5e18d575a21, 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 DatabaseReachable True Observed <time> The migration history was read 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-06 15:43:50.129549857 +0000+++ demo/manifests/second-release/shipments-release-2.yaml 2026-11-06 15:43:50.129643477 +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-2e528f729e726001a7904389kubectl 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-3144c6a88019fac0d24a30a1Content: sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read: <time>Current version: 1Applied: 1Pending: 1 Last run: Applied, started <time>, finished <time> Job: ptah-m-apply-shipments-ca2cf5e18d575a21, UID <uid> Versions: 1 Message: 1 migrations are recorded applied Plan ptah-mplan-2e528f729e726001a7904389, 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 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-2e528f729e726001a7904389, UID <uid>Fingerprint: sha256:2e528f729e726001a790438964f7effa1c39e25c73d7231d6cc6a88304b9f21a 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-2e528f729e726001a7904389 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-2e528f729e726001a7904389, fingerprint sha256:2e528f729e726001a790438964f7effa1c39e25c73d7231d6cc6a88304b9f21a.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-b32808cbfb2f2abfkubectl 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-3144c6a88019fac0d24a30a1Content: sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read: <time>Current version: 2Applied: 2Pending: 0 Last run: Applied, started <time>, finished <time> Job: ptah-m-apply-shipments-b32808cbfb2f2abf, 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-2e528f729e726001a7904389, 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 DatabaseReachable True Observed <time> The migration history was read 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-3144c6a88019fac0d24a30a1Content: sha256:012ea3755bdf49b4372a392234e025f2118b80a9fe8d86b4c1cb34352c4fe5a6History read: <time>Current version: 2Applied: 2Pending: 0 Last run: Applied, started <time>, finished <time> Job: ptah-m-apply-shipments-b32808cbfb2f2abf, 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-2e528f729e726001a7904389, 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 DatabaseReachable True Observed <time> The migration history was read 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.
| Step | Claim | Held |
|---|---|---|
| 1 | exits 0, prints "configmap/shipments-release-1 created", prints "shipments created" | yes |
| 2 | exits 0, prints "reached in-sync" | yes |
| 2 | ptahmigration/shipments: Ready=True (HistoryMatched) | yes |
| 3 | exits 0, prints "InSync", prints "Current version: 1", prints "Last run: Applied" | yes |
| 4 | exits 0, prints "INSERT 0 3", prints "EU-1001", prints "US-2002", prints "EU-1003", prints "(3 rows)" | yes |
| 5 | exits 0, prints "ADD COLUMN carrier", prints "UPDATE shipments", prints "LIKE 'EU-%'", prints "SET NOT NULL" | yes |
| 6 | exits 0, prints "+ name: shipments-release-2", prints "+ apply: OnApproval", prints "shipments configured" | yes |
| 7 | exits 0, prints "reached approval" | yes |
| 7 | ptahmigration/shipments: ApprovalRequired=True (AwaitingApproval) | yes |
| 8 | exits 0, prints "Pending: 1", prints "1 migration from version 1", prints "Add Carrier", never prints "Create Shipments" | yes |
| 9 | exits 0, prints "From version 1", prints "Add Carrier", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "reached applied" | yes |
| 10 | exits 0, prints "EU-1001 | dhl", prints "US-2002 | ups", prints "EU-1003 | dhl", prints "(3 rows)" | yes |
| 11 | exits 0, prints "Last run: Applied", prints "Versions: 2", prints "Pending: 0" | yes |
| 11 | ptahmigration/shipments: Ready=True (HistoryMatched), status.lastRun.jobUID is set, kept as release-2-run | yes |
| 12 | exits 0, prints "Read requested for", prints "reached read" | yes |
| 13 | exits 0, prints "Last run: Applied", prints "Versions: 2", prints "Pending: 0" | yes |
| 13 | ptahmigration/shipments: status.lastRun.jobUID is the value kept as release-2-run, status.history.pendingCount=0 | 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
executorImagevalue - 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
ptahitself - 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.
| Command | The line it changes |
|---|---|
kubectl ptah suspend | spec.suspend: true |
kubectl ptah resume | spec.suspend: false |
kubectl ptah set-source --oci | spec.desired.ociRef of a schema, spec.artifact.ociRef of a migration |
kubectl ptah refresh | the 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.