Mutant Operations
A Kubernetes Operator
Motivation
Don’t you wish there was a way to run x deployments with y tweaks?
Usage
apiVersion: neiam.org/v1
kind: MutantDeployment
metadata:
name: nginx-mutant
spec:
deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.21
ports:
- containerPort: 80
mutations:
- label: canary
env:
NGINX_VERSION: "1.22"
instances: 1
- label: experimental
env:
NGINX_EXPERIMENTAL: "true"
instances: 3
Removing a mutation from the list deletes its Deployment. Deleting the MutantDeployment deletes all of them.
Mutations: variants configured at runtime
A Mutation is the same thing as a MutantDeployment, a base Deployment
plus variants of it, but it is configured at runtime through the HTTP API
below instead of being declared in git. Use it when variants come and go
while things run. It stays a Kubernetes object, so the operator keeps no
state of its own.
apiVersion: neiam.org/v1
kind: Mutation
metadata:
name: browser-snap
spec:
deployment: # the base, as in a MutantDeployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: browser-snap
spec: { ... }
variants:
- label: radxa-8-hd
env:
SNAP_RES: "1280x800"
instances: 3
- label: lilygo47
env:
SNAP_RES: "960x540"
instances: 1
$ kubectl get mutations
NAME BASE READY SECRET AGE
browser-snap browser-snap 2/2 variants ready browser-snap-secret 1m
-
Each variant becomes Deployment
<base name>-<label>. Adding a variant starts one, removing it deletes it, and deleting the Mutation deletes them all. -
Each Mutation gets a Secret,
<mutation-name>-secret, holding a random 64-hex-charactertoken. The operator creates it once and never rewrites it, and it is deleted with the Mutation. It is not injected into the variants’ pods. Anything in the namespace can read it as an env var:- name: BROWSER_SNAP_TOKEN valueFrom: secretKeyRef: name: browser-snap-secret key: token optional: true # exists once the operator has seen the Mutation -
statusreports the Secret, a summary, and each variant’s Deployment, ready replicas and state:Progressing,Ready,Invalid, orConflict.Conflictmeans the label repeats, or the Deployment name is already owned by another Mutation or MutantDeployment. The operator never takes over another owner’s Deployment. -
If the base is invalid, nothing is applied or pruned: what is running keeps running until the base is fixed.
Argo CD
A Mutation made entirely at runtime does not go in git. One whose base
should live in git, with its variants still managed at runtime, can be
synced by Argo CD, if it opts in and leaves variants out of its manifest:
apiVersion: neiam.org/v1
kind: Mutation
metadata:
name: browser-snap
annotations:
mutantops.neiam.org/runtime-variants: "true"
spec:
deployment: { ... } # no `variants`: those belong to the API
Argo CD only applies and diffs the fields its manifest declares, so it
neither reverts nor reports the variants the API adds. The API recognises a
synced Mutation by Argo CD’s argocd.argoproj.io/tracking-id annotation or
app.kubernetes.io/instance label and never deletes one. Without the
annotation it refuses variant changes too (readOnly: true), because they
would belong in git.
To show Mutation health in Argo CD, add to argocd-cm:
resource.customizations.health.neiam.org_Mutation: |
hs = { status = "Progressing", message = "Waiting for the operator" }
if obj.status ~= nil and obj.status.variants ~= nil then
hs.status = "Healthy"
hs.message = obj.status.summary
for _, v in ipairs(obj.status.variants) do
if v.state == "Invalid" or v.state == "Conflict" then
hs.status = "Degraded"; hs.message = v.label .. ": " .. (v.message or v.state); break
elseif v.state == "Progressing" then
hs.status = "Progressing"
end
end
end
return hs
HTTP API
The operator serves an HTTP API to create, list and delete Mutations and their variants.
Every /api request needs Authorization: Bearer <token>, and which token
depends on the request:
- A Mutation’s own token (
tokenin its<name>-secret) allows getting that Mutation and listing, adding, scaling and removing its variants, and nothing else. An app in the Mutation’s namespace reads it from that Secret, so it can manage its own Mutation’s variants without credentials for anything else. The operator only accepts a Secret owned by the Mutation, so one created by someone else does not count. - The admin token (
MUTANT_API_TOKEN, optional;operator.ymlreads it from themutantops-apisecret) also allows listing every Mutation in a namespace and creating or deleting a Mutation. Without it, those answer 401.
A wrong token and a Mutation that does not exist both answer 401, so one
Mutation’s token cannot probe for others. MUTANT_API_INSECURE=true serves
the API without auth, for local development. MUTANT_API_ADDR sets the
listen address (default 0.0.0.0:8080).
GET /api/v1/namespaces/{ns}/mutations
POST /api/v1/namespaces/{ns}/mutations {name, deployment, variants}
GET /api/v1/namespaces/{ns}/mutations/{name}
DELETE /api/v1/namespaces/{ns}/mutations/{name}
GET /api/v1/namespaces/{ns}/mutations/{name}/variants
POST /api/v1/namespaces/{ns}/mutations/{name}/variants {label, env, instances}
GET /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
PATCH /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} {instances}
DELETE /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
PATCH scales a variant in place: its Deployment’s replica count changes
and the running pods are kept.
Creating answers 201, or 400 if invalid and 409 if the name or label is
taken. Deleting answers 204, or 404 if absent. Changes to an Argo-synced
Mutation answer 409. Concurrent variant edits are serialised on the
Mutation’s resourceVersion, so none is lost.
kubectl -n mutants create secret generic mutantops-api --from-literal=token="$(openssl rand -hex 32)"
kubectl -n mutants rollout restart deploy/mutantops-operator
TOKEN=$(kubectl -n mutants get secret mutantops-api -o jsonpath='{.data.token}' | base64 -d) # admin
API=http://mutantops-api.mutants.svc # or: kubectl -n mutants port-forward svc/mutantops-api 8080:80
NS=$API/api/v1/namespaces/default
AUTH="Authorization: Bearer $TOKEN"
# create a Mutation from a base Deployment (deployment.json)
jq -n --slurpfile d deployment.json '{name: "web", deployment: $d[0], variants: []}' |
curl -H "$AUTH" -H 'Content-Type: application/json' -d @- $NS/mutations
# add and remove variants: the admin token works, and so does web's own
WEB=$(kubectl -n default get secret web-secret -o jsonpath='{.data.token}' | base64 -d)
AUTH="Authorization: Bearer $WEB"
curl -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"label":"beta","env":{"FEATURE_X":"on"},"instances":2}' $NS/mutations/web/variants
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"instances":4}' $NS/mutations/web/variants/beta
curl -X DELETE -H "$AUTH" $NS/mutations/web/variants/beta
# list
curl -H "$AUTH" $NS/mutations
curl -H "$AUTH" $NS/mutations/web/variants
A listed variant looks like:
{
"label": "beta",
"env": { "FEATURE_X": "on" },
"instances": 2,
"readOnly": false,
"status": { "label": "beta", "state": "Ready", "message": null, "deployment": "web-beta", "replicas": 2, "readyReplicas": 2 }
}
A Mutation adds its name, namespace, base deployment, variants,
secret, summary, message and readOnly.
Metrics
Prometheus metrics are served at /metrics on a listener of their own,
MUTANT_METRICS_ADDR (default 0.0.0.0:9568), which the API’s Service
does not name. operator.yml annotates the pod for scraping. kind is
MutantDeployment or Mutation.
| Metric | Type | Labels | What it says |
|---|---|---|---|
mutant_operator_reconciliations_total |
counter | kind, outcome (ok/error) |
Reconciles finished |
mutant_operator_reconcile_duration_seconds |
histogram | kind, outcome |
Time one reconcile took |
mutant_operator_reconcile_errors_total |
counter | kind, error (invalid/api/client) |
Failed reconciles, retried after 10s |
mutant_operator_controller_errors_total |
counter | kind, error (queue/object_not_found/runner) |
Other controller errors; queue means a watch failed |
mutant_operator_managed_objects |
gauge | kind |
Resources in the controller’s cache |
mutant_operator_variants |
gauge | kind |
Variants declared in specs, summed |
mutant_operator_mutation_variants |
gauge | state |
Mutation variants by reported state |
mutant_operator_build_info |
gauge | version |
Always 1 |
Plus the standard process_* series (CPU, memory, file descriptors). The
gauges are read from the controllers’ caches at scrape time; the counters
reset when the pod restarts.