Skip to content
PtahOperator

Approve a plan

With apply set to OnApproval, nothing runs until a person approves that exact plan.

What it shows. What an approval is bound to, and how much of that binding you never type.

A replay of a verified run, not a live cluster. 14 checks held while it was recorded. Play types it out in the frame below; the transcript is the same session, already there.

sh · manual-approval
# Same resource, one field different -- apply is OnApproval, which is the default.$ APPLY=OnApproval demo/bin/lab manifest storefront "$(cat demo/.lab/digest)" | tee demo/.lab/storefront.yaml# The desired state of one database, as a Kubernetes resource.## Nothing here is a credential. The database URL and the registry password are# named, and read by the operator from Secrets this namespace holds; what the# resource carries is which artifact, which database, and what the operator is# allowed to do about the difference.apiVersion: operator.ptah.run/v1alpha1kind: PtahSchemametadata:  name: storefront  namespace: demospec:  target:    engine: PostgreSQL    # Every resource that can reach this physical database, through any alias    # or credential, has to use this exact key. It is how two of them are kept    # from planning against each other.    coordinationKey: demo/storefront/primary    urlFrom:      name: demo-database      key: url  desired:    # A digest, not a tag: what was reviewed is what runs.    ociRef: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:d2a387e8b8dd6963a00790c3318251d528da5dc6ffb16a70486dda2b1c42be2a    registryAuthFrom:      name: demo-registry      mode: Environment      usernameKey: username      passwordKey: password      registryKey: registry    verificationPolicyFrom:      name: demo-verification-policy      key: policy.yaml    transport:      plainHTTP: true  policy:    apply: OnApproval    allowDestructive: false    driftSeverity: all  interval: 1m  suspend: false  execution:    activeDeadlineSeconds: 300    failureRetryInterval: 10s    connectTimeout: 30s # Apply it, and the operator plans but stops.$ kubectl apply -f demo/.lab/storefront.yamlptahschema.operator.ptah.run/storefront created # It has a plan and it is waiting. The database has not been touched.$ kubectl -n "$NAMESPACE" get ptahschema storefront -o json \  | jq -r '.status.phase, (.status.conditions[] | select(.type == "ApprovalRequired") | .message)'AwaitingApprovalCreate an approval bound to the current plan fingerprint # Nothing was created. This is what waiting looks like from the database.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt"Did not find any relations. # Read the plan before approving it. This is what would run.$ kubectl ptah plan storefront --current -n "$NAMESPACE" -o json \  | jq -r '.statements[] | "\(.severity)\t\(.sql)"'safe	-- POSTGRES TABLE: customers --CREATE TABLE "customers" (  "id" bigint PRIMARY KEY NOT NULL,  "email" text NOT NULL) # An approval names the schema, the plan, and the fingerprint of that plan.$ kubectl -n "$NAMESPACE" get ptahschema storefront -o json > demo/.lab/schema.jsonkubectl -n "$NAMESPACE" get ptahschemaplan \  "$(jq -r .status.plan.name demo/.lab/schema.json)" -o json > demo/.lab/plan.jsonjq -n --slurpfile schema demo/.lab/schema.json --slurpfile plan demo/.lab/plan.json '  {    apiVersion: "operator.ptah.run/v1alpha1", kind: "PtahSchemaApproval",    metadata: {name: "storefront-v1"},    spec: {      schemaRef: {name: $schema[0].metadata.name, uid: $schema[0].metadata.uid},      planRef:   {name: $plan[0].metadata.name,   uid: $plan[0].metadata.uid},      planFingerprint: $plan[0].spec.fingerprint    }  }' | tee demo/.lab/approval.json | jq -c .spec{"schemaRef":{"name":"storefront","uid":"<uid>"},"planRef":{"name":"ptah-plan-d4a5c0efab2947c8c110ed9a","uid":"<uid>"},"planFingerprint":"sha256:d4a5c0efab2947c8c110ed9a58bd9fd6d0d6bed03d30be54c06f936798c5239b"} # Create it. The webhook stamps who you are and binds the rest of the plan's identity.$ kubectl -n "$NAMESPACE" create -f demo/.lab/approval.jsonptahschemaapproval.operator.ptah.run/storefront-v1 created # None of this was typed. The webhook read it off the plan being approved.$ kubectl -n "$NAMESPACE" get ptahschemaapproval storefront-v1 -o json \  | jq -r '.spec | {approver: .approver.username, ptahVersion, executorImage, artifactDigest, executionBindingID}'{  "approver": "kubernetes-admin",  "ptahVersion": "v0.6.0",  "executorImage": "e2e-registry.demo.svc.cluster.local:5000/ptah-executor@sha256:91ec0a6fa27fab4d8ad52c77ea332dc3984def59a9c63891e120304d07edaf6f",  "artifactDigest": "sha256:d2a387e8b8dd6963a00790c3318251d528da5dc6ffb16a70486dda2b1c42be2a",  "executionBindingID": "v1-c073a1306a04f57276592f5b4238e4e9"} # With the decision recorded, the plan runs and convergence is proved.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt"             List of relations Schema |   Name    | Type  |     Owner--------+-----------+-------+--------------- public | customers | table | ptah_external(1 row) # It was dispatched once, and is already stale. Retiring a used approval shows why.$ kubectl -n "$NAMESPACE" get ptahschemaapproval storefront-v1 -o json \  | jq -r '.status.conditions[] | "\(.type)\t\(.status)\t\(.reason)"'Accepted	False	PlanNoLongerCurrentStale	True	PlanNoLongerCurrentConsumed	True	DispatchCommitted

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 "apply: OnApproval"yes
2exits 0, prints "created"yes
3ptahschema/storefront: ApprovalRequired=True (Waiting)yes
3exits 0, prints "AwaitingApproval"yes
4exits 0, reports "Did not find any relations"yes
5exits 0, prints "CREATE TABLE \"customers\"", prints "safe"yes
6exits 0, prints "\"planFingerprint\":\"sha256:"yes
7exits 0, prints "created"yes
8exits 0, prints "approver", prints "ptahVersion", prints "executorImage"yes
8ptahschemaapproval/storefront-v1: spec.approver.username is set, spec.artifactDigest is set, spec.executorImage is set, spec.mutationRequestUID is setyes
9ptahschema/storefront: InSync=True (ScopedConverged)yes
9exits 0, prints "customers"yes
10exits 0, prints "Consumed", prints "DispatchCommitted"yes
10ptahschemaapproval/storefront-v1: Consumed=True (DispatchCommitted)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/manual-approval.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.