Skip to content
PtahOperator

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 = '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.

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 "schema_migrations", never prints "shipments"yes
7exits 0, prints "\"planFingerprint\":\"sha256:"yes
8exits 0, prints "created"yes
9exits 0, prints "approver", prints "historyFingerprint", prints "artifactDigest"yes
9ptahmigrationapproval/shipments-v1: spec.approver.username is set, spec.historyFingerprint is set, spec.artifactDigest 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
  • 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.