Mutation resources and an HTTP API to manage them

c8d9978 · gmorell · 2026-11-09 05:41

12 files +1245 -125
Message
{commit_body(@commit)}

Files changed

modified Cargo.lock
+81 −0
@@ -305,6 +305,56 @@ version = "1.4.0"
305 305 source = "registry+https://github.com/rust-lang/crates.io-index"
306 306 checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
307 307
308 +[[package]]
309 +name = "axum"
310 +version = "0.8.9"
311 +source = "registry+https://github.com/rust-lang/crates.io-index"
312 +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
313 +dependencies = [
314 + "axum-core",
315 + "bytes",
316 + "form_urlencoded",
317 + "futures-util",
318 + "http",
319 + "http-body",
320 + "http-body-util",
321 + "hyper",
322 + "hyper-util",
323 + "itoa",
324 + "matchit",
325 + "memchr",
326 + "mime",
327 + "percent-encoding",
328 + "pin-project-lite",
329 + "serde_core",
330 + "serde_json",
331 + "serde_path_to_error",
332 + "serde_urlencoded",
333 + "sync_wrapper",
334 + "tokio",
335 + "tower",
336 + "tower-layer",
337 + "tower-service",
338 +]
339 +
340 +[[package]]
341 +name = "axum-core"
342 +version = "0.5.6"
343 +source = "registry+https://github.com/rust-lang/crates.io-index"
344 +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
345 +dependencies = [
346 + "bytes",
347 + "futures-core",
348 + "http",
349 + "http-body",
350 + "http-body-util",
351 + "mime",
352 + "pin-project-lite",
353 + "sync_wrapper",
354 + "tower-layer",
355 + "tower-service",
356 +]
357 +
308 358 [[package]]
309 359 name = "backon"
310 360 version = "1.5.0"
@@ -1221,6 +1271,7 @@ dependencies = [
1221 1271 "http",
1222 1272 "http-body",
1223 1273 "httparse",
1274 + "httpdate",
1224 1275 "itoa",
1225 1276 "pin-project-lite",
1226 1277 "smallvec",
@@ -1786,6 +1837,12 @@ dependencies = [
1786 1837 "windows-sys 0.61.2",
1787 1838 ]
1788 1839
1840 +[[package]]
1841 +name = "matchit"
1842 +version = "0.8.4"
1843 +source = "registry+https://github.com/rust-lang/crates.io-index"
1844 +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
1845 +
1789 1846 [[package]]
1790 1847 name = "md-5"
1791 1848 version = "0.11.0"
@@ -1834,6 +1891,7 @@ version = "0.1.0"
1834 1891 dependencies = [
1835 1892 "anyhow",
1836 1893 "async-trait",
1894 + "axum",
1837 1895 "derive",
1838 1896 "futures",
1839 1897 "k8s-openapi",
@@ -2804,6 +2862,29 @@ dependencies = [
2804 2862 "zmij",
2805 2863 ]
2806 2864
2865 +[[package]]
2866 +name = "serde_path_to_error"
2867 +version = "0.1.20"
2868 +source = "registry+https://github.com/rust-lang/crates.io-index"
2869 +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
2870 +dependencies = [
2871 + "itoa",
2872 + "serde",
2873 + "serde_core",
2874 +]
2875 +
2876 +[[package]]
2877 +name = "serde_urlencoded"
2878 +version = "0.7.1"
2879 +source = "registry+https://github.com/rust-lang/crates.io-index"
2880 +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
2881 +dependencies = [
2882 + "form_urlencoded",
2883 + "itoa",
2884 + "ryu",
2885 + "serde",
2886 +]
2887 +
2807 2888 [[package]]
2808 2889 name = "serde_yaml"
2809 2890 version = "0.9.34+deprecated"
modified Cargo.toml
+3 −2
@@ -9,7 +9,7 @@ k8s-openapi = { version = "0.24.0", features = ["latest"] }
9 9 kube = { version = "0.99.0", features = ["derive", "runtime"] }
10 10 schemars = "0.8.22"
11 11 serde = { version = "1.0.219", features = ["derive"] }
12 −tokio = { version = "1.44.2", features = ["rt", "rt-multi-thread", "macros"] }
12 +tokio = { version = "1.44.2", features = ["rt", "rt-multi-thread", "macros", "signal", "net"] }
13 13 async-trait = "0.1.88"
14 14 serde_json = "1.0.140"
15 15 anyhow = "1.0.98"
@@ -18,6 +18,7 @@ tracing = "0.1.41"
18 18 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 +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "tower-log", "query"] }
21 22
22 23
23 24 [package.metadata.sqlx]
@@ -25,4 +26,4 @@ offline = true
25 26
26 27 [[bin]]
27 28 name = "mutant-deployment"
28 −path = "src/bin/main-deployment.rs"
29 +path = "src/bin/main-deployment.rs"
modified README.md
+101 −1
@@ -43,4 +43,104 @@ spec:
43 43 env:
44 44 NGINX_EXPERIMENTAL: "true"
45 45 instances: 3
46 −```
46 +```
47 +Removing a mutation from the list deletes its Deployment. Deleting the
48 +MutantDeployment deletes all of them.
49 +
50 +## Mutation resources
51 +
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:
55 +
56 +```yaml
57 +apiVersion: neiam.org/v1
58 +kind: Mutation
59 +metadata:
60 + name: nginx-mutant-beta
61 +spec:
62 + mutantDeployment: nginx-mutant # same namespace
63 + label: beta
64 + env:
65 + FEATURE_X: "on"
66 + instances: 2
67 +```
68 +
69 +```console
70 +$ kubectl get mutations
71 +NAME TARGET LABEL INSTANCES READY STATE AGE
72 +nginx-mutant-beta nginx-mutant beta 2 2 Ready 1m
73 +```
74 +
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.
84 +
85 +### Argo CD
86 +
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`:
91 +
92 +```yaml
93 +resource.customizations.health.neiam.org_Mutation: |
94 + 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
100 + end
101 + return hs
102 +```
103 +
104 +## HTTP API
105 +
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.
109 +
110 +The API is off until `MUTANT_API_TOKEN` is set (`operator.yml` reads it from
111 +the `mutantops-api` secret). Every `/api` request then needs
112 +`Authorization: Bearer <token>`. `MUTANT_API_INSECURE=true` serves it without
113 +auth. `MUTANT_API_ADDR` sets the listen address (default `0.0.0.0:8080`).
114 +
115 +```sh
116 +kubectl -n mutants create secret generic mutantops-api --from-literal=token="$(openssl rand -hex 32)"
117 +kubectl -n mutants rollout restart deploy/mutantops-operator
118 +TOKEN=$(kubectl -n mutants get secret mutantops-api -o jsonpath='{.data.token}' | base64 -d)
119 +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
132 +```
133 +
134 +A listed mutation looks like:
135 +
136 +```json
137 +{
138 + "label": "beta",
139 + "env": { "FEATURE_X": "on" },
140 + "instances": 2,
141 + "source": { "kind": "resource", "name": "nginx-mutant-beta" },
142 + "status": { "state": "Ready", "message": null, "deployment": "nginx-beta", "replicas": 2, "readyReplicas": 2 }
143 +}
144 +```
145 +
146 +`source.kind` is `inline` for mutations declared in the MutantDeployment.
modified crd.yml
+94 −0
@@ -41,3 +41,97 @@ spec:
41 41 kind: MutantDeployment
42 42 shortNames:
43 43 - md
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.
48 +apiVersion: apiextensions.k8s.io/v1
49 +kind: CustomResourceDefinition
50 +metadata:
51 + name: mutations.neiam.org
52 +spec:
53 + group: neiam.org
54 + scope: Namespaced
55 + names:
56 + plural: mutations
57 + singular: mutation
58 + kind: Mutation
59 + shortNames:
60 + - mut
61 + versions:
62 + - name: v1
63 + served: true
64 + storage: true
65 + subresources:
66 + status: {}
67 + additionalPrinterColumns:
68 + - name: Target
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
77 + - name: Ready
78 + type: integer
79 + jsonPath: .status.readyReplicas
80 + - name: State
81 + type: string
82 + jsonPath: .status.state
83 + - name: Age
84 + type: date
85 + jsonPath: .metadata.creationTimestamp
86 + schema:
87 + openAPIV3Schema:
88 + type: object
89 + required:
90 + - spec
91 + properties:
92 + spec:
93 + type: object
94 + required:
95 + - mutantDeployment
96 + - label
97 + - instances
98 + 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:
107 + type: object
108 + default: {}
109 + additionalProperties:
110 + type: string
111 + instances:
112 + type: integer
113 + format: int32
114 + minimum: 0
115 + status:
116 + type: object
117 + required:
118 + - state
119 + properties:
120 + state:
121 + type: string
122 + enum: [Progressing, Ready, Invalid, Conflict]
123 + message:
124 + type: string
125 + nullable: true
126 + deployment:
127 + description: The generated Deployment.
128 + type: string
129 + nullable: true
130 + replicas:
131 + type: integer
132 + format: int32
133 + nullable: true
134 + readyReplicas:
135 + type: integer
136 + format: int32
137 + nullable: true
modified operator.yml
+27 −0
@@ -29,6 +29,31 @@ spec:
29 29 fieldPath: metadata.name
30 30 - name: OPERATOR_NAME
31 31 value: "mutantops-operator"
32 + # The HTTP API stays off until this is set. Create the secret with:
33 + # kubectl -n mutants create secret generic mutantops-api \
34 + # --from-literal=token="$(openssl rand -hex 32)"
35 + - name: MUTANT_API_TOKEN
36 + valueFrom:
37 + secretKeyRef:
38 + name: mutantops-api
39 + key: token
40 + optional: true
41 + ports:
42 + - name: http
43 + containerPort: 8080
44 +---
45 +apiVersion: v1
46 +kind: Service
47 +metadata:
48 + name: mutantops-api
49 + namespace: mutants
50 +spec:
51 + selector:
52 + app: mutantops-operator
53 + ports:
54 + - name: http
55 + port: 80
56 + targetPort: http
32 57
33 58 ---
34 59 apiVersion: v1
@@ -80,6 +105,8 @@ rules:
80 105 - neiam.org
81 106 resources:
82 107 - mutantdeployments
108 + - mutations
109 + - mutations/status
83 110 verbs:
84 111 - create
85 112 - delete
added src/api.rs
+307 −0
@@ -0,0 +1,307 @@
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.
9 +//!
10 +//! ```text
11 +//! 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}
17 +//! ```
18 +
19 +use crate::crd::{
20 + applied_status, resolve, MutantDeployment, Mutation, MutationDef, MutationSpec, MutationStatus,
21 + Source, OWNER_LABEL, TARGET_LABEL,
22 +};
23 +use axum::{
24 + extract::{Path, Request, State},
25 + http::{header, StatusCode},
26 + middleware::{self, Next},
27 + response::{IntoResponse, Response},
28 + routing::get,
29 + Json, Router,
30 +};
31 +use k8s_openapi::api::apps::v1::Deployment;
32 +use kube::{
33 + api::{Api, DeleteParams, ListParams, PostParams},
34 + Client, Resource, ResourceExt,
35 +};
36 +use serde::Serialize;
37 +use serde_json::json;
38 +use std::{collections::BTreeMap, sync::Arc};
39 +use tracing::{info, warn};
40 +
41 +#[derive(Clone)]
42 +pub struct AppState {
43 + client: Client,
44 + /// Bearer token required on every `/api` request; `None` disables auth.
45 + token: Option<Arc<str>>,
46 +}
47 +
48 +impl AppState {
49 + pub fn new(client: Client, token: Option<String>) -> Self {
50 + Self { client, token: token.map(Into::into) }
51 + }
52 +}
53 +
54 +pub fn router(state: AppState) -> Router {
55 + let api = Router::new()
56 + .route("/namespaces/{ns}/mutantdeployments", get(list_mutants))
57 + .route(
58 + "/namespaces/{ns}/mutantdeployments/{name}/mutations",
59 + get(list_mutations).post(create_mutation),
60 + )
61 + .route(
62 + "/namespaces/{ns}/mutantdeployments/{name}/mutations/{label}",
63 + get(get_mutation).delete(delete_mutation),
64 + )
65 + .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
66 + Router::new()
67 + .route("/healthz", get(|| async { "ok" }))
68 + .nest("/api/v1", api)
69 + .with_state(state)
70 +}
71 +
72 +async fn require_token(State(state): State<AppState>, req: Request, next: Next) -> Response {
73 + let presented = req
74 + .headers()
75 + .get(header::AUTHORIZATION)
76 + .and_then(|v| v.to_str().ok())
77 + .and_then(|v| v.strip_prefix("Bearer "));
78 + if authorized(state.token.as_deref(), presented) {
79 + next.run(req).await
80 + } else {
81 + ApiError::Unauthorized.into_response()
82 + }
83 +}
84 +
85 +fn authorized(expected: Option<&str>, presented: Option<&str>) -> bool {
86 + match (expected, presented) {
87 + (None, _) => true,
88 + (Some(want), Some(got)) => constant_time_eq(want.as_bytes(), got.as_bytes()),
89 + (Some(_), None) => false,
90 + }
91 +}
92 +
93 +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
94 + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
95 +}
96 +
97 +#[derive(Debug)]
98 +enum ApiError {
99 + Unauthorized,
100 + BadRequest(String),
101 + NotFound(String),
102 + Conflict(String),
103 + Kube(kube::Error),
104 +}
105 +
106 +impl From<kube::Error> for ApiError {
107 + fn from(err: kube::Error) -> Self {
108 + match err {
109 + kube::Error::Api(ref e) if e.code == 404 => ApiError::NotFound(e.message.clone()),
110 + kube::Error::Api(ref e) if e.code == 409 => ApiError::Conflict(e.message.clone()),
111 + kube::Error::Api(ref e) if e.code == 422 => ApiError::BadRequest(e.message.clone()),
112 + other => ApiError::Kube(other),
113 + }
114 + }
115 +}
116 +
117 +impl IntoResponse for ApiError {
118 + fn into_response(self) -> Response {
119 + let (status, msg) = match self {
120 + ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "missing or invalid bearer token".into()),
121 + ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
122 + ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
123 + ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
124 + ApiError::Kube(e) => {
125 + warn!("kubernetes request failed: {e}");
126 + (StatusCode::BAD_GATEWAY, format!("kubernetes request failed: {e}"))
127 + }
128 + };
129 + (status, Json(json!({ "error": msg }))).into_response()
130 + }
131 +}
132 +
133 +type ApiResult<T> = Result<T, ApiError>;
134 +
135 +/// A mutation as the API reports it: its definition, where it was declared,
136 +/// and its live state.
137 +#[derive(Serialize, Debug)]
138 +struct MutationView {
139 + #[serde(flatten)]
140 + def: MutationDef,
141 + source: Source,
142 + status: MutationStatus,
143 +}
144 +
145 +#[derive(Serialize)]
146 +struct MutantSummary {
147 + name: String,
148 + deployment: String,
149 + mutations: Vec<String>,
150 +}
151 +
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 + });
186 + }
187 + }
188 + Ok(out)
189 +}
190 +
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 + ))
209 +}
210 +
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?))
216 +}
217 +
218 +async fn get_mutation(
219 + State(st): State<AppState>,
220 + Path((ns, name, label)): Path<(String, String, String)>,
221 +) -> 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")))
229 +}
230 +
231 +/// Creates a Mutation resource named `{name}-{label}`.
232 +async fn create_mutation(
233 + State(st): State<AppState>,
234 + 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 + }
243 +
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]);
253 +
254 + let created = Api::<Mutation>::namespaced(st.client, &ns)
255 + .create(&PostParams::default(), &mutation)
256 + .await?;
257 + info!(%ns, %name, %label, "mutation created");
258 + Ok((StatusCode::CREATED, Json(created)))
259 +}
260 +
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(
265 + State(st): State<AppState>,
266 + Path((ns, name, label)): Path<(String, String, String)>,
267 +) -> 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) {
277 + return Err(ApiError::Conflict(format!(
278 + "mutation {label:?} is declared inline in MutantDeployment {name}; remove it from its spec.mutations (in git) instead"
279 + )));
280 + }
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 + }
288 + }
289 + }
290 + info!(%ns, %name, %label, "mutation deleted");
291 + Ok(StatusCode::NO_CONTENT)
292 +}
293 +
294 +#[cfg(test)]
295 +mod tests {
296 + use super::*;
297 +
298 + #[test]
299 + fn auth() {
300 + assert!(authorized(None, None));
301 + assert!(authorized(None, Some("anything")));
302 + assert!(authorized(Some("s3cret"), Some("s3cret")));
303 + assert!(!authorized(Some("s3cret"), Some("s3cre")));
304 + assert!(!authorized(Some("s3cret"), Some("s3creT")));
305 + assert!(!authorized(Some("s3cret"), None));
306 + }
307 +}
modified src/bin/main-deployment.rs
+38 −124
@@ -1,135 +1,49 @@
1 −use futures::StreamExt;
2 −use k8s_openapi::api::apps::v1::Deployment;
3 −use kube::{
4 − api::{Api, PostParams},
5 − runtime::controller::{self, Action, Controller},
6 − Client, CustomResource, ResourceExt,
7 −};
8 −use schemars::JsonSchema;
9 −use serde::{Deserialize, Serialize};
10 −use std::{collections::HashMap, fmt, sync::Arc};
11 −use kube::runtime::watcher;
12 −use tokio::time::Duration;
13 −use tracing::info;
1 +use kube::Client;
2 +use mutantops::{api, controller};
3 +use std::net::SocketAddr;
4 +use tracing::{info, warn};
14 5
15 −// Define a custom error type
16 −#[derive(Debug)]
17 −pub enum MutantopsError {
18 − ParseError(String),
19 − IoError(std::io::Error),
20 − KubeError(kube::Error),
21 −}
22 −
23 −impl std::error::Error for MutantopsError {}
24 −
25 −// Implement the `fmt::Display` trait for `MyError`
26 −impl fmt::Display for MutantopsError {
27 − fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
28 − match self {
29 − MutantopsError::ParseError(msg) => write!(f, "Parse error: {}", msg),
30 − MutantopsError::IoError(err) => write!(f, "IO error: {}", err),
31 − MutantopsError::KubeError(err) => write!(f, "Kube error: {}", err),
32 − }
33 − }
34 −}
35 −
36 −// Implement the `From` trait for `std::io::Error`
37 −impl From<std::io::Error> for MutantopsError {
38 − fn from(err: std::io::Error) -> Self {
39 − MutantopsError::IoError(err)
40 − }
41 −}
42 −
43 −// Implement the `From` trait for `kube::Error`
44 −impl From<kube::Error> for MutantopsError {
45 − fn from(err: kube::Error) -> Self {
46 − MutantopsError::KubeError(err)
47 − }
48 −}
49 −
50 −#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
51 −#[kube(group = "neiam.org", version = "v1", kind = "MutantDeployment", namespaced)]
52 −pub struct MutantDeploymentSpec {
53 − #[schemars(skip)]
54 − pub deployment: Deployment,
55 − pub mutations: Vec<Mutation>,
56 −}
57 −
58 −#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
59 −pub struct Mutation {
60 − pub label: String,
61 − pub env: HashMap<String, String>,
62 − pub instances: i32,
63 −}
64 −
65 −// #[instrument(skip(_ctx))]
66 −async fn reconcile(mutant: Arc<MutantDeployment>, _ctx: Arc<()>) -> Result<Action, MutantopsError> {
67 − let client = Client::try_default().await?;
68 − let deployments: Api<Deployment> = Api::namespaced(client.clone(), &mutant.namespace().unwrap());
69 −
70 − let base_deploy = &mutant.spec.deployment;
71 −
72 − for mutation in &mutant.spec.mutations {
73 − let mut new_deploy = base_deploy.clone();
74 −
75 − // Modify name
76 − let name = format!("{}-{}", base_deploy.metadata.name.clone().unwrap_or("mutant".into()), mutation.label);
77 − new_deploy.metadata.name = Some(name.clone());
78 − new_deploy.metadata.labels.get_or_insert_with(Default::default).insert("mutation".to_string(), mutation.label.clone());
79 −
80 − // Modify replicas
81 − if let Some(spec) = &mut new_deploy.spec {
82 − spec.replicas = Some(mutation.instances);
83 −
84 − if let Some(template) = &mut spec.template.spec {
85 − for container in &mut template.containers {
86 − let envs = container.env.get_or_insert(vec![]);
87 − for (key, val) in &mutation.env {
88 − envs.push(k8s_openapi::api::core::v1::EnvVar {
89 − name: key.clone(),
90 − value: Some(val.clone()),
91 − value_from: None,
92 − });
93 − }
94 − }
95 − }
96 − }
97 −
98 − // Apply the new deployment
99 − match deployments.get(&name).await {
100 − Ok(_) => {
101 − deployments.replace(&name, &PostParams::default(), &new_deploy).await?;
102 − }
103 − Err(_) => {
104 − deployments.create(&PostParams::default(), &new_deploy).await?;
105 − }
106 − }
107 − }
108 −
109 − Ok(Action::await_change())
110 −}
111 −
112 −fn error_policy(_object: Arc<MutantDeployment>, error: &MutantopsError, _ctx: Arc<()>) -> controller::Action {
113 − println!("Error reconciling: {:?}", error);
114 − controller::Action::requeue(Duration::from_secs(10))
115 −}
6 +/// Address the HTTP API listens on.
7 +const DEFAULT_API_ADDR: &str = "0.0.0.0:8080";
116 8
117 9 #[tokio::main]
118 10 async fn main() -> anyhow::Result<()> {
119 11 tracing_subscriber::fmt::init();
120 12
121 13 let client = Client::try_default().await?;
122 − let crds = Api::<MutantDeployment>::all(client.clone());
123 14
124 − Controller::new(crds, watcher::Config::default())
125 − .run(reconcile, error_policy, Arc::new(()))
126 − .for_each(|res| async move {
127 − match res {
128 − Ok((mutant, _)) => info!("Reconciled {:?}", mutant.name),
129 − Err(err) => info!("Reconcile failed: {:?}", err),
130 − }
131 − })
132 − .await;
15 + // The API can create Deployments with arbitrary env in any namespace the
16 + // operator can write to, so it needs a token unless explicitly opted out.
17 + let token = std::env::var("MUTANT_API_TOKEN").ok().filter(|t| !t.is_empty());
18 + let insecure = std::env::var("MUTANT_API_INSECURE").is_ok_and(|v| v == "true");
19 + let api_enabled = token.is_some() || insecure;
20 + if token.is_none() && insecure {
21 + warn!("MUTANT_API_INSECURE=true: HTTP API is serving without authentication");
22 + }
23 +
24 + let addr: SocketAddr = std::env::var("MUTANT_API_ADDR")
25 + .unwrap_or_else(|_| DEFAULT_API_ADDR.into())
26 + .parse()?;
133 27
28 + let server = async {
29 + if !api_enabled {
30 + warn!("HTTP API disabled: set MUTANT_API_TOKEN (or MUTANT_API_INSECURE=true) to enable it");
31 + std::future::pending::<()>().await;
32 + }
33 + let listener = tokio::net::TcpListener::bind(addr).await?;
34 + info!(%addr, "HTTP API listening");
35 + axum::serve(listener, api::router(api::AppState::new(client.clone(), token)))
36 + .with_graceful_shutdown(async {
37 + let _ = tokio::signal::ctrl_c().await;
38 + })
39 + .await?;
40 + anyhow::Ok(())
41 + };
42 +
43 + // The controller stops on SIGINT/SIGTERM; when it does, so does the API.
44 + tokio::select! {
45 + () = controller::run(client.clone()) => {}
46 + res = server => res?,
47 + }
134 48 Ok(())
135 49 }
added src/controller.rs
+166 −0
@@ -0,0 +1,166 @@
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.
4 +
5 +use crate::crd::{
6 + applied_status, build_deployment, resolve, MutantDeployment, Mutation, MutationStatus, Source,
7 + MUTATION_LABEL, OWNER_LABEL,
8 +};
9 +use futures::StreamExt;
10 +use k8s_openapi::api::apps::v1::Deployment;
11 +use kube::{
12 + api::{Api, DeleteParams, ListParams, Patch, PatchParams},
13 + runtime::{
14 + controller::{Action, Controller},
15 + reflector::ObjectRef,
16 + watcher,
17 + },
18 + Client, Resource, ResourceExt,
19 +};
20 +use serde_json::json;
21 +use std::{collections::HashSet, fmt, sync::Arc, time::Duration};
22 +use tracing::{info, warn};
23 +
24 +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 +const RESYNC: Duration = Duration::from_secs(300);
29 +
30 +#[derive(Debug)]
31 +pub enum MutantopsError {
32 + Invalid(String),
33 + Kube(kube::Error),
34 +}
35 +
36 +impl std::error::Error for MutantopsError {}
37 +
38 +impl fmt::Display for MutantopsError {
39 + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40 + match self {
41 + MutantopsError::Invalid(msg) => write!(f, "invalid resource: {msg}"),
42 + MutantopsError::Kube(err) => write!(f, "kube error: {err}"),
43 + }
44 + }
45 +}
46 +
47 +impl From<kube::Error> for MutantopsError {
48 + fn from(err: kube::Error) -> Self {
49 + MutantopsError::Kube(err)
50 + }
51 +}
52 +
53 +struct Ctx {
54 + client: Client,
55 +}
56 +
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 + }
75 +
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 + }
89 + }
90 +
91 + let resolved = resolve(&mutant, &resources);
92 + let find = |name: &str| resources.iter().find(|m| m.name_any() == name);
93 +
94 + 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?;
102 + }
103 + }
104 + wanted.insert(name);
105 + }
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 +
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? {
118 + let name = existing.name_any();
119 + let owned = existing.owner_references().iter().any(|o| o.uid == controller_ref.uid);
120 + 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?;
123 + }
124 + }
125 +
126 + Ok(Action::requeue(RESYNC))
127 +}
128 +
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(());
134 + }
135 + let patch = json!({ "status": status });
136 + api.patch_status(&m.name_any(), &PatchParams::default(), &Patch::Merge(&patch)).await?;
137 + Ok(())
138 +}
139 +
140 +fn error_policy(mutant: Arc<MutantDeployment>, error: &MutantopsError, _ctx: Arc<Ctx>) -> Action {
141 + warn!(mutant = %mutant.name_any(), "reconcile failed: {error}");
142 + Action::requeue(Duration::from_secs(10))
143 +}
144 +
145 +/// Runs the controller until a shutdown signal.
146 +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 + })
157 + .shutdown_on_signal()
158 + .run(reconcile, error_policy, Arc::new(Ctx { client }))
159 + .for_each(|res| async move {
160 + match res {
161 + Ok((obj, _)) => info!(name = %obj.name, ns = ?obj.namespace, "reconciled"),
162 + Err(err) => warn!("reconcile failed: {err}"),
163 + }
164 + })
165 + .await;
166 +}
added src/crd.rs
+427 −0
@@ -0,0 +1,427 @@
1 +//! The `MutantDeployment` and `Mutation` resources, and the pure logic that
2 +//! turns them into concrete `Deployment`s.
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).
10 +
11 +use k8s_openapi::api::apps::v1::Deployment;
12 +use k8s_openapi::api::core::v1::EnvVar;
13 +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference};
14 +use kube::{CustomResource, ResourceExt};
15 +use schemars::JsonSchema;
16 +use serde::{Deserialize, Serialize};
17 +use std::collections::{BTreeMap, HashMap, HashSet};
18 +
19 +/// Set on every generated Deployment: the mutation it was built from.
20 +/// Kept unprefixed for compatibility with Deployments created before it
21 +/// was namespaced.
22 +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.
25 +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";
29 +
30 +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
31 +#[kube(group = "neiam.org", version = "v1", kind = "MutantDeployment", namespaced)]
32 +pub struct MutantDeploymentSpec {
33 + #[schemars(skip)]
34 + pub deployment: Deployment,
35 + #[serde(default)]
36 + pub mutations: Vec<MutationDef>,
37 +}
38 +
39 +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
40 +#[kube(
41 + group = "neiam.org",
42 + version = "v1",
43 + kind = "Mutation",
44 + namespaced,
45 + status = "MutationStatus"
46 +)]
47 +#[serde(rename_all = "camelCase")]
48 +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,
53 +}
54 +
55 +/// One mutation, however it was declared.
56 +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, JsonSchema)]
57 +pub struct MutationDef {
58 + pub label: String,
59 + #[serde(default)]
60 + pub env: HashMap<String, String>,
61 + pub instances: i32,
62 +}
63 +
64 +#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq, JsonSchema)]
65 +#[serde(rename_all = "camelCase")]
66 +pub struct MutationStatus {
67 + // `None` serializes as null on purpose: status is written as a merge
68 + // patch, where null clears a field left over from an earlier state.
69 + pub state: MutationState,
70 + #[serde(default)]
71 + pub message: Option<String>,
72 + /// The generated Deployment.
73 + #[serde(default)]
74 + pub deployment: Option<String>,
75 + #[serde(default)]
76 + pub replicas: Option<i32>,
77 + #[serde(default)]
78 + pub ready_replicas: Option<i32>,
79 +}
80 +
81 +#[derive(Deserialize, Serialize, Clone, Copy, Debug, Default, PartialEq, Eq, JsonSchema)]
82 +pub enum MutationState {
83 + /// Deployment applied; not all replicas ready yet.
84 + #[default]
85 + Progressing,
86 + /// Every requested replica is ready.
87 + Ready,
88 + /// Not applied: the spec is invalid.
89 + Invalid,
90 + /// Not applied: another mutation of the same MutantDeployment already
91 + /// uses this label.
92 + Conflict,
93 +}
94 +
95 +impl MutationDef {
96 + /// Checks the mutation can become a valid Deployment, so a bad entry is
97 + /// refused at the API instead of failing reconciliation later.
98 + pub fn validate(&self) -> Result<(), String> {
99 + validate_label(&self.label)?;
100 + if self.instances < 0 {
101 + return Err(format!("instances must be >= 0, got {}", self.instances));
102 + }
103 + for key in self.env.keys() {
104 + validate_env_name(key)?;
105 + }
106 + Ok(())
107 + }
108 +}
109 +
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.
113 +pub fn validate_label(label: &str) -> Result<(), String> {
114 + let ok = !label.is_empty()
115 + && label.len() <= 63
116 + && label
117 + .bytes()
118 + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
119 + && !label.starts_with('-')
120 + && !label.ends_with('-');
121 + if ok {
122 + Ok(())
123 + } else {
124 + Err(format!(
125 + "invalid label {label:?}: must be 1-63 lowercase alphanumerics or '-', \
126 + starting and ending alphanumeric"
127 + ))
128 + }
129 +}
130 +
131 +/// Kubernetes' rule for env var names: `[-._a-zA-Z][-._a-zA-Z0-9]*`.
132 +fn validate_env_name(name: &str) -> Result<(), String> {
133 + let mut bytes = name.bytes();
134 + let first_ok = matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b"-._".contains(&b));
135 + if first_ok && bytes.all(|b| b.is_ascii_alphanumeric() || b"-._".contains(&b)) {
136 + Ok(())
137 + } else {
138 + Err(format!("invalid env var name {name:?}"))
139 + }
140 +}
141 +
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 + }
157 +}
158 +
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 },
167 +}
168 +
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 + }
191 + }
192 +
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
215 +}
216 +
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);
221 + let observed_current = deploy.is_some_and(|d| {
222 + d.status.as_ref().and_then(|s| s.observed_generation) >= d.metadata.generation
223 + });
224 + MutationStatus {
225 + state: if observed_current && ready >= desired {
226 + MutationState::Ready
227 + } else {
228 + MutationState::Progressing
229 + },
230 + message: None,
231 + deployment: Some(name),
232 + replicas: Some(desired),
233 + ready_replicas: Some(ready),
234 + }
235 +}
236 +
237 +/// Builds the Deployment for one mutation, ready for server-side apply.
238 +pub fn build_deployment(
239 + mutant: &MutantDeployment,
240 + mutation: &MutationDef,
241 + owner: OwnerReference,
242 +) -> Deployment {
243 + let base = &mutant.spec.deployment;
244 + 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());
247 +
248 + let mut deploy = Deployment {
249 + // Only the user-authored parts of the base metadata are carried over;
250 + // server-populated fields would make the apply fail.
251 + metadata: ObjectMeta {
252 + name: Some(mutant.deployment_name(&mutation.label)),
253 + namespace: mutant.namespace(),
254 + labels: Some(labels),
255 + annotations: base.metadata.annotations.clone(),
256 + owner_references: Some(vec![owner]),
257 + ..Default::default()
258 + },
259 + spec: base.spec.clone(),
260 + status: None,
261 + };
262 +
263 + if let Some(spec) = &mut deploy.spec {
264 + spec.replicas = Some(mutation.instances);
265 + // Pod labels (not the selector, which is immutable on existing
266 + // Deployments) so a Service or query can single out one mutation.
267 + spec.template
268 + .metadata
269 + .get_or_insert_with(Default::default)
270 + .labels
271 + .get_or_insert_with(Default::default)
272 + .insert(MUTATION_LABEL.into(), mutation.label.clone());
273 +
274 + if let Some(pod) = &mut spec.template.spec {
275 + let mut env: Vec<_> = mutation.env.iter().collect();
276 + env.sort(); // stable order, so re-applies don't churn the pod template
277 + for container in &mut pod.containers {
278 + let vars = container.env.get_or_insert_with(Vec::new);
279 + for (key, val) in &env {
280 + // Override a variable the base already sets rather than
281 + // adding a duplicate entry.
282 + vars.retain(|v| &v.name != *key);
283 + vars.push(EnvVar {
284 + name: (*key).clone(),
285 + value: Some((*val).clone()),
286 + value_from: None,
287 + });
288 + }
289 + }
290 + }
291 + }
292 + deploy
293 +}
294 +
295 +#[cfg(test)]
296 +mod tests {
297 + use super::*;
298 + use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
299 +
300 + fn mutant(inline: Vec<MutationDef>) -> MutantDeployment {
301 + let deployment: Deployment = serde_json::from_value(serde_json::json!({
302 + "apiVersion": "apps/v1",
303 + "kind": "Deployment",
304 + "metadata": { "name": "nginx", "labels": { "app": "nginx" }, "resourceVersion": "9" },
305 + "spec": {
306 + "replicas": 3,
307 + "selector": { "matchLabels": { "app": "nginx" } },
308 + "template": {
309 + "metadata": { "labels": { "app": "nginx" } },
310 + "spec": { "containers": [{
311 + "name": "nginx", "image": "nginx",
312 + "env": [{ "name": "MODE", "value": "base" }]
313 + }] }
314 + }
315 + }
316 + }))
317 + .unwrap();
318 + let mut md = MutantDeployment::new("nginx-mutant", MutantDeploymentSpec { deployment, mutations: inline });
319 + md.metadata.namespace = Some("default".into());
320 + md
321 + }
322 +
323 + fn def(label: &str) -> MutationDef {
324 + MutationDef {
325 + label: label.into(),
326 + env: HashMap::from([("MODE".into(), "canary".into()), ("EXTRA".into(), "1".into())]),
327 + instances: 1,
328 + }
329 + }
330 +
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()));
334 + m
335 + }
336 +
337 + #[test]
338 + fn builds_deployment() {
339 + let md = mutant(vec![]);
340 + let d = build_deployment(&md, &def("canary"), OwnerReference::default());
341 + assert_eq!(d.metadata.name.as_deref(), Some("nginx-canary"));
342 + assert_eq!(d.metadata.namespace.as_deref(), Some("default"));
343 + assert_eq!(d.metadata.resource_version, None);
344 + let labels = d.metadata.labels.unwrap();
345 + assert_eq!(labels[MUTATION_LABEL], "canary");
346 + assert_eq!(labels[OWNER_LABEL], "nginx-mutant");
347 + assert_eq!(labels["app"], "nginx");
348 +
349 + let spec = d.spec.unwrap();
350 + assert_eq!(spec.replicas, Some(1));
351 + assert_eq!(spec.template.metadata.unwrap().labels.unwrap()[MUTATION_LABEL], "canary");
352 + let env = spec.template.spec.unwrap().containers[0].env.clone().unwrap();
353 + let names: Vec<_> = env.iter().map(|e| (e.name.as_str(), e.value.as_deref().unwrap())).collect();
354 + assert_eq!(names, [("EXTRA", "1"), ("MODE", "canary")]);
355 + }
356 +
357 + #[test]
358 + fn validates() {
359 + assert!(def("canary").validate().is_ok());
360 + for bad in ["", "Canary", "-a", "a-", "a_b", &"a".repeat(64)] {
361 + assert!(validate_label(bad).is_err(), "{bad:?}");
362 + }
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());
369 + }
370 +
371 + #[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 + );
402 + }
403 +
404 + #[test]
405 + fn status_from_deployment() {
406 + let mut d: Deployment = serde_json::from_value(serde_json::json!({
407 + "metadata": { "generation": 2 },
408 + "status": { "observedGeneration": 2, "readyReplicas": 2 }
409 + }))
410 + .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);
413 + 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);
416 + }
417 +
418 + #[test]
419 + fn mutation_wire_format() {
420 + let m: MutationSpec = serde_json::from_value(serde_json::json!({
421 + "mutantDeployment": "nginx-mutant", "label": "beta", "env": { "A": "1" }, "instances": 2
422 + }))
423 + .unwrap();
424 + assert_eq!(m.mutant_deployment, "nginx-mutant");
425 + assert_eq!(m.def.label, "beta");
426 + }
427 +}
added src/lib.rs
+3 −0
@@ -0,0 +1,3 @@
1 +pub mod api;
2 +pub mod controller;
3 +pub mod crd;

Parents: e4ce82a