browser-controller

June 19, 2026 · View on GitHub

Kubernetes operator for the Selenosis platform. It reconciles Browser and BrowserConfig custom resources into ephemeral browser Pods — one Pod per Browser — with deterministic, finalizer-backed cleanup.

GitHub release Go Reference Docker Pulls codecov Go Report Card License


What it does

  • Reconciles Browser → Pod. Each Browser resource becomes exactly one Pod with the same name, built from a reusable BrowserConfig template.
  • Resolves configuration. Merges BrowserConfig in a deterministic order (version → browser → template).
  • Publishes status. Continuously updates Browser.status (phase, pod IP, container statuses, human-readable failure reason).
  • Guarantees cleanup. A finalizer ensures the Browser CR and its Pod are always removed — on success, failure, eviction, idle, or external delete.
  • Treats pods as ephemeral. Pods are non-restarting; failures are terminal and surfaced in status before deletion.

It is the only component in the stack that creates or deletes Pods.


How it fits

ComponentRole
selenosisStateless Selenium / Playwright / MCP hub.
seleniferousSidecar proxy inside each browser pod.
browser-controller (this repo)Operator that reconciles Browser / BrowserConfig CRDs into pods. Owns the CRD types.
browser-serviceREST + SSE facade over Browser and BrowserConfig resources.
browser-uiDashboard with live sessions + VNC.
selenosis-deployHelm chart that deploys the whole stack. Start here.

Quickstart

Normally deployed via the Helm chart. To run it standalone, apply the CRDs, RBAC, and the controller:

kubectl apply -f config/crd
kubectl apply -f config/rbac
kubectl apply -f config/controller

Runs in-cluster (in-cluster config) with a ServiceAccount + ClusterRole/ClusterRoleBinding (config/rbac). Ready-to-use BrowserConfig examples live in config/examples.


Custom resources

Two namespaced CRDs, each in its own API group:

  • Browser (brw, group browser.selenosis.io/v1) — a desired browser session (browserName, browserVersion) plus the live Pod state (phase, IP, container statuses) published to .status.
  • BrowserConfig (group browserconfig.selenosis.io/v1) — the browser images and pod templates the controller uses when creating Pods. It does not create Pods itself.
apiVersion: browser.selenosis.io/v1
kind: Browser
metadata:
  name: d568aeff-a91a-449b-834b-d79bf2d6d623
  namespace: default
spec:
  browserName: chrome
  browserVersion: "120.0"
Browser CRD — full spec, status, and printer columns
  • Group/Version: browser.selenosis.io/v1 · Kind: Browser · Scope: Namespaced
  • Resource: browsers · Short name: brw · Categories: selenosis
  • Status subresource: enabled (/status)

Printer columns: Browser (.spec.browserName), Version (.spec.browserVersion), Phase (.status.phase), PodIP (.status.podIP), StartTime (.status.startTime), Age.

Spec

  • browserName (string, required, minLength=1) — e.g. chrome, firefox.
  • browserVersion (string, required, minLength=1) — e.g. 120.0, or latest if supported.

Status (populated by the controller)

  • podIP (string) — IP assigned to the pod.
  • phase (PodPhase)Pending / Running / Succeeded / Failed / Unknown.
  • message (string) — human-readable condition description.
  • reason (string) — short machine reason (e.g. Evicted).
  • startTime (Time) — when the pod started.
  • containerStatuses (array) — per-container name, state, image, restartCount, ports.
kubectl get brw
kubectl describe brw <name>
kubectl get brw <name> -o yaml
BrowserConfig CRD — template, browsers, command/args, merge semantics

BrowserConfig centrally manages defaults (template) and overrides them per browser name and version. Group/Version browserconfig.selenosis.io/v1, Kind BrowserConfig, namespaced, /status subresource enabled.

spec.template — base pod config applied to all browsers/versions unless overridden. Supported fields (all optional): labels, annotations, env, resources, imagePullPolicy, volumes, volumeMounts, nodeSelector, affinity, tolerations, hostAliases, initContainers, sidecars, privileged, imagePullSecrets, dnsConfig, securityContext, command, args, workingDir.

spec.browsers — required map of browser-specific, version-specific config:

browsers:
  chrome:
    "120.0":
      image: selenium/standalone-chrome:120.0
  firefox:
    "118.0":
      image: selenium/standalone-firefox:118.0

command / args override the container ENTRYPOINT / CMD at three levels: template (default for the main browser container), browser version (override per version), and per sidecar/init container. nil inherits from the template.

# Custom entrypoint to start a Playwright server
browsers:
  playwright-chromium:
    "1.59.1":
      image: mcr.microsoft.com/playwright:v1.59.1
      command: ["sh", "-c", "cd /opt/pw && exec ./node_modules/.bin/playwright-core run-server --port 4444 --host 0.0.0.0"]

# CLI args for an MCP image that already has an entrypoint
  playwright-mcp:
    "0.0.75":
      image: mcr.microsoft.com/playwright/mcp:v0.0.75
      args: ["--port", "8808", "--host", "0.0.0.0"]

Merge semantics — two tiers, later overrides earlier: spec.template (base) → the per-version entry at spec.browsers[name][version].

  • nil fields inherit from the template.
  • Maps and lists are merged with deduplication (override wins on key conflict), not replaced.
  • Merge keys: sidecars/init containers by name, env vars by name, volumes by name, tolerations by key, host aliases by IP, image pull secrets by name, container ports by port number, volume mounts by mount path.

This keeps configuration reusable and avoids Pods rejected for duplicate entries.

Status: version (config version id), lastUpdated (timestamp).


Reconciliation & cleanup

BrowserConfig is cached by the controller; reconciling a Browser resolves its config, creates a Pod with the same name, tracks the Pod's lifecycle, and updates Browser.status. Pods are ephemeral and non-restarting; failures are terminal.

Cleanup is enforced by a finalizer (browserpod.selenosis.io/finalizer) on every Browser CR, so the CR and its Pod are always removed regardless of failure mode — and a human-readable reason is written to Browser.status.message first (and surfaced as an SSE event by browser-service) before the CR disappears.

Cleanup mechanism and scenario matrix

Two internal primitives:

  • deletePod — force-deletes the Pod (gracePeriodSeconds=0); ignores NotFound.
  • deleteBrowser — removes the finalizer, then Deletes the CR (Kubernetes completes deletion immediately once the finalizer is gone).

When a failure is detected, the controller handles it in a single reconcile: force-delete the Pod (if any), write status.phase=Failed with a message, and call deleteBrowser. As a safety net, a guard at the top of Reconcile re-runs deletePod + deleteBrowser whenever it observes a Browser already in Failed, so an interrupted cleanup is retried idempotently on the next reconcile.

ScenarioPodBrowser CR
No matching BrowserConfignever createdFaileddeleted
Pod creation blocked by ResourceQuota (403)never createdFailed / QuotaExceededdeleted
browser-pending-timeout exceedednever createdFailed / PendingTimeoutExceededdeleted
pod-creation-timeout exceeded (stuck Pending)force-deletedFaileddeleted
Init container Terminated (non-zero) / non-transient Waiting (ErrImagePull, …)force-deletedFaileddeleted
Container Terminated / non-transient Waiting (CrashLoopBackOff, …)force-deletedFaileddeleted
Pod phase Failedforce-deletedFaileddeleted
Critical container (browser/seleniferous) Terminated while RunningGC via OwnerReferencedeleteBrowserdeleted
CR DeletionTimestamp set (kubectl delete)explicit delete, waits for terminationfinalizer removed after pod gone → deleted
Pod DeletionTimestamp set while CR alivealready terminatingdeleteBrowserdeleted
Pod stuck Terminating beyond pod-deletion-timeoutforce-deleted (best-effort)finalizer removed → deleted

Example status.message values: Browser configuration not found, pods "b1" is forbidden: exceeded quota: ..., Browser did not start within 5m0s, pod container browser terminated: OOMKilled (exit code 137), Browser pod container browser failed: CrashLoopBackOff - back-off restarting failed container.


Configuration flags

FlagDefaultDescription
--metrics-addr:8080Metrics endpoint bind address.
--health-probe-bind-address:8081Health/readiness probe bind address.
--enable-leader-electionfalseLeader election for HA deployments.
--browser-pod-creation-timeout5mWait for a new Pod to leave Pending before force-delete + Failed.
--browser-pod-deletion-timeout5mWait for a Pod to finish terminating before force-delete.
--browser-pending-timeout0 (disabled)Cap on how long a Browser may stay Pending without a Pod (e.g. blocked by ResourceQuota) before Failed / PendingTimeoutExceeded.
--max-retries3Max retries for conflict resolution on Browser patch/status updates.
--max-workers4Max concurrent reconcile workers.
--rate-limiter-base-delay100msBase delay for the exponential failure rate limiter.
--rate-limiter-max-delay30sMax delay for the exponential failure rate limiter.
--browser-pending-timeout in detail

When the cluster runs out of Pod quota (or another admission plugin blocks Pod creation), the controller keeps retrying and the Browser CR stays Pending indefinitely. This flag gives a deterministic deadline: if a Browser has lived without a Pod longer than the configured duration, the next reconcile marks it Failed (reason PendingTimeoutExceeded, message Browser did not start within <timeout>), and the following reconcile removes it.

The timeout is measured against Browser.metadata.creationTimestamp (not when the controller first saw the CR), so a controller restart does not reset the window. The default 0 disables it and preserves retry-forever behavior.


Build & generate

This project uses make to generate code/manifests and build the image.

make install-tools          # controller-gen, client-gen, lister-gen, informer-gen, deepcopy-gen
make generate && make manifests   # or: make all
make docker-build           # or: make deploy (build + push)
Makefile build variables
VariableDescription
BINARY_NAMEName of the produced binary (fixed: browser-controller).
REGISTRYDocker registry prefix (default: localhost:5000).
IMAGE_NAMEFull image name, derived as $(REGISTRY)/$(BINARY_NAME).
VERSIONImage version/tag (default: develop).
EXTRA_TAGSAdditional -t tags passed to docker-push (default: none).
PLATFORMTarget platform (default: linux/amd64).
CONTAINER_TOOLContainer build tool (default: docker).

REGISTRY and VERSION are expected to be supplied externally so the same Makefile works locally and in CI.


Deployment

Deployed as part of the full stack via the selenosis-deploy Helm chart.


License

Apache-2.0