Skip to content

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.

Where it starts. An empty database and a namespace with the lab's Secrets, as Stand up the lab leaves them, with no migration history.

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.

sh · versioned-migration
# 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 = CASE WHEN reference LIKE 'EU-%' THEN 'dhl' ELSE 'ups' END; ALTER TABLE shipments ALTER COLUMN carrier SET NOT NULL; # Publish the directory, and create the resource that names it and asks for a person.$ REF=$(ptah migrations push "oci://$PTAH_OCI_REGISTRY/migrations/demo:v1-$$" \  --migrations-dir demo/migrations --dir-format ptah --version "v1-$$" --plain-http --format reference)PULL="oci://$REGISTRY_IN_CLUSTER/${REF#"oci://$PTAH_OCI_REGISTRY/"}"kubectl apply -n "$NAMESPACE" -f - <<EOFapiVersion: operator.ptah.run/v1alpha1kind: PtahMigrationmetadata:  name: shipmentsspec:  target:    engine: PostgreSQL    coordinationKey: demo/storefront/primary    urlFrom: {name: demo-database, key: url}  artifact:    ociRef: $PULL    registryAuthFrom: {name: demo-registry}    verificationPolicyFrom: {name: demo-migration-verification-policy, key: policy.yaml}    transport: {plainHTTP: true}  policy:    apply: OnApproval    lockTimeout: 30s  interval: 1mEOFptahmigration.operator.ptah.run/shipments created # The operator resolves the artifact, verifies it, and reads the database's history.$ 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-1cc2513b9c7752b0133a9cfekubectl ptah approve migration/shipments -n demo reviews it. # Two migrations pending against an empty history, and a plan waiting for a decision.$ kubectl ptah migration shipments -n "$NAMESPACE"Migration:            demo/shipmentsPhase:                AwaitingApprovalReady:                False, AwaitingApproval: The plan is waiting for the approval its policy requiresPolicy:               apply OnApprovalArtifact:             oci://e2e-registry.demo.svc.cluster.local:5000/migrations/demo@sha256:d954bd855f897186ba20ac3026b8d58bc35f9f2895c860ccfd02fa0759875d18History read:         <time>Current version:      0Applied:              0Pending:              2 Plan ptah-mplan-1cc2513b9c7752b0133a9cfe, 2 migrations from version 0:  1                    Create Shipments  2                    Add Carrier  ApprovalRequired: True, AwaitingApproval: 2 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  ArtifactVerified  True    PolicySatisfied   <time>                The resolved artifact satisfied its verification policy  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>                2 planned migrations need an approval naming this plan kubectl events -n demo --for ptahmigration/shipments prints its Events. # No migration has run, and reading the history wrote nothing. The database is still empty.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt"Did not find any relations. # Approve it. The plan is versions and checksums, and the approval names its fingerprint.$ kubectl ptah approve migration/shipments -n "$NAMESPACE" --name shipments-v1 --waitPtahMigration: demo/shipments, UID <uid>Plan:          ptah-mplan-1cc2513b9c7752b0133a9cfe, UID <uid>Fingerprint:   sha256:1cc2513b9c7752b0133a9cfe07e4de004da03cb63a849c85d3257f8556264732 From version 0, in this order:  1                Create Shipments                   checksum 7d908326855e19bb7152d1cba687705f0b481b7dc46d8195060db581a283a17a  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:          OCI artifact  Artifact:        sha256:d954bd855f897186ba20ac3026b8d58bc35f9f2895c860ccfd02fa0759875d18 Approve plan ptah-mplan-1cc2513b9c7752b0133a9cfe of migration/shipments in demo? Type yes to approve: yesApproved: PtahMigrationApproval demo/shipments-v1, recorded by admission as kubernetes-admin at <time>.It binds plan ptah-mplan-1cc2513b9c7752b0133a9cfe, fingerprint sha256:1cc2513b9c7752b0133a9cfe07e4de004da03cb63a849c85d3257f8556264732.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-0f713ecd9bcc25c0kubectl ptah migration shipments -n demo shows where it stands. # The sequence ran, and the column is constrained the way migration 2 left 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:                InSyncReady:                True, HistoryMatched: The database has every migration this artifact carriesPolicy:               apply OnApprovalArtifact:             oci://e2e-registry.demo.svc.cluster.local:5000/migrations/demo@sha256:d954bd855f897186ba20ac3026b8d58bc35f9f2895c860ccfd02fa0759875d18History read:         <time>Current version:      2Applied:              2Pending:              0 Last run:             Applied, started <time>, finished <time>  Job:                ptah-m-apply-shipments-0f713ecd9bcc25c0, UID <uid>  Versions:           1, 2  Message:            2 migrations are recorded applied No plan is published. Approvals, newest first:  shipments-v1    approved by kubernetes-admin at <time>    plan ptah-mplan-1cc2513b9c7752b0133a9cfe, 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  ArtifactVerified  True    PolicySatisfied  <time>                The resolved artifact satisfied its verification policy  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. # Read the history again. There is nothing pending, so nothing runs.$ 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: 1, observed by the operator  Database:   last read at <time>kubectl ptah migration shipments -n demo shows where it stands.

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 "0000000001_create_shipments.up.sql", prints "0000000002_add_carrier.up.sql"yes
2exits 0, prints "ADD COLUMN carrier", prints "UPDATE shipments", prints "SET NOT NULL"yes
3exits 0, prints "shipments created"yes
3ptahmigration/shipments: spec.policy.apply=OnApprovalyes
4exits 0, prints "reached approval"yes
4ptahmigration/shipments: ApprovalRequired=True (AwaitingApproval)yes
5exits 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
6exits 0, reports "Did not find any relations"yes
7exits 0, prints "Fingerprint:", prints "checksum", prints "not shown here", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "reached applied"yes
7ptahmigrationapproval/shipments-v1: spec.approver.username is set, spec.planFingerprint is set, spec.mutationRequestUID is setyes
8exits 0, prints "carrier", prints "not null"yes
9exits 0, prints "InSync", prints "Current version: 2", prints "Pending: 0", prints "Last run: Applied", prints "Versions: 1, 2", prints "No plan is published.", prints "shipments-v1", never prints "CREATE TABLE", never prints "ALTER TABLE"yes
9ptahmigration/shipments: Ready=True (HistoryMatched), status.lastRun.jobUID is set, kept as the-runyes
10exits 0, prints "Read requested for", prints "reached read"yes
10ptahmigration/shipments: status.history.pendingCount=0, status.lastRun.jobUID is the value kept as the-runyes

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