Mutation: a MutantDeployment configured at runtime

f38ff4e · gmorell · 2026-11-09 06:17

9 files +818 -560
Message
{commit_body(@commit)}

Files changed

modified Cargo.lock
+1 −0
@@ -1894,6 +1894,7 @@ dependencies = [
1894 1894 "axum",
1895 1895 "derive",
1896 1896 "futures",
1897 + "getrandom 0.3.2",
1897 1898 "k8s-openapi",
1898 1899 "kube",
1899 1900 "schemars",
modified Cargo.toml
+1 −0
@@ -19,6 +19,7 @@ tracing-subscriber = "0.3.19"
19 19 sea-orm = { version = "2.0.4", features = ["sqlx-postgres", "runtime-tokio-native-tls"] }
20 20 futures = "0.3.31"
21 21 axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "tower-log", "query"] }
22 +getrandom = "0.3"
22 23
23 24
24 25 [package.metadata.sqlx]
modified README.md
+100 −49
@@ -44,103 +44,154 @@ spec:
44 44 NGINX_EXPERIMENTAL: "true"
45 45 instances: 3
46 46 ```
47 +
47 48 Removing a mutation from the list deletes its Deployment. Deleting the
48 49 MutantDeployment deletes all of them.
49 50
50 −## Mutation resources
51 +## Mutations: variants configured at runtime
51 52
52 −Mutations can also be standalone `Mutation` resources, created at runtime
53 −without touching the MutantDeployment. Use them when the MutantDeployment is
54 −owned by Argo CD or another GitOps tool:
53 +A `Mutation` is the same thing as a MutantDeployment, a base Deployment
54 +plus variants of it, but it is configured at runtime through the HTTP API
55 +below instead of being declared in git. Use it when variants come and go
56 +while things run. It stays a Kubernetes object, so the operator keeps no
57 +state of its own.
55 58
56 59 ```yaml
57 60 apiVersion: neiam.org/v1
58 61 kind: Mutation
59 62 metadata:
60 − name: nginx-mutant-beta
63 + name: browser-snap
61 64 spec:
62 − mutantDeployment: nginx-mutant # same namespace
63 − label: beta
64 − env:
65 − FEATURE_X: "on"
66 − instances: 2
65 + deployment: # the base, as in a MutantDeployment
66 + apiVersion: apps/v1
67 + kind: Deployment
68 + metadata:
69 + name: browser-snap
70 + spec: { ... }
71 + variants:
72 + - label: radxa-8-hd
73 + env:
74 + SNAP_RES: "1280x800"
75 + instances: 3
76 + - label: lilygo47
77 + env:
78 + SNAP_RES: "960x540"
79 + instances: 1
67 80 ```
68 81
69 82 ```console
70 83 $ kubectl get mutations
71 −NAME TARGET LABEL INSTANCES READY STATE AGE
72 −nginx-mutant-beta nginx-mutant beta 2 2 Ready 1m
84 +NAME BASE READY SECRET AGE
85 +browser-snap browser-snap 2/2 variants ready browser-snap-secret 1m
73 86 ```
74 87
75 −- A MutantDeployment runs its inline `spec.mutations` plus every Mutation
76 − that targets it. Labels are unique per MutantDeployment. An inline
77 − mutation wins a clash, then the oldest Mutation. A Mutation that loses is
78 − not run and shows `State: Conflict`.
79 −- `status` reports the generated Deployment, its ready replicas, and
80 − `Progressing`, `Ready`, `Invalid` or `Conflict`.
81 −- The operator makes each Mutation a dependent (`ownerReferences`) of its
82 − MutantDeployment. Argo CD shows it under the MutantDeployment in the
83 − resource tree, and deleting the MutantDeployment deletes its Mutations.
88 +- Each variant becomes Deployment `<base name>-<label>`. Adding a variant
89 + starts one, removing it deletes it, and deleting the Mutation deletes
90 + them all.
91 +- Each Mutation gets a Secret, `<mutation-name>-secret`, holding a random
92 + 64-hex-character `token`. The operator creates it once and never rewrites
93 + it, and it is deleted with the Mutation. It is not injected into the
94 + variants' pods. Anything in the namespace can read it as an env var:
95 +
96 + ```yaml
97 + - name: BROWSER_SNAP_TOKEN
98 + valueFrom:
99 + secretKeyRef:
100 + name: browser-snap-secret
101 + key: token
102 + optional: true # exists once the operator has seen the Mutation
103 + ```
104 +
105 +- `status` reports the Secret, a summary, and each variant's Deployment,
106 + ready replicas and state: `Progressing`, `Ready`, `Invalid`, or
107 + `Conflict`. `Conflict` means the label repeats, or the Deployment name is
108 + already owned by another Mutation or MutantDeployment. The operator never
109 + takes over another owner's Deployment.
110 +- If the base is invalid, nothing is applied or pruned: what is running
111 + keeps running until the base is fixed.
84 112
85 113 ### Argo CD
86 114
87 −Mutations created through the API are not in git and carry no Argo CD
88 −tracking label, so Argo CD neither prunes nor reverts them. Inline
89 −mutations remain in git and are read-only through the API. For Argo CD to
90 −show Mutation health, add to `argocd-cm`:
115 +Mutations are meant to be created at runtime, so they do not go in git.
116 +The operator API refuses to change or delete a Mutation that Argo CD syncs
117 +(one with its `argocd.argoproj.io/tracking-id` annotation or
118 +`app.kubernetes.io/instance` label), because the next sync would undo it.
119 +To show Mutation health in Argo CD, add to `argocd-cm`:
91 120
92 121 ```yaml
93 122 resource.customizations.health.neiam.org_Mutation: |
94 123 hs = { status = "Progressing", message = "Waiting for the operator" }
95 − if obj.status ~= nil and obj.status.state ~= nil then
96 − hs.message = obj.status.message or obj.status.state
97 − if obj.status.state == "Ready" then hs.status = "Healthy"
98 − elseif obj.status.state == "Progressing" then hs.status = "Progressing"
99 − else hs.status = "Degraded" end
124 + if obj.status ~= nil and obj.status.variants ~= nil then
125 + hs.status = "Healthy"
126 + hs.message = obj.status.summary
127 + for _, v in ipairs(obj.status.variants) do
128 + if v.state == "Invalid" or v.state == "Conflict" then
129 + hs.status = "Degraded"; hs.message = v.label .. ": " .. (v.message or v.state); break
130 + elseif v.state == "Progressing" then
131 + hs.status = "Progressing"
132 + end
133 + end
100 134 end
101 135 return hs
102 136 ```
103 137
104 138 ## HTTP API
105 139
106 −The operator serves an HTTP API to list, create and destroy mutations.
107 −Creating one makes a Mutation resource named `<mutantdeployment>-<label>`.
108 −Destroying one deletes it.
140 +The operator serves an HTTP API to create, list and delete Mutations and
141 +their variants.
109 142
110 143 The API is off until `MUTANT_API_TOKEN` is set (`operator.yml` reads it from
111 144 the `mutantops-api` secret). Every `/api` request then needs
112 145 `Authorization: Bearer <token>`. `MUTANT_API_INSECURE=true` serves it without
113 146 auth. `MUTANT_API_ADDR` sets the listen address (default `0.0.0.0:8080`).
114 147
148 +```text
149 +GET /api/v1/namespaces/{ns}/mutations
150 +POST /api/v1/namespaces/{ns}/mutations {name, deployment, variants}
151 +GET /api/v1/namespaces/{ns}/mutations/{name}
152 +DELETE /api/v1/namespaces/{ns}/mutations/{name}
153 +GET /api/v1/namespaces/{ns}/mutations/{name}/variants
154 +POST /api/v1/namespaces/{ns}/mutations/{name}/variants {label, env, instances}
155 +GET /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
156 +DELETE /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
157 +```
158 +
159 +Creating answers 201, or 400 if invalid and 409 if the name or label is
160 +taken. Deleting answers 204, or 404 if absent. Changes to an Argo-synced
161 +Mutation answer 409. Concurrent variant edits are serialised on the
162 +Mutation's `resourceVersion`, so none is lost.
163 +
115 164 ```sh
116 165 kubectl -n mutants create secret generic mutantops-api --from-literal=token="$(openssl rand -hex 32)"
117 166 kubectl -n mutants rollout restart deploy/mutantops-operator
118 167 TOKEN=$(kubectl -n mutants get secret mutantops-api -o jsonpath='{.data.token}' | base64 -d)
119 168 API=http://mutantops-api.mutants.svc # or: kubectl -n mutants port-forward svc/mutantops-api 8080:80
120 −MD=$API/api/v1/namespaces/default/mutantdeployments/nginx-mutant
121 −
122 −# MutantDeployments in a namespace, with their running mutation labels
123 −curl -H "Authorization: Bearer $TOKEN" $API/api/v1/namespaces/default/mutantdeployments
124 −# list / get mutations: inline and resource, with live status
125 −curl -H "Authorization: Bearer $TOKEN" $MD/mutations
126 −curl -H "Authorization: Bearer $TOKEN" $MD/mutations/canary
127 −# create (201; 409 if the label is taken, 400 if invalid, 404 if no such MutantDeployment)
128 −curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
129 − -d '{"label":"beta","env":{"FEATURE_X":"on"},"instances":2}' $MD/mutations
130 −# destroy (204; 404 if absent; 409 if inline, which is changed in git instead)
131 −curl -X DELETE -H "Authorization: Bearer $TOKEN" $MD/mutations/beta
169 +NS=$API/api/v1/namespaces/default
170 +AUTH="Authorization: Bearer $TOKEN"
171 +
172 +# create a Mutation from a base Deployment (deployment.json)
173 +jq -n --slurpfile d deployment.json '{name: "web", deployment: $d[0], variants: []}' |
174 + curl -H "$AUTH" -H 'Content-Type: application/json' -d @- $NS/mutations
175 +# add and remove variants
176 +curl -H "$AUTH" -H 'Content-Type: application/json' \
177 + -d '{"label":"beta","env":{"FEATURE_X":"on"},"instances":2}' $NS/mutations/web/variants
178 +curl -X DELETE -H "$AUTH" $NS/mutations/web/variants/beta
179 +# list
180 +curl -H "$AUTH" $NS/mutations
181 +curl -H "$AUTH" $NS/mutations/web/variants
132 182 ```
133 183
134 −A listed mutation looks like:
184 +A listed variant looks like:
135 185
136 186 ```json
137 187 {
138 188 "label": "beta",
139 189 "env": { "FEATURE_X": "on" },
140 190 "instances": 2,
141 − "source": { "kind": "resource", "name": "nginx-mutant-beta" },
142 − "status": { "state": "Ready", "message": null, "deployment": "nginx-beta", "replicas": 2, "readyReplicas": 2 }
191 + "readOnly": false,
192 + "status": { "label": "beta", "state": "Ready", "message": null, "deployment": "web-beta", "replicas": 2, "readyReplicas": 2 }
143 193 }
144 194 ```
145 195
146 −`source.kind` is `inline` for mutations declared in the MutantDeployment.
196 +A Mutation adds its `name`, `namespace`, base `deployment`, `variants`,
197 +`secret`, `summary`, `message` and `readOnly`.
modified crd.yml
+69 −48
@@ -42,9 +42,9 @@ spec:
42 42 shortNames:
43 43 - md
44 44 ---
45 −# A single mutation of a MutantDeployment, created at runtime (e.g. through
46 −# the operator's HTTP API) so the MutantDeployment itself can stay owned by
47 −# GitOps. Schema matches `Mutation` in src/crd.rs.
45 +# A Mutation: a base Deployment plus variants of it, like a
46 +# MutantDeployment, but configured at runtime through the operator's HTTP
47 +# API rather than declared in git. Schema matches `Mutation` in src/crd.rs.
48 48 apiVersion: apiextensions.k8s.io/v1
49 49 kind: CustomResourceDefinition
50 50 metadata:
@@ -65,21 +65,15 @@ spec:
65 65 subresources:
66 66 status: {}
67 67 additionalPrinterColumns:
68 − - name: Target
68 + - name: Base
69 69 type: string
70 − jsonPath: .spec.mutantDeployment
71 − - name: Label
72 − type: string
73 − jsonPath: .spec.label
74 − - name: Instances
75 − type: integer
76 − jsonPath: .spec.instances
70 + jsonPath: .spec.deployment.metadata.name
77 71 - name: Ready
78 − type: integer
79 − jsonPath: .status.readyReplicas
80 − - name: State
81 72 type: string
82 − jsonPath: .status.state
73 + jsonPath: .status.summary
74 + - name: Secret
75 + type: string
76 + jsonPath: .status.secret
83 77 - name: Age
84 78 type: date
85 79 jsonPath: .metadata.creationTimestamp
@@ -92,46 +86,73 @@ spec:
92 86 spec:
93 87 type: object
94 88 required:
95 − - mutantDeployment
96 − - label
97 − - instances
89 + - deployment
98 90 properties:
99 − mutantDeployment:
100 − description: Name of the MutantDeployment, in the same namespace, to mutate.
101 − type: string
102 − label:
103 − description: Suffix of the generated Deployment's name; unique per MutantDeployment.
104 − type: string
105 − pattern: '^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$'
106 − env:
91 + deployment:
92 + description: The base Deployment every variant is derived from.
107 93 type: object
108 − default: {}
109 − additionalProperties:
110 − type: string
111 − instances:
112 − type: integer
113 − format: int32
114 − minimum: 0
94 + x-kubernetes-preserve-unknown-fields: true
95 + variants:
96 + type: array
97 + default: []
98 + items:
99 + type: object
100 + required:
101 + - label
102 + - instances
103 + properties:
104 + label:
105 + description: Suffix of the variant's Deployment name; unique within the Mutation.
106 + type: string
107 + pattern: '^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$'
108 + env:
109 + type: object
110 + default: {}
111 + additionalProperties:
112 + type: string
113 + instances:
114 + type: integer
115 + format: int32
116 + minimum: 0
115 117 status:
116 118 type: object
117 − required:
118 − - state
119 119 properties:
120 − state:
121 − type: string
122 − enum: [Progressing, Ready, Invalid, Conflict]
123 − message:
120 + summary:
121 + description: e.g. "2/3 variants ready".
124 122 type: string
125 123 nullable: true
126 − deployment:
127 − description: The generated Deployment.
124 + secret:
125 + description: The Mutation's generated Secret, holding a random `token`.
128 126 type: string
129 127 nullable: true
130 − replicas:
131 − type: integer
132 − format: int32
133 − nullable: true
134 − readyReplicas:
135 − type: integer
136 − format: int32
128 + message:
129 + type: string
137 130 nullable: true
131 + variants:
132 + type: array
133 + items:
134 + type: object
135 + required:
136 + - label
137 + - state
138 + properties:
139 + label:
140 + type: string
141 + state:
142 + type: string
143 + enum: [Progressing, Ready, Invalid, Conflict]
144 + message:
145 + type: string
146 + nullable: true
147 + deployment:
148 + description: The generated Deployment.
149 + type: string
150 + nullable: true
151 + replicas:
152 + type: integer
153 + format: int32
154 + nullable: true
155 + readyReplicas:
156 + type: integer
157 + format: int32
158 + nullable: true
modified operator.yml
+18 −48
@@ -66,55 +66,25 @@ apiVersion: rbac.authorization.k8s.io/v1
66 66 kind: ClusterRole
67 67 metadata:
68 68 name: mutantops-operator
69 +# Only what the operator uses: generated Deployments, MutantDeployments
70 +# (read), Mutations and their status, and each Mutation's generated Secret
71 +# (get/create only; garbage collection deletes it with the Mutation).
69 72 rules:
70 −- apiGroups:
71 − - ""
72 − resources:
73 − - pods
74 − - services
75 − - services/finalizers
76 − - endpoints
77 − - persistentvolumeclaims
78 − - events
79 − - configmaps
80 − - secrets
81 − verbs:
82 − - create
83 − - delete
84 − - get
85 − - list
86 − - patch
87 − - update
88 − - watch
89 −- apiGroups:
90 − - apps
91 − resources:
92 − - deployments
93 − - daemonsets
94 − - replicasets
95 − - statefulsets
96 − verbs:
97 − - create
98 − - delete
99 − - get
100 − - list
101 − - patch
102 − - update
103 − - watch
104 −- apiGroups:
105 − - neiam.org
106 − resources:
107 − - mutantdeployments
108 − - mutations
109 − - mutations/status
110 − verbs:
111 − - create
112 − - delete
113 − - get
114 − - list
115 − - patch
116 − - update
117 − - watch
73 +- apiGroups: ["apps"]
74 + resources: ["deployments"]
75 + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
76 +- apiGroups: ["neiam.org"]
77 + resources: ["mutantdeployments"]
78 + verbs: ["get", "list", "watch"]
79 +- apiGroups: ["neiam.org"]
80 + resources: ["mutations"]
81 + verbs: ["get", "list", "watch", "create", "patch", "delete"]
82 +- apiGroups: ["neiam.org"]
83 + resources: ["mutations/status"]
84 + verbs: ["get", "patch", "update"]
85 +- apiGroups: [""]
86 + resources: ["secrets"]
87 + verbs: ["get", "create"]
118 88 ---
119 89 apiVersion: rbac.authorization.k8s.io/v1
120 90 kind: ClusterRoleBinding
modified src/api.rs
+202 −144
@@ -1,24 +1,25 @@
1 −//! HTTP API for listing, creating and deleting the mutations of a live
2 −//! MutantDeployment.
3 −//!
4 −//! Created mutations are standalone `Mutation` resources, never edits to the
5 −//! MutantDeployment, which can stay owned by Argo CD (or any GitOps tool)
6 −//! without the API and the sync fighting over it. Mutations declared inline
7 −//! in the MutantDeployment are listed too, but are read-only here: they
8 −//! change in git.
1 +//! HTTP API for Mutations: base Deployments whose variants are configured at
2 +//! runtime.
9 3 //!
10 4 //! ```text
11 5 //! GET /healthz
12 −//! GET /api/v1/namespaces/{ns}/mutantdeployments
13 −//! GET /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations
14 −//! POST /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations
15 −//! GET /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}
16 −//! DELETE /api/v1/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}
6 +//! GET /api/v1/namespaces/{ns}/mutations
7 +//! POST /api/v1/namespaces/{ns}/mutations {name, deployment, variants}
8 +//! GET /api/v1/namespaces/{ns}/mutations/{name}
9 +//! DELETE /api/v1/namespaces/{ns}/mutations/{name}
10 +//! GET /api/v1/namespaces/{ns}/mutations/{name}/variants
11 +//! POST /api/v1/namespaces/{ns}/mutations/{name}/variants {label, env, instances}
12 +//! GET /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
13 +//! DELETE /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
17 14 //! ```
15 +//!
16 +//! A Mutation is a Kubernetes object, so the operator keeps no state of its
17 +//! own. Ones synced by Argo CD are listed with `readOnly: true` and cannot be
18 +//! changed here: the next sync would put them back.
18 19
19 20 use crate::crd::{
20 − applied_status, resolve, MutantDeployment, Mutation, MutationDef, MutationSpec, MutationStatus,
21 − Source, OWNER_LABEL, TARGET_LABEL,
21 + insert_variant, is_gitops_managed, remove_variant, validate_base, validate_label, validate_variants,
22 + Mutation, MutationSpec, Variant, VariantState, VariantStatus,
22 23 };
23 24 use axum::{
24 25 extract::{Path, Request, State},
@@ -30,14 +31,18 @@ use axum::{
30 31 };
31 32 use k8s_openapi::api::apps::v1::Deployment;
32 33 use kube::{
33 − api::{Api, DeleteParams, ListParams, PostParams},
34 − Client, Resource, ResourceExt,
34 + api::{Api, DeleteParams, ListParams, Patch, PatchParams, PostParams},
35 + Client, ResourceExt,
35 36 };
36 −use serde::Serialize;
37 +use serde::{Deserialize, Serialize};
37 38 use serde_json::json;
38 −use std::{collections::BTreeMap, sync::Arc};
39 +use std::sync::Arc;
39 40 use tracing::{info, warn};
40 41
42 +/// Attempts at a read-modify-write of a Mutation's variants before giving
43 +/// up on conflicts.
44 +const MAX_ATTEMPTS: usize = 5;
45 +
41 46 #[derive(Clone)]
42 47 pub struct AppState {
43 48 client: Client,
@@ -53,14 +58,15 @@ impl AppState {
53 58
54 59 pub fn router(state: AppState) -> Router {
55 60 let api = Router::new()
56 − .route("/namespaces/{ns}/mutantdeployments", get(list_mutants))
61 + .route("/namespaces/{ns}/mutations", get(list_mutations).post(create_mutation))
62 + .route("/namespaces/{ns}/mutations/{name}", get(get_mutation).delete(delete_mutation))
57 63 .route(
58 − "/namespaces/{ns}/mutantdeployments/{name}/mutations",
59 − get(list_mutations).post(create_mutation),
64 + "/namespaces/{ns}/mutations/{name}/variants",
65 + get(list_variants).post(create_variant),
60 66 )
61 67 .route(
62 − "/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}",
63 − get(get_mutation).delete(delete_mutation),
68 + "/namespaces/{ns}/mutations/{name}/variants/{label}",
69 + get(get_variant).delete(delete_variant),
64 70 )
65 71 .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
66 72 Router::new()
@@ -132,168 +138,201 @@ impl IntoResponse for ApiError {
132 138
133 139 type ApiResult<T> = Result<T, ApiError>;
134 140
135 −/// A mutation as the API reports it: its definition, where it was declared,
136 −/// and its live state.
141 +/// A Mutation as the API reports it.
137 142 #[derive(Serialize, Debug)]
143 +#[serde(rename_all = "camelCase")]
138 144 struct MutationView {
145 + name: String,
146 + namespace: String,
147 + /// The base Deployment.
148 + deployment: Deployment,
149 + variants: Vec<VariantView>,
150 + /// The generated Secret, once the operator has made it.
151 + secret: Option<String>,
152 + summary: Option<String>,
153 + message: Option<String>,
154 + /// Synced by Argo CD: changes in git, not here.
155 + read_only: bool,
156 +}
157 +
158 +/// A variant: its definition and its last reported state.
159 +#[derive(Serialize, Debug)]
160 +#[serde(rename_all = "camelCase")]
161 +struct VariantView {
139 162 #[serde(flatten)]
140 − def: MutationDef,
141 − source: Source,
142 − status: MutationStatus,
163 + variant: Variant,
164 + status: VariantStatus,
165 + read_only: bool,
143 166 }
144 167
145 −#[derive(Serialize)]
146 −struct MutantSummary {
168 +#[derive(Deserialize)]
169 +struct CreateMutation {
147 170 name: String,
148 − deployment: String,
149 − mutations: Vec<String>,
171 + deployment: Deployment,
172 + #[serde(default)]
173 + variants: Vec<Variant>,
150 174 }
151 175
152 −/// Every mutation of `name`: the ones running, then Mutation resources that
153 −/// are not (invalid, or a duplicate label), with why.
154 −async fn views(client: &Client, ns: &str, name: &str) -> ApiResult<Vec<MutationView>> {
155 − let mutant = Api::<MutantDeployment>::namespaced(client.clone(), ns).get(name).await?;
156 − let resources = Api::<Mutation>::namespaced(client.clone(), ns)
157 − .list(&ListParams::default())
158 − .await?
159 − .items;
160 − // Status is read live from the Deployments rather than from Mutation
161 − // status, so inline mutations, which have no status, get one too.
162 − let deployments: BTreeMap<String, Deployment> = Api::<Deployment>::namespaced(client.clone(), ns)
163 − .list(&ListParams::default().labels(&format!("{OWNER_LABEL}={name}")))
164 − .await?
165 − .into_iter()
166 − .map(|d| (d.name_any(), d))
167 − .collect();
168 −
169 − let resolved = resolve(&mutant, &resources);
170 − let mut out: Vec<_> = resolved
171 − .active
172 − .into_iter()
173 − .map(|(def, source)| {
174 − let dname = mutant.deployment_name(&def.label);
175 − let status = applied_status(dname.clone(), def.instances, deployments.get(&dname));
176 − MutationView { def, source, status }
177 − })
178 − .collect();
179 − for (rname, state, message) in resolved.rejected {
180 − if let Some(m) = resources.iter().find(|m| m.name_any() == rname) {
181 − out.push(MutationView {
182 − def: m.spec.def.clone(),
183 − source: Source::Resource { name: rname },
184 − status: MutationStatus { state, message: Some(message), ..Default::default() },
185 − });
176 +impl From<Mutation> for MutationView {
177 + fn from(m: Mutation) -> Self {
178 + let read_only = is_gitops_managed(&m);
179 + let status = m.status.clone().unwrap_or_default();
180 + let variants = m
181 + .spec
182 + .variants
183 + .iter()
184 + .map(|v| VariantView {
185 + status: status.variants.iter().find(|s| s.label == v.label).cloned().unwrap_or_else(|| {
186 + // Added since the operator last reported.
187 + VariantStatus::rejected(&v.label, VariantState::Progressing, "not yet reconciled".into())
188 + }),
189 + variant: v.clone(),
190 + read_only,
191 + })
192 + .collect();
193 + MutationView {
194 + name: m.name_any(),
195 + namespace: m.namespace().unwrap_or_default(),
196 + deployment: m.spec.deployment,
197 + variants,
198 + secret: status.secret,
199 + summary: status.summary,
200 + message: status.message,
201 + read_only,
186 202 }
187 203 }
188 − Ok(out)
189 204 }
190 205
191 −async fn list_mutants(State(st): State<AppState>, Path(ns): Path<String>) -> ApiResult<Json<Vec<MutantSummary>>> {
192 − let mutants = Api::<MutantDeployment>::namespaced(st.client.clone(), &ns)
193 − .list(&ListParams::default())
194 − .await?;
195 − let resources = Api::<Mutation>::namespaced(st.client, &ns)
196 − .list(&ListParams::default())
197 − .await?
198 − .items;
199 − Ok(Json(
200 − mutants
201 − .into_iter()
202 − .map(|md| MutantSummary {
203 − mutations: resolve(&md, &resources).active.into_iter().map(|(d, _)| d.label).collect(),
204 − deployment: md.base_name(),
205 − name: md.name_any(),
206 − })
207 − .collect(),
208 − ))
206 +fn api(st: &AppState, ns: &str) -> Api<Mutation> {
207 + Api::namespaced(st.client.clone(), ns)
209 208 }
210 209
211 −async fn list_mutations(
212 − State(st): State<AppState>,
213 − Path((ns, name)): Path<(String, String)>,
214 −) -> ApiResult<Json<Vec<MutationView>>> {
215 − Ok(Json(views(&st.client, &ns, &name).await?))
210 +async fn list_mutations(State(st): State<AppState>, Path(ns): Path<String>) -> ApiResult<Json<Vec<MutationView>>> {
211 + let items = api(&st, &ns).list(&ListParams::default()).await?;
212 + Ok(Json(items.into_iter().map(Into::into).collect()))
216 213 }
217 214
218 215 async fn get_mutation(
219 216 State(st): State<AppState>,
220 − Path((ns, name, label)): Path<(String, String, String)>,
217 + Path((ns, name)): Path<(String, String)>,
221 218 ) -> ApiResult<Json<MutationView>> {
222 − // The running one: listed first, ahead of any rejected duplicate.
223 − views(&st.client, &ns, &name)
224 − .await?
225 − .into_iter()
226 − .find(|v| v.def.label == label)
227 − .map(Json)
228 − .ok_or_else(|| ApiError::NotFound(format!("mutation {label:?} not found")))
219 + Ok(Json(api(&st, &ns).get(&name).await?.into()))
229 220 }
230 221
231 −/// Creates a Mutation resource named `{name}-{label}`.
232 222 async fn create_mutation(
223 + State(st): State<AppState>,
224 + Path(ns): Path<String>,
225 + Json(req): Json<CreateMutation>,
226 +) -> ApiResult<(StatusCode, Json<MutationView>)> {
227 + validate_label(&req.name).map_err(ApiError::BadRequest)?;
228 + validate_base(&req.deployment).map_err(ApiError::BadRequest)?;
229 + validate_variants(&req.variants).map_err(ApiError::BadRequest)?;
230 + let mutation = Mutation::new(&req.name, MutationSpec { deployment: req.deployment, variants: req.variants });
231 + let created = api(&st, &ns).create(&PostParams::default(), &mutation).await?;
232 + info!(%ns, name = %req.name, "mutation created");
233 + Ok((StatusCode::CREATED, Json(created.into())))
234 +}
235 +
236 +async fn delete_mutation(
233 237 State(st): State<AppState>,
234 238 Path((ns, name)): Path<(String, String)>,
235 − Json(def): Json<MutationDef>,
236 −) -> ApiResult<(StatusCode, Json<Mutation>)> {
237 − def.validate().map_err(ApiError::BadRequest)?;
238 − let mutant = Api::<MutantDeployment>::namespaced(st.client.clone(), &ns).get(&name).await?;
239 − // Refused here rather than created only to sit in Conflict.
240 − if views(&st.client, &ns, &name).await?.iter().any(|v| v.def.label == def.label) {
241 − return Err(ApiError::Conflict(format!("mutation {:?} already exists", def.label)));
242 − }
239 +) -> ApiResult<StatusCode> {
240 + let api = api(&st, &ns);
241 + refuse_if_gitops(&api.get(&name).await?)?;
242 + api.delete(&name, &DeleteParams::default()).await?;
243 + info!(%ns, %name, "mutation deleted");
244 + Ok(StatusCode::NO_CONTENT)
245 +}
243 246
244 − let label = def.label.clone();
245 − let mut mutation = Mutation::new(
246 − &format!("{name}-{label}"),
247 − MutationSpec { mutant_deployment: name.clone(), def },
248 − );
249 − mutation.metadata.labels = Some(BTreeMap::from([(TARGET_LABEL.to_string(), name.clone())]));
250 − // Set up front so the Mutation is a dependent of its MutantDeployment
251 − // from the start; the controller also adds it to hand-made ones.
252 − mutation.metadata.owner_references = mutant.owner_ref(&()).map(|o| vec![o]);
247 +async fn list_variants(
248 + State(st): State<AppState>,
249 + Path((ns, name)): Path<(String, String)>,
250 +) -> ApiResult<Json<Vec<VariantView>>> {
251 + Ok(Json(MutationView::from(api(&st, &ns).get(&name).await?).variants))
252 +}
253 253
254 − let created = Api::<Mutation>::namespaced(st.client, &ns)
255 − .create(&PostParams::default(), &mutation)
256 − .await?;
257 − info!(%ns, %name, %label, "mutation created");
254 +async fn get_variant(
255 + State(st): State<AppState>,
256 + Path((ns, name, label)): Path<(String, String, String)>,
257 +) -> ApiResult<Json<VariantView>> {
258 + MutationView::from(api(&st, &ns).get(&name).await?)
259 + .variants
260 + .into_iter()
261 + .find(|v| v.variant.label == label)
262 + .map(Json)
263 + .ok_or_else(|| ApiError::NotFound(format!("variant {label:?} not found")))
264 +}
265 +
266 +async fn create_variant(
267 + State(st): State<AppState>,
268 + Path((ns, name)): Path<(String, String)>,
269 + Json(variant): Json<Variant>,
270 +) -> ApiResult<(StatusCode, Json<Variant>)> {
271 + variant.validate().map_err(ApiError::BadRequest)?;
272 + let created = variant.clone();
273 + update_variants(&api(&st, &ns), &name, |list| {
274 + insert_variant(list, variant.clone()).map_err(ApiError::Conflict)
275 + })
276 + .await?;
277 + info!(%ns, %name, label = %created.label, "variant created");
258 278 Ok((StatusCode::CREATED, Json(created)))
259 279 }
260 280
261 −/// Deletes every Mutation resource of `name` with this label: the running
262 −/// one and any duplicate waiting behind it, which would otherwise take its
263 −/// place. Inline mutations are refused.
264 −async fn delete_mutation(
281 +async fn delete_variant(
265 282 State(st): State<AppState>,
266 283 Path((ns, name, label)): Path<(String, String, String)>,
267 284 ) -> ApiResult<StatusCode> {
268 − let matching: Vec<_> = views(&st.client, &ns, &name)
269 − .await?
270 − .into_iter()
271 − .filter(|v| v.def.label == label)
272 − .collect();
273 − if matching.is_empty() {
274 − return Err(ApiError::NotFound(format!("mutation {label:?} not found")));
275 − }
276 − if matching.iter().any(|v| v.source == Source::Inline) {
285 + update_variants(&api(&st, &ns), &name, |list| {
286 + remove_variant(list, &label)
287 + .map(drop)
288 + .ok_or_else(|| ApiError::NotFound(format!("variant {label:?} not found")))
289 + })
290 + .await?;
291 + info!(%ns, %name, %label, "variant deleted");
292 + Ok(StatusCode::NO_CONTENT)
293 +}
294 +
295 +fn refuse_if_gitops(m: &Mutation) -> ApiResult<()> {
296 + if is_gitops_managed(m) {
277 297 return Err(ApiError::Conflict(format!(
278 − "mutation {label:?} is declared inline in MutantDeployment {name}; remove it from its spec.mutations (in git) instead"
298 + "mutation {} is synced by Argo CD; change it in git instead",
299 + m.name_any()
279 300 )));
280 301 }
281 − let api = Api::<Mutation>::namespaced(st.client, &ns);
282 − for v in matching {
283 − if let Source::Resource { name: rname } = v.source {
284 − match api.delete(&rname, &DeleteParams::default()).await {
285 − Ok(_) | Err(kube::Error::Api(kube::core::ErrorResponse { code: 404, .. })) => {}
286 − Err(e) => return Err(e.into()),
287 − }
302 + Ok(())
303 +}
304 +
305 +/// Read-modify-write of `spec.variants`, guarded by `resourceVersion` so a
306 +/// concurrent edit (another API call, or kubectl) is never overwritten: the
307 +/// API server answers 409 when the object changed since the read, and the
308 +/// edit is retried against the fresh copy.
309 +async fn update_variants<F>(api: &Api<Mutation>, name: &str, edit: F) -> ApiResult<()>
310 +where
311 + F: Fn(&mut Vec<Variant>) -> ApiResult<()>,
312 +{
313 + for _ in 0..MAX_ATTEMPTS {
314 + let current = api.get(name).await?;
315 + refuse_if_gitops(&current)?;
316 + let mut variants = current.spec.variants.clone();
317 + edit(&mut variants)?;
318 + let patch = json!({
319 + "metadata": { "resourceVersion": current.resource_version() },
320 + "spec": { "variants": variants },
321 + });
322 + match api.patch(name, &PatchParams::default(), &Patch::Merge(&patch)).await {
323 + Ok(_) => return Ok(()),
324 + Err(kube::Error::Api(e)) if e.code == 409 => continue,
325 + Err(e) => return Err(e.into()),
288 326 }
289 327 }
290 − info!(%ns, %name, %label, "mutation deleted");
291 − Ok(StatusCode::NO_CONTENT)
328 + Err(ApiError::Conflict(format!("{name} kept changing; gave up after {MAX_ATTEMPTS} attempts")))
292 329 }
293 330
294 331 #[cfg(test)]
295 332 mod tests {
296 333 use super::*;
334 + use crate::crd::MutationStatus;
335 + use std::collections::HashMap;
297 336
298 337 #[test]
299 338 fn auth() {
@@ -304,4 +343,23 @@ mod tests {
304 343 assert!(!authorized(Some("s3cret"), Some("s3creT")));
305 344 assert!(!authorized(Some("s3cret"), None));
306 345 }
346 +
347 + #[test]
348 + fn view_joins_variants_with_status() {
349 + let v = |label: &str| Variant { label: label.into(), env: HashMap::new(), instances: 1 };
350 + let mut m = Mutation::new(
351 + "browser-snap",
352 + MutationSpec { deployment: Deployment::default(), variants: vec![v("lilygo47"), v("new")] },
353 + );
354 + m.status = Some(MutationStatus {
355 + secret: Some("browser-snap-secret".into()),
356 + variants: vec![VariantStatus { ready_replicas: Some(1), ..VariantStatus::rejected("lilygo47", VariantState::Ready, String::new()) }],
357 + ..Default::default()
358 + });
359 + let view = MutationView::from(m);
360 + assert_eq!(view.secret.as_deref(), Some("browser-snap-secret"));
361 + assert!(!view.read_only);
362 + assert_eq!(view.variants[0].status.state, VariantState::Ready);
363 + assert_eq!(view.variants[1].status.message.as_deref(), Some("not yet reconciled"));
364 + }
307 365 }
modified src/controller.rs
+177 −89
@@ -1,18 +1,21 @@
1 −//! Reconciles each MutantDeployment, together with the Mutation resources
2 −//! that target it, into one Deployment per mutation; prunes Deployments
3 −//! whose mutation is gone; and reports each Mutation's state in its status.
1 +//! Reconciles MutantDeployments and Mutations alike: a base Deployment plus
2 +//! variants becomes one Deployment per variant, and Deployments whose
3 +//! variant is gone are pruned. A Mutation also gets a generated Secret and a
4 +//! status reporting each variant.
4 5
5 6 use crate::crd::{
6 − applied_status, build_deployment, resolve, MutantDeployment, Mutation, MutationStatus, Source,
7 + applied_status, build_deployment, build_secret, deployment_name, generate_token, secret_name, summarize,
8 + validate_base, MutantDeployment, Mutation, MutationStatus, Variant, VariantState, VariantStatus,
7 9 MUTATION_LABEL, OWNER_LABEL,
8 10 };
9 11 use futures::StreamExt;
10 12 use k8s_openapi::api::apps::v1::Deployment;
13 +use k8s_openapi::api::core::v1::Secret;
14 +use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
11 15 use kube::{
12 − api::{Api, DeleteParams, ListParams, Patch, PatchParams},
16 + api::{Api, DeleteParams, ListParams, Patch, PatchParams, PostParams},
13 17 runtime::{
14 18 controller::{Action, Controller},
15 − reflector::ObjectRef,
16 19 watcher,
17 20 },
18 21 Client, Resource, ResourceExt,
@@ -22,9 +25,7 @@ use std::{collections::HashSet, fmt, sync::Arc, time::Duration};
22 25 use tracing::{info, warn};
23 26
24 27 const FIELD_MANAGER: &str = "mutantops-operator";
25 −/// Periodic resync. Events drive reconciliation; this only catches what no
26 −/// event points at, such as a Mutation retargeted away from a
27 −/// MutantDeployment, whose old Deployment is then pruned.
28 +/// Periodic resync. Events drive reconciliation; this is a backstop.
28 29 const RESYNC: Duration = Duration::from_secs(300);
29 30
30 31 #[derive(Debug)]
@@ -54,113 +55,200 @@ struct Ctx {
54 55 client: Client,
55 56 }
56 57
57 −async fn reconcile(mutant: Arc<MutantDeployment>, ctx: Arc<Ctx>) -> Result<Action, MutantopsError> {
58 − let ns = mutant
59 − .namespace()
60 − .ok_or_else(|| MutantopsError::Invalid("MutantDeployment has no namespace".into()))?;
61 − let controller_ref = mutant
62 − .controller_owner_ref(&())
63 − .ok_or_else(|| MutantopsError::Invalid("MutantDeployment has no uid".into()))?;
64 − let deployments: Api<Deployment> = Api::namespaced(ctx.client.clone(), &ns);
65 − let mutations: Api<Mutation> = Api::namespaced(ctx.client.clone(), &ns);
66 − let apply = PatchParams::apply(FIELD_MANAGER).force();
67 −
68 − // Inline entries can only be edited in the MutantDeployment itself, and
69 − // have no status to report into, so a bad one is just logged.
70 − for def in &mutant.spec.mutations {
71 − if let Err(msg) = def.validate() {
72 − warn!(mutant = %mutant.name_any(), %ns, "skipping inline mutation: {msg}");
73 − }
74 − }
58 +/// Who the generated Deployments belong to.
59 +struct Owner {
60 + name: String,
61 + ns: String,
62 + controller_ref: OwnerReference,
63 +}
75 64
76 − let resources = mutations.list(&ListParams::default()).await?.items;
77 − // Each Mutation becomes a dependent of its MutantDeployment: Argo CD then
78 − // shows it in the MutantDeployment's resource tree, and deleting the
79 − // MutantDeployment garbage-collects it. Done before the status writes
80 − // below, which would invalidate the resourceVersion this is guarded by.
81 − let owner = mutant.owner_ref(&()).expect("uid checked above");
82 − for m in resources.iter().filter(|m| m.spec.mutant_deployment == mutant.name_any()) {
83 − if !m.owner_references().iter().any(|o| o.uid == owner.uid) {
84 − let mut refs = m.owner_references().to_vec();
85 − refs.push(owner.clone());
86 − let patch = json!({ "metadata": { "ownerReferences": refs, "resourceVersion": m.resource_version() } });
87 − mutations.patch(&m.name_any(), &PatchParams::default(), &Patch::Merge(&patch)).await?;
88 − }
65 +impl Owner {
66 + fn of<K: Resource<DynamicType = ()>>(obj: &K) -> Result<Self, MutantopsError> {
67 + Ok(Self {
68 + name: obj.name_any(),
69 + ns: obj
70 + .namespace()
71 + .ok_or_else(|| MutantopsError::Invalid(format!("{} has no namespace", obj.name_any())))?,
72 + controller_ref: obj
73 + .controller_owner_ref(&())
74 + .ok_or_else(|| MutantopsError::Invalid(format!("{} has no uid", obj.name_any())))?,
75 + })
89 76 }
77 +}
90 78
91 − let resolved = resolve(&mutant, &resources);
92 − let find = |name: &str| resources.iter().find(|m| m.name_any() == name);
79 +/// Applies one Deployment per variant of `base` and prunes the owner's
80 +/// Deployments whose variant is gone. Returns each variant's status.
81 +///
82 +/// A variant is skipped, and reported, when it is invalid, repeats an
83 +/// earlier label, or its Deployment name is already controlled by a
84 +/// different owner: two owners would otherwise take turns overwriting it.
85 +async fn apply_variants(
86 + client: &Client,
87 + owner: &Owner,
88 + base: &Deployment,
89 + variants: &[Variant],
90 +) -> Result<Vec<VariantStatus>, kube::Error> {
91 + let api: Api<Deployment> = Api::namespaced(client.clone(), &owner.ns);
92 + let apply = PatchParams::apply(FIELD_MANAGER).force();
93 + let uid = &owner.controller_ref.uid;
93 94
95 + let mut statuses = Vec::with_capacity(variants.len());
94 96 let mut wanted = HashSet::new();
95 − for (def, source) in &resolved.active {
96 − let name = mutant.deployment_name(&def.label);
97 − let deploy = build_deployment(&mutant, def, controller_ref.clone());
98 − let applied = deployments.patch(&name, &apply, &Patch::Apply(&deploy)).await?;
99 − if let Source::Resource { name: mname } = source {
100 − if let Some(m) = find(mname) {
101 − set_status(&mutations, m, applied_status(name.clone(), def.instances, Some(&applied))).await?;
97 + let mut labels = HashSet::new();
98 + for v in variants {
99 + if let Err(msg) = v.validate() {
100 + statuses.push(VariantStatus::rejected(&v.label, VariantState::Invalid, msg));
101 + continue;
102 + }
103 + if !labels.insert(v.label.as_str()) {
104 + let msg = "label repeats an earlier variant".to_string();
105 + statuses.push(VariantStatus::rejected(&v.label, VariantState::Conflict, msg));
106 + continue;
107 + }
108 + let name = deployment_name(base, &v.label);
109 + if let Some(existing) = api.get_opt(&name).await? {
110 + let other = existing.owner_references().iter().find(|o| o.controller == Some(true) && &o.uid != uid);
111 + if let Some(other) = other {
112 + let msg = format!("deployment {name} is already managed by {} {}", other.kind, other.name);
113 + statuses.push(VariantStatus::rejected(&v.label, VariantState::Conflict, msg));
114 + continue;
102 115 }
103 116 }
117 + let deploy = build_deployment(base, Some(owner.ns.clone()), &owner.name, v, owner.controller_ref.clone());
118 + let applied = api.patch(&name, &apply, &Patch::Apply(&deploy)).await?;
119 + statuses.push(applied_status(&v.label, name.clone(), v.instances, Some(&applied)));
104 120 wanted.insert(name);
105 121 }
106 − for (mname, state, message) in &resolved.rejected {
107 − if let Some(m) = find(mname) {
108 − let status = MutationStatus { state: *state, message: Some(message.clone()), ..Default::default() };
109 − set_status(&mutations, m, status).await?;
110 − }
111 − }
112 122
113 − // Prune: Deployments this MutantDeployment owns whose mutation is gone.
114 − // Checking the owner uid as well as the label means a Deployment that
115 − // merely carries the label is never deleted.
116 − let selector = format!("{OWNER_LABEL}={}", mutant.name_any());
117 − for existing in deployments.list(&ListParams::default().labels(&selector)).await? {
123 + // Prune. Checking the owner uid as well as the label means a Deployment
124 + // that merely carries the label, or belongs to a same-named owner of
125 + // the other kind, is never deleted.
126 + let selector = format!("{OWNER_LABEL}={}", owner.name);
127 + for existing in api.list(&ListParams::default().labels(&selector)).await? {
118 128 let name = existing.name_any();
119 − let owned = existing.owner_references().iter().any(|o| o.uid == controller_ref.uid);
129 + let owned = existing.owner_references().iter().any(|o| &o.uid == uid);
120 130 if owned && !wanted.contains(&name) {
121 − info!(%name, %ns, mutation = ?existing.labels().get(MUTATION_LABEL), "pruning removed mutation");
122 − deployments.delete(&name, &DeleteParams::background()).await?;
131 + info!(%name, ns = %owner.ns, variant = ?existing.labels().get(MUTATION_LABEL), "pruning removed variant");
132 + api.delete(&name, &DeleteParams::background()).await?;
123 133 }
124 134 }
135 + Ok(statuses)
136 +}
125 137
138 +async fn reconcile_mutant(md: Arc<MutantDeployment>, ctx: Arc<Ctx>) -> Result<Action, MutantopsError> {
139 + let owner = Owner::of(md.as_ref())?;
140 + // A MutantDeployment has no status to report into, so problems are logged.
141 + for s in apply_variants(&ctx.client, &owner, &md.spec.deployment, &md.spec.mutations).await? {
142 + if let Some(msg) = s.message {
143 + warn!(mutant = %owner.name, ns = %owner.ns, variant = %s.label, state = ?s.state, "{msg}");
144 + }
145 + }
126 146 Ok(Action::requeue(RESYNC))
127 147 }
128 148
129 −/// Writes `status` unless it is already current. Skipping no-op writes
130 −/// matters: every write is a watch event that reconciles again.
131 −async fn set_status(api: &Api<Mutation>, m: &Mutation, status: MutationStatus) -> Result<(), kube::Error> {
132 − if m.status.as_ref() == Some(&status) {
133 − return Ok(());
149 +async fn reconcile_mutation(m: Arc<Mutation>, ctx: Arc<Ctx>) -> Result<Action, MutantopsError> {
150 + if m.metadata.deletion_timestamp.is_some() {
151 + // Garbage collection removes its Deployments and Secret.
152 + return Ok(Action::await_change());
153 + }
154 + let owner = Owner::of(m.as_ref())?;
155 + let secrets: Api<Secret> = Api::namespaced(ctx.client.clone(), &owner.ns);
156 +
157 + let mut status = MutationStatus::default();
158 + match ensure_secret(&secrets, &m).await? {
159 + Ok(name) => status.secret = Some(name),
160 + Err(msg) => status.message = Some(msg),
161 + }
162 + match validate_base(&m.spec.deployment) {
163 + // Nothing is applied or pruned from a broken base: what is running
164 + // keeps running until the base is fixed.
165 + Err(msg) => status.message = Some(msg),
166 + Ok(()) => status.variants = apply_variants(&ctx.client, &owner, &m.spec.deployment, &m.spec.variants).await?,
167 + }
168 + status.summary = Some(summarize(&status.variants));
169 +
170 + if m.status.as_ref() != Some(&status) {
171 + // Skipped when unchanged: every write is a watch event, which would
172 + // reconcile again.
173 + let api: Api<Mutation> = Api::namespaced(ctx.client.clone(), &owner.ns);
174 + let patch = json!({ "status": status });
175 + api.patch_status(&owner.name, &PatchParams::default(), &Patch::Merge(&patch)).await?;
176 + }
177 + Ok(Action::requeue(RESYNC))
178 +}
179 +
180 +/// Creates the Mutation's Secret unless it exists, returning its name, or
181 +/// why there is none as a message for the Mutation's status.
182 +///
183 +/// An existing Secret is never rewritten, so the token stays stable. One
184 +/// that this Mutation does not own is left alone and reported: it was made
185 +/// by someone else, and its contents are not ours to vouch for.
186 +async fn ensure_secret(api: &Api<Secret>, m: &Mutation) -> Result<Result<String, String>, MutantopsError> {
187 + let name = secret_name(&m.name_any());
188 + let uid = m.uid();
189 + let owned = |s: &Secret| s.owner_references().iter().any(|o| Some(&o.uid) == uid.as_ref());
190 +
191 + if let Some(existing) = api.get_opt(&name).await? {
192 + return Ok(if owned(&existing) {
193 + Ok(name)
194 + } else {
195 + Err(format!("secret {name} exists but is not owned by this Mutation; not using it"))
196 + });
197 + }
198 +
199 + let owner = m
200 + .controller_owner_ref(&())
201 + .ok_or_else(|| MutantopsError::Invalid("Mutation has no uid".into()))?;
202 + let token = generate_token().map_err(|e| MutantopsError::Invalid(format!("no randomness for token: {e}")))?;
203 + match api.create(&PostParams::default(), &build_secret(m, owner, token)).await {
204 + Ok(_) => {
205 + info!(secret = %name, mutation = %m.name_any(), "created mutation secret");
206 + Ok(Ok(name))
207 + }
208 + // Lost a race with another reconcile; the next pass checks ownership.
209 + Err(kube::Error::Api(e)) if e.code == 409 => Ok(Ok(name)),
210 + // Invalid for this Mutation alone: report it there.
211 + Err(kube::Error::Api(e)) if e.code == 422 => {
212 + warn!(secret = %name, "cannot create mutation secret: {}", e.message);
213 + Ok(Err(format!("cannot create secret {name}: {}", e.message)))
214 + }
215 + Err(e) => Err(e.into()),
134 216 }
135 − let patch = json!({ "status": status });
136 − api.patch_status(&m.name_any(), &PatchParams::default(), &Patch::Merge(&patch)).await?;
137 − Ok(())
138 217 }
139 218
140 −fn error_policy(mutant: Arc<MutantDeployment>, error: &MutantopsError, _ctx: Arc<Ctx>) -> Action {
141 − warn!(mutant = %mutant.name_any(), "reconcile failed: {error}");
219 +fn error_policy<K: ResourceExt>(obj: Arc<K>, error: &MutantopsError, _ctx: Arc<Ctx>) -> Action {
220 + warn!(name = %obj.name_any(), ns = ?obj.namespace(), "reconcile failed: {error}");
142 221 Action::requeue(Duration::from_secs(10))
143 222 }
144 223
145 −/// Runs the controller until a shutdown signal.
224 +/// Runs both controllers until a shutdown signal.
146 225 pub async fn run(client: Client) {
147 − let mutants = Api::<MutantDeployment>::all(client.clone());
148 − Controller::new(mutants, watcher::Config::default())
149 − // Watch only the Deployments we generate: a hand edit or delete is
150 − // put back, and replica readiness flows into Mutation status.
151 − .owns(Api::<Deployment>::all(client.clone()), watcher::Config::default().labels(OWNER_LABEL))
152 − // A Mutation created, changed or deleted reconciles its target.
153 − .watches(Api::<Mutation>::all(client.clone()), watcher::Config::default(), |m| {
154 − let ns = m.namespace()?;
155 − Some(ObjectRef::new(&m.spec.mutant_deployment).within(&ns))
156 − })
226 + let ctx = Arc::new(Ctx { client: client.clone() });
227 + // Each watches only the Deployments it generates: a hand edit or delete
228 + // is put back, and replica readiness flows into status.
229 + let owned = || watcher::Config::default().labels(OWNER_LABEL);
230 +
231 + let mutants = Controller::new(Api::<MutantDeployment>::all(client.clone()), watcher::Config::default())
232 + .owns(Api::<Deployment>::all(client.clone()), owned())
157 233 .shutdown_on_signal()
158 − .run(reconcile, error_policy, Arc::new(Ctx { client }))
234 + .run(reconcile_mutant, error_policy, ctx.clone())
159 235 .for_each(|res| async move {
160 236 match res {
161 − Ok((obj, _)) => info!(name = %obj.name, ns = ?obj.namespace, "reconciled"),
162 − Err(err) => warn!("reconcile failed: {err}"),
237 + Ok((obj, _)) => info!(kind = "MutantDeployment", name = %obj.name, ns = ?obj.namespace, "reconciled"),
238 + Err(err) => warn!(kind = "MutantDeployment", "reconcile failed: {err}"),
163 239 }
164 − })
165 − .await;
240 + });
241 +
242 + let mutations = Controller::new(Api::<Mutation>::all(client.clone()), watcher::Config::default())
243 + .owns(Api::<Deployment>::all(client.clone()), owned())
244 + .shutdown_on_signal()
245 + .run(reconcile_mutation, error_policy, ctx)
246 + .for_each(|res| async move {
247 + match res {
248 + Ok((obj, _)) => info!(kind = "Mutation", name = %obj.name, ns = ?obj.namespace, "reconciled"),
249 + Err(err) => warn!(kind = "Mutation", "reconcile failed: {err}"),
250 + }
251 + });
252 +
253 + futures::join!(mutants, mutations);
166 254 }
modified src/crd.rs
+250 −182
@@ -1,31 +1,33 @@
1 1 //! The `MutantDeployment` and `Mutation` resources, and the pure logic that
2 2 //! turns them into concrete `Deployment`s.
3 3 //!
4 −//! A mutation can be declared two ways:
5 −//! - inline, in `MutantDeployment.spec.mutations` (lives in git with the
6 −//! MutantDeployment, so GitOps tools own it);
7 −//! - as a standalone `Mutation` resource naming its MutantDeployment in
8 −//! `spec.mutantDeployment` (created at runtime, e.g. through the HTTP API,
9 −//! without touching the GitOps-managed MutantDeployment).
4 +//! Both are a base Deployment plus variants of it, one Deployment per
5 +//! variant. They differ in who configures them:
6 +//! - a `MutantDeployment` is declared in git (its variants are
7 +//! `spec.mutations`), so GitOps tools own it;
8 +//! - a `Mutation` is configured at runtime through the operator's HTTP API
9 +//! (its variants are `spec.variants`), and gets a generated Secret.
10 10
11 11 use k8s_openapi::api::apps::v1::Deployment;
12 −use k8s_openapi::api::core::v1::EnvVar;
12 +use k8s_openapi::api::core::v1::{EnvVar, Secret};
13 13 use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference};
14 +use k8s_openapi::ByteString;
14 15 use kube::{CustomResource, ResourceExt};
15 16 use schemars::JsonSchema;
16 17 use serde::{Deserialize, Serialize};
17 18 use std::collections::{BTreeMap, HashMap, HashSet};
18 19
19 −/// Set on every generated Deployment: the mutation it was built from.
20 +/// Set on every generated Deployment: the variant it was built from.
20 21 /// Kept unprefixed for compatibility with Deployments created before it
21 22 /// was namespaced.
22 23 pub const MUTATION_LABEL: &str = "mutation";
23 −/// Set on every generated Deployment: the MutantDeployment that owns it.
24 −/// Used to find Deployments to prune when a mutation is removed.
24 +/// Set on every generated Deployment: the name of the MutantDeployment or
25 +/// Mutation that owns it. Used to find Deployments to prune.
25 26 pub const OWNER_LABEL: &str = "mutantops.neiam.org/owner";
26 −/// Set on Mutation resources the API creates: their MutantDeployment, so
27 −/// they can be selected with `kubectl get mutations -l ...`.
28 −pub const TARGET_LABEL: &str = "mutantops.neiam.org/mutant-deployment";
27 +/// Set on each Mutation's generated Secret: the Mutation it belongs to.
28 +pub const SECRET_MUTATION_LABEL: &str = "mutantops.neiam.org/mutation";
29 +/// Key of the generated token in a Mutation's Secret.
30 +pub const SECRET_TOKEN_KEY: &str = "token";
29 31
30 32 #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
31 33 #[kube(group = "neiam.org", version = "v1", kind = "MutantDeployment", namespaced)]
@@ -33,10 +35,10 @@ pub struct MutantDeploymentSpec {
33 35 #[schemars(skip)]
34 36 pub deployment: Deployment,
35 37 #[serde(default)]
36 − pub mutations: Vec<MutationDef>,
38 + pub mutations: Vec<Variant>,
37 39 }
38 40
39 −#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
41 +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
40 42 #[kube(
41 43 group = "neiam.org",
42 44 version = "v1",
@@ -44,17 +46,18 @@ pub struct MutantDeploymentSpec {
44 46 namespaced,
45 47 status = "MutationStatus"
46 48 )]
47 −#[serde(rename_all = "camelCase")]
48 49 pub struct MutationSpec {
49 − /// Name of the MutantDeployment, in the same namespace, to mutate.
50 − pub mutant_deployment: String,
51 − #[serde(flatten)]
52 − pub def: MutationDef,
50 + /// The base Deployment every variant is derived from.
51 + #[schemars(skip)]
52 + pub deployment: Deployment,
53 + #[serde(default)]
54 + pub variants: Vec<Variant>,
53 55 }
54 56
55 −/// One mutation, however it was declared.
57 +/// One variant of a base Deployment: its own name suffix, env overrides
58 +/// and replica count.
56 59 #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
57 −pub struct MutationDef {
60 +pub struct Variant {
58 61 pub label: String,
59 62 #[serde(default)]
60 63 pub env: HashMap<String, String>,
@@ -66,7 +69,24 @@ pub struct MutationDef {
66 69 pub struct MutationStatus {
67 70 // `None` serializes as null on purpose: status is written as a merge
68 71 // patch, where null clears a field left over from an earlier state.
69 − pub state: MutationState,
72 + /// e.g. "2/3 variants ready", for `kubectl get mutations`.
73 + #[serde(default)]
74 + pub summary: Option<String>,
75 + /// The Mutation's generated Secret, holding a random `token`.
76 + #[serde(default)]
77 + pub secret: Option<String>,
78 + /// A problem with the Mutation as a whole, such as its Secret.
79 + #[serde(default)]
80 + pub message: Option<String>,
81 + #[serde(default)]
82 + pub variants: Vec<VariantStatus>,
83 +}
84 +
85 +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
86 +#[serde(rename_all = "camelCase")]
87 +pub struct VariantStatus {
88 + pub label: String,
89 + pub state: VariantState,
70 90 #[serde(default)]
71 91 pub message: Option<String>,
72 92 /// The generated Deployment.
@@ -79,21 +99,28 @@ pub struct MutationStatus {
79 99 }
80 100
81 101 #[derive(Deserialize, Serialize, Clone, Copy, Debug, Default, PartialEq, Eq, JsonSchema)]
82 −pub enum MutationState {
102 +pub enum VariantState {
83 103 /// Deployment applied; not all replicas ready yet.
84 104 #[default]
85 105 Progressing,
86 106 /// Every requested replica is ready.
87 107 Ready,
88 − /// Not applied: the spec is invalid.
108 + /// Not applied: the variant is invalid.
89 109 Invalid,
90 − /// Not applied: another mutation of the same MutantDeployment already
91 − /// uses this label.
110 + /// Not applied: its label repeats an earlier variant's, or its
111 + /// Deployment name is already taken by another MutantDeployment or
112 + /// Mutation.
92 113 Conflict,
93 114 }
94 115
95 −impl MutationDef {
96 − /// Checks the mutation can become a valid Deployment, so a bad entry is
116 +impl VariantStatus {
117 + pub fn rejected(label: &str, state: VariantState, message: String) -> Self {
118 + Self { label: label.into(), state, message: Some(message), deployment: None, replicas: None, ready_replicas: None }
119 + }
120 +}
121 +
122 +impl Variant {
123 + /// Checks the variant can become a valid Deployment, so a bad entry is
97 124 /// refused at the API instead of failing reconciliation later.
98 125 pub fn validate(&self) -> Result<(), String> {
99 126 validate_label(&self.label)?;
@@ -107,9 +134,33 @@ impl MutationDef {
107 134 }
108 135 }
109 136
110 −/// The label becomes both a name suffix and a label value, so it must be an
111 −/// RFC 1123 label: lowercase alphanumerics and '-', 1-63 chars, alphanumeric
112 −/// at both ends.
137 +/// Checks every variant, and that no two share a label.
138 +pub fn validate_variants(variants: &[Variant]) -> Result<(), String> {
139 + let mut seen = HashSet::new();
140 + for v in variants {
141 + v.validate()?;
142 + if !seen.insert(&v.label) {
143 + return Err(format!("variant label {:?} appears more than once", v.label));
144 + }
145 + }
146 + Ok(())
147 +}
148 +
149 +/// Checks a base Deployment can have variants built from it.
150 +pub fn validate_base(base: &Deployment) -> Result<(), String> {
151 + match base.metadata.name.as_deref() {
152 + None | Some("") => return Err("deployment.metadata.name is required: variants are named after it".into()),
153 + Some(_) => {}
154 + }
155 + if base.spec.as_ref().and_then(|s| s.template.spec.as_ref()).is_none() {
156 + return Err("deployment.spec.template.spec is required".into());
157 + }
158 + Ok(())
159 +}
160 +
161 +/// Labels become both a name suffix and a label value, and Mutation names
162 +/// a Secret name prefix, so both must be RFC 1123 labels: lowercase
163 +/// alphanumerics and '-', 1-63 chars, alphanumeric at both ends.
113 164 pub fn validate_label(label: &str) -> Result<(), String> {
114 165 let ok = !label.is_empty()
115 166 && label.len() <= 63
@@ -122,7 +173,7 @@ pub fn validate_label(label: &str) -> Result<(), String> {
122 173 Ok(())
123 174 } else {
124 175 Err(format!(
125 − "invalid label {label:?}: must be 1-63 lowercase alphanumerics or '-', \
176 + "invalid name {label:?}: must be 1-63 lowercase alphanumerics or '-', \
126 177 starting and ending alphanumeric"
127 178 ))
128 179 }
@@ -139,94 +190,40 @@ fn validate_env_name(name: &str) -> Result<(), String> {
139 190 }
140 191 }
141 192
142 −impl MutantDeployment {
143 − /// Name of the base Deployment the mutations are derived from.
144 − pub fn base_name(&self) -> String {
145 − self.spec
146 − .deployment
147 − .metadata
148 − .name
149 − .clone()
150 − .unwrap_or_else(|| "mutant".into())
151 − }
152 −
153 − /// Name of the Deployment generated for `label`.
154 − pub fn deployment_name(&self, label: &str) -> String {
155 − format!("{}-{}", self.base_name(), label)
156 − }
193 +/// Name of the base Deployment variants are derived from.
194 +pub fn base_name(base: &Deployment) -> String {
195 + base.metadata.name.clone().unwrap_or_else(|| "mutant".into())
157 196 }
158 197
159 −/// Where an active mutation was declared.
160 −#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
161 −#[serde(tag = "kind", rename_all = "camelCase")]
162 −pub enum Source {
163 − /// In the MutantDeployment's `spec.mutations`.
164 − Inline,
165 − /// A `Mutation` resource of this name.
166 − Resource { name: String },
198 +/// Name of the Deployment generated for variant `label` of `base`.
199 +pub fn deployment_name(base: &Deployment, label: &str) -> String {
200 + format!("{}-{}", base_name(base), label)
167 201 }
168 202
169 −/// The outcome of combining a MutantDeployment's inline mutations with the
170 −/// Mutation resources that target it.
171 −#[derive(Debug, Default)]
172 −pub struct Resolved {
173 − /// Mutations to run, one per label.
174 − pub active: Vec<(MutationDef, Source)>,
175 − /// Mutation resources not run, by name, with why.
176 − pub rejected: Vec<(String, MutationState, String)>,
177 −}
178 −
179 −/// Decides which mutations run. Inline mutations come first, since they
180 −/// live in git with the MutantDeployment; Mutation resources follow, oldest
181 −/// first, so an existing mutation is never displaced by a newer one with the
182 −/// same label. Invalid inline entries are dropped (the caller logs them).
183 −pub fn resolve(mutant: &MutantDeployment, resources: &[Mutation]) -> Resolved {
184 − let mut out = Resolved::default();
185 − let mut labels = HashSet::new();
186 −
187 − for def in &mutant.spec.mutations {
188 − if def.validate().is_ok() && labels.insert(def.label.clone()) {
189 − out.active.push((def.clone(), Source::Inline));
190 − }
203 +/// Adds `variant` to `list`, refusing a duplicate label.
204 +pub fn insert_variant(list: &mut Vec<Variant>, variant: Variant) -> Result<(), String> {
205 + if list.iter().any(|v| v.label == variant.label) {
206 + return Err(format!("variant {:?} already exists", variant.label));
191 207 }
208 + list.push(variant);
209 + Ok(())
210 +}
192 211
193 − let mut targeting: Vec<_> = resources
194 − .iter()
195 − .filter(|m| m.spec.mutant_deployment == mutant.name_any() && m.metadata.deletion_timestamp.is_none())
196 − .collect();
197 − targeting.sort_by(|a, b| {
198 − (a.creation_timestamp(), a.name_any()).cmp(&(b.creation_timestamp(), b.name_any()))
199 − });
200 − for m in targeting {
201 − let def = &m.spec.def;
202 − if let Err(msg) = def.validate() {
203 − out.rejected.push((m.name_any(), MutationState::Invalid, msg));
204 − } else if !labels.insert(def.label.clone()) {
205 − out.rejected.push((
206 − m.name_any(),
207 − MutationState::Conflict,
208 − format!("label {:?} is already used by another mutation of {}", def.label, mutant.name_any()),
209 − ));
210 − } else {
211 − out.active.push((def.clone(), Source::Resource { name: m.name_any() }));
212 − }
213 − }
214 − out
212 +/// Removes the variant labelled `label`, returning it if it was present.
213 +pub fn remove_variant(list: &mut Vec<Variant>, label: &str) -> Option<Variant> {
214 + let idx = list.iter().position(|v| v.label == label)?;
215 + Some(list.remove(idx))
215 216 }
216 217
217 −/// Status of an applied mutation, from its Deployment's state.
218 −pub fn applied_status(name: String, desired: i32, deploy: Option<&Deployment>) -> MutationStatus {
219 − let status = deploy.and_then(|d| d.status.as_ref());
220 − let ready = status.and_then(|s| s.ready_replicas).unwrap_or(0);
218 +/// Status of an applied variant, from its Deployment's state.
219 +pub fn applied_status(label: &str, name: String, desired: i32, deploy: Option<&Deployment>) -> VariantStatus {
220 + let ready = deploy.and_then(|d| d.status.as_ref()).and_then(|s| s.ready_replicas).unwrap_or(0);
221 221 let observed_current = deploy.is_some_and(|d| {
222 222 d.status.as_ref().and_then(|s| s.observed_generation) >= d.metadata.generation
223 223 });
224 − MutationStatus {
225 − state: if observed_current && ready >= desired {
226 − MutationState::Ready
227 − } else {
228 − MutationState::Progressing
229 − },
224 + VariantStatus {
225 + label: label.into(),
226 + state: if observed_current && ready >= desired { VariantState::Ready } else { VariantState::Progressing },
230 227 message: None,
231 228 deployment: Some(name),
232 229 replicas: Some(desired),
@@ -234,23 +231,72 @@ pub fn applied_status(name: String, desired: i32, deploy: Option<&Deployment>) -
234 231 }
235 232 }
236 233
237 −/// Builds the Deployment for one mutation, ready for server-side apply.
234 +/// e.g. "2/3 variants ready".
235 +pub fn summarize(variants: &[VariantStatus]) -> String {
236 + let ready = variants.iter().filter(|v| v.state == VariantState::Ready).count();
237 + format!("{ready}/{} variants ready", variants.len())
238 +}
239 +
240 +/// Markers Argo CD puts on what it syncs, by tracking method: the
241 +/// annotation (Argo CD 3's default) and the label (earlier default).
242 +const ARGO_TRACKING_ANNOTATION: &str = "argocd.argoproj.io/tracking-id";
243 +const ARGO_TRACKING_LABEL: &str = "app.kubernetes.io/instance";
244 +
245 +/// Whether a GitOps tool owns this Mutation, so it changes in git and the
246 +/// API must not change or delete it (the sync would only put it back).
247 +pub fn is_gitops_managed(m: &Mutation) -> bool {
248 + m.annotations().contains_key(ARGO_TRACKING_ANNOTATION) || m.labels().contains_key(ARGO_TRACKING_LABEL)
249 +}
250 +
251 +/// Name of the Secret generated for the Mutation named `mutation`.
252 +pub fn secret_name(mutation: &str) -> String {
253 + format!("{mutation}-secret")
254 +}
255 +
256 +/// The Secret generated for `mutation`: a random `token`, owned by the
257 +/// Mutation so it is garbage-collected with it. Created once and never
258 +/// rewritten, so the token is stable for the Mutation's lifetime.
259 +pub fn build_secret(mutation: &Mutation, owner: OwnerReference, token: String) -> Secret {
260 + Secret {
261 + metadata: ObjectMeta {
262 + name: Some(secret_name(&mutation.name_any())),
263 + namespace: mutation.namespace(),
264 + labels: Some(BTreeMap::from([(SECRET_MUTATION_LABEL.to_string(), mutation.name_any())])),
265 + owner_references: Some(vec![owner]),
266 + ..Default::default()
267 + },
268 + type_: Some("Opaque".into()),
269 + data: Some(BTreeMap::from([(SECRET_TOKEN_KEY.to_string(), ByteString(token.into_bytes()))])),
270 + ..Default::default()
271 + }
272 +}
273 +
274 +/// 32 random bytes from the OS, hex-encoded.
275 +pub fn generate_token() -> Result<String, getrandom::Error> {
276 + let mut bytes = [0u8; 32];
277 + getrandom::fill(&mut bytes)?;
278 + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
279 +}
280 +
281 +/// Builds the Deployment for one variant of `base`, ready for server-side
282 +/// apply, owned by `owner` (named `owner_name`) in namespace `ns`.
238 283 pub fn build_deployment(
239 − mutant: &MutantDeployment,
240 − mutation: &MutationDef,
284 + base: &Deployment,
285 + ns: Option<String>,
286 + owner_name: &str,
287 + variant: &Variant,
241 288 owner: OwnerReference,
242 289 ) -> Deployment {
243 − let base = &mutant.spec.deployment;
244 290 let mut labels: BTreeMap<String, String> = base.metadata.labels.clone().unwrap_or_default();
245 − labels.insert(MUTATION_LABEL.into(), mutation.label.clone());
246 − labels.insert(OWNER_LABEL.into(), mutant.name_any());
291 + labels.insert(MUTATION_LABEL.into(), variant.label.clone());
292 + labels.insert(OWNER_LABEL.into(), owner_name.into());
247 293
248 294 let mut deploy = Deployment {
249 295 // Only the user-authored parts of the base metadata are carried over;
250 296 // server-populated fields would make the apply fail.
251 297 metadata: ObjectMeta {
252 − name: Some(mutant.deployment_name(&mutation.label)),
253 − namespace: mutant.namespace(),
298 + name: Some(deployment_name(base, &variant.label)),
299 + namespace: ns,
254 300 labels: Some(labels),
255 301 annotations: base.metadata.annotations.clone(),
256 302 owner_references: Some(vec![owner]),
@@ -261,18 +307,18 @@ pub fn build_deployment(
261 307 };
262 308
263 309 if let Some(spec) = &mut deploy.spec {
264 − spec.replicas = Some(mutation.instances);
310 + spec.replicas = Some(variant.instances);
265 311 // Pod labels (not the selector, which is immutable on existing
266 − // Deployments) so a Service or query can single out one mutation.
312 + // Deployments) so a Service or query can single out one variant.
267 313 spec.template
268 314 .metadata
269 315 .get_or_insert_with(Default::default)
270 316 .labels
271 317 .get_or_insert_with(Default::default)
272 − .insert(MUTATION_LABEL.into(), mutation.label.clone());
318 + .insert(MUTATION_LABEL.into(), variant.label.clone());
273 319
274 320 if let Some(pod) = &mut spec.template.spec {
275 − let mut env: Vec<_> = mutation.env.iter().collect();
321 + let mut env: Vec<_> = variant.env.iter().collect();
276 322 env.sort(); // stable order, so re-applies don't churn the pod template
277 323 for container in &mut pod.containers {
278 324 let vars = container.env.get_or_insert_with(Vec::new);
@@ -295,10 +341,9 @@ pub fn build_deployment(
295 341 #[cfg(test)]
296 342 mod tests {
297 343 use super::*;
298 − use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
299 344
300 − fn mutant(inline: Vec<MutationDef>) -> MutantDeployment {
301 − let deployment: Deployment = serde_json::from_value(serde_json::json!({
345 + fn base() -> Deployment {
346 + serde_json::from_value(serde_json::json!({
302 347 "apiVersion": "apps/v1",
303 348 "kind": "Deployment",
304 349 "metadata": { "name": "nginx", "labels": { "app": "nginx" }, "resourceVersion": "9" },
@@ -314,36 +359,32 @@ mod tests {
314 359 }
315 360 }
316 361 }))
317 − .unwrap();
318 − let mut md = MutantDeployment::new("nginx-mutant", MutantDeploymentSpec { deployment, mutations: inline });
319 − md.metadata.namespace = Some("default".into());
320 − md
362 + .unwrap()
321 363 }
322 364
323 − fn def(label: &str) -> MutationDef {
324 − MutationDef {
365 + fn variant(label: &str) -> Variant {
366 + Variant {
325 367 label: label.into(),
326 368 env: HashMap::from([("MODE".into(), "canary".into()), ("EXTRA".into(), "1".into())]),
327 369 instances: 1,
328 370 }
329 371 }
330 372
331 − fn resource(name: &str, target: &str, label: &str, created_secs: i64) -> Mutation {
332 − let mut m = Mutation::new(name, MutationSpec { mutant_deployment: target.into(), def: def(label) });
333 − m.metadata.creation_timestamp = Some(Time(k8s_openapi::chrono::DateTime::from_timestamp(created_secs, 0).unwrap()));
373 + fn mutation(name: &str) -> Mutation {
374 + let mut m = Mutation::new(name, MutationSpec { deployment: base(), variants: vec![variant("a")] });
375 + m.metadata.namespace = Some("default".into());
334 376 m
335 377 }
336 378
337 379 #[test]
338 380 fn builds_deployment() {
339 − let md = mutant(vec![]);
340 − let d = build_deployment(&md, &def("canary"), OwnerReference::default());
381 + let d = build_deployment(&base(), Some("default".into()), "web", &variant("canary"), OwnerReference::default());
341 382 assert_eq!(d.metadata.name.as_deref(), Some("nginx-canary"));
342 383 assert_eq!(d.metadata.namespace.as_deref(), Some("default"));
343 384 assert_eq!(d.metadata.resource_version, None);
344 385 let labels = d.metadata.labels.unwrap();
345 386 assert_eq!(labels[MUTATION_LABEL], "canary");
346 − assert_eq!(labels[OWNER_LABEL], "nginx-mutant");
387 + assert_eq!(labels[OWNER_LABEL], "web");
347 388 assert_eq!(labels["app"], "nginx");
348 389
349 390 let spec = d.spec.unwrap();
@@ -356,49 +397,35 @@ mod tests {
356 397
357 398 #[test]
358 399 fn validates() {
359 − assert!(def("canary").validate().is_ok());
400 + assert!(variant("canary").validate().is_ok());
360 401 for bad in ["", "Canary", "-a", "a-", "a_b", &"a".repeat(64)] {
361 402 assert!(validate_label(bad).is_err(), "{bad:?}");
362 403 }
363 − let mut m = def("ok");
364 − m.instances = -1;
365 − assert!(m.validate().is_err());
366 − let mut m = def("ok");
367 − m.env.insert("1BAD".into(), "x".into());
368 − assert!(m.validate().is_err());
404 + let mut v = variant("ok");
405 + v.instances = -1;
406 + assert!(v.validate().is_err());
407 + let mut v = variant("ok");
408 + v.env.insert("1BAD".into(), "x".into());
409 + assert!(v.validate().is_err());
410 +
411 + assert!(validate_variants(&[variant("a"), variant("b")]).is_ok());
412 + assert!(validate_variants(&[variant("a"), variant("a")]).is_err());
413 +
414 + assert!(validate_base(&base()).is_ok());
415 + let mut nameless = base();
416 + nameless.metadata.name = None;
417 + assert!(validate_base(&nameless).is_err());
418 + assert!(validate_base(&Deployment::default()).is_err());
369 419 }
370 420
371 421 #[test]
372 − fn resolves_inline_first_then_oldest_resource() {
373 − let md = mutant(vec![def("canary")]);
374 − let mut bad = resource("bad", "nginx-mutant", "Nope", 1);
375 − bad.spec.def.label = "Nope".into();
376 − let resources = [
377 − resource("newer-beta", "nginx-mutant", "beta", 20),
378 − resource("older-beta", "nginx-mutant", "beta", 10),
379 − resource("dup-canary", "nginx-mutant", "canary", 5),
380 − resource("elsewhere", "other-mutant", "gamma", 1),
381 − bad,
382 − ];
383 − let r = resolve(&md, &resources);
384 −
385 − let active: Vec<_> = r.active.iter().map(|(d, s)| (d.label.as_str(), s.clone())).collect();
386 − assert_eq!(
387 − active,
388 − [
389 − ("canary", Source::Inline),
390 − ("beta", Source::Resource { name: "older-beta".into() }),
391 − ]
392 − );
393 − let rejected: Vec<_> = r.rejected.iter().map(|(n, s, _)| (n.as_str(), *s)).collect();
394 − assert_eq!(
395 − rejected,
396 − [
397 − ("bad", MutationState::Invalid),
398 − ("dup-canary", MutationState::Conflict),
399 − ("newer-beta", MutationState::Conflict),
400 − ]
401 − );
422 + fn inserts_and_removes() {
423 + let mut list = vec![variant("a")];
424 + assert!(insert_variant(&mut list, variant("a")).is_err());
425 + insert_variant(&mut list, variant("b")).unwrap();
426 + assert_eq!(remove_variant(&mut list, "a").map(|v| v.label), Some("a".into()));
427 + assert_eq!(remove_variant(&mut list, "a"), None);
428 + assert_eq!(list.len(), 1);
402 429 }
403 430
404 431 #[test]
@@ -408,20 +435,61 @@ mod tests {
408 435 "status": { "observedGeneration": 2, "readyReplicas": 2 }
409 436 }))
410 437 .unwrap();
411 − assert_eq!(applied_status("x".into(), 2, Some(&d)).state, MutationState::Ready);
412 − assert_eq!(applied_status("x".into(), 3, Some(&d)).state, MutationState::Progressing);
438 + assert_eq!(applied_status("x", "d".into(), 2, Some(&d)).state, VariantState::Ready);
439 + assert_eq!(applied_status("x", "d".into(), 3, Some(&d)).state, VariantState::Progressing);
413 440 d.metadata.generation = Some(3); // spec changed, not yet observed
414 − assert_eq!(applied_status("x".into(), 2, Some(&d)).state, MutationState::Progressing);
415 − assert_eq!(applied_status("x".into(), 0, None).state, MutationState::Progressing);
441 + assert_eq!(applied_status("x", "d".into(), 2, Some(&d)).state, VariantState::Progressing);
442 + assert_eq!(applied_status("x", "d".into(), 0, None).state, VariantState::Progressing);
443 +
444 + let ready = applied_status("a", "d".into(), 0, Some(&d));
445 + let rejected = VariantStatus::rejected("b", VariantState::Conflict, "taken".into());
446 + assert_eq!(summarize(&[ready, rejected]), "0/2 variants ready");
447 + }
448 +
449 + #[test]
450 + fn detects_gitops_managed() {
451 + let mut m = mutation("a");
452 + assert!(!is_gitops_managed(&m));
453 + m.annotations_mut().insert(ARGO_TRACKING_ANNOTATION.into(), "crib:neiam.org/Mutation:x/a".into());
454 + assert!(is_gitops_managed(&m));
455 + let mut m = mutation("b");
456 + m.labels_mut().insert(ARGO_TRACKING_LABEL.into(), "crib".into());
457 + assert!(is_gitops_managed(&m));
416 458 }
417 459
418 460 #[test]
419 − fn mutation_wire_format() {
461 + fn builds_secret() {
462 + let m = mutation("browser-snap");
463 + let s = build_secret(&m, OwnerReference { uid: "u1".into(), ..Default::default() }, "abc".into());
464 + assert_eq!(s.metadata.name.as_deref(), Some("browser-snap-secret"));
465 + assert_eq!(s.metadata.namespace.as_deref(), Some("default"));
466 + assert_eq!(s.metadata.owner_references.unwrap()[0].uid, "u1");
467 + assert_eq!(s.metadata.labels.unwrap()[SECRET_MUTATION_LABEL], "browser-snap");
468 + assert_eq!(s.data.unwrap()[SECRET_TOKEN_KEY].0, b"abc");
469 + }
470 +
471 + #[test]
472 + fn tokens_are_random_hex() {
473 + let (a, b) = (generate_token().unwrap(), generate_token().unwrap());
474 + assert_eq!(a.len(), 64);
475 + assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
476 + assert_ne!(a, b);
477 + }
478 +
479 + #[test]
480 + fn wire_format() {
420 481 let m: MutationSpec = serde_json::from_value(serde_json::json!({
421 − "mutantDeployment": "nginx-mutant", "label": "beta", "env": { "A": "1" }, "instances": 2
482 + "deployment": { "metadata": { "name": "browser-snap" } },
483 + "variants": [{ "label": "lilygo47", "env": { "SNAP_RES": "960x540" }, "instances": 1 }]
484 + }))
485 + .unwrap();
486 + assert_eq!(base_name(&m.deployment), "browser-snap");
487 + assert_eq!(m.variants[0].label, "lilygo47");
488 + // A MutantDeployment's variants keep their `mutations` key.
489 + let md: MutantDeploymentSpec = serde_json::from_value(serde_json::json!({
490 + "deployment": {}, "mutations": [{ "label": "a", "instances": 1 }]
422 491 }))
423 492 .unwrap();
424 − assert_eq!(m.mutant_deployment, "nginx-mutant");
425 − assert_eq!(m.def.label, "beta");
493 + assert_eq!(md.mutations[0].label, "a");
426 494 }
427 495 }

Parents: c8d9978