Run a prepared migration sequence
Numbered SQL files in an artifact, matched against the database's own record of what has run.
What it shows. How the versioned workflow differs from the declarative one, and what the database is asked before anything executes.
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 sequence is files. Numbered, with a direction, and written by a person.$ ls demo/migrations0000000001_create_shipments.down.sql0000000001_create_shipments.up.sql0000000002_add_carrier.down.sql0000000002_add_carrier.up.sql # The second one adds a column, fills it, and only then constrains it. Order is the point.$ cat demo/migrations/0000000002_add_carrier.up.sqlALTER TABLE shipments ADD COLUMN carrier TEXT; UPDATE shipments SET carrier = 'unassigned'; ALTER TABLE shipments ALTER COLUMN carrier SET NOT NULL; # The resource names the sequence, the database, and what may be done about the difference.$ APPLY=OnApproval demo/bin/lab manifest shipments "$(cat demo/.lab/migration-digest)" | tee demo/.lab/shipments.yaml# A prepared sequence of migrations, as a Kubernetes resource.## The other manifest in this directory declares what a database should look# like and lets the operator work out the statements. This one names a sequence# somebody already wrote and matches it against the database's own record of# what has run. Neither is a mode of the other: they have different state# models, and the field that says so is `kind`.apiVersion: operator.ptah.run/v1alpha1kind: PtahMigrationmetadata: name: shipments namespace: demospec: target: engine: PostgreSQL # The same key the schema resource uses, because it is the same physical # database. Two resources that name it are refused rather than serialized, # unless each of them declares the realm shared -- so this demonstration # resets the schema away first, and manages the database alone. coordinationKey: demo/storefront/primary urlFrom: name: demo-database key: url artifact: # A digest, not a tag: what was reviewed is what runs. ociRef: oci://e2e-registry.demo.svc.cluster.local:5000/migrations/demo@sha256:2025a6e917e4c139035fcc93d16872d5b9e0b69dd0dc6c4251cbfca3f288fc87 registryAuthFrom: name: demo-registry mode: Environment usernameKey: username passwordKey: password registryKey: registry # A migration's own policy. One that also accepted the schema artifact type # would let a declared schema stand in for a prepared sequence. verificationPolicyFrom: name: demo-migration-verification-policy key: policy.yaml transport: plainHTTP: true policy: # A migration artifact carries arbitrary SQL, and no analyzer classifies # arbitrary SQL as safe, so the conservative setting is the default one. apply: OnApproval # The wait for the database's own migration lock, which is not the # Kubernetes Lease. lockTimeout: 30s interval: 1m suspend: false execution: activeDeadlineSeconds: 300 failureRetryInterval: 10s connectTimeout: 30s # Apply it. The operator resolves the artifact, verifies it, and reads the database's history.$ kubectl apply -f demo/.lab/shipments.yamlptahmigration.operator.ptah.run/shipments created # Two migrations pending against an empty history, and a plan waiting for a decision.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration: demo/shipmentsPhase: AwaitingApprovalArtifact: oci://e2e-registry.demo.svc.cluster.local:5000/migrations/demo@sha256:2025a6e917e4c139035fcc93d16872d5b9e0b69dd0dc6c4251cbfca3f288fc87History read: <time>Current version:0Applied: 0Pending: 2ArtifactVerified:True (PolicySatisfied)Ready: False (AwaitingApproval)ApprovalRequired:True (AwaitingApproval) Plan ptah-mplan-899d4dc9634adb3f0bc5cbe4, 2 migrations from version 0: 1 Create Shipments 2 Add Carrier # No migration has run. The revision table is there because reading a history needs it.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt" List of relations Schema | Name | Type | Owner--------+-------------------+-------+--------------- public | schema_migrations | table | ptah_external(1 row) # An approval names the migration, the plan and its fingerprint. The rest is read off the plan.$ 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-v1"}, 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 } }' | tee demo/.lab/migration-approval.json | jq -c .spec{"migrationRef":{"name":"shipments","uid":"<uid>"},"planRef":{"name":"ptah-mplan-899d4dc9634adb3f0bc5cbe4","uid":"<uid>"},"planFingerprint":"sha256:899d4dc9634adb3f0bc5cbe48730681b95cc6e2b6d902798473ee6b387efa0d8"} # Create it. The webhook stamps who you are and binds the history the plan was computed against.$ kubectl -n "$NAMESPACE" create -f demo/.lab/migration-approval.jsonptahmigrationapproval.operator.ptah.run/shipments-v1 created # The binding covers the history too, so an approval cannot outlive another run.$ kubectl -n "$NAMESPACE" get ptahmigrationapproval shipments-v1 -o json \ | jq -r '.spec | {approver: .approver.username, historyFingerprint, artifactDigest, ptahVersion}'{ "approver": "kubernetes-admin", "historyFingerprint": "sha256:20b3ad19836a0f62c12dd81f9d0c3599bce9935fca0f2070716936e22da499a2", "artifactDigest": "sha256:2025a6e917e4c139035fcc93d16872d5b9e0b69dd0dc6c4251cbfca3f288fc87", "ptahVersion": "v0.6.0"} # With the decision recorded, the sequence runs and the history is read back to confirm it.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\d shipments" Table "public.shipments" Column | Type | Collation | Nullable | Default-----------+---------+-----------+----------+--------- id | integer | | not null | reference | text | | not null | carrier | text | | not null |Indexes: "shipments_pkey" PRIMARY KEY, btree (id) # The verdict is the database's, read from the revision table and not from the Job's exit code.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration: demo/shipmentsPhase: InSyncArtifact: oci://e2e-registry.demo.svc.cluster.local:5000/migrations/demo@sha256:2025a6e917e4c139035fcc93d16872d5b9e0b69dd0dc6c4251cbfca3f288fc87History read: <time>Current version:2Applied: 2Pending: 0Last run: Applied at <time>Run applied: 1, 2Run message: 2 migrations are recorded appliedArtifactVerified:True (PolicySatisfied)Ready: True (HistoryMatched) No plan is published. # Reconciling again runs nothing. The history already holds both, so there is nothing pending.$ kubectl -n "$NAMESPACE" get ptahmigration shipments -o json \ | jq -r '"pending: \(.status.history.pendingCount)", "applied: \(.status.history.appliedCount)", "plan: \(.status.plan // "none")"'pending: 0applied: 2plan: none
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 "0000000001_create_shipments.up.sql", prints "0000000002_add_carrier.up.sql" | yes |
| 2 | exits 0, prints "ADD COLUMN carrier", prints "UPDATE shipments", prints "SET NOT NULL" | yes |
| 3 | exits 0, prints "kind: PtahMigration", prints "apply: OnApproval" | yes |
| 4 | exits 0, prints "created" | yes |
| 5 | ptahmigration/shipments: ApprovalRequired=True (AwaitingApproval) | yes |
| 5 | exits 0, prints "AwaitingApproval", prints "Current version:0", prints "Pending: 2", prints "Create Shipments", prints "Add Carrier", never prints "CREATE TABLE", never prints "ALTER TABLE" | yes |
| 6 | exits 0, prints "schema_migrations", never prints "shipments" | yes |
| 7 | exits 0, prints "\"planFingerprint\":\"sha256:" | yes |
| 8 | exits 0, prints "created" | yes |
| 9 | exits 0, prints "approver", prints "historyFingerprint", prints "artifactDigest" | yes |
| 9 | ptahmigrationapproval/shipments-v1: spec.approver.username is set, spec.historyFingerprint is set, spec.artifactDigest is set, spec.mutationRequestUID is set | yes |
| 10 | ptahmigration/shipments: Ready=True (HistoryMatched) | yes |
| 10 | exits 0, prints "carrier", prints "not null" | yes |
| 11 | exits 0, prints "InSync", prints "Current version:2", prints "Last run: Applied", prints "Run applied: 1, 2", never prints "CREATE TABLE", never prints "ALTER TABLE" | yes |
| 12 | exits 0, prints "pending: 0", prints "applied: 2", prints "plan: none" | yes |
What it ran against
- Kubernetes1.37.0
- Operator8c7fe187707f
- Ptahv0.6.0
- Executorsha256:91ec0a6fa27f
- Recorded2026-09-17
- Scenarios at8c7fe187707f
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.
The scenario is demo/scenarios/versioned-migration.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.