API: scale a variant in place

8cbd453 · gmorell · 2026-11-09 15:46

3 files +56 -3
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