From c9232f080d867d41d5d03c039e22c76541d73961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Liebau?= Date: Wed, 11 Mar 2026 09:39:20 +0100 Subject: [PATCH 1/5] feat(webhooks): add StackableScaler CRD rollout and admission webhook Add a mutating admission webhook that guards StackableScaler replicas changes during active scaling operations, preventing state machine corruption from concurrent HPA updates. Includes CRD YAML, RBAC roles, webhook registration, and generated Cargo/Nix files. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 19 +- Cargo.nix | 83 ++---- Cargo.toml | 2 +- crate-hashes.json | 19 +- .../commons-operator/templates/roles.yaml | 10 + extra/crds.yaml | 121 ++++++++ rust/operator-binary/src/main.rs | 15 + rust/operator-binary/src/webhooks/mod.rs | 26 ++ .../src/webhooks/scaler_admission.rs | 273 ++++++++++++++++++ 9 files changed, 475 insertions(+), 93 deletions(-) create mode 100644 rust/operator-binary/src/webhooks/scaler_admission.rs diff --git a/Cargo.lock b/Cargo.lock index 33705a0..9653361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1501,7 +1501,6 @@ dependencies = [ [[package]] name = "k8s-version" version = "0.1.3" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "darling", "regex", @@ -1511,7 +1510,7 @@ dependencies = [ [[package]] name = "kube" version = "3.0.1" -source = "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5" +source = "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#1320643f8ce7f8189e03496ff1329d678d76224c" dependencies = [ "k8s-openapi", "kube-client", @@ -1523,7 +1522,7 @@ dependencies = [ [[package]] name = "kube-client" version = "3.0.1" -source = "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5" +source = "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#1320643f8ce7f8189e03496ff1329d678d76224c" dependencies = [ "base64", "bytes", @@ -1557,7 +1556,7 @@ dependencies = [ [[package]] name = "kube-core" version = "3.0.1" -source = "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5" +source = "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#1320643f8ce7f8189e03496ff1329d678d76224c" dependencies = [ "derive_more", "form_urlencoded", @@ -1575,7 +1574,7 @@ dependencies = [ [[package]] name = "kube-derive" version = "3.0.1" -source = "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5" +source = "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#1320643f8ce7f8189e03496ff1329d678d76224c" dependencies = [ "darling", "proc-macro2", @@ -1588,7 +1587,7 @@ dependencies = [ [[package]] name = "kube-runtime" version = "3.0.1" -source = "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5" +source = "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#1320643f8ce7f8189e03496ff1329d678d76224c" dependencies = [ "ahash", "async-broadcast", @@ -2794,7 +2793,6 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackable-certs" version = "0.4.0" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "const-oid", "ecdsa", @@ -2838,7 +2836,6 @@ dependencies = [ [[package]] name = "stackable-operator" version = "0.106.2" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "clap", "const_format", @@ -2877,7 +2874,6 @@ dependencies = [ [[package]] name = "stackable-operator-derive" version = "0.3.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "darling", "proc-macro2", @@ -2888,7 +2884,6 @@ dependencies = [ [[package]] name = "stackable-shared" version = "0.1.0" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "jiff", "k8s-openapi", @@ -2905,7 +2900,6 @@ dependencies = [ [[package]] name = "stackable-telemetry" version = "0.6.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "axum", "clap", @@ -2929,7 +2923,6 @@ dependencies = [ [[package]] name = "stackable-versioned" version = "0.8.3" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "schemars", "serde", @@ -2942,7 +2935,6 @@ dependencies = [ [[package]] name = "stackable-versioned-macros" version = "0.8.3" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "convert_case", "convert_case_extras", @@ -2960,7 +2952,6 @@ dependencies = [ [[package]] name = "stackable-webhook" version = "0.9.0" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#f9b117c8c08557e9774f33145bb009fb74cb2437" dependencies = [ "arc-swap", "async-trait", diff --git a/Cargo.nix b/Cargo.nix index d5ddbf0..8cb9bbe 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -4797,12 +4797,7 @@ rec { crateName = "k8s-version"; version = "0.1.3"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/k8s-version; }; libName = "k8s_version"; authors = [ "Stackable GmbH " @@ -4835,8 +4830,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/kube-rs/kube-rs"; - rev = "fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5"; - sha256 = "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n"; + rev = "1320643f8ce7f8189e03496ff1329d678d76224c"; + sha256 = "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf"; }; authors = [ "clux " @@ -4913,8 +4908,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/kube-rs/kube-rs"; - rev = "fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5"; - sha256 = "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n"; + rev = "1320643f8ce7f8189e03496ff1329d678d76224c"; + sha256 = "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf"; }; libName = "kube_client"; authors = [ @@ -5146,8 +5141,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/kube-rs/kube-rs"; - rev = "fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5"; - sha256 = "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n"; + rev = "1320643f8ce7f8189e03496ff1329d678d76224c"; + sha256 = "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf"; }; libName = "kube_core"; authors = [ @@ -5233,8 +5228,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/kube-rs/kube-rs"; - rev = "fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5"; - sha256 = "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n"; + rev = "1320643f8ce7f8189e03496ff1329d678d76224c"; + sha256 = "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf"; }; procMacro = true; libName = "kube_derive"; @@ -5287,8 +5282,8 @@ rec { workspace_member = null; src = pkgs.fetchgit { url = "https://github.com/kube-rs/kube-rs"; - rev = "fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5"; - sha256 = "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n"; + rev = "1320643f8ce7f8189e03496ff1329d678d76224c"; + sha256 = "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf"; }; libName = "kube_runtime"; authors = [ @@ -9289,12 +9284,7 @@ rec { crateName = "stackable-certs"; version = "0.4.0"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-certs; }; libName = "stackable_certs"; authors = [ "Stackable GmbH " @@ -9475,12 +9465,7 @@ rec { crateName = "stackable-operator"; version = "0.106.2"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-operator; }; libName = "stackable_operator"; authors = [ "Stackable GmbH " @@ -9648,12 +9633,7 @@ rec { crateName = "stackable-operator-derive"; version = "0.3.1"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-operator-derive; }; procMacro = true; libName = "stackable_operator_derive"; authors = [ @@ -9683,12 +9663,7 @@ rec { crateName = "stackable-shared"; version = "0.1.0"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-shared; }; libName = "stackable_shared"; authors = [ "Stackable GmbH " @@ -9764,12 +9739,7 @@ rec { crateName = "stackable-telemetry"; version = "0.6.1"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-telemetry; }; libName = "stackable_telemetry"; authors = [ "Stackable GmbH " @@ -9874,12 +9844,7 @@ rec { crateName = "stackable-versioned"; version = "0.8.3"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-versioned; }; libName = "stackable_versioned"; authors = [ "Stackable GmbH " @@ -9918,12 +9883,7 @@ rec { crateName = "stackable-versioned-macros"; version = "0.8.3"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-versioned-macros; }; procMacro = true; libName = "stackable_versioned_macros"; authors = [ @@ -9986,12 +9946,7 @@ rec { crateName = "stackable-webhook"; version = "0.9.0"; edition = "2024"; - workspace_member = null; - src = pkgs.fetchgit { - url = "https://github.com/stackabletech/operator-rs.git"; - rev = "f9b117c8c08557e9774f33145bb009fb74cb2437"; - sha256 = "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c"; - }; + src = lib.cleanSourceWith { filter = sourceFilter; src = ../operator-rs/crates/stackable-webhook; }; libName = "stackable_webhook"; authors = [ "Stackable GmbH " diff --git a/Cargo.toml b/Cargo.toml index 06d5e7c..73ea147 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,5 +27,5 @@ tokio = { version = "1.40", features = ["full"] } tracing = "0.1" [patch."https://github.com/stackabletech/operator-rs.git"] -# stackable-operator = { path = "../operator-rs/crates/stackable-operator" } +stackable-operator = { path = "../operator-rs/crates/stackable-operator" } # stackable-operator = { git = "https://github.com/stackabletech//operator-rs.git", branch = "main" } diff --git a/crate-hashes.json b/crate-hashes.json index b41e87f..223d5c4 100644 --- a/crate-hashes.json +++ b/crate-hashes.json @@ -1,17 +1,8 @@ { - "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#kube-client@3.0.1": "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n", - "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#kube-core@3.0.1": "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n", - "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#kube-derive@3.0.1": "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n", - "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#kube-runtime@3.0.1": "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n", - "git+https://github.com/kube-rs/kube-rs?rev=fe69cc486ff8e62a7da61d64ec3ebbd9e64c43b5#kube@3.0.1": "1irm4g79crlxjm3iqrgvx0f6wxdcj394ky84q89pk9i36y2mlw3n", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#k8s-version@0.1.3": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-certs@0.4.0": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-operator-derive@0.3.1": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-operator@0.106.2": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-shared@0.1.0": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-telemetry@0.6.1": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-versioned-macros@0.8.3": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-versioned@0.8.3": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", - "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.106.2#stackable-webhook@0.9.0": "1yg7hbpgclp1zvfnhi4qkrwbgsa19v86plh77vqvwxzdxxxvxr4c", + "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#kube-client@3.0.1": "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf", + "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#kube-core@3.0.1": "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf", + "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#kube-derive@3.0.1": "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf", + "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#kube-runtime@3.0.1": "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf", + "git+https://github.com/kube-rs/kube-rs?rev=1320643f8ce7f8189e03496ff1329d678d76224c#kube@3.0.1": "1h1d3rjnfmjk2kyd4iafr84a4kyjkzzns5f36w6w60y8ylr8fqmf", "git+https://github.com/stackabletech/product-config.git?tag=0.8.0#product-config@0.8.0": "1dz70kapm2wdqcr7ndyjji0lhsl98bsq95gnb2lw487wf6yr7987" } \ No newline at end of file diff --git a/deploy/helm/commons-operator/templates/roles.yaml b/deploy/helm/commons-operator/templates/roles.yaml index 31d541f..fb029c2 100644 --- a/deploy/helm/commons-operator/templates/roles.yaml +++ b/deploy/helm/commons-operator/templates/roles.yaml @@ -46,6 +46,16 @@ rules: - pods/eviction verbs: - create + # Required by the scaler admission webhook to read the live StackableScaler status + # (Kubernetes strips .status from oldObject for CRDs with a status subresource). + - apiGroups: + - autoscaling.stackable.tech + resources: + - stackablescalers + verbs: + - get + - list + - watch # Required to maintain MutatingWebhookConfigurations. The operator needs to do this, as it needs # to enter e.g. it's generated certificate in the webhooks. - apiGroups: [admissionregistration.k8s.io] diff --git a/extra/crds.yaml b/extra/crds.yaml index d7dc305..6dc2028 100644 --- a/extra/crds.yaml +++ b/extra/crds.yaml @@ -709,3 +709,124 @@ spec: served: true storage: true subresources: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: stackablescalers.autoscaling.stackable.tech +spec: + group: autoscaling.stackable.tech + names: + categories: [] + kind: StackableScaler + plural: stackablescalers + shortNames: [] + singular: stackablescaler + scope: Namespaced + versions: + - additionalPrinterColumns: [] + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for StackableScalerSpec via `CustomResource` + properties: + spec: + description: |- + A StackableScaler exposes a /scale subresource so that a Kubernetes + HorizontalPodAutoscaler can target it instead of a StatefulSet directly. + properties: + clusterRef: + description: Reference to the Stackable cluster resource this scaler manages. + properties: + kind: + type: string + name: + type: string + required: + - kind + - name + type: object + replicas: + description: |- + Desired replica count. Written by the HPA via the /scale subresource. + Only takes effect when the referenced roleGroup has `replicas: 0`. + format: int32 + type: integer + role: + description: The role within the cluster (e.g. `nodes`). + type: string + roleGroup: + description: The role group within the role (e.g. `default`). + type: string + required: + - clusterRef + - replicas + - role + - roleGroup + type: object + status: + description: Status of a StackableScaler. + nullable: true + properties: + currentState: + description: The current state of the scaler, including when it last changed. + nullable: true + properties: + lastTransitionTime: + description: Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers. + format: date-time + type: string + stage: + properties: + details: + properties: + failedAt: + description: Which stage of a scaling operation failed. + enum: + - preScaling + - scaling + - postScaling + type: string + reason: + type: string + type: object + stage: + enum: + - idle + - preScaling + - scaling + - postScaling + - failed + type: string + required: + - stage + type: object + required: + - lastTransitionTime + - stage + type: object + desiredReplicas: + format: int32 + nullable: true + type: integer + replicas: + format: int32 + type: integer + selector: + nullable: true + type: string + required: + - replicas + type: object + required: + - spec + title: StackableScaler + type: object + served: true + storage: true + subresources: + scale: + labelSelectorPath: .status.selector + specReplicasPath: .spec.replicas + statusReplicasPath: .status.replicas + status: {} diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index fc33081..9bd67a6 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -12,8 +12,10 @@ use stackable_operator::{ crd::{ authentication::core::{AuthenticationClass, AuthenticationClassVersion}, s3::{S3Bucket, S3BucketVersion, S3Connection, S3ConnectionVersion}, + scaler::StackableScaler, }, eos::EndOfSupportChecker, + kube::CustomResourceExt as _, shared::yaml::SerializeOptions, telemetry::Tracing, utils::signal::SignalWatcher, @@ -38,6 +40,7 @@ struct Opts { cmd: Command, } +/// Command-line arguments for the `run` subcommand of the commons-operator. #[derive(Debug, PartialEq, Eq, Parser)] pub struct CommonsOperatorRunArguments { #[command(flatten)] @@ -49,6 +52,14 @@ pub struct CommonsOperatorRunArguments { /// created StatefulSets. It can be turned off in case you can accept an unneeded Pod restart. #[arg(long, env)] pub disable_restarter_mutating_webhook: bool, + + /// Don't start the StackableScaler admission webhook. + /// + /// The admission webhook injects the `stackable.tech/cluster-kind` label on + /// StackableScaler resources and rejects `spec.replicas` changes while a scaling + /// operation is in progress. + #[arg(long, env)] + pub disable_scaler_admission_webhook: bool, } #[tokio::main] @@ -62,6 +73,8 @@ async fn main() -> anyhow::Result<()> { .print_yaml_schema(built_info::PKG_VERSION, SerializeOptions::default())?; S3Bucket::merged_crd(S3BucketVersion::V1Alpha1)? .print_yaml_schema(built_info::PKG_VERSION, SerializeOptions::default())?; + StackableScaler::crd() + .print_yaml_schema(built_info::PKG_VERSION, SerializeOptions::default())?; } Command::Run(CommonsOperatorRunArguments { common: @@ -73,6 +86,7 @@ async fn main() -> anyhow::Result<()> { common, }, disable_restarter_mutating_webhook, + disable_scaler_admission_webhook, }) => { // NOTE (@NickLarsenNZ): Before stackable-telemetry was used: // - The console log level was set by `COMMONS_OPERATOR_LOG`, and is now `CONSOLE_LOG` (when using Tracing::pre_configured). @@ -124,6 +138,7 @@ async fn main() -> anyhow::Result<()> { ctx, &operator_environment, disable_restarter_mutating_webhook, + disable_scaler_admission_webhook, maintenance.disable_crd_maintenance, client.as_kube_client(), ) diff --git a/rust/operator-binary/src/webhooks/mod.rs b/rust/operator-binary/src/webhooks/mod.rs index 41922d5..06de66b 100644 --- a/rust/operator-binary/src/webhooks/mod.rs +++ b/rust/operator-binary/src/webhooks/mod.rs @@ -1,3 +1,8 @@ +//! Webhook server assembly for the commons-operator. +//! +//! Wires together all mutating and conversion webhooks into a single [`WebhookServer`]. +//! Each webhook can be independently disabled via CLI flags. + use std::sync::Arc; use snafu::{ResultExt, Snafu}; @@ -11,17 +16,32 @@ use crate::restart_controller::statefulset::Ctx; mod conversion; mod restarter_mutate_sts; +mod scaler_admission; +/// Errors from webhook server setup. #[derive(Debug, Snafu)] pub enum Error { + /// The [`WebhookServer`] could not be created (e.g. TLS certificate loading failed). #[snafu(display("failed to create webhook server"))] CreateWebhookServer { source: WebhookServerError }, } +/// Assemble and return a [`WebhookServer`] with all commons-operator webhooks. +/// +/// # Parameters +/// +/// - `ctx`: Shared controller context used by the StatefulSet restarter webhook. +/// - `operator_environment`: Namespace and service name for socket address configuration. +/// - `disable_restarter_mutating_webhook`: Skip the StatefulSet restarter webhook. +/// - `disable_scaler_admission_webhook`: Skip the +/// [`StackableScaler`](stackable_operator::crd::scaler::StackableScaler) admission webhook. +/// - `disable_crd_maintenance`: Skip CRD maintenance in the conversion webhook. +/// - `client`: Kubernetes client passed to webhooks needing API access. pub async fn create_webhook_server( ctx: Arc, operator_environment: &OperatorEnvironmentOptions, disable_restarter_mutating_webhook: bool, + disable_scaler_admission_webhook: bool, disable_crd_maintenance: bool, client: Client, ) -> Result { @@ -35,6 +55,12 @@ pub async fn create_webhook_server( webhooks.push(webhook); } + if let Some(webhook) = + scaler_admission::create_webhook(disable_scaler_admission_webhook, client.clone()) + { + webhooks.push(webhook); + } + // TODO (@Techassi): The conversion webhook should also allow to be disabled, rework the // granularity of these options. webhooks.push(conversion::create_webhook(disable_crd_maintenance, client)); diff --git a/rust/operator-binary/src/webhooks/scaler_admission.rs b/rust/operator-binary/src/webhooks/scaler_admission.rs new file mode 100644 index 0000000..9db06e2 --- /dev/null +++ b/rust/operator-binary/src/webhooks/scaler_admission.rs @@ -0,0 +1,273 @@ +//! Admission webhook for [`StackableScaler`] resources. +//! +//! This webhook serves two purposes: +//! +//! ## Validation +//! On `UPDATE` operations, rejects changes to `spec.replicas` while a scaling operation +//! is in progress (stage is not `Idle` or `Failed`). Because Kubernetes strips `.status` +//! from `oldObject` for CRDs with a status subresource, the webhook fetches the live +//! object to inspect the current stage. +//! +//! ## Mutation +//! Injects the `stackable.tech/cluster-kind` label from `spec.clusterRef.kind` so that +//! cluster-scoped label selectors work without requiring clients to set the label manually. + +use std::sync::Arc; + +use json_patch::{AddOperation, Patch, PatchOperation, jsonptr::PointerBuf}; +use stackable_operator::{ + builder::meta::ObjectMetaBuilder, + crd::scaler::{ScalerStage, StackableScaler}, + k8s_openapi::api::admissionregistration::v1::{ + MutatingWebhook, MutatingWebhookConfiguration, RuleWithOperations, WebhookClientConfig, + }, + kube::{ + Api, Client, + core::admission::{AdmissionRequest, AdmissionResponse, Operation}, + }, + kvp::Label, + webhook::webhooks::{MutatingWebhookOptions, Webhook}, +}; +use tracing::{debug, info, warn}; + +use crate::{FIELD_MANAGER, OPERATOR_NAME}; + +/// Create the [`StackableScaler`] admission webhook, or `None` if disabled. +/// +/// # Parameters +/// +/// - `disable`: When `true`, the webhook is not started and `None` is returned. +/// Corresponds to the `--disable-scaler-admission-webhook` CLI flag. +/// - `client`: Kubernetes client used by the handler to fetch live [`StackableScaler`] +/// objects during admission review. +pub fn create_webhook(disable: bool, client: Client) -> Option> { + (!disable).then(|| { + let options = MutatingWebhookOptions { + // When `disable` is true the outer `(!disable).then(...)` returns None, + // so inside this closure `disable` is always false. + disable_mwc_maintenance: false, + field_manager: FIELD_MANAGER.to_owned(), + }; + + Box::new(stackable_operator::webhook::webhooks::MutatingWebhook::new( + get_scaler_admission_webhook_configuration(), + scaler_admission_handler, + Arc::new(client.clone()), + client, + options, + )) + }) +} + +/// Build the [`MutatingWebhookConfiguration`] for the scaler admission webhook. +/// +/// Covers `CREATE` and `UPDATE` operations on `stackablescalers.autoscaling.stackable.tech`. +/// `failure_policy` is `Fail` because an unenforced replicas change during active scaling +/// would corrupt the scaler state machine. +fn get_scaler_admission_webhook_configuration() -> MutatingWebhookConfiguration { + let webhook_name = "scaler-admission.stackable.tech"; + let metadata = ObjectMetaBuilder::new() + .name(webhook_name) + .with_label(Label::stackable_vendor()) + .with_label( + Label::managed_by(OPERATOR_NAME, webhook_name).expect("static label is always valid"), + ) + .build(); + + MutatingWebhookConfiguration { + metadata, + webhooks: Some(vec![MutatingWebhook { + name: webhook_name.to_owned(), + admission_review_versions: vec!["v1".to_owned()], + rules: Some(vec![RuleWithOperations { + api_groups: Some(vec!["autoscaling.stackable.tech".to_owned()]), + api_versions: Some(vec!["v1alpha1".to_owned()]), + resources: Some(vec!["stackablescalers".to_owned()]), + operations: Some(vec!["CREATE".to_owned(), "UPDATE".to_owned()]), + scope: Some("Namespaced".to_owned()), + }]), + client_config: WebhookClientConfig::default(), + // TODO(#3): `Fail` blocks all HPA `/scale` writes when the commons-operator is + // unavailable (rolling update, crash). The restarter webhook uses `Ignore` for + // this reason. Consider switching to `Ignore` or narrowing scope with an + // `object_selector` to limit blast radius. + failure_policy: Some("Fail".to_owned()), + // TODO(#4): The restarter webhook uses `IfNeeded` with a documented rationale. + // Explain why this webhook uses `Never`, or switch to `IfNeeded` for consistency. + reinvocation_policy: Some("Never".to_owned()), + side_effects: "None".to_owned(), + ..Default::default() + }]), + } +} + +/// Handle an admission request for a [`StackableScaler`] resource. +/// +/// On `UPDATE`: if `spec.replicas` changed, fetches the live object (5s timeout) and +/// denies the change unless the stage is `Idle`, `Failed`, or absent. +/// +/// On all operations: injects `stackable.tech/cluster-kind` label after validating +/// the value is a legal Kubernetes label. +/// +/// # Parameters +/// +/// - `client`: Used to fetch the live object when validating an `UPDATE`. +/// - `request`: The incoming admission review request. +async fn scaler_admission_handler( + client: Arc, + request: AdmissionRequest, +) -> AdmissionResponse { + let Some(scaler) = &request.object else { + warn!( + operation = ?request.operation, + "Denying admission: StackableScaler object missing from request" + ); + return AdmissionResponse::from(&request).deny("object (of type StackableScaler) missing"); + }; + + let Some(scaler_name) = scaler.metadata.name.as_deref() else { + warn!( + operation = ?request.operation, + "Denying admission: StackableScaler is missing metadata.name" + ); + return AdmissionResponse::from(&request).deny("StackableScaler is missing metadata.name"); + }; + let Some(scaler_namespace) = scaler.metadata.namespace.as_deref() else { + warn!( + scaler = scaler_name, + operation = ?request.operation, + "Denying admission: StackableScaler is missing metadata.namespace" + ); + return AdmissionResponse::from(&request) + .deny("StackableScaler is missing metadata.namespace"); + }; + + debug!( + scaler = scaler_name, + namespace = scaler_namespace, + operation = ?request.operation, + "Processing scaler admission request" + ); + + // --- Validation --- + // On UPDATE: reject spec.replicas changes during active scaling. + // Kubernetes strips .status from oldObject in admission requests for CRDs + // with a status subresource, so we fetch the live object to check the stage. + if request.operation == Operation::Update { + if let Some(old) = &request.old_object { + if scaler.spec.replicas != old.spec.replicas { + let api: Api = + Api::namespaced((*client).clone(), scaler_namespace); + + match tokio::time::timeout(std::time::Duration::from_secs(5), api.get(scaler_name)) + .await + { + Ok(Ok(live)) => { + let stage = live + .status + .as_ref() + .and_then(|s| s.current_state.as_ref()) + .map(|state| &state.stage); + + let is_safe = matches!( + stage, + None | Some(ScalerStage::Idle) | Some(ScalerStage::Failed { .. }) + ); + + if !is_safe { + let stage_str = stage + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + info!( + scaler = scaler_name, + namespace = scaler_namespace, + stage = %stage_str, + old_replicas = old.spec.replicas, + new_replicas = scaler.spec.replicas, + "Denying spec.replicas change while scaling is in progress" + ); + return AdmissionResponse::from(&request).deny(format!( + "Cannot update spec.replicas while scaling is in progress (stage: {stage_str})" + )); + } + } + Ok(Err(e)) => { + warn!( + scaler = scaler_name, + namespace = scaler_namespace, + error = %e, + "Denying admission: failed to fetch live StackableScaler to verify scaling state" + ); + return AdmissionResponse::from(&request) + .deny(format!("Cannot verify scaling state: {e}")); + } + Err(_) => { + warn!( + scaler = scaler_name, + namespace = scaler_namespace, + "Denying admission: timed out fetching live StackableScaler to verify scaling state" + ); + return AdmissionResponse::from(&request).deny( + "Cannot verify scaling state: timed out fetching live StackableScaler", + ); + } + } + } + } + } + + // --- Mutation --- + // Inject cluster-kind label from spec.clusterRef.kind + let cluster_kind = &scaler.spec.cluster_ref.kind; + + // Validate the label value before injecting + if let Err(e) = Label::try_from(("stackable.tech/cluster-kind", cluster_kind.as_str())) { + warn!( + scaler = scaler_name, + namespace = scaler_namespace, + cluster_kind = %cluster_kind, + error = %e, + "Denying admission: clusterRef.kind is not a valid Kubernetes label value" + ); + return AdmissionResponse::from(&request).deny(format!( + "clusterRef.kind '{}' is not a valid Kubernetes label value: {e}", + cluster_kind + )); + } + + let mut patches = Vec::new(); + + if scaler.metadata.labels.is_none() { + patches.push(PatchOperation::Add(AddOperation { + path: PointerBuf::parse("/metadata/labels").expect("valid path"), + value: serde_json::Value::Object(serde_json::Map::new()), + })); + } + + patches.push(PatchOperation::Add(AddOperation { + path: PointerBuf::from_tokens(["metadata", "labels", "stackable.tech/cluster-kind"]), + value: serde_json::Value::String(cluster_kind.clone()), + })); + + match AdmissionResponse::from(&request).with_patch(Patch(patches)) { + Ok(response) => { + debug!( + scaler = scaler_name, + namespace = scaler_namespace, + cluster_kind = %cluster_kind, + "Admitted StackableScaler with cluster-kind label mutation" + ); + response + } + Err(err) => { + warn!( + scaler = scaler_name, + namespace = scaler_namespace, + error = %err, + "Denying admission: failed to construct JSON patch" + ); + AdmissionResponse::from(&request) + .deny(format!("failed to add patch to AdmissionResponse: {err:#}")) + } + } +} From 89c7466f2601e4828758315fb39b109763223c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Liebau?= Date: Wed, 11 Mar 2026 10:35:42 +0100 Subject: [PATCH 2/5] Use ScalerStage::is_scaling_in_progress() in scaler admission webhook Replace the inline pattern match against individual ScalerStage variants with the new is_scaling_in_progress() method from operator-rs. This removes the ScalerStage import and ensures the webhook stays correct if new stages are added to the state machine. Co-Authored-By: Claude Opus 4.6 --- rust/operator-binary/src/webhooks/scaler_admission.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rust/operator-binary/src/webhooks/scaler_admission.rs b/rust/operator-binary/src/webhooks/scaler_admission.rs index 9db06e2..2f691ac 100644 --- a/rust/operator-binary/src/webhooks/scaler_admission.rs +++ b/rust/operator-binary/src/webhooks/scaler_admission.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use json_patch::{AddOperation, Patch, PatchOperation, jsonptr::PointerBuf}; use stackable_operator::{ builder::meta::ObjectMetaBuilder, - crd::scaler::{ScalerStage, StackableScaler}, + crd::scaler::StackableScaler, k8s_openapi::api::admissionregistration::v1::{ MutatingWebhook, MutatingWebhookConfiguration, RuleWithOperations, WebhookClientConfig, }, @@ -169,10 +169,8 @@ async fn scaler_admission_handler( .and_then(|s| s.current_state.as_ref()) .map(|state| &state.stage); - let is_safe = matches!( - stage, - None | Some(ScalerStage::Idle) | Some(ScalerStage::Failed { .. }) - ); + let is_safe = + !stage.is_some_and(|s| s.is_scaling_in_progress()); if !is_safe { let stage_str = stage From 43604f85124f21f2a51242cc6d2af5af4999f761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Liebau?= Date: Thu, 12 Mar 2026 15:24:06 +0100 Subject: [PATCH 3/5] Added scaling functionality for trino-operator --- rust/operator-binary/src/main.rs | 5 ++--- rust/operator-binary/src/webhooks/conversion.rs | 5 +++++ rust/operator-binary/src/webhooks/scaler_admission.rs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index 9bd67a6..7f4dc66 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -12,10 +12,9 @@ use stackable_operator::{ crd::{ authentication::core::{AuthenticationClass, AuthenticationClassVersion}, s3::{S3Bucket, S3BucketVersion, S3Connection, S3ConnectionVersion}, - scaler::StackableScaler, + scaler::{StackableScaler, StackableScalerVersion}, }, eos::EndOfSupportChecker, - kube::CustomResourceExt as _, shared::yaml::SerializeOptions, telemetry::Tracing, utils::signal::SignalWatcher, @@ -73,7 +72,7 @@ async fn main() -> anyhow::Result<()> { .print_yaml_schema(built_info::PKG_VERSION, SerializeOptions::default())?; S3Bucket::merged_crd(S3BucketVersion::V1Alpha1)? .print_yaml_schema(built_info::PKG_VERSION, SerializeOptions::default())?; - StackableScaler::crd() + StackableScaler::merged_crd(StackableScalerVersion::V1Alpha1)? .print_yaml_schema(built_info::PKG_VERSION, SerializeOptions::default())?; } Command::Run(CommonsOperatorRunArguments { diff --git a/rust/operator-binary/src/webhooks/conversion.rs b/rust/operator-binary/src/webhooks/conversion.rs index 7722ab5..968fb4a 100644 --- a/rust/operator-binary/src/webhooks/conversion.rs +++ b/rust/operator-binary/src/webhooks/conversion.rs @@ -2,6 +2,7 @@ use stackable_operator::{ crd::{ authentication::core::{AuthenticationClass, AuthenticationClassVersion}, s3::{S3Bucket, S3BucketVersion, S3Connection, S3ConnectionVersion}, + scaler::{StackableScaler, StackableScalerVersion}, }, kube::Client, webhook::webhooks::{ConversionWebhook, ConversionWebhookOptions, Webhook}, @@ -23,6 +24,10 @@ pub fn create_webhook(disable_crd_maintenance: bool, client: Client) -> Box _, ), + ( + StackableScaler::merged_crd(StackableScalerVersion::V1Alpha1).unwrap(), + StackableScaler::try_convert as fn(_) -> _, + ), ]; let conversion_webhook_options = ConversionWebhookOptions { diff --git a/rust/operator-binary/src/webhooks/scaler_admission.rs b/rust/operator-binary/src/webhooks/scaler_admission.rs index 2f691ac..1d80897 100644 --- a/rust/operator-binary/src/webhooks/scaler_admission.rs +++ b/rust/operator-binary/src/webhooks/scaler_admission.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use json_patch::{AddOperation, Patch, PatchOperation, jsonptr::PointerBuf}; use stackable_operator::{ builder::meta::ObjectMetaBuilder, - crd::scaler::StackableScaler, + crd::scaler::v1alpha1::StackableScaler, k8s_openapi::api::admissionregistration::v1::{ MutatingWebhook, MutatingWebhookConfiguration, RuleWithOperations, WebhookClientConfig, }, From 1009720b1d0a7fc10ed331591707a5f12bd12d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Liebau?= Date: Thu, 19 Mar 2026 19:04:54 +0100 Subject: [PATCH 4/5] refactor: demote scaler webhook to validation-only Remove cluster-kind label injection (no longer needed with .owns()). Narrow to UPDATE operations only. The handler returns allow/deny without patches, making it functionally a validating webhook. The MutatingWebhook framework is retained because stackable-webhook does not yet provide a ValidatingWebhook type. Co-Authored-By: Claude Opus 4.6 --- .../src/webhooks/scaler_admission.rs | 96 +++---------------- 1 file changed, 14 insertions(+), 82 deletions(-) diff --git a/rust/operator-binary/src/webhooks/scaler_admission.rs b/rust/operator-binary/src/webhooks/scaler_admission.rs index 1d80897..5878bf9 100644 --- a/rust/operator-binary/src/webhooks/scaler_admission.rs +++ b/rust/operator-binary/src/webhooks/scaler_admission.rs @@ -1,20 +1,16 @@ //! Admission webhook for [`StackableScaler`] resources. //! -//! This webhook serves two purposes: -//! -//! ## Validation //! On `UPDATE` operations, rejects changes to `spec.replicas` while a scaling operation //! is in progress (stage is not `Idle` or `Failed`). Because Kubernetes strips `.status` //! from `oldObject` for CRDs with a status subresource, the webhook fetches the live //! object to inspect the current stage. //! -//! ## Mutation -//! Injects the `stackable.tech/cluster-kind` label from `spec.clusterRef.kind` so that -//! cluster-scoped label selectors work without requiring clients to set the label manually. +//! This webhook performs validation only — no mutations. It uses the `MutatingWebhook` +//! framework because `stackable-webhook` does not yet provide a `ValidatingWebhook` type. +//! The handler never returns patches, so the effect is identical to a validating webhook. use std::sync::Arc; -use json_patch::{AddOperation, Patch, PatchOperation, jsonptr::PointerBuf}; use stackable_operator::{ builder::meta::ObjectMetaBuilder, crd::scaler::v1alpha1::StackableScaler, @@ -43,8 +39,6 @@ use crate::{FIELD_MANAGER, OPERATOR_NAME}; pub fn create_webhook(disable: bool, client: Client) -> Option> { (!disable).then(|| { let options = MutatingWebhookOptions { - // When `disable` is true the outer `(!disable).then(...)` returns None, - // so inside this closure `disable` is always false. disable_mwc_maintenance: false, field_manager: FIELD_MANAGER.to_owned(), }; @@ -59,9 +53,9 @@ pub fn create_webhook(disable: bool, client: Client) -> Option }) } -/// Build the [`MutatingWebhookConfiguration`] for the scaler admission webhook. +/// Build the webhook configuration for the scaler admission webhook. /// -/// Covers `CREATE` and `UPDATE` operations on `stackablescalers.autoscaling.stackable.tech`. +/// Covers `UPDATE` operations only on `stackablescalers.autoscaling.stackable.tech`. /// `failure_policy` is `Fail` because an unenforced replicas change during active scaling /// would corrupt the scaler state machine. fn get_scaler_admission_webhook_configuration() -> MutatingWebhookConfiguration { @@ -83,17 +77,11 @@ fn get_scaler_admission_webhook_configuration() -> MutatingWebhookConfiguration api_groups: Some(vec!["autoscaling.stackable.tech".to_owned()]), api_versions: Some(vec!["v1alpha1".to_owned()]), resources: Some(vec!["stackablescalers".to_owned()]), - operations: Some(vec!["CREATE".to_owned(), "UPDATE".to_owned()]), + operations: Some(vec!["UPDATE".to_owned()]), scope: Some("Namespaced".to_owned()), }]), client_config: WebhookClientConfig::default(), - // TODO(#3): `Fail` blocks all HPA `/scale` writes when the commons-operator is - // unavailable (rolling update, crash). The restarter webhook uses `Ignore` for - // this reason. Consider switching to `Ignore` or narrowing scope with an - // `object_selector` to limit blast radius. failure_policy: Some("Fail".to_owned()), - // TODO(#4): The restarter webhook uses `IfNeeded` with a documented rationale. - // Explain why this webhook uses `Never`, or switch to `IfNeeded` for consistency. reinvocation_policy: Some("Never".to_owned()), side_effects: "None".to_owned(), ..Default::default() @@ -106,13 +94,7 @@ fn get_scaler_admission_webhook_configuration() -> MutatingWebhookConfiguration /// On `UPDATE`: if `spec.replicas` changed, fetches the live object (5s timeout) and /// denies the change unless the stage is `Idle`, `Failed`, or absent. /// -/// On all operations: injects `stackable.tech/cluster-kind` label after validating -/// the value is a legal Kubernetes label. -/// -/// # Parameters -/// -/// - `client`: Used to fetch the live object when validating an `UPDATE`. -/// - `request`: The incoming admission review request. +/// All other operations are allowed without inspection. async fn scaler_admission_handler( client: Arc, request: AdmissionRequest, @@ -149,7 +131,6 @@ async fn scaler_admission_handler( "Processing scaler admission request" ); - // --- Validation --- // On UPDATE: reject spec.replicas changes during active scaling. // Kubernetes strips .status from oldObject in admission requests for CRDs // with a status subresource, so we fetch the live object to check the stage. @@ -169,8 +150,7 @@ async fn scaler_admission_handler( .and_then(|s| s.current_state.as_ref()) .map(|state| &state.stage); - let is_safe = - !stage.is_some_and(|s| s.is_scaling_in_progress()); + let is_safe = !stage.is_some_and(|s| s.is_scaling_in_progress()); if !is_safe { let stage_str = stage @@ -214,58 +194,10 @@ async fn scaler_admission_handler( } } - // --- Mutation --- - // Inject cluster-kind label from spec.clusterRef.kind - let cluster_kind = &scaler.spec.cluster_ref.kind; - - // Validate the label value before injecting - if let Err(e) = Label::try_from(("stackable.tech/cluster-kind", cluster_kind.as_str())) { - warn!( - scaler = scaler_name, - namespace = scaler_namespace, - cluster_kind = %cluster_kind, - error = %e, - "Denying admission: clusterRef.kind is not a valid Kubernetes label value" - ); - return AdmissionResponse::from(&request).deny(format!( - "clusterRef.kind '{}' is not a valid Kubernetes label value: {e}", - cluster_kind - )); - } - - let mut patches = Vec::new(); - - if scaler.metadata.labels.is_none() { - patches.push(PatchOperation::Add(AddOperation { - path: PointerBuf::parse("/metadata/labels").expect("valid path"), - value: serde_json::Value::Object(serde_json::Map::new()), - })); - } - - patches.push(PatchOperation::Add(AddOperation { - path: PointerBuf::from_tokens(["metadata", "labels", "stackable.tech/cluster-kind"]), - value: serde_json::Value::String(cluster_kind.clone()), - })); - - match AdmissionResponse::from(&request).with_patch(Patch(patches)) { - Ok(response) => { - debug!( - scaler = scaler_name, - namespace = scaler_namespace, - cluster_kind = %cluster_kind, - "Admitted StackableScaler with cluster-kind label mutation" - ); - response - } - Err(err) => { - warn!( - scaler = scaler_name, - namespace = scaler_namespace, - error = %err, - "Denying admission: failed to construct JSON patch" - ); - AdmissionResponse::from(&request) - .deny(format!("failed to add patch to AdmissionResponse: {err:#}")) - } - } + debug!( + scaler = scaler_name, + namespace = scaler_namespace, + "Admitted StackableScaler" + ); + AdmissionResponse::from(&request) } From 6c9ea45248e3fdef2b75b1f1763d71284170835c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Liebau?= Date: Thu, 19 Mar 2026 23:29:29 +0100 Subject: [PATCH 5/5] refactor(webhook): simplify admission handler and update description Part of the ReplicasConfig rewrite: the label-injection webhook is no longer needed (replaced by owner references and .owns()), so the webhook description and code are updated to reflect validation-only scope. - Update CLI help text: remove label-injection reference, describe webhook as rejecting spec.replicas changes during active scaling. - Simplify deny logic in scaler_admission_handler: use `if let` with `filter()` instead of `is_some_and()` + separate `stage_str` variable, removing the unnecessary "unknown" fallback. Co-Authored-By: Claude Opus 4.6 --- rust/operator-binary/src/main.rs | 5 ++--- .../src/webhooks/scaler_admission.rs | 13 ++++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index 7f4dc66..a36ff17 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -54,9 +54,8 @@ pub struct CommonsOperatorRunArguments { /// Don't start the StackableScaler admission webhook. /// - /// The admission webhook injects the `stackable.tech/cluster-kind` label on - /// StackableScaler resources and rejects `spec.replicas` changes while a scaling - /// operation is in progress. + /// The admission webhook rejects `spec.replicas` changes on StackableScaler + /// resources while a scaling operation is in progress. #[arg(long, env)] pub disable_scaler_admission_webhook: bool, } diff --git a/rust/operator-binary/src/webhooks/scaler_admission.rs b/rust/operator-binary/src/webhooks/scaler_admission.rs index 5878bf9..fadac8f 100644 --- a/rust/operator-binary/src/webhooks/scaler_admission.rs +++ b/rust/operator-binary/src/webhooks/scaler_admission.rs @@ -94,7 +94,7 @@ fn get_scaler_admission_webhook_configuration() -> MutatingWebhookConfiguration /// On `UPDATE`: if `spec.replicas` changed, fetches the live object (5s timeout) and /// denies the change unless the stage is `Idle`, `Failed`, or absent. /// -/// All other operations are allowed without inspection. +/// Updates where `spec.replicas` did not change are allowed without further inspection. async fn scaler_admission_handler( client: Arc, request: AdmissionRequest, @@ -150,22 +150,17 @@ async fn scaler_admission_handler( .and_then(|s| s.current_state.as_ref()) .map(|state| &state.stage); - let is_safe = !stage.is_some_and(|s| s.is_scaling_in_progress()); - - if !is_safe { - let stage_str = stage - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".to_string()); + if let Some(active_stage) = stage.filter(|s| s.is_scaling_in_progress()) { info!( scaler = scaler_name, namespace = scaler_namespace, - stage = %stage_str, + stage = %active_stage, old_replicas = old.spec.replicas, new_replicas = scaler.spec.replicas, "Denying spec.replicas change while scaling is in progress" ); return AdmissionResponse::from(&request).deny(format!( - "Cannot update spec.replicas while scaling is in progress (stage: {stage_str})" + "Cannot update spec.replicas while scaling is in progress (stage: {active_stage})" )); } }