First draft for design
This commit is contained in:
@@ -0,0 +1,545 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
controller-runtime / Kubebuilder conventions.
|
||||||
|
|
||||||
|
**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 an explicit consent object in the target namespace
|
||||||
|
(§4.2, §4.6) — 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.
|
||||||
|
|
||||||
|
## 2. The README's open questions, resolved
|
||||||
|
|
||||||
|
> How do team-crd connect with server-crd?
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 +
|
||||||
|
credentials against it, which is a namespace-boundary violation, not a
|
||||||
|
gitops convenience. §4.6 covers the consent object
|
||||||
|
(`TerdutServerReferenceGrant`), modeled directly on Gateway API's
|
||||||
|
`ReferenceGrant` — the established Kubernetes pattern for exactly this
|
||||||
|
"object A in namespace X wants to reference object B in namespace Y"
|
||||||
|
problem. No other reference in this design (`teamRef` on the child CRDs)
|
||||||
|
crosses a namespace boundary, so this is the only place `ReferenceGrant`-style
|
||||||
|
consent is needed (§1, §4.2, §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. |
|
||||||
|
| `TerdutTeam` | Namespaced | One team on a `TerdutServer`, possibly in another namespace: name, OIDC group mapping. |
|
||||||
|
| `TerdutServerReferenceGrant` | Namespaced (lives with the `TerdutServer`) | Consent for a `TerdutTeam` in another namespace to reference this namespace's `TerdutServer`(s). |
|
||||||
|
| `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). |
|
||||||
|
|
||||||
|
## 4. Per-CRD spec
|
||||||
|
|
||||||
|
### 4.1 `TerdutServer`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: terdut.ryuvia.com/v1alpha1
|
||||||
|
kind: TerdutServer
|
||||||
|
metadata:
|
||||||
|
name: terdut
|
||||||
|
namespace: oncall
|
||||||
|
spec:
|
||||||
|
image:
|
||||||
|
repository: git.ryuvia.com/niklas/terdut-server
|
||||||
|
tag: v0.9.3
|
||||||
|
replicas: 1 # terdut-server is not horizontally-scale-tested; keep the field, default 1
|
||||||
|
networking:
|
||||||
|
hostname: terdut.example.com
|
||||||
|
servicePort: 8080
|
||||||
|
gatewayListener: "" # same semantics as chart's networking.listener
|
||||||
|
database:
|
||||||
|
dsn: "postgres://terdut@terdut-postgres:5432/terdut?sslmode=require" # mutually exclusive with postgresClusterRef
|
||||||
|
passwordSecretRef: {name: terdut.terdut-postgres.credentials.postgresql.acid.zalan.do, key: password}
|
||||||
|
# --- OR ---
|
||||||
|
postgresClusterRef: {name: terdut-postgres} # Zalando `postgresql` CR in the same namespace
|
||||||
|
sweeper:
|
||||||
|
staleAfter: 6h
|
||||||
|
archiveAfter: 168h
|
||||||
|
deadman:
|
||||||
|
matchers: "alertname=Watchdog"
|
||||||
|
timeout: 15m
|
||||||
|
severity: critical
|
||||||
|
notify:
|
||||||
|
ntfyURL: "http://ntfy.ntfy.svc.cluster.local"
|
||||||
|
fallbackTopic: ""
|
||||||
|
repeatEvery: 15m
|
||||||
|
tokenSecretRef: {name: "", key: token}
|
||||||
|
oidc:
|
||||||
|
enabled: false
|
||||||
|
issuer: ""
|
||||||
|
clientID: ""
|
||||||
|
clientSecretRef: {name: "", key: client-secret}
|
||||||
|
name: SSO
|
||||||
|
scopes: "openid profile email"
|
||||||
|
allowedGroups: []
|
||||||
|
adminGroup: ""
|
||||||
|
sessionMaxAge: 12h
|
||||||
|
passwordLogin: true
|
||||||
|
status:
|
||||||
|
conditions: [...] # Ready, DatabaseReady, Bootstrapped
|
||||||
|
observedGeneration: 3
|
||||||
|
serviceName: terdut
|
||||||
|
operatorCredentialsSecretRef: {name: terdut-operator-credentials} # see §6
|
||||||
|
```
|
||||||
|
|
||||||
|
Field-for-field this is the chart's `values.yaml` reshaped as a spec — the
|
||||||
|
operator absorbs the chart's Deployment/Service/bootstrap-job templates, so
|
||||||
|
existing installs have a direct mapping when migrating (see §10).
|
||||||
|
|
||||||
|
`spec.database` fields are `+kubebuilder:validation:XValidation` guarded to
|
||||||
|
be mutually exclusive (`dsn` xor `postgresClusterRef`); mirrors the chart's
|
||||||
|
"chart provisions no database" stance — this operator provisions no database
|
||||||
|
either, only wires up one that exists.
|
||||||
|
|
||||||
|
### 4.2 `TerdutTeam`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
serverRef:
|
||||||
|
name: terdut
|
||||||
|
namespace: platform-oncall # optional; defaults to this TerdutTeam's own namespace.
|
||||||
|
# Cross-namespace requires a matching TerdutServerReferenceGrant
|
||||||
|
# in that namespace (§4.6) — otherwise Ready: False, reason: RefNotPermitted.
|
||||||
|
displayName: "Platform" # -> POST /api/teams {"name": ...}; server assigns the ID
|
||||||
|
oidc:
|
||||||
|
memberGroup: "terdut-platform-members"
|
||||||
|
ownerGroup: "terdut-platform-owners"
|
||||||
|
status:
|
||||||
|
conditions: [...]
|
||||||
|
teamID: 42 # the server-side ID; needed by every child object's controller
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 `TerdutAlertSource`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
teamRef: {name: platform-team}
|
||||||
|
kind: alertmanager
|
||||||
|
name: "prod-alertmanager"
|
||||||
|
status:
|
||||||
|
conditions: [...]
|
||||||
|
integrationID: 3
|
||||||
|
webhookURLSecretRef: {name: prod-alertmanager-terdut-webhook} # url + key, never in status/spec
|
||||||
|
```
|
||||||
|
|
||||||
|
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 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
|
||||||
|
`spec.secretName` rather than the key material itself.
|
||||||
|
|
||||||
|
### 4.6 `TerdutServerReferenceGrant`
|
||||||
|
|
||||||
|
Consent object living in the *`TerdutServer`'s* namespace, modeled directly
|
||||||
|
on Gateway API's `ReferenceGrant` (`gateway.networking.k8s.io/v1beta1`):
|
||||||
|
without one, no `TerdutTeam` outside this namespace may resolve a
|
||||||
|
`serverRef` into it, no matter what it names.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: terdut.ryuvia.com/v1alpha1
|
||||||
|
kind: TerdutServerReferenceGrant
|
||||||
|
metadata:
|
||||||
|
name: allow-app-teams
|
||||||
|
namespace: platform-oncall # the TerdutServer's namespace
|
||||||
|
spec:
|
||||||
|
from:
|
||||||
|
- group: terdut.ryuvia.com
|
||||||
|
kind: TerdutTeam
|
||||||
|
namespace: team-checkout # one entry per namespace permitted to reference in
|
||||||
|
- group: terdut.ryuvia.com
|
||||||
|
kind: TerdutTeam
|
||||||
|
namespace: team-payments
|
||||||
|
to:
|
||||||
|
- group: terdut.ryuvia.com
|
||||||
|
kind: TerdutServer
|
||||||
|
name: terdut # optional: omit to permit any TerdutServer in this namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
- **No selector/wildcard-across-namespaces field** (e.g. no `namespaceSelector`)
|
||||||
|
in v1, matching upstream `ReferenceGrant`'s own choice: the namespace owner
|
||||||
|
enumerates exactly which namespaces may reference in, which is the whole
|
||||||
|
point of requiring affirmative, auditable consent rather than an implicit
|
||||||
|
or pattern-matched grant.
|
||||||
|
- A `TerdutTeam`'s controller checks for a matching grant (namespace +
|
||||||
|
optionally name) on every reconcile before it will resolve `serverRef`
|
||||||
|
cross-namespace, exactly mirroring how `HTTPRoute`/`Certificate`
|
||||||
|
controllers check `ReferenceGrant` before honouring a cross-namespace
|
||||||
|
`backendRef`/`secretRef`. Same-namespace `serverRef` never needs a grant.
|
||||||
|
- Deleting the grant is a live revocation: the next reconcile of any
|
||||||
|
`TerdutTeam` it used to authorize finds no matching grant, 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 a revoked grant blocking *new* changes rather than
|
||||||
|
forcing an immediate, possibly credential-less deletion is the safer
|
||||||
|
failure mode).
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|
|
||||||
|
| Team | POST create, PUT rename, DELETE, PUT oidc-groups | Real update-in-place: diff spec vs. last-applied, PUT the changed pieces. |
|
||||||
|
| 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, DELETE — **no PUT** | Delete-and-recreate on any spec diff other than `name`. The controller diffs against `status` (which mirrors what was last successfully applied) rather than re-reading the server every reconcile, to avoid a spurious recreate from field reordering. |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **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.teamID`
|
||||||
|
and `serverRef` chain down to `TerdutServer.status.operatorCredentialsSecretRef`,
|
||||||
|
the way any two independent controller-runtime reconcilers would. If the
|
||||||
|
referenced parent isn't `Ready` yet, 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 lists `TerdutServerReferenceGrant`
|
||||||
|
objects in the target namespace on every pass (cheap: a namespaced List
|
||||||
|
with a field/label index, not a full-cluster scan) before touching the
|
||||||
|
cross-namespace `TerdutServer` — revocation (§4.6) takes effect on the
|
||||||
|
team's very next reconcile, not just when the CR is first applied.
|
||||||
|
|
||||||
|
## 6. Bootstrap & authentication to terdut-server's API
|
||||||
|
|
||||||
|
terdut-server has no first-class "service account" token type — API keys
|
||||||
|
belong to a real user row (`api_keys.user_id`). The chart's current answer is
|
||||||
|
a one-shot Job that calls `POST /api/bootstrap`, gets a one-time admin key
|
||||||
|
back, and stores it in a Secret (`bootstrap-job.yaml`). The operator absorbs
|
||||||
|
this rather than shelling out to curl:
|
||||||
|
|
||||||
|
1. On a `TerdutServer`'s first reconcile after its Deployment reports Ready
|
||||||
|
(`/healthz` reachable through the Service), the controller calls
|
||||||
|
`POST /api/bootstrap` itself with a fixed, recognizable identity —
|
||||||
|
e.g. username `terdut-operator`, email `terdut-operator@<serverRef>` —
|
||||||
|
distinct from `spec.bootstrap.username/email` used for the *human* first
|
||||||
|
admin the chart bootstraps today. Two separate bootstrap identities are
|
||||||
|
needed only if terdut-server's `/api/bootstrap` is single-shot per
|
||||||
|
install; if it already returns 403 after the first caller regardless of
|
||||||
|
identity, the operator instead **creates its bot user via
|
||||||
|
`POST /api/users`** once a human admin exists — this needs confirming
|
||||||
|
against the endpoint's actual behavior before implementation and is
|
||||||
|
flagged as a first task, not assumed here.
|
||||||
|
2. The returned API key is written to a generated Secret
|
||||||
|
(`<name>-operator-credentials`), owner-referenced to the `TerdutServer`,
|
||||||
|
referenced back from `status.operatorCredentialsSecretRef`.
|
||||||
|
3. Every other **same-namespace** controller (EscalationRule, DeadmanSwitch,
|
||||||
|
AlertSource, and any same-namespace `TerdutTeam`) reads that Secret
|
||||||
|
directly to call the API — never its own credentials.
|
||||||
|
4. **Cross-namespace `TerdutTeam` never reads the source Secret directly.**
|
||||||
|
Granting arbitrary consuming namespaces `get`/`list`/`watch` on a Secret
|
||||||
|
in the server's namespace would mean *any* future workload in that
|
||||||
|
namespace with Secret-read RBAC could be pointed at it too — the
|
||||||
|
`TerdutServerReferenceGrant` (§4.6) only authorizes the `TerdutTeam`
|
||||||
|
*kind*, not "read this Secret". Instead, once a `TerdutServer` observes at
|
||||||
|
least one valid grant for a namespace, its own controller (which already
|
||||||
|
holds the real credentials, and whose ServiceAccount is the only thing
|
||||||
|
with legitimate cross-namespace write access — see §9) mirrors a copy of
|
||||||
|
the Secret into that consenting namespace, named
|
||||||
|
`<serverRef.name>.<serverRef.namespace>-terdut-credentials`, owned not by
|
||||||
|
an `OwnerReference` (those can't cross namespaces) but tracked in the
|
||||||
|
`TerdutServer`'s status and cleaned up explicitly when the grant
|
||||||
|
authorizing that namespace is removed. The remote `TerdutTeam`'s
|
||||||
|
controller reads only this local mirror, never the original.
|
||||||
|
- This is one shared mirrored Secret per (server, consuming namespace)
|
||||||
|
pair, not one per `TerdutTeam` — terdut-server's own API key isn't
|
||||||
|
team-scoped (§13), so there is nothing finer to hand out; "scoped" here
|
||||||
|
means scoped by *namespace boundary*, not by team permission.
|
||||||
|
5. **Rotation**: the key is a bearer credential with no expiry modeled
|
||||||
|
server-side today. Rotation is manual (delete the Secret + the
|
||||||
|
`api_keys` row via `DELETE /api/users/{id}/api-keys/{keyID}`, let the
|
||||||
|
controller re-bootstrap) until/unless terdut-server grows key expiry; a
|
||||||
|
rotation invalidates every mirror too, which the `TerdutServer` controller
|
||||||
|
re-copies on its own next reconcile.
|
||||||
|
Documented as an operational runbook note, not automated in v1.
|
||||||
|
6. This is explicitly a stand-in for a real scoped service-account token
|
||||||
|
type; see §13.
|
||||||
|
|
||||||
|
## 7. Ownership, status, garbage collection
|
||||||
|
|
||||||
|
- Every generated object (Deployment, Service, credentials Secret, webhook
|
||||||
|
Secret) carries a `metav1.OwnerReference` to the CR that caused it, in the
|
||||||
|
same namespace — standard GC, no finalizer needed for these (only for the
|
||||||
|
server-side REST resources, per §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.
|
||||||
|
|
||||||
|
## 8. Postgres integration
|
||||||
|
|
||||||
|
Mirrors the chart's existing two-path contract (`values.yaml`
|
||||||
|
`database.dsn`/`passwordSecret`), because that contract is already
|
||||||
|
documented and tested operationally:
|
||||||
|
|
||||||
|
- **Bring-your-own**: `spec.database.dsn` (no password) +
|
||||||
|
`spec.database.passwordSecretRef` — the operator sets `PGPASSWORD` on the
|
||||||
|
Deployment's container env from that Secret, exactly like the chart does
|
||||||
|
today, and does nothing else. No connectivity check beyond what the
|
||||||
|
Deployment's own readiness probe already gives.
|
||||||
|
- **Zalando `postgres-operator`**: `spec.database.postgresClusterRef` names a
|
||||||
|
`postgresql.acid.zalan.do` CR in the same namespace. The controller:
|
||||||
|
- Reads that CR's status for the primary Service name/port to build the DSN
|
||||||
|
host — `<cluster>.<namespace>.svc:5432` — and database name convention.
|
||||||
|
- Resolves the generated credentials Secret
|
||||||
|
(`<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).
|
||||||
|
- 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`,
|
||||||
|
`Secrets` it owns, and `get/list/watch` on `postgresql.acid.zalan.do`
|
||||||
|
(optional, degrade gracefully if absent per §8), plus `get/list/watch` on
|
||||||
|
`TerdutServerReferenceGrant` (§4.6).
|
||||||
|
- No cluster-scoped resources are created by this operator (namespaced CRDs
|
||||||
|
only, per §1) — a `Role` + `RoleBinding` per watched namespace is
|
||||||
|
sufficient; a `ClusterRole` is only needed for watching CRDs across all
|
||||||
|
namespaces, which is the normal Kubebuilder multi-tenant-operator default
|
||||||
|
and doesn't imply cluster-scoped *managed* resources.
|
||||||
|
- **The operator's ServiceAccount is the only thing that ever holds
|
||||||
|
cross-namespace `Secret` write.** It is a single Deployment/binary already
|
||||||
|
watching every namespace it's granted (the normal Kubebuilder shape), so
|
||||||
|
mirroring a credentials Secret into a consenting namespace (§6) is not a
|
||||||
|
new privilege *boundary* — it's the same ServiceAccount that already
|
||||||
|
reconciles objects there — but it is new *scope* (`create`/`update` on
|
||||||
|
`Secrets` cluster-wide rather than only within each object's own
|
||||||
|
namespace), and should be called out explicitly in the operator's
|
||||||
|
ClusterRole/RBAC review, not left implicit. No human or team's own
|
||||||
|
RBAC is ever granted cross-namespace Secret access by this design —
|
||||||
|
`TerdutServerReferenceGrant` only ever authorizes the operator to act,
|
||||||
|
never a person.
|
||||||
|
- terdut-server's own RBAC is unaffected — the operator talks to it purely
|
||||||
|
over HTTP with the bot user's API key, never via the Kubernetes API for
|
||||||
|
app-level state.
|
||||||
|
|
||||||
|
## 10. Relationship to `charts/terdut-server`
|
||||||
|
|
||||||
|
**Recommendation** (flagged explicitly as a decision to confirm before
|
||||||
|
implementation starts, not settled by this document alone): the chart's
|
||||||
|
Deployment/Service/bootstrap-job templates become 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. Proposed path:
|
||||||
|
- 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.
|
||||||
|
- Existing installs migrate by: `helm template` the current release's
|
||||||
|
`values.yaml` into an equivalent `TerdutServer` CR (mechanical, since §4.1
|
||||||
|
is deliberately shaped to make that mapping 1:1), install the operator,
|
||||||
|
apply the CR, then let Helm's release be uninstalled or reduced to just
|
||||||
|
the CRD/operator subchart.
|
||||||
|
- This is a breaking change to the chart's contract and needs its own
|
||||||
|
migration guide and probably a major chart version bump — out of scope
|
||||||
|
for this design doc beyond flagging it; do not start that migration work
|
||||||
|
without separately confirming this recommendation.
|
||||||
|
|
||||||
|
## 11. Testing strategy
|
||||||
|
|
||||||
|
- `envtest` (controller-runtime's fake API server) for every controller's
|
||||||
|
reconcile logic against the Kubernetes side.
|
||||||
|
- terdut-server's REST API is faked with a small `httptest.Server` per
|
||||||
|
controller test driven by fixtures matching the real handlers'
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
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
|
||||||
|
|
||||||
|
- A real scoped service-account/token type in terdut-server (least-privilege
|
||||||
|
operator identity instead of a bot admin user) — worth raising as a
|
||||||
|
terdut-server feature request, not designed 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.
|
||||||
|
- A `TerdutServerReferenceGrant`-style namespace selector (rather than an
|
||||||
|
enumerated list of namespaces) — deliberately omitted to match upstream
|
||||||
|
`ReferenceGrant`'s own choice of explicit, auditable consent over
|
||||||
|
pattern-matching (§4.6); revisit only if enumerating namespaces becomes
|
||||||
|
genuinely unworkable at scale.
|
||||||
|
- Gitops-managed team *membership* (see §4.2).
|
||||||
|
- Automatic Deployment restart on upstream Postgres credential rotation.
|
||||||
|
- 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).
|
||||||
|
- OLM packaging, Helm chart migration execution (§10 is a recommendation,
|
||||||
|
not a plan to execute).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Terdut operator
|
||||||
|
|
||||||
|
Aims to expose most config as CRD's, so end users can self-service over gitops.
|
||||||
|
|
||||||
|
See [DESIGN.md](./DESIGN.md) for the full design: CRD catalog and specs,
|
||||||
|
reconciliation semantics, bootstrap/auth, Postgres integration, RBAC, and the
|
||||||
|
relationship to `charts/terdut-server`. This README stays a short pitch; the
|
||||||
|
open questions it used to carry are now resolved decisions there (§2).
|
||||||
|
|
||||||
|
## CRD's
|
||||||
|
|
||||||
|
### terdutServers
|
||||||
|
Creates a server — Deployment, Service, database wiring, bootstrap, operator
|
||||||
|
credentials. See DESIGN.md §4.1.
|
||||||
|
|
||||||
|
### terdutTeams
|
||||||
|
- team name
|
||||||
|
- oidc groups
|
||||||
|
- `serverRef` — explicit reference to its `TerdutServer`, may be in a
|
||||||
|
different namespace (one team owns the server, others self-service a
|
||||||
|
team against it), gated by a `TerdutServerReferenceGrant` in the
|
||||||
|
server's namespace (DESIGN.md §2, §4.2, §4.6)
|
||||||
|
|
||||||
|
### terdutServerReferenceGrants
|
||||||
|
- lives in the `TerdutServer`'s namespace; lists which other namespaces'
|
||||||
|
`TerdutTeam` objects may reference it (DESIGN.md §4.6)
|
||||||
|
|
||||||
|
### terdutEscalationrules
|
||||||
|
- rule
|
||||||
|
- `teamRef` — explicit reference to its `TerdutTeam` (DESIGN.md §2, §4.3)
|
||||||
|
|
||||||
|
### terdutDeadmansswitches
|
||||||
|
- rule
|
||||||
|
- `teamRef` (DESIGN.md §4.4)
|
||||||
|
|
||||||
|
### terdutAlertSources
|
||||||
|
- `teamRef` (DESIGN.md §4.5)
|
||||||
|
- URL/key are generated by the server at creation and surfaced only via a
|
||||||
|
generated Secret, never set explicitly
|
||||||
Reference in New Issue
Block a user