A helpful micro-framework for writing Kubernetes Admission Controllers 🔎🎟
APACHE-2.0 License
🕵️🕵️🕵️
A micro-framework for building and deploying dynamic Admission Controllers for your Kubernetes clusters. It reduces the boilerplate needed to inspect, validate and/or reject the admission of objects to your cluster, allowing you to focus on writing the specific business logic you want to enforce.
ValidatingWebhookConfiguration
andMutatingWebhookConfiguration
- handlers can return simple allow/denyAdmissionHandler
type that accepts a customAdmitFunc
), making it easy for you to add newDeployment
, Service
andValidatingWebhookConfiguration
definitions for you to build off of, and anexample webhook server
Admission Control provides a number of useful built-in AdmitFuncs, including:
EnforcePodAnnotations
- ensures that admitted Pods have (at least) thematchFunc
(a func(string) bool
) that allows flexible matching. ForIsDomainName
miekg/dns
, or reference a []string
of accepted values. ItnamespaceSelector
ignoreNamespaces
argument to includekube-system
, as annotation validation will otherwise include system Pods.DenyPublicLoadBalancers
- prevents exposing Services
of type: LoadBalancer
outside of the cluster, instead requiring the LB to beDenyIngresses
- similar to the above, it prevents creating IngressesMore built-ins are coming soon, and suggestions are welcome! ⏳
The core type of the library is the AdmitFunc
- a function that takes a k8s AdmissionReview
object and returns an (*AdmissionResponse, error)
tuple. You can provide a closure that returns an AdmitFunc
type if you need to inject additional dependencies into your handler, and/or use a constructor function to do the same.
The AdmissionReview
type wraps the AdmissionRequest
, which can be serialized into a concrete type—such as a Pod
or Service
—and subsequently validated.
An example AdmitFunc
looks like this:
// DenyDefaultLoadBalancerSourceRanges denies any kind: Service of type:
// LoadBalancer that does not explicitly set .spec.loadBalancerSourceRanges -
// which defaults to 0.0.0.0/0 (e.g. Internet traffic, if routable).
//
// This prevents LoadBalancers from being accidentally exposed to the Internet.
func DenyDefaultLoadBalancerSourceRanges() AdmitFunc {
// Return a function of type AdmitFunc
return func(admissionReview *admission.AdmissionReview) (*admission.AdmissionResponse, error) {
kind := admissionReview.Request.Kind.Kind
// Create an *admission.AdmissionResponse that denies by default.
resp := newDefaultDenyResponse()
// Create an object to deserialize our requests' object into
service := core.Service{}
deserializer := serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer()
if _, _, err := deserializer.Decode(admissionReview.Request.Object.Raw, nil, &service); err != nil {
return nil, err
}
// Allow non-LoadBalancer Services to pass through.
if service.Spec.Type != "LoadBalancer" {
resp.Allowed = true
resp.Result.Message = fmt.Sprintf(
"received a non-LoadBalancer type (%s)",
service.Spec.Type,
)
return resp, nil
}
// Inspect the service.Spec.LoadBalancerSourceRanges field
// If unset, reject it.
// Returning an error from an AdmitFunc will automatically deny admission of that requests' object.
if service.Spec.LoadBalancerSourceRanges == nil {
return resp, fmt.Errorf("LoadBalancers without explicitly configured LoadBalancerSourceRanges are not allowed.")
}
// Set resp.Allowed to true before returning your AdmissionResponse
resp.Allowed = true
return resp, nil
}
}
You can see that we deserialize the raw object in our AdmissionReview
into an object (based on its Kind), inspect and validate the fields we're interested in, and either return an error (rejecting admission) or set resp.Allowed = true
and allow admission.
Tips:
AdmitFunc
s focus on "one" thing is best practice: it allows you to be more granular in how you apply constraints to your clusterAdmitFunc
from a constructor/closure will allow you to inject dependencies and/or configuration into your handler.You can then create an AdmissionHandler
and pass it the AdmitFunc
. Use your favorite HTTP router, and associate a path with your handler:
// We're using "gorilla/mux" as our router here.
r := mux.NewRouter().StrictSlash(true)
admissions := r.PathPrefix("/admission-control").Subrouter()
admissions.Handle("/deny-default-load-balancer-source-ranges", &admissioncontrol.AdmissionHandler{
AdmitFunc: admissioncontrol.DenyDefaultLoadBalancerSourceRanges(),
Logger: logger,
}).Methods(http.MethodPost)
The example server admissiond
provides a more complete example of how to configure & serve your admission controller endpoints.
There are two ways to deploy an admission controller:
CloudRun.Dockerfile
for an example of how to build an image for Cloud Run.The documentation below covers deploying within a Kubernetes cluster (option 1).
You'll need:
cfssl
as part of the process of generating a TLS key-pair, and some familiarity with creating TLS (SSL) certificates (CSRs, PEM-encoded certificates, keys).AdmitFuncs
(refer to the example DenyPublicServices
AdmitFunc included).docker build
or similar.Setting up an Admission Controller in your Kubernetes cluster has three major steps:
Generate a TLS keypair—Kubernetes only allows HTTPS (TLS) communication to Admission Controllers, whether in-cluster or hosted externally—and make the key & certificate available as a Secret
within your cluster.
Create a Deployment
with your Admission-Control-based server, mounting the TLS keypair in your Secret
as a volume in the container.
Configure a ValidatingWebhookConfiguration
that tells Kubernetes which objects should be validated, and the endpoint (URL) on your Service
to validate them against.
Your single server can act as the admission controller for any number of ValidatingWebhookConfiguration
or MutatingWebhookConfiguration
- each configuration can point to a specific URL on the same server.
⚠ Reminder: Admission webhooks must support HTTPS (TLS) connections; k8s does not allow webhooks to be reached over plain-text HTTP. If running in-cluster, the Service fronting the controller must be reachable via TCP port 443. External webhooks only need to satisfy the HTTPS requirement, but can be reached on any valid TCP port.
Having your k8s cluster create a TLS certificate for you will dramatically simplify the configuration, as self-signed certificates require you to provide a .webhooks.clientConfig.caBundle
value for verification.
The key steps include:
CertificateSigningRequest
against the Kubernetes cluster, and obtain the CA certificate from the k8s cluster.Deployment
and a Service
that makes the admission controller available to the cluster.ValidatingWebhookConfiguration
that points matching k8s API requests to a route on your admission controller. i.e. you may want to configure different validation policies between Services and Pods.As noted above, we need to make our webhook endpoint available over HTTPS (TLS), which requires generating a CA cert (required as the caBundle
value), key and certificate. You can can choose to have your k8s cluster sign & provide a cert for you, or otherwise provide your own self-signed cert & CA cert.
We're going to have our cluster issue a certificate for us, which simplifies the process:
Create a k8s CertificateSigningRequest
for the hostname(s) you will deploy the Service as. There is an example CSR in demo-certs/csr.yaml
for the admission-control-service.default.svc
hostname. This hostname must match the .webhooks.name[].clientConfig.service.name
described in your ValidatingWebhookConfiguration
.
Approve and then fetch the certificate from the k8s API server.
Create a Secret
that contains the TLS key-pair - the key you created alongside the CSR in step 1, and the certificate you fetched via kubectl get csr <name> ...
- e.g. kubectl create secret tls <name> --cert=cert.crt --key=key.key
.
Retrieve the k8s cluster CA cert - this will be the .webhooks.clientConfig.caBundle
value in our ValidatingWebhookConfiguration
: `
kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[].cluster.certificate-authority-data}'
Specifically, you'll want to make sure your manifest looks like this:
apiVersion: admissionregistration.k8s.io/v1beta1
kind: ValidatingWebhookConfiguration
metadata:
name: deny-public-services
webhooks:
- name: deny-public-services.questionable.services
# <snip, for brevity>
clientConfig:
service:
# This is the hostname our certificate needs in its Subject Alternative
# Name array - name.namespace.svc
# If the certificate does NOT have this name, TLS validation will fail.
name: admission-control-service
namespace: default
path: "/admission-control/deny-public-services"
# This will be the CA cert from your k8s cluster, or the CA cert you
# generated if you took the DIY approach.
caBundle: "<your-base64-encoded-PEM-certificate-here>"
With the TLS certificates in hand, you can now move on to deploying the controller.
With the TLS certificates generated & the associated Secret
created, we can update our Deployment
, Service
and ValidatingWebhookConfiguration
in-kind. Refer to the samples/
directory if you need a reference config.
Deployment
and make sure the -host
flag passed to the admissiond container matches the hostname (ServerName) you used in the CSR..spec.containers[].volumes.secret.secretName
to refer to the Secret
you created in step 3.Service
that exposes the Deployment
in step no. 4 to the cluster. Remember: the name of the Service should match one of the names in step 1.ValidatingWebhookConfiguration
that matches the objects (kinds, versions) and actions (create, update, delete), and configure the .webhooks.clientConfig.service
map to point to the Service
you created.Note: A set of example manifests - both
admissiond-deployment.yml
anddeny-public-admissions-config.yml
- are available in thesamples/
directory.
To deploy the built-in server to your cluster with its existing validation endpoints, you'll need to build the container image and push it to an image registry that your k8s cluster can access.
If you're using Google Container Registry, you can push images to the same project as your GKE cluster:
docker build -t yourco/admissiond .
docker tag yourco/admissiond gcr.io/$PROJECTNAME/admissiond
docker push gcr.io/$PROJECTNAME/admissiond
Make sure to update/copy samples/admission-control-service.yaml
with the new container image URL before deploying it:
# An example Deployment we'll try to expose
kubectl apply -f samples/hello-app.yaml
# Install the Admission Controller into the cluster
kubectl apply -f samples/admission-control-service.yaml
# Add our ValidatingWebhookConfiguration
kubectl apply -f samples/deny-public-webhook-config.yaml
Let's now attempt to deploy a kind: Service
of type: LoadBalancer
without the internal-only annotations:
kubectl apply -f samples/public-service.yaml
You should see the following output:
Error from server (hello-service does not have the cloud.google.com/load-balancer-type: Internal annotation.): error when creating "samples/public-service.yaml": admission webhook "deny-public-services.questionable.services" denied the request: Services of type: LoadBalancer without an internal annotation are not allowed on this cluster
Perfect! 🎉
If you run into problems setting up the admission-controller, make sure that:
kubectl logs -f -l app=admission-control
- all HTTP handler errors are logged to the configured logger.ValidatingWebhookConfiguration
is matching the right API versions, namespaces & objects vs. what you have configured as an AdmitFunc
endpoint in the admission-control server.If you're stuck, open an issue with the output of:
kubectl version
# replace the label if you've authored your own Deployment manifest
kubectl logs -f -l app=admission-control
... and any relevant error messages from attempting to kubectl apply -f <manifest>
that match your ValidatingWebhookConfiguration
.
This project is open to contributions!
As a courtesy: please open an issue with a brief proposal of your idea first (and the use-cases surrounding it!) before diving into implementation.
Apache 2.0 licensed. Copyright Google, LLC (2019). See the LICENSE file for details.