Authenticate with a seeded operator key; fold escalation and switches into TerdutTeam

Credentials: the TerdutServer controller generates <name>-operator-key in
the server's own namespace (owned by it) and hands it to the pods as
TERDUT_OPERATOR_KEY; the server creates its instance-scoped account from it
at every start. A replaced Secret rolls the pods. The bootstrap handshake,
the checkpoint Secret, per-team service accounts and credentials Secrets,
BootstrapStateLost and credentials.deletionPolicy are gone.

CRDs: TerdutServer, TerdutTeam and TerdutAlertSource. TerdutEscalationRule
and TerdutDeadmanSwitch become spec.escalation and spec.deadmanSwitches[]
on the team (matched by name, extras removed); team invites are removed.
A team is created under the identity <namespace>/<name> (external_id), so a
retry, a lost status or a deleted team heal by repeating the same call, and
a display name owned by another team is TeamNameTaken instead of an
adoption. The server resolves escalation usernames (UnknownUser condition).
OIDC claim names and trustEmail are spec fields.

Fixes: query values are URL-escaped; every delete treats 404 as success;
deleting a team no longer depends on allowedTeams consent; a switch or
integration deleted on the server is recreated; unnamed switches take the
CR's name.

Cleanup: scaffold e2e test, AGENTS.md, devcontainer, unused config/ pieces
and Client.Version() removed; DESIGN.md, README, ROADMAP and the demo
(run-demo.sh, manifests) rewritten for the new design. Secret RBAC stays
cluster-wide, now stated in DESIGN.md section 9.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-09 14:56:22 +02:00
parent b0a431f2a4
commit e1103f2b7d
92 changed files with 2082 additions and 7561 deletions
+180 -616
View File
@@ -1,146 +1,83 @@
# terdut-operator design
This is the design reference for implementing terdut-operator. It exists so
implementation can start from settled decisions instead of re-litigating them
mid-PR. It is deliberately more detailed than the README; the README stays as
the short pitch and now points here instead of carrying open questions.
Written against terdut-server as of the Postgres-only, per-team-resources
version (teams, escalation policies, dead man's switches and integrations are
all rows scoped to a team, managed over `internal/api/*` — not env/config-file
driven; see that repo's `charts/terdut-server` for the current deploy story
this operator supersedes).
The design of terdut-operator as built. terdut-server is the system of record; this operator
makes one install of it — the server, its teams, their escalation ladders, dead man's switches
and alert-source integrations — describable as Kubernetes objects and manageable through gitops.
## 1. Goals & non-goals
**Goal:** let a terdut-server install — the server itself, its teams, their
escalation policies, dead man's switches and alert-source integrations — be
fully described as Kubernetes objects and managed through gitops, following
**Goal:** a terdut-server install fully described as Kubernetes objects, following
controller-runtime / Kubebuilder conventions.
**The operator creates and owns every `TerdutServer` it manages. It never
adopts a pre-existing, independently-deployed terdut-server** — whether
deployed by hand or by `charts/terdut-server`. There is no migration path
from an existing chart-based install, and none is planned (§10): starting
with the operator means applying a fresh `TerdutServer` CR, not converting
one. This is the root a few things downstream hang off of — notably §6's
bootstrap flow, which only has to handle the operator bootstrapping a server
it just created, never a server something else already bootstrapped first.
**The operator creates and owns every `TerdutServer` it manages. It never adopts a pre-existing,
independently-deployed terdut-server**, whether deployed by hand or by `charts/terdut-server`.
There is no migration path from a chart-based install (§10): starting with the operator means
applying a fresh `TerdutServer`.
**Non-goals (v1):**
- Not a general-purpose Postgres operator. It *consumes* a database that
either the Zalando `postgres-operator` or something else already provides.
- Not managing Alertmanager itself, or the routing rules that decide which
alerts reach which integration webhook — only the terdut-server side
(creating the integration and handing back its URL/key).
- Not OLM packaging. Plain Kubebuilder manifests + Helm chart for
install, matching how terdut-server itself ships.
- Cross-namespace references are limited to exactly one edge:
`TerdutTeam.spec.serverRef` may name a `TerdutServer` in a different
namespace, gated by that `TerdutServer`'s own `spec.allowedTeams` consent
field (§4.1, §4.2) — this is the multi-tenant shape the operator exists
for (one platform team owns a `TerdutServer`; other teams self-service a
`TerdutTeam` against it without needing write access to the server's
namespace). Every other reference (`teamRef` on the escalation
rule/dead-man-switch/alert-source CRDs) stays same-namespace-as-its-`TerdutTeam`
only, in v1 — those manage a specific team's own resources and are
expected to live alongside it.
- No validating/mutating admission webhooks in v1. CEL validation rules on
the CRDs (OpenAPI `x-kubernetes-validations`) cover what they can; anything
that needs a live look at another object (e.g. "does this teamRef exist")
is a status condition, not an admission rejection — keeps v1 to a
controller-only deployment with no cert-manager/webhook dependency.
- **Never manages external exposure/ingress for `TerdutServer`, in any
form — a permanent non-goal, not a staged one.** The operator creates a
plain `ClusterIP` Service (§4.1) and stops there: no `Ingress`, no
Gateway API `HTTPRoute`, no Istio `VirtualService`, nothing. Some
installs won't expose `TerdutServer` outside the cluster at all; others
will use whichever of those mechanisms already fits their cluster. That
choice belongs to whoever deploys it, not to this operator — see
`examples/networking` for worked (but not operator-managed) examples.
- Not a Postgres operator. It consumes a database that the Zalando `postgres-operator` or
something else provides (§8).
- Not managing Alertmanager or its routing, only the terdut-server side (creating the
integration and handing back its URL/key).
- Not OLM packaging: plain Kubebuilder manifests and a Helm chart, like terdut-server.
- Cross-namespace references are limited to one edge: `TerdutTeam.spec.serverRef` may name a
`TerdutServer` in another namespace, gated by that server's `spec.allowedTeams` (§4.6). A
`TerdutAlertSource` lives beside its `TerdutTeam`.
- No admission webhooks. CEL validation covers what it can; anything needing a live look at
another object is a status condition, not an admission rejection.
- **Never manages external exposure for a `TerdutServer`** (Ingress, HTTPRoute, VirtualService) —
a permanent non-goal. The operator creates a plain `ClusterIP` Service; see `examples/networking`.
## 2. The README's open questions, resolved
## 2. Credentials and CRD shape (the 2026-10 redesign)
> How do team-crd connect with server-crd?
What the design is, and why — each point replaced something heavier:
Explicit `spec.serverRef: {name, namespace}` on `TerdutTeam` — same reasoning
as before (explicit, greppable, trivially validated), but **`namespace` is
deliberately part of the reference**: one team can run and own a
`TerdutServer`, and other teams — in their own namespaces, without any write
access to the server-owning team's namespace — self-service a `TerdutTeam`
against it. `namespace` defaults to the `TerdutTeam`'s own namespace when
omitted, so the common single-tenant case (`serverRef: {name: terdut}`) is
unchanged.
- **No bootstrap handshake.** The operator generates a key into a Secret named
`<TerdutServer>-operator-key`, in the TerdutServer's own namespace and owned by
it (a pod can only mount Secrets of its own namespace, and an owner reference
replaces the old finalizer and `credentials.deletionPolicy`). The Deployment
hands it to terdut-server as `TERDUT_OPERATOR_KEY`; the server creates or
re-keys its instance-scoped service account `terdut-operator` from it at every
start. A replaced Secret rolls the pods (key-hash annotation). `/api/bootstrap`
stays free for the first human administrator. Gone with it: the checkpoint
Secret, `BootstrapStateLost`, `DatabaseReady`/`Bootstrapped` conditions.
- **One credential per server.** An instance-scoped account acts as owner of every
team's configuration (not a member, so it reads no incidents). The per-team
service accounts, Secrets and `status.credentialsSecretRef`/`serverEndpoint` on
`TerdutTeam` are gone.
- **Team identity is `external_id`.** The operator creates a team with
`external_id: <namespace>/<name>` of its CR; the server returns the existing team
for a known id (200) instead of creating one, so crash recovery, a lost status
and a deleted team all heal by repeating the same call, and a display name that
belongs to another team is a 409 (`TeamNameTaken`) instead of an adoption.
- **Three CRDs.** `TerdutServer`, `TerdutTeam` and `TerdutAlertSource`.
`TerdutEscalationRule` and `TerdutDeadmanSwitch` are `spec.escalation` and
`spec.deadmanSwitches[]` (matched by name, unique per team server-side; ones not
listed are removed) on the team: they were one-to-one children with the team's
lifecycle, and folding them removes `teamRef`, the two-rules-clobber footgun and
three controllers. `TerdutAlertSource` stays separate because it owns a webhook
Secret in its own namespace.
- **Usernames are resolved by the server** (`PUT .../escalation` accepts
`username`), so an unknown user is a `UnknownUser` condition, not a list-and-match.
- **Invites are removed.** Membership is not modelled; people get in through the
server's own signup/OIDC.
- **Env mirrors the chart's where it matters:** OIDC claim names and `trustEmail`
are spec fields; `TERDUT_DEADMAN_*` no longer exist on the server.
This cross-namespace edge needs the target namespace's explicit consent —
otherwise any namespace in the cluster could point a `TerdutTeam` at
someone else's `TerdutServer` and have the operator provision a team on
its behalf, which is a namespace-boundary violation, not a gitops
convenience. (This consent gate is about which `TerdutTeam`s the operator
will act on, not about credential exposure — no terdut-server credential
is ever placed in a `TerdutTeam`'s own namespace regardless of this
setting; see §6.) Kubernetes has two established patterns for this kind
of consent, and Gateway API itself uses both, for two different
relationships:
## 3. CRD catalog
- **`ReferenceGrant`** (used for a Route reaching into an arbitrary
Service/Secret): a separate object, living in the *target* namespace,
enumerating exact `{fromNamespace, fromKind} → {toKind, toName}` pairs.
No wildcard, no selector — every permitted namespace is spelled out.
- **An inline field on the parent** (used for a `ListenerSet` attaching to
a shared `Gateway`, GA since Gateway API v1.5): the parent carries
`spec.allowedListeners.namespaces: {from: None|Same|All|Selector,
selector}` directly, no separate CRD.
Group `terdut.ryuvia.com`, version `v1alpha1`; module `git.ryuvia.com/niklas/terdut-operator`.
`TerdutTeam` attaching to a shared `TerdutServer` is structurally the
second case, not the first — a bounded set of expected children attaching
to a parent they were deliberately made shareable, not an arbitrary
backend reference — so this design follows the `ListenerSet` precedent:
`TerdutServer.spec.allowedTeams` (§4.1), no extra CRD. §4.2 covers how
`TerdutTeam` resolves against it. No other reference in this design
(`teamRef` on the child CRDs) crosses a namespace boundary, so this is the
only place cross-namespace consent is needed at all (§1, §5, §6, §9).
> How does escalationrules, switches and alertsources connect to a team?
Explicit `spec.teamRef: {name}` on each of `TerdutEscalationRule`,
`TerdutDeadmanSwitch`, `TerdutAlertSource` — same reasoning, and it mirrors
terdut-server's own data model, where every one of these rows carries a
`team_id` foreign key already. A matcher/selector on `TerdutTeam` would be
inventing a second source of truth for an association the server already
models as a plain reference.
> Support for both postgres-operator (Zalando) and bring-your-own, how do we
> design that to be user friendly?
`TerdutServer.spec.database` is a oneOf, mirroring the chart's existing
`database.dsn` / `database.passwordSecret` contract (see §8):
- `dsn` + `passwordSecretRef` — bring-your-own, exactly today's chart inputs.
- `postgresClusterRef` — points at a Zalando `postgresql.acid.zalan.do` CR;
the operator derives the DSN and resolves the generated credentials Secret
itself (see §8). CloudNativePG support is a natural follow-up using the
same shape and is called out as deferred (§13), not designed in detail now.
## 3. API group, versions, CRD catalog
- Group: `terdut.ryuvia.com`, version: `v1alpha1` (matches the `ryuvia.com`
domain terdut-server already uses; bump to `v1beta1`/`v1` per the normal
Kubernetes API graduation criteria once the shapes below have proven
stable against a real install).
- Module: `git.ryuvia.com/niklas/terdut-operator`, scaffolded with
Kubebuilder (controller-runtime), matching terdut-server's Go toolchain
and house style.
| Kind | Scope | Purpose |
|---|---|---|
| `TerdutServer` | Namespaced | One terdut-server install: Deployment, Service, database wiring, bootstrap, operator credentials, cross-namespace team consent. |
| `TerdutTeam` | Namespaced | One team on a `TerdutServer`, possibly in another namespace: name, OIDC group mapping. |
| `TerdutEscalationRule` | Namespaced | A team's escalation policy (levels, targets, repeat). |
| `TerdutDeadmanSwitch` | Namespaced | One dead man's switch on a team. |
| `TerdutAlertSource` | Namespaced | One alert-ingest integration on a team (currently: Alertmanager webhook). |
| Kind | Purpose |
|---|---|
| `TerdutServer` | One terdut-server install: Deployment, Service, database wiring, operator key, cross-namespace team consent. |
| `TerdutTeam` | One team on a server, possibly in another namespace: name, OIDC groups, escalation ladder, dead man's switches. |
| `TerdutAlertSource` | One alert-ingest integration on a team; owns a webhook Secret. |
## 4. Per-CRD spec
(The escalation and dead man's switch specs that used to be §4.3 and §4.4 are part of §4.2; the section numbers are kept because code comments cite them.)
### 4.1 `TerdutServer`
```yaml
@@ -165,12 +102,6 @@ spec:
sweeper:
staleAfter: 6h
archiveAfter: 168h
deadman:
matchers: "alertname=Watchdog"
timeout: 15m
severity: critical
credentials:
deletionPolicy: Retain # Retain (default) | Delete -- what deleting this CR does to the instance credential Secret; see §6 point 2
notify:
ntfyURL: "http://ntfy.ntfy.svc.cluster.local"
fallbackTopic: ""
@@ -185,6 +116,8 @@ spec:
scopes: "openid profile email"
allowedGroups: []
adminGroup: ""
usernameClaim: preferred_username # also emailClaim, groupsClaim
trustEmail: false
sessionMaxAge: 12h
passwordLogin: true
# Consent for TerdutTeams in OTHER namespaces to set serverRef at this
@@ -212,10 +145,9 @@ spec:
disruptionBudget:
minAvailable: 1 # mutually exclusive with maxUnavailable
status:
conditions: [...] # Ready, DatabaseReady, Bootstrapped
conditions: [...] # Ready
observedGeneration: 3
serviceName: terdut
credentialsSecretRef: {name: terdut.platform-oncall-instance-credentials, key: token} # see §6; pure output -- generated by the controller's own self-registration flow, always in the OPERATOR's namespace (always, implicitly -- not stored here, since it's never anything else), under a fixed key ("token").
credentialsSecretRef: {name: terdut-operator-key, key: token} # see §6; pure output, in this TerdutServer's own namespace
```
Field-for-field this is the chart's `values.yaml` reshaped as a spec — not
@@ -260,89 +192,37 @@ same way as `spec.database`'s own `dsn`/`postgresClusterRef` rule.
```yaml
spec:
serverRef:
name: terdut
namespace: platform-oncall # optional; defaults to this TerdutTeam's own namespace.
# Cross-namespace requires that TerdutServer's spec.allowedTeams
# (§4.1) to admit this namespace — otherwise Ready: False, reason: RefNotPermitted.
displayName: "Platform" # -> POST /api/teams {"name": ...}; server assigns the ID
oidc:
memberGroup: "terdut-platform-members"
ownerGroup: "terdut-platform-owners"
serverRef: {name: terdut, namespace: platform-oncall} # namespace optional; cross-namespace needs allowedTeams
displayName: Platform
oidc: {memberGroup: terdut-platform-members, ownerGroup: terdut-platform-owners}
escalation:
repeatCount: 2
fallbackTopic: platform-oncall
levels:
- timeout: 5m
targets: [{kind: oncall}, {kind: user, username: alice}]
- timeout: 15m
targets: [{kind: oncall}]
deadmanSwitches:
- {name: watchdog, matcher: "alertname=Watchdog,cluster=prod", timeout: 15m, severity: critical}
status:
conditions: [...]
teamID: 42 # the server-side ID; needed by every child object's controller
credentialsSecretRef: {name: platform-oncall.platform-team-credentials, key: token} # see §6; this team's own scoped key, always in the OPERATOR's own namespace (not stored here — same reasoning as TerdutServer's, §4.1)
serverEndpoint: "http://terdut.platform-oncall.svc:8080" # resolved once, here, from spec.serverRef -- see §5: this is what actually makes "a child never needs to chain up to TerdutServer" true, not just a stated intent. Set alongside teamID/credentialsSecretRef, same reconcile.
conditions: [...] # Ready; reasons: ServerRefNotFound, RefNotPermitted, WaitingForServer,
# TeamNameTaken, UnknownUser, InvalidSpec, Adopted
teamID: 42
observedGeneration: 1
```
Team *membership* (which users belong, `team_members`) is explicitly **not**
modeled as a CRD field in v1: terdut-server already manages membership via
OIDC group sync at login for SSO installs, and manual membership for
password-login installs is a people-management action, not infrastructure —
forcing it through gitops would mean a human's team change goes through a PR
review. Flagged in §13 as revisitable if a real gitops-membership need shows up.
The team is created under the identity `<namespace>/<name>` of its CR (`external_id`), so a retry,
a lost status or a team deleted behind the operator's back all heal by repeating the same call; a
`displayName` that belongs to a different team is `TeamNameTaken`, never an adoption. Every
reconcile applies, in order: create-or-find, rename, OIDC groups, the escalation ladder (one PUT
of the whole ladder; absent `spec.escalation` clears it), and the switches. Switches are matched
by name (unique per team on the server): missing ones are created, changed ones updated in
place, and any not listed are deleted, since in operator mode nobody else can add one. Durations
are validated by a CRD pattern and re-checked (`InvalidSpec`). A username the server does not
know is `UnknownUser` until that person exists.
### 4.3 `TerdutEscalationRule`
```yaml
spec:
teamRef: {name: platform-team}
repeatCount: 2
fallbackTopic: "platform-oncall"
levels:
- timeout: 5m
targets:
- kind: oncall # "oncall" or "user"
- kind: user
username: alice # resolved to a user ID by the controller at apply time
- timeout: 15m
targets:
- kind: oncall
status:
conditions: [...]
observedGeneration: 1
```
One `TerdutEscalationRule` per team — the server itself models a policy as
one row (`escalation_policies`) with an owned list of levels, so a
one-CRD-to-one-policy mapping (not one-CRD-per-level) matches the server's
own aggregate and lets the whole thing be reconciled with the single
`PUT /api/teams/{teamID}/escalation` the API actually exposes (see §5). Not
enforced at admission (no webhooks in v1, §1) — two `TerdutEscalationRule`s
naming the same team would both `PUT` it and clobber each other every
reconcile; a footgun worth this one sentence, not a technical guard.
A `username` target is resolved to the `user_id` `PUT /api/teams/{teamID}/escalation`
actually requires (confirmed against source: `escalationTargetJSON.UserID
*int64`, no username field at all) via `GET /api/users` — confirmed open to
any authenticated caller, not gated by team membership or admin
(`internal/api/router.go`'s own comment: "readable by anyone signed in"), so
the team-scoped credential this controller already holds is enough; no
extra RBAC-equivalent server-side needed. Re-resolved every reconcile rather
than cached, in case a username is renamed. An unresolvable username is
`Ready: False, reason: UnknownUser`, naming which one.
### 4.4 `TerdutDeadmanSwitch`
```yaml
spec:
teamRef: {name: platform-team}
name: "prod-watchdog"
matcher: "alertname=Watchdog,cluster=prod"
timeout: 15m
severity: critical
status:
conditions: [...]
switchID: 7
```
No unique-name constraint server-side (§5's table row, confirmed against
source) — the controller's own idempotent-create step is a `GET`-list and
name match, not a conflict to recover from. `name` is optional, same as the
API: left empty, terdut-server derives it from `matcher`'s own canonical
form, and that's what the lookup matches against too.
Team *membership* is not modelled: the server manages it through OIDC group sync and its own UI.
### 4.5 `TerdutAlertSource`
@@ -359,7 +239,7 @@ status:
The integration key is shown by the API exactly once, at creation
(`Integration.Key`/`URL` in terdut-server's own model) — never re-readable,
same shape as the bootstrap admin key. The controller writes it straight into
a one-shot value. The controller writes it straight into
a generated, owner-referenced Secret on the create it caused and never logs
or stores it anywhere else; the CR's `status` carries only the Secret
reference, matching how e.g. cert-manager's `Certificate` exposes
@@ -379,8 +259,7 @@ of both patterns).
`serverRef` into this `TerdutServer`. Same-namespace `TerdutTeam`s are
always allowed regardless of this field.
- `from: Same` — equivalent to `None` in effect (same-namespace is already
unrestricted) but kept for parity with the upstream enum and to make the
policy self-documenting in a diff.
unrestricted); kept for parity with the upstream enum.
- `from: All` — any namespace in the cluster may reference in. Appropriate
for a genuinely shared, cluster-wide `TerdutServer`; the audit trail is
"check `allowedTeams` plus who has RBAC to create a `TerdutTeam`
@@ -403,280 +282,64 @@ of both patterns).
it used to authorize finds itself no longer permitted, flips
`Ready: False, reason: RefNotPermitted`, and — deliberately — does
**not** delete the team server-side on revocation alone; it stops
reconciling further changes until access is restored or the `TerdutTeam`
CR itself is deleted (whose finalizer still needs the credentials
Secret described in §6 to clean up, so blocking *new* changes rather
than forcing an immediate, possibly credential-less deletion is the
safer failure mode).
reconciling further changes until access is restored. Deleting the
`TerdutTeam` CR still deletes the team: consent gates what the operator
starts acting on, not whether it may clean up after itself.
## 5. Reconciliation semantics
terdut-server's REST surface (`internal/api/router.go`) does not give every
resource a full update verb, so reconciliation strategy is per-resource:
| Resource | Verbs available | Strategy |
| Resource | Server verbs | Strategy |
|---|---|---|
| Team | POST create, PUT rename, DELETE, PUT oidc-groups, GET by name | Real update-in-place: diff spec vs. last-applied, PUT the changed pieces. `GET /api/teams?name=` (terdut-server's `TEAM-LOOKUP.md`, landed 2026-10-01) is what makes the idempotent-create general rule below actually true for Team — confirmed by checking: until that endpoint existed, an instance-scoped service account had no way to recover a team's id after a 409, unlike every other resource in this table, where the adopt-on-conflict rule had a real lookup to call. |
| Escalation policy | GET/PUT whole-policy | Update-in-place: PUT the full desired policy every reconcile that finds drift; cheap because whole-policy is small and already loaded whole server-side. |
| Dead man's switch | POST create, **PUT update-in-place** (added in terdut-server v0.33.0), DELETE, GET-list | Real update-in-place, same shape as Team/Escalation: PUT the whole switch every reconcile once its id is known. No unique-name constraint server-side (confirmed against source — `handleCreateTeamDeadman` has no conflict handling at all, unlike Team/service-account creation), so idempotent-create here can't rely on a 409 to adopt from: before POSTing, `GET /api/teams/{teamID}/deadman/switches` and match by `name` first: found → adopt its id; not found → POST. `handleUpdateTeamDeadman`'s own doc comment (terdut-server) confirms the motivation directly: "Added alongside create/delete so an automated caller (terdut-operator) can reconcile a spec change without deleting and recreating the switch, which would otherwise ... needlessly rotate its id for no reason a reconciler's diff should ever manufacture." An earlier draft of this row, written before that endpoint existed, described delete-and-recreate; corrected here, confirmed against source rather than left stale. |
| Integration (alert source) | POST create, PATCH rename, DELETE | Rename via PATCH; any other spec change (kind) is delete-and-recreate, which **rotates the webhook key** — called out loudly in the CRD's field docs and in a `Warning` event, since it breaks whatever sends to the old URL/key until the new Secret is picked up. (See the general rule below for what happens if the Secret is lost with *no* spec change.) |
| Team | POST (idempotent on `external_id`), PUT rename, PUT oidc-groups, DELETE | Repeat the idempotent POST, then PUT the rest, every reconcile. Delete runs in a finalizer; the server refuses (409) while the team has open incidents, which is retried. |
| Escalation ladder | PUT whole ladder | PUT the full desired ladder every reconcile; the server resolves usernames. |
| Dead man's switch | list, POST, PUT, DELETE; names unique per team | Diff by name against the list. |
| Integration (alert source) | POST, PATCH rename, DELETE | Rename via PATCH; a kind change is delete-and-recreate, which **rotates the webhook key** (Warning event). An integration deleted on the server is recreated with a new key (Warning event). |
General rules for every controller:
- **Idempotent create**: before POSTing, check `status.<serverSideID>` is
unset; if the server already has a same-named object from a previous
partial reconcile (e.g. after a crash between POST and status-write), treat
a 409/name-conflict as "adopt" — GET-by-name and populate status, rather
than erroring forever. terdut-server's list endpoints in each of these
areas return objects by name, so this is a straightforward correlation.
- **Periodic resync** in addition to watch-triggered reconciles (Kubebuilder
default `RequeueAfter` on success, e.g. every 5–10 minutes) to catch drift
from **someone changing state directly against the server's API/UI**,
since gitops correctness means the CR wins, not "first write wins".
- **Finalizers** on every CRD that has a server-side counterpart, so deletion
calls the corresponding DELETE before the Kubernetes object disappears.
Failure to delete server-side (e.g. server unreachable) blocks finalizer
removal and surfaces as a `Degraded` condition + event, rather than
silently orphaning a row.
- **Generated Secrets holding unrecoverable server-issued material are
watched, and their loss is fail-closed, not self-healed.** Currently this
is just `TerdutAlertSource`'s webhook Secret (§4.5): the controller adds
it to its `Owns()` watches, not just the CR. If it disappears while
`status.integrationID` is still set, the controller does **not** attempt
to recreate it — the key is genuinely gone (§4.5: never stored anywhere
but that one Secret), so silently minting a replacement would rotate a
live production webhook URL with no corresponding spec change to explain
why. Instead it flips `Ready: False, reason: WebhookSecretLost` and fires
a `Warning` event telling the operator to delete and recreate the
`TerdutAlertSource`. No new mechanism is needed for recovery: deleting the
CR runs the existing finalizer (DELETE the still-live integration
server-side, above), and recreating it runs the existing idempotent-create
path (this same section) — a fresh POST, a new key, a new Secret. This is
deliberately the same recovery motion as the kind-change rotation above,
just human-triggered instead of spec-triggered.
- **Owner chain for status resolution, not API calls**: `TerdutTeam`'s
controller does not call any other controller; every child CRD's
controller independently resolves its own `teamRef` → `TerdutTeam.status`
for the `teamID`, `credentialsSecretRef`, and `serverEndpoint` it needs to
call the API (§6) — it never needs to chain further up to `TerdutServer`
at all, not just as a stated intent but literally: `serverEndpoint` is
resolved once, by `TerdutTeam`'s own controller, and stored in its status
specifically so no child ever needs its own `TerdutServer` RBAC (`get`/
`list`/`watch` on `terdutservers`) to find out where to send a request —
the team's own scoped credential plus that one status field is everything
a child resource's controller requires. If the referenced `TerdutTeam`
isn't `Ready` yet (which includes not having a `credentialsSecretRef` set),
the child requeues with backoff and reports `Ready: False, reason:
WaitingForTeam` — no cross-controller RPC.
- **Cross-namespace `serverRef` is re-checked every reconcile, not just at
creation**: `TerdutTeam`'s controller reads the target `TerdutServer`'s
`spec.allowedTeams` (and, under `Selector`, a `Get` on its own `Namespace`
object for labels) on every pass before touching a cross-namespace
`TerdutServer` — revocation (§4.6) takes effect on the team's very next
reconcile, not just when the CR is first applied.
General rules:
- **Periodic resync** (5 minutes) besides watch-triggered reconciles, to catch someone changing
state directly against the server: the CR wins.
- **Finalizers** on `TerdutTeam` and `TerdutAlertSource` call the server's DELETE first. A 404 is
success; any other failure blocks removal and surfaces as an event rather than orphaning a row.
`TerdutServer` needs none: its Secret is owned and its database is never touched.
- **A webhook Secret that is lost fails closed.** The key is never re-readable from the server, so
the controller does not mint a replacement for a live URL with no spec change to explain it:
`Ready: False, reason: WebhookSecretLost`; delete and recreate the `TerdutAlertSource`.
- **Children resolve through the team.** A `TerdutAlertSource` finds its `TerdutTeam` (same
namespace), requires it Ready, then reads the server named by the team's `serverRef` and that
server's operator key. A `TerdutTeam` re-reconciles when its `TerdutServer` changes.
- **Cross-namespace consent is re-checked every reconcile** (§4.6), so revocation takes effect on
the team's next pass.
## 6. Bootstrap & authentication to terdut-server's API
## 6. Authentication to terdut-server's API
terdut-server's scoped service-account credential type
(`terdut-server`'s `SERVICE-ACCOUNTS.md`) has shipped — confirmed against
source: `internal/api/service_accounts.go`, migration
`014_service_accounts.sql`, and `internal/api/router.go` wiring it in under
`AuthMiddleware`. This section is no longer blocked on it; the "v1-blocking"
framing here was accurate when this section was first written and is stale
now.
The operator talks to the server over HTTP with one bearer key per `TerdutServer`:
**`GET /api/service-accounts?name=` is not an unauthenticated lookup —
confirmed against `internal/api/middleware.go`'s `AuthMiddleware`, which
hard-rejects any request carrying neither a Bearer token nor a session
cookie with `401` before any handler ever runs.** This would matter a great
deal if the operator's `/api/bootstrap` call could ever lose a race to
something else bootstrapping the same server first — a credential-less
loser would have no authenticated way to recover. It doesn't matter here,
by construction (§1): **the operator only ever calls `/api/bootstrap`
against a `TerdutServer` it just created**, so there is nothing else in a
position to race it. An earlier draft of this section added a
`spec.credentialsSecretRef` bring-your-own input specifically to work around
that race, for a world where the operator might adopt a server something
else had already bootstrapped. That world doesn't exist (§1), so the field
was removed rather than kept as unused flexibility — self-registration
(point 1, below) is simply the only path, not one of two.
1. The `TerdutServer` controller creates the Secret `<name>-operator-key` (data key `token`,
`tdsa_` + 48 hex characters) in the server's own namespace, owned by the `TerdutServer`. It
is created once and never overwritten while it exists: the running server was seeded with it.
2. The Deployment hands it to the pods as `TERDUT_OPERATOR_KEY` through a `secretKeyRef` (the key
never appears in the pod spec), plus an annotation holding a hash of it so a replaced Secret
rolls the pods.
3. At every start the server creates or re-keys its instance-scoped service account
`terdut-operator` from that value. An instance-scoped account acts as owner of every team's
configuration but is not a member of any team, so it reads no incidents and is never an
administrator.
4. `status.credentialsSecretRef` points at the Secret; `TerdutTeam` and `TerdutAlertSource`
controllers read it from the server's namespace.
**Every credential the operator holds — the one instance-scoped key per
`TerdutServer`, and one team-scoped key per `TerdutTeam` — lives in a Secret
in the *operator's own* namespace, never in the namespace of the CR it
authenticates for.** Reconciliation happens entirely inside the operator's
controller loop, which is a single Deployment/ServiceAccount already
watching every namespace it's granted (§9); nothing about calling
terdut-server's API on a CR's behalf requires the credential to be
physically located near that CR, and no CR owner (human or otherwise) ever
needs to see, hold, or have RBAC to read a terdut-server credential. This is
a straight simplification of an earlier draft of this section, which mirrored
a shared credential into each consenting namespace instead — that version
conflated "the CR's owner never needs to see this" (true, and preserved
here) with "so the credential must live in the CR's namespace" (a
non-sequitur once you don't need to grant *anyone else* namespace-local
read access). Dropping that assumption also removes an entire class of
complexity: no on-demand mirroring, no garbage-collecting an orphaned copy
when `allowedTeams` narrows, no "OwnerReferences can't cross namespaces so
track it in status instead" workaround — none of that machinery is needed
when nothing ever crosses into a tenant namespace in the first place.
1. **First reconcile, confirmed against source**
(`internal/api/users.go`'s `handleBootstrap`): `/api/bootstrap` is
single-shot *per install*, gated on `SELECT COUNT(*) FROM users` — once
non-zero, every call `403`s regardless of identity. There are two crash
windows between "get an admin key" and "have a lasting, usable
credential" — getting from `/api/bootstrap`'s key to a minted
service-account key, and getting from that key to a persisted Secret —
and both get a checkpoint rather than being left as a theoretical gap,
the same rigor §5's general idempotent-create rule already applies
elsewhere:
- `status.credentialsSecretRef` already set: done, nothing to do.
- Otherwise, look for the instance credential Secret itself
(`<namespace>.<name>-instance-credentials`, point 2 below): a
`TerdutServer` deleted and recreated under the same name leaves it
behind by default (`spec.credentials.deletionPolicy: Retain`), and the
server it logs in to has not changed, because deleting a
`TerdutServer` never touches its database. If it exists, ask the server
for the operator's own service account with that token
(`GET /api/service-accounts?name=terdut-operator`). Accepted: adopt it,
set `status.credentialsSecretRef`, and skip everything below. Rejected
with a `401`/`403`: it is stale (a database reset since), so ignore it
and carry on; the steps below replace it. Any other failure is a retry,
not a guess.
- Otherwise, check for an intermediate
`<namespace>.<name>-bootstrap-admin` Secret in
the operator's own namespace first. If it exists, its key is a still-
valid admin credential from an earlier, interrupted attempt — skip
`/api/bootstrap` entirely and reuse it. If not, call `/api/bootstrap`
once the Deployment this `TerdutServer` created has a ready replica;
on `201`, immediately checkpoint its response's raw admin key
(`{"user": ..., "api_key": {"key": "<raw>", ...}}`) into that Secret
before doing anything else with it. A `403` with neither
`status.credentialsSecretRef`, nor an instance credential the server
accepts, nor this checkpoint Secret present is the one genuinely
pathological case left: the server's database is already bootstrapped
and no credential for it survives here (the checkpoint deleted out from
under a reconcile already past this point, `deletionPolicy: Delete`, or
the Secret removed by hand). Fail closed, `Ready: False, reason:
BootstrapStateLost`, with a message that names the instance credential
Secret to restore. Deleting and recreating the `TerdutServer` does **not**
recover from it: the finalizer removes Secrets only, and the database
(the one thing that still says "already bootstrapped") is not the
operator's to reset. Restore the Secret from a copy, or reset the
server's database and then recreate the `TerdutServer`, which then
bootstraps like a first install. (This section used to say a fresh
create "starts clean"; that was only ever true when the database went
with it.)
- With an admin key in hand (fresh or checkpointed): `POST
/api/service-accounts {name: "terdut-operator", scope: "instance"}`.
A `409` here means a prior attempt got this far before being
interrupted — adopt rather than error, per §5's general rule:
`GET /api/service-accounts?name=terdut-operator` (authenticated with
the checkpointed admin key, not an unauthenticated lookup) to find its
id, then `POST /api/service-accounts/{id}/keys` to mint a fresh key —
an orphaned first key some interrupted attempt minted and never used
is inert, not a cleanup obligation.
2. The resulting instance-scoped key — not the checkpointed admin key, which
is deleted once this step succeeds — is written to a generated Secret in
the **operator's own namespace** (e.g.
`<serverRef.namespace>.<serverRef.name>-instance-credentials`,
under a fixed data key, `token`), referenced back from
`TerdutServer.status.credentialsSecretRef: {name, key}` (§4.1). No
`OwnerReference` (those can't cross namespaces, and this Secret doesn't
share a namespace with the `TerdutServer` that caused it). What the
`TerdutServer`'s finalizer does with it is `spec.credentials.deletionPolicy`:
`Retain` (the default) leaves it for a recreated `TerdutServer` to adopt
(point 1), `Delete` removes it directly as part of the teardown. There are
no server-side resources to undo either way: the bootstrap user and
service account have no delete verb in terdut-server's API. The bootstrap
checkpoint Secret is always removed. A retained Secret of a `TerdutServer`
that is gone for good is an orphan to delete by hand, and it is inert:
adoption asks the server to accept the token first.
3. When a `TerdutTeam` first becomes `Ready` (its `serverRef` resolved,
`allowedTeams` satisfied if cross-namespace), its controller uses the
`TerdutServer`'s instance-scoped credential (read from the operator's own
namespace, resolved via the owner chain in §5) to mint a **team-scoped**
service account for itself: `POST /api/service-accounts` with
`scope: team, teamID: <status.teamID>`. The resulting key is written to
its own generated Secret, again in the **operator's own namespace**
(e.g. `<teamNamespace>.<teamName>-team-credentials`), referenced from
`TerdutTeam.status.credentialsSecretRef` (§4.2). Same finalizer pattern as
point 2: the `TerdutTeam`'s finalizer deletes this Secret as part of its
own teardown.
4. Every child controller (`TerdutEscalationRule`, `TerdutDeadmanSwitch`,
`TerdutAlertSource`) reads its team's `credentialsSecretRef` — resolved
through its `teamRef` → `TerdutTeam.status` (§5) — and never touches the
instance-scoped credential at all. Since child CRDs stay same-namespace-
as-their-`TerdutTeam` in v1 (§1), and the credential itself lives in the
operator's namespace regardless of where the `TerdutTeam` or its children
are, this works identically whether the `TerdutTeam` is same-namespace or
cross-namespace relative to its `TerdutServer` — there is no separate
cross-namespace case to handle here at all, unlike the mirroring design
this replaced.
5. **Blast radius**: a team-scoped key can only touch its own `team_id`'s
escalation policy, dead-man switches, integrations, schedule and OIDC
group bindings server-side (enforced by terdut-server itself, per
`SERVICE-ACCOUNTS.md`) — compromising one such Secret (e.g. a bug that
leaks operator-namespace Secrets, or an overly broad RBAC grant on that
one namespace) exposes exactly one team, never the whole server. This is
the real fix for what an earlier draft of this section called out as its
weak point (every mirrored copy being server-admin-equivalent); it falls
out of team-scoped credentials existing at all, independent of where
they're stored — the operator-private storage described above closes the
RBAC-footprint half of the problem, team scoping closes the credential-
privilege half.
6. **Rotation**: `POST /api/service-accounts/{id}/keys` mints a new key on
the existing account without recreating it; the old key is revoked via
`DELETE /api/service-accounts/{id}/keys/{keyID}`; the operator's local
Secret is updated in place. No DB-level workaround, no re-triggering a
single-shot endpoint that can't fire twice (which is what made rotation
unworkable under the old `/api/bootstrap`-only design).
7. **Operator mode** (`TERDUT_OPERATOR_MODE`, terdut-server's own
deploy-time flag, off by default) is the complementary half of this
trust model: it makes terdut-server itself refuse a *human* write (a
session or a user's own API key) on a route, while a service account's
— this operator's — still goes through. Confirmed against source
(`internal/api/middleware.go`'s `OperatorModeBlock`,
`internal/api/router.go`'s `opMode` wrapper): the blocked set is exactly
team create/rename/delete, a team's OIDC-group binding, its escalation
policy, its dead man's switches, and its integrations — precisely the
resources `TerdutTeam`, `TerdutEscalationRule`, `TerdutDeadmanSwitch` and
`TerdutAlertSource` manage, and nothing more. **Deliberately not
blocked, confirmed against the same router**: team membership and
invites (the router's own comment: "membership is deliberately never
gitops-managed"), and the on-call schedule/rota
(`/api/teams/{teamID}/schedule`, `/api/schedule/current`) — neither
route carries the `opMode` wrapper at all. A human can still add or
remove a team member, or assign who's on call, on a server running in
operator mode; only the CRD-shaped resources above are locked to
GitOps. This isn't a gap to close — it's the same boundary §4.2 already
draws for membership, confirmed to hold on the server side too, not
just stated as an intent here.
There is no bootstrap handshake: `/api/bootstrap` stays free for the first human administrator.
Deleting the `TerdutServer` deletes the key with it; a recreated one gets a new key and the server
re-seeds on its next start. Rotating by hand means deleting the Secret: the next reconcile makes
a new one and rolls the pods.
## 7. Ownership, status, garbage collection
- Every generated object that lives in the *same* namespace as the CR that
caused it (Deployment, Service, webhook Secret, and `TerdutServer`'s own
PodDisruptionBudget) carries a standard `metav1.OwnerReference` — GC
handles these, no finalizer needed. PodDisruptionBudget is the one
member of that list that's conditionally created/deleted rather than
always present: it exists only while `spec.pod.disruptionBudget` is set,
and the controller deletes it itself the moment that field is cleared
(it doesn't wait on GC for that case, only for the `TerdutServer` being
deleted outright). The two credential Secrets from §6 are the one
exception to OwnerReference-based cleanup generally: they live in the
operator's own namespace regardless of where their owning CR lives, so
`OwnerReference` doesn't apply (cross-namespace) and cleanup instead runs
through that CR's finalizer directly, alongside the server-side DELETE
it already has to issue (§5).
- Status conditions follow the standard `metav1.Condition` shape with at
least `Ready` on every kind, plus kind-specific ones (`TerdutServer`:
`DatabaseReady`, `Bootstrapped`; children: `Synced`).
- `status.observedGeneration` on every kind, bumped only after a successful
reconcile against that generation's spec — the standard way a client
(or `kubectl wait`) tells "applied" from "seen".
- No cluster-scoped aggregation object (e.g. no cluster-wide "all servers"
status) in v1 — `kubectl get terdutservers -A` is the aggregate view.
- Everything the controllers generate in a CR's namespace carries an `OwnerReference` (Deployment,
Service, PodDisruptionBudget, the operator key Secret, the webhook Secret), so GC cleans up
and no finalizer is needed. The PDB exists only while `spec.pod.disruptionBudget` is set; the
controller deletes it itself when the field is cleared.
- Every kind has a `Ready` condition and `status.observedGeneration`.
- No cluster-scoped aggregate object: `kubectl get terdutservers -A` is the overview.
## 8. Postgres integration
@@ -697,85 +360,43 @@ documented and tested operationally:
(`<user>.<cluster>.credentials.postgresql.acid.zalan.do`) the same way
the chart's comment already documents, and wires it in as
`PGPASSWORD` the same way.
- Watches that Secret (not just the `postgresql` CR) so a credential
rotation triggers a requeue — the chart today requires a manual pod
restart for this; the operator can at least detect and report it via a
condition even if restarting on rotation is left as a §13 follow-up
rather than done automatically (a rolling restart on credential change
is a behavior change worth its own design pass, not folded in here).
- Does not watch that Secret: a rotated credential is noticed at the next
5-minute resync, and restarting pods on rotation is a §13 follow-up.
- Requires read RBAC on `postgresql.acid.zalan.do` (optional CRD — the
operator's ClusterRole/Role should not hard-fail if the CRD isn't
installed and a given `TerdutServer` uses BYO DSN instead).
## 9. RBAC
- The operator's own ServiceAccount needs, per namespace it's granted:
`get/list/watch/create/update/patch/delete` on `Deployments`, `Services`
and `PodDisruptionBudgets` it owns, and `get/list/watch` on
`postgresql.acid.zalan.do` (optional, degrade gracefully if absent per
§8), plus cluster-wide `get/list` on `Namespace` (labels only, for
`allowedTeams: {from: Selector}` evaluation — §4.1, §4.6).
- **Two different `Secret` scopes, not one — corrected from an earlier draft
of this section.** That earlier draft said `Secret` access was "scoped to
the operator's own namespace only... nowhere else," reasoning that with
every credential held privately in the operator's own namespace (§6)
there was no legitimate reason to touch a `Secret` anywhere else. That was
wrong once §4.5 existed:
- The §6 credential Secrets (one instance-scoped key per `TerdutServer`,
one team-scoped key per `TerdutTeam`) do live in, and are only ever
touched from, the operator's own namespace —
`get/list/watch/create/update/patch/delete` there, nowhere else. The
rest of the original reasoning stands for *these* Secrets specifically:
no human or team's own RBAC is ever granted access to a terdut-server
credential by this design, and the operator itself never needs
cross-namespace access to reach them.
- The §4.5 webhook Secret is different: it's owned by and lives beside
its `TerdutAlertSource`, in that CR's own tenant namespace, not the
operator's. The per-namespace `Role` already granted for
`Deployments`/`Services` in each watched namespace (below) must carry
the same `Secret` verbs there too, or the controller cannot create,
watch, or even detect the loss of (§5) that Secret at all.
- This necessarily widens the operator's footprint in each watched
tenant namespace to "any `Secret` in that namespace," not just the ones
it created — Kubernetes RBAC has no owner-scoped grant finer than the
namespace itself, and the design already accepts this same granularity
for Deployments/Services there. Flagged as an accepted trade-off, not a
silent gap (§13).
- No cluster-scoped resources are created by this operator (namespaced CRDs
only, per §1) — a `Role` + `RoleBinding` per watched (tenant) namespace is
sufficient for Deployments/Services/the webhook `Secret`/the optional
Zalando CRD, plus a separate `Role` + `RoleBinding` in the operator's own
namespace for the §6 credential Secrets; a `ClusterRole` is only needed
for watching CRDs across all namespaces (the normal Kubebuilder
multi-tenant-operator default) — none of the `Secret` access above needs
to be cluster-scoped.
- terdut-server's own RBAC is unaffected — the operator talks to it purely
over HTTP with service-account API keys (§6), never via the Kubernetes
API for app-level state.
- The operator needs `get/list/watch/create/update/patch/delete` on `Deployments`, `Services`,
`PodDisruptionBudgets` and `Secrets` it owns, `get/list/watch` on `postgresql.acid.zalan.do`
(optional; degraded gracefully when the CRD is absent), and `get/list/watch` on `Namespaces`
(labels only, for `allowedTeams: {from: Selector}`).
- Secrets live in the `TerdutServer`'s namespace (the operator key) and in each
`TerdutAlertSource`'s namespace (the webhook Secret), so the operator needs Secret access in
every tenant namespace. Kubernetes RBAC has no owner-scoped grant finer than the namespace.
- **As built (2026-10): broader than the above.** The shipped default is a
`ClusterRole` with full verbs on `Secrets` in every namespace (the chart's
`rbac.namespaced: true` gives a `Role` in the release namespace only, which
cannot serve tenant namespaces), and the manager's cache is not restricted
to watched namespaces. A per-namespace `Role` split (a Role and RoleBinding per watched namespace, with the cache
restricted to them) is the intended end state, not implemented, and the decision (2026-10) is to stay
cluster-wide for now: treat this operator as able to read every Secret in the cluster.
- terdut-server's own RBAC is unaffected: the operator uses only its HTTP API (§6), never the
Kubernetes API for app-level state.
## 10. Relationship to `charts/terdut-server`
The chart's Deployment/Service/bootstrap-job templates are redundant once
`TerdutServer` exists — running both would mean two controllers (Helm and
this operator) reconciling the same Deployment, which is exactly the
conflict Kubernetes operators exist to avoid. The chart is repurposed into
an **installer chart**: it installs the operator + CRDs (and optionally one
`TerdutServer` CR from `values.yaml`, for users who want "helm install and
get a server" without hand-writing a CR) rather than templating the
Deployment directly.
The server chart's Deployment, Service and bootstrap Job are redundant once `TerdutServer`
exists: running both would have two controllers reconciling the same Deployment. The operator's
chart (`charts/terdut-operator`) installs the operator, CRDs and RBAC, and optionally one
`TerdutServer` from `values.yaml` (`terdutServer.enabled`). There is no migration from a
chart-based install and none is planned (§1); whatever the server chart deployed stays a separate
install until someone deletes it.
**No migration path from an existing chart-based install, and none is
planned (§1).** An earlier draft of this section spent most of its length on
one — `helm template` the current release's `values.yaml` into an equivalent
`TerdutServer` CR, uninstall or shrink the old release, and a whole
sub-question about who gets to call `/api/bootstrap` first, the chart's Job
or the operator — all of which presupposed the operator might end up
managing a server the chart had already deployed and bootstrapped. §1 rules
that out: the operator only ever manages servers it created itself, so
there's nothing to migrate and no bootstrap race to settle (§6 covers why
that race doesn't exist either). Adopting the operator means applying a
fresh `TerdutServer` CR; whatever the chart deployed before stays exactly
what it was, a separate install, until someone deletes it.
The operator's Deployment builder mirrors the chart's env block for the knobs both expose (the
chart's `deployment.yaml` and `buildEnv` in `internal/controller/terdutserver_deployment.go`);
a new server setting is added in `config.go`, the chart, and the operator, in that order.
## 11. Testing strategy
@@ -786,84 +407,27 @@ what it was, a separate install, until someone deletes it.
request/response shapes (already well-documented in
`internal/api/*_test.go` on the server side) — no real Postgres or real
terdut-server binary needed for controller unit tests.
- A smaller number of true end-to-end tests (`kind` cluster + real
terdut-server image + real Postgres) covering the golden path per CRD:
create `TerdutServer` → `TerdutTeam` → one of each child kind → verify via
terdut-server's own API that the objects exist with the right shape →
delete the CR → verify the server-side object is gone.
- The golden path (`kind` cluster + real terdut-server image + real Postgres:
create `TerdutServer` → `TerdutTeam` → `TerdutAlertSource`, verify through
terdut-server's own API, delete, verify it is gone) is a manual pass via
`examples/demo/run-demo.sh`, not a CI job.
## 12. Observability
- Standard controller-runtime metrics (reconcile duration/error counts) are
enough for v1 — no custom metrics.
- Every externally-visible action (bootstrap, key rotation-needed, delete-
and-recreate on the no-PUT resources, adopt-on-conflict) emits a
- Every externally-visible action (a team created or renamed, an integration recreated, a delete
that failed) emits a
Kubernetes `Event` on the CR, since that's what shows up in `kubectl
describe` and gitops tooling (Argo CD/Flux) surfaces without extra wiring.
## 13. Deferred / explicitly out of scope for this design
## 13. Deferred / out of scope
- **A real scoped service-account/token type in terdut-server — not merely
deferred, this is v1-blocking for §6 as written** (verified: without it,
§6's bootstrap flow has no working credential-rotation path and no clean
answer to the chart-vs-operator bootstrap race; see §6 points 1, 5, 6 and
§10). Sequence this server-side change *before* implementing the
`TerdutServer` controller's bootstrap logic, not after.
- **A version-discovery endpoint on terdut-server** (e.g. `GET /api/version`).
Neither this operator nor terdut-tui has one today — both independently
detect capability by probing specific routes (terdut-tui via `GET
/api/teams` 404-checking; this operator would otherwise need to invent
its own equivalent probe). An unattended reconciler is more exposed to a
silent breaking API change than an interactive TUI a human is watching;
raising this alongside the service-account request rather than inventing
another route-probe here.
- CloudNativePG support — same `spec.database` shape as Zalando should
extend to it, but the concrete field/Secret-naming conventions need their
own look.
- Cross-namespace `teamRef` on the child CRDs (`TerdutEscalationRule`,
`TerdutDeadmanSwitch`, `TerdutAlertSource`) — only `TerdutTeam.serverRef`
crosses namespaces in v1 (§2, §4.2, §4.6); these stay same-namespace as
their `TerdutTeam` until a real need for splitting them out shows up.
- Narrower-than-namespace RBAC for the §4.5 webhook Secret (Kubernetes RBAC
has no owner-scoped grant below the namespace itself, per §9) — revisit
if the widened per-tenant-namespace `Secret` access proves too broad in
practice.
- A mid-life `spec.teamRef` change on a child CRD (`TerdutEscalationRule`,
`TerdutDeadmanSwitch`, `TerdutAlertSource`) isn't specially detected —
noticed while grounding Stage 4 against source, not newly introduced by
it: all three controllers always resolve `spec.teamRef` fresh every
reconcile and act against whatever `TerdutTeam` that currently names,
trusting the server-side id already stored in `status` remains valid
there. There's no server-side verb that could move an existing
integration/policy/switch to a different team in place regardless, so
retargeting one onto a live child isn't a supported operation in v1 —
delete and recreate the CR instead.
- `TerdutServerSpec.OIDC` has no `trustEmail` field (nor `usernameClaim`,
`emailClaim`, `groupsClaim` — the "rather than being added here
speculatively" fields its own doc comment already names), unlike
`charts/terdut-server`'s own chart, which sets `oidc.trustEmail: true`
for the production install specifically because Authentik reports
`email_verified: false` and without it a user's first SSO sign-in
creates a second, empty account instead of linking to their existing
one (`terdut-server/README.md`'s own account of this). Found by
actually trying to stand up a second real `TerdutServer` against the
same Authentik provider (`terdut-demo`, `Ryuvia/charts#275`), not by
inspection: that install's OIDC config is otherwise a straight copy of
production's and runs with terdut-server's own default (`trustEmail:
false`) regardless, since the CRD has nowhere to put the override.
Worth closing if a second real OIDC install becomes routine rather than
a one-off exercise.
- Gitops-managed team *membership* (see §4.2).
- CloudNativePG support, alongside the Zalando `postgresClusterRef` (same `spec.database` shape).
- Gitops-managed team membership (§4.2).
- Per-namespace RBAC with a restricted cache (§9).
- Automatic Deployment restart on upstream Postgres credential rotation.
- `spec.pod.priorityClassName`, pod-label passthrough beyond
`spec.pod.annotations`, and a HorizontalPodAutoscaler for `TerdutServer`
— all considered alongside §4.1's `spec.pod` and left out of that round.
An HPA no longer contradicts anything now that `spec.replicas` defaults
to 2 (terdut-server v0.36.0's advisory locks), but it is still a
separate, not-yet-made decision: a fixed replica count has no scaling
metric, min/max bounds, or cooldown behaviour to get right, and nobody
has asked for it yet.
- Admission webhooks / CEL-only validation limits (e.g. verifying a
`teamRef` exists at admission time rather than surfacing it as a status
condition after the fact).
- `spec.pod.priorityClassName`, pod labels beyond annotations, and an HPA for `TerdutServer`.
- Admission webhooks beyond CEL (for example, checking a `teamRef` exists at admission time).
- A shared API types module or generated client for terdut-server, terdut-operator and terdut-tui.
- OLM packaging.