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.
A replay of a verified run, not a live cluster. 15 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.$ APPLY=Always demo/bin/lab manifest shipments "$(cat demo/.lab/release-1-digest)" | kubectl apply -f -ptahmigration.operator.ptah.run/shipments created # The table exists and the history records version 1.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration: demo/shipmentsPhase: InSyncArtifact: oci://e2e-registry.demo.svc.cluster.local:5000/migrations/demo@sha256:441df4e25f559faa16475a0e3bc5da8e4bb1582976f47a7f50f6ff094bc0af66History read: <time>Current version:1Applied: 1Pending: 0Last run: Applied at <time>Run applied: 1Run message: 1 migrations are recorded appliedArtifactVerified:True (PolicySatisfied)Ready: True (HistoryMatched) No plan is published. # 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.$ APPLY=OnApproval demo/bin/lab manifest shipments "$(cat demo/.lab/release-2-digest)" \ | tee demo/.lab/shipments.yaml | kubectl apply -f -ptahmigration.operator.ptah.run/shipments configured # The plan holds only version 2. Version 1 is in the history, so it is not offered again.$ kubectl -n "$NAMESPACE" get ptahmigrationplan \ "$(kubectl -n "$NAMESPACE" get ptahmigration shipments -o jsonpath='{.status.plan.name}')" -o json \ | jq -r '.spec.migrations[] | "planned: \(.version) \(.description)"'planned: 2 Add Carrier # Approve that plan by name, UID and fingerprint.$ kubectl -n "$NAMESPACE" get ptahmigration shipments -o json > demo/.lab/migration.jsonkubectl -n "$NAMESPACE" get ptahmigrationplan \ "$(jq -r .status.plan.name demo/.lab/migration.json)" -o json > demo/.lab/migration-plan.jsonjq -n --slurpfile migration demo/.lab/migration.json --slurpfile plan demo/.lab/migration-plan.json ' { apiVersion: "operator.ptah.run/v1alpha1", kind: "PtahMigrationApproval", metadata: {name: "shipments-release-2"}, spec: { migrationRef: {name: $migration[0].metadata.name, uid: $migration[0].metadata.uid}, planRef: {name: $plan[0].metadata.name, uid: $plan[0].metadata.uid}, planFingerprint: $plan[0].spec.fingerprint } }' | kubectl -n "$NAMESPACE" create -f -ptahmigrationapproval.operator.ptah.run/shipments-release-2 created # The same three rows, each with a carrier its reference decided.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT * FROM shipments ORDER BY id"kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -tAc \ "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) 1 EU-1001 dhl2 US-2002 ups3 EU-1003 dhl # The run applied version 2 alone. Keep its Job's UID, to compare after the next pass.$ kubectl -n "$NAMESPACE" get ptahmigration shipments -o json \ | jq -r '"applied: \(.status.lastRun.appliedVersions | join(","))", "job: \(.status.lastRun.jobUID)"' \ | tee demo/.lab/release-2-runapplied: 2job: <uid> # Change only the interval. The operator has to read the history again to settle it.$ kubectl -n "$NAMESPACE" patch ptahmigration shipments --type merge -p '{"spec":{"interval":"2m"}}'ptahmigration.operator.ptah.run/shipments patched # That pass found nothing pending, and the last run is still the one kept above.$ kubectl -n "$NAMESPACE" get ptahmigration shipments -o json \ | jq -r '"applied: \(.status.lastRun.appliedVersions | join(","))", "job: \(.status.lastRun.jobUID)"' \ | diff demo/.lab/release-2-run - && echo "the same run, and no other"kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -tAc "SELECT count(*) FROM shipments"the same run, and no other3
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 "created" | yes |
| 2 | ptahmigration/shipments: Ready=True (HistoryMatched) | yes |
| 2 | exits 0, prints "InSync", prints "Current version:1", prints "Last run: Applied" | yes |
| 3 | exits 0, prints "INSERT 0 3", prints "EU-1001", prints "US-2002", prints "EU-1003", prints "(3 rows)" | yes |
| 4 | exits 0, prints "ADD COLUMN carrier", prints "UPDATE shipments", prints "LIKE 'EU-%'", prints "SET NOT NULL" | yes |
| 5 | exits 0, prints "configured" | yes |
| 6 | ptahmigration/shipments: ApprovalRequired=True (AwaitingApproval) | yes |
| 6 | exits 0, prints "planned: 2 Add Carrier", never prints "planned: 1" | yes |
| 7 | exits 0, prints "created" | yes |
| 8 | exits 0, prints "(3 rows)", prints "1 EU-1001 dhl", prints "2 US-2002 ups", prints "3 EU-1003 dhl" | yes |
| 9 | ptahmigration/shipments: Ready=True (HistoryMatched) | yes |
| 9 | exits 0, prints "applied: 2", prints "job: " | yes |
| 10 | exits 0, prints "patched" | yes |
| 11 | ptahmigration/shipments: Ready=True (HistoryMatched) | yes |
| 11 | exits 0, prints "the same run, and no other", prints "3" | yes |
What it ran against
- Kubernetes1.37.0
- Operatorb98f92bed240
- Ptahv0.13.0
- Executorsha256:f68968558f06
- Recorded2026-10-10
- Scenarios at0f7a06595d8b
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 read-only client.
- KUBECONFIGthe cluster the commands run against
- NAMESPACEthe namespace the schema and its database credentials live in
- OPERATOR_NAMESPACEwhere the chart installed the operator
- CONTROLLERthe controller Deployment’s name, from that release
- 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
- PTAH_OCI_PASSWORDits password, which no command prints
A session also uses demo/bin/lab in two places, and both are the lab handing over what it generated rather than doing the demonstration’s work:lab credentials prints the three registry values above, and lab manifestfills the published template with values the step states. Rundemo/acceptance/reproduce.sh to watch the same scenario repeated from a directory where neither exists, 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.
The scenario is demo/scenarios/second-release.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.