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>
282 lines
14 KiB
YAML
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 }}
|