neiam
/mutant-operator
An Experimental Operator
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
API: scale a variant in place
8cbd453 · gmorell · 2026-11-09 15:46
Message
{commit_body(@commit)}
Files changed
modified
README.md
+7
−1
@@ -162,7 +162,7 @@ Every `/api` request needs `Authorization: Bearer <token>`, and which token
| 162 | 162 | depends on the request: |
| 163 | 163 | |
| 164 | 164 | - **A Mutation's own token** (`token` in its `<name>-secret`) allows getting |
| 165 | − that Mutation and listing, adding and removing its variants, and nothing | |
| 165 | + that Mutation and listing, adding, scaling and removing its variants, and nothing | |
| 166 | 166 | else. An app in the Mutation's namespace reads it from that Secret, so it |
| 167 | 167 | can manage its own Mutation's variants without credentials for anything |
| 168 | 168 | else. The operator only accepts a Secret owned by the Mutation, so one |
@@ -185,9 +185,13 @@ DELETE /api/v1/namespaces/{ns}/mutations/{name}
| 185 | 185 | GET /api/v1/namespaces/{ns}/mutations/{name}/variants |
| 186 | 186 | POST /api/v1/namespaces/{ns}/mutations/{name}/variants {label, env, instances} |
| 187 | 187 | GET /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} |
| 188 | +PATCH /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} {instances} | |
| 188 | 189 | DELETE /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} |
| 189 | 190 | ``` |
| 190 | 191 | |
| 192 | +`PATCH` scales a variant in place: its Deployment's replica count changes | |
| 193 | +and the running pods are kept. | |
| 194 | + | |
| 191 | 195 | Creating answers 201, or 400 if invalid and 409 if the name or label is |
| 192 | 196 | taken. Deleting answers 204, or 404 if absent. Changes to an Argo-synced |
| 193 | 197 | Mutation answer 409. Concurrent variant edits are serialised on the |
@@ -209,6 +213,8 @@ WEB=$(kubectl -n default get secret web-secret -o jsonpath='{.data.token}' | bas
| 209 | 213 | AUTH="Authorization: Bearer $WEB" |
| 210 | 214 | curl -H "$AUTH" -H 'Content-Type: application/json' \ |
| 211 | 215 | -d '{"label":"beta","env":{"FEATURE_X":"on"},"instances":2}' $NS/mutations/web/variants |
| 216 | +curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \ | |
| 217 | + -d '{"instances":4}' $NS/mutations/web/variants/beta | |
| 212 | 218 | curl -X DELETE -H "$AUTH" $NS/mutations/web/variants/beta |
| 213 | 219 | # list |
| 214 | 220 | curl -H "$AUTH" $NS/mutations |
modified
src/api.rs
+49
−2
@@ -10,13 +10,15 @@
| 10 | 10 | //! GET /api/v1/namespaces/{ns}/mutations/{name}/variants |
| 11 | 11 | //! POST /api/v1/namespaces/{ns}/mutations/{name}/variants {label, env, instances} |
| 12 | 12 | //! GET /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} |
| 13 | +//! PATCH /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} {instances} | |
| 13 | 14 | //! DELETE /api/v1/namespaces/{ns}/mutations/{name}/variants/{label} |
| 14 | 15 | //! ``` |
| 15 | 16 | //! |
| 16 | 17 | //! ## Authorization |
| 17 | 18 | //! |
| 18 | 19 | //! Requests carry `Authorization: Bearer <token>`. A request about one |
| 19 | −//! Mutation -- getting it, listing, adding or removing its variants -- is | |
| 20 | +//! Mutation -- getting it, listing, adding, scaling or removing its | |
| 21 | +//! variants -- is | |
| 20 | 22 | //! allowed with that Mutation's own token: the `token` in the |
| 21 | 23 | //! `<name>-secret` the operator generated for it, in its namespace. An app |
| 22 | 24 | //! reads that Secret from its own namespace and can then manage that one |
@@ -87,7 +89,7 @@ pub fn router(state: AppState) -> Router {
| 87 | 89 | ) |
| 88 | 90 | .route( |
| 89 | 91 | "/namespaces/{ns}/mutations/{name}/variants/{label}", |
| 90 | − get(get_variant).delete(delete_variant), | |
| 92 | + get(get_variant).patch(update_variant).delete(delete_variant), | |
| 91 | 93 | ); |
| 92 | 94 | Router::new() |
| 93 | 95 | .route("/healthz", get(|| async { "ok" })) |
@@ -224,6 +226,14 @@ struct VariantView {
| 224 | 226 | read_only: bool, |
| 225 | 227 | } |
| 226 | 228 | |
| 229 | +/// A change to an existing variant. Only its replica count, for now: a | |
| 230 | +/// variant's label and env are what make it that variant. | |
| 231 | +#[derive(Deserialize)] | |
| 232 | +#[serde(deny_unknown_fields)] | |
| 233 | +struct UpdateVariant { | |
| 234 | + instances: i32, | |
| 235 | +} | |
| 236 | + | |
| 227 | 237 | #[derive(Deserialize)] |
| 228 | 238 | struct CreateMutation { |
| 229 | 239 | name: String, |
@@ -365,6 +375,34 @@ async fn delete_variant(
| 365 | 375 | Ok(StatusCode::NO_CONTENT) |
| 366 | 376 | } |
| 367 | 377 | |
| 378 | +/// Scales a variant in place: its Deployment's replicas change, and the | |
| 379 | +/// pods already running are kept. | |
| 380 | +async fn update_variant( | |
| 381 | + State(st): State<AppState>, | |
| 382 | + headers: HeaderMap, | |
| 383 | + Path((ns, name, label)): Path<(String, String, String)>, | |
| 384 | + Json(req): Json<UpdateVariant>, | |
| 385 | +) -> ApiResult<Json<Variant>> { | |
| 386 | + authorize_mutation(&st, &headers, &ns, &name).await?; | |
| 387 | + if req.instances < 0 { | |
| 388 | + return Err(ApiError::BadRequest(format!("instances must be >= 0, got {}", req.instances))); | |
| 389 | + } | |
| 390 | + let updated = std::sync::Mutex::new(None); | |
| 391 | + update_variants(&st.mutations(&ns), &name, |list| { | |
| 392 | + let v = list | |
| 393 | + .iter_mut() | |
| 394 | + .find(|v| v.label == label) | |
| 395 | + .ok_or_else(|| ApiError::NotFound(format!("variant {label:?} not found")))?; | |
| 396 | + v.instances = req.instances; | |
| 397 | + *updated.lock().unwrap() = Some(v.clone()); | |
| 398 | + Ok(()) | |
| 399 | + }) | |
| 400 | + .await?; | |
| 401 | + info!(%ns, %name, %label, instances = req.instances, "variant scaled"); | |
| 402 | + let variant = updated.into_inner().unwrap().expect("set by the successful edit"); | |
| 403 | + Ok(Json(variant)) | |
| 404 | +} | |
| 405 | + | |
| 368 | 406 | fn refuse_if_gitops(m: &Mutation) -> ApiResult<()> { |
| 369 | 407 | if is_gitops_managed(m) { |
| 370 | 408 | return Err(ApiError::Conflict(format!( |
@@ -424,6 +462,15 @@ mod tests {
| 424 | 462 | assert!(admin_ok(None, true, None)); |
| 425 | 463 | } |
| 426 | 464 | |
| 465 | + #[test] | |
| 466 | + fn update_variant_body() { | |
| 467 | + let ok: UpdateVariant = serde_json::from_str(r#"{"instances": 3}"#).unwrap(); | |
| 468 | + assert_eq!(ok.instances, 3); | |
| 469 | + // A label or env change is not a scale; refused rather than ignored. | |
| 470 | + assert!(serde_json::from_str::<UpdateVariant>(r#"{"instances": 3, "label": "x"}"#).is_err()); | |
| 471 | + assert!(serde_json::from_str::<UpdateVariant>(r#"{}"#).is_err()); | |
| 472 | + } | |
| 473 | + | |
| 427 | 474 | #[test] |
| 428 | 475 | fn mutation_token_auth() { |
| 429 | 476 | use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; |
Parents: 241f400