fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy #70

Merged
jakman85 merged 1 commits from fix/oauth2-proxy-oidc-startup-race into main 2026-10-18 04:14:52 +00:00
Owner

The bug

Exposed by the 2026-10-17 whole-cluster shutdown for the UPS move. oauth2-proxy performs OIDC discovery once, at startup, and exit 1s if it fails. On a cold boot Keycloak is not serving yet, so the shared Gateway answers 503 and the proxy dies:

[provider.go:55] Performing OIDC Discovery...
[main.go:73] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: ...
             failed to discover OIDC configuration: unexpected status "503": no healthy upstream

It then sits in exponential CrashLoopBackOff long after Keycloak is healthy — one of the two replicas was still down minutes later, and only a manual kubectl delete pod cleared it.

There is no retry flag; the process does not survive a cold start. A readinessProbe cannot help — the container is already dead — and CrashLoopBackOff backoff is not tunable per-pod.

The fix

An extraInitContainers gate (wait-for-idp) that polls Keycloak's discovery doc until it answers, then lets the proxy start. A pod in Init accrues no CrashLoopBackOff backoff and retries promptly, which is the whole point.

Bounded, and fails open. After ~5 minutes it gives up and starts the proxy anyway, so the worst case is exactly today's behaviour and never something new. An unbounded wait would trade a CrashLoop for a pod wedged in Init forever, which is not an improvement.

Why this shape

  • extraInitContainers is the chart's own hook for exactly this — its commented example in values.yaml is literally - name: wait-for-idp. The key was confirmed against the pinned chart (helm show values oauth2-proxy/oauth2-proxy --version 10.7.1), not assumed: a wrong Helm values key is silently ignored, which is the "reviewed, merged, apparently done, still broken" shape this repo keeps hitting.
  • No new base image. The chart already runs an alpine init container for its built-in initContainers.waitForRedis gate. Pinned to 3.22 (verified to exist upstream) rather than the chart's own latest.
  • Rejected — Flux postRenderers: works independently of the chart schema, but nothing in this tree uses postRenderers, so it would add a mechanism with no precedent for a problem the chart already has a hook for.
  • Rejected — --skip-oidc-discovery: removes the startup fetch entirely and is arguably more elegant, but it means hardcoding four Keycloak endpoints, and I could not verify offline that oauth2-proxy then fetches JWKS lazily rather than at startup. This component gates SSO for PiHole, the *arr apps, qBittorrent, Homepage, Prometheus, Alertmanager and Hubble — a wrong guess locks the operator out of all of them, so the additive, reversible option wins.

Shell variables are written bare ($i, $url, $(( ))) with no braced form: platform declares postBuild.substituteFrom, and envsubst would blank a braced shell variable and silently break the loop. Same hazard and convention as the gitea/nextcloud OIDC bootstrap hooks; ci: flux-postbuild-substitution enforces it.

Verification

The failure is measured, not theorised — the log above is from the live pod (lastState.terminated: {reason: Error, exitCode: 1}) captured with --previous, and the surviving replica's log shows the same startup succeeding once Keycloak was up, which is what makes it a race rather than a config fault.

Not verified, and one point needs your eyes: this is unapplied — the init container has never run, and I did not re-create the race to prove the fix (that needs Keycloak down while oauth2-proxy starts; the honest test is the next cold boot). busybox wget's TLS behaviour here is assumed: if it cannot validate the Gateway cert, the gate degrades safely (burns the 5-minute budget, then starts the proxy anyway) but the fast path would be silently dead. Check the init container's log on first apply — it should print "Keycloak discovery reachable" within seconds on a warm cluster, not the give-up message. Applying this rolls both replicas of the component gating SSO for seven apps, so do it when you can watch.

Local gate: rc 3, zero failures — only gitea-settings (CI-only token) and L2/L3 conformance (CLUSTER_AVAILABLE unset) skipped, both structurally impossible locally per CLAUDE.md §3.

Full detail: decisions/2026-10-17-oauth2-proxy-oidc-startup-race.md

🤖 Generated with Claude Code

## The bug Exposed by the 2026-10-17 whole-cluster shutdown for the UPS move. **oauth2-proxy performs OIDC discovery once, at startup, and `exit 1`s if it fails.** On a cold boot Keycloak is not serving yet, so the shared Gateway answers 503 and the proxy dies: ``` [provider.go:55] Performing OIDC Discovery... [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: ... failed to discover OIDC configuration: unexpected status "503": no healthy upstream ``` It then sits in **exponential CrashLoopBackOff long after Keycloak is healthy** — one of the two replicas was still down minutes later, and only a manual `kubectl delete pod` cleared it. There is no retry flag; the process does not survive a cold start. A readinessProbe cannot help — the container is already dead — and CrashLoopBackOff backoff is not tunable per-pod. ## The fix An `extraInitContainers` gate (`wait-for-idp`) that polls Keycloak's discovery doc until it answers, then lets the proxy start. **A pod in `Init` accrues no CrashLoopBackOff backoff and retries promptly**, which is the whole point. **Bounded, and fails open.** After ~5 minutes it gives up and starts the proxy anyway, so the worst case is exactly today's behaviour and never something new. An unbounded wait would trade a CrashLoop for a pod wedged in `Init` forever, which is not an improvement. ## Why this shape - **`extraInitContainers` is the chart's own hook for exactly this** — its commented example in `values.yaml` is literally `- name: wait-for-idp`. The key was **confirmed against the pinned chart** (`helm show values oauth2-proxy/oauth2-proxy --version 10.7.1`), not assumed: a wrong Helm values key is silently ignored, which is the "reviewed, merged, apparently done, still broken" shape this repo keeps hitting. - **No new base image.** The chart already runs an `alpine` init container for its built-in `initContainers.waitForRedis` gate. Pinned to `3.22` (verified to exist upstream) rather than the chart's own `latest`. - **Rejected — Flux `postRenderers`:** works independently of the chart schema, but nothing in this tree uses postRenderers, so it would add a mechanism with no precedent for a problem the chart already has a hook for. - **Rejected — `--skip-oidc-discovery`:** removes the startup fetch entirely and is arguably more elegant, but it means hardcoding four Keycloak endpoints, and I could not verify offline that oauth2-proxy then fetches JWKS lazily rather than at startup. This component gates SSO for PiHole, the *arr apps, qBittorrent, Homepage, Prometheus, Alertmanager and Hubble — a wrong guess locks the operator out of all of them, so the additive, reversible option wins. Shell variables are written **bare** (`$i`, `$url`, `$(( ))`) with no braced form: `platform` declares `postBuild.substituteFrom`, and envsubst would blank a braced shell variable and silently break the loop. Same hazard and convention as the gitea/nextcloud OIDC bootstrap hooks; `ci: flux-postbuild-substitution` enforces it. ## Verification The failure is measured, not theorised — the log above is from the live pod (`lastState.terminated: {reason: Error, exitCode: 1}`) captured with `--previous`, and the surviving replica's log shows the same startup succeeding once Keycloak was up, which is what makes it a race rather than a config fault. **Not verified, and one point needs your eyes:** this is **unapplied** — the init container has never run, and I did not re-create the race to prove the fix (that needs Keycloak down while oauth2-proxy starts; the honest test is the next cold boot). **busybox `wget`'s TLS behaviour here is assumed**: if it cannot validate the Gateway cert, the gate degrades safely (burns the 5-minute budget, then starts the proxy anyway) but the fast path would be silently dead. **Check the init container's log on first apply** — it should print "Keycloak discovery reachable" within seconds on a warm cluster, not the give-up message. Applying this rolls both replicas of the component gating SSO for seven apps, so do it when you can watch. Local gate: **rc 3, zero failures** — only `gitea-settings` (CI-only token) and L2/L3 `conformance` (`CLUSTER_AVAILABLE` unset) skipped, both structurally impossible locally per CLAUDE.md §3. Full detail: `decisions/2026-10-17-oauth2-proxy-oidc-startup-race.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-10-18 03:59:02 +00:00
fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
c150327a30
Exposed by the 2026-10-17 whole-cluster shutdown. oauth2-proxy performs OIDC
discovery ONCE at startup and exits 1 if it fails. On a cold boot Keycloak is
not serving yet, the shared Gateway answers 503, and the proxy dies:

  [provider.go:55] Performing OIDC Discovery...
  [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover
               OIDC configuration: unexpected status "503": no healthy upstream

It then sits in exponential CrashLoopBackOff long after Keycloak is healthy --
one replica was still down minutes later and only a manual pod delete cleared
it. There is no retry flag, a readinessProbe cannot help (the container is
already dead), and CrashLoopBackOff backoff is not tunable per-pod.

Add an extraInitContainers gate (wait-for-idp) that polls the discovery doc
until it answers. A pod in Init accrues no CrashLoopBackOff backoff and retries
promptly. BOUNDED and FAILS OPEN: after ~5m it starts the proxy anyway, so the
worst case is exactly today's behaviour and never something new.

extraInitContainers is the chart's OWN hook for this -- its commented example is
literally `- name: wait-for-idp` -- and the key was CONFIRMED against the pinned
chart (helm show values, 10.7.1) rather than assumed, because a wrong Helm values
key is silently ignored. No new base image: the chart already runs an alpine init
container for its built-in waitForRedis gate; pinned to 3.22 (verified upstream)
rather than the chart's `latest`.

Rejected: Flux postRenderers (no precedent in this tree, and the chart has a
hook); --skip-oidc-discovery (removes the fetch entirely, but needs four
hardcoded endpoints and I could not verify lazy-JWKS offline -- this component
gates SSO for seven apps, so the additive option wins).

Shell vars are written BARE with no braced form: platform declares
postBuild.substituteFrom, and envsubst would blank a braced shell variable and
silently break the loop. Same convention as the gitea/nextcloud OIDC hooks.

UNAPPLIED; the init container has never run, and the race was not re-created to
prove the fix. Check the init container's log on first apply. See the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
jakman85 merged commit 1a0b20857a into main 2026-10-18 04:14:52 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#70