paths, self-registration bootstrap)
Replaces the bring-your-own-only Stage 1 (commit 1be7cf2) wholesale, per
the redesign in the previous two commits: the operator creates every
server it manages, so self-registration (DESIGN.md §6) is the only
bootstrap path, and Deployment/Service/database management builds
together with it (ROADMAP.md Stage 1) rather than behind a separate
later stage.
Grounded in terdut-server's actual chart (charts/terdut-server/templates/
deployment.yaml, values.yaml), not reconstructed from DESIGN.md's
illustrative YAML alone -- env var names, the password-via-PGPASSWORD
convention, the Recreate deployment strategy, /healthz probes, and the
TERDUT_OPERATOR_MODE=true decision (always on here, unlike the chart's
default-off: every write this operator's own future controllers make
goes through a service account already) all match that source exactly.
- api/v1alpha1: full TerdutServerSpec (image, replicas, networking,
database, sweeper, deadman, notify, oidc, passwordLogin, allowedTeams).
spec.database is a oneOf (dsn xor postgresClusterRef) via CEL
XValidation. No spec.credentialsSecretRef -- removed entirely in the
prior redesign commit, not carried forward.
- internal/controller:
- terdutserver_deployment.go: Deployment + Service via CreateOrUpdate,
owned (OwnerReference), env built field-for-field against the chart.
- terdutserver_database.go: both §8 paths. The Zalando path resolves
the postgresql.acid.zalan.do CR by convention (database/role both
"terdut", matching every DESIGN.md example) and only ever confirms
its generated credentials Secret exists -- never reads the value,
same "wire a secretKeyRef, don't read it" posture the DSN path takes.
classifyClusterGetError is its own function specifically so the
CRD-not-installed case (meta.IsNoMatchError) is unit-testable without
a real client.
- terdutserver_bootstrap.go: self-registration, checkpointed against
both real crash windows (DESIGN.md §6 point 1) -- an admin-key
checkpoint Secret, and adopt-via-GET+mint-new-key on a 409 from
creating the service account. BootstrapStateLost is its own error
type so Reconcile can route it to a condition instead of an infinite
retry.
- terdutserver_controller.go: ties it together -- finalizer add, DB
resolution, Deployment/Service reconcile, wait for a ready replica,
bootstrap, Ready/Bootstrapped/DatabaseReady conditions. Finalizer on
delete only removes the generated Secrets: terdut-server's API can't
delete a user or service account, only revoke keys, so there's
nothing server-side to undo.
- internal/tdclient: added Bootstrap, CreateInstanceServiceAccount,
GetServiceAccountByName, CreateServiceAccountKey, matching
terdut-server's real handlers' request/response shapes (internal/api/
users.go, service_accounts.go in that repo) field-for-field.
- Tests: envtest suite covering the full DSN-path lifecycle end to end
(finalizer -> Deployment/Service -> simulated readiness -> real
bootstrap against an httptest.Server fake), the adopt-on-409 recovery
path, BootstrapStateLost, both Zalando outcomes (cluster not found;
cluster + Secret found -> real DSN -> Ready), and deletion. A minimal
test-only stub of the Zalando CRD (internal/controller/testdata) lets
envtest create fixture objects without a real postgres-operator
installed. 74.0%/44.7% coverage, 0 lint issues.
- Two things scoped down from §8's full ambition, called out in code and
ROADMAP.md rather than silently dropped: no live watch on the
Zalando-generated Secret for rotation (periodic resync notices
eventually, not immediately), no Gateway API HTTPRoute creation from
spec.networking (would add a new dependency; nothing about proving
bootstrap works depends on external ingress existing). Both are
near-term follow-ups.
Verified locally: make fmt lint test build all clean.
8.0 KiB
terdut-operator build roadmap
This is the staging plan for implementing the operator against DESIGN.md's
settled decisions. It exists for the same reason DESIGN.md and
SERVICE-ACCOUNTS.md do: so each stage starts from an agreed sequencing
instead of re-litigating "what do we build first" mid-PR.
Sequencing call this roadmap makes
The operator creates and owns every TerdutServer it manages — it never
adopts one deployed independently, by hand or by charts/terdut-server
(DESIGN.md §1). An earlier version of this roadmap staged TerdutServer's
Deployment/Service/bootstrap takeover separately (old Stage 5), behind a
hand-deployed server the simpler CRDs could be proven against first.
That staging existed only because a credential-less operator couldn't
/api/bootstrap its way into a server something else had already
bootstrapped (DESIGN.md §6's original gap). With no server to adopt at
all, that split has nothing left to justify it: TerdutServer now builds
its full lifecycle — Deployment, Service, database wiring, bootstrap,
credentials — in one stage, Stage 1, since bootstrap only has something to
bootstrap once the Deployment exists.
Stage 0 — Scaffolding & CI
go.mod(git.ryuvia.com/niklas/terdut-operator) + Kubebuilder v4 scaffold (cmd/main.go,config/,Makefile,PROJECT), matching terdut-server's Go toolchain and house style (§3). Kubebuilder's own scaffoldedMakefilealready wiresmanifests/generate(controller-gen) andsetup-envtestintotest, andgolangci-lintintolint, all fetched on demand intobin/— no separate install step needed beyond whatmake test/make lintalready do..release.confdeliberately not added yet: it names aHELM_CHARTthis repo doesn't have until Stage 5. Adding it now would either be a stub that lies about what's releasable or dead config nobody can run — it lands in Stage 5, alongside the chart it describes.- Gitea Actions CI calling
fmt lint test, mirroring terdut-server'sci.yamlconvention (itsCLAUDE.md: "a green gate here and a green pipeline are the same code, not two descriptions of it") minus thechart/securityjobs, which need a chart (Stage 5) and real controller code (Stage 1+) respectively to have anything to check. - Drop kubebuilder's default
.github/workflows/*scaffold — this org runs on Gitea, not GitHub;.gitea/workflows/ci.yamlis the only CI this repo has. - Housekeeping: drop the stray
.DESIGN.md.swp(leftover vim swapfile, shouldn't be committed); correctDESIGN.md§6/§13's "v1-blocking, not v1-shippable" language — the service-account feature it was blocking on has since shipped in terdut-server.
Done when: CI is green on an otherwise-empty scaffold.
Stage 1 — TerdutServer, full lifecycle
Supersedes the Stage 1 shipped before this redesign (commit 1be7cf2)
outright — that TerdutServerSpec/Status/controller/tests implemented the
now-removed bring-your-own path and get replaced wholesale, not extended.
New commits build forward over the old ones; no git history rewrite.
- Full §4.1 spec:
image,replicas,networking,database,sweeper,deadman,notify,oidc,passwordLogin,allowedTeams, all together — no narrowing, since bootstrap needs the Deployment it's narrowed away from in the version this replaces. - Controller manages the Deployment + Service, both Postgres paths from §8
at once (bring-your-own DSN and the Zalando
postgres-operatorpostgresClusterRefintegration — not sequenced, per the user's call), and bootstrap/credentials per §6's self-registration flow:/api/bootstraponce the Deployment has a ready replica, checkpoint the admin key, mint the instance-scoped service account, generated credentials Secret in the operator's own namespace,status.credentialsSecretRef. Finalizer cleans up that Secret (and the checkpoint, if one's still there) on delete — there's no server-side row to clean up alongside it: terdut-server's API has no way to delete a user or a service account, only to revoke individual keys, so there's nothing to undo there regardless. - RBAC: read-only watch on
postgresql.acid.zalan.do, degrading gracefully if that CRD isn't installed (§8, §9). - Shipped, scoped down from §8's full ambition in two ways, both called out
in code rather than silently dropped: no live watch on the Zalando-
generated credentials Secret for rotation (relies on the periodic resync
to notice eventually, higher latency than a watch); no Gateway API
HTTPRoutecreation fromspec.networking.hostname/gatewayListener(needs the Gateway API types as a new dependency, and nothing about proving aTerdutServerboots and bootstraps a real server depends on external ingress existing). Both are near-term follow-ups, not deferred to a later stage. envtestcovering Deployment/Service reconciliation and both database paths — the Zalando path needs that CRD's schema vendored into the test environment (there's no realpostgres-operatorcontroller inenvtest, only the CRD shape to create fixture objects against) — plus the self-registration flow against anhttptest.Serverfake of/api/bootstrapand/api/service-accounts(§11), including the adopt-on-409 recovery path and the one fail-closed case (BootstrapStateLost), not just the happy path.- A real
kindend-to-end pass is still open (bring-your-own DSN is simplest there) to prove aTerdutServerCR actually produces a running, bootstrapped terdut-server pod against a real cluster, not just envtest's fake API server. The Zalando path can additionally be validated for real against the org's own cluster later, wherepostgres-operatoralready runs, rather than only in a disposablekindstand-in.
Stage 2 — TerdutTeam
- §4.2:
serverRefresolution, real update-in-place (POST create / PUT rename / PUT oidc-groups), team-scoped service-account minting onceReady(§6 point 3), finalizer that DELETEs the team server-side and its credential Secret. - First place the "every child resolves its own
teamRef→TerdutTeam.status, never chains up toTerdutServer" pattern (§5) gets proven end to end.
Stage 3 — TerdutEscalationRule + TerdutDeadmanSwitch
- Built together: both stay same-namespace-as-their-
TerdutTeam(§1), so neither exercises cross-namespace complexity, but together they cover the two different reconciliation shapes §5's table calls out — whole-policy PUT-on-drift for the escalation policy, delete-and-recreate (no PUT available) for the dead man's switch — against the same shared create/finalizer/resync scaffolding Stage 2 already built.
Stage 4 — TerdutAlertSource
- Last of the children on purpose: it has the subtlest failure mode of the
four. Covers webhook Secret generation/ownership (§4.5, §7), the
WebhookSecretLostfail-closed condition +Warningevent (§5, added 2026-09-30), and the kind-change delete-and-recreate rotation path — all easier to get right with the other three controllers' patterns already in place to build on.
Stage 5 — Installer chart + real release
- Package CRDs + the operator's own Deployment/RBAC into the installer
chart §10 describes; wire
.release.conf/release-vars the same way terdut-server does; run it through thereleaseskill for a real first cut. - Full
kindend-to-end test per §11: createTerdutServer→TerdutTeam→ one of each child kind → verify via terdut-server's own API that each object exists with the right shape → delete the CR → verify the server-side object is gone.
Deferred (§13, unchanged by this roadmap)
Cross-namespace allowedTeams exercised against a real second namespace,
CloudNativePG support, narrower-than-namespace Secret RBAC for the webhook
Secret, gitops-managed team membership, automatic Deployment restart on
upstream Postgres credential rotation, admission webhooks/CEL-only
validation limits, OLM packaging.