docs(log): record the prod OIDC-URL outage and the rule it establishes
Non-secret, environment-specific values must not live inside an encrypted
Secret: Flux's ${BASE_DOMAIN} substitution rewrites rendered manifest text and
cannot reach inside ciphertext, so such a value silently freezes at whatever
one environment happened to need. Dev was correct only by coincidence (dev IS
the apex); prod was wrong from its first reconcile, for both Headlamp and
Semaphore. StrictPostBuildSubstitutions cannot catch this — there is no
unresolved ${...} to complain about.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
6548f9a89c
commit
e65a76dc51
1 file changed
+89
@@ -1758,3 +1758,92 @@ into a transient hard Kustomization failure.
|
|||||||
- **Prod's source URL cannot work on a cold prod cluster** — split-horizon answers the whole zone
|
- **Prod's source URL cannot work on a cold prod cluster** — split-horizon answers the whole zone
|
||||||
with `${GATEWAY_VIP}`, so prod would resolve `gitea.home.keithjackson.dev` to its own gateway.
|
with `${GATEWAY_VIP}`, so prod would resolve `gitea.home.keithjackson.dev` to its own gateway.
|
||||||
Bring-up needs a temporary source and a cutover, as `docs/05` describes for dev's own Phase-6.
|
Bring-up needs a temporary source and a cutover, as `docs/05` describes for dev's own Phase-6.
|
||||||
|
|
||||||
|
## 2026-09-07 — LIVE BUG: prod's OIDC clients were pointed at DEV's Keycloak. Secrets are a substitution blind spot.
|
||||||
|
|
||||||
|
The operator could not log in to Headlamp on prod:
|
||||||
|
|
||||||
|
```
|
||||||
|
Get "https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration":
|
||||||
|
dial tcp 192.168.1.53:443: i/o timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
`keycloak.home.keithjackson.dev` is **dev's** Keycloak. It resolves to `192.168.1.53`, on the dev
|
||||||
|
LAN behind pfSense; prod lives on `192.168.2.0/24` (pfSense's WAN) and cannot route there, so the
|
||||||
|
discovery fetch simply hung until it timed out.
|
||||||
|
|
||||||
|
### Root cause — the class, not the instance
|
||||||
|
|
||||||
|
**Flux's `${BASE_DOMAIN}` postBuild substitution rewrites the RENDERED MANIFEST TEXT. It can never
|
||||||
|
reach inside a SOPS-encrypted Secret.** Environment-specific URLs had been stored *inside* two
|
||||||
|
encrypted Secrets purely because it was convenient to keep one blob per app:
|
||||||
|
|
||||||
|
| File | Buried, un-substitutable values |
|
||||||
|
|---|---|
|
||||||
|
| `platform/headlamp/headlamp-oidc-credentials.sops.yaml` | `OIDC_ISSUER_URL`, `OIDC_CALLBACK_URL` |
|
||||||
|
| `apps/wave-1/semaphore-bootstrap-secret.sops.yaml` (`oidc-providers-json`) | the realm issuer + the OIDC redirect URI |
|
||||||
|
|
||||||
|
Both were frozen at the apex `home.keithjackson.dev`. **Dev IS the apex, so dev was correct by
|
||||||
|
coincidence** — the values were never per-environment, they just happened to match one environment.
|
||||||
|
Prod was wrong from the first reconcile. Semaphore on prod was broken the same way and nobody had
|
||||||
|
noticed, because nobody had tried prod SSO yet.
|
||||||
|
|
||||||
|
This is the same failure shape as the 2026-09-01 nextcloud guard: **a value that silently resolves
|
||||||
|
to something plausible is worse than one that fails.** `StrictPostBuildSubstitutions` cannot catch
|
||||||
|
it either — there is no `${...}` left unresolved to complain about; the ciphertext never contained
|
||||||
|
one.
|
||||||
|
|
||||||
|
### The rule this establishes
|
||||||
|
|
||||||
|
> **Non-secret, environment-specific values MUST NOT live inside an encrypted Secret.** Issuer URLs,
|
||||||
|
> callback URLs, redirect URIs and hostnames are public. Encrypt the secret half only, and template
|
||||||
|
> the rest where `${BASE_DOMAIN}` can reach it.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
- **Headlamp** — `OIDC_ISSUER_URL` / `OIDC_CALLBACK_URL` moved to the HelmRelease's
|
||||||
|
`spec.values.env`, templated `${BASE_DOMAIN}`. The Secret keeps `OIDC_CLIENT_SECRET` +
|
||||||
|
the environment-invariant `OIDC_CLIENT_ID` / `OIDC_SCOPES`, still `envFrom`'d.
|
||||||
|
**Exactly one source per variable** — an explicit `env:` entry silently outranks the same name
|
||||||
|
arriving via `envFrom`, so the two sets deliberately do not overlap.
|
||||||
|
Side effect, intended: naming `OIDC_CALLBACK_URL` in `env:` makes chart 0.41.0 finally emit
|
||||||
|
`-oidc-callback-url=$(OIDC_CALLBACK_URL)`; it gates that arg on `env:`/`config.oidc`, which a
|
||||||
|
Secret key alone never tripped — so the Secret's callback URL had been dead weight all along.
|
||||||
|
- **Semaphore** — the `SEMAPHORE_OIDC_PROVIDERS` JSON is now a plaintext literal in
|
||||||
|
`catalog/semaphore.yaml` (re-rendered to the `apps/wave-1` golden). Only the client secret stays
|
||||||
|
encrypted, in its own key `oidc-client-secret`, and the **kubelet** splices it back via
|
||||||
|
dependent-env-var expansion: `$(SEMAPHORE_OIDC_CLIENT_SECRET)` resolves against the `secretKeyRef`
|
||||||
|
env var declared immediately above it. **Order is load-bearing** — the kubelet resolves `env` in
|
||||||
|
list order and leaves a forward reference as literal text. `flux envsubst` ignores `$(...)`, so
|
||||||
|
the two expansion layers do not collide (the `$${VAR}` escape in `apps/wave-1/nextcloud.yaml`
|
||||||
|
documents the one form that DOES need escaping).
|
||||||
|
|
||||||
|
### Proof
|
||||||
|
|
||||||
|
Rendered per overlay (`kubectl kustomize --load-restrictor LoadRestrictionsNone`, then
|
||||||
|
`flux envsubst --strict` with that overlay's `cluster-vars`), plus `helm template` of headlamp
|
||||||
|
0.41.0 against the substituted values:
|
||||||
|
|
||||||
|
| | dev | prod |
|
||||||
|
|---|---|---|
|
||||||
|
| Headlamp `OIDC_ISSUER_URL` | `https://keycloak.home.keithjackson.dev/realms/homelab` | `https://keycloak.prod.home.keithjackson.dev/realms/homelab` |
|
||||||
|
| Headlamp `OIDC_CALLBACK_URL` | `https://headlamp.home.keithjackson.dev/oidc-callback` | `https://headlamp.prod.home.keithjackson.dev/oidc-callback` |
|
||||||
|
| Semaphore `provider_url` | `https://keycloak.home.keithjackson.dev/realms/homelab` | `https://keycloak.prod.home.keithjackson.dev/realms/homelab` |
|
||||||
|
| Semaphore `redirect_url` | `https://semaphore.home.keithjackson.dev/api/...` | `https://semaphore.prod.home.keithjackson.dev/api/...` |
|
||||||
|
|
||||||
|
Dev's values are byte-identical to what it runs today — this change moves prod only.
|
||||||
|
|
||||||
|
### [parity] the apiserver invariant HOLDS
|
||||||
|
|
||||||
|
Prod's kube-apiserver `oidc-issuer-url` is an **install-time k3s flag** derived from
|
||||||
|
`k3s_base_domain` (`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` = `prod.home.keithjackson.dev`),
|
||||||
|
not from `cluster-vars` — it cannot read the Flux variable. Headlamp forwards the user's `id_token`
|
||||||
|
to the apiserver, so the two must name the same issuer. After this change they do, in both
|
||||||
|
environments, because both derive from the same per-environment domain. Unchanged risk, now written
|
||||||
|
down in `platform/headlamp/README.md`: moving one without the other 401s every request.
|
||||||
|
|
||||||
|
### Also fixed for the next person
|
||||||
|
|
||||||
|
`headlamp-oidc-credentials.sops.yaml.example` used to *teach* the broken pattern — it listed
|
||||||
|
`OIDC_ISSUER_URL` and `OIDC_CALLBACK_URL` as keys to fill in. It now says, in the file, that adding
|
||||||
|
either key back re-breaks prod.
|
||||||
Reference in new issue
Block a user