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.

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; # 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:198820c30f02bf2b565f15e47e69bbd141515d286a21fccf8e38dd9f3e4c09ab    registryAuthFrom:      name: demo-registry      mode: Environment      usernameKey: username      passwordKey: password    # 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:198820c30f02bf2b565f15e47e69bbd141515d286a21fccf8e38dd9f3e4c09abHistory read:   <time>Current version:0Applied:        0Pending:        2ArtifactVerified:True (PolicySatisfied)Ready:          False (AwaitingApproval)ApprovalRequired:True (AwaitingApproval) Plan ptah-mplan-48f1c3734cc46bad17d86441, 2 migrations from version 0:  1                    Create Shipments  2                    Add Carrier # No migration has run, and reading the history wrote nothing. The database is still empty.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -tAc "SELECT 'tables: ' || count(*) FROM pg_tables WHERE schemaname = current_schema()"tables: 0 # 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-48f1c3734cc46bad17d86441","uid":"<uid>"},"planFingerprint":"sha256:48f1c3734cc46bad17d8644171b26860ac30c5a6c68db7a168466ae579058740"} # Create it. The webhook stamps who you are and holds the three to the live plan.$ kubectl -n "$NAMESPACE" create -f demo/.lab/migration-approval.jsonptahmigrationapproval.operator.ptah.run/shipments-v1 created # The plan's fingerprint covers the history, so an approval cannot outlive another run.$ jq -r '.spec | {fingerprint, historyFingerprint, artifactDigest}' demo/.lab/migration-plan.jsonkubectl -n "$NAMESPACE" get ptahmigrationapproval shipments-v1 -o json \  | jq -r '.spec | {approver: .approver.username, planFingerprint}'{  "fingerprint": "sha256:48f1c3734cc46bad17d8644171b26860ac30c5a6c68db7a168466ae579058740",  "historyFingerprint": "sha256:999adb060839cde5198cc02242dd3a82cab03b0ca183211f24cbc47eed7789f6",  "artifactDigest": "sha256:198820c30f02bf2b565f15e47e69bbd141515d286a21fccf8e38dd9f3e4c09ab"}{  "approver": "kubernetes-admin",  "planFingerprint": "sha256:48f1c3734cc46bad17d8644171b26860ac30c5a6c68db7a168466ae579058740"} # 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:198820c30f02bf2b565f15e47e69bbd141515d286a21fccf8e38dd9f3e4c09abHistory 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.

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 "kind: PtahMigration", prints "apply: OnApproval"yes
4exits 0, prints "created"yes
5ptahmigration/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, prints "tables: 0"yes
7exits 0, prints "\"planFingerprint\":\"sha256:"yes
8exits 0, prints "created"yes
9exits 0, prints "historyFingerprint", prints "approver", prints "planFingerprint"yes
9ptahmigrationapproval/shipments-v1: spec.approver.username is set, spec.planFingerprint is set, spec.mutationRequestUID is setyes
10ptahmigration/shipments: Ready=True (HistoryMatched)yes
10exits 0, prints "carrier", prints "not null"yes
11exits 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
12exits 0, prints "pending: 0", prints "applied: 2", prints "plan: none"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/versioned-migration.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.