The pain point
Pull-request CI should check a release against the real cluster, not just render YAML. kubectl apply --dry-run=server does that: schema validation, defaulting, and every admission webhook, with nothing stored.
The catch: RBAC authorizes a dry-run exactly like a real write. To dry-run a create, CI needs create. So the identity that validates pull requests can also deploy them.
The decision
Give CI the deployer’s verbs, then deny every request from CI that isn’t a dry-run. RBAC can’t see dryRun. Admission can: it’s a field on every admission request.
CI is bound to the same deployer Role as the deploy identity (namespace.yaml). The limit is one ValidatingAdmissionPolicy (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]matchConditionsscopes it to the CI service account. Nobody else is affected.- It matches every group and resource, so a new resource type can’t slip past it.
- It’s CEL, evaluated inside the API server. No webhook pod to go down, and
failurePolicy: Failcovers the rest.
CI then validates a release with a plain server-side dry-run (validate-release.sh):
kubectl apply --dry-run=server -f release.yamlThat request goes through the whole admission chain, including the webhook that rejects Deployments without CPU and memory requests. A bad chart fails on the pull request, not at deploy time.
One requirement for that: a webhook only runs on dry-run requests if it declares sideEffects: None. If it doesn’t, the API server rejects the dry-run outright.
Proving it holds
A policy you never test is a policy you assume. Every make environment-up sends a real write as CI, and fails unless the denial comes from 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}"
fiChecking the denial’s source matters. A write that fails for another reason, like a missing namespace, would otherwise pass as “CI is locked down”.
Summary
- RBAC can’t express “dry-run only”. Admission can:
request.dryRun. - Give CI the verbs it needs to dry-run, and deny everything else in one CEL rule scoped to its service account.
- Webhooks in the chain need
sideEffects: Noneto run on dry-runs. - Test the denial on every run, and check it came from the policy.
Ask about this post