Skip to content
PtahOperator

Read a plan

The SQL the operator applies is not in a status field or a log line. It is a document the operator publishes into immutable ConfigMaps, bound to a PtahSchemaPlan by index, key, size and digest, and read back only after every one of those bindings has held.

kubectl ptah is how you read it. It reads Kubernetes objects and never creates or changes a resource, starts a Job, or connects to your database.

Terminal window
kubectl ptah plan storefront -n application

Three verbs read three different things: plan reads the SQL a stored plan holds, schema reads where a PtahSchema stands and what its last observation found, and migration reads a PtahMigration.

kubectl runs any executable named kubectl-<verb> on your PATH as kubectl <verb>, so installing the plugin is putting one file there.

Each release publishes a binary for the supported client platforms and a SHA256SUMS beside them:

Terminal window
version=<release tag>
platform=darwin-arm64 # or linux-amd64, linux-arm64, darwin-amd64
base=https://github.com/stokaro/ptah-operator/releases/download/$version
curl -fsSLO "$base/kubectl-ptah-$platform"
curl -fsSLO "$base/SHA256SUMS"
shasum -a 256 --check --ignore-missing SHA256SUMS
install -m 0755 "kubectl-ptah-$platform" /usr/local/bin/kubectl-ptah
kubectl ptah --version

kubectl plugin list shows it once it is on PATH. It needs nothing else: no checkout of this repository, no Helm release on your machine, and no files beside it.

Current and applied are two different plans, and neither stands in for the other.

Flag What it reads
--current (the default) the plan the operator would run next, which is what status.plan names
--applied the plan the last confirmed apply ran, which is what status.applied records

A converged schema usually has no current plan, because there is nothing left to do; a schema that has never applied anything has no applied plan. Either way the command says so and exits with status 3 rather than printing empty SQL.

--applied shows the plan that ran. It is not a log of what each statement did in the database, and it does not read the database again.

Without -o you get what the plan is, and then the SQL it holds:

Terminal window
kubectl ptah plan storefront --applied -n application
Schema: application/storefront
Plan: applied (ptah-plan-4b44084123f0958600629632)
Fingerprint: sha256:4b44084123f095860062963255f9f84c2921946524e187186eca71ca88cc366e
Content digest: sha256:99a821c0f52b1eb6d643633d40a7db7467b81e10a3be16982626807ef6c692ad
Dialect: postgres
Statements: 1
Destructive: false
Stored: 2026-09-13T08:04:05Z
Applied: 2026-09-13T08:04:24Z
-- POSTGRES TABLE: customers --
CREATE TABLE "customers" (
"id" bigint PRIMARY KEY NOT NULL,
"email" text NOT NULL
);

The statements come out as the planner wrote them, comments and all.

-o sql prints the statements alone, in the order the plan holds them, each terminated once. Nothing is re-split on a semicolon: a statement may carry one inside a string, a comment or a function body.

-o json prints the stored plan document byte for byte, so what you save still matches the content digest printed above it. It is the executable plan, not the Kubernetes object:

Terminal window
kubectl ptah plan storefront --applied -n application -o json > plan.json

Diagnostics go to standard error. Nothing reaches standard output until every check has held, so a plan whose last chunk is corrupt leaves you with an error rather than half its SQL.

Exit status: 0 printed, 1 could not read or verify, 2 the command line, 3 nothing stored to print.

A plan is what the operator would run. It does not say what the operator saw, and a converged schema has no plan at all, which is exactly when you want to know what the last reading found.

Terminal window
kubectl ptah schema storefront -n application
Schema: application/storefront
Phase: AwaitingApproval
Artifact: oci://registry.example/acme/storefront@sha256:...
Verified: true
Observed: 2026-09-16T06:00:00Z
Drift: 6 in 3 categories, highest destructive
Reference data: 2 to insert, 1 to update, 3 to delete
data_rows_deleted 3 destructive
data_rows_inserted 2 safe
data_rows_updated 1 destructive
ApprovalRequired: True (AwaitingApproval)
Ready: False (AwaitingApproval)
Plan ptah-plan-4b44084123f0958600629632, 4 statements, stored 2026-09-16T06:00:00Z
not approved
kubectl ptah plan prints the SQL this plan holds.

Observed: not yet and Drift: none are different answers. The first is a schema nothing has read; the second is a database that matches its declaration.

Reference data is how far the declared rows sit from the database, in counts: how many rows the declaration has and the database is missing, how many the database holds with a different value in a managed column, and how many it holds that the declaration no longer has.

It carries no key, no column name and no value. Which rows those are is in the plan, and a data plan contains values, so reading one is data access.

The line appears only when the observation carried a reference-data category. A schema that declares no rows says nothing here, rather than reporting three zeroes about a comparison that never ran.

-o json prints the same view as a document, including referenceData, for a script that wants the counts without parsing text.

Reading a schema needs get on ptahschemas in the namespace and nothing else: no plan object, no ConfigMap, and no Secret.

A PtahMigration runs SQL that is already written. There is no generated plan to print: the statements live in the artifact, and what the operator publishes is which versions it would run, against which reading of the database’s own history.

Terminal window
kubectl ptah migration orders -n application
Migration: application/orders
Phase: AwaitingApproval
Artifact: oci://registry.example/acme/orders-migrations@sha256:...
History read: 2026-08-30T12:00:00Z
Current version:2
Applied: 2
Pending: 1
ApprovalRequired: True (AwaitingApproval)
Plan ptah-mplan-0123456789abcdef01234567, 1 migration from version 2:
3 add the orders index

-o json prints the same view as a document, for a script that wants the pending count or the last run’s outcome without parsing text.

What it never prints is SQL or a table row. A migration plan records versions, descriptions and checksums; reading the statements means reading the artifact, which this command does not fetch. The same is true of reference data: a row this operator writes is never printed back to you here, in the status, or in an Event.

Three states are worth recognizing in the output:

  • Dirty – a failed or interrupted run left a revision row behind. Nothing applies while one exists, and the operator never removes it.
  • Modified – an applied migration’s file no longer accounts for it, and the versions are listed. This is the refusal a versioned workflow exists to make.
  • Last run: Partial – a migration committed some of its statements and not the rest. Retrying the file would run them twice, so the operator stops and the recovery is yours to choose.

Reading a migration needs get on ptahmigrations and ptahmigrationplans in the namespace, and nothing else – no ConfigMap, because there is no chunk store behind it.

Reading a plan is reading three kinds of object in one namespace. No Secret, no Pod log, no pods/exec, no Job, and nothing cluster-wide:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ptah-plan-reader
namespace: application
rules:
- apiGroups: [operator.ptah.run]
resources: [ptahschemas, ptahschemaplans]
verbs: [get]
- apiGroups: [""]
resources: [configmaps]
verbs: [get]

Add list on ptahschemaplans for a schema whose last apply predates the status.applied.planRef field: without the reference the command has to find the plan by the fingerprint the record does carry, and finding it means listing the namespace’s plans. It refuses to guess – no match is an absence, and more than one match is reported rather than resolved by taking the first.

Worth knowing when you are diagnosing the store rather than reading a plan.

A plan document is split into 512 KiB chunks by bytes, up to 8 MiB in total. The split is of the serialized document, so a boundary falls wherever 512 KiB falls – possibly inside a SQL string, inside a JSON escape, or inside a multi-byte character. Each chunk is an immutable ConfigMap; the plan’s spec.chunks binds every one by name, key, index, size and digest, and spec.contentDigest binds the whole document.

Reading one chunk on its own is therefore not reading a plan, and decoding chunks by hand is reproducing bindings that already exist. The command does what the operator does: read every chunk, check each against its binding, concatenate in index order, check the whole against the content digest, and only then parse.