Apps, kubernetes

Mutating Admissions Controllers with Open Policy Agent and Rego

First up, quick refresher – what is a mutating admission controller?

Well it’s a nice feature in Kubernetes which lets you intercept objects when they’re created and make changes to them before they are deployed into the cluster.

Cool right? All those fiddly bits of YAML or hard to enforce company policies around network access, image stores you can and can’t use, they can all be enforced and FIXED automagically! (Like all magic caution is advised, choose wisely – queue Monty python gif)

giphy

So what’s the catch? Well without Open Policy Agent (OPA) you had to build out a web api to do the magic of changing the object then build/push an image and go through maintaining the solution. While you can write them quite easily now with solutions like KubeBuilder, or if you really love node I build one using that too, I wanted to see if OPA made things easier.

So say you want something more dynamic, flexible and a little easier to look after?

This is where Open Policy Agent comes in, they have a DSL language specially designed to build out and enforce complex policies.

Today I’ve been having a play with it to work out if I could build a controller which would set a certain nodeSelector on pods based on which namespace they are deployed in.

I’ll go over this very broadly I highly recommend looking at the docs in detail before diving in, I lost quite a bit of time to not reading things properly before starting.

I won’t lie, getting used to the DSL (rego) was painful for me, mainly because I came at it thinking it was going to be really like Golang. It does look quite like it but that’s where the similarity ends, it’s more functional/pattern matching and better suited to tersely making decisions based on data.

To counter the learning curve of rego I have to say, as I’ve raised issues and contributions the maintainers have been super responsive and helpful (even when I’ve made some silly mistakes) and the docs are great with runnable samples to get started.

Lets talk more about what I built out.

Health warning: I’m new to this and still learning, I may get some of this wrong. Over the next few days I’ll be doing more testing and loop back to fix things up.

First up you need to process the input from the request input and output a main object which will be the response sent to the K8s API.

The first response on line 10 is the default which is returned if nothing else takes over.

The second response is a mix between and object definition and a set of rules lines 18->25 (think assertions). If the rules all match then the assignment takes place and response becomes the output object on line 49. After the assertions there is some plumbing which builds up the response with the JSON patches K8s is looking, these set the nodeselector

# The top level response sent to the webrequest
main = {
"apiVersion": "admission.k8s.io/v1beta1",
"kind": "AdmissionReview",
"response": response,
}
# If the conditions on the `response` below aren't met this default `allow` response
# is returned.
default response = {"allowed": true }
# This is the response body sent back to admissions controller request
# it starts with a number of conditions which have to be met for it to take effect
#
# Note: output is the return item, inside the body the `output :=` sets this so that response equals
# the value of the `output` object defined in this body.
response = output {
# Retrun this if request is:
#
# A pod
isPod
# Without any pre-existing selectors
not hasNodeSelector
# And in a namespace we care about
shouldProcessForNamespace(ignoredNamespaces)
# Generate the JSON Patch object
patch := {
"op": "add",
"path": "spec/nodeSelector",
"value": {
# Retrieve the `pool` name which should be applied given the
# namespace in which this pod is created.
"agentpool": getPoolForNamespace(input.request.object.metadata.namespace)
}
}
# Patches have to be an array of base64 encoded JSON Patches so lets
# make our single patch into an array, serialize as JSON and base64 encode.
patches := [patch]
patchEncoded := base64.encode(json.marshal(patches))
# Output a trace use `opa test *.rego -v –explain full` to see them.
trace(sprintf("POLICY:generatedPatch raw = '%s'", [patches]))
trace(sprintf("POLICY:generatedPatch encoded = '%s'", [patchEncoded]))
# Generate the patch response and return it! We're done!
output := {
"allowed": true,
"patchType": "JSONPatch",
"patch": patchEncoded
}
}

view raw
start.rego
hosted with ❤ by GitHub

So how do these rules work then? Well they’re like little functions that check certain things.

In isPod we do a simple equality check on the kind of the request.

In hasNodeSelector we check if the pod already has a node selector by first checking if the field exists, if it doesn’t the next check doesn’t happen, then checking how many items are in it.

getPoolForNamespace is a special case it takes an input namespace then loops through the array defined as namespaceToAgentPool and sees if any pools match the namespace of the pod. The magic happens here with the namespaceToAgentPool[_] which means, roughly, check the rules against all of the items in that array.

# Rule: Check if the item submitted is a pod.
isPod {
input.request.kind.kind == "Pod"
}
# Rule: Check if pod already has a `nodeSelector` set
hasNodeSelector {
input.request.object.spec.nodeSelector
count(input.request.object.spec.nodeSelector) > 0
}
# Rule: Given a namespace iterate through the `namespaceToAgentPool` array
# and return the value which the `agentpool` should be set to in the
# node selector.
getPoolForNamespace(namespace) = poolLabel {
pool := namespaceToAgentPool[_]
pool.namespace == namespace
poolLabel := pool.agentpool
}
# Rule: Checks if the object is in a namespace we should process.
shouldProcessForNamespace(ignored) {
not contains(ignored, input.request.object.metadata.namespace)
}
# Rule: Helper to check if an array contains an instance of `item`
contains(items, item) {
items[_] == item
}
# Data: Used to map namespace -> agentpools…
# Would be updated with more rules as the list grows
namespaceToAgentPool := [
{ "namespace": "default", "agentpool": "pool1"},
{ "namespace": "gpuwork", "agentpool": "gpu1"},
{ "namespace": "memintensivework", "agentpool": "highmem1"},
]
# Data: Namespaces which we should ignore when processing requests
# so we don't mess with any system pods etc.
# todo// check not missing any
ignoredNamespaces := [
"kube-node-lease",
"kube-public",
"kube-system",
"opa"
]

view raw
rules.rego
hosted with ❤ by GitHub

So how do you know this stuff works? Well it’s got a nice testing framework you can use to check things are working how you expect. I found the trace command super useful when running opa test *.rego -v --explain full as it would prove the value of the items passed to it along with other information about the execution.

# patch we expect to be generated
expectedPatch = {
"op": "add",
"path": "spec/nodeSelector",
"value": {
"agentpool": "pool1"
}
}
# Helper to check patch is set
hasPatch(patches, expectedPatch) {
# One of the patches returned should match the `expectedPatch`
patches[_] == expectedPatch
}
# Checks the response is a patch response
isPatchResponse(res) {
# Is the response patch type correct?
res.response.patchType = "JSONPatch"
# Is the patch body set?
res.response.patch
# Is the patch body an array with more than one item?
count(res.response.patch) > 0
}
# Test that the controller correctly sets a patch on a pod
# to assign it the correct `nodeSelector` of `agentpool=pool1`
test_response_patch {
# Invoke the policy main with a pod which doesn't have a node selector
# and is in the default namespace
body := main with input as testdata.example_pod_doesnt_have_node_selector
# Check policy returned an allowed response
body.response.allowed = true
# Check the response is a patch response
isPatchResponse(body)
# The admission controller response is an array of base64 encoded
# jsonpatches so deserialize so we can review them.
patches := json.unmarshal(base64.decode(body.response.patch))
# Output some tracing… `opa test *.rego -v –explain full` to see them
trace(sprintf("TEST:appliedPatch = '%s'", [patches]))
trace(sprintf("TEST:expectedPatch = '%s'", [expectedPatch]))
# Check the policy created the expected patch
hasPatch(patches, expectedPatch)
}

view raw
test.rego
hosted with ❤ by GitHub

So all together now this looks like: https://github.com/open-policy-agent/contrib/pull/93

Hopefully this is useful. For more advanced stuff there is a library of shared helpers that can be pulled into rego which is well worth a look: https://github.com/open-policy-agent/library/tree/master/kubernetes/mutating-admission nearly everything I’ve done here is largely based on simplifying those funcs and adding more comments.

 

Standard

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s