openova/products/catalyst/chart/templates/catalyst-openova-kc-credentials-secret.yaml
e3mrah 9077016466
feat(bp-stalwart-sovereign): per-Sovereign Stalwart for Console mail (#924) (#931)
Phase-2 follow-up to #883: replace mothership Stalwart relay
(mail.openova.io:587) with a Sovereign-local Stalwart so Console
PIN/magic-link mail originates from `noreply@<sovereignFQDN>` with
per-Sovereign SPF/DKIM/DMARC posture, eliminating the mothership
SMTP SPOF for Sovereign Console login.

What ships:

  1. NEW blueprint platform/stalwart-sovereign/ (otech-level — distinct
     from per-tenant bp-stalwart-tenant). Single Stalwart instance per
     Sovereign cluster, scoped to Sovereign Console system mail. NO
     Keycloak OIDC, NO webmail UI — Sovereign Console is the only
     consumer. Auto-provisioned admin + submission Secrets via the
     lookup-or-generate pattern (#898/#830/#887). Post-install Job:
       - registers the noreply submission principal in Stalwart
       - allows send-as for noreply@<sovereignFQDN>
       - reads DKIM public key, patches dns-records ConfigMap
       - materialises catalyst-system/sovereign-smtp-credentials with
         Sovereign-local infrastructure addresses + credentials,
         carrying BOTH key shapes (smtp-user/smtp-pass + legacy
         user/password) so the consumer chart works either way.

  2. NEW bootstrap-kit slot 95 (clusters/_template/bootstrap-kit/
     95-bp-stalwart-sovereign.yaml). dependsOn: bp-cert-manager,
     bp-catalyst-platform. Sequenced after bp-catalyst-platform (slot
     13) so the chart's post-install Job lands its mirror Secret in
     an already-existing catalyst-system namespace.

  3. bp-catalyst-platform 1.4.19 → 1.4.20: SOURCE-wins precedence
     extended to (a) non-secret fields smtp-host/smtp-port/smtp-from
     so Sovereign-local infra addresses (`mail.<sovereignFQDN>`) take
     over from mothership defaults (`mail.openova.io`) on the next
     reconcile after slot 95 lands, and (b) canonical key shape
     `smtp-user`/`smtp-pass` in addition to legacy `user`/`password`
     source key shape.

  4. expected-bootstrap-deps.yaml: declare slot 95 graph edge.

  5. catalyst-api handler/sovereign_smtp_seed.go: documentation-only
     update to note this Phase-1 step is now a graceful fallback —
     the Phase-2 chart's post-install Job overwrites the mirror
     Secret on first reconcile so the cutover from mothership relay
     to Sovereign-local relay is automatic, no operator action.

Verification:
  - `helm template smoke ./platform/stalwart-sovereign/chart` clean
    (smoke-render-safe; per-template gates skip when sovereignFQDN unset).
  - `helm template smoke -f operator-values.yaml` emits StatefulSet,
    LoadBalancer Service, ClusterIP HTTP Service, DKIM-signing config,
    dns-records ConfigMap, Setup Job + RBAC.
  - `chart/tests/sovereign-render.sh` 3 cases all PASS.
  - `helm template smoke ./products/catalyst/chart` (1.4.20) clean.
  - `helm lint` both charts: clean (only icon-recommended INFO).
  - `bash scripts/check-bootstrap-deps.sh` PASSED — bootstrap-kit
    dependency graph audit, 0 drift, 0 cycles.
  - `go test -run TestSeedSovereignSMTP` — Phase-1 seed tests pass.
  - `go test -run TestBootstrapKit_TemplateClusterParses` — slot 95
    YAML parses cleanly.

Out of scope (sub-PR follow-up under #924):
  - DKIM keypair generation in catalyst-api orchestrator + DNS records
    (MX/A/SPF/DMARC/DKIM-pubkey) registration via PDM dynadot adapter
    at omani.works.
  - Hetzner PTR (rDNS) auto-registration via the Hetzner cloud API.
  - Cert-manager Certificate adding mail.<sovereignFQDN> SAN to the
    Sovereign wildcard cert (chart relies on the existing wildcard
    cert from bp-catalyst-platform 1.4.0+'s per-zone Certificate
    template — when that wildcard chain covers the Sovereign FQDN,
    `mail.<sovereignFQDN>` is already covered).

Acceptance (lands when sub-PR follow-up ships):
  - Sovereign Console PIN delivery uses noreply@<sov-fqdn>.
  - External mail server (e.g. Gmail) accepts mail with valid SPF + DKIM.
  - Mothership SMTP no longer SPOF for Sovereign Console login.

Co-authored-by: hatiyildiz <hatiyildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:20:16 +04:00

282 lines
14 KiB
YAML

{{- /*
Auto-provision catalyst-openova-kc-credentials Secret on Sovereign install
(issue #901, login PIN-issue 503 chain).
Why this template exists
========================
templates/api-deployment.yaml lines 676-739 reference a secretKeyRef on
`catalyst-openova-kc-credentials` for the full PIN-auth env block:
- kc-addr, kc-realm, kc-sa-client-id, kc-sa-client-secret, kc-audience
- smtp-host, smtp-port, smtp-from, smtp-user, smtp-pass
On Catalyst-Zero (contabo-mkt) this Secret is hand-rolled by the operator
under clusters/contabo-mkt/apps/.../catalyst-openova-kc-credentials.yaml
and points at the openova-realm running in keycloak-zero (separate ns).
On a freshly franchised Sovereign nothing equivalent existed — every
secretKeyRef has optional=true, so the catalyst-api Pod started, but
POST /api/v1/auth/pin/issue then 503'd with
"CATALYST_OPENOVA_KC_SA_CLIENT_SECRET not set".
Caught live on otech103, 2026-05-05.
Sovereign-vs-Mothership gate (load-bearing)
===========================================
The canonical KC SA source on a Sovereign is the `catalyst-kc-sa-credentials`
Secret in the `keycloak` namespace (created by bp-keycloak's openbao-bridge
post-install hook — see platform/keycloak/chart/templates/, issue #781).
On contabo-mkt Catalyst-Zero uses `keycloak-zero` (separate Helm release
in its own namespace) — there is NO `catalyst-kc-sa-credentials` Secret in
the `keycloak` namespace there.
We gate render on `lookup "v1" "Secret" "keycloak" "catalyst-kc-sa-credentials"`
returning non-nil. This means:
- On a Sovereign: lookup returns the secret → template renders → the
chart auto-provisions catalyst-openova-kc-credentials in
catalyst-system from the keycloak SA Secret + sovereign.smtp values.
- On contabo-mkt: lookup returns nil → template renders empty bytes →
the existing hand-rolled Secret in clusters/contabo-mkt/apps/...
is untouched (no helm-vs-kustomize ownership flap).
Persistence across reconciles (1.4.19, issue #910 Bug 3 fix)
============================================================
Per the marketplace-api/secret.yaml + sme-secrets.yaml + valkey-cross-ns-
secret.yaml pattern (issues #859/#863/#866/#887), this Secret MUST survive
helm upgrade / Flux reconciliation. It carries the SMTP password (rotated
out-of-band by the operator) and the keycloak client-secret (auto-rotated
by openbao).
The lookup contract differs by FIELD CLASS:
KC fields (kc-addr, kc-realm, kc-sa-client-id, kc-sa-client-secret,
kc-audience): EXISTING TARGET WINS over source `keycloak/catalyst-kc-
sa-credentials`. Rationale: bp-keycloak's openbao-bridge post-install
hook auto-rotates the client-secret on every Helm upgrade; the
catalyst-api Pod's secretKeyRef → catalyst-openova-kc-credentials must
survive that rotation without restarting until the operator
explicitly forces it (delete the target Secret to re-mirror from
source).
SMTP fields (smtp-user, smtp-pass): SOURCE WINS over existing target
`catalyst-system/sovereign-smtp-credentials`. Rationale: A5's
provisioner (issue #883) seeds the source Secret AFTER the chart
install fires. Pre-1.4.19 target-wins meant first-install rendered
empty bytes that NEVER got refreshed once A5 finished — POST
/api/v1/auth/pin/issue 502'd with `email-send-failed` for the life of
the cluster. 1.4.19 makes source the canonical seam: every reconcile
re-reads sovereign-smtp-credentials, so as soon as A5 lands the next
Flux 1m tick picks up the bytes. Operator rotation: edit
sovereign-smtp-credentials (the operator-facing seam) — never the
derived target. The target is a projection.
SMTP non-secret fields (smtp-host, smtp-port, smtp-from): EXISTING
TARGET WINS over `.Values.sovereign.smtp.*`. Rationale: these are
operator-tunable infrastructure addresses (mail.openova.io vs a
Sovereign-local Stalwart relay); once an operator overrides via the
per-Sovereign overlay or a direct kubectl edit, the bytes persist.
First install (lookup returns nil for both sources): renders empty
bytes for SMTP creds, falls back to .Values.sovereign.smtp.* for the
non-secret fields. The catalyst-api Pod starts cleanly because every
secretKeyRef has optional=true; PIN email send returns a clear
`email-send-failed` log line until A5's seed lands and the next Flux
tick refreshes the target.
helm.sh/resource-policy: keep — survives helm uninstall so a re-install
picks up the same bytes via lookup.
Per docs/INVIOLABLE-PRINCIPLES.md #10: NO plaintext credentials in this
template. All values flow through Helm `lookup` of existing K8s Secrets
or through .Values.sovereign.smtp.{host,port,from} which are non-secret
infrastructure addresses (mail.openova.io / 587 / noreply@openova.io
defaults).
*/}}
{{- $kcSrc := lookup "v1" "Secret" "keycloak" "catalyst-kc-sa-credentials" -}}
{{- if and $kcSrc $kcSrc.data -}}
{{- $secretName := "catalyst-openova-kc-credentials" -}}
{{- $namespace := .Release.Namespace -}}
{{- /* ---- Persistent target lookup ---- */ -}}
{{- $existing := lookup "v1" "Secret" $namespace $secretName -}}
{{- /* ---- KC fields: prefer existing target → fall back to source Secret ---- */ -}}
{{- $kcAddr := "" -}}
{{- $kcRealm := "" -}}
{{- $kcClientID := "" -}}
{{- $kcClientSecret := "" -}}
{{- $kcAudience := "" -}}
{{- if and $existing $existing.data (index $existing.data "kc-addr") -}}
{{- $kcAddr = index $existing.data "kc-addr" | b64dec -}}
{{- else if (index $kcSrc.data "addr") -}}
{{- $kcAddr = index $kcSrc.data "addr" | b64dec -}}
{{- end -}}
{{- if and $existing $existing.data (index $existing.data "kc-realm") -}}
{{- $kcRealm = index $existing.data "kc-realm" | b64dec -}}
{{- else if (index $kcSrc.data "realm") -}}
{{- $kcRealm = index $kcSrc.data "realm" | b64dec -}}
{{- end -}}
{{- if and $existing $existing.data (index $existing.data "kc-sa-client-id") -}}
{{- $kcClientID = index $existing.data "kc-sa-client-id" | b64dec -}}
{{- else if (index $kcSrc.data "client-id") -}}
{{- $kcClientID = index $kcSrc.data "client-id" | b64dec -}}
{{- end -}}
{{- if and $existing $existing.data (index $existing.data "kc-sa-client-secret") -}}
{{- $kcClientSecret = index $existing.data "kc-sa-client-secret" | b64dec -}}
{{- else if (index $kcSrc.data "client-secret") -}}
{{- $kcClientSecret = index $kcSrc.data "client-secret" | b64dec -}}
{{- end -}}
{{- /* kc-audience: source has no `audience` key — default to client-id (the
Keycloak client itself is the audience for token-exchange in the
PIN-auth flow). Existing target wins if set. */ -}}
{{- if and $existing $existing.data (index $existing.data "kc-audience") -}}
{{- $kcAudience = index $existing.data "kc-audience" | b64dec -}}
{{- else -}}
{{- $kcAudience = $kcClientID -}}
{{- end -}}
{{- /* ---- SMTP creds: SOURCE-wins lookup against sovereign-smtp-credentials ---- */ -}}
{{- /* Provisioner (issue #883, agent A5) seeds catalyst-system/sovereign-smtp-credentials
at handover time. Keys (Phase-1 seed): smtp-user, smtp-pass.
Phase-2 (issue #924, bp-stalwart-sovereign): the per-Sovereign
Stalwart chart's post-install Job re-materialises the SAME
Secret with BOTH key shapes — `smtp-user`/`smtp-pass` (Phase-1
contract) AND `user`/`password` (legacy contract referenced
below). Either path yields the same bytes; the lookup below
checks `smtp-user`/`smtp-pass` FIRST (canonical Phase-1+ shape)
and falls back to `user`/`password` for older sources.
Why SOURCE wins over the persisted target (issue #910 Bug 3)
=============================================================
Pre-1.4.19 the target persistence (`existing` Secret) won over the
source: once any value (including empty) landed in the persisted
target, subsequent reconciles re-emitted the persisted bytes and
NEVER rechecked the source.
Live failure mode caught on otech105, 2026-05-05:
t0: chart 1.4.18 install fires.
- sovereign-smtp-credentials NOT YET seeded (A5's
seedSovereignSMTP step runs concurrently with the chart
reconcile, not strictly before it).
- lookup → nil → smtp-user/smtp-pass render as "".
- Secret created with empty SMTP creds.
t+30s: A5 finishes, sovereign-smtp-credentials Secret exists
with real bytes.
t+1m: Flux reconciles bp-catalyst-platform.
- existing target Secret has smtp-user="" (key present, value empty).
- The OLD existing-wins guard `(index $existing.data "smtp-user")`
returns the b64-encoded empty string `""` — that string
IS falsy in Go templates, so the OLD code DID fall through
to the source. BUT: an operator who hand-set smtp-user
to a non-empty value would have it overwritten by every
subsequent reconcile because the source ALSO won then.
That's a footgun.
Better contract: SOURCE always wins for SMTP creds. The
sovereign-smtp-credentials Secret is the operator-rotatable
seam; the catalyst-openova-kc-credentials Secret is a chart-
derived projection. If the operator wants to override they
edit the source (sovereign-smtp-credentials) — never the
target. This matches docs/INVIOLABLE-PRINCIPLES.md #4 (single
source of truth — runtime configuration, not lookup-derived
drift).
Fallback to existing target ONLY when source is missing —
preserves bytes across helm-uninstall-then-reinstall when
the operator deleted the source after first install. After
A5's seed lands, every subsequent reconcile picks up the
current source bytes immediately. */ -}}
{{- $smtpSrc := lookup "v1" "Secret" $namespace "sovereign-smtp-credentials" -}}
{{- $smtpUser := "" -}}
{{- $smtpPass := "" -}}
{{- /* SOURCE-wins precedence with key-shape compatibility:
1. Phase-1+ canonical keys `smtp-user`/`smtp-pass` (A5's seed +
bp-stalwart-sovereign #924 chart writes).
2. Legacy keys `user`/`password` (older mothership-derived
sources kept for back-compat).
3. Existing-target fallback (`smtp-user`/`smtp-pass` in the
chart-rendered Secret) — only when both source shapes are
absent. */ -}}
{{- if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "smtp-user") -}}
{{- $smtpUser = index $smtpSrc.data "smtp-user" | b64dec -}}
{{- else if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "user") -}}
{{- $smtpUser = index $smtpSrc.data "user" | b64dec -}}
{{- else if and $existing $existing.data (index $existing.data "smtp-user") -}}
{{- $smtpUser = index $existing.data "smtp-user" | b64dec -}}
{{- end -}}
{{- if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "smtp-pass") -}}
{{- $smtpPass = index $smtpSrc.data "smtp-pass" | b64dec -}}
{{- else if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "password") -}}
{{- $smtpPass = index $smtpSrc.data "password" | b64dec -}}
{{- else if and $existing $existing.data (index $existing.data "smtp-pass") -}}
{{- $smtpPass = index $existing.data "smtp-pass" | b64dec -}}
{{- end -}}
{{- /* SMTP non-secret fields (smtp-host, smtp-port, smtp-from):
SOURCE-wins lookup as well — bp-stalwart-sovereign's mirror
Secret carries the Sovereign-local infrastructure addresses
(`mail.<sovereignFQDN>` / `587` / `noreply@<sovereignFQDN>`).
Pre-#924 the source carried only credentials; the chart fell
back to `.Values.sovereign.smtp.*` defaults (`mail.openova.io`).
Post-#924 the source carries Sovereign-local addresses too,
and source-wins ensures Phase-2 cutover is automatic on the
next Flux reconcile after bp-stalwart-sovereign installs.
Existing-target stays the second fallback (back-compat for
operator-edited targets). */ -}}
{{- $smtpHostSrc := "" -}}
{{- $smtpPortSrc := "" -}}
{{- $smtpFromSrc := "" -}}
{{- if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "smtp-host") -}}
{{- $smtpHostSrc = index $smtpSrc.data "smtp-host" | b64dec -}}
{{- end -}}
{{- if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "smtp-port") -}}
{{- $smtpPortSrc = index $smtpSrc.data "smtp-port" | b64dec -}}
{{- end -}}
{{- if and $smtpSrc $smtpSrc.data (index $smtpSrc.data "smtp-from") -}}
{{- $smtpFromSrc = index $smtpSrc.data "smtp-from" | b64dec -}}
{{- end -}}
{{- /* ---- SMTP non-secret fields: source → target → values defaults ---- */ -}}
{{- /* Phase-2 (#924): bp-stalwart-sovereign's mirror Secret carries
Sovereign-local `mail.<sovereignFQDN>` / `noreply@<sovereignFQDN>`.
Source-wins so cutover is automatic. */ -}}
{{- $smtpHost := .Values.sovereign.smtp.host -}}
{{- $smtpPort := .Values.sovereign.smtp.port -}}
{{- $smtpFrom := .Values.sovereign.smtp.from -}}
{{- if $smtpHostSrc -}}
{{- $smtpHost = $smtpHostSrc -}}
{{- else if and $existing $existing.data (index $existing.data "smtp-host") -}}
{{- $smtpHost = index $existing.data "smtp-host" | b64dec -}}
{{- end -}}
{{- if $smtpPortSrc -}}
{{- $smtpPort = $smtpPortSrc -}}
{{- else if and $existing $existing.data (index $existing.data "smtp-port") -}}
{{- $smtpPort = index $existing.data "smtp-port" | b64dec -}}
{{- end -}}
{{- if $smtpFromSrc -}}
{{- $smtpFrom = $smtpFromSrc -}}
{{- else if and $existing $existing.data (index $existing.data "smtp-from") -}}
{{- $smtpFrom = index $existing.data "smtp-from" | b64dec -}}
{{- end -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
namespace: {{ $namespace }}
labels:
catalyst.openova.io/blueprint: bp-catalyst-platform
catalyst.openova.io/component: catalyst-api
app.kubernetes.io/part-of: catalyst
annotations:
# Survive helm uninstall — the Secret outlives the release. A
# subsequent helm install picks up the bytes via lookup against the
# source Secrets (and the persisted target if still present).
helm.sh/resource-policy: keep
type: Opaque
data:
kc-addr: {{ $kcAddr | b64enc | quote }}
kc-realm: {{ $kcRealm | b64enc | quote }}
kc-sa-client-id: {{ $kcClientID | b64enc | quote }}
kc-sa-client-secret: {{ $kcClientSecret | b64enc | quote }}
kc-audience: {{ $kcAudience | b64enc | quote }}
smtp-host: {{ $smtpHost | b64enc | quote }}
smtp-port: {{ $smtpPort | b64enc | quote }}
smtp-from: {{ $smtpFrom | b64enc | quote }}
smtp-user: {{ $smtpUser | b64enc | quote }}
smtp-pass: {{ $smtpPass | b64enc | quote }}
{{- end }}