Skip to content

Architecture

This page describes the operator’s components, where they live, and the constraints a change has to preserve. For using the operator rather than changing it, start from Operations.

One rule generates most of the rest: the controller never touches a database, and the process that touches a database never holds a Kubernetes API credential. Almost every boundary below is that rule being enforced somewhere.

Before the components: the shape a platform owner has to plan for, and where to look when something is wrong. Each row is argued for further down the page.

Question Answer
How many installations per cluster? Exactly one. The admission configurations are a fixed singleton, so a second release is refused rather than sharing them.
What does the manager watch? Every namespace. It is a cluster-scoped controller over namespaced resources.
How is it made available? Replicas within that one release. One is the active reconciler through a leader-election Lease; every ready replica serves the webhooks.
What contends with what? Every resource addressing one database, whatever namespace it is in. They take turns through a Lease in one configurable coordination namespace.
What touches a database? Only a Job, one per operation, in the resource’s own namespace. The manager never opens a database connection.
What can call the Kubernetes API? Only the control plane. Operation Pods disable automatic API-token mounting. Their explicit Pod-bound token authenticates result delivery only; its audience must not be accepted by the Kubernetes API. See Plan results stay out of logs.
What stops SQL nobody approved? For each family, a named enforcement point: Execution guarantees.
Where do I look when an Apply is uncertain? The resource’s own status, and A migration run nobody accounted for for the family that cannot resolve it alone.

The failure domains that follow from those answers are not the same size. Losing a manager replica costs a reconciler and no availability. Losing the release costs reconciliation for the whole cluster, while every database keeps whatever the last Apply left it holding. Losing a database costs the resources bound to it and nothing else: the operator’s own state lives in Kubernetes.

Five of them are this project’s rather than Kubernetes’s, and the diagram below uses all five. Each links to the section that goes into it.

Term What it is Where it lives
Operation claim One unit of work a controller has decided to do – its type, a deterministic id, the inputs it was computed from, and the Job it will dispatch. Persisted before the Job exists, so a restart finds the claim rather than a Job nobody expected. status.activeOperation; One operation, end to end
Result frame The structured result document: protocol version, operation identity, child exit code and operation-specific output. By default, the receiver persists it in PtahResultRecord objects before acknowledgment, and a background consumer validates it for the controller. Only legacy delivery writes the frame to stdout. internal/runner, runner.ProtocolVersion; One operation, end to end
Realm The set of resources that can mutate one physical database, whatever URL each of them uses to reach it. Concurrency and coordination
Post-Apply verification Reading the database back after a mutation to confirm what it did, as distinct from what the run claimed. The schema lifecycle, The migration lifecycle
Protected table A table a verification policy refuses to let a plan change, by name. Refusal happens where the plan is computed, so no SQL is produced for it. protectedTables; Declarative reference data

Four identities, and what invalidates each

Section titled “Four identities, and what invalidates each”

They are easy to read as interchangeable evidence and they are not. Each answers a different question, and each is invalidated by a different event.

Identity Answers Invalidated by
Coordination identity Which realm a resource claims – derived from spec.target.engine and either the namespace with spec.target.coordinationKey or the PtahRealm in spec.target.realmRef, and from nothing a credential carries Editing any of those fields, which moves the resource to another realm
Target identity The effective engine, host and port, database, username, and execution-relevant connection parameters derived from the Pod’s resolved database URL Changing a bound value. Password rotation alone leaves it unchanged; replacing a backend behind an unchanged route is outside this identity
Lease epoch Which uninterrupted interval of realm ownership a claim holds The Lease lapsing and being acquired again, which loses continuity and discards any result produced across the change
Execution binding epoch Which set of execution components a plan was computed under – controller-state version, Ptah version, executor image, runner protocol. The manager’s image and revision and the runner image are recorded on plans and Jobs and are not in it Any one of those four changing, which retires the plans computed under the old set. A manager release that changes none of them keeps the epoch. A schema names what the retired epoch left behind – its plan and the Job its claim dispatched – in status.pendingBindingRetirement until both are tidied, and rotates no further until then (Retire an execution binding)

Coordination identity selects the shared lock. Target identity binds the route and SQL session context supplied to the runner; it does not identify a physical database instance. The Lease epoch tracks continuous lock ownership, while the execution binding tracks the components that may run an approved plan.

flowchart LR
  subgraph cp["Control plane"]
    manager["manager<br/>reconcilers + webhooks"]
    receiver["result receiver<br/>in manager"]
    rotator["ptah-cert-rotator"]
    crd["ptah-crd-manager<br/>Helm hooks"]
  end

  subgraph api["Kubernetes API"]
    kinds["PtahSchema, PtahMigration<br/>plans, approvals"]
    results["PtahResultRecord"]
    guards["typed policies<br/>+ webhooks"]
    leases["Leases"]
  end

  subgraph dp["Data plane, one Job per operation"]
    runner["ptah-runner<br/>bounds output"]
    ptah["Ptah executor"]
  end

  registry[("OCI registry")]
  database[("PostgreSQL / MySQL")]

  manager -->|"claims, then creates"| dp
  manager --> kinds
  kinds --> guards
  manager --> leases
  runner --> ptah
  ptah --> registry
  ptah --> database
  dp -->|"result over TLS"| receiver
  receiver -->|"persist before acknowledgment"| results
  results -->|"background read"| manager
  rotator --> guards
  crd --> kinds

  classDef plane fill:#eef,stroke:#88a
  class cp,api,dp plane

The manager decides and records. It creates Jobs and reads persisted results through bounded background consumers. It holds no database credential and has no permission to read the Secret that carries one.

Five programs ship from cmd/:

Program What it is
manager The reconcilers for both resource families, plus the admission webhook server
ptah-runner Runs inside every operation Pod: validates inputs, bounds output, redacts credentials, frames the result
ptah-cert-rotator Issues and replaces the webhook serving certificates
ptah-crd-manager The Helm hook and the runtime verifier: the reconcile that checks the stored state, stops the previous runtime when the manager image changes and updates the CRDs, on install, upgrade and rollback alike, and the init container that holds a manager until its CRDs and admission singleton match
kubectl-ptah A plugin that reconstructs a published plan for a person to read, prints where a schema or a migration stands, what holds it, and the approvals and acknowledgments that name it, creates the approval of a plan a person reviewed, as that person, suspends, resumes and refreshes a resource or points it at another artifact, as that person, and waits for the operator to act

A watch is a cache, and a cache holds whole objects. The controllers register watches for both resource families, their approvals, migration run acknowledgments, realms and the Jobs they own. They also watch ConfigMaps across every namespace, because a changed verification policy has to wake the resources bound to it.

That last watch is the one whose size nobody chooses. It would otherwise hold every application ConfigMap in the cluster and the ConfigMaps each Apply mounts its plan through, up to 8 MiB of SQL per applied plan; forty applied plans is 320 MiB of cached payload against a manager whose default limit is 256 MiB. So the cache empties a ConfigMap as it stores it, keeping the metadata that names it and dropping its data, its binary data and the managed fields that describe them.

Nothing reads those emptied objects. Every ConfigMap this operator acts on – a verification policy, an Apply’s plan projection – is read straight from the API server, because each is a decision a cache may not be current enough to make, and the manager’s client routes ConfigMap reads there as well so that a read added later cannot quietly start seeing an emptied object or build a second cache holding what the first one dropped. The plan chunks themselves are not watched at all, and the client routes their reads to the API server for the same reason: a cached read of one would start a cache of every chunk in the cluster.

PtahSchema answers “what should the database look like?”. It observes, plans against what it found, and converges — including the reference rows a declaration names.

PtahMigration answers “which ordered versions have run?”. It reads the recorded history, selects the pending sequence, and executes it in order.

Each family has three kinds at its core: the resource a person writes, the immutable plan the controller publishes, and the immutable approval a person creates to authorize exactly that plan.

Family Desired state Published plan Decision
Declared schema PtahSchema PtahSchemaPlan PtahSchemaApproval
Versioned migrations PtahMigration PtahMigrationPlan PtahMigrationApproval

A schema plan’s SQL lives in a seventh kind, PtahSchemaPlanChunk: up to sixteen immutable chunks the plan owns and binds by UID, size and digest, so reading a plan takes one RBAC rule on that kind. The migration family has no such kind, because a migration plan carries no SQL: it names the files and their checksums, and the Apply reads them from the verified artifact. See Immutable bindings.

The migration family has an eighth kind for its second decision. PtahMigrationRunAcknowledgment is how a person settles a run the migration recorded in status.unresolvedRun: an Apply whose effect on the database nobody established. Admission stamps who created it, the controller takes it only for the run it names, and the migration’s status.resolvedRun names that person. It replaces a hand-written status update, which needed the manager’s own authority and recorded nobody; the chart now refuses a write to the status subresource of every operator kind from anyone but the manager. See A migration run nobody accounted for.

A ninth kind belongs to neither family. PtahRealm is cluster-scoped and written by an administrator: it names a database that resources in more than one namespace manage, and lists the namespaces allowed to claim it. The manager reads it and never writes it. See Concurrency and coordination.

PtahResultRecord stores immutable intent, payload, and completion records for durable runner result delivery. Results up to 256 KiB commit atomically in one intent containing the exact bytes, digest, and operation binding. Larger results use separate chunks and a completion record. Existing chunked publications retain their layout when retried. Publication checks the complete payload before acknowledgment; a consumer checks the binding, digest, and any chunk identities before using it. The dedicated resource permits result reads without access to database Secrets. Its payloads are confidential and must be excluded from ordinary reader and author roles. Delivery is enabled by default. The receiver authenticates the exact operation and Job/Pod, persists the complete result, and only then acknowledges it. Bounded background consumers load results independently of reconcile workers and Lease renewal. Repeated delivery returns the same receipt without executing SQL again. Diagnostic logs are never a correctness fallback. A leader-only collector preserves active and recovery-pinned records and waits at least one hour after retirement before deleting eligible publications. It runs with delivery on or off, because a record outlives the setting that wrote it. The API admission guard checks current recovery pins, source identity, Job absence, retention age, and dependency order for every deletion. The collector sends UID- and resource-version-bound requests; a denied or unavailable guard retains the evidence. Backup and recovery includes these records and the receiver trust and rotation state.

The manager reconciles the two desired-state kinds, watches the two approval kinds, the run acknowledgments and the realms, and watches the verification-policy ConfigMaps resources point at, so an edited policy is noticed rather than waited out. A realm’s grant is read before every claim: a withdrawn grant refuses the claimant on the pass the change starts, and a new one lifts a standing refusal at that refusal’s re-check, within a minute. It does not watch the plan kinds or the chunks: it writes them, and a plan it wrote tells it nothing it did not already know.

The two families are separate kinds rather than modes of one, because their evidence differs: a schema plan binds an observed state and a desired state, and a migration plan binds a version sequence, per-migration checksums and the history it was computed against. One kind carrying both would make every field conditional on which controller wrote it, and would make approval and status ambiguous.

What they genuinely share is the mechanism, not the model: OCI tag resolution and digest pinning (internal/ocireference), registry authentication and custom transport trust, artifact verification and type enforcement (internal/policy), Job construction (one workload.Builder, with a single Pod skeleton that owns the hardening and the annotation envelope), result framing (internal/runner), coordination realms and Leases (internal/targetlock), and the route identity guard.

This page is the shape. Each contract has one home, and the page that owns it is the one to change when the contract changes.

Page Owns
Reconciliation Both lifecycles, phase by phase: what each operation is for, what blocks, what suspends
Execution and coordination One operation from claim to evidence, the durable claims, and how two resources share a database
Plans and approvals What a plan binds, how its bytes are stored, what a decision authorizes, and declared rows
Credentials and admission Which process holds which credential, and what the admission contract refuses
Mutation lifecycle The obligations a mutating operation carries, and where each family enforces them
Release lifecycle Install, upgrade, rollback and uninstall, and the certificate handoffs inside them
Execution guarantees Each promise mapped to the enforcement point in each family, and the test that proves it
Concept Package
Reconcilers for both families internal/controller
What each operation type is, the dispatch sequence, what retiring a claim releases internal/mutationlifecycle
Whether a Job is the one its claim built internal/jobclaim
Bounded Job and Pod construction internal/workload
Result framing, redaction, input validation internal/runner
Machine-readable data-plane contracts internal/dataplane
Canonical content identities internal/fingerprint
Plan publication into immutable chunks, and an Apply’s projection of them internal/planstore
Plan byte-size contract internal/plancontract
Migration plan derivation and naming internal/migrationplan
Approval and run-acknowledgment admission internal/admission
Which part of a spec is desired state: all of it but spec.suspend, for admission and kubectl ptah approve internal/desiredspec
The manager’s own write guard internal/controllerwrite
Pod admission snapshots internal/podintent
Verification policy binding internal/policy
OCI reference parsing and pinning internal/ocireference
Database realm Leases internal/targetlock
Durable manager-state contract internal/controllerstate
Certificate lifecycle internal/certrotation
CRD, RBAC and release lifecycle internal/crdupgrade
Reading a stored plan back, for kubectl ptah and other programs pkg/planread
Read-only views behind kubectl ptah internal/planview, internal/schemaview, internal/migrationview, internal/sourceview, internal/statusview
The approval behind kubectl ptah approve, bound to the plan a person reviewed internal/approve
Suspend, resume, refresh, set-source and wait behind kubectl ptah, and what became of a change internal/operate
How a plugin command reads the API server’s refusal of its write internal/apirefusal
Bounded-cardinality metrics internal/telemetry
What the manager caches from a watch internal/managercache
How fast a manager process calls the API server internal/apipace

Unit tests measure this code. The envtest suites under test/envtest measure the API contract against a real kube-apiserver and etcd: the CRDs’ schemas and CEL rules, every ValidatingAdmissionPolicy the chart installs together with its binding, and the manager’s webhooks behind the chart’s own webhook configurations. Each policy is held to a request it refuses, the write it must admit from the identity that makes it, and a mutation of its binding, its match or the release values it carries under which those rows have to fail. They do not measure what Kubernetes would do: envtest runs no built-in controllers, so nothing there reconciles a Deployment into Pods or garbage-collects by owner reference.

What proves the claims on this page is the acceptance matrix, one job per supported Kubernetes minor and suite, against kind with a real registry, PostgreSQL and MySQL. support/e2e-suites.json is the single place the suites and their phases are written down. make acceptance-coverage lists each supported minor and suite, its phases, resource families, engine inputs and scenarios, and the CI job that supplies its evidence. The strict Kubernetes support gate is what says they passed, and it fails on a skipped job, an incomplete matrix or a missing result rather than reporting green. support/databases.json declares the database release lines, and the Added database lines workflow runs the same jobs on the ones an ordinary run leaves out.