The problem
I want pull-request CI to validate a release against the real cluster: schema, defaulting, and every admission webhook. kubectl apply --dry-run=server does exactly that and stores nothing.
But RBAC authorizes a dry-run like a real write. To dry-run a create, CI needs create. The identity that checks pull requests could also deploy them.
The options
| Option | Why not |
|---|---|
Render only (helm template, schema lint) | Skips admission. My webhook that rejects Deployments without resource requests never runs. |
| A separate validation cluster | A second cluster to keep in sync. It drifts, and then it validates the wrong thing. |
| An admission webhook that denies CI writes | One more pod in the write path. If it’s down, every write fails. |
| A ValidatingAdmissionPolicy | CEL, evaluated inside the API server. Nothing extra to run. |
The decision
I gave CI the deployer’s verbs, then denied every CI request that isn’t a dry-run. RBAC can’t see dryRun. Admission can.
The implementation
CI binds to the same deployer Role as the deploy identity (namespace.yaml). The limit is one policy (agent-rbac.yaml):
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: jenkins-ci-dry-run-only
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["*"]
apiVersions: ["*"]
operations: ["CREATE", "UPDATE", "DELETE"]
resources: ["*/*"]
matchConditions:
- name: jenkins-ci-requests-only
expression: request.userInfo.username == 'system:serviceaccount:jenkins:jenkins-ci'
validations:
- expression: request.dryRun == true
message: jenkins-ci may only send dry-run requests
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: jenkins-ci-dry-run-only
spec:
policyName: jenkins-ci-dry-run-only
validationActions: [Deny]matchConditionslimits it to the CI service account.*/*covers every resource, including ones added later.UPDATEcoverspatchtoo: admission sees a patch as an update.
CI validates a release with a plain server-side dry-run (validate-release.sh):
kubectl apply --dry-run=server -f release.yamlA webhook only runs on a dry-run if it declares sideEffects: None. Otherwise the API server rejects the dry-run.
The proof
Every make environment-up sends a real write as CI. It fails unless the write is denied, and denied by this policy (environment/bin/up):
if denial="$(kubectl -n "${NAMESPACE}" create configmap delivery-probe \
--from-literal=probe=1 \
--as=system:serviceaccount:jenkins:jenkins-ci 2>&1)"; then
kubectl -n "${NAMESPACE}" delete configmap delivery-probe >/dev/null
fail 'jenkins-ci can write to the cluster'
fi
if [[ "${denial}" != *jenkins-ci-dry-run-only* ]]; then
fail "unexpected CI denial: ${denial}"
fiThe second check matters. A write that fails for another reason, like a missing namespace, would otherwise pass as “CI is locked down”.
What’s still open
- Reads aren’t covered. Admission only sees writes. CI can still
getConfigMaps and read pod logs in the namespaces it validates. - The policy keys on a username string. Rename the service account and the policy silently matches nothing. The check above is what catches that.
Takeaways
- RBAC can’t express “dry-run only”.
request.dryRunin admission can. - Prefer a ValidatingAdmissionPolicy to a webhook when CEL is enough: nothing extra to run.
- Webhooks need
sideEffects: Noneto run on dry-runs. - Test a denial on every run, and check which rule denied it.
Ask about this post