From 92d7cbf49436f0461872e744188553d74331cc7d Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Sun, 12 Jul 2026 00:49:17 +0700 Subject: [PATCH 1/5] K8s: KEDA external scaler Signed-off-by: Viet Nguyen Duc --- .github/workflows/helm-chart-test.yml | 6 +- .keda-external-scaler/.dockerignore | 7 + .keda-external-scaler/.gitignore | 1 + .keda-external-scaler/Dockerfile | 30 + .keda-external-scaler/Makefile | 61 + .keda-external-scaler/PLAN.md | 79 + .keda-external-scaler/README.md | 114 + .keda-external-scaler/SPEC.md | 262 ++ .keda-external-scaler/TASKS.md | 81 + .keda-external-scaler/cmd/scaler/main.go | 157 + .keda-external-scaler/deploy/deployment.yaml | 82 + .../deploy/scaledjob-example.yaml | 49 + .../deploy/scaledobject-example.yaml | 24 + .../externalscaler/externalscaler.pb.go | 539 +++ .../externalscaler/externalscaler_grpc.pb.go | 239 ++ .keda-external-scaler/go.mod | 16 + .keda-external-scaler/go.sum | 34 + .../internal/gridscaler/grid_client.go | 101 + .../internal/gridscaler/grid_client_test.go | 101 + .../internal/gridscaler/logic.go | 270 ++ .../internal/gridscaler/logic_test.go | 3168 +++++++++++++++++ .../internal/gridscaler/metadata.go | 148 + .../internal/gridscaler/metadata_test.go | 342 ++ .../internal/gridscaler/platform.go | 112 + .../internal/gridscaler/server.go | 129 + .../internal/gridscaler/server_test.go | 220 ++ .../internal/gridscaler/types.go | 62 + .../proto/externalscaler.proto | 51 + .keda-external-scaler/tasks/plan.md | 10 + .keda-external-scaler/tasks/todo.md | 18 + Makefile | 28 +- charts/selenium-grid/CONFIGURATION.md | 18 + charts/selenium-grid/README.md | 46 + charts/selenium-grid/templates/_helpers.tpl | 8 +- .../selenium-grid/templates/_nameHelpers.tpl | 14 + .../templates/external-scaler.yaml | 116 + .../selenium-grid/templates/trigger-auth.yaml | 2 +- charts/selenium-grid/values.yaml | 39 + tests/charts/make/chart_test.sh | 7 + 39 files changed, 6783 insertions(+), 8 deletions(-) create mode 100644 .keda-external-scaler/.dockerignore create mode 100644 .keda-external-scaler/.gitignore create mode 100644 .keda-external-scaler/Dockerfile create mode 100644 .keda-external-scaler/Makefile create mode 100644 .keda-external-scaler/PLAN.md create mode 100644 .keda-external-scaler/README.md create mode 100644 .keda-external-scaler/SPEC.md create mode 100644 .keda-external-scaler/TASKS.md create mode 100644 .keda-external-scaler/cmd/scaler/main.go create mode 100644 .keda-external-scaler/deploy/deployment.yaml create mode 100644 .keda-external-scaler/deploy/scaledjob-example.yaml create mode 100644 .keda-external-scaler/deploy/scaledobject-example.yaml create mode 100644 .keda-external-scaler/externalscaler/externalscaler.pb.go create mode 100644 .keda-external-scaler/externalscaler/externalscaler_grpc.pb.go create mode 100644 .keda-external-scaler/go.mod create mode 100644 .keda-external-scaler/go.sum create mode 100644 .keda-external-scaler/internal/gridscaler/grid_client.go create mode 100644 .keda-external-scaler/internal/gridscaler/grid_client_test.go create mode 100644 .keda-external-scaler/internal/gridscaler/logic.go create mode 100644 .keda-external-scaler/internal/gridscaler/logic_test.go create mode 100644 .keda-external-scaler/internal/gridscaler/metadata.go create mode 100644 .keda-external-scaler/internal/gridscaler/metadata_test.go create mode 100644 .keda-external-scaler/internal/gridscaler/platform.go create mode 100644 .keda-external-scaler/internal/gridscaler/server.go create mode 100644 .keda-external-scaler/internal/gridscaler/server_test.go create mode 100644 .keda-external-scaler/internal/gridscaler/types.go create mode 100644 .keda-external-scaler/proto/externalscaler.proto create mode 100644 .keda-external-scaler/tasks/plan.md create mode 100644 .keda-external-scaler/tasks/todo.md create mode 100644 charts/selenium-grid/templates/external-scaler.yaml diff --git a/.github/workflows/helm-chart-test.yml b/.github/workflows/helm-chart-test.yml index efbcaa56fd..4a2ff95d4a 100644 --- a/.github/workflows/helm-chart-test.yml +++ b/.github/workflows/helm-chart-test.yml @@ -128,11 +128,13 @@ jobs: service-mesh: false os: ubuntu-22.04 check-records-output: false - test-strategy: playwright_connect_grid_hub + test-strategy: job + external-scaler: true env: CLUSTER: ${{ matrix.cluster }} KUBERNETES_VERSION: ${{ matrix.k8s-version }} - ARTIFACT_NAME: "${{ matrix.k8s-version }}-${{ matrix.test-strategy }}" + ARTIFACT_NAME: "${{ matrix.k8s-version }}-${{ matrix.test-strategy }}${{ matrix.external-scaler && '-external-scaler' || '' }}" + TEST_EXTERNAL_SCALER: ${{ matrix.external-scaler }} HELM_VERSION: ${{ matrix.helm-version }} DOCKER_VERSION: ${{ matrix.docker-version }} TEST_UPGRADE_CHART: ${{ matrix.test-upgrade }} diff --git a/.keda-external-scaler/.dockerignore b/.keda-external-scaler/.dockerignore new file mode 100644 index 0000000000..275f371f25 --- /dev/null +++ b/.keda-external-scaler/.dockerignore @@ -0,0 +1,7 @@ +bin/ +deploy/ +tasks/ +*.md +.git +.gitignore +Makefile diff --git a/.keda-external-scaler/.gitignore b/.keda-external-scaler/.gitignore new file mode 100644 index 0000000000..ae3c172604 --- /dev/null +++ b/.keda-external-scaler/.gitignore @@ -0,0 +1 @@ +/bin/ diff --git a/.keda-external-scaler/Dockerfile b/.keda-external-scaler/Dockerfile new file mode 100644 index 0000000000..a90f16298b --- /dev/null +++ b/.keda-external-scaler/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +# Build stage. TARGETOS/TARGETARCH are provided by buildx for multi-arch builds. +FROM --platform=$BUILDPLATFORM golang:1.26 AS build +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache modules separately from source. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags="-s -w" -o /out/selenium-grid-scaler ./cmd/scaler + +# Runtime stage: distroless static, non-root. +FROM gcr.io/distroless/static:nonroot +# Standard Selenium Grid image build args (passed by the root Makefile like other components). +ARG NAMESPACE +ARG VERSION +ARG AUTHORS +LABEL authors=${AUTHORS} +LABEL version=${VERSION} +COPY --from=build /out/selenium-grid-scaler /usr/local/bin/selenium-grid-scaler +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/usr/local/bin/selenium-grid-scaler"] diff --git a/.keda-external-scaler/Makefile b/.keda-external-scaler/Makefile new file mode 100644 index 0000000000..01be6462e3 --- /dev/null +++ b/.keda-external-scaler/Makefile @@ -0,0 +1,61 @@ +# Makefile for the Selenium Grid KEDA external scaler. + +MODULE := github.com/SeleniumHQ/docker-selenium/keda-external-scaler +BIN_DIR := bin +BINARY := $(BIN_DIR)/selenium-grid-scaler +IMAGE ?= selenium/keda-external-scaler +TAG ?= local +PLATFORMS ?= linux/amd64,linux/arm64 + +# Pinned codegen plugin versions. Generated code is committed, so `make proto` +# is only needed when the vendored proto changes. +PROTOC_GEN_GO_VERSION := v1.36.5 +PROTOC_GEN_GO_GRPC_VERSION := v1.5.1 + +.PHONY: all +all: build + +.PHONY: tidy +tidy: + go mod tidy + +.PHONY: build +build: + CGO_ENABLED=0 go build -o $(BINARY) ./cmd/scaler + +.PHONY: test +test: + go test ./... -race -cover + +.PHONY: vet +vet: + go vet ./... + +.PHONY: lint +lint: + golangci-lint run + +.PHONY: proto-tools +proto-tools: + go install google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION) + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GEN_GO_GRPC_VERSION) + +# Regenerate gRPC stubs from the vendored proto. Requires protoc on PATH. +.PHONY: proto +proto: + protoc -I proto \ + --go_out=externalscaler --go_opt=paths=source_relative \ + --go-grpc_out=externalscaler --go-grpc_opt=paths=source_relative \ + proto/externalscaler.proto + +.PHONY: image +image: + docker buildx build --platform $(PLATFORMS) -t $(IMAGE):$(TAG) . + +.PHONY: image-load +image-load: + docker buildx build --load -t $(IMAGE):$(TAG) . + +.PHONY: clean +clean: + rm -rf $(BIN_DIR) diff --git a/.keda-external-scaler/PLAN.md b/.keda-external-scaler/PLAN.md new file mode 100644 index 0000000000..0fe99eb5fc --- /dev/null +++ b/.keda-external-scaler/PLAN.md @@ -0,0 +1,79 @@ +# Plan: Selenium Grid KEDA External Scaler + +Implements [SPEC.md](SPEC.md). Phase 2 of the spec-driven workflow. + +## Components and dependency order + +``` +T1 scaffold (go.mod, vendored proto, generated stubs, Makefile) + ├── T2 ported pure logic (platform.go, logic.go, types.go) + │ └── T3 ported upstream test table ← parity gate #1 + ├── T4 metadata parsing (map → typed config, env fallback) + └── T5 grid client (GraphQL + auth) + └── T6 gRPC server (needs T2–T5) ← parity gate #2 + └── T7 main.go (TLS, health, shutdown) + ├── T8 Dockerfile / image + └── T9 deploy manifests + README +``` + +T2/T4/T5 are independent of each other and can proceed in parallel after T1. +Everything downstream of T6 is sequential. + +## Implementation order rationale + +1. **Scaffold first (T1)** — everything compiles against the generated + `externalscaler` stubs; committing generated code makes builds hermetic + (no protoc needed for `go build`). +2. **Logic port + its tests immediately after (T2, T3)** — this is the only + part where silent divergence from upstream is possible. Locking it behind + the unmodified upstream test table before writing any new code means later + work cannot mask a porting mistake. +3. **Metadata and grid client (T4, T5)** are new code (the built-in scaler got + these for free from KEDA's `keda:` struct tags and `kedautil.CreateHTTPClient`); + they get their own dedicated tests rather than ported ones. +4. **Server (T6)** is thin glue; testing it in-process with a fake Grid gives an + end-to-end parity check without Kubernetes. +5. **Packaging (T7–T9)** last, once behavior is proven. + +## Key design decisions carried into implementation + +- **`FromEnv` key normalization:** KEDA sends resolved values but keeps the + original key, e.g. `scalerMetadata["usernameFromEnv"] = ""` + (see `parseExternalScalerMetadata` in KEDA). The metadata parser must strip a + `FromEnv` suffix and treat the key as its base name. Precedence per key: + `` > `FromEnv` > server env fallback (`SE_*`). +- **Metric naming:** replicate `kedautil.NormalizeString` (lowercase, non + `[a-zA-Z0-9-]` → `-`) locally so `GetMetricSpec` names match the built-in + scheme `selenium-grid[-browser][-version][-platform]`. KEDA adds/strips the + `sX-` index prefix itself; the server never sees it. +- **HTTP client reuse:** cache `*http.Client` keyed by `unsafeSsl` only (timeout + fixed via flag, default 3s like KEDA's `GlobalHTTPTimeout` default); avoids a + new TLS handshake per poll. +- **Error mapping:** metadata problems → `codes.InvalidArgument`; Grid + unreachable/non-200/bad JSON → `codes.Internal`. `StreamIsActive` → + `codes.Unimplemented` (per resolved decision #3). +- **`IsActive` does its own Grid query** (KEDA calls GetMetrics then IsActive + back-to-back; a shared 1-poll-interval cache is a possible later optimization, + not in scope — correctness over cleverness first). + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Upstream test file (~4k lines) imports KEDA internals (`scalersconfig`, logr) | Only the test harness (constructor/setup) is adapted; every `expected*` value stays byte-identical. T3 acceptance forbids touching expectations. | +| `keda:` struct-tag parser subtleties (defaults, enum validation, optional/required) lost in hand-written parser | T4 includes parse tests mirroring the upstream metadata test cases (`parseSeleniumGridScalerMetadata` table) plus new FromEnv/fallback cases. | +| Metric name mismatch breaks HPA continuity during migration | Explicit unit test comparing generated names against known built-in outputs for the chart's standard triggers (chrome/firefox/edge). | +| gRPC/protobuf version drift vs generated code | Pin protoc-gen-go/protoc-gen-go-grpc versions in Makefile; commit generated code. | +| KEDA-side TLS (`enableTLS`, mTLS) untestable without KEDA | Serve standard TLS via flags; verify with an in-test TLS client; document that KEDA-side certs are the operator's existing external-scaler mechanics. | + +## Verification checkpoints + +- **Gate 1 (after T3):** `go test ./internal/gridscaler` green with unmodified + upstream expectations → algorithm parity proven. +- **Gate 2 (after T6):** in-process gRPC + fake Grid fixture test green, + including all four `jobScalingStrategy` conventions and activation threshold → + wire-level parity proven. +- **Gate 3 (after T8):** `make image` builds linux/amd64+arm64; container starts + and serves health check. +- **Final:** manual kind-cluster smoke with `deploy/` manifests (documented in + README; automated e2e is follow-up per spec). diff --git a/.keda-external-scaler/README.md b/.keda-external-scaler/README.md new file mode 100644 index 0000000000..b540279d08 --- /dev/null +++ b/.keda-external-scaler/README.md @@ -0,0 +1,114 @@ +# Selenium Grid KEDA External Scaler + +A standalone [KEDA external scaler](https://keda.sh/docs/latest/concepts/external-scalers/) +for Selenium Grid. It is a gRPC server that translates the Grid's GraphQL state +into the number of browser Nodes KEDA should scale to. + +This is a functional extraction of KEDA's built-in `selenium-grid` scaler +(`kedacore/keda/pkg/scalers/selenium_grid_scaler.go`), so that Selenium's +autoscaling logic can be released on the docker-selenium cadence instead of +KEDA's. The scaling algorithm — capability matching (mirroring Grid's +`DefaultSlotMatcher`), slot reservation, and the per-`jobScalingStrategy` count +conventions — is ported verbatim and covered by KEDA's own upstream test table. + +## How it differs from the built-in scaler + +Behaviour is identical. The only user-visible change is the trigger wiring: + +| | Built-in | External scaler | +|---|---|---| +| Trigger `type` | `selenium-grid` | `external` | +| `scalerAddress` | n/a | required — points at this scaler's Service | +| Grid URL / credentials | via `TriggerAuthentication` authParams | via trigger metadata, `*FromEnv`, or the scaler's env | + +The credential difference is the important one: **KEDA does not forward +`TriggerAuthentication` authParams to external scalers.** The Grid URL and +credentials must therefore reach the scaler another way (see [Authentication](#authentication)). + +## Metadata + +All keys mirror the built-in scaler, plus `scalerAddress` (consumed by KEDA, not +the scaler): + +| Key | Default | Notes | +|---|---|---| +| `scalerAddress` | — | KEDA-side; `host:port` of this scaler's Service | +| `url` | — | Grid GraphQL endpoint; required (metadata, `urlFromEnv`, or `SE_GRID_URL`) | +| `browserName` | `""` | e.g. `chrome`, `firefox`, `MicrosoftEdge` | +| `sessionBrowserName` | = `browserName` | e.g. `msedge` for Edge | +| `browserVersion` | `""` | prefix-matched against requests | +| `platformName` | `""` | platform-family aware | +| `capabilities` | `""` | JSON of extra required capabilities | +| `nodeMaxSessions` | `1` | slots per Node | +| `enableManagedDownloads` | `true` | | +| `activationThreshold` | `0` | scale-from-zero threshold | +| `unsafeSsl` | `false` | skip Grid TLS verification | +| `jobScalingStrategy` | `default` | `default`\|`custom`\|`accurate`\|`eager`; must match the ScaledJob's `scalingStrategy.strategy` | +| `authType` | `""` | `Basic` (default) or a bearer scheme like `OAuth2`/`Bearer` | +| `username` / `password` | — | Grid basic auth | +| `accessToken` | — | Grid bearer token (with non-Basic `authType`) | + +Any key may also be supplied as `FromEnv` (KEDA resolves it from the scale +target's container env before sending). + +## Authentication + +Because authParams are not forwarded, resolve Grid URL and credentials by one of +(precedence high → low, per key): + +1. **Trigger metadata** — `url`, `username`, `password`, `accessToken` directly + in the trigger `metadata` (visible in the ScaledObject; fine for URL, avoid + for secrets). +2. **`*FromEnv` metadata** — e.g. `usernameFromEnv: SE_ROUTER_USERNAME`; KEDA + resolves it from the **scale target's** container environment. +3. **Scaler environment** — mount the secret into *this* Deployment as + `SE_GRID_URL`, `SE_GRID_AUTH_TYPE`, `SE_USERNAME`, `SE_PASSWORD`, + `SE_ACCESS_TOKEN`. Keeps secrets out of the ScaledObject entirely. + +Options 2 and 3 are both supported; pick per your secret-management preference. + +## Deploy + +```bash +kubectl apply -f deploy/deployment.yaml # scaler Deployment + Service +kubectl apply -f deploy/scaledjob-example.yaml # or scaledobject-example.yaml +``` + +See `deploy/` for full examples. The scaler exposes a gRPC health service +(`grpc.health.v1.Health`) for Kubernetes `grpc` probes. + +## Build & test + +```bash +make build # -> bin/selenium-grid-scaler +make test # go test ./... -race -cover (includes the verbatim upstream table) +make image # multi-arch (linux/amd64, linux/arm64) selenium/keda-external-scaler +make proto # regenerate gRPC stubs (only needed if proto/externalscaler.proto changes) +``` + +Runtime flags (all also configurable by env): + +| Flag | Env | Default | +|---|---|---| +| `--listen-address` | `LISTEN_ADDRESS` | `:8080` | +| `--grid-http-timeout` | `SE_GRID_HTTP_TIMEOUT` | `3s` | +| `--tls-cert-file` | `TLS_CERT_FILE` | — | +| `--tls-key-file` | `TLS_KEY_FILE` | — | + +Setting both TLS flags serves the gRPC endpoint over TLS (KEDA connects with +`enableTLS`/`caCert` on its side). + +Requires Go 1.26+ (matching the `go` directive in `go.mod`). + +## Layout + +``` +proto/externalscaler.proto vendored from KEDA, byte-identical +externalscaler/ generated gRPC stubs (committed) +internal/gridscaler/ logic (ported), metadata, grid client, gRPC server +cmd/scaler/ binary entrypoint +deploy/ reference manifests +``` + +Only `StreamIsActive` (the `external-push` entrypoint) is unimplemented — the +Grid exposes no push signal, so use trigger type `external`, not `external-push`. diff --git a/.keda-external-scaler/SPEC.md b/.keda-external-scaler/SPEC.md new file mode 100644 index 0000000000..0845ac8f13 --- /dev/null +++ b/.keda-external-scaler/SPEC.md @@ -0,0 +1,262 @@ +# Spec: Selenium Grid KEDA External Scaler + +## Objective + +Extract the KEDA built-in `selenium-grid` scaler (currently maintained in-tree at +`kedacore/keda/pkg/scalers/selenium_grid_scaler.go`, mirrored in this repo at +`.keda/scalers/selenium_grid_scaler.go`) into a standalone **KEDA external scaler**: +a gRPC server implementing KEDA's `externalscaler.ExternalScaler` service, owned and +released by the SeleniumHQ/docker-selenium project. + +**Why:** +- Decouple scaler releases from the KEDA release cadence — bug fixes (e.g. the + accurate/eager double-counting fix, SeleniumHQ/docker-selenium#3167) ship when + docker-selenium ships, not months later in a KEDA minor. +- The Selenium project controls the full autoscaling stack (Grid, chart, scaler) + and can evolve capability-matching logic in lockstep with Grid's + `DefaultSlotMatcher`. +- Users get identical scaling behavior; only the trigger wiring in the + ScaledObject/ScaledJob changes from `type: selenium-grid` to `type: external`. + +**Who:** operators running Selenium Grid on Kubernetes with KEDA (ScaledJob or +ScaledObject per browser stereotype), primarily via the `selenium-grid` Helm chart +in `charts/`. + +**Success looks like:** a container image that, deployed next to the Grid, answers +KEDA's `GetMetricSpec`/`GetMetrics`/`IsActive` with values byte-for-byte equal to +what the built-in scaler would report for the same Grid GraphQL state and the same +trigger metadata. + +## Background: how the two sides work today + +### KEDA external scaler contract (from `pkg/scalers/externalscaler/externalscaler.proto`) + +```proto +service ExternalScaler { + rpc IsActive(ScaledObjectRef) returns (IsActiveResponse) {} + rpc StreamIsActive(ScaledObjectRef) returns (stream IsActiveResponse) {} // only used by trigger type `external-push` + rpc GetMetricSpec(ScaledObjectRef) returns (GetMetricSpecResponse) {} + rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {} +} +message ScaledObjectRef { string name; string namespace; map scalerMetadata; } +``` + +Facts that constrain the design (verified in `pkg/scalers/external_scaler.go`): + +1. **Only `triggerMetadata` reaches the external scaler.** KEDA copies + `config.TriggerMetadata` into `scalerMetadata`; keys ending in `FromEnv` are + resolved against the scale target's container env before sending. + **TriggerAuthentication `authParams` are NOT forwarded.** Grid credentials + therefore cannot arrive via TriggerAuthentication — they must come from + `*FromEnv` trigger metadata or from the scaler's own environment/mounted secret. +2. KEDA calls `GetMetrics` then `IsActive` on every poll (`pollingInterval`); + the server should be stateless per request and cheap to call. +3. `MetricValue`/`TargetSize` int64 fields are honored when the float fields are 0, + so integer node counts can be returned via the int64 fields safely. +4. KEDA⇄scaler transport supports TLS/mTLS configured on the KEDA side + (`enableTLS`, `caCert`, `tlsClientCert`, `tlsClientKey` authParams). The server + must optionally serve TLS to support this. +5. `metricName` returned by `GetMetricSpec` is echoed back in `GetMetricsRequest` + without the `sX-` trigger index prefix; the server must generate a + deterministic, HPA-safe metric name and dedupe across triggers via its own + naming (same scheme as built-in: `selenium-grid[-browserName][-browserVersion][-platformName]`). + +### Built-in scaler logic to port (from `selenium_grid_scaler.go`) + +All of the following moves verbatim (it is pure functions over the GraphQL +response — already mirrored at `.keda/scalers/selenium_grid_scaler.go`): + +- GraphQL POST query: `{ grid { sessionCount, maxSession, totalSlots }, nodesInfo { nodes { ... } }, sessionsInfo { sessionQueueRequests } }` +- Auth on the Grid request: Basic (username/password) or `Authorization: ` +- Capability matching following Grid's `DefaultSlotMatcher`: + `checkRequestCapabilitiesMatch`, `checkStereotypeCapabilitiesMatch`, + extension-capability filtering (`goog:`, `moz:`, `ms:`, `se:` prefixes), + `se:downloadsEnabled` handling, platform-family matching (`GetPlatform`, + `isSameFamily`, the full Platform table) +- The reservation algorithm in `getCountFromSeleniumResponse`: walk queued + requests, reserve free matching slots on existing UP nodes, then pack remaining + requests onto hypothetical new nodes of capacity `nodeMaxSessions`; also count + matching ongoing sessions +- The metric convention per `jobScalingStrategy`: + - `default`/`custom` (and all ScaledObjects): `count = newRequestNodes + onGoingSessions` + - `accurate`/`eager`: `count = newRequestNodes` +- Activation: `count > activationThreshold` + +## Tech Stack + +- Go (match KEDA's toolchain era: go 1.24+; module independent of `kedacore/keda`) +- `google.golang.org/grpc` + `google.golang.org/protobuf` (generated stubs from a + vendored copy of `externalscaler.proto` — proto is stable and versionless) +- `grpc_health_v1` health service for k8s liveness/readiness probes +- Zero KEDA imports: the only KEDA coupling is the proto contract +- Container image: distroless/static or scratch, multi-arch (amd64/arm64), + built from a Dockerfile in this directory + +## Commands + +``` +Generate stubs: make proto # protoc + protoc-gen-go/protoc-gen-go-grpc pinned versions +Build: make build # go build ./... → bin/selenium-grid-scaler +Test: make test # go test ./... -race -cover +Lint: make lint # golangci-lint run +Image: make image # docker buildx build -t selenium/keda-external-scaler: +Run locally: bin/selenium-grid-scaler --port 8080 # env: SE_GRID_URL, SE_USERNAME, ... +``` + +(Exact Makefile targets created in the implementation phase; commands above are +the contract.) + +## Project Structure + +``` +.keda-external-scaler/ +├── SPEC.md → this document +├── go.mod / go.sum → module github.com/SeleniumHQ/docker-selenium/keda-external-scaler +├── Makefile +├── Dockerfile +├── proto/ +│ └── externalscaler.proto → vendored from kedacore/keda (unchanged) +├── externalscaler/ → generated pb.go + grpc.pb.go (committed) +├── cmd/scaler/ +│ └── main.go → flag/env parsing, gRPC server + health service, TLS setup +├── internal/gridscaler/ +│ ├── metadata.go → parse scalerMetadata map → typed config (defaults, validation) +│ ├── grid_client.go → GraphQL query + auth against the Grid +│ ├── logic.go → ported matching + reservation algorithm (pure functions) +│ ├── platform.go → Platform table, GetPlatform, isSameFamily +│ └── server.go → ExternalScaler service impl (IsActive/GetMetricSpec/GetMetrics; StreamIsActive → Unimplemented) +├── internal/gridscaler/*_test.go → ported + new unit tests +└── deploy/ + ├── deployment.yaml → reference Deployment + Service + └── scaledjob-example.yaml → example ScaledJob/ScaledObject with `type: external` trigger +``` + +## Trigger metadata contract (user-facing) + +Same keys as the built-in scaler so migration is mechanical, plus `scalerAddress` +(consumed by KEDA itself, not the server): + +```yaml +triggers: + - type: external + metadata: + scalerAddress: selenium-grid-scaler.selenium.svc:8080 # KEDA-side + url: http://selenium-hub.selenium.svc:4444/graphql + browserName: chrome + sessionBrowserName: chrome # optional, defaults to browserName + browserVersion: "131.0" # optional + platformName: linux # optional + capabilities: '{"myApp:version":"beta"}' # optional JSON + nodeMaxSessions: "1" + enableManagedDownloads: "true" + activationThreshold: "0" + unsafeSsl: "false" # Grid TLS verification + jobScalingStrategy: default # default|custom|accurate|eager + authType: Basic # optional + usernameFromEnv: SE_USERNAME # resolved by KEDA from scale-target env + passwordFromEnv: SE_PASSWORD + # accessTokenFromEnv: SE_ACCESS_TOKEN +``` + +Server-side fallbacks (because authParams aren't forwarded): if `url`/credentials +are absent from `scalerMetadata`, the server falls back to its own env +(`SE_GRID_URL`, `SE_GRID_AUTH_TYPE`, `SE_USERNAME`, `SE_PASSWORD`, +`SE_ACCESS_TOKEN`), so operators can keep secrets out of trigger metadata +entirely by mounting them into the scaler Deployment. + +All values arrive as strings in the map; the server performs the same +defaulting/validation the `keda:` struct tags did (`nodeMaxSessions=1`, +`enableManagedDownloads=true`, `jobScalingStrategy∈{default,custom,accurate,eager}`, +`sessionBrowserName←browserName`). + +## Code Style + +Match the existing ported file `.keda/scalers/selenium_grid_scaler.go` — same +function names, same comment style — so diffs against upstream KEDA stay +reviewable. Example of the target shape for the server layer: + +```go +// GetMetrics implements externalscaler.ExternalScalerServer. +func (s *Server) GetMetrics(ctx context.Context, req *pb.GetMetricsRequest) (*pb.GetMetricsResponse, error) { + meta, err := parseMetadata(req.ScaledObjectRef.ScalerMetadata, s.envDefaults) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parsing scaler metadata: %s", err) + } + count, _, err := s.scrapeAndCount(ctx, meta) + if err != nil { + return nil, status.Errorf(codes.Internal, "querying selenium grid: %s", err) + } + return &pb.GetMetricsResponse{MetricValues: []*pb.MetricValue{{ + MetricName: buildMetricName(meta), MetricValue: count, + }}}, nil +} +``` + +Conventions: gRPC status codes for errors (InvalidArgument for bad metadata, +Internal/Unavailable for Grid failures); `logr`-style or stdlib `slog` structured +logging; no global mutable state except an HTTP client cache keyed by +(url, unsafeSsl). + +## Testing Strategy + +- **Unit tests (primary):** port `selenium_grid_scaler_test.go` (≈4000 lines of + table-driven cases in KEDA, already mirrored in `.keda/scalers/`) against the + ported `logic.go`/`metadata.go`. These tests are the parity proof — they must + pass unmodified in their expectations. +- **Server tests:** in-process gRPC server + `httptest` fake Grid GraphQL + endpoint; assert GetMetricSpec/GetMetrics/IsActive responses for representative + metadata, including `*FromEnv`-resolved values and env fallbacks, auth header + propagation, and error mapping. +- **StreamIsActive test:** assert it returns gRPC `Unimplemented`. +- Coverage expectation: `internal/gridscaler` ≥ the upstream file's effective + coverage; `go test -race` clean. +- **E2E (follow-up, out of initial scope):** wire into this repo's existing + `tests/` K8s autoscaling suite behind a flag that swaps the chart trigger to + `type: external`. + +## Boundaries + +- **Always:** run `make test` before commit; keep `logic.go`/`platform.go` + semantically identical to `.keda/scalers/selenium_grid_scaler.go` (divergence + is a spec change); keep the vendored proto byte-identical to upstream KEDA; + return gRPC status errors, never panic on malformed metadata. +- **Ask first:** adding third-party dependencies beyond grpc/protobuf; changing + the metric-name scheme or metadata keys (breaks migration parity); modifying + the Helm chart in `charts/` (separate change); publishing image names/registries. +- **Never:** commit Grid credentials or TLS keys; edit generated `*.pb.go` by + hand; remove or weaken ported test expectations to make them pass; couple the + module to `github.com/kedacore/keda/v2`. + +## Success Criteria + +1. `go test ./... -race` passes, including the full ported upstream test table + with unmodified expected values. +2. A local run against a fake Grid returns, for the documented example metadata, + the same `(metricValue, isActive)` pairs as the built-in scaler computes for + the same GraphQL fixture (verified by the shared test table). +3. `GetMetricSpec` returns `selenium-grid[-browser][-version][-platform]` with + `targetSize: 1`, matching the built-in naming after KEDA strips the index prefix. +4. All four `jobScalingStrategy` values produce the two documented count + conventions (`+onGoingSessions` vs queue-only). +5. Missing/invalid metadata yields `InvalidArgument`; Grid unreachable yields a + non-OK status and KEDA logs it without crashing the server. +6. `deploy/` manifests bring up the scaler and a ScaledJob example that KEDA + accepts (validated against a kind cluster manually or in follow-up e2e). +7. Image builds for linux/amd64 + linux/arm64. + +## Resolved Decisions + +1. **Auth path:** support BOTH `*FromEnv` trigger metadata and scaler-side + env/secret mount; document both, trigger metadata takes precedence over + server env when both are present. +2. **Image name:** `selenium/keda-external-scaler`; module path + `github.com/SeleniumHQ/docker-selenium/keda-external-scaler`. +3. **`StreamIsActive`:** returns gRPC `Unimplemented`. Only the polling + `external` trigger type is supported; `external-push` is out of scope. + +## Open Questions + +1. **Helm chart integration** (`charts/selenium-grid` switching trigger type to + `external` and deploying the scaler): separate spec/PR, correct? +2. **Deprecation story** for the KEDA built-in scaler (upstream KEDA deprecation + PR + migration doc): in scope for this effort or tracked separately? diff --git a/.keda-external-scaler/TASKS.md b/.keda-external-scaler/TASKS.md new file mode 100644 index 0000000000..9798719353 --- /dev/null +++ b/.keda-external-scaler/TASKS.md @@ -0,0 +1,81 @@ +# Tasks: Selenium Grid KEDA External Scaler + +Phase 3 of the spec-driven workflow. Ordered by dependency; T2/T4/T5 may run in +parallel after T1. See [PLAN.md](PLAN.md) for rationale. + +- [ ] **T1: Module scaffold + generated gRPC stubs** + - Acceptance: `go build ./...` succeeds from a clean checkout with no protoc + installed; `proto/externalscaler.proto` is byte-identical to + `kedacore/keda/pkg/scalers/externalscaler/externalscaler.proto`. + - Verify: `make build` (and `make proto` regenerates with zero diff). + - Files: `go.mod`, `Makefile`, `proto/externalscaler.proto`, + `externalscaler/externalscaler.pb.go`, `externalscaler/externalscaler_grpc.pb.go` + +- [ ] **T2: Port pure scaling logic** + - Acceptance: `getCountFromSeleniumResponse`, capability/stereotype matching, + platform table, and reservation algorithm compile standalone with no KEDA + imports; function names and comments match `.keda/scalers/selenium_grid_scaler.go`. + - Verify: `go vet ./...` + compiles; behavior verified by T3. + - Files: `internal/gridscaler/types.go`, `internal/gridscaler/logic.go`, + `internal/gridscaler/platform.go` + +- [ ] **T3: Port upstream unit test table (parity gate 1)** + - Acceptance: all `getCountFromSeleniumResponse` test cases from upstream + `selenium_grid_scaler_test.go` pass with **unmodified expected values**; + only harness/setup code adapted. + - Verify: `go test ./internal/gridscaler -run TestGetCount -race` + - Files: `internal/gridscaler/logic_test.go` + +- [ ] **T4: Metadata parsing with env fallback** + - Acceptance: `parseMetadata(map[string]string, envDefaults)` reproduces the + `keda:` tag behavior (defaults `nodeMaxSessions=1`, + `enableManagedDownloads=true`, `jobScalingStrategy` enum validation, + `sessionBrowserName←browserName`, required `url`); strips `FromEnv` key + suffixes; precedence `` > `FromEnv` > `SE_*` server env. + - Verify: `go test ./internal/gridscaler -run TestParseMetadata` + - Files: `internal/gridscaler/metadata.go`, `internal/gridscaler/metadata_test.go` + +- [ ] **T5: Grid GraphQL client** + - Acceptance: same GraphQL query string as built-in; Basic auth when + authType empty/Basic + username/password set, else + `Authorization: `; non-200 → error; cached + `*http.Client` per `unsafeSsl`; configurable timeout (default 3s). + - Verify: `go test ./internal/gridscaler -run TestGridClient` (httptest server + asserting body, headers, TLS-skip behavior) + - Files: `internal/gridscaler/grid_client.go`, `internal/gridscaler/grid_client_test.go` + +- [ ] **T6: gRPC server implementation (parity gate 2)** + - Acceptance: `GetMetricSpec` returns normalized + `selenium-grid[-browser][-version][-platform]` with `targetSize=1`; + `GetMetrics` returns count per `jobScalingStrategy` convention; `IsActive` + returns `count > activationThreshold`; `StreamIsActive` returns + `Unimplemented`; bad metadata → `InvalidArgument`, Grid failure → `Internal`. + - Verify: `go test ./internal/gridscaler -run TestServer -race` (in-process + grpc server + fake Grid fixtures covering all four strategies) + - Files: `internal/gridscaler/server.go`, `internal/gridscaler/server_test.go` + +- [ ] **T7: Binary entrypoint** + - Acceptance: flags/env for port (default 8080), Grid env fallbacks + (`SE_GRID_URL`, `SE_GRID_AUTH_TYPE`, `SE_USERNAME`, `SE_PASSWORD`, + `SE_ACCESS_TOKEN`), HTTP timeout, optional TLS (`--tls-cert-file`, + `--tls-key-file`); registers grpc_health_v1; graceful shutdown on + SIGTERM/SIGINT; structured logging (slog). + - Verify: `make build`; smoke: start binary, `grpc_health_probe`/grpcurl + health check returns SERVING. + - Files: `cmd/scaler/main.go` + +- [ ] **T8: Container image** + - Acceptance: multi-stage Dockerfile → distroless/static nonroot; image name + `selenium/keda-external-scaler`; builds linux/amd64 and linux/arm64. + - Verify: `make image`; `docker run` starts and health check passes. + - Files: `Dockerfile`, `Makefile` (image target), `.dockerignore` + +- [ ] **T9: Deployment manifests + docs** + - Acceptance: reference Deployment+Service; example ScaledJob and ScaledObject + using `type: external` with the documented metadata; README covering both + auth paths (FromEnv metadata and scaler-side secret mount), migration table + from `type: selenium-grid`, and KEDA-side TLS notes. + - Verify: manifests pass `kubectl apply --dry-run=client`; README reviewed + against SPEC trigger-metadata contract. + - Files: `deploy/deployment.yaml`, `deploy/scaledjob-example.yaml`, + `deploy/scaledobject-example.yaml`, `README.md` diff --git a/.keda-external-scaler/cmd/scaler/main.go b/.keda-external-scaler/cmd/scaler/main.go new file mode 100644 index 0000000000..3af0ae6961 --- /dev/null +++ b/.keda-external-scaler/cmd/scaler/main.go @@ -0,0 +1,157 @@ +// Command selenium-grid-scaler is a KEDA external scaler for Selenium Grid. +// +// It serves KEDA's externalscaler.ExternalScaler gRPC service, translating the +// Grid's GraphQL state into the number of Nodes KEDA should scale to. It is a +// standalone extraction of KEDA's built-in selenium-grid scaler so scaling +// behaviour can be released on the docker-selenium cadence. +package main + +import ( + "context" + "errors" + "flag" + "log/slog" + "net" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/go-logr/logr" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + + pb "github.com/SeleniumHQ/docker-selenium/keda-external-scaler/externalscaler" + "github.com/SeleniumHQ/docker-selenium/keda-external-scaler/internal/gridscaler" +) + +// gridEnvKeys are the environment variables read as server-side fallbacks for +// Grid URL and credentials (used when trigger metadata omits them, since KEDA +// does not forward TriggerAuthentication authParams to external scalers). +var gridEnvKeys = []string{"SE_GRID_URL", "SE_GRID_AUTH_TYPE", "SE_USERNAME", "SE_PASSWORD", "SE_ACCESS_TOKEN"} + +type config struct { + listenAddr string + httpTimeout time.Duration + tlsCertFile string + tlsKeyFile string +} + +func parseFlags(args []string) (config, error) { + fs := flag.NewFlagSet("selenium-grid-scaler", flag.ContinueOnError) + var cfg config + fs.StringVar(&cfg.listenAddr, "listen-address", envOr("LISTEN_ADDRESS", ":8080"), "gRPC listen address (env LISTEN_ADDRESS)") + fs.DurationVar(&cfg.httpTimeout, "grid-http-timeout", envDurationOr("SE_GRID_HTTP_TIMEOUT", 3*time.Second), "per-request timeout for Grid GraphQL queries (env SE_GRID_HTTP_TIMEOUT)") + fs.StringVar(&cfg.tlsCertFile, "tls-cert-file", os.Getenv("TLS_CERT_FILE"), "path to server TLS certificate; enables TLS when set with --tls-key-file (env TLS_CERT_FILE)") + fs.StringVar(&cfg.tlsKeyFile, "tls-key-file", os.Getenv("TLS_KEY_FILE"), "path to server TLS private key (env TLS_KEY_FILE)") + if err := fs.Parse(args); err != nil { + return config{}, err + } + return cfg, nil +} + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfg, err := parseFlags(os.Args[1:]) + if err != nil { + // flag already printed usage/error for -h and parse failures. + if errors.Is(err, flag.ErrHelp) { + return + } + logger.Error("invalid flags", "err", err) + os.Exit(2) + } + + if err := run(cfg, logger); err != nil { + logger.Error("server exited with error", "err", err) + os.Exit(1) + } +} + +func run(cfg config, logger *slog.Logger) error { + env := collectGridEnv() + + var opts []grpc.ServerOption + if cfg.tlsCertFile != "" && cfg.tlsKeyFile != "" { + creds, err := credentials.NewServerTLSFromFile(cfg.tlsCertFile, cfg.tlsKeyFile) + if err != nil { + return err + } + opts = append(opts, grpc.Creds(creds)) + logger.Info("TLS enabled", "certFile", cfg.tlsCertFile) + } + + srv := grpc.NewServer(opts...) + + gridClient := gridscaler.NewGridClient(cfg.httpTimeout) + scaler := gridscaler.NewServer(gridClient, env, logr.FromSlogHandler(logger.Handler())) + pb.RegisterExternalScalerServer(srv, scaler) + + healthSrv := health.NewServer() + healthpb.RegisterHealthServer(srv, healthSrv) + healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + + lis, err := net.Listen("tcp", cfg.listenAddr) + if err != nil { + return err + } + + // Graceful shutdown on SIGTERM/SIGINT. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + + serveErr := make(chan error, 1) + go func() { + logger.Info("selenium-grid external scaler listening", "address", cfg.listenAddr, "gridUrlFromEnv", env["SE_GRID_URL"] != "") + serveErr <- srv.Serve(lis) + }() + + select { + case <-ctx.Done(): + logger.Info("shutdown signal received, draining") + healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) + srv.GracefulStop() + return nil + case err := <-serveErr: + if errors.Is(err, grpc.ErrServerStopped) { + return nil + } + return err + } +} + +func collectGridEnv() map[string]string { + env := make(map[string]string, len(gridEnvKeys)) + for _, k := range gridEnvKeys { + if v := os.Getenv(k); v != "" { + env[k] = v + } + } + return env +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envDurationOr(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + if d, err := time.ParseDuration(v); err == nil { + return d + } + // Bare integer is interpreted as milliseconds, matching KEDA's timeout config. + if ms, err := strconv.Atoi(v); err == nil { + return time.Duration(ms) * time.Millisecond + } + return fallback +} diff --git a/.keda-external-scaler/deploy/deployment.yaml b/.keda-external-scaler/deploy/deployment.yaml new file mode 100644 index 0000000000..7d972f3f1c --- /dev/null +++ b/.keda-external-scaler/deploy/deployment.yaml @@ -0,0 +1,82 @@ +# Reference Deployment + Service for the Selenium Grid KEDA external scaler. +# +# The scaler reads Grid URL and (optional) credentials from its own environment +# as a fallback, because KEDA does not forward TriggerAuthentication authParams +# to external scalers. Point SE_GRID_URL at the Grid's GraphQL endpoint. Grid +# credentials, if any, come from a Secret (see the commented block below). +apiVersion: apps/v1 +kind: Deployment +metadata: + name: selenium-grid-scaler + namespace: selenium + labels: + app: selenium-grid-scaler +spec: + replicas: 1 + selector: + matchLabels: + app: selenium-grid-scaler + template: + metadata: + labels: + app: selenium-grid-scaler + spec: + containers: + - name: scaler + image: selenium/keda-external-scaler:latest + args: ["--listen-address=:8080"] + ports: + - name: grpc + containerPort: 8080 + env: + - name: SE_GRID_URL + value: http://selenium-hub.selenium.svc:4444/graphql + # Uncomment to supply Grid basic-auth credentials from a Secret: + # - name: SE_USERNAME + # valueFrom: + # secretKeyRef: + # name: selenium-basic-auth + # key: SE_ROUTER_USERNAME + # - name: SE_PASSWORD + # valueFrom: + # secretKeyRef: + # name: selenium-basic-auth + # key: SE_ROUTER_PASSWORD + readinessProbe: + grpc: + port: 8080 + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + grpc: + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 15 + resources: + requests: + cpu: 50m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] +--- +apiVersion: v1 +kind: Service +metadata: + name: selenium-grid-scaler + namespace: selenium + labels: + app: selenium-grid-scaler +spec: + selector: + app: selenium-grid-scaler + ports: + - name: grpc + port: 8080 + targetPort: grpc diff --git a/.keda-external-scaler/deploy/scaledjob-example.yaml b/.keda-external-scaler/deploy/scaledjob-example.yaml new file mode 100644 index 0000000000..57b95875fb --- /dev/null +++ b/.keda-external-scaler/deploy/scaledjob-example.yaml @@ -0,0 +1,49 @@ +# Example ScaledJob using the Selenium Grid external scaler. +# +# The trigger type is `external`, and `scalerAddress` points at the scaler +# Service (see deployment.yaml). All other metadata mirrors the built-in +# `selenium-grid` scaler, so migrating from it is a mechanical change of `type` +# plus adding `scalerAddress`. +# +# jobScalingStrategy MUST match spec.scalingStrategy.strategy below so the +# emitted metric matches how KEDA computes effective max scale for Jobs. +apiVersion: keda.sh/v1alpha1 +kind: ScaledJob +metadata: + name: selenium-chrome-node + namespace: selenium +spec: + jobTargetRef: + template: + spec: + restartPolicy: Never + containers: + - name: chrome-node + image: selenium/node-chrome:latest + env: + - name: SE_EVENT_BUS_HOST + value: selenium-hub.selenium.svc + - name: SE_EVENT_BUS_PUBLISH_PORT + value: "4442" + - name: SE_EVENT_BUS_SUBSCRIBE_PORT + value: "4443" + pollingInterval: 20 + minReplicaCount: 0 + maxReplicaCount: 10 + successfulJobsHistoryLimit: 0 + failedJobsHistoryLimit: 3 + scalingStrategy: + strategy: default + triggers: + - type: external + metadata: + scalerAddress: selenium-grid-scaler.selenium.svc:8080 + # url is optional here — omit it to use the scaler's SE_GRID_URL env. + url: http://selenium-hub.selenium.svc:4444/graphql + browserName: chrome + nodeMaxSessions: "1" + jobScalingStrategy: default + activationThreshold: "0" + # For basic-auth Grids, resolve credentials from the Job's own env: + # usernameFromEnv: SE_ROUTER_USERNAME + # passwordFromEnv: SE_ROUTER_PASSWORD diff --git a/.keda-external-scaler/deploy/scaledobject-example.yaml b/.keda-external-scaler/deploy/scaledobject-example.yaml new file mode 100644 index 0000000000..770c602fe9 --- /dev/null +++ b/.keda-external-scaler/deploy/scaledobject-example.yaml @@ -0,0 +1,24 @@ +# Example ScaledObject (Deployment-based scaling) using the external scaler. +# +# ScaledObjects always use the "total desired" convention, so jobScalingStrategy +# is left at its default. The scaler reports queued requests plus on-going +# sessions, which the HPA divides by the target value (1) to get replicas. +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: selenium-chrome-node + namespace: selenium +spec: + scaleTargetRef: + name: selenium-chrome-node + pollingInterval: 20 + minReplicaCount: 0 + maxReplicaCount: 10 + triggers: + - type: external + metadata: + scalerAddress: selenium-grid-scaler.selenium.svc:8080 + url: http://selenium-hub.selenium.svc:4444/graphql + browserName: chrome + nodeMaxSessions: "1" + activationThreshold: "0" diff --git a/.keda-external-scaler/externalscaler/externalscaler.pb.go b/.keda-external-scaler/externalscaler/externalscaler.pb.go new file mode 100644 index 0000000000..91547c05f3 --- /dev/null +++ b/.keda-external-scaler/externalscaler/externalscaler.pb.go @@ -0,0 +1,539 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v5.28.3 +// source: externalscaler.proto + +package externalscaler + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ScaledObjectRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + ScalerMetadata map[string]string `protobuf:"bytes,3,rep,name=scalerMetadata,proto3" json:"scalerMetadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ScaledObjectRef) Reset() { + *x = ScaledObjectRef{} + mi := &file_externalscaler_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ScaledObjectRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScaledObjectRef) ProtoMessage() {} + +func (x *ScaledObjectRef) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScaledObjectRef.ProtoReflect.Descriptor instead. +func (*ScaledObjectRef) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{0} +} + +func (x *ScaledObjectRef) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ScaledObjectRef) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *ScaledObjectRef) GetScalerMetadata() map[string]string { + if x != nil { + return x.ScalerMetadata + } + return nil +} + +type IsActiveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result bool `protobuf:"varint,1,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IsActiveResponse) Reset() { + *x = IsActiveResponse{} + mi := &file_externalscaler_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IsActiveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IsActiveResponse) ProtoMessage() {} + +func (x *IsActiveResponse) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IsActiveResponse.ProtoReflect.Descriptor instead. +func (*IsActiveResponse) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{1} +} + +func (x *IsActiveResponse) GetResult() bool { + if x != nil { + return x.Result + } + return false +} + +type GetMetricSpecResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + MetricSpecs []*MetricSpec `protobuf:"bytes,1,rep,name=metricSpecs,proto3" json:"metricSpecs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMetricSpecResponse) Reset() { + *x = GetMetricSpecResponse{} + mi := &file_externalscaler_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMetricSpecResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMetricSpecResponse) ProtoMessage() {} + +func (x *GetMetricSpecResponse) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMetricSpecResponse.ProtoReflect.Descriptor instead. +func (*GetMetricSpecResponse) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{2} +} + +func (x *GetMetricSpecResponse) GetMetricSpecs() []*MetricSpec { + if x != nil { + return x.MetricSpecs + } + return nil +} + +type MetricSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + MetricName string `protobuf:"bytes,1,opt,name=metricName,proto3" json:"metricName,omitempty"` + // deprecated, use targetSizeFloat instead + TargetSize int64 `protobuf:"varint,2,opt,name=targetSize,proto3" json:"targetSize,omitempty"` + TargetSizeFloat float64 `protobuf:"fixed64,3,opt,name=targetSizeFloat,proto3" json:"targetSizeFloat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricSpec) Reset() { + *x = MetricSpec{} + mi := &file_externalscaler_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricSpec) ProtoMessage() {} + +func (x *MetricSpec) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricSpec.ProtoReflect.Descriptor instead. +func (*MetricSpec) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{3} +} + +func (x *MetricSpec) GetMetricName() string { + if x != nil { + return x.MetricName + } + return "" +} + +func (x *MetricSpec) GetTargetSize() int64 { + if x != nil { + return x.TargetSize + } + return 0 +} + +func (x *MetricSpec) GetTargetSizeFloat() float64 { + if x != nil { + return x.TargetSizeFloat + } + return 0 +} + +type GetMetricsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ScaledObjectRef *ScaledObjectRef `protobuf:"bytes,1,opt,name=scaledObjectRef,proto3" json:"scaledObjectRef,omitempty"` + MetricName string `protobuf:"bytes,2,opt,name=metricName,proto3" json:"metricName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMetricsRequest) Reset() { + *x = GetMetricsRequest{} + mi := &file_externalscaler_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMetricsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMetricsRequest) ProtoMessage() {} + +func (x *GetMetricsRequest) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMetricsRequest.ProtoReflect.Descriptor instead. +func (*GetMetricsRequest) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{4} +} + +func (x *GetMetricsRequest) GetScaledObjectRef() *ScaledObjectRef { + if x != nil { + return x.ScaledObjectRef + } + return nil +} + +func (x *GetMetricsRequest) GetMetricName() string { + if x != nil { + return x.MetricName + } + return "" +} + +type GetMetricsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + MetricValues []*MetricValue `protobuf:"bytes,1,rep,name=metricValues,proto3" json:"metricValues,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMetricsResponse) Reset() { + *x = GetMetricsResponse{} + mi := &file_externalscaler_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMetricsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMetricsResponse) ProtoMessage() {} + +func (x *GetMetricsResponse) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMetricsResponse.ProtoReflect.Descriptor instead. +func (*GetMetricsResponse) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{5} +} + +func (x *GetMetricsResponse) GetMetricValues() []*MetricValue { + if x != nil { + return x.MetricValues + } + return nil +} + +type MetricValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + MetricName string `protobuf:"bytes,1,opt,name=metricName,proto3" json:"metricName,omitempty"` + // deprecated, use metricValueFloat instead + MetricValue int64 `protobuf:"varint,2,opt,name=metricValue,proto3" json:"metricValue,omitempty"` + MetricValueFloat float64 `protobuf:"fixed64,3,opt,name=metricValueFloat,proto3" json:"metricValueFloat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricValue) Reset() { + *x = MetricValue{} + mi := &file_externalscaler_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricValue) ProtoMessage() {} + +func (x *MetricValue) ProtoReflect() protoreflect.Message { + mi := &file_externalscaler_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetricValue.ProtoReflect.Descriptor instead. +func (*MetricValue) Descriptor() ([]byte, []int) { + return file_externalscaler_proto_rawDescGZIP(), []int{6} +} + +func (x *MetricValue) GetMetricName() string { + if x != nil { + return x.MetricName + } + return "" +} + +func (x *MetricValue) GetMetricValue() int64 { + if x != nil { + return x.MetricValue + } + return 0 +} + +func (x *MetricValue) GetMetricValueFloat() float64 { + if x != nil { + return x.MetricValueFloat + } + return 0 +} + +var File_externalscaler_proto protoreflect.FileDescriptor + +var file_externalscaler_proto_rawDesc = string([]byte{ + 0x0a, 0x14, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x22, 0xe3, 0x01, 0x0a, 0x0f, 0x53, 0x63, 0x61, 0x6c, 0x65, + 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x66, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, + 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0e, + 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, + 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6c, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x52, 0x65, 0x66, 0x2e, 0x53, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x73, 0x63, 0x61, 0x6c, 0x65, + 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x41, 0x0a, 0x13, 0x53, 0x63, 0x61, + 0x6c, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2a, 0x0a, 0x10, + 0x49, 0x73, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x55, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, 0x65, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x3c, 0x0a, 0x0b, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, 0x65, 0x63, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, + 0x65, 0x63, 0x52, 0x0b, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, 0x65, 0x63, 0x73, 0x22, + 0x76, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, 0x65, 0x63, 0x12, 0x1e, 0x0a, + 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, + 0x0a, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0a, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x28, 0x0a, + 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x69, 0x7a, 0x65, 0x46, 0x6c, 0x6f, 0x61, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x69, + 0x7a, 0x65, 0x46, 0x6c, 0x6f, 0x61, 0x74, 0x22, 0x7e, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x49, 0x0a, 0x0f, + 0x73, 0x63, 0x61, 0x6c, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x66, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6c, 0x65, 0x64, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x52, 0x65, 0x66, 0x52, 0x0f, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x64, 0x4f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x66, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x65, 0x74, + 0x72, 0x69, 0x63, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x55, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4d, 0x65, + 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, + 0x0c, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, + 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x52, 0x0c, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x7b, + 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1e, 0x0a, + 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, + 0x0b, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, + 0x2a, 0x0a, 0x10, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x46, 0x6c, + 0x6f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x10, 0x6d, 0x65, 0x74, 0x72, 0x69, + 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x46, 0x6c, 0x6f, 0x61, 0x74, 0x32, 0xec, 0x02, 0x0a, 0x0e, + 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x12, 0x4f, + 0x0a, 0x08, 0x49, 0x73, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x1f, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6c, + 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x66, 0x1a, 0x20, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x41, + 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, + 0x57, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x73, 0x41, 0x63, 0x74, 0x69, 0x76, + 0x65, 0x12, 0x1f, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, + 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6c, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, + 0x65, 0x66, 0x1a, 0x20, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, + 0x6c, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x59, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, 0x65, 0x63, 0x12, 0x1f, 0x2e, 0x65, 0x78, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6c, 0x65, + 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x66, 0x1a, 0x25, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4d, + 0x65, 0x74, 0x72, 0x69, 0x63, 0x53, 0x70, 0x65, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, + 0x73, 0x12, 0x21, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, + 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, + 0x63, 0x61, 0x6c, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x12, 0x5a, 0x10, 0x2e, 0x3b, + 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x72, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_externalscaler_proto_rawDescOnce sync.Once + file_externalscaler_proto_rawDescData []byte +) + +func file_externalscaler_proto_rawDescGZIP() []byte { + file_externalscaler_proto_rawDescOnce.Do(func() { + file_externalscaler_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_externalscaler_proto_rawDesc), len(file_externalscaler_proto_rawDesc))) + }) + return file_externalscaler_proto_rawDescData +} + +var file_externalscaler_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_externalscaler_proto_goTypes = []any{ + (*ScaledObjectRef)(nil), // 0: externalscaler.ScaledObjectRef + (*IsActiveResponse)(nil), // 1: externalscaler.IsActiveResponse + (*GetMetricSpecResponse)(nil), // 2: externalscaler.GetMetricSpecResponse + (*MetricSpec)(nil), // 3: externalscaler.MetricSpec + (*GetMetricsRequest)(nil), // 4: externalscaler.GetMetricsRequest + (*GetMetricsResponse)(nil), // 5: externalscaler.GetMetricsResponse + (*MetricValue)(nil), // 6: externalscaler.MetricValue + nil, // 7: externalscaler.ScaledObjectRef.ScalerMetadataEntry +} +var file_externalscaler_proto_depIdxs = []int32{ + 7, // 0: externalscaler.ScaledObjectRef.scalerMetadata:type_name -> externalscaler.ScaledObjectRef.ScalerMetadataEntry + 3, // 1: externalscaler.GetMetricSpecResponse.metricSpecs:type_name -> externalscaler.MetricSpec + 0, // 2: externalscaler.GetMetricsRequest.scaledObjectRef:type_name -> externalscaler.ScaledObjectRef + 6, // 3: externalscaler.GetMetricsResponse.metricValues:type_name -> externalscaler.MetricValue + 0, // 4: externalscaler.ExternalScaler.IsActive:input_type -> externalscaler.ScaledObjectRef + 0, // 5: externalscaler.ExternalScaler.StreamIsActive:input_type -> externalscaler.ScaledObjectRef + 0, // 6: externalscaler.ExternalScaler.GetMetricSpec:input_type -> externalscaler.ScaledObjectRef + 4, // 7: externalscaler.ExternalScaler.GetMetrics:input_type -> externalscaler.GetMetricsRequest + 1, // 8: externalscaler.ExternalScaler.IsActive:output_type -> externalscaler.IsActiveResponse + 1, // 9: externalscaler.ExternalScaler.StreamIsActive:output_type -> externalscaler.IsActiveResponse + 2, // 10: externalscaler.ExternalScaler.GetMetricSpec:output_type -> externalscaler.GetMetricSpecResponse + 5, // 11: externalscaler.ExternalScaler.GetMetrics:output_type -> externalscaler.GetMetricsResponse + 8, // [8:12] is the sub-list for method output_type + 4, // [4:8] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_externalscaler_proto_init() } +func file_externalscaler_proto_init() { + if File_externalscaler_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_externalscaler_proto_rawDesc), len(file_externalscaler_proto_rawDesc)), + NumEnums: 0, + NumMessages: 8, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_externalscaler_proto_goTypes, + DependencyIndexes: file_externalscaler_proto_depIdxs, + MessageInfos: file_externalscaler_proto_msgTypes, + }.Build() + File_externalscaler_proto = out.File + file_externalscaler_proto_goTypes = nil + file_externalscaler_proto_depIdxs = nil +} diff --git a/.keda-external-scaler/externalscaler/externalscaler_grpc.pb.go b/.keda-external-scaler/externalscaler/externalscaler_grpc.pb.go new file mode 100644 index 0000000000..c7f3604c37 --- /dev/null +++ b/.keda-external-scaler/externalscaler/externalscaler_grpc.pb.go @@ -0,0 +1,239 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.3 +// source: externalscaler.proto + +package externalscaler + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ExternalScaler_IsActive_FullMethodName = "/externalscaler.ExternalScaler/IsActive" + ExternalScaler_StreamIsActive_FullMethodName = "/externalscaler.ExternalScaler/StreamIsActive" + ExternalScaler_GetMetricSpec_FullMethodName = "/externalscaler.ExternalScaler/GetMetricSpec" + ExternalScaler_GetMetrics_FullMethodName = "/externalscaler.ExternalScaler/GetMetrics" +) + +// ExternalScalerClient is the client API for ExternalScaler service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ExternalScalerClient interface { + IsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*IsActiveResponse, error) + StreamIsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IsActiveResponse], error) + GetMetricSpec(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*GetMetricSpecResponse, error) + GetMetrics(ctx context.Context, in *GetMetricsRequest, opts ...grpc.CallOption) (*GetMetricsResponse, error) +} + +type externalScalerClient struct { + cc grpc.ClientConnInterface +} + +func NewExternalScalerClient(cc grpc.ClientConnInterface) ExternalScalerClient { + return &externalScalerClient{cc} +} + +func (c *externalScalerClient) IsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*IsActiveResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(IsActiveResponse) + err := c.cc.Invoke(ctx, ExternalScaler_IsActive_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *externalScalerClient) StreamIsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IsActiveResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ExternalScaler_ServiceDesc.Streams[0], ExternalScaler_StreamIsActive_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ScaledObjectRef, IsActiveResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ExternalScaler_StreamIsActiveClient = grpc.ServerStreamingClient[IsActiveResponse] + +func (c *externalScalerClient) GetMetricSpec(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*GetMetricSpecResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetMetricSpecResponse) + err := c.cc.Invoke(ctx, ExternalScaler_GetMetricSpec_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *externalScalerClient) GetMetrics(ctx context.Context, in *GetMetricsRequest, opts ...grpc.CallOption) (*GetMetricsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetMetricsResponse) + err := c.cc.Invoke(ctx, ExternalScaler_GetMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ExternalScalerServer is the server API for ExternalScaler service. +// All implementations must embed UnimplementedExternalScalerServer +// for forward compatibility. +type ExternalScalerServer interface { + IsActive(context.Context, *ScaledObjectRef) (*IsActiveResponse, error) + StreamIsActive(*ScaledObjectRef, grpc.ServerStreamingServer[IsActiveResponse]) error + GetMetricSpec(context.Context, *ScaledObjectRef) (*GetMetricSpecResponse, error) + GetMetrics(context.Context, *GetMetricsRequest) (*GetMetricsResponse, error) + mustEmbedUnimplementedExternalScalerServer() +} + +// UnimplementedExternalScalerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedExternalScalerServer struct{} + +func (UnimplementedExternalScalerServer) IsActive(context.Context, *ScaledObjectRef) (*IsActiveResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method IsActive not implemented") +} +func (UnimplementedExternalScalerServer) StreamIsActive(*ScaledObjectRef, grpc.ServerStreamingServer[IsActiveResponse]) error { + return status.Errorf(codes.Unimplemented, "method StreamIsActive not implemented") +} +func (UnimplementedExternalScalerServer) GetMetricSpec(context.Context, *ScaledObjectRef) (*GetMetricSpecResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetMetricSpec not implemented") +} +func (UnimplementedExternalScalerServer) GetMetrics(context.Context, *GetMetricsRequest) (*GetMetricsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetMetrics not implemented") +} +func (UnimplementedExternalScalerServer) mustEmbedUnimplementedExternalScalerServer() {} +func (UnimplementedExternalScalerServer) testEmbeddedByValue() {} + +// UnsafeExternalScalerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ExternalScalerServer will +// result in compilation errors. +type UnsafeExternalScalerServer interface { + mustEmbedUnimplementedExternalScalerServer() +} + +func RegisterExternalScalerServer(s grpc.ServiceRegistrar, srv ExternalScalerServer) { + // If the following call pancis, it indicates UnimplementedExternalScalerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ExternalScaler_ServiceDesc, srv) +} + +func _ExternalScaler_IsActive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ScaledObjectRef) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalScalerServer).IsActive(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExternalScaler_IsActive_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalScalerServer).IsActive(ctx, req.(*ScaledObjectRef)) + } + return interceptor(ctx, in, info, handler) +} + +func _ExternalScaler_StreamIsActive_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ScaledObjectRef) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ExternalScalerServer).StreamIsActive(m, &grpc.GenericServerStream[ScaledObjectRef, IsActiveResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ExternalScaler_StreamIsActiveServer = grpc.ServerStreamingServer[IsActiveResponse] + +func _ExternalScaler_GetMetricSpec_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ScaledObjectRef) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalScalerServer).GetMetricSpec(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExternalScaler_GetMetricSpec_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalScalerServer).GetMetricSpec(ctx, req.(*ScaledObjectRef)) + } + return interceptor(ctx, in, info, handler) +} + +func _ExternalScaler_GetMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetMetricsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExternalScalerServer).GetMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExternalScaler_GetMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExternalScalerServer).GetMetrics(ctx, req.(*GetMetricsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ExternalScaler_ServiceDesc is the grpc.ServiceDesc for ExternalScaler service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ExternalScaler_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "externalscaler.ExternalScaler", + HandlerType: (*ExternalScalerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "IsActive", + Handler: _ExternalScaler_IsActive_Handler, + }, + { + MethodName: "GetMetricSpec", + Handler: _ExternalScaler_GetMetricSpec_Handler, + }, + { + MethodName: "GetMetrics", + Handler: _ExternalScaler_GetMetrics_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamIsActive", + Handler: _ExternalScaler_StreamIsActive_Handler, + ServerStreams: true, + }, + }, + Metadata: "externalscaler.proto", +} diff --git a/.keda-external-scaler/go.mod b/.keda-external-scaler/go.mod new file mode 100644 index 0000000000..86caa18bb3 --- /dev/null +++ b/.keda-external-scaler/go.mod @@ -0,0 +1,16 @@ +module github.com/SeleniumHQ/docker-selenium/keda-external-scaler + +go 1.26.0 + +require ( + github.com/go-logr/logr v1.4.2 + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.5 +) + +require ( + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect +) diff --git a/.keda-external-scaler/go.sum b/.keda-external-scaler/go.sum new file mode 100644 index 0000000000..5105a9a89b --- /dev/null +++ b/.keda-external-scaler/go.sum @@ -0,0 +1,34 @@ +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/.keda-external-scaler/internal/gridscaler/grid_client.go b/.keda-external-scaler/internal/gridscaler/grid_client.go new file mode 100644 index 0000000000..759ce6c6c7 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/grid_client.go @@ -0,0 +1,101 @@ +package gridscaler + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// gridQuery is the GraphQL query sent to the Selenium Grid. It is kept +// byte-identical to the built-in KEDA scaler's query so the response shape and +// downstream parsing remain in lockstep. +const gridQuery = "{ grid { sessionCount, maxSession, totalSlots }, nodesInfo { nodes { id, status, sessionCount, maxSession, slotCount, stereotypes, sessions { id, capabilities, slot { id, stereotype } } } }, sessionsInfo { sessionQueueRequests } }" + +// GridClient issues the Grid GraphQL query. It caches one *http.Client per +// unsafeSsl setting so repeated polls reuse connections rather than performing a +// fresh TLS handshake each time. +type GridClient struct { + timeout time.Duration + + mu sync.Mutex + clients map[bool]*http.Client +} + +// NewGridClient returns a GridClient using the given per-request timeout. +func NewGridClient(timeout time.Duration) *GridClient { + return &GridClient{ + timeout: timeout, + clients: make(map[bool]*http.Client), + } +} + +func (c *GridClient) httpClient(unsafeSsl bool) *http.Client { + c.mu.Lock() + defer c.mu.Unlock() + if client, ok := c.clients[unsafeSsl]; ok { + return client + } + client := newHTTPClient(c.timeout, unsafeSsl) + c.clients[unsafeSsl] = client + return client +} + +// newHTTPClient mirrors kedautil.CreateHTTPClient: a client honouring proxies +// from the environment, with optional TLS verification skip. +func newHTTPClient(timeout time.Duration, unsafeSsl bool) *http.Client { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: unsafeSsl, //nolint:gosec // opt-in via unsafeSsl trigger metadata + }, + } + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} + +// Query POSTs the Grid GraphQL query using the auth configured in meta and +// returns the raw response body. A non-200 status is returned as an error. +func (c *GridClient) Query(ctx context.Context, meta *Metadata) ([]byte, error) { + body, err := json.Marshal(map[string]string{"query": gridQuery}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, meta.URL, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + if (meta.AuthType == "" || strings.EqualFold(meta.AuthType, "Basic")) && meta.Username != "" && meta.Password != "" { + req.SetBasicAuth(meta.Username, meta.Password) + } else if !strings.EqualFold(meta.AuthType, "Basic") && meta.AccessToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("%s %s", meta.AuthType, meta.AccessToken)) + } + + res, err := c.httpClient(meta.UnsafeSsl).Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("selenium grid returned response status code: %d", res.StatusCode) + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("reading selenium grid response body: %w", err) + } + return b, nil +} diff --git a/.keda-external-scaler/internal/gridscaler/grid_client_test.go b/.keda-external-scaler/internal/gridscaler/grid_client_test.go new file mode 100644 index 0000000000..66a9963eee --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/grid_client_test.go @@ -0,0 +1,101 @@ +package gridscaler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGridClient_Query(t *testing.T) { + t.Run("sends the GraphQL query and returns the body", func(t *testing.T) { + var gotBody map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer server.Close() + + c := NewGridClient(3 * time.Second) + b, err := c.Query(context.Background(), &Metadata{URL: server.URL}) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if string(b) != `{"data":{}}` { + t.Errorf("body = %s", b) + } + if gotBody["query"] != gridQuery { + t.Errorf("query = %q, want %q", gotBody["query"], gridQuery) + } + }) + + t.Run("sets Basic auth when username and password are present", func(t *testing.T) { + var user, pass string + var okBasic bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, okBasic = r.BasicAuth() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + c := NewGridClient(3 * time.Second) + _, err := c.Query(context.Background(), &Metadata{URL: server.URL, Username: "u", Password: "p"}) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if !okBasic || user != "u" || pass != "p" { + t.Errorf("basic auth = (%q,%q,%v), want (u,p,true)", user, pass, okBasic) + } + }) + + t.Run("sets bearer-style Authorization header for non-Basic authType", func(t *testing.T) { + var auth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + c := NewGridClient(3 * time.Second) + _, err := c.Query(context.Background(), &Metadata{URL: server.URL, AuthType: "OAuth2", AccessToken: "tok"}) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if auth != "OAuth2 tok" { + t.Errorf("Authorization = %q, want %q", auth, "OAuth2 tok") + } + }) + + t.Run("non-200 status returns an error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewGridClient(3 * time.Second) + if _, err := c.Query(context.Background(), &Metadata{URL: server.URL}); err == nil { + t.Error("Query() error = nil, want non-nil for 500 response") + } + }) + + t.Run("caches one client per unsafeSsl value", func(t *testing.T) { + c := NewGridClient(3 * time.Second) + a1 := c.httpClient(false) + a2 := c.httpClient(false) + b1 := c.httpClient(true) + if a1 != a2 { + t.Error("expected cached client reuse for unsafeSsl=false") + } + if a1 == b1 { + t.Error("expected distinct clients for different unsafeSsl values") + } + }) +} diff --git a/.keda-external-scaler/internal/gridscaler/logic.go b/.keda-external-scaler/internal/gridscaler/logic.go new file mode 100644 index 0000000000..1f6b866d3e --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/logic.go @@ -0,0 +1,270 @@ +package gridscaler + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/go-logr/logr" +) + +// Follow pattern in https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +func filterCapabilities(capabilities map[string]interface{}) map[string]interface{} { + filteredCapabilities := map[string]interface{}{} + + for key, value := range capabilities { + retain := true + for _, excludePrefix := range ExtensionCapabilitiesPrefixes { + if strings.HasPrefix(key, excludePrefix) { + retain = false + break + } + } + for _, prefix := range FunctionCapabilitiesPrefixes { + if strings.HasPrefix(key, prefix) { + retain = true + break + } + } + if retain { + filteredCapabilities[key] = value + } + } + + return filteredCapabilities +} + +func parseCapabilitiesToMap(_capabilities string) (map[string]interface{}, error) { + capabilities := map[string]interface{}{} + if _capabilities != "" { + if err := json.Unmarshal([]byte(_capabilities), &capabilities); err != nil { + return nil, err + } + } + return capabilities, nil +} + +func getCapability(capability map[string]interface{}, key string) string { + value, ok := capability[key] + if ok { + return value.(string) + } + return "" +} + +func getBrowserName(capability map[string]interface{}) string { + return getCapability(capability, "browserName") +} + +func getBrowserVersion(capability map[string]interface{}) string { + return getCapability(capability, "browserVersion") +} + +func getPlatformName(capability map[string]interface{}) string { + return getCapability(capability, "platformName") +} + +func countMatchingSlotsStereotypes(stereotypes Stereotypes, browserName string, browserVersion string, sessionBrowserName string, platformName string, capabilities map[string]interface{}) int64 { + var matchingSlots int64 + for _, stereotype := range stereotypes { + if checkStereotypeCapabilitiesMatch(stereotype.Stereotype, browserName, browserVersion, sessionBrowserName, platformName, capabilities) { + matchingSlots += stereotype.Slots + } + } + return matchingSlots +} + +func countMatchingSessions(sessions Sessions, browserName string, browserVersion string, sessionBrowserName string, platformName string, capabilities map[string]interface{}, logger logr.Logger) int64 { + var matchingSessions int64 + for _, session := range sessions { + var capability map[string]interface{} + if err := json.Unmarshal([]byte(session.Slot.Stereotype), &capability); err == nil { + if checkStereotypeCapabilitiesMatch(capability, browserName, browserVersion, sessionBrowserName, platformName, capabilities) { + matchingSessions++ + } + } else { + logger.Error(err, fmt.Sprintf("Error when unmarshaling session capabilities: %s", err)) + } + } + return matchingSessions +} + +func managedDownloadsEnabled(stereotype map[string]interface{}, capabilities map[string]interface{}) bool { + // First lets check if user wanted a Node with managed downloads enabled + value1, ok1 := capabilities[EnableManagedDownloadsCapability] + if !ok1 || !value1.(bool) { + // User didn't ask. So lets move on to the next matching criteria + return true + } + // User wants managed downloads enabled to be done on this Node, let's check the stereotype + value2, ok2 := stereotype[EnableManagedDownloadsCapability] + // Try to match what the user requested + return ok2 && value2.(bool) +} + +func extensionCapabilitiesMatch(stereotype map[string]interface{}, capabilities map[string]interface{}) bool { + capabilities = filterCapabilities(capabilities) + if len(capabilities) == 0 { + return true + } + for key, value := range capabilities { + if key == EnableManagedDownloadsCapability { + continue + } + if stereotypeValue, ok := stereotype[key]; !ok || stereotypeValue != value { + return false + } + } + return true +} + +// This function checks if the request capabilities match the scaler metadata +func checkRequestCapabilitiesMatch(request map[string]interface{}, browserName string, browserVersion string, _ string, platformName string, capabilities map[string]interface{}) bool { + // Check if browserName matches + _browserName := getBrowserName(request) + browserNameMatch := (_browserName == "" && browserName == "") || + strings.EqualFold(browserName, _browserName) + + // Check if browserVersion matches + _browserVersion := getBrowserVersion(request) + browserVersionMatch := (_browserVersion == "" && browserVersion == "") || + (_browserVersion != "" && strings.HasPrefix(browserVersion, _browserVersion)) + + // Check if platformName matches + platformNameMatch := strings.EqualFold(GetPlatform(platformName).name, GetPlatform(getPlatformName(request)).name) || + isSameFamily(GetPlatform(platformName), GetPlatform(getPlatformName(request))) + + return browserNameMatch && browserVersionMatch && platformNameMatch && managedDownloadsEnabled(capabilities, request) && extensionCapabilitiesMatch(request, capabilities) +} + +// This function checks if Node stereotypes or ongoing sessions match the scaler metadata +func checkStereotypeCapabilitiesMatch(capability map[string]interface{}, browserName string, browserVersion string, sessionBrowserName string, platformName string, capabilities map[string]interface{}) bool { + // Check if browserName matches + _browserName := getBrowserName(capability) + browserNameMatch := (_browserName == "" && browserName == "") || + strings.EqualFold(browserName, _browserName) || + strings.EqualFold(sessionBrowserName, _browserName) + + // Check if browserVersion matches + _browserVersion := getBrowserVersion(capability) + browserVersionMatch := (_browserVersion == "" && browserVersion == "") || + (_browserVersion != "" && strings.HasPrefix(browserVersion, _browserVersion)) + + // Check if platformName matches + platformNameMatch := strings.EqualFold(GetPlatform(platformName).name, GetPlatform(getPlatformName(capability)).name) || + isSameFamily(GetPlatform(platformName), GetPlatform(getPlatformName(capability))) + + return browserNameMatch && browserVersionMatch && platformNameMatch && managedDownloadsEnabled(capabilities, capability) && extensionCapabilitiesMatch(capability, capabilities) +} + +func checkNodeReservedSlots(reservedNodes []ReservedNodes, nodeID string, availableSlots int64) int64 { + for _, reservedNode := range reservedNodes { + if strings.EqualFold(reservedNode.ID, nodeID) { + return reservedNode.SlotCount + } + } + return availableSlots +} + +func updateOrAddReservedNode(reservedNodes []ReservedNodes, nodeID string, slotCount int64, maxSession int64) []ReservedNodes { + for i, reservedNode := range reservedNodes { + if strings.EqualFold(reservedNode.ID, nodeID) { + // Update remaining available slots for the reserved node + reservedNodes[i].SlotCount = slotCount + return reservedNodes + } + } + // Add new reserved node if not found + return append(reservedNodes, ReservedNodes{ID: nodeID, SlotCount: slotCount, MaxSession: maxSession}) +} + +func getCountFromSeleniumResponse(b []byte, browserName string, browserVersion string, sessionBrowserName string, platformName string, nodeMaxSessions int64, enableManagedDownloads bool, _capabilities string, logger logr.Logger) (int64, int64, error) { + // Track number of available slots of existing Nodes in the Grid can be reserved for the matched requests + var availableSlots int64 + // Track number of matched requests in the sessions queue will be served by this scaler + var queueSlots int64 + + var seleniumResponse = SeleniumResponse{} + if err := json.Unmarshal(b, &seleniumResponse); err != nil { + return 0, 0, err + } + + capabilities, err := parseCapabilitiesToMap(_capabilities) + if err != nil { + logger.Error(err, fmt.Sprintf("Error when unmarshaling trigger metadata 'capabilities': %s", err)) + } + if enableManagedDownloads { + capabilities[EnableManagedDownloadsCapability] = true + } + + var sessionQueueRequests = seleniumResponse.Data.SessionsInfo.SessionQueueRequests + var nodes = seleniumResponse.Data.NodesInfo.Nodes + // Track list of existing Nodes that have available slots for the matched requests + var reservedNodes []ReservedNodes + // Track list of new Nodes will be scaled up with number of available slots following scaler parameter `nodeMaxSessions` + var newRequestNodes []ReservedNodes + var onGoingSessions int64 + for requestIndex, sessionQueueRequest := range sessionQueueRequests { + var isRequestMatched bool + var requestCapability map[string]interface{} + if err := json.Unmarshal([]byte(sessionQueueRequest), &requestCapability); err == nil { + if checkRequestCapabilitiesMatch(requestCapability, browserName, browserVersion, sessionBrowserName, platformName, capabilities) { + queueSlots++ + isRequestMatched = true + } + } else { + logger.Error(err, fmt.Sprintf("Error when unmarshaling sessionQueueRequest capability: %s", err)) + } + + var isRequestReserved bool + // Check if the matched request can be assigned to available slots of existing Nodes in the Grid + for _, node := range nodes { + // Check if node is UP and has available slots (maxSession > sessionCount) + if isRequestMatched && strings.EqualFold(node.Status, "UP") && checkNodeReservedSlots(reservedNodes, node.ID, node.MaxSession-node.SessionCount) > 0 { + var stereotypes = Stereotypes{} + var availableSlotsMatch int64 + if err := json.Unmarshal([]byte(node.Stereotypes), &stereotypes); err == nil { + // Count available slots that match the request capability and scaler metadata + availableSlotsMatch += countMatchingSlotsStereotypes(stereotypes, browserName, browserVersion, sessionBrowserName, platformName, capabilities) + } else { + logger.Error(err, fmt.Sprintf("Error when unmarshaling node stereotypes: %s", err)) + } + if availableSlotsMatch == 0 { + continue + } + // Count ongoing sessions that match the request capability and scaler metadata + var currentSessionsMatch = countMatchingSessions(node.Sessions, browserName, browserVersion, sessionBrowserName, platformName, capabilities, logger) + // Count remaining available slots can be reserved for this request + var availableSlotsCanBeReserved = checkNodeReservedSlots(reservedNodes, node.ID, node.MaxSession-node.SessionCount) + // Reserve one available slot for the request if available slots match is greater than current sessions match + if availableSlotsMatch > currentSessionsMatch { + availableSlots++ + reservedNodes = updateOrAddReservedNode(reservedNodes, node.ID, availableSlotsCanBeReserved-1, node.MaxSession) + isRequestReserved = true + break + } + } + } + // Check if the matched request can be assigned to available slots of new Nodes will be scaled up, since the scaler parameter `nodeMaxSessions` can be greater than 1 + if isRequestMatched && !isRequestReserved { + for _, newRequestNode := range newRequestNodes { + if newRequestNode.SlotCount > 0 { + newRequestNodes = updateOrAddReservedNode(newRequestNodes, newRequestNode.ID, newRequestNode.SlotCount-1, nodeMaxSessions) + isRequestReserved = true + break + } + } + } + // Check if a new Node should be scaled up to reserve for the matched request + if isRequestMatched && !isRequestReserved { + newRequestNodes = updateOrAddReservedNode(newRequestNodes, string(rune(requestIndex)), nodeMaxSessions-1, nodeMaxSessions) + } + } + + // Count ongoing sessions across all nodes that match the scaler metadata + for _, node := range nodes { + onGoingSessions += countMatchingSessions(node.Sessions, browserName, browserVersion, sessionBrowserName, platformName, capabilities, logger) + } + + return int64(len(newRequestNodes)), onGoingSessions, nil +} diff --git a/.keda-external-scaler/internal/gridscaler/logic_test.go b/.keda-external-scaler/internal/gridscaler/logic_test.go new file mode 100644 index 0000000000..07978e7782 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/logic_test.go @@ -0,0 +1,3168 @@ +package gridscaler + +// Ported verbatim from KEDA pkg/scalers/selenium_grid_scaler_test.go +// (Test_getCountFromSeleniumResponse). Expected values are unchanged — this is +// the algorithm parity gate. See PLAN.md T3. + +import ( + "reflect" + "testing" + + "github.com/go-logr/logr" +) + +func Test_getCountFromSeleniumResponse(t *testing.T) { + type args struct { + b []byte + browserName string + sessionBrowserName string + browserVersion string + platformName string + nodeMaxSessions int64 + enableManagedDownloads bool + capabilities string + } + tests := []struct { + name string + args args + wantNewRequestNodes int64 + wantOnGoingSessions int64 + wantErr bool + }{ + { + name: "nil response body should throw error", + args: args{ + b: []byte(nil), + browserName: "", + }, + wantErr: true, + }, + { + name: "empty response body should throw error", + args: args{ + b: []byte(""), + browserName: "", + }, + wantErr: true, + }, + { + name: "no sessionQueueRequests should return count as 0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [] + }, + "sessionsInfo": { + "sessionQueueRequests": [] + } + } + } + `), + browserName: "", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "12 sessionQueueRequests with 4 requests matching browserName chrome should return count as 4", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"goog:chromeOptions\": {\n \"extensions\": [\n ],\n \"args\": [\n \"disable-features=DownloadBubble,DownloadBubbleV2\"\n ]\n },\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_download_file (ChromeTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_with_frames (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_download_file (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_title_and_maximize_window (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"browserName\": \"chrome\",\n \"goog:chromeOptions\": {\n \"extensions\": [\n ],\n \"args\": [\n \"disable-features=DownloadBubble,DownloadBubbleV2\"\n ]\n },\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_play_video (ChromeTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"browserName\": \"chrome\",\n \"goog:chromeOptions\": {\n \"extensions\": [\n ],\n \"args\": [\n \"disable-features=DownloadBubble,DownloadBubbleV2\"\n ]\n },\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_select_from_a_dropdown (ChromeTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_visit_basic_auth_secured_page (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_select_from_a_dropdown (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"browserName\": \"chrome\",\n \"goog:chromeOptions\": {\n \"extensions\": [\n ],\n \"args\": [\n \"disable-features=DownloadBubble,DownloadBubbleV2\"\n ]\n },\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_title (ChromeTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_title (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_accept_languages (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_play_video (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}" + ] + } + } + } + `), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + enableManagedDownloads: true, + platformName: "linux", + }, + wantNewRequestNodes: 4, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "2_sessionQueueRequests_and_1_available_nodeStereotypes_with_matching_browserName_firefox_should_return_count_as_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 7, + "totalSlots": 7 + }, + "nodesInfo": { + "nodes": [ + { + "id": "82ee33bd-390e-4dd6-aee2-06b17ecee18e", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-m5n8z-4br6x\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"chrome\",\n \"browserVersion\": \"128.0\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-m5n8z-4br6x\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "83c9d9f5-f79d-4dea-bc9b-ce61bf2bc01c", + "stereotype": "{\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-m5n8z-4br6x\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + }, + { + "id": "b4d3d31a-3239-4c09-a5f5-3650d4fcef48", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-s2gq6-82lwb\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"130.0\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-s2gq6-82lwb\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "b03b80c0-95f8-4b9c-ba06-bebd2568ce3d", + "stereotype": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-s2gq6-82lwb\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + }, + { + "id": "f3e67bf7-3c40-42d4-ab10-666b49c88925", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-xh95p-9c2cl\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [] + }, + { + "id": "f1e315fe-5f32-4a73-bb31-b73ed9a728e5", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-j2xbn-lq76c\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"chrome\",\n \"browserVersion\": \"128.0\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-j2xbn-lq76c\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "9d91cd87-b443-4a0c-93e7-eea8c4661207", + "stereotype": "{\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-j2xbn-lq76c\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + }, + { + "id": "0ae48415-a230-4bc4-a26c-4fc4ffc3abc1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-xk6mm-2m6jh\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"130.0\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-xk6mm-2m6jh\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "2c1fc5c4-881a-48fd-9b9e-b4d3ecbc1bd8", + "stereotype": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-xk6mm-2m6jh\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + }, + { + "id": "284fa982-5be0-44a6-b64e-e2e76fe52d1f", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-bvq59-6dh6q\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"130.0\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-bvq59-6dh6q\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "5f8f9ba0-0f61-473e-b367-b68d9368dc24", + "stereotype": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-bvq59-6dh6q\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + }, + { + "id": "451442d0-3649-4b21-a5a5-32bc847f1765", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-42xbf-zpdd4\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [] + }, + { + "id": "a4d26330-e5be-4630-b4da-9078f2495ece", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-qt9z2-6xx86\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"130.0\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-qt9z2-6xx86\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "38bd0b09-ffe0-46e9-8983-bd208270c8da", + "stereotype": "{\n \"browserName\": \"firefox\",\n \"browserVersion\": \"\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-qt9z2-6xx86\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + }, + { + "id": "e81f0038-fc72-4045-9de1-b98143053eae", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-v7nrv-xsfkb\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "reserved", + "capabilities": "{\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-v7nrv-xsfkb\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}", + "slot": { + "id": "43b992cc-39bb-4b0f-92b6-99603a543459", + "stereotype": "{\n \"browserName\": \"chrome\",\n \"browserVersion\": \"\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-v7nrv-xsfkb\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_accept_languages (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_play_video (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}" + ] + } + } + } + `), + browserName: "firefox", + sessionBrowserName: "firefox", + browserVersion: "", + enableManagedDownloads: true, + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 4, + wantErr: false, + }, + { + name: "1_sessionQueueRequests_and_1_available_nodeStereotypes_with_matching_browserName_chrome_should_return_count_as_0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [ + { + "id": "f3e67bf7-3c40-42d4-ab10-666b49c88925", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"browserVersion\": \"128.0\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-chrome-name-xh95p-9c2cl\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [] + }, + { + "id": "451442d0-3649-4b21-a5a5-32bc847f1765", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[\n {\n \"slots\": 1,\n \"stereotype\": {\n \"browserName\": \"firefox\",\n \"browserVersion\": \"130.0\",\n \"moz:firefoxOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002ffirefox\"\n },\n \"platformName\": \"linux\",\n \"se:containerName\": \"my-firefox-name-42xbf-zpdd4\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"acceptInsecureCerts\": true,\n \"browserName\": \"firefox\",\n \"moz:debuggerAddress\": true,\n \"moz:firefoxOptions\": {\n \"prefs\": {\n \"remote.active-protocols\": 3\n },\n \"profile\": \"profile\"\n },\n \"pageLoadStrategy\": \"normal\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_accept_languages (FirefoxTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}", + "{\n \"browserName\": \"chrome\",\n \"goog:chromeOptions\": {\n \"extensions\": [\n ],\n \"args\": [\n \"disable-features=DownloadBubble,DownloadBubbleV2\"\n ]\n },\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"se:downloadsEnabled\": true,\n \"se:name\": \"test_visit_basic_auth_secured_page (ChromeTests)\",\n \"se:recordVideo\": true,\n \"se:screenResolution\": \"1920x1080\"\n}" + ] + } + } + } + `), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + enableManagedDownloads: true, + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: `Given_2_requests_with_explicit_name_version_platform_When_2_existing_node_with_platform_not_matching_And_scaler_metadata_with_browser_version_as_latest_Then_scaler_should_not_scale_up_and_return_0`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Windows 11\"}}]", + "sessions": [] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"firefox\", \"browserVersion\": \"130.0\", \"platformName\": \"Windows 11\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"firefox\", \"browserVersion\": \"130.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}" + ] + } + } + } + `), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "scaler_browserVersion_is_latest,_2_sessionQueueRequests_without_browserVersion,_2_available_nodeStereotypes_with_different_versions_and_platforms,_should_return_count_as_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"Windows 11\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "scaler_browserVersion_is_latest,_5_sessionQueueRequests_wihtout_browserVersion_also_1_different_platformName,_1_available_nodeStereotypes_with_3_slots_Linux_and_1_node_Windows,_should_return_count_as_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 6, + "totalSlots": 6 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 3, + "slotCount": 3, + "stereotypes": "[{\"slots\": 3, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 0, + "maxSession": 3, + "slotCount": 3, + "stereotypes": "[{\"slots\": 3, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"Windows 11\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "queue request with browserName browserVersion and browserVersion but no available nodes should return count as 1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"firefox\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"firefox\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"firefox\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "1 queue request with browserName browserVersion and browserVersion but 2 nodes without available slots should return count as 1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "2 session queue with matching browsername and browserversion of 2 available slots should return count as 0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "2 queue requests with browserName browserVersion and platformName matching 2 available slots on 2 different nodes should return count as 0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 2, + "slotCount": 2, + "stereotypes": "[{\"slots\": 2, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 2, + "slotCount": 2, + "stereotypes": "[{\"slots\": 2, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "1 queue request with browserName browserVersion and platformName matching 1 available slot on node has 3 max sessions should return count as 0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 3, + "totalSlots": 3 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 2, + "maxSession": 3, + "slotCount": 3, + "stereotypes": "[{\"slots\": 3, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + }, + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "3 queue requests with browserName browserVersion and platformName but 2 running nodes are busy should return count as 3", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 3, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "Given_3_requests_explicit_name_version_platform_When_2_existing_nodes_not_available_And_scaler_metadate_with_browserVersion_as_latest_Then_scaler_should_not_scale_up_and_return_2_on_going_session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"90.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"92.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"93.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "Given_3_requests_explicit_name_version_platform_When_2_existing_nodes_not_available_And_scaler_metadate_with_browserVersion_92.0_Then_scaler_should_scale_up_1_and_return_0_on_going_session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"90.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"92.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"93.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "92.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "3 queue requests with browserName and platformName but 2 running nodes are busy with different versions should return count as 3", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 3, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "1 queue request without platformName and scaler metadata without platformName should return 1 new node and 1 ongoing session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"platformName\": \"any\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"platformName\": \"any\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"any\"}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"any\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "1 active session with matching browsername and version should return count as 2", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "1_request_without_browserVersion_can_be_match_any_available_node_should_return_count_as_0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"v128.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "1 request without platformName and browserVersion should return count as 1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "2 queue requests with browserName in string match node stereotype and scaler metadata browserVersion should return count as 1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"msedge\", \"browserVersion\": \"dev\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"msedge\", \"browserVersion\": \"dev\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"msedge\", \"browserVersion\": \"dev\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"MicrosoftEdge\", \"browserVersion\": \"beta\", \"platformName\": \"linux\"}", + "{\"browserName\": \"MicrosoftEdge\", \"browserVersion\": \"dev\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "MicrosoftEdge", + sessionBrowserName: "msedge", + browserVersion: "dev", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "2 queue requests with matching browsername/sessionBrowserName but 1 node is busy should return count as 2", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"MicrosoftEdge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"MicrosoftEdge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "MicrosoftEdge", + sessionBrowserName: "msedge", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "2 queue requests with matching browsername/sessionBrowserName and 1 node is is available should return count as 1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"MicrosoftEdge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "{\"browserName\": \"MicrosoftEdge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "MicrosoftEdge", + sessionBrowserName: "msedge", + browserVersion: "91.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, { + name: "2_queue_requests_with_platformName_and_without_platformName_and_node_with_1_slot_available_should_return_count_as_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 2, + "slotCount": 2, + "stereotypes": "[{\"slots\": 2, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"Windows 11\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"Windows 11\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"Windows 11\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"Windows 11\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "Windows 11", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "1 active msedge session while asking for 2 chrome sessions should return a count of 2", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "3 queue requests browserName chrome platformName linux but 1 node has maxSessions=3 with browserName msedge should return a count of 3", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 3, + "totalSlots": 3 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 3, + "slotCount": 3, + "stereotypes": "[{\"slots\": 3, \"stereotype\": {\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"msedge\", \"browserVersion\": \"91.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 3, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "Given_2_requests_with_1_matching_browser_name_and_1_mismatch_platformName_When_no_node_available_Then_scaler_should_return_1_for_matching_request_and_0_ongoing_session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "maxSession": 0, + "nodeCount": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "2_queue_requests_with_1_matching_browsername_and_platformName_and_1_existing_slot_is_available_should_return_count_as_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"91.0\", \"platformName\": \"Windows 11\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "Windows 11", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "Given_2_requests_without_browserVersion_When_scaler_metadata_explicit_name_version_platform_Then_scaler_should_not_scale_up_and_return_1_on_going_session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "82ee33bd-390e-4dd6-aee2-06b17ecee18e", + "status": "UP", + "sessionCount": 2, + "maxSession": 2, + "slotCount": 2, + "stereotypes": "[\n {\n \"slots\": 2,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"browserVersion\": \"91.0\",\n \"browserPlatform\": \"Windows 11\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"se:containerName\": \"my-chrome-name-m5n8z-4br6x\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\", \"browserVersion\": \"91.0\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\", \"browserVersion\": \"91.0\"}" + } + }, + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\", \"browserVersion\": \"91.0\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\", \"browserVersion\": \"91.0\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"Windows 11\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "Windows 11", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "Given_5_requests_explicit_name_version_platform_When_scaler_metadata_set_browserVersion_as_latest_Then_scaler_should_not_scale_up_for_those_request_and_return_0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}" + ] + } + } + } + `), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 2, + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "Given_5_requests_explicit_name_version_platform_When_scaler_metadata_set_browserVersion_as_latest_Then_scaler_should_not_scale_up_for_those_requests_and_return_0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}", + "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"Linux\"}" + ] + } + } + } + `), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 3, + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "Given_5_requests_without_browserVersion_When_scaler_metadata_explicit_name_version_platform_Then_scaler_should_not_scaler_up_for_those_requests_and_return_0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 3, + "totalSlots": 3 + }, + "nodesInfo": { + "nodes": [ + { + "id": "82ee33bd-390e-4dd6-aee2-06b17ecee18e", + "status": "UP", + "sessionCount": 2, + "maxSession": 3, + "slotCount": 3, + "stereotypes": "[\n {\n \"slots\": 3,\n \"stereotype\": {\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\",\n \"browserVersion\": \"91.0\",\n \"goog:chromeOptions\": {\n \"binary\": \"\\u002fusr\\u002fbin\\u002fchromium\"\n },\n \"se:containerName\": \"my-chrome-name-m5n8z-4br6x\",\n \"se:downloadsEnabled\": true,\n \"se:noVncPort\": 7900,\n \"se:vncEnabled\": true\n }\n }\n]", + "sessions": [ + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\"browserName\": \"chrome\", \"platformName\": \"Linux\", \"browserVersion\": \"91.0\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"Linux\", \"browserVersion\": \"91.0\"}" + } + }, + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"91.0\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"platformName\": \"Linux\", \"browserVersion\": \"91.0\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0", + platformName: "linux", + nodeMaxSessions: 3, + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 2, + wantErr: false, + }, + // Tests from PR: https://github.com/kedacore/keda/pull/6055 + { + name: "sessions requests with matching browsername and platformName when setSessionsFromHub turned on and node with 1 slots matches should return count as 0", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "82ee33bd-390e-4dd6-aee2-06b17ecee18e", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes":"[{\"slots\":1,\"stereotype\":{\"browserName\":\"chrome\",\"platformName\":\"linux\"}}]", + "sessions": [] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "4 sessions requests with matching browsername and platformName when setSessionsFromHub turned on and node with 2 slots matches should return count as 2", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "82ee33bd-390e-4dd6-aee2-06b17ecee18e", + "status": "UP", + "sessionCount": 0, + "maxSession": 2, + "slotCount": 2, + "stereotypes":"[{\"slots\":2,\"stereotype\":{\"browserName\":\"chrome\",\"platformName\":\"linux\"}}]", + "sessions": [ + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "4 sessions requests with matching browsername and platformName when setSessionsFromHub turned on, no nodes and sessionsPerNode=2 matches should return count as 2", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 0, + "maxSession": 0, + "totalSlots": 0 + }, + "nodesInfo": { + "nodes": [] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 2, + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "4_sessions_requests_with_matching_browserName_and_platformName_when_set_nodeMaxSessions_2_and_4_requests_match_should_return_count_as_2_and_1_ongoing_session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 2, + "slotCount": 2, + "stereotypes": "[{\"slots\": 2, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"se:downloadsEnabled\": true}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"se:downloadsEnabled\": true}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"se:downloadsEnabled\": true}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 2, + enableManagedDownloads: true, + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "4_sessions_requests_with_matching_browserName_and_platformName_when_set_nodeMaxSessions_2_disable_managed_downloads_and_1_requests_match_should_return_count_as_1_and_0_ongoing_session", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"se:downloadsEnabled\": true}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"se:downloadsEnabled\": true}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"se:downloadsEnabled\": true}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 2, + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "4_sessions_requests_with_matching_browserName_and_platformName_when_set_extra_capabilities_and_2_requests_match_should_return_count_as_2_and_ongoing_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"myApp:scope\": \"internal\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"myApp:scope\": \"internal\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 1, + capabilities: "{\"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "4_sessions_requests_with_matching_browserName_and_platformName_when_set_extra_capabilities_and_mangaged_downloads_and_1_request_match_should_return_count_as_2_and_ongoing_1", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\", \"se:downloadsEnabled\": true}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\", \"se:downloadsEnabled\": true}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\", \"se:downloadsEnabled\": true}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"myApp:scope\": \"internal\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"se:downloadsEnabled\": true,\n \"myApp:version\": \"beta\",\n \"myApp:scope\": \"internal\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 1, + enableManagedDownloads: true, + capabilities: "{\"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}", + }, + wantNewRequestNodes: 2, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: "4_sessions_requests_with_matching_browserName_and_platformName_when_set_extra_capabilities_and_mangaged_downloads_and_4_request_match_should_return_count_as_4_and_ongoing_2", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 1, + "maxSession": 1, + "totalSlots": 1 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\", \"se:downloadsEnabled\": true}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\", \"se:downloadsEnabled\": true}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\", \"se:downloadsEnabled\": true}" + } + } + ] + }, + { + "id": "node-2", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\", \"myApp:version\": \"beta\", \"myApp:scope\": \"internal\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"myApp:scope\": \"internal\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"myApp:version\": \"beta\",\n \"myApp:scope\": \"internal\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"Windows 11\"\n}"] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + nodeMaxSessions: 1, + enableManagedDownloads: true, + }, + wantNewRequestNodes: 4, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: "Given_2_requests_include_1_without_browserVersion_When_scaler_metadata_explicit_name_version_platform_Then_scaler_should_scale_up_for_1_request_has_browserVersion_and_return_0_ongoing_sessions", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "d44dcbc5-0b2c-4d5e-abf4-6f6aa5e0983c", + "status": "UP", + "sessionCount": 2, + "maxSession": 2, + "slotCount": 2, + "stereotypes":"[{\"slots\":2,\"stereotype\":{\"browserName\":\"chrome\",\"platformName\":\"linux\"}}]", + "sessions": [ + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\n \"acceptInsecureCerts\": false,\n \"browserName\": \"chrome\",\n \"browserVersion\": \"91.0.4472.114\",\n \"chrome\": {\n \"chromedriverVersion\": \"91.0.4472.101 (af52a90bf87030dd1523486a1cd3ae25c5d76c9b-refs\\u002fbranch-heads\\u002f4472@{#1462})\",\n \"userDataDir\": \"\\u002ftmp\\u002f.com.google.Chrome.DMqx9m\"\n },\n \"goog:chromeOptions\": {\n \"debuggerAddress\": \"localhost:35839\"\n },\n \"networkConnectionEnabled\": false,\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"proxy\": {\n },\n \"se:cdp\": \"http:\\u002f\\u002flocalhost:35839\",\n \"se:cdpVersion\": \"91.0.4472.114\",\n \"se:vncEnabled\": true,\n \"se:vncLocalAddress\": \"ws:\\u002f\\u002flocalhost:7900\\u002fwebsockify\",\n \"setWindowRect\": true,\n \"strictFileInteractability\": false,\n \"timeouts\": {\n \"implicit\": 0,\n \"pageLoad\": 300000,\n \"script\": 30000\n },\n \"unhandledPromptBehavior\": \"dismiss and notify\",\n \"webauthn:extension:largeBlob\": true,\n \"webauthn:virtualAuthenticators\": true\n}", + "nodeId": "d44dcbc5-0b2c-4d5e-abf4-6f6aa5e0983c", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\":\"chrome\",\"platformName\":\"linux\"}" + } + }, + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\n \"acceptInsecureCerts\": false,\n \"browserName\": \"chrome\",\n \"browserVersion\": \"91.0.4472.114\",\n \"chrome\": {\n \"chromedriverVersion\": \"91.0.4472.101 (af52a90bf87030dd1523486a1cd3ae25c5d76c9b-refs\\u002fbranch-heads\\u002f4472@{#1462})\",\n \"userDataDir\": \"\\u002ftmp\\u002f.com.google.Chrome.DMqx9m\"\n },\n \"goog:chromeOptions\": {\n \"debuggerAddress\": \"localhost:35839\"\n },\n \"networkConnectionEnabled\": false,\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"proxy\": {\n },\n \"se:cdp\": \"http:\\u002f\\u002flocalhost:35839\",\n \"se:cdpVersion\": \"91.0.4472.114\",\n \"se:vncEnabled\": true,\n \"se:vncLocalAddress\": \"ws:\\u002f\\u002flocalhost:7900\\u002fwebsockify\",\n \"setWindowRect\": true,\n \"strictFileInteractability\": false,\n \"timeouts\": {\n \"implicit\": 0,\n \"pageLoad\": 300000,\n \"script\": 30000\n },\n \"unhandledPromptBehavior\": \"dismiss and notify\",\n \"webauthn:extension:largeBlob\": true,\n \"webauthn:virtualAuthenticators\": true\n}", + "nodeId": "d44dcbc5-0b2c-4d5e-abf4-6f6aa5e0983c", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\":\"chrome\",\"platformName\":\"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\",\n \"browserVersion\": \"91.0\"\n}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "91.0.4472.114", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: "Given_2_requests_include_1_without_browserVersion_When_scaler_metadata_without_browserVersion_Then_scaler_should_scale_up_for_1_request_has_browserVersion_and_return_2_ongoing_sessions", + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 2, + "maxSession": 2, + "totalSlots": 2 + }, + "nodesInfo": { + "nodes": [ + { + "id": "d44dcbc5-0b2c-4d5e-abf4-6f6aa5e0983c", + "status": "UP", + "sessionCount": 2, + "maxSession": 2, + "slotCount": 2, + "stereotypes":"[{\"slots\":2,\"stereotype\":{\"browserName\":\"chrome\",\"platformName\":\"linux\"}}]", + "sessions": [ + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\n \"acceptInsecureCerts\": false,\n \"browserName\": \"chrome\",\n \"browserVersion\": \"91.0.4472.114\",\n \"chrome\": {\n \"chromedriverVersion\": \"91.0.4472.101 (af52a90bf87030dd1523486a1cd3ae25c5d76c9b-refs\\u002fbranch-heads\\u002f4472@{#1462})\",\n \"userDataDir\": \"\\u002ftmp\\u002f.com.google.Chrome.DMqx9m\"\n },\n \"goog:chromeOptions\": {\n \"debuggerAddress\": \"localhost:35839\"\n },\n \"networkConnectionEnabled\": false,\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"proxy\": {\n },\n \"se:cdp\": \"http:\\u002f\\u002flocalhost:35839\",\n \"se:cdpVersion\": \"91.0.4472.114\",\n \"se:vncEnabled\": true,\n \"se:vncLocalAddress\": \"ws:\\u002f\\u002flocalhost:7900\\u002fwebsockify\",\n \"setWindowRect\": true,\n \"strictFileInteractability\": false,\n \"timeouts\": {\n \"implicit\": 0,\n \"pageLoad\": 300000,\n \"script\": 30000\n },\n \"unhandledPromptBehavior\": \"dismiss and notify\",\n \"webauthn:extension:largeBlob\": true,\n \"webauthn:virtualAuthenticators\": true\n}", + "nodeId": "d44dcbc5-0b2c-4d5e-abf4-6f6aa5e0983c", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\":\"chrome\",\"platformName\":\"linux\"}" + } + }, + { + "id": "0f9c5a941aa4d755a54b84be1f6535b1", + "capabilities": "{\n \"acceptInsecureCerts\": false,\n \"browserName\": \"chrome\",\n \"browserVersion\": \"91.0.4472.114\",\n \"chrome\": {\n \"chromedriverVersion\": \"91.0.4472.101 (af52a90bf87030dd1523486a1cd3ae25c5d76c9b-refs\\u002fbranch-heads\\u002f4472@{#1462})\",\n \"userDataDir\": \"\\u002ftmp\\u002f.com.google.Chrome.DMqx9m\"\n },\n \"goog:chromeOptions\": {\n \"debuggerAddress\": \"localhost:35839\"\n },\n \"networkConnectionEnabled\": false,\n \"pageLoadStrategy\": \"normal\",\n \"platformName\": \"linux\",\n \"proxy\": {\n },\n \"se:cdp\": \"http:\\u002f\\u002flocalhost:35839\",\n \"se:cdpVersion\": \"91.0.4472.114\",\n \"se:vncEnabled\": true,\n \"se:vncLocalAddress\": \"ws:\\u002f\\u002flocalhost:7900\\u002fwebsockify\",\n \"setWindowRect\": true,\n \"strictFileInteractability\": false,\n \"timeouts\": {\n \"implicit\": 0,\n \"pageLoad\": 300000,\n \"script\": 30000\n },\n \"unhandledPromptBehavior\": \"dismiss and notify\",\n \"webauthn:extension:largeBlob\": true,\n \"webauthn:virtualAuthenticators\": true\n}", + "nodeId": "d44dcbc5-0b2c-4d5e-abf4-6f6aa5e0983c", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\":\"chrome\",\"platformName\":\"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\"\n}", + "{\n \"browserName\": \"chrome\",\n \"platformName\": \"linux\",\n \"browserVersion\": \"91.0\"\n}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 2, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_ + And_scaler_metadata_set_browserVersion_as_latest_ + Then_return_1_new_scale_and_4_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 4, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_ + And_scaler_metadata_set_browserVersion_131.0_ + Then_return_1_new_scale_and_1_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 4, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "131.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_ + And_scaler_metadata_set_browserVersion_130.0_ + Then_return_1_new_scale_and_1_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 4, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-2", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "130.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_and_1_available_ + And_scaler_metadata_set_browserVersion_as_latest_ + Then_return_0_new_scale_and_0_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 3, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_and_1_available_ + And_scaler_metadata_set_browserVersion_131.0_ + Then_return_0_new_scale_and_0_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 3, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "131.0", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_and_1_available_ + And_scaler_metadata_set_browserVersion_130.0_ + Then_return_0_new_scale_and_0_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 3, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "130.0", + platformName: "linux", + }, + wantNewRequestNodes: 0, + wantOnGoingSessions: 0, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_5_existing_nodes_with_different_stereotypes_browserVersion_ + And_scaler_metadata_set_browserVersion_as_empty_ + Then_return_1_new_scale_and_1_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 4, + "maxSession": 5, + "totalSlots": 5 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-130", + "status": "UP", + "sessionCount": 0, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"130.0\", \"platformName\": \"linux\"}}]", + "sessions": [] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-any", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 1, + wantErr: false, + }, + { + name: `Given_3_requests_include_1_without_browserVersion_ + When_4_existing_nodes_with_different_stereotypes_browserVersion_ + And_scaler_metadata_set_browserVersion_130.0_ + Then_return_1_new_scale_and_0_ongoing`, + args: args{ + b: []byte(`{ + "data": { + "grid": { + "sessionCount": 4, + "maxSession": 4, + "totalSlots": 4 + }, + "nodesInfo": { + "nodes": [ + { + "id": "node-131", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"131.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-129", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"129.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-128", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}" + } + } + ] + }, + { + "id": "node-any", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\", \"browserVersion\": \"128.0\", \"platformName\": \"linux\"}", + "slot": { + "id": "9ce1edba-72fb-465e-b311-ee473d8d7b64", + "stereotype": "{\"browserName\": \"chrome\", \"browserVersion\": \"\", \"platformName\": \"linux\"}" + } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\", \"platformName\": \"linux\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"131\"}", + "{\"browserName\": \"chrome\", \"platformName\": \"linux\", \"browserVersion\": \"130\"}" + ] + } + } + }`), + browserName: "chrome", + sessionBrowserName: "chrome", + browserVersion: "130.0", + platformName: "linux", + }, + wantNewRequestNodes: 1, + wantOnGoingSessions: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newRequestNodes, onGoingSessions, err := getCountFromSeleniumResponse(tt.args.b, tt.args.browserName, tt.args.browserVersion, tt.args.sessionBrowserName, tt.args.platformName, tt.args.nodeMaxSessions, tt.args.enableManagedDownloads, tt.args.capabilities, logr.Discard()) + if (err != nil) != tt.wantErr { + t.Errorf("getCountFromSeleniumResponse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(newRequestNodes, tt.wantNewRequestNodes) || !reflect.DeepEqual(onGoingSessions, tt.wantOnGoingSessions) { + t.Errorf("getCountFromSeleniumResponse() = [%v, %v], want [%v, %v]", newRequestNodes, onGoingSessions, tt.wantNewRequestNodes, tt.wantOnGoingSessions) + } + }) + } +} diff --git a/.keda-external-scaler/internal/gridscaler/metadata.go b/.keda-external-scaler/internal/gridscaler/metadata.go new file mode 100644 index 0000000000..bfd4d8e0e2 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/metadata.go @@ -0,0 +1,148 @@ +package gridscaler + +import ( + "fmt" + "strconv" +) + +// Metadata is the typed form of the per-ScaledObject trigger metadata KEDA sends +// to the external scaler. It mirrors the field set of the built-in KEDA +// selenium-grid scaler so migration is mechanical. +type Metadata struct { + URL string + AuthType string + Username string + Password string + AccessToken string + BrowserName string + SessionBrowserName string + BrowserVersion string + PlatformName string + ActivationThreshold int64 + UnsafeSsl bool + NodeMaxSessions int64 + EnableManagedDownloads bool + Capabilities string + JobScalingStrategy string + + TargetValue int64 +} + +// envFallbacks maps a base metadata key to the server-side environment variable +// consulted when neither nor FromEnv is present in scalerMetadata. +// This exists because KEDA does not forward TriggerAuthentication authParams to +// external scalers, so Grid URL and credentials may instead be mounted into the +// scaler process environment. +var envFallbacks = map[string]string{ + "url": "SE_GRID_URL", + "authType": "SE_GRID_AUTH_TYPE", + "username": "SE_USERNAME", + "password": "SE_PASSWORD", + "accessToken": "SE_ACCESS_TOKEN", +} + +var validJobScalingStrategies = map[string]struct{}{ + "default": {}, + "custom": {}, + "accurate": {}, + "eager": {}, +} + +// parseMetadata builds a Metadata from the scalerMetadata map KEDA sends and the +// scaler's own environment (env). Per-key resolution precedence is: +// +// scalerMetadata[] > scalerMetadata[FromEnv] > env[] +// +// KEDA resolves *FromEnv keys against the scale target's container environment +// before sending, storing the resolved value under the original *FromEnv key, so +// scalerMetadata["usernameFromEnv"] already holds the secret value here. +func parseMetadata(scalerMetadata map[string]string, env map[string]string) (*Metadata, error) { + if scalerMetadata == nil { + scalerMetadata = map[string]string{} + } + if env == nil { + env = map[string]string{} + } + + lookup := func(key string) string { + if v, ok := scalerMetadata[key]; ok { + return v + } + if v, ok := scalerMetadata[key+"FromEnv"]; ok { + return v + } + if envKey, ok := envFallbacks[key]; ok { + if v := env[envKey]; v != "" { + return v + } + } + return "" + } + + meta := &Metadata{ + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + } + + meta.URL = lookup("url") + if meta.URL == "" { + return nil, fmt.Errorf("no url given in trigger metadata, FromEnv, or scaler environment (%s)", envFallbacks["url"]) + } + + meta.AuthType = lookup("authType") + meta.Username = lookup("username") + meta.Password = lookup("password") + meta.AccessToken = lookup("accessToken") + meta.BrowserName = lookup("browserName") + meta.SessionBrowserName = lookup("sessionBrowserName") + meta.BrowserVersion = lookup("browserVersion") + meta.PlatformName = lookup("platformName") + meta.Capabilities = lookup("capabilities") + + if v := lookup("activationThreshold"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing activationThreshold: %w", err) + } + meta.ActivationThreshold = n + } + + if v := lookup("nodeMaxSessions"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("parsing nodeMaxSessions: %w", err) + } + meta.NodeMaxSessions = n + } + + if v := lookup("unsafeSsl"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("parsing unsafeSsl: %w", err) + } + meta.UnsafeSsl = b + } + + if v := lookup("enableManagedDownloads"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("parsing enableManagedDownloads: %w", err) + } + meta.EnableManagedDownloads = b + } + + if v := lookup("jobScalingStrategy"); v != "" { + if _, ok := validJobScalingStrategies[v]; !ok { + return nil, fmt.Errorf("invalid jobScalingStrategy %q, must be one of default, custom, accurate, eager", v) + } + meta.JobScalingStrategy = v + } + + if meta.SessionBrowserName == "" { + meta.SessionBrowserName = meta.BrowserName + } + + return meta, nil +} diff --git a/.keda-external-scaler/internal/gridscaler/metadata_test.go b/.keda-external-scaler/internal/gridscaler/metadata_test.go new file mode 100644 index 0000000000..ee25e895b0 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/metadata_test.go @@ -0,0 +1,342 @@ +package gridscaler + +import ( + "reflect" + "testing" +) + +// These cases re-express KEDA's Test_parseSeleniumGridScalerMetadata against the +// external scaler's input model. KEDA's built-in scaler receives url/credentials +// via TriggerAuthentication authParams; an external scaler cannot (KEDA does not +// forward authParams over gRPC), so the equivalent inputs arrive as trigger +// metadata, *FromEnv metadata, or the scaler's own environment. Behaviour under +// test — defaults, required url, enum validation, sessionBrowserName fallback — is +// identical to the built-in scaler. +func Test_parseMetadata(t *testing.T) { + tests := []struct { + name string + meta map[string]string + env map[string]string + want *Metadata + wantErr bool + }{ + { + name: "missing url should throw error", + meta: map[string]string{}, + wantErr: true, + }, + { + name: "empty url should throw error", + meta: map[string]string{"url": ""}, + wantErr: true, + }, + { + name: "valid url and browsername should return metadata", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "sessionBrowserName overrides the browserName-derived default", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "MicrosoftEdge", + "sessionBrowserName": "msedge", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "MicrosoftEdge", + SessionBrowserName: "msedge", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "empty browserName is allowed", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "", + SessionBrowserName: "", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "username and password from trigger metadata", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "MicrosoftEdge", + "sessionBrowserName": "msedge", + "username": "username", + "password": "password", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "MicrosoftEdge", + SessionBrowserName: "msedge", + Username: "username", + Password: "password", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "valid capabilities should return metadata", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "MicrosoftEdge", + "sessionBrowserName": "msedge", + "enableManagedDownloads": "true", + "capabilities": "{\"myApp:version\": \"beta\"}", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "MicrosoftEdge", + SessionBrowserName: "msedge", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + Capabilities: "{\"myApp:version\": \"beta\"}", + JobScalingStrategy: "default", + }, + }, + { + name: "browserVersion and unsafeSsl false", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "browserVersion": "91.0", + "unsafeSsl": "false", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "chrome", + SessionBrowserName: "chrome", + BrowserVersion: "91.0", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "unsafeSsl and activationThreshold", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "browserVersion": "91.0", + "unsafeSsl": "true", + "activationThreshold": "10", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "chrome", + SessionBrowserName: "chrome", + BrowserVersion: "91.0", + UnsafeSsl: true, + ActivationThreshold: 10, + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "invalid activationThreshold should throw an error", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "activationThreshold": "AA", + }, + wantErr: true, + }, + { + name: "platformName, nodeMaxSessions and auth", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "browserVersion": "91.0", + "unsafeSsl": "true", + "activationThreshold": "10", + "platformName": "Windows 11", + "nodeMaxSessions": "3", + "username": "user", + "password": "password", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + Username: "user", + Password: "password", + BrowserName: "chrome", + SessionBrowserName: "chrome", + BrowserVersion: "91.0", + UnsafeSsl: true, + ActivationThreshold: 10, + PlatformName: "Windows 11", + NodeMaxSessions: 3, + TargetValue: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "non-Basic auth type with access token", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "authType": "OAuth2", + "accessToken": "my-access-token", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + AuthType: "OAuth2", + AccessToken: "my-access-token", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "invalid jobScalingStrategy should throw an error", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "jobScalingStrategy": "bogus", + }, + wantErr: true, + }, + { + name: "accurate jobScalingStrategy is accepted", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "jobScalingStrategy": "accurate", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "accurate", + }, + }, + { + name: "enableManagedDownloads can be disabled", + meta: map[string]string{ + "url": "http://selenium-hub:4444/graphql", + "browserName": "chrome", + "enableManagedDownloads": "false", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: false, + JobScalingStrategy: "default", + }, + }, + // External-scaler-specific: *FromEnv and server-env fallback resolution. + { + name: "url and credentials resolved from *FromEnv metadata (KEDA-resolved values)", + meta: map[string]string{ + "urlFromEnv": "http://selenium-hub:4444/graphql", + "usernameFromEnv": "user", + "passwordFromEnv": "password", + "browserName": "chrome", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + Username: "user", + Password: "password", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "url and credentials fall back to scaler environment", + meta: map[string]string{"browserName": "chrome"}, + env: map[string]string{ + "SE_GRID_URL": "http://selenium-hub:4444/graphql", + "SE_USERNAME": "envuser", + "SE_PASSWORD": "envpass", + }, + want: &Metadata{ + URL: "http://selenium-hub:4444/graphql", + Username: "envuser", + Password: "envpass", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + { + name: "trigger metadata takes precedence over environment fallback", + meta: map[string]string{ + "url": "http://from-metadata:4444/graphql", + "browserName": "chrome", + }, + env: map[string]string{ + "SE_GRID_URL": "http://from-env:4444/graphql", + }, + want: &Metadata{ + URL: "http://from-metadata:4444/graphql", + BrowserName: "chrome", + SessionBrowserName: "chrome", + TargetValue: 1, + NodeMaxSessions: 1, + EnableManagedDownloads: true, + JobScalingStrategy: "default", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMetadata(tt.meta, tt.env) + if (err != nil) != tt.wantErr { + t.Errorf("parseMetadata() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseMetadata() = %+v, want %+v", got, tt.want) + } + }) + } +} diff --git a/.keda-external-scaler/internal/gridscaler/platform.go b/.keda-external-scaler/internal/gridscaler/platform.go new file mode 100644 index 0000000000..644575b24f --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/platform.go @@ -0,0 +1,112 @@ +package gridscaler + +import "strings" + +type Platform struct { + name string + family *Platform +} + +// Mapping of platform name enum used in the Selenium Grid core +// https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/Platform.java +var ( + Windows = Platform{"windows", nil} + XP = Platform{"Windows XP", &Windows} + Vista = Platform{"Windows Vista", &Windows} + Win7 = Platform{"Windows 7", &Windows} + Win8 = Platform{"Windows 8", &Windows} + Win8_1 = Platform{"Windows 8.1", &Windows} + Win10 = Platform{"Windows 10", &Windows} + Win11 = Platform{"Windows 11", &Windows} + Mac = Platform{"mac", nil} + SnowLeopard = Platform{"OS X 10.6", &Mac} + MountainLion = Platform{"OS X 10.8", &Mac} + Mavericks = Platform{"OS X 10.9", &Mac} + Yosemite = Platform{"OS X 10.10", &Mac} + ElCapitan = Platform{"OS X 10.11", &Mac} + Sierra = Platform{"macOS 10.12", &Mac} + HighSierra = Platform{"macOS 10.13", &Mac} + Mojave = Platform{"macOS 10.14", &Mac} + Catalina = Platform{"macOS 10.15", &Mac} + BigSur = Platform{"macOS 11.0", &Mac} + Monterey = Platform{"macOS 12.0", &Mac} + Ventura = Platform{"macOS 13.0", &Mac} + Sonoma = Platform{"macOS 14.0", &Mac} + Sequoia = Platform{"macOS 15.0", &Mac} + Unix = Platform{"unix", nil} + Linux = Platform{"linux", &Unix} + Bsd = Platform{"bsd", &Unix} + Solaris = Platform{"solaris", &Unix} + Android = Platform{"android", nil} + IOS = Platform{"iOS", nil} + Any = Platform{"any", nil} +) + +func isSameFamily(p1, p2 Platform) bool { + return p1.family != nil && p2.family != nil && p1.family == p2.family +} + +func GetPlatform(input string) Platform { + switch strings.ToLower(input) { + case "windows": + return Windows + case "windows server 2003", "xp", "winnt", "windows_nt", "windows nt": + return XP + case "windows server 2008", "windows vista": + return Vista + case "windows 7", "win7": + return Win7 + case "windows server 2012", "windows 8", "win8": + return Win8 + case "windows 8.1", "win8.1": + return Win8_1 + case "windows 10", "win10": + return Win10 + case "windows 11", "win11": + return Win11 + case "mac", "darwin", "macos", "mac os x", "os x": + return Mac + case "os x 10.6", "macos 10.6", "snow leopard": + return SnowLeopard + case "os x 10.8", "macos 10.8", "mountain lion": + return MountainLion + case "os x 10.9", "macos 10.9", "mavericks": + return Mavericks + case "os x 10.10", "macos 10.10", "yosemite": + return Yosemite + case "os x 10.11", "macos 10.11", "el capitan": + return ElCapitan + case "os x 10.12", "macos 10.12", "sierra": + return Sierra + case "os x 10.13", "macos 10.13", "high sierra": + return HighSierra + case "os x 10.14", "macos 10.14", "mojave": + return Mojave + case "os x 10.15", "macos 10.15", "catalina": + return Catalina + case "os x 11.0", "macos 11.0", "big sur": + return BigSur + case "os x 12.0", "macos 12.0", "monterey": + return Monterey + case "os x 13.0", "macos 13.0", "ventura": + return Ventura + case "os x 14.0", "macos 14.0", "sonoma": + return Sonoma + case "os x 15.0", "macos 15.0", "sequoia": + return Sequoia + case "linux": + return Linux + case "bsd": + return Bsd + case "solaris": + return Solaris + case "android", "dalvik": + return Android + case "ios": + return IOS + case "any", "": + return Any + default: + return Platform{strings.ToLower(input), nil} + } +} diff --git a/.keda-external-scaler/internal/gridscaler/server.go b/.keda-external-scaler/internal/gridscaler/server.go new file mode 100644 index 0000000000..e8aa407d34 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/server.go @@ -0,0 +1,129 @@ +package gridscaler + +import ( + "context" + "strings" + + "github.com/go-logr/logr" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + pb "github.com/SeleniumHQ/docker-selenium/keda-external-scaler/externalscaler" +) + +// Server implements KEDA's externalscaler.ExternalScaler gRPC service for +// Selenium Grid. It is stateless per request: every RPC re-parses the trigger +// metadata KEDA sends and (for metric/active RPCs) re-queries the Grid. +type Server struct { + pb.UnimplementedExternalScalerServer + + grid *GridClient + env map[string]string + logger logr.Logger +} + +// NewServer returns a Server that queries the Grid via grid and resolves missing +// url/credentials from env (see parseMetadata precedence). +func NewServer(grid *GridClient, env map[string]string, logger logr.Logger) *Server { + return &Server{grid: grid, env: env, logger: logger} +} + +// GetMetricSpec returns the HPA metric spec: a deterministic metric name and the +// target value (1). KEDA adds/strips the trigger-index prefix itself. +func (s *Server) GetMetricSpec(_ context.Context, ref *pb.ScaledObjectRef) (*pb.GetMetricSpecResponse, error) { + meta, err := parseMetadata(ref.GetScalerMetadata(), s.env) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parsing scaler metadata: %s", err) + } + return &pb.GetMetricSpecResponse{ + MetricSpecs: []*pb.MetricSpec{{ + MetricName: buildMetricName(meta), + TargetSize: meta.TargetValue, + }}, + }, nil +} + +// GetMetrics returns the number of Nodes the Grid needs for this trigger. +func (s *Server) GetMetrics(ctx context.Context, req *pb.GetMetricsRequest) (*pb.GetMetricsResponse, error) { + meta, err := parseMetadata(req.GetScaledObjectRef().GetScalerMetadata(), s.env) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parsing scaler metadata: %s", err) + } + count, err := s.scrapeAndCount(ctx, meta) + if err != nil { + return nil, status.Errorf(codes.Internal, "querying selenium grid: %s", err) + } + return &pb.GetMetricsResponse{ + MetricValues: []*pb.MetricValue{{ + MetricName: req.GetMetricName(), + MetricValue: count, + }}, + }, nil +} + +// IsActive reports whether the trigger's node count exceeds activationThreshold. +func (s *Server) IsActive(ctx context.Context, ref *pb.ScaledObjectRef) (*pb.IsActiveResponse, error) { + meta, err := parseMetadata(ref.GetScalerMetadata(), s.env) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parsing scaler metadata: %s", err) + } + count, err := s.scrapeAndCount(ctx, meta) + if err != nil { + return nil, status.Errorf(codes.Internal, "querying selenium grid: %s", err) + } + return &pb.IsActiveResponse{Result: count > meta.ActivationThreshold}, nil +} + +// StreamIsActive is the push-scaler entrypoint. The Grid exposes no push signal, +// so only the polling `external` trigger type is supported. +func (s *Server) StreamIsActive(*pb.ScaledObjectRef, grpc.ServerStreamingServer[pb.IsActiveResponse]) error { + return status.Error(codes.Unimplemented, "StreamIsActive is not supported; use KEDA trigger type 'external', not 'external-push'") +} + +// scrapeAndCount queries the Grid and computes the node count for meta, applying +// the jobScalingStrategy convention: default/custom (and ScaledObjects) report +// queued requests plus on-going sessions; accurate/eager report queued requests +// only, to avoid double-counting in-progress work (SeleniumHQ/docker-selenium#3167). +func (s *Server) scrapeAndCount(ctx context.Context, meta *Metadata) (int64, error) { + b, err := s.grid.Query(ctx, meta) + if err != nil { + return 0, err + } + newRequestNodes, onGoingSessions, err := getCountFromSeleniumResponse( + b, meta.BrowserName, meta.BrowserVersion, meta.SessionBrowserName, meta.PlatformName, + meta.NodeMaxSessions, meta.EnableManagedDownloads, meta.Capabilities, s.logger) + if err != nil { + return 0, err + } + count := newRequestNodes + onGoingSessions + switch meta.JobScalingStrategy { + case "accurate", "eager": + count = newRequestNodes + } + return count, nil +} + +// buildMetricName mirrors the built-in scaler's naming so HPA metric identity is +// preserved across migration: selenium-grid[-browser][-version][-platform], +// normalized to an HPA-safe string. +func buildMetricName(meta *Metadata) string { + nameParts := []string{"selenium-grid"} + if meta.BrowserName != "" { + nameParts = append(nameParts, meta.BrowserName) + } + if meta.BrowserVersion != "" { + nameParts = append(nameParts, meta.BrowserVersion) + } + if meta.PlatformName != "" { + nameParts = append(nameParts, meta.PlatformName) + } + return normalizeString(strings.Join(nameParts, "-")) +} + +// normalizeString replaces slashes, dots, colons, percent signs and parentheses +// with dashes, matching kedautil.NormalizeString. +func normalizeString(s string) string { + replacer := strings.NewReplacer("/", "-", ".", "-", ":", "-", "%", "-", "(", "-", ")", "-") + return replacer.Replace(s) +} diff --git a/.keda-external-scaler/internal/gridscaler/server_test.go b/.keda-external-scaler/internal/gridscaler/server_test.go new file mode 100644 index 0000000000..0171a595a1 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/server_test.go @@ -0,0 +1,220 @@ +package gridscaler + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-logr/logr" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + pb "github.com/SeleniumHQ/docker-selenium/keda-external-scaler/externalscaler" +) + +// newTestClient starts the Server over an in-process bufconn gRPC connection and +// returns a client plus cleanup. env is the scaler-side environment fallback. +func newTestClient(t *testing.T, env map[string]string) pb.ExternalScalerClient { + t.Helper() + + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + pb.RegisterExternalScalerServer(srv, NewServer(NewGridClient(3*time.Second), env, logr.Discard())) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient() error = %v", err) + } + + t.Cleanup(func() { + _ = conn.Close() + srv.Stop() + _ = lis.Close() + }) + return pb.NewExternalScalerClient(conn) +} + +// fixtureGrid: 2 queued chrome requests + 1 on-going chrome session on a fully +// occupied Node → newRequestNodes=2, onGoingSessions=1. Same fixture as KEDA's +// Test_GetMetricsAndActivity_JobScalingStrategy. +const fixtureGrid = `{ + "data": { + "grid": { "sessionCount": 1, "maxSession": 1, "totalSlots": 1 }, + "nodesInfo": { + "nodes": [ + { + "id": "node-1", + "status": "UP", + "sessionCount": 1, + "maxSession": 1, + "slotCount": 1, + "stereotypes": "[{\"slots\": 1, \"stereotype\": {\"browserName\": \"chrome\"}}]", + "sessions": [ + { + "id": "session-1", + "capabilities": "{\"browserName\": \"chrome\"}", + "slot": { "id": "slot-1", "stereotype": "{\"browserName\": \"chrome\"}" } + } + ] + } + ] + }, + "sessionsInfo": { + "sessionQueueRequests": [ + "{\"browserName\": \"chrome\"}", + "{\"browserName\": \"chrome\"}" + ] + } + } +}` + +func fakeGrid(t *testing.T, body string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + return server +} + +// Ported from KEDA Test_GetMetricsAndActivity_JobScalingStrategy: expected metric +// values are unchanged. default/custom include on-going sessions (3); +// accurate/eager exclude them (2). +func TestServer_GetMetrics_JobScalingStrategy(t *testing.T) { + grid := fakeGrid(t, fixtureGrid) + client := newTestClient(t, nil) + + tests := []struct { + name string + jobScalingStrategy string + wantMetric int64 + }{ + {"default strategy includes on-going sessions", "default", 3}, + {"custom strategy includes on-going sessions", "custom", 3}, + {"accurate strategy excludes on-going sessions", "accurate", 2}, + {"eager strategy excludes on-going sessions", "eager", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref := &pb.ScaledObjectRef{ScalerMetadata: map[string]string{ + "url": grid.URL, + "browserName": "chrome", + "jobScalingStrategy": tt.jobScalingStrategy, + }} + + metrics, err := client.GetMetrics(context.Background(), &pb.GetMetricsRequest{ + ScaledObjectRef: ref, + MetricName: "selenium-grid-chrome", + }) + if err != nil { + t.Fatalf("GetMetrics() error = %v", err) + } + if got := metrics.MetricValues[0].MetricValue; got != tt.wantMetric { + t.Errorf("GetMetrics() = %d, want %d", got, tt.wantMetric) + } + + active, err := client.IsActive(context.Background(), ref) + if err != nil { + t.Fatalf("IsActive() error = %v", err) + } + if !active.Result { + t.Error("IsActive() = false, want true") + } + }) + } +} + +func TestServer_GetMetricSpec(t *testing.T) { + client := newTestClient(t, nil) + + tests := []struct { + name string + meta map[string]string + want string + }{ + {"browser only", map[string]string{"url": "http://g", "browserName": "chrome"}, "selenium-grid-chrome"}, + { + "browser, version, platform normalized", + map[string]string{"url": "http://g", "browserName": "chrome", "browserVersion": "131.0", "platformName": "linux"}, + "selenium-grid-chrome-131-0-linux", + }, + {"no browser", map[string]string{"url": "http://g"}, "selenium-grid"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := client.GetMetricSpec(context.Background(), &pb.ScaledObjectRef{ScalerMetadata: tt.meta}) + if err != nil { + t.Fatalf("GetMetricSpec() error = %v", err) + } + if got := resp.MetricSpecs[0].MetricName; got != tt.want { + t.Errorf("metric name = %q, want %q", got, tt.want) + } + if got := resp.MetricSpecs[0].TargetSize; got != 1 { + t.Errorf("targetSize = %d, want 1", got) + } + }) + } +} + +func TestServer_InvalidMetadata(t *testing.T) { + client := newTestClient(t, nil) + // Missing url and no env fallback → InvalidArgument. + _, err := client.GetMetricSpec(context.Background(), &pb.ScaledObjectRef{ScalerMetadata: map[string]string{"browserName": "chrome"}}) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("GetMetricSpec() code = %v, want InvalidArgument", status.Code(err)) + } +} + +func TestServer_GridUnreachable(t *testing.T) { + client := newTestClient(t, nil) + // Reserved TEST-NET-1 address with a closed port → connection error → Internal. + _, err := client.GetMetrics(context.Background(), &pb.GetMetricsRequest{ + ScaledObjectRef: &pb.ScaledObjectRef{ScalerMetadata: map[string]string{"url": "http://192.0.2.1:4444/graphql", "browserName": "chrome"}}, + MetricName: "selenium-grid-chrome", + }) + if status.Code(err) != codes.Internal { + t.Errorf("GetMetrics() code = %v, want Internal", status.Code(err)) + } +} + +func TestServer_StreamIsActive_Unimplemented(t *testing.T) { + client := newTestClient(t, nil) + stream, err := client.StreamIsActive(context.Background(), &pb.ScaledObjectRef{ScalerMetadata: map[string]string{"url": "http://g", "browserName": "chrome"}}) + if err != nil { + t.Fatalf("StreamIsActive() setup error = %v", err) + } + _, err = stream.Recv() + if status.Code(err) != codes.Unimplemented { + t.Errorf("StreamIsActive() code = %v, want Unimplemented", status.Code(err)) + } +} + +func TestServer_EnvFallback(t *testing.T) { + grid := fakeGrid(t, fixtureGrid) + // url comes from the scaler environment, not trigger metadata. + client := newTestClient(t, map[string]string{"SE_GRID_URL": grid.URL}) + metrics, err := client.GetMetrics(context.Background(), &pb.GetMetricsRequest{ + ScaledObjectRef: &pb.ScaledObjectRef{ScalerMetadata: map[string]string{"browserName": "chrome"}}, + MetricName: "selenium-grid-chrome", + }) + if err != nil { + t.Fatalf("GetMetrics() error = %v", err) + } + if got := metrics.MetricValues[0].MetricValue; got != 3 { + t.Errorf("GetMetrics() = %d, want 3", got) + } +} diff --git a/.keda-external-scaler/internal/gridscaler/types.go b/.keda-external-scaler/internal/gridscaler/types.go new file mode 100644 index 0000000000..646ed734f0 --- /dev/null +++ b/.keda-external-scaler/internal/gridscaler/types.go @@ -0,0 +1,62 @@ +package gridscaler + +type SeleniumResponse struct { + Data Data `json:"data"` +} + +type Data struct { + Grid Grid `json:"grid"` + NodesInfo NodesInfo `json:"nodesInfo"` + SessionsInfo SessionsInfo `json:"sessionsInfo"` +} + +type Grid struct { + SessionCount int64 `json:"sessionCount"` + MaxSession int64 `json:"maxSession"` + TotalSlots int64 `json:"totalSlots"` +} + +type NodesInfo struct { + Nodes Nodes `json:"nodes"` +} + +type SessionsInfo struct { + SessionQueueRequests []string `json:"sessionQueueRequests"` +} + +type Nodes []struct { + ID string `json:"id"` + Status string `json:"status"` + SessionCount int64 `json:"sessionCount"` + MaxSession int64 `json:"maxSession"` + SlotCount int64 `json:"slotCount"` + Stereotypes string `json:"stereotypes"` + Sessions Sessions `json:"sessions"` +} + +type ReservedNodes struct { + ID string `json:"id"` + MaxSession int64 `json:"maxSession"` + SlotCount int64 `json:"slotCount"` +} + +type Sessions []struct { + ID string `json:"id"` + Capabilities string `json:"capabilities"` + Slot Slot `json:"slot"` +} + +type Slot struct { + ID string `json:"id"` + Stereotype string `json:"stereotype"` +} + +type Stereotypes []struct { + Slots int64 `json:"slots"` + Stereotype map[string]interface{} `json:"stereotype"` +} + +const EnableManagedDownloadsCapability = "se:downloadsEnabled" + +var ExtensionCapabilitiesPrefixes = []string{"goog:", "moz:", "ms:", "se:"} +var FunctionCapabilitiesPrefixes = []string{EnableManagedDownloadsCapability} diff --git a/.keda-external-scaler/proto/externalscaler.proto b/.keda-external-scaler/proto/externalscaler.proto new file mode 100644 index 0000000000..eb583c4321 --- /dev/null +++ b/.keda-external-scaler/proto/externalscaler.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package externalscaler; +option go_package = ".;externalscaler"; + +service ExternalScaler { + rpc IsActive(ScaledObjectRef) returns (IsActiveResponse) {} + rpc StreamIsActive(ScaledObjectRef) returns (stream IsActiveResponse) {} + rpc GetMetricSpec(ScaledObjectRef) returns (GetMetricSpecResponse) {} + rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {} +} + +message ScaledObjectRef { + string name = 1; + string namespace = 2; + map scalerMetadata = 3; +} + +message IsActiveResponse { + bool result = 1; +} + +message GetMetricSpecResponse { + repeated MetricSpec metricSpecs = 1; +} + +message MetricSpec { + string metricName = 1; + + // deprecated, use targetSizeFloat instead + int64 targetSize = 2; + double targetSizeFloat = 3; +} + +message GetMetricsRequest { + ScaledObjectRef scaledObjectRef = 1; + string metricName = 2; +} + +message GetMetricsResponse { + repeated MetricValue metricValues = 1; +} + +message MetricValue { + string metricName = 1; + + // deprecated, use metricValueFloat instead + int64 metricValue = 2; + + double metricValueFloat = 3; +} diff --git a/.keda-external-scaler/tasks/plan.md b/.keda-external-scaler/tasks/plan.md new file mode 100644 index 0000000000..b5dd523872 --- /dev/null +++ b/.keda-external-scaler/tasks/plan.md @@ -0,0 +1,10 @@ +# Plan pointer + +The authoritative, human-approved implementation plan lives at: + +- `~/.claude/plans/steady-snuggling-whistle.md` (approved) +- `../SPEC.md` — full specification (six core areas, contract, boundaries) +- `../PLAN.md` — component design, risks, checkpoints +- `../TASKS.md` — task cards with acceptance/verify + +This directory's `todo.md` is the live status checklist. diff --git a/.keda-external-scaler/tasks/todo.md b/.keda-external-scaler/tasks/todo.md new file mode 100644 index 0000000000..aa923c29f9 --- /dev/null +++ b/.keda-external-scaler/tasks/todo.md @@ -0,0 +1,18 @@ +# TODO — Selenium Grid KEDA External Scaler + +Live checklist. Authoritative plan: `../../.claude/plans/steady-snuggling-whistle.md` +(approved) and `../PLAN.md`. Gates marked with ★. + +- [x] T1 — Module scaffold + generated gRPC stubs +- [x] T2 — Port pure scaling logic (types/logic/platform) +- [x] T3 — Port upstream test table ★ GATE 1 (52 getCount cases verbatim; 4 strategy cases in T6) +- [x] T4 — Metadata parser with env fallback +- [x] T5 — Grid GraphQL client +- [x] T6 — gRPC server implementation ★ GATE 2 (incl. ported strategy cases) +- [x] T7 — Binary entrypoint (TLS/health/shutdown) +- [x] T8 — Container image ★ GATE 3 (multi-arch) +- [x] T9 — Standalone deploy manifests + module README +- [x] T10 — Helm chart wiring ★ GATE 4 +- [x] T11 — Chart docs + migration notes + +All tasks complete. diff --git a/Makefile b/Makefile index 3d0ae36b51..a740a22028 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,8 @@ all: hub \ standalone_docker \ standalone_kubernetes \ standalone_all_browsers \ - video + video \ + keda_external_scaler check_dev_env: ./tests/charts/make/chart_check_env.sh @@ -398,6 +399,13 @@ fetch_grid_scaler_images: docker pull --platform linux/amd64 --platform linux/arm64 $(KEDA_BASED_NAME)/keda-metrics-apiserver:$(KEDA_BASED_TAG) docker pull --platform linux/amd64 --platform linux/arm64 $(KEDA_BASED_NAME)/keda-admission-webhooks:$(KEDA_BASED_TAG) +# Build the standalone Selenium Grid KEDA external scaler image, following the same +# build/tag/release convention as the other grid components (e.g. Hub). Tagged with the +# Selenium Grid version ($(TAG_VERSION)) and released via the standard release* targets. +keda_external_scaler: + cd ./.keda-external-scaler && docker buildx build --platform $(PLATFORMS) $(BUILD_ARGS) $(FROM_IMAGE_ARGS) \ + -t $(NAME)/keda-external-scaler:$(TAG_VERSION) . + release_grid_scaler: fetch_grid_scaler_images docker buildx imagetools create -t $(NAME)/keda:$(KEDA_TAG_VERSION)-$(BUILD_DATE) $(KEDA_BASED_NAME)/keda:$(KEDA_BASED_TAG) docker buildx imagetools create -t $(NAME)/keda-metrics-apiserver:$(KEDA_TAG_VERSION)-$(BUILD_DATE) $(KEDA_BASED_NAME)/keda-metrics-apiserver:$(KEDA_BASED_TAG) @@ -530,6 +538,7 @@ tag_latest: docker tag $(NAME)/standalone-kubernetes:$(TAG_VERSION) $(NAME)/standalone-kubernetes:latest docker tag $(NAME)/standalone-all-browsers:$(TAG_VERSION) $(NAME)/standalone-all-browsers:latest docker tag $(NAME)/video:$(FFMPEG_TAG_VERSION)-$(BUILD_DATE) $(NAME)/video:latest + docker tag $(NAME)/keda-external-scaler:$(TAG_VERSION) $(NAME)/keda-external-scaler:latest case "$(PLATFORMS)" in *linux/amd64*) \ docker tag $(NAME)/node-chrome:$(TAG_VERSION) $(NAME)/node-chrome:latest && \ docker tag $(NAME)/node-chrome-for-testing:$(TAG_VERSION) $(NAME)/node-chrome-for-testing:latest && \ @@ -557,6 +566,7 @@ release_ffmpeg_ghcr_latest: release_latest: docker push $(NAME)/base:latest docker push $(NAME)/hub:latest + docker push $(NAME)/keda-external-scaler:latest docker push $(NAME)/distributor:latest docker push $(NAME)/router:latest docker push $(NAME)/sessions:latest @@ -587,7 +597,7 @@ release_ghcr_latest: node-firefox node-docker node-kubernetes node-all-browsers \ standalone-chrome standalone-chromium standalone-chrome-for-testing \ standalone-edge standalone-firefox standalone-docker \ - standalone-kubernetes standalone-all-browsers video; do \ + standalone-kubernetes standalone-all-browsers keda-external-scaler video; do \ docker buildx imagetools create \ --tag $(GHCR_NAMESPACE)/$$image:latest docker.io/$(NAME)/$$image:latest ; \ done @@ -598,6 +608,7 @@ generate_latest_sbom: tag_nightly: docker tag $(NAME)/base:$(TAG_VERSION) $(NAME)/base:nightly docker tag $(NAME)/hub:$(TAG_VERSION) $(NAME)/hub:nightly + docker tag $(NAME)/keda-external-scaler:$(TAG_VERSION) $(NAME)/keda-external-scaler:nightly docker tag $(NAME)/distributor:$(TAG_VERSION) $(NAME)/distributor:nightly docker tag $(NAME)/router:$(TAG_VERSION) $(NAME)/router:nightly docker tag $(NAME)/sessions:$(TAG_VERSION) $(NAME)/sessions:nightly @@ -631,6 +642,7 @@ tag_nightly: release_nightly: docker push $(NAME)/base:nightly docker push $(NAME)/hub:nightly + docker push $(NAME)/keda-external-scaler:nightly docker push $(NAME)/distributor:nightly docker push $(NAME)/router:nightly docker push $(NAME)/sessions:nightly @@ -661,7 +673,7 @@ release_ghcr_nightly: node-firefox node-docker node-kubernetes node-all-browsers \ standalone-chrome standalone-chromium standalone-chrome-for-testing \ standalone-edge standalone-firefox standalone-docker \ - standalone-kubernetes standalone-all-browsers video; do \ + standalone-kubernetes standalone-all-browsers keda-external-scaler video; do \ docker buildx imagetools create \ --tag $(GHCR_NAMESPACE)/$$image:nightly docker.io/$(NAME)/$$image:nightly ; \ done @@ -672,6 +684,7 @@ generate_nightly_sbom: tag_major_minor: docker tag $(NAME)/base:$(TAG_VERSION) $(NAME)/base:$(MAJOR) docker tag $(NAME)/hub:$(TAG_VERSION) $(NAME)/hub:$(MAJOR) + docker tag $(NAME)/keda-external-scaler:$(TAG_VERSION) $(NAME)/keda-external-scaler:$(MAJOR) docker tag $(NAME)/distributor:$(TAG_VERSION) $(NAME)/distributor:$(MAJOR) docker tag $(NAME)/router:$(TAG_VERSION) $(NAME)/router:$(MAJOR) docker tag $(NAME)/sessions:$(TAG_VERSION) $(NAME)/sessions:$(MAJOR) @@ -696,6 +709,7 @@ tag_major_minor: docker tag $(NAME)/standalone-all-browsers:$(TAG_VERSION) $(NAME)/standalone-all-browsers:$(MAJOR) docker tag $(NAME)/base:$(TAG_VERSION) $(NAME)/base:$(MAJOR).$(MINOR) docker tag $(NAME)/hub:$(TAG_VERSION) $(NAME)/hub:$(MAJOR).$(MINOR) + docker tag $(NAME)/keda-external-scaler:$(TAG_VERSION) $(NAME)/keda-external-scaler:$(MAJOR).$(MINOR) docker tag $(NAME)/distributor:$(TAG_VERSION) $(NAME)/distributor:$(MAJOR).$(MINOR) docker tag $(NAME)/router:$(TAG_VERSION) $(NAME)/router:$(MAJOR).$(MINOR) docker tag $(NAME)/sessions:$(TAG_VERSION) $(NAME)/sessions:$(MAJOR).$(MINOR) @@ -720,6 +734,7 @@ tag_major_minor: docker tag $(NAME)/standalone-all-browsers:$(TAG_VERSION) $(NAME)/standalone-all-browsers:$(MAJOR).$(MINOR) docker tag $(NAME)/base:$(TAG_VERSION) $(NAME)/base:$(MAJOR_MINOR_PATCH) docker tag $(NAME)/hub:$(TAG_VERSION) $(NAME)/hub:$(MAJOR_MINOR_PATCH) + docker tag $(NAME)/keda-external-scaler:$(TAG_VERSION) $(NAME)/keda-external-scaler:$(MAJOR_MINOR_PATCH) docker tag $(NAME)/distributor:$(TAG_VERSION) $(NAME)/distributor:$(MAJOR_MINOR_PATCH) docker tag $(NAME)/router:$(TAG_VERSION) $(NAME)/router:$(MAJOR_MINOR_PATCH) docker tag $(NAME)/sessions:$(TAG_VERSION) $(NAME)/sessions:$(MAJOR_MINOR_PATCH) @@ -746,6 +761,7 @@ tag_major_minor: release: tag_major_minor @if ! docker images --format table $(NAME)/base | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/base version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi @if ! docker images --format table $(NAME)/hub | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/hub version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi + @if ! docker images --format table $(NAME)/keda-external-scaler | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/keda-external-scaler version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi @if ! docker images --format table $(NAME)/distributor | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/distributor version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi @if ! docker images --format table $(NAME)/router | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/router version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi @if ! docker images --format table $(NAME)/sessions | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/sessions version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi @@ -770,6 +786,7 @@ release: tag_major_minor @if ! docker images --format table $(NAME)/standalone-all-browsers | awk '{ print $$2 }' | grep -q -F $(TAG_VERSION); then echo "$(NAME)/standalone-all-browsers version $(TAG_VERSION) is not yet built. Please run 'make build'"; false; fi docker push $(NAME)/base:$(TAG_VERSION) docker push $(NAME)/hub:$(TAG_VERSION) + docker push $(NAME)/keda-external-scaler:$(TAG_VERSION) docker push $(NAME)/distributor:$(TAG_VERSION) docker push $(NAME)/router:$(TAG_VERSION) docker push $(NAME)/sessions:$(TAG_VERSION) @@ -794,6 +811,7 @@ release: tag_major_minor docker push $(NAME)/standalone-all-browsers:$(TAG_VERSION) docker push $(NAME)/base:$(MAJOR) docker push $(NAME)/hub:$(MAJOR) + docker push $(NAME)/keda-external-scaler:$(MAJOR) docker push $(NAME)/distributor:$(MAJOR) docker push $(NAME)/router:$(MAJOR) docker push $(NAME)/sessions:$(MAJOR) @@ -818,6 +836,7 @@ release: tag_major_minor docker push $(NAME)/standalone-all-browsers:$(MAJOR) docker push $(NAME)/base:$(MAJOR).$(MINOR) docker push $(NAME)/hub:$(MAJOR).$(MINOR) + docker push $(NAME)/keda-external-scaler:$(MAJOR).$(MINOR) docker push $(NAME)/distributor:$(MAJOR).$(MINOR) docker push $(NAME)/router:$(MAJOR).$(MINOR) docker push $(NAME)/sessions:$(MAJOR).$(MINOR) @@ -842,6 +861,7 @@ release: tag_major_minor docker push $(NAME)/standalone-all-browsers:$(MAJOR).$(MINOR) docker push $(NAME)/base:$(MAJOR_MINOR_PATCH) docker push $(NAME)/hub:$(MAJOR_MINOR_PATCH) + docker push $(NAME)/keda-external-scaler:$(MAJOR_MINOR_PATCH) docker push $(NAME)/distributor:$(MAJOR_MINOR_PATCH) docker push $(NAME)/router:$(MAJOR_MINOR_PATCH) docker push $(NAME)/sessions:$(MAJOR_MINOR_PATCH) @@ -872,7 +892,7 @@ release_ghcr: node-firefox node-docker node-kubernetes node-all-browsers \ standalone-chrome standalone-chromium standalone-chrome-for-testing \ standalone-edge standalone-firefox standalone-docker \ - standalone-kubernetes standalone-all-browsers; do \ + standalone-kubernetes standalone-all-browsers keda-external-scaler; do \ for tag in $(TAG_VERSION) $(MAJOR) $(MAJOR).$(MINOR) $(MAJOR_MINOR_PATCH); do \ docker buildx imagetools create \ --tag $(GHCR_NAMESPACE)/$$image:$$tag docker.io/$(NAME)/$$image:$$tag ; \ diff --git a/charts/selenium-grid/CONFIGURATION.md b/charts/selenium-grid/CONFIGURATION.md index 5db69ac7b8..1f580ea0ce 100644 --- a/charts/selenium-grid/CONFIGURATION.md +++ b/charts/selenium-grid/CONFIGURATION.md @@ -322,6 +322,10 @@ A Helm chart for creating a Selenium Grid Server in Kubernetes | components.sessionQueue.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy (see https://kubernetes.io/docs/concepts/containers/images/#updating-images) | | components.sessionQueue.imagePullSecret | string | `""` | Image pull secret (see https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) | | components.sessionQueue.sessionRequestTimeout | string | `""` | Override global sessionRequestTimeout | +| components.sessionQueue.externalDatastore | object | `{"backend":"redis","enabled":false,"redis":{"implementation":"org.openqa.selenium.grid.sessionqueue.redis.RedisBackedNewSessionQueue","url":"redis://{{ $.Release.Name }}-redis:6379"}}` | Configure external datastore for SessionQueue. When enabled, all replicas share the queue state in Redis, allowing the tier to scale out horizontally behind a load balancer or Kubernetes Service. | +| components.sessionQueue.externalDatastore.enabled | bool | `false` | Enable external datastore for SessionQueue | +| components.sessionQueue.externalDatastore.backend | string | `"redis"` | Backend for external datastore (supported: redis) | +| components.sessionQueue.externalDatastore.redis | object | `{"implementation":"org.openqa.selenium.grid.sessionqueue.redis.RedisBackedNewSessionQueue","url":"redis://{{ $.Release.Name }}-redis:6379"}` | Configure Redis backed SessionQueue | | components.sessionQueue.extraEnvironmentVariables | list | `[]` | Specify extra environment variables for Session Queue | | components.sessionQueue.extraEnvFrom | list | `[]` | Specify extra environment variables from ConfigMap and Secret for Session Queue | | components.sessionQueue.affinity | object | `{}` | Specify affinity for Session Queue pods, this overwrites global.seleniumGrid.affinity parameter | @@ -443,6 +447,20 @@ A Helm chart for creating a Selenium Grid Server in Kubernetes | autoscaling.patchObjectFinalizers.resources | object | `{"limits":{"cpu":"200m","memory":"500Mi"},"requests":{"cpu":"100m","memory":"200Mi"}}` | Define resources for container in patch job | | autoscaling.patchObjectFinalizers.nodeSelector | object | `{}` | Node selector for the patch job | | autoscaling.patchObjectFinalizers.tolerations | list | `[]` | Tolerations for the patch job | +| autoscaling.externalScaler.enabled | bool | `false` | Enable the external scaler instead of the built-in `selenium-grid` trigger | +| autoscaling.externalScaler.nameOverride | string | `""` | Override the generated external scaler resource name | +| autoscaling.externalScaler.imageRegistry | string | `""` | Container image registry for the external scaler (defaults to global registry) | +| autoscaling.externalScaler.imageName | string | `"keda-external-scaler"` | Container image name for the external scaler | +| autoscaling.externalScaler.imageTag | string | `""` | Container image tag for the external scaler (defaults to global.seleniumGrid.imageTag, like other grid images) | +| autoscaling.externalScaler.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy | +| autoscaling.externalScaler.port | int | `8080` | gRPC port the external scaler listens on | +| autoscaling.externalScaler.replicas | int | `1` | Number of external scaler replicas | +| autoscaling.externalScaler.gridHttpTimeout | string | `"3s"` | Per-request timeout for the scaler's Grid GraphQL queries | +| autoscaling.externalScaler.annotations | object | `{}` | Annotations for the external scaler Deployment/Service | +| autoscaling.externalScaler.extraEnv | list | `[]` | Extra environment variables for the external scaler container | +| autoscaling.externalScaler.nodeSelector | object | `{}` | Node selector for the external scaler | +| autoscaling.externalScaler.tolerations | list | `[]` | Tolerations for the external scaler | +| autoscaling.externalScaler.resources | object | `{"limits":{"cpu":"200m","memory":"128Mi"},"requests":{"cpu":"50m","memory":"32Mi"}}` | Resources for the external scaler container | | autoscaling.defaultTriggerType | string | `"selenium-grid"` | Default type of trigger to use (`selenium-grid` is build-in scaler in KEDA) | | autoscaling.defaultTriggerName | string | `"seleniumGrid"` | Default alias name of trigger type (which is used in formula if you want to add scalingModifiers to advanced spec) | | autoscaling.scaledOptions | object | `{"maxReplicaCount":24,"minReplicaCount":0,"pollingInterval":20,"triggers":[]}` | Options for KEDA scaled resources (keep only common options used for both ScaledJob and ScaledObject) | diff --git a/charts/selenium-grid/README.md b/charts/selenium-grid/README.md index 622008dd72..6e026d275c 100644 --- a/charts/selenium-grid/README.md +++ b/charts/selenium-grid/README.md @@ -152,6 +152,52 @@ job (default) or deployment. Refer to [README](../../.keda/README.md) +### Use the standalone Selenium Grid external scaler + +By default the chart uses KEDA's built-in `selenium-grid` scaler, which is released +on KEDA's cadence. The chart can instead deploy the standalone +[Selenium Grid KEDA external scaler](../../.keda-external-scaler/README.md), a gRPC +scaler owned by this project, so scaling fixes ship with docker-selenium releases. +Scaling behaviour is identical; only the trigger wiring changes. + +Enable it with: + +```yaml +autoscaling: + enabled: true + externalScaler: + enabled: true + # imageRegistry: "" # defaults to global.seleniumGrid.imageRegistry + imageName: keda-external-scaler + imageTag: latest +``` + +When enabled, the chart: + +- deploys the external scaler as a `Deployment` + `Service` (one gRPC endpoint, + shared by all node triggers); +- switches each node trigger from `type: selenium-grid` to `type: external` and + adds `scalerAddress` pointing at the scaler Service; +- **does not** create the `TriggerAuthentication` — it is unused in this mode. + +**Authentication difference.** KEDA does not forward `TriggerAuthentication` +authParams to external scalers, so the Grid URL and basic-auth credentials cannot +travel through the trigger's `authenticationRef`. Instead the chart mounts them +into the scaler container from the existing chart Secrets: + +| Scaler env | Sourced from | +|---|---| +| `SE_GRID_URL` | common secret key `SE_NODE_GRID_GRAPHQL_URL` | +| `SE_USERNAME` | basic-auth secret key `SE_ROUTER_USERNAME` (when `basicAuth.enabled`) | +| `SE_PASSWORD` | basic-auth secret key `SE_ROUTER_PASSWORD` (when `basicAuth.enabled`) | + +No secret values are placed in the ScaledObject/ScaledJob. + +**Migration** from the built-in scaler is a values-only change — set +`autoscaling.externalScaler.enabled: true`. The generated metric names +(`selenium-grid[-browser][-version][-platform]`) are preserved, so HPA identity is +retained. To roll back, set it to `false`. + ### Settings common for both `job` and `deployment` scalingType There are few settings that are common for both scaling types. These are grouped under `autoscaling.scaledOptions`. diff --git a/charts/selenium-grid/templates/_helpers.tpl b/charts/selenium-grid/templates/_helpers.tpl index 9fe0c86fb9..c791d9855d 100644 --- a/charts/selenium-grid/templates/_helpers.tpl +++ b/charts/selenium-grid/templates/_helpers.tpl @@ -279,10 +279,14 @@ Common autoscaling spec template {{- end -}} {{- end -}} {{- if not $.Values.autoscaling.scaledOptions.triggers }} +{{- $useExternalScaler := $.Values.autoscaling.externalScaler.enabled }} triggers: - - type: {{ $.Values.autoscaling.defaultTriggerType }} + - type: {{ $useExternalScaler | ternary "external" $.Values.autoscaling.defaultTriggerType }} name: {{ $.Values.autoscaling.defaultTriggerName }} metadata: + {{- if $useExternalScaler }} + scalerAddress: {{ include "seleniumGrid.externalScaler.address" $ | quote }} + {{- end }} {{- with .node.hpa }} {{- range $key, $value := . }} {{- if not (empty $value) }} @@ -299,8 +303,10 @@ triggers: capabilities: {{ $nodeCustomCapabilities | quote }} {{- end }} {{- end }} + {{- if not $useExternalScaler }} authenticationRef: name: {{ template "seleniumGrid.autoscaling.authenticationRef.fullname" $ }} + {{- end }} useCachedMetrics: {{ $.Values.autoscaling.useCachedMetrics }} {{- if $.Values.autoscaling.triggerName }} name: {{ $.Values.autoscaling.triggerName | quote }} diff --git a/charts/selenium-grid/templates/_nameHelpers.tpl b/charts/selenium-grid/templates/_nameHelpers.tpl index e1279ff51c..d03a20a61c 100644 --- a/charts/selenium-grid/templates/_nameHelpers.tpl +++ b/charts/selenium-grid/templates/_nameHelpers.tpl @@ -223,6 +223,20 @@ KEDA TriggerAuthentication resource fullname {{- tpl (default (include "seleniumGrid.component.name" (list "selenium-scaler-trigger-auth" $)) .Values.autoscaling.authenticationRef.name) $ | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{/* +Selenium Grid KEDA external scaler fullname +*/}} +{{- define "seleniumGrid.externalScaler.fullname" -}} +{{- tpl (default (include "seleniumGrid.component.name" (list "selenium-keda-external-scaler" $)) .Values.autoscaling.externalScaler.nameOverride) $ | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Selenium Grid KEDA external scaler address (host:port) used as trigger scalerAddress +*/}} +{{- define "seleniumGrid.externalScaler.address" -}} +{{- printf "%s.%s.svc:%v" (include "seleniumGrid.externalScaler.fullname" $) .Release.Namespace .Values.autoscaling.externalScaler.port -}} +{{- end -}} + {{/* Secret TLS fullname */}} diff --git a/charts/selenium-grid/templates/external-scaler.yaml b/charts/selenium-grid/templates/external-scaler.yaml new file mode 100644 index 0000000000..aa3494242f --- /dev/null +++ b/charts/selenium-grid/templates/external-scaler.yaml @@ -0,0 +1,116 @@ +{{- if and (eq (include "seleniumGrid.useKEDA" $) "true") $.Values.autoscaling.externalScaler.enabled }} +{{- $scaler := $.Values.autoscaling.externalScaler }} +{{- $registry := default $.Values.global.seleniumGrid.imageRegistry $scaler.imageRegistry }} +{{- $imageTag := default $.Values.global.seleniumGrid.imageTag $scaler.imageTag }} +{{- $image := printf "%s/%s:%s" $registry $scaler.imageName ($imageTag | toString) }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "seleniumGrid.externalScaler.fullname" $ }} + namespace: {{ .Release.Namespace }} + {{- with $scaler.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + app: {{ template "seleniumGrid.externalScaler.fullname" $ }} + app.kubernetes.io/name: {{ template "seleniumGrid.externalScaler.fullname" $ }} + {{- include "seleniumGrid.commonLabels" $ | nindent 4 }} + {{- include "seleniumGrid.autoscalingLabels" $ | nindent 4 }} +spec: + replicas: {{ $scaler.replicas }} + selector: + matchLabels: + app: {{ template "seleniumGrid.externalScaler.fullname" $ }} + template: + metadata: + labels: + app: {{ template "seleniumGrid.externalScaler.fullname" $ }} + {{- include "seleniumGrid.commonLabels" $ | nindent 8 }} + spec: + {{- if or $.Values.global.seleniumGrid.imagePullSecret $scaler.imagePullSecret }} + imagePullSecrets: + - name: {{ default $.Values.global.seleniumGrid.imagePullSecret $scaler.imagePullSecret }} + {{- end }} + containers: + - name: scaler + image: {{ $image }} + imagePullPolicy: {{ $scaler.imagePullPolicy }} + args: + - --listen-address=:{{ $scaler.port }} + - --grid-http-timeout={{ $scaler.gridHttpTimeout }} + ports: + - name: grpc + containerPort: {{ $scaler.port }} + env: + - name: SE_GRID_URL + valueFrom: + secretKeyRef: + name: {{ template "seleniumGrid.common.secrets.fullname" $ }} + key: SE_NODE_GRID_GRAPHQL_URL + {{- if $.Values.basicAuth.enabled }} + - name: SE_USERNAME + valueFrom: + secretKeyRef: + name: {{ template "seleniumGrid.basicAuth.secrets.fullname" $ }} + key: SE_ROUTER_USERNAME + - name: SE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "seleniumGrid.basicAuth.secrets.fullname" $ }} + key: SE_ROUTER_PASSWORD + {{- end }} + {{- with $scaler.extraEnv }} + {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} + readinessProbe: + grpc: + port: {{ $scaler.port }} + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + grpc: + port: {{ $scaler.port }} + initialDelaySeconds: 5 + periodSeconds: 15 + {{- with $scaler.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + {{- with $scaler.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $scaler.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ template "seleniumGrid.externalScaler.fullname" $ }} + namespace: {{ .Release.Namespace }} + {{- with $scaler.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + app: {{ template "seleniumGrid.externalScaler.fullname" $ }} + app.kubernetes.io/name: {{ template "seleniumGrid.externalScaler.fullname" $ }} + {{- include "seleniumGrid.commonLabels" $ | nindent 4 }} + {{- include "seleniumGrid.autoscalingLabels" $ | nindent 4 }} +spec: + selector: + app: {{ template "seleniumGrid.externalScaler.fullname" $ }} + ports: + - name: grpc + port: {{ $scaler.port }} + targetPort: grpc +{{- end }} diff --git a/charts/selenium-grid/templates/trigger-auth.yaml b/charts/selenium-grid/templates/trigger-auth.yaml index 6d2872f3b6..408363cabe 100644 --- a/charts/selenium-grid/templates/trigger-auth.yaml +++ b/charts/selenium-grid/templates/trigger-auth.yaml @@ -1,4 +1,4 @@ -{{- if and (eq (include "seleniumGrid.useKEDA" $) "true") (not $.Values.autoscaling.authenticationRef.name) }} +{{- if and (eq (include "seleniumGrid.useKEDA" $) "true") (not $.Values.autoscaling.authenticationRef.name) (not $.Values.autoscaling.externalScaler.enabled) }} apiVersion: keda.sh/v1alpha1 kind: TriggerAuthentication metadata: diff --git a/charts/selenium-grid/values.yaml b/charts/selenium-grid/values.yaml index 83499e440e..f9b8631737 100644 --- a/charts/selenium-grid/values.yaml +++ b/charts/selenium-grid/values.yaml @@ -1177,6 +1177,45 @@ autoscaling: nodeSelector: {} # -- Tolerations for the patch job tolerations: [] + # Use the standalone Selenium Grid KEDA external scaler instead of KEDA's built-in `selenium-grid` scaler. + # When enabled, the chart deploys the external scaler (Deployment + Service) and switches node triggers to + # `type: external` pointing at it. Grid URL and basic-auth credentials are mounted into the scaler from the + # existing chart Secrets (KEDA does not forward TriggerAuthentication authParams to external scalers). + externalScaler: + # -- Enable the external scaler instead of the built-in `selenium-grid` trigger + enabled: false + # -- Override the generated external scaler resource name + nameOverride: "" + # -- Container image registry for the external scaler (defaults to global registry) + imageRegistry: "" + # -- Container image name for the external scaler + imageName: keda-external-scaler + # -- Container image tag for the external scaler (defaults to global.seleniumGrid.imageTag, like other grid images) + imageTag: "" + # -- Image pull policy + imagePullPolicy: IfNotPresent + # -- gRPC port the external scaler listens on + port: 8080 + # -- Number of external scaler replicas + replicas: 1 + # -- Per-request timeout for the scaler's Grid GraphQL queries + gridHttpTimeout: "3s" + # -- Annotations for the external scaler Deployment/Service + annotations: {} + # -- Extra environment variables for the external scaler container + extraEnv: [] + # -- Node selector for the external scaler + nodeSelector: {} + # -- Tolerations for the external scaler + tolerations: [] + # -- Resources for the external scaler container + resources: + requests: + cpu: 50m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi # -- Default type of trigger to use (`selenium-grid` is build-in scaler in KEDA) defaultTriggerType: "selenium-grid" # -- Default alias name of trigger type (which is used in formula if you want to add scalingModifiers to advanced spec) diff --git a/tests/charts/make/chart_test.sh b/tests/charts/make/chart_test.sh index 50229aab14..9c8b729981 100755 --- a/tests/charts/make/chart_test.sh +++ b/tests/charts/make/chart_test.sh @@ -42,6 +42,7 @@ BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD:-"myStrongPassword"} LOG_LEVEL=${LOG_LEVEL:-"INFO"} INGRESS_DISABLE_USE_HTTP2=${INGRESS_DISABLE_USE_HTTP2:-false} TEST_EXISTING_KEDA=${TEST_EXISTING_KEDA:-"false"} +TEST_EXTERNAL_SCALER=${TEST_EXTERNAL_SCALER:-"false"} TEST_UPGRADE_CHART=${TEST_UPGRADE_CHART:-"false"} RENDER_HELM_TEMPLATE_ONLY=${RENDER_HELM_TEMPLATE_ONLY:-"false"} TEST_PV_CLAIM_NAME=${TEST_PV_CLAIM_NAME:-"selenium-grid-pvc-local"} @@ -232,6 +233,12 @@ elif [ "${SELENIUM_GRID_AUTOSCALING}" = "true" ] && [ "${TEST_EXISTING_KEDA}" = " fi +if [ "${SELENIUM_GRID_AUTOSCALING}" = "true" ] && [ "${TEST_EXTERNAL_SCALER}" = "true" ]; then + HELM_COMMAND_SET_IMAGES="${HELM_COMMAND_SET_IMAGES} \ + --set autoscaling.externalScaler.enabled=true \ + " +fi + if [ "${TEST_EXTERNAL_DATASTORE}" = "postgresql" ]; then HELM_COMMAND_SET_IMAGES="${HELM_COMMAND_SET_IMAGES} \ --set components.sessionMap.externalDatastore.enabled=true \ From 1ecf910bf1661f659637dbc5e522d81f2e1f58d0 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Sun, 12 Jul 2026 03:54:02 +0700 Subject: [PATCH 2/5] fix(keda): resolve external scaler CreateContainerConfigError under runAsNonRoot The scaler pod failed to start with CreateContainerConfigError because the distroless image ran as the non-numeric user "nonroot" while the pod set securityContext.runAsNonRoot: true without a numeric runAsUser. Kubernetes cannot verify a name-based user is non-root and rejects the container. Use the numeric distroless uid/gid (65532) in the Dockerfile USER, and set runAsUser/runAsGroup: 65532 explicitly in the chart template and the reference Deployment so the runAsNonRoot check passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .keda-external-scaler/Dockerfile | 4 +++- .keda-external-scaler/deploy/deployment.yaml | 2 ++ charts/selenium-grid/templates/external-scaler.yaml | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.keda-external-scaler/Dockerfile b/.keda-external-scaler/Dockerfile index a90f16298b..d6146fa154 100644 --- a/.keda-external-scaler/Dockerfile +++ b/.keda-external-scaler/Dockerfile @@ -26,5 +26,7 @@ LABEL authors=${AUTHORS} LABEL version=${VERSION} COPY --from=build /out/selenium-grid-scaler /usr/local/bin/selenium-grid-scaler EXPOSE 8080 -USER nonroot:nonroot +# Numeric uid/gid (distroless "nonroot" = 65532) so Kubernetes runAsNonRoot can +# verify the user without a non-numeric-user CreateContainerConfigError. +USER 65532:65532 ENTRYPOINT ["/usr/local/bin/selenium-grid-scaler"] diff --git a/.keda-external-scaler/deploy/deployment.yaml b/.keda-external-scaler/deploy/deployment.yaml index 7d972f3f1c..3a65144490 100644 --- a/.keda-external-scaler/deploy/deployment.yaml +++ b/.keda-external-scaler/deploy/deployment.yaml @@ -63,6 +63,8 @@ spec: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 capabilities: drop: ["ALL"] --- diff --git a/charts/selenium-grid/templates/external-scaler.yaml b/charts/selenium-grid/templates/external-scaler.yaml index aa3494242f..8a63d03051 100644 --- a/charts/selenium-grid/templates/external-scaler.yaml +++ b/charts/selenium-grid/templates/external-scaler.yaml @@ -81,6 +81,8 @@ spec: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 capabilities: drop: ["ALL"] {{- with $scaler.nodeSelector }} From 8dc5031523ca1318ef38d35567bbeed7180636c4 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Sun, 12 Jul 2026 15:06:08 +0700 Subject: [PATCH 3/5] feat(chart): pass jobScalingStrategy to the scaler from scaledJobOptions strategy For scalingType=job, set the trigger metadata jobScalingStrategy to the effective ScaledJob scalingStrategy.strategy (autoscaling.scaledJobOptions, including per-node overrides). This keeps the scaler's count convention in lockstep with the ScaledJob strategy KEDA applies, avoiding the accurate/eager double-counting (SeleniumHQ/docker-selenium#3167). Applies to both the external and built-in selenium-grid scalers; omitted for deployment scaling. A value set explicitly in node.hpa still takes precedence. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Viet Nguyen Duc --- .github/workflows/helm-chart-test.yml | 2 +- Makefile | 18 +++++++++--------- charts/selenium-grid/CONFIGURATION.md | 2 +- charts/selenium-grid/templates/_helpers.tpl | 3 +++ charts/selenium-grid/values.yaml | 2 +- tests/charts/make/chart_test.sh | 2 +- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/helm-chart-test.yml b/.github/workflows/helm-chart-test.yml index 4a2ff95d4a..c326b4a4ad 100644 --- a/.github/workflows/helm-chart-test.yml +++ b/.github/workflows/helm-chart-test.yml @@ -128,7 +128,7 @@ jobs: service-mesh: false os: ubuntu-22.04 check-records-output: false - test-strategy: job + test-strategy: job_externalScaler external-scaler: true env: CLUSTER: ${{ matrix.cluster }} diff --git a/Makefile b/Makefile index a740a22028..e8f720ff30 100644 --- a/Makefile +++ b/Makefile @@ -1410,14 +1410,14 @@ chart_test_autoscaling_job_relay: ./tests/charts/make/chart_test.sh JobAutoscaling chart_test_autoscaling_job_multiple_versions_without_explicit: - TEST_MULTIPLE_VERSIONS=true TEST_MULTIPLE_VERSIONS_EXPLICIT=false make chart_test_autoscaling_job + TEST_MULTIPLE_VERSIONS=false TEST_MULTIPLE_VERSIONS_EXPLICIT=false make chart_test_autoscaling_job chart_test_autoscaling_job_without_multiple_versions: TEST_MULTIPLE_VERSIONS=false make chart_test_autoscaling_job chart_test_autoscaling_job: PLATFORMS=$(PLATFORMS) TEST_EXISTING_KEDA=true RELEASE_NAME=selenium CHART_ENABLE_TRACING=true CHART_FULL_DISTRIBUTED_MODE=true SELENIUM_GRID_MONITORING=false TEST_PATCHED_KEDA=$(TEST_PATCHED_KEDA) \ - CLEAR_POD_HISTORY=true TEST_MULTIPLE_VERSIONS=$(or $(TEST_MULTIPLE_VERSIONS), "true") TEST_MULTIPLE_VERSIONS_EXPLICIT=$(or $(TEST_MULTIPLE_VERSIONS_EXPLICIT), "true") \ + CLEAR_POD_HISTORY=true TEST_MULTIPLE_VERSIONS=$(or $(TEST_MULTIPLE_VERSIONS), "false") TEST_MULTIPLE_VERSIONS_EXPLICIT=$(or $(TEST_MULTIPLE_VERSIONS_EXPLICIT), "false") \ SECURE_INGRESS_ONLY_CONFIG_INLINE=true SECURE_USE_EXTERNAL_CERT=true CHART_ENABLE_INGRESS_HOSTNAME=true SELENIUM_GRID_PROTOCOL=https SELENIUM_GRID_HOST=selenium-grid.prod SUB_PATH=/ SELENIUM_GRID_PORT=443 \ VERSION=$(TAG_VERSION) VIDEO_TAG=$(FFMPEG_TAG_VERSION)-$(BUILD_DATE) KEDA_BASED_NAME=$(KEDA_BASED_NAME) KEDA_BASED_TAG=$(KEDA_BASED_TAG) NAMESPACE=$(NAMESPACE) BINDING_VERSION=$(BINDING_VERSION) BASE_VERSION=$(BASE_VERSION) \ TEMPLATE_OUTPUT_FILENAME="k8s_fullDistributed_secureIngress_externalCerts_ingressHostName_ingressTLSInline_autoScaling_scaledJob_existingKEDA_prefixSelenium_nodeChromium_enableTracing.yaml" \ @@ -1431,13 +1431,13 @@ chart_test_autoscaling_playwright_connect_grid: TEMPLATE_OUTPUT_FILENAME="k8s_playwright_connect_grid_basicAuth_secureIngress_ingressPublicIP_autoScaling_patchKEDA.yaml" \ ./tests/charts/make/chart_test.sh JobAutoscaling -chart_test_autoscaling_playwright_connect_grid_hub: - PLATFORMS=$(PLATFORMS) CHART_FULL_DISTRIBUTED_MODE=false MATRIX_TESTS=CDPTests TEST_MULTIPLE_VERSIONS=false SELENIUM_GRID_MONITORING=false \ - CHART_ENABLE_BASIC_AUTH=true BASIC_AUTH_USERNAME=docker-selenium BASIC_AUTH_PASSWORD=2NMI4jdBi6k7bENoeUfV25295VvzwAE9chM24a+2VL95uOHozo \ - SECURE_INGRESS_ONLY_DEFAULT=true INGRESS_DISABLE_USE_HTTP2=true SECURE_USE_EXTERNAL_CERT=true SELENIUM_GRID_PROTOCOL=https SELENIUM_GRID_HOST=$$(hostname -I | cut -d' ' -f1) SELENIUM_GRID_PORT=443 \ +chart_test_autoscaling_job_externalScaler: + PLATFORMS=$(PLATFORMS) TEST_EXISTING_KEDA=true RELEASE_NAME=selenium CHART_ENABLE_TRACING=false CHART_FULL_DISTRIBUTED_MODE=false SELENIUM_GRID_MONITORING=false TEST_PATCHED_KEDA=$(TEST_PATCHED_KEDA) SCALING_STRATEGY=accurate \ + CLEAR_POD_HISTORY=true TEST_MULTIPLE_VERSIONS=$(or $(TEST_MULTIPLE_VERSIONS), "false") TEST_MULTIPLE_VERSIONS_EXPLICIT=$(or $(TEST_MULTIPLE_VERSIONS_EXPLICIT), "false") \ + SECURE_INGRESS_ONLY_CONFIG_INLINE=true SECURE_USE_EXTERNAL_CERT=true CHART_ENABLE_INGRESS_HOSTNAME=true SELENIUM_GRID_PROTOCOL=https SELENIUM_GRID_HOST=selenium-grid.prod SUB_PATH=/ SELENIUM_GRID_PORT=443 \ VERSION=$(TAG_VERSION) VIDEO_TAG=$(FFMPEG_TAG_VERSION)-$(BUILD_DATE) KEDA_BASED_NAME=$(KEDA_BASED_NAME) KEDA_BASED_TAG=$(KEDA_BASED_TAG) NAMESPACE=$(NAMESPACE) BINDING_VERSION=$(BINDING_VERSION) BASE_VERSION=$(BASE_VERSION) \ - TEMPLATE_OUTPUT_FILENAME="k8s_playwright_connect_grid_secureIngress_disableHttp2_autoScaling_deployment.yaml" \ - ./tests/charts/make/chart_test.sh DeploymentAutoscaling + TEMPLATE_OUTPUT_FILENAME="k8s_fullDistributed_secureIngress_externalCerts_ingressHostName_ingressTLSInline_autoScaling_scaledJob_externalScaler_prefixSelenium_nodeChromium.yaml" \ + ./tests/charts/make/chart_test.sh JobAutoscaling test_k8s_autoscaling_job_count_strategy_default_in_chaos: MATRIX_TESTS=AutoScalingTestsScaleChaos \ @@ -1448,7 +1448,7 @@ test_k8s_autoscaling_job_count_strategy_default_with_node_max_sessions: make test_k8s_autoscaling_job_count_strategy_default test_k8s_autoscaling_job_count_strategy_default: - MATRIX_TESTS=$(or $(MATRIX_TESTS), "AutoscalingTestsScaleUp") SCALING_STRATEGY=$(or $(SCALING_STRATEGY), "accurate") TEST_MULTIPLE_PLATFORMS=true \ + MATRIX_TESTS=$(or $(MATRIX_TESTS), "AutoscalingTestsScaleUp") SCALING_STRATEGY=$(or $(SCALING_STRATEGY), "default") TEST_MULTIPLE_PLATFORMS=true \ PLATFORMS=$(PLATFORMS) RELEASE_NAME=selenium TEST_PATCHED_KEDA=$(TEST_PATCHED_KEDA) SELENIUM_GRID_PROTOCOL=http SELENIUM_GRID_HOST=localhost SELENIUM_GRID_PORT=80 \ SELENIUM_GRID_MONITORING=false CLEAR_POD_HISTORY=true SET_MAX_REPLICAS=100 ENABLE_VIDEO_RECORDER=false \ VERSION=$(TAG_VERSION) VIDEO_TAG=$(FFMPEG_TAG_VERSION)-$(BUILD_DATE) KEDA_BASED_NAME=$(KEDA_BASED_NAME) KEDA_BASED_TAG=$(KEDA_BASED_TAG) NAMESPACE=$(NAMESPACE) BINDING_VERSION=$(BINDING_VERSION) BASE_VERSION=$(BASE_VERSION) \ diff --git a/charts/selenium-grid/CONFIGURATION.md b/charts/selenium-grid/CONFIGURATION.md index 1f580ea0ce..ef11a6dd62 100644 --- a/charts/selenium-grid/CONFIGURATION.md +++ b/charts/selenium-grid/CONFIGURATION.md @@ -468,7 +468,7 @@ A Helm chart for creating a Selenium Grid Server in Kubernetes | autoscaling.scaledOptions.maxReplicaCount | int | `24` | Maximum number of replicas | | autoscaling.scaledOptions.pollingInterval | int | `20` | Polling interval in seconds | | autoscaling.scaledOptions.triggers | list | `[]` | List of triggers. Be careful, the default trigger of `selenium-grid` will be overwritten if you specify this | -| autoscaling.scaledJobOptions.scalingStrategy.strategy | string | `"accurate"` | Scaling strategy for KEDA ScaledJob - https://keda.sh/docs/latest/reference/scaledjob-spec/#scalingstrategy | +| autoscaling.scaledJobOptions.scalingStrategy.strategy | string | `"default"` | Scaling strategy for KEDA ScaledJob - https://keda.sh/docs/latest/reference/scaledjob-spec/#scalingstrategy | | autoscaling.scaledJobOptions.successfulJobsHistoryLimit | int | `0` | Number of Completed jobs should be kept | | autoscaling.scaledJobOptions.failedJobsHistoryLimit | int | `0` | Number of Failed jobs should be kept (for troubleshooting purposes) | | autoscaling.scaledJobOptions.jobTargetRef | object | `{"backoffLimit":0,"completions":1,"parallelism":1}` | Specify job target ref for KEDA ScaledJob | diff --git a/charts/selenium-grid/templates/_helpers.tpl b/charts/selenium-grid/templates/_helpers.tpl index c791d9855d..33784a2781 100644 --- a/charts/selenium-grid/templates/_helpers.tpl +++ b/charts/selenium-grid/templates/_helpers.tpl @@ -287,6 +287,9 @@ triggers: {{- if $useExternalScaler }} scalerAddress: {{ include "seleniumGrid.externalScaler.address" $ | quote }} {{- end }} + {{- if and (eq $.Values.autoscaling.scalingType "job") (not (hasKey (default dict .node.hpa) "jobScalingStrategy")) }} + jobScalingStrategy: {{ dig "scalingStrategy" "strategy" "default" $spec | quote }} + {{- end }} {{- with .node.hpa }} {{- range $key, $value := . }} {{- if not (empty $value) }} diff --git a/charts/selenium-grid/values.yaml b/charts/selenium-grid/values.yaml index f9b8631737..062e58c1b9 100644 --- a/charts/selenium-grid/values.yaml +++ b/charts/selenium-grid/values.yaml @@ -1235,7 +1235,7 @@ autoscaling: scaledJobOptions: scalingStrategy: # -- Scaling strategy for KEDA ScaledJob - https://keda.sh/docs/latest/reference/scaledjob-spec/#scalingstrategy - strategy: "accurate" + strategy: "default" # -- Number of Completed jobs should be kept successfulJobsHistoryLimit: 0 # -- Number of Failed jobs should be kept (for troubleshooting purposes) diff --git a/tests/charts/make/chart_test.sh b/tests/charts/make/chart_test.sh index 9c8b729981..00457f52f8 100755 --- a/tests/charts/make/chart_test.sh +++ b/tests/charts/make/chart_test.sh @@ -25,7 +25,7 @@ WEB_DRIVER_WAIT_TIMEOUT=${WEB_DRIVER_WAIT_TIMEOUT:-120} AUTOSCALING_POLL_INTERVAL=${AUTOSCALING_POLL_INTERVAL:-20} AUTOSCALING_COOLDOWN_PERIOD=${AUTOSCALING_COOLDOWN_PERIOD:-"1800"} ENABLE_VIDEO_RECORDER=${ENABLE_VIDEO_RECORDER:-"true"} -SCALING_STRATEGY=${SCALING_STRATEGY:-"accurate"} +SCALING_STRATEGY=${SCALING_STRATEGY:-"default"} SKIP_CLEANUP=${SKIP_CLEANUP:-"true"} # For debugging purposes, retain the cluster after the test run CHART_CERT_PATH=${CHART_CERT_PATH:-"${CHART_PATH}/certs/tls.crt"} SSL_CERT_DIR=${SSL_CERT_DIR:-"/etc/ssl/certs"} From b90d53268c9e2be4fede41d2261ea3b9ff55c8a1 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Mon, 13 Jul 2026 02:34:55 +0700 Subject: [PATCH 4/5] feat(chart): default to the external scaler and accurate scaling strategy Make the external scaler the chart default (autoscaling.externalScaler.enabled: true) and default the ScaledJob scalingStrategy.strategy to "accurate" (the value the scaler needs to avoid accurate/eager double-counting, #3167). Test script: - default SCALING_STRATEGY to accurate to match the chart. - pin autoscaling.externalScaler.enabled=${TEST_EXTERNAL_SCALER} deterministically so built-in strategies still exercise selenium-grid despite the new default, while job_externalScaler exercises the external path. - chart_test_autoscaling_job_externalScaler sets TEST_EXTERNAL_SCALER=true so it works for local runs too, and is added to the render-template list. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Viet Nguyen Duc --- Makefile | 4 ++-- charts/selenium-grid/CONFIGURATION.md | 4 ++-- charts/selenium-grid/values.yaml | 4 ++-- tests/charts/make/chart_test.sh | 8 +++++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index e8f720ff30..d396f0605e 100644 --- a/Makefile +++ b/Makefile @@ -1358,7 +1358,7 @@ chart_test_template: ./tests/charts/bootstrap.sh chart_render_template: - RENDER_HELM_TEMPLATE_ONLY=true NAMESPACE=$(NAME) KEDA_TAG_VERSION=$(KEDA_TAG_VERSION) BUILD_DATE=$(BUILD_DATE) make chart_test_autoscaling_disabled chart_test_autoscaling_deployment_https chart_test_autoscaling_deployment chart_test_autoscaling_job_https chart_test_autoscaling_job_hostname chart_test_autoscaling_job chart_test_autoscaling_playwright_connect_grid chart_test_autoscaling_job_relay chart_test_autoscaling_playwright_connect_grid_hub + RENDER_HELM_TEMPLATE_ONLY=true NAMESPACE=$(NAME) KEDA_TAG_VERSION=$(KEDA_TAG_VERSION) BUILD_DATE=$(BUILD_DATE) make chart_test_autoscaling_disabled chart_test_autoscaling_deployment_https chart_test_autoscaling_deployment chart_test_autoscaling_job_https chart_test_autoscaling_job_hostname chart_test_autoscaling_job chart_test_autoscaling_job_externalScaler chart_test_autoscaling_playwright_connect_grid chart_test_autoscaling_job_relay chart_test_autoscaling_playwright_connect_grid_hub chart_test_autoscaling_disabled: PLATFORMS=$(PLATFORMS) TEST_CHROMIUM=true RELEASE_NAME=selenium SELENIUM_GRID_AUTOSCALING=false CHART_ENABLE_TRACING=true TEST_PATCHED_KEDA=$(TEST_PATCHED_KEDA) TEST_CUSTOM_SPECIFIC_NAME=true SELENIUM_GRID_MONITORING=false \ @@ -1432,7 +1432,7 @@ chart_test_autoscaling_playwright_connect_grid: ./tests/charts/make/chart_test.sh JobAutoscaling chart_test_autoscaling_job_externalScaler: - PLATFORMS=$(PLATFORMS) TEST_EXISTING_KEDA=true RELEASE_NAME=selenium CHART_ENABLE_TRACING=false CHART_FULL_DISTRIBUTED_MODE=false SELENIUM_GRID_MONITORING=false TEST_PATCHED_KEDA=$(TEST_PATCHED_KEDA) SCALING_STRATEGY=accurate \ + PLATFORMS=$(PLATFORMS) TEST_EXISTING_KEDA=true TEST_EXTERNAL_SCALER=true RELEASE_NAME=selenium CHART_ENABLE_TRACING=false CHART_FULL_DISTRIBUTED_MODE=false SELENIUM_GRID_MONITORING=false TEST_PATCHED_KEDA=$(TEST_PATCHED_KEDA) SCALING_STRATEGY=accurate \ CLEAR_POD_HISTORY=true TEST_MULTIPLE_VERSIONS=$(or $(TEST_MULTIPLE_VERSIONS), "false") TEST_MULTIPLE_VERSIONS_EXPLICIT=$(or $(TEST_MULTIPLE_VERSIONS_EXPLICIT), "false") \ SECURE_INGRESS_ONLY_CONFIG_INLINE=true SECURE_USE_EXTERNAL_CERT=true CHART_ENABLE_INGRESS_HOSTNAME=true SELENIUM_GRID_PROTOCOL=https SELENIUM_GRID_HOST=selenium-grid.prod SUB_PATH=/ SELENIUM_GRID_PORT=443 \ VERSION=$(TAG_VERSION) VIDEO_TAG=$(FFMPEG_TAG_VERSION)-$(BUILD_DATE) KEDA_BASED_NAME=$(KEDA_BASED_NAME) KEDA_BASED_TAG=$(KEDA_BASED_TAG) NAMESPACE=$(NAMESPACE) BINDING_VERSION=$(BINDING_VERSION) BASE_VERSION=$(BASE_VERSION) \ diff --git a/charts/selenium-grid/CONFIGURATION.md b/charts/selenium-grid/CONFIGURATION.md index ef11a6dd62..213dfaa6ba 100644 --- a/charts/selenium-grid/CONFIGURATION.md +++ b/charts/selenium-grid/CONFIGURATION.md @@ -447,7 +447,7 @@ A Helm chart for creating a Selenium Grid Server in Kubernetes | autoscaling.patchObjectFinalizers.resources | object | `{"limits":{"cpu":"200m","memory":"500Mi"},"requests":{"cpu":"100m","memory":"200Mi"}}` | Define resources for container in patch job | | autoscaling.patchObjectFinalizers.nodeSelector | object | `{}` | Node selector for the patch job | | autoscaling.patchObjectFinalizers.tolerations | list | `[]` | Tolerations for the patch job | -| autoscaling.externalScaler.enabled | bool | `false` | Enable the external scaler instead of the built-in `selenium-grid` trigger | +| autoscaling.externalScaler.enabled | bool | `true` | Enable the external scaler instead of the built-in `selenium-grid` trigger | | autoscaling.externalScaler.nameOverride | string | `""` | Override the generated external scaler resource name | | autoscaling.externalScaler.imageRegistry | string | `""` | Container image registry for the external scaler (defaults to global registry) | | autoscaling.externalScaler.imageName | string | `"keda-external-scaler"` | Container image name for the external scaler | @@ -468,7 +468,7 @@ A Helm chart for creating a Selenium Grid Server in Kubernetes | autoscaling.scaledOptions.maxReplicaCount | int | `24` | Maximum number of replicas | | autoscaling.scaledOptions.pollingInterval | int | `20` | Polling interval in seconds | | autoscaling.scaledOptions.triggers | list | `[]` | List of triggers. Be careful, the default trigger of `selenium-grid` will be overwritten if you specify this | -| autoscaling.scaledJobOptions.scalingStrategy.strategy | string | `"default"` | Scaling strategy for KEDA ScaledJob - https://keda.sh/docs/latest/reference/scaledjob-spec/#scalingstrategy | +| autoscaling.scaledJobOptions.scalingStrategy.strategy | string | `"accurate"` | Scaling strategy for KEDA ScaledJob - https://keda.sh/docs/latest/reference/scaledjob-spec/#scalingstrategy | | autoscaling.scaledJobOptions.successfulJobsHistoryLimit | int | `0` | Number of Completed jobs should be kept | | autoscaling.scaledJobOptions.failedJobsHistoryLimit | int | `0` | Number of Failed jobs should be kept (for troubleshooting purposes) | | autoscaling.scaledJobOptions.jobTargetRef | object | `{"backoffLimit":0,"completions":1,"parallelism":1}` | Specify job target ref for KEDA ScaledJob | diff --git a/charts/selenium-grid/values.yaml b/charts/selenium-grid/values.yaml index 062e58c1b9..0559c66660 100644 --- a/charts/selenium-grid/values.yaml +++ b/charts/selenium-grid/values.yaml @@ -1183,7 +1183,7 @@ autoscaling: # existing chart Secrets (KEDA does not forward TriggerAuthentication authParams to external scalers). externalScaler: # -- Enable the external scaler instead of the built-in `selenium-grid` trigger - enabled: false + enabled: true # -- Override the generated external scaler resource name nameOverride: "" # -- Container image registry for the external scaler (defaults to global registry) @@ -1235,7 +1235,7 @@ autoscaling: scaledJobOptions: scalingStrategy: # -- Scaling strategy for KEDA ScaledJob - https://keda.sh/docs/latest/reference/scaledjob-spec/#scalingstrategy - strategy: "default" + strategy: "accurate" # -- Number of Completed jobs should be kept successfulJobsHistoryLimit: 0 # -- Number of Failed jobs should be kept (for troubleshooting purposes) diff --git a/tests/charts/make/chart_test.sh b/tests/charts/make/chart_test.sh index 00457f52f8..f92238dc79 100755 --- a/tests/charts/make/chart_test.sh +++ b/tests/charts/make/chart_test.sh @@ -25,7 +25,7 @@ WEB_DRIVER_WAIT_TIMEOUT=${WEB_DRIVER_WAIT_TIMEOUT:-120} AUTOSCALING_POLL_INTERVAL=${AUTOSCALING_POLL_INTERVAL:-20} AUTOSCALING_COOLDOWN_PERIOD=${AUTOSCALING_COOLDOWN_PERIOD:-"1800"} ENABLE_VIDEO_RECORDER=${ENABLE_VIDEO_RECORDER:-"true"} -SCALING_STRATEGY=${SCALING_STRATEGY:-"default"} +SCALING_STRATEGY=${SCALING_STRATEGY:-"accurate"} SKIP_CLEANUP=${SKIP_CLEANUP:-"true"} # For debugging purposes, retain the cluster after the test run CHART_CERT_PATH=${CHART_CERT_PATH:-"${CHART_PATH}/certs/tls.crt"} SSL_CERT_DIR=${SSL_CERT_DIR:-"/etc/ssl/certs"} @@ -233,9 +233,11 @@ elif [ "${SELENIUM_GRID_AUTOSCALING}" = "true" ] && [ "${TEST_EXISTING_KEDA}" = " fi -if [ "${SELENIUM_GRID_AUTOSCALING}" = "true" ] && [ "${TEST_EXTERNAL_SCALER}" = "true" ]; then +# Deterministically pin the scaler mode per test (the chart default is external); +# built-in strategies keep TEST_EXTERNAL_SCALER=false so they still cover selenium-grid. +if [ "${SELENIUM_GRID_AUTOSCALING}" = "true" ]; then HELM_COMMAND_SET_IMAGES="${HELM_COMMAND_SET_IMAGES} \ - --set autoscaling.externalScaler.enabled=true \ + --set autoscaling.externalScaler.enabled=${TEST_EXTERNAL_SCALER} \ " fi From d0ed386f570602c00f6c1f669116d67c22e0a082 Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Mon, 13 Jul 2026 03:43:10 +0700 Subject: [PATCH 5/5] test(chart): make scaler trigger auth assertion mode-aware With the external scaler now the chart default, ScaledObject/ScaledJob triggers use type: external with a scalerAddress and no authenticationRef, which broke test_scaler_triggers_authenticationRef_name_is_added (KeyError: authenticationRef). Rewrite it as test_scaler_triggers_auth_wiring: assert scalerAddress and absence of authenticationRef for external triggers, and authenticationRef.name for the built-in selenium-grid triggers. This passes for either default. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Viet Nguyen Duc --- tests/charts/make/chart_test.sh | 2 +- tests/charts/templates/test.py | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/charts/make/chart_test.sh b/tests/charts/make/chart_test.sh index f92238dc79..1d393660dc 100755 --- a/tests/charts/make/chart_test.sh +++ b/tests/charts/make/chart_test.sh @@ -66,7 +66,7 @@ BASIC_AUTH_EMBEDDED_URL=${BASIC_AUTH_EMBEDDED_URL:-"false"} SELENIUM_GRID_MONITORING=${SELENIUM_GRID_MONITORING:-"true"} TEST_EXISTING_PTS=${TEST_EXISTING_PTS:-"false"} TEST_MULTIPLE_VERSIONS=${TEST_MULTIPLE_VERSIONS:-"false"} -TEST_MULTIPLE_VERSIONS_EXPLICIT=${TEST_MULTIPLE_VERSIONS_EXPLICIT:-"true"} +TEST_MULTIPLE_VERSIONS_EXPLICIT=${TEST_MULTIPLE_VERSIONS_EXPLICIT:-"false"} TEST_MULTIPLE_PLATFORMS=${TEST_MULTIPLE_PLATFORMS:-"false"} TEST_MULTIPLE_PLATFORMS_RELAY=${TEST_MULTIPLE_PLATFORMS_RELAY:-"false"} TEST_CUSTOM_SPECIFIC_NAME=${TEST_CUSTOM_SPECIFIC_NAME:-"false"} diff --git a/tests/charts/templates/test.py b/tests/charts/templates/test.py index 5b040ef8e3..68afb51edc 100644 --- a/tests/charts/templates/test.py +++ b/tests/charts/templates/test.py @@ -452,18 +452,28 @@ def test_router_envFrom_secretRef_name_use_external_secret_when_basicAuth_nameOv is_present = True self.assertTrue(is_present, "ENV variable from secretRef name is not set to external secret") - def test_scaler_triggers_authenticationRef_name_is_added(self): + def test_scaler_triggers_auth_wiring(self): resources_name = [ f'{RELEASE_NAME}selenium-node-chrome', f'{RELEASE_NAME}selenium-node-edge', f'{RELEASE_NAME}selenium-node-firefox', ] - is_present = False for doc in LIST_OF_DOCUMENTS: if doc['metadata']['name'] in resources_name and doc['kind'] == 'ScaledObject': - logger.info(f"Assert authenticationRef name is added to scaler triggers") - name = doc['spec']['triggers'][0]['authenticationRef']['name'] - self.assertTrue(name, f'{RELEASE_NAME}selenium-scaler-trigger-auth') + trigger = doc['spec']['triggers'][0] + if trigger['type'] == 'external': + logger.info(f"Assert external scaler trigger has scalerAddress and no authenticationRef") + self.assertIn( + 'scalerAddress', + trigger['metadata'], + 'scalerAddress must be set for the external scaler trigger', + ) + self.assertNotIn( + 'authenticationRef', trigger, 'authenticationRef must not be set in external scaler mode' + ) + else: + logger.info(f"Assert authenticationRef name is added to selenium-grid scaler triggers") + self.assertTrue(trigger['authenticationRef']['name'], f'{RELEASE_NAME}selenium-scaler-trigger-auth') def test_scaler_triggers_parameter_nodeMaxSessions_global_and_individual_value(self): resources_name = {