fix(webhook): pre-populate Keycloak client-credentials annotation at admission - #356
Conversation
…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
left a comment
There was a problem hiding this comment.
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)) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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] { |
There was a problem hiding this comment.
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.
…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>
Summary
First-deploy race between the AuthBridge mutating webhook and the
ClientRegistrationcontroller: 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'sjwt-validationplugin returns503 "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-nameannotation 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.ApplyKeycloakClientCredentialsSecretVolumesthen declares a Secret volume withOptional=false, and kubelet holds the pod inContainerCreatingwith aFailedMountevent until the controller creates the Secret — a standard lazy-resolve pattern.Observed repro in the field: operator
v0.2.0-rc.3on Kind + Sandbox-based weather agent. Pod log showed the mutator decision"deliveryPaths":"skip","keycloakClientCredentialsSecretName":""at pod CREATE; ~5 seconds later,"operator client registration applied"withsecret=kagenti-keycloak-client-credentials-<hash>. Secret existed, pod did not mount it.kubectl delete podtriggered recreate, mutator saw the annotation (now in the template), mounted correctly. Post-fix: single pod lifecycle, no restart.Why this is better than today
/shared/→ envoy 503s → user deletes pod to "fix" itkagenti.io/client-registration-inject=true)/shared//shared/injectTools=falseCode 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.AnnotationKeycloakClientSecretName, label names/values) previously duplicated across packages.internal/controller/clientregistration_controller.go— the three functions collapse to one-line wrappers overclientreg. 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 afterderiveWorkloadName, beforeisAlreadyInjected:SPIRE / non-SPIRE
Works for both.
KeycloakClientCredentialsSecretNameis a pure function of(namespace, workload)— SPIRE only changes whatresolveKeycloakClientID(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'sworkloadNamefor all three supported kinds:deriveWorkloadNameworkloadNameGenerateName=<dep>-<hash>-+ labelpod-template-hash=<hash><dep>(strips hash)dep.Name✓GenerateName=<sts>-<sts>sts.Name✓Name=<sbx><sbx>sbx.GetName()✓Test plan
go vet ./internal/clientreg/ ./internal/controller/ ./internal/webhook/v1alpha1/— cleangolangci-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 passv0.2.0-rc.3on Kind, Sandbox-based weather agent, confirmed pod went from "Running with empty /shared/, 503s forever" to "briefly ContainerCreating then Running with creds mounted"Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>