Skip to content

fix(webhook): pre-populate Keycloak client-credentials annotation at admission - #356

Merged
huang195 merged 3 commits into
rossoctl:mainfrom
huang195:fix/mutator-eager-keycloak-secret-mount
May 12, 2026
Merged

fix(webhook): pre-populate Keycloak client-credentials annotation at admission#356
huang195 merged 3 commits into
rossoctl:mainfrom
huang195:fix/mutator-eager-keycloak-secret-mount

Conversation

@huang195

Copy link
Copy Markdown
Member

Summary

First-deploy race between the AuthBridge mutating webhook and the ClientRegistration controller: on a fresh deploy, the webhook runs before the controller has created the per-workload Keycloak credentials Secret, builds the pod with an empty /shared/, and envoy's jwt-validation plugin returns 503 "identity not yet configured (credentials pending)" indefinitely until the user manually deletes the pod.

This PR fixes the race by letting the webhook pre-populate the kagenti.io/keycloak-client-credentials-secret-name annotation on the incoming pod at admission time. The Secret name is a pure function of (namespace, workload) — the webhook can compute it without consulting the controller or the API server. ApplyKeycloakClientCredentialsSecretVolumes then declares a Secret volume with Optional=false, and kubelet holds the pod in ContainerCreating with a FailedMount event until the controller creates the Secret — a standard lazy-resolve pattern.

Observed repro in the field: operator v0.2.0-rc.3 on Kind + Sandbox-based weather agent. Pod log showed the mutator decision "deliveryPaths":"skip","keycloakClientCredentialsSecretName":"" at pod CREATE; ~5 seconds later, "operator client registration applied" with secret=kagenti-keycloak-client-credentials-<hash>. Secret existed, pod did not mount it. kubectl delete pod triggered recreate, mutator saw the annotation (now in the template), mounted correctly. Post-fix: single pod lifecycle, no restart.

Why this is better than today

Scenario Today After this PR
Fresh deploy Pod Running with empty /shared/ → envoy 503s → user deletes pod to "fix" it Pod ContainerCreating a few seconds → mount resolves → Running; envoy works on first start
Keycloak down / admin creds missing Pod Running and silently wrong Pod ContainerCreating with visible FailedMount event — observable, actionable
Legacy sidecar mode (kagenti.io/client-registration-inject=true) Sidecar populates /shared/ No Secret mount added (workload is ineligible); sidecar still populates /shared/
Tool + injectTools=false Skipped Skipped (identical)

Code layout

  • internal/clientreg/ (new) — one source of truth for the two load-bearing helpers the controller and the webhook both need:

    • KeycloakClientCredentialsSecretName(namespace, workload) string — deterministic name.
    • SkipReason(labels, injectTools) string / WorkloadWantsOperatorClientReg(labels, injectTools) bool — shared eligibility.
    • Constants (AnnotationKeycloakClientSecretName, label names/values) previously duplicated across packages.
  • internal/controller/clientregistration_controller.go — the three functions collapse to one-line wrappers over clientreg. The controller keeps its local constants as aliases for its existing callers. No behavior change.

  • internal/webhook/v1alpha1/authbridge_webhook.go — 6 lines of real logic inserted right after deriveWorkloadName, before isAlreadyInjected:

    if pod.Annotations[injector.AnnotationKeycloakClientSecretName] == "" &&
        clientreg.WorkloadWantsOperatorClientReg(pod.Labels, w.Mutator.GetFeatureGates().InjectTools) {
        if pod.Annotations == nil {
            pod.Annotations = map[string]string{}
        }
        pod.Annotations[injector.AnnotationKeycloakClientSecretName] =
            clientreg.KeycloakClientCredentialsSecretName(req.Namespace, resourceName)
    }

SPIRE / non-SPIRE

Works for both. KeycloakClientCredentialsSecretName is a pure function of (namespace, workload) — SPIRE only changes what resolveKeycloakClientID (inside the controller) writes as the Secret's content, not the Secret's name. The webhook never needs to know about SPIRE.

Agreement between webhook and controller

Verified that deriveWorkloadName(pod) (webhook) equals the controller's workloadName for all three supported kinds:

Kind Pod input deriveWorkloadName Controller's workloadName
Deployment GenerateName=<dep>-<hash>- + label pod-template-hash=<hash> <dep> (strips hash) dep.Name
StatefulSet GenerateName=<sts>- <sts> sts.Name
Sandbox Name=<sbx> <sbx> sbx.GetName()

Test plan

  • go vet ./internal/clientreg/ ./internal/controller/ ./internal/webhook/v1alpha1/ — clean
  • golangci-lint run — zero new issues on touched files (pre-existing issues in unrelated files untouched)
  • go test ./internal/clientreg/ ./internal/controller/ ./internal/webhook/v1alpha1/ — all pass
  • Webhook envtest: 17/17 specs (13 pre-existing + 4 new)
  • Manual cluster repro: operator v0.2.0-rc.3 on Kind, Sandbox-based weather agent, confirmed pod went from "Running with empty /shared/, 503s forever" to "briefly ContainerCreating then Running with creds mounted"
  • CI green

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>

…admission

On a fresh deploy of a Sandbox-based agent (and the same pattern for fresh
Deployment/StatefulSet workloads), the AuthBridge mutating webhook ran
before the ClientRegistration controller had produced the per-workload
Keycloak client credentials Secret. The webhook saw no annotation on the
pod, decided `deliveryPaths: skip`, and built the pod with an emptyDir at
/shared/. Moments later the controller created the Secret and patched the
owning workload's pod template with the annotation — but that patch does
nothing to the already-running pod. The envoy-proxy jwt-validation plugin
polled /shared/client-id.txt, timed out, and returned
`503 "identity not yet configured (credentials pending)"` on every inbound
request until the user manually deleted the pod so the owning controller
would recreate it from the updated template.

Fix: at admission time, when the workload is eligible for operator-managed
client registration (labels say agent, or tool with the injectTools gate
on, and not opted into the legacy client-registration sidecar), compute
the deterministic Secret name and set the annotation on the pod before
sidecar injection runs. The existing ApplyKeycloakClientCredentialsSecretVolumes
call then adds a Secret volume with Optional=false. If the Secret does not
exist yet, kubelet holds the pod in ContainerCreating with a FailedMount
event and retries until the controller creates it — a standard lazy-resolve
pattern and strictly better than today's silent 503: the problem is now
observable via `kubectl describe pod` instead of hidden behind an
"Agent error: 503" in the UI.

Code layout:
- Extract the shared helpers (KeycloakClientCredentialsSecretName,
  WorkloadWantsOperatorClientReg, SkipReason) and the constants they share
  with the controller into a new internal/clientreg package. The controller
  and webhook now call the same functions, so they cannot drift. The
  controller keeps thin aliases so local callers are unchanged.
- The webhook imports clientreg and calls the two helpers directly. Six
  new lines of real logic in authbridge_webhook.go, plus an early log line
  for observability.

Tests:
- New unit tests in internal/clientreg for the name function and
  SkipReason coverage (nil labels, agent, tool with/without gate, legacy
  opt-in).
- New envtest cases in authbridge_webhook_test.go covering: eligible agent
  gets annotation + Secret volume; existing annotation is not overwritten;
  tool workload with the gate off is not pre-populated; legacy opt-in is
  not pre-populated.

Verified locally: go vet clean on touched packages; golangci-lint clean on
touched files; all three affected package test suites pass (17/17 specs in
the webhook suite, up from 13).

Works for both SPIRE and non-SPIRE configurations because the Secret *name*
is a pure function of (namespace, workload) — SPIRE only changes the
Secret *contents* (SPIFFE ID vs ns/workload as clientID), not the name the
webhook needs to declare at admission time.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>

@pdettori pdettori left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent PR — well-motivated race fix with clean architecture (shared clientreg package), strong commit message, and good test coverage. The centralization of naming/eligibility logic eliminates controller-webhook drift by construction. The webhook pre-population + kubelet lazy-mount is a sound pattern.

Two minor test coverage suggestions below (non-blocking).

Areas reviewed: Go, Tests, Cross-file consistency, Security
Commits: 1 commit, signed-off: yes
CI status: All passing except E2E (pending)


Expect(pod.Annotations).NotTo(HaveKey(injector.AnnotationKeycloakClientSecretName))
})
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Missing test case for the positive tool path — tool workload with injectTools=true. The suite covers gate-off (skip) and legacy-opt-in (skip), but not the eligible-tool scenario. Would strengthen confidence that the webhook correctly pre-populates the annotation for tools when the gate is enabled.

// The webhook should also have declared the Secret volume (lazy-resolved by kubelet).
found := false
for _, v := range pod.Spec.Volumes {
if v.Secret != nil && v.Secret.SecretName == pod.Annotations[injector.AnnotationKeycloakClientSecretName] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The test checks that a Secret volume exists with the matching name, but does not assert Optional is false (or nil). Since the PR description highlights Optional=false as the key behavioral mechanism that gives lazy-resolve semantics, asserting it here would document and protect that invariant.

huang195 added 2 commits May 12, 2026 17:44
…mbined agents

The previous commit makes the webhook eagerly declare a Secret volume for any
pod eligible for operator-managed Keycloak client registration. In prod this
succeeds because the ClientRegistration controller produces the Secret shortly
after — kubelet lazy-resolves the mount and the pod transitions from
ContainerCreating to Running.

In the e2e environment there is no real Keycloak and no keycloak-admin-secret,
so the controller's reconcile loop can never register a client; the Secret is
never produced; the pod sits in ContainerCreating until the test's
WaitForDeploymentReady times out. This affected two tests whose pods are
eligible and have sidecars actually injected: authbridge-agent and
combined-agent. (Other e2e agents either set kagenti.io/inject=disabled, which
short-circuits injection before the Secret mount, or omit kagenti.io/type,
which makes them ineligible for operator-managed registration.)

Pre-create a Secret with the deterministic name the webhook will compute, in
each affected namespace, with dummy client-id.txt / client-secret.txt values.
These tests exercise sidecar injection shape, not the OAuth flow, so dummy
credentials are appropriate — we just need the mount to succeed so the pod
can reach Ready.

The fixture helper uses clientreg.KeycloakClientCredentialsSecretName so the
name stays in lockstep with the webhook and controller — there is no place
for these three components to drift apart.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
The proxy-sidecar branch of InjectAuthBridge returned before reaching the
ApplyKeycloakClientCredentialsSecretVolumes call on the envoy-sidecar path.
Every pod mutated in proxy-sidecar mode therefore ended up with its Secret
volume undeclared: authbridge-proxy polled /shared/client-id.txt forever and
rejected every inbound request with:

    503 {"error":"upstream.unreachable",
         "message":"identity not yet configured (credentials pending)",
         "plugin":"jwt-validation"}

Envoy-sidecar mode has always worked because the envoy path reaches line 494
which invokes the helper. Proxy-sidecar mode was silently broken for
operator-managed client registration.

Invoke the same helper inside the proxy-sidecar branch, right before the
final log + return. Both modes now declare the same Secret volume and the
same /shared/client-id.txt, /shared/client-secret.txt subPath mounts for any
container that already mounts shared-data so authbridge-proxy gets its
credentials the moment the ClientRegistration controller produces the Secret.

Verified end-to-end on a Kind cluster: switched the weather agent Sandbox
to kagenti.io/authbridge-mode=proxy-sidecar, recreated the pod, observed
/shared/client-id.txt + /shared/client-secret.txt populated from the
kagenti-keycloak-client-credentials-<hash> Secret, and the prior 503s
disappeared from the authbridge-proxy logs.

Tests: TestInjectAuthBridge_ProxySidecarMode_MountsKeycloakCredentials
covers the regression (Secret volume present, authbridge-proxy mounts both
subpaths).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 merged commit 21f506d into rossoctl:main May 12, 2026
15 checks passed
@huang195
huang195 deleted the fix/mutator-eager-keycloak-secret-mount branch May 12, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants