Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/helm-chart-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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_externalScaler
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 }}
Expand Down
7 changes: 7 additions & 0 deletions .keda-external-scaler/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
bin/
deploy/
tasks/
*.md
.git
.gitignore
Makefile
1 change: 1 addition & 0 deletions .keda-external-scaler/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/bin/
32 changes: 32 additions & 0 deletions .keda-external-scaler/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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
# 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"]
61 changes: 61 additions & 0 deletions .keda-external-scaler/Makefile
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 79 additions & 0 deletions .keda-external-scaler/PLAN.md
Original file line number Diff line number Diff line change
@@ -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"] = "<resolved value>"`
(see `parseExternalScalerMetadata` in KEDA). The metadata parser must strip a
`FromEnv` suffix and treat the key as its base name. Precedence per key:
`<name>` > `<name>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).
114 changes: 114 additions & 0 deletions .keda-external-scaler/README.md
Original file line number Diff line number Diff line change
@@ -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 `<key>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`.
Loading
Loading