Skip to content

Read a plan

The SQL the operator applies is not in a status field or the manager’s log. It is a document the operator publishes into immutable PtahSchemaPlanChunk objects, bound to a PtahSchemaPlan by index, key, size and digest, and read back only after every one of those bindings has held. The Plan runner first delivers the result over TLS to the receiver, which persists it in confidential PtahResultRecord objects before acknowledging it. The controller validates that stored result before publishing the plan chunks. Plan results stay out of logs describes this default delivery path and the separate legacy log transport. An Apply projects the verified chunks into immutable ConfigMaps for its Pod to mount. Those projections are created only when the Apply is dispatched.

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
Privileges: none
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. Privileges names the kinds of authority the plan records, or says none. A plan that records any waited for an approval even under apply: Always, and Plans that change privileges says what each kind covers. A kind the plugin reads in the SQL that the plan does not record is listed after them, marked (read by this plugin, not recorded): the plugin and the manager that published the plan are different builds, and the manager did not hold the plan for it.

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

A schema read from a ConfigMap or a Git repository prints its source in place of the Artifact line:

Source: Git https://git.example.com/acme/storefront.git
Ref: branch main
Path: db/schema.sql
Commit: 3f1c2b0a9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a
Content: sha256:...
Source policy: sha256:...

Content is the digest of the selected files, the content a plan computed from them is bound to. A ConfigMap source prints Source: ConfigMap with the ConfigMap’s name, and Snapshot, the copy every operation reads. Source policy appears only when the resource names one.

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.

A change the drift report has no category for prints Drift: found, in no category the report counts; the plan says what changes. The report saw a difference and counted nothing, so there is no count and no severity to show; the plan below names the privilege kinds it changes.

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

A ConfigMap or Git source prints the same lines as kubectl ptah schema in place of Artifact.

-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 chunk, because a migration plan stores no SQL of its own.

Reading a plan is reading three kinds of object in one namespace, all of them this operator’s, through one rule. No Secret, no ConfigMap, 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, ptahschemaplanchunks]
verbs: [get]

The rule names no object, so it reads the next plan as well as this one. examples/approver-plan-reader-role.yaml is the same Role with a binding, and the chart’s approver ClusterRole carries the same read.

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 PtahSchemaPlanChunk the plan owns; the plan’s spec.chunks binds every one by name, index, size and digest, status.publishedChunks by UID, and spec.contentDigest binds the whole document.

A plan that was applied is also in the ConfigMaps its Apply Pod mounted: the operator copies the chunks into immutable ConfigMaps of the same names just before it creates the Apply Job, because the Pod holds no Kubernetes credential and the kubelet mounts no custom resource. Those copies are transport, not a second place to read a plan from, and a plan that was never applied has none.

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.