diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e953f6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + pull_request: + push: + branches: ["main"] + release: + types: [published] + +permissions: + contents: read + +jobs: + test: + name: Go tests (fmt/vet/tidy/test) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run quality checks + unit tests + run: | + make tidy + make fmt + make vet + make test + + docker: + name: Build & Push Docker image (release only) + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + steps: + - name: Checkout (release tag) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set image tag (VERSION) from release tag + id: meta + shell: bash + run: | + set -euo pipefail + echo "version=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build & push via Makefile + env: + REGISTRY: ${{ secrets.REGISTRY }} + VERSION: ${{ steps.meta.outputs.version }} + PLATFORM: linux/amd64 + EXTRA_TAGS: -t ${{ secrets.REGISTRY }}/browser-controller:latest + run: | + make deploy + + - name: Summary + env: + REGISTRY: ${{ secrets.REGISTRY }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + echo "Pushed image: ${REGISTRY}/browser-controller:${VERSION}" diff --git a/Dockerfile b/Dockerfile index 1623d14..6c595df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24.4 AS builder +FROM golang:1.25.0 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 0182c6a..846386d 100644 --- a/Makefile +++ b/Makefile @@ -15,13 +15,14 @@ CLIENT_GEN := $(shell which client-gen) LISTER_GEN := $(shell which lister-gen) INFORMER_GEN := $(shell which informer-gen) -BINARY_NAME := manager -DOCKER_REGISTRY ?= 192.168.1.101:30000 -IMAGE_NAME := $(DOCKER_REGISTRY)/selenosis-controller -IMAGE_TAG ?= v1.0.1 -IMG := $(IMAGE_NAME):$(IMAGE_TAG) -PLATFORM ?= linux/amd64 +BINARY_NAME := browser-controller + +REGISTRY ?= localhost:5000 +IMAGE_NAME := $(REGISTRY)/$(BINARY_NAME) +VERSION ?= develop +EXTRA_TAGS ?= +PLATFORM ?= linux/amd64 CONTAINER_TOOL ?= docker .PHONY: all generate deepcopy client lister informer manifests install-tools verify clean fmt vet tidy docker-build docker-push deploy install help show-vars @@ -86,26 +87,31 @@ manifests: verify: @git diff --exit-code || (echo "Generated code is out of date. Run 'make generate'." && exit 1) -clean: - @rm -rf pkg/clientset pkg/listers pkg/informers - @find $(APIS_PKG) -name 'zz_generated.deepcopy.go' -delete - $(CONTAINER_TOOL) rmi $(IMG) 2>/dev/null || true - docker-build: manifests generate tidy fmt vet - $(CONTAINER_TOOL) build --platform $(PLATFORM) -t $(IMG) . - -docker-push: - $(CONTAINER_TOOL) push $(IMG) + $(CONTAINER_TOOL) buildx build \ + --platform $(PLATFORM) \ + -t $(IMAGE_NAME):$(VERSION) \ + --load \ + . + +docker-push: manifests generate tidy fmt vet + $(CONTAINER_TOOL) buildx build \ + --platform $(PLATFORM) \ + -t $(IMAGE_NAME):$(VERSION) \ + $(EXTRA_TAGS) \ + --push \ + . + +deploy: docker-push -deploy: docker-build docker-push +clean: + $(CONTAINER_TOOL) rmi $(IMAGE_NAME):$(VERSION) 2>/dev/null || true show-vars: - @echo "MODULE: $(MODULE)" @echo "BINARY_NAME: $(BINARY_NAME)" - @echo "DOCKER_REGISTRY: $(DOCKER_REGISTRY)" + @echo "REGISTRY: $(REGISTRY)" @echo "IMAGE_NAME: $(IMAGE_NAME)" - @echo "IMAGE_TAG: $(IMAGE_TAG)" - @echo "IMG: $(IMG)" + @echo "VERSION: $(VERSION)" @echo "PLATFORM: $(PLATFORM)" @echo "CONTAINER_TOOL: $(CONTAINER_TOOL)" diff --git a/README.md b/README.md index 6d5ce87..d656c30 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,351 @@ # Browser Controller -Browser Controller is a Kubernetes controller that manages `Browser` and `BrowserConfig` custom resources for the Selenosis ecosystem. It creates and monitors browser pods based on `Browser` requests and a `BrowserConfig` template/override system. - -## What it does -- Registers `Browser` and `BrowserConfig` CRDs. -- Reconciles `Browser` resources into Pods. -- Maintains `Browser.status` (pod IP, phase, container statuses, start time). -- Caches `BrowserConfig` data in an in-memory store for fast lookups during reconciliation. -- Exposes metrics and health/ready probes. - -## Components -- `cmd/controller/main.go`: controller manager entrypoint, flags, logging, manager setup. -- `controllers/browser`: `BrowserReconciler` (pod creation, status updates, deletion handling). -- `controllers/browserconfig`: `BrowserConfigReconciler` (CRD wiring, store updates). -- `store/browserconfig_store.go`: in-memory cache of merged browser configs keyed by `namespace/browser:version`. -- `apis/browser/v1`: `Browser` CRD types. -- `apis/browserconfig/v1`: `BrowserConfig` CRD types and merge logic. - -## CRDs - -### Browser -Spec fields: -- `browserName` (string) -- `browserVersion` (string) - -Status fields: -- `podIP` -- `phase` -- `message`, `reason` -- `startTime` -- `containerStatuses` - -### BrowserConfig -- `template`: shared defaults (env, resources, sidecars, volumes, labels, annotations, security context, etc.). -- `browsers`: map of browser name -> version -> overrides. -- Merge logic combines `template` with per-version overrides to produce the effective pod spec. - -## Reconciliation flow (high level) -1. A `Browser` resource is created. -2. The controller resolves the effective config from the `BrowserConfigStore`. -3. It creates or updates a Pod owned by the `Browser` resource. -4. Status is updated based on Pod phase, IP, and container state. -5. On deletion, the controller removes the pod and finalizer. - -## Flags -The controller uses controller-runtime flags (see `cmd/controller/main.go`): -- `--metrics-addr` (default `:8080`) -- `--health-probe-bind-address` (default `:8081`) -- `--enable-leader-election` (default `false`) - -## Build +Browser Controller is a Kubernetes controller for the **Selenosis** ecosystem. +It manages `Browser` and `BrowserConfig` custom resources and is responsible for creating, monitoring, and cleaning up ephemeral browser Pods. + +The controller is designed for deterministic browser provisioning with strict lifecycle management. + +--- + +## Overview + +- **Browser** — runtime resource representing a single browser instance. +- **BrowserConfig** — configuration resource defining browser images and pod templates. +- **Controller** — resolves configuration, creates Pods, tracks their lifecycle, and updates status. + +Each `Browser` resource results in **exactly one Pod** with the same name. + +--- + +## What the Controller Does + +- Registers `Browser` and `BrowserConfig` CRDs +- Reconciles `Browser` resources into Pods +- Resolves configuration using `BrowserConfig` (template + overrides) +- Updates `Browser.status` with runtime information +- Ensures proper cleanup via finalizers +- Exposes health, readiness, and metrics endpoints + +--- + +## Requirements & RBAC + +- Runs inside a Kubernetes cluster (in-cluster config) +- Uses a ServiceAccount with ClusterRole / ClusterRoleBinding +- RBAC manifests are located in `config/rbac` +- Examples assume the `default` namespace + +--- + +## Quickstart + +Apply CRDs, RBAC, and deploy the controller: + +```bash +kubectl apply -f config/crd +kubectl apply -f config/rbac +kubectl apply -f config/controller +``` + +BrowserConfig examples `config/examples` + +--- + +## Browser CRD + +`Browser` is a namespaced CustomResource that defines a desired browser session (browser type and version) and exposes the actual runtime state of the underlying Kubernetes Pod (phase, IP, container details). It is used by the browser-controller to manage the full lifecycle of browser pods. + +### API Overview + +- **Group/Version:** `selenosis.io/v1` (adjust if your API group differs) +- **Kind:** `Browser` +- **Scope:** Namespaced +- **Resource:** `browsers` +- **Short name:** `brw` +- **Categories:** `selenosis` +- **Status subresource:** enabled (`/status`) + +The CRD defines additional printer columns for quick inspection: + +- **Browser**: `.spec.browserName` +- **Version**: `.spec.browserVersion` +- **Phase**: `.status.phase` +- **PodIP**: `.status.podIP` +- **StartTime**: `.status.startTime` +- **Age**: `.metadata.creationTimestamp` + +Example: + ```bash -go mod download -go build -o bin/manager ./cmd/manager +kubectl get browsers +kubectl get brw ``` -## Docker -The provided Dockerfile builds a distroless image: +### Spec + +`spec` describes the desired browser configuration: -```Dockerfile -FROM gcr.io/distroless/static:nonroot -WORKDIR / -COPY bin/manager /manager -USER 65532:65532 -ENTRYPOINT ["/manager"] +- **browserName** *(string, required, minLength=1)* + Name of the browser to run (for example: `chrome`, `firefox`). + +- **browserVersion** *(string, required, minLength=1)* + Browser version to use (for example: `91.0`, `120.0`, or `latest` if supported by the controller). + +### Status + +`status` is populated by the controller and reflects the observed state of the browser pod: + +- **podIP** *(string, optional)* + IP address assigned to the pod. + +- **phase** *(PodPhase, optional)* + Current lifecycle phase of the pod (`Pending`, `Running`, `Succeeded`, `Failed`, `Unknown`). + +- **message** *(string, optional)* + Human-readable description of the current condition. + +- **reason** *(string, optional)* + Short, machine-friendly reason (for example: `Evicted`). + +- **startTime** *(Time, optional)* + Timestamp when the pod was started. + +- **containerStatuses** *(array, optional)* + Detailed status for each container: + - **name** — container name + - **state** — current container state (`Pending`, `Running`, `Failed`) + - **image** — container image + - **restartCount** — number of restarts + - **ports** — exposed ports (container/host, protocol, name) + +### Minimal Manifest Example + +```yaml +apiVersion: selenosis.io/v1 +kind: Browser +metadata: + name: d568aeff-a91a-449b-834b-d79bf2d6d623 + namespace: default +spec: + browserName: chrome + browserVersion: "120.0" ``` -Build and run: +Apply and inspect: + ```bash -docker build -t browser-controller:local . +kubectl apply -f browser.yaml +kubectl get brw +kubectl describe brw d568aeff-a91a-449b-834b-d79bf2d6d623 +kubectl get brw d568aeff-a91a-449b-834b-d79bf2d6d623 -o yaml ``` -## Code generation -Makefile targets generate CRDs and clients: -- `make generate` (deepcopy, clientset, listers, informers) -- `make manifests` (CRD and RBAC manifests) -- `make docker-build` (runs `manifests`, `generate`, `tidy`, `fmt`, `vet`) +### Expected Controller Behavior + +- Based on `spec.browserName` and `spec.browserVersion`, the controller creates and manages a dedicated browser pod. +- Runtime details (IP, phase, start time, container statuses) are continuously published to `.status`, allowing UIs and clients to quickly determine browser availability and health. +--- +## BrowserConfig CRD + +`BrowserConfig` is a namespaced CustomResource that defines **browser images and pod-level configuration templates** used by the browser-controller when creating browser pods. +It allows you to centrally manage defaults (template) and override them per **browser name** and **browser version**. + +This CRD does **not** create pods by itself. Instead, it acts as a configuration source consumed by the browser-controller. + +--- + +### API Overview + +- **Group/Version:** `selenosis.io/v1` (adjust if your API group differs) +- **Kind:** `BrowserConfig` +- **Scope:** Namespaced +- **Status subresource:** enabled (`/status`) + +--- + +### Purpose + +`BrowserConfig` provides: + +- A **global pod template** applied to all browsers and versions +- Per-browser and per-version **override capabilities** +- A deterministic **merge strategy** (version → browser → template) +- Centralized control over: + - Browser images + - Resources + - Environment variables + - Volumes and mounts + - Sidecars and init containers + - Scheduling and security settings + +--- + +### Spec + +#### Template + +`spec.template` defines a **base pod configuration** applied to all browsers and versions unless explicitly overridden. + +Supported fields include: + +- `labels`, `annotations` +- `env` +- `resources` +- `volumes`, `volumeMounts` +- `nodeSelector`, `affinity`, `tolerations` +- `hostAliases` +- `initContainers` +- `sidecars` +- `privileged` +- `imagePullSecrets` +- `dnsConfig` +- `securityContext` +- `workingDir` -Tools installed by `make install-tools`: -- `deepcopy-gen`, `client-gen`, `lister-gen`, `informer-gen`, `controller-gen` +All fields are optional. -## Notes -- The controller is stateless and can be scaled horizontally. -- Leader election is supported via `--enable-leader-election`. -- RBAC manifests are generated into `config/rbac` by `make manifests`. +--- + +#### Browsers + +`spec.browsers` is a required map that defines browser-specific and version-specific configuration. + +Structure: + +```yaml +browsers: + : + : + image: + ... +``` + +Example: + +```yaml +browsers: + chrome: + "120.0": + image: selenium/standalone-chrome:120.0 + "121.0": + image: selenium/standalone-chrome:121.0 + firefox: + "118.0": + image: selenium/standalone-firefox:118.0 +``` + +Each browser version supports the same override fields as the template. + +--- + +### Merge Semantics + +Configuration is merged in the following order (later overrides earlier): + +1. **Template** +2. **Browser-level version config** +3. **Explicit version overrides** + +Rules: + +- `nil` fields inherit values from the template +- Maps and lists are **merged**, not replaced +- Sidecars and init containers are merged by **name** +- Environment variables are merged by **variable name** + +This ensures predictable and reusable configuration without duplication. + +--- + +### Status + +`status` reflects metadata about the effective configuration: + +- **version** *(string)* — current configuration version identifier +- **lastUpdated** *(timestamp)* — last update time + +--- + +### Minimal Example + +```yaml +apiVersion: selenosis.io/v1 +kind: BrowserConfig +metadata: + name: default-browser-config + namespace: default +spec: + template: + resources: + requests: + cpu: "500m" + memory: "1Gi" + env: + - name: TZ + value: UTC + + browsers: + chrome: + "120.0": + image: selenium/standalone-chrome:120.0 +``` + +Apply and inspect: + +```bash +kubectl apply -f browserconfig.yaml +kubectl get browserconfig -n +kubectl describe browserconfig default-browser-config +kubectl get browserconfig default-browser-config -o yaml +``` + +--- + +## Reconciliation Model (Summary) + +- `BrowserConfig` is loaded and cached by the controller +- `Browser` reconciliation: + - resolves configuration + - creates a Pod with the same name + - tracks Pod lifecycle + - updates `Browser.status` +- Pods are **non-restarting** and treated as ephemeral +- Failures are terminal and reflected in `Browser.status` + +--- + +## Build & Generate + +This project uses `make` to generate code, manifests, and build the controller image. + +### Install tools + +```bash +make install-tools +``` + +### Generate code and manifests + +```bash +make generate +make manifests +``` + +Or run everything: + +```bash +make all +``` + +### Build and push image + +```bash +make docker-build +make docker-push +``` + +Or combined: + +```bash +make deploy +``` diff --git a/apis/browser/v1/register.go b/apis/browser/v1/register.go index b6a75b4..9d77f15 100644 --- a/apis/browser/v1/register.go +++ b/apis/browser/v1/register.go @@ -36,7 +36,6 @@ var ( AddToScheme = SchemeBuilder.AddToScheme ) -// Resource возвращает GroupResource для ресурса func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() } diff --git a/apis/browserconfig/v1/browser_config.go b/apis/browserconfig/v1/browser_config.go index 7bc6734..5468cba 100644 --- a/apis/browserconfig/v1/browser_config.go +++ b/apis/browserconfig/v1/browser_config.go @@ -371,7 +371,6 @@ func mergeSidecarPtr(template, override *[]Sidecar) *[]Sidecar { return &cp } - // карта для быстрого поиска result := append([]Sidecar{}, *override...) overrideNames := map[string]struct{}{} for _, s := range *override { @@ -390,7 +389,6 @@ func mergeVolumeMountsPtr(template, override *[]corev1.VolumeMount) *[]corev1.Vo if template != nil { for _, t := range *template { - // DeepCopy потому что внутри VolumeMount есть поля-указатели copy := t.DeepCopy() result = append(result, *copy) } diff --git a/apis/browserconfig/v1/register.go b/apis/browserconfig/v1/register.go index b9f8745..379a119 100644 --- a/apis/browserconfig/v1/register.go +++ b/apis/browserconfig/v1/register.go @@ -36,7 +36,6 @@ var ( AddToScheme = SchemeBuilder.AddToScheme ) -// Resource возвращает GroupResource для ресурса func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() } diff --git a/cmd/controller/main.go b/cmd/manager/main.go similarity index 100% rename from cmd/controller/main.go rename to cmd/manager/main.go diff --git a/config/controller/browser-controller.yaml b/config/controller/browser-controller.yaml new file mode 100644 index 0000000..a88b91e --- /dev/null +++ b/config/controller/browser-controller.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: browser-controller + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + role: browser-controller + template: + metadata: + labels: + role: browser-controller + spec: + serviceAccountName: browser-controller + containers: + - name: manager + image: alcounit/browser-controller:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 100m + memory: 64Mi + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + + diff --git a/config/crd/selenosis.io_browserconfigs.yaml b/config/crd/selenosis.io_browserconfigs.yaml index 98a0dab..da21d61 100644 --- a/config/crd/selenosis.io_browserconfigs.yaml +++ b/config/crd/selenosis.io_browserconfigs.yaml @@ -623,8 +623,8 @@ spec: most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred. items: description: The weights of all of the matched WeightedPodAffinityTerm @@ -1027,8 +1027,9 @@ spec: in a Container. properties: name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -1086,6 +1087,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -1198,8 +1236,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. - Must be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -1258,6 +1297,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -1373,7 +1449,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -1516,7 +1592,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -1821,8 +1897,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. - Must be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -1881,6 +1958,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -1996,7 +2110,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -2139,9 +2253,10 @@ spec: operator: description: |- Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. Exists is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). type: string tolerationSeconds: description: |- @@ -2813,7 +2928,7 @@ spec: resources: description: |- resources represents the minimum resources the volume should have. - If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + Users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources @@ -2902,15 +3017,13 @@ spec: volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. If specified, the CSI driver will create or update the volume with the attributes defined in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, - it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass - will be applied to the claim but it's not allowed to reset this field to empty string once it is set. - If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass - will be set by the persistentvolume controller if it exists. + it can be changed after the claim is created. An empty string or nil value indicates that no + VolumeAttributesClass will be applied to the claim. If the claim enters an Infeasible error state, + this field can be reset to its previous value (including nil) to cancel the modification. If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -3092,12 +3205,10 @@ spec: description: |- glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. Deprecated: Glusterfs is deprecated and the in-tree glusterfs type is no longer supported. - More info: https://examples.k8s.io/volumes/glusterfs/README.md properties: endpoints: - description: |- - endpoints is the endpoint name that details Glusterfs topology. - More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + description: endpoints is the endpoint name that + details Glusterfs topology. type: string path: description: |- @@ -3176,7 +3287,7 @@ spec: description: |- iscsi represents an ISCSI Disk resource that is attached to a kubelet's host machine and then exposed to the pod. - More info: https://examples.k8s.io/volumes/iscsi/README.md + More info: https://kubernetes.io/docs/concepts/storage/volumes/#iscsi properties: chapAuthDiscovery: description: chapAuthDiscovery defines whether support @@ -3602,6 +3713,129 @@ spec: type: array x-kubernetes-list-type: atomic type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs + will be addressed to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object secret: description: secret information about the secret data to project @@ -3736,7 +3970,6 @@ spec: description: |- rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. Deprecated: RBD is deprecated and the in-tree rbd type is no longer supported. - More info: https://examples.k8s.io/volumes/rbd/README.md properties: fsType: description: |- @@ -4610,8 +4843,8 @@ spec: most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred. items: description: The weights of all of the matched WeightedPodAffinityTerm @@ -5011,8 +5244,9 @@ spec: in a Container. properties: name: - description: Name of the environment variable. Must be a - C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -5070,6 +5304,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -5183,8 +5454,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -5242,6 +5514,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -5355,7 +5664,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -5503,7 +5812,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -5806,8 +6115,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -5865,6 +6175,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -5978,7 +6325,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -6123,9 +6470,10 @@ spec: operator: description: |- Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. Exists is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). type: string tolerationSeconds: description: |- @@ -6795,7 +7143,7 @@ spec: resources: description: |- resources represents the minimum resources the volume should have. - If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + Users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources @@ -6883,15 +7231,13 @@ spec: volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. If specified, the CSI driver will create or update the volume with the attributes defined in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, - it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass - will be applied to the claim but it's not allowed to reset this field to empty string once it is set. - If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass - will be set by the persistentvolume controller if it exists. + it can be changed after the claim is created. An empty string or nil value indicates that no + VolumeAttributesClass will be applied to the claim. If the claim enters an Infeasible error state, + this field can be reset to its previous value (including nil) to cancel the modification. If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -7073,12 +7419,10 @@ spec: description: |- glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. Deprecated: Glusterfs is deprecated and the in-tree glusterfs type is no longer supported. - More info: https://examples.k8s.io/volumes/glusterfs/README.md properties: endpoints: - description: |- - endpoints is the endpoint name that details Glusterfs topology. - More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + description: endpoints is the endpoint name that details + Glusterfs topology. type: string path: description: |- @@ -7157,7 +7501,7 @@ spec: description: |- iscsi represents an ISCSI Disk resource that is attached to a kubelet's host machine and then exposed to the pod. - More info: https://examples.k8s.io/volumes/iscsi/README.md + More info: https://kubernetes.io/docs/concepts/storage/volumes/#iscsi properties: chapAuthDiscovery: description: chapAuthDiscovery defines whether support @@ -7579,6 +7923,129 @@ spec: type: array x-kubernetes-list-type: atomic type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will + be addressed to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object secret: description: secret information about the secret data to project @@ -7713,7 +8180,6 @@ spec: description: |- rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. Deprecated: RBD is deprecated and the in-tree rbd type is no longer supported. - More info: https://examples.k8s.io/volumes/rbd/README.md properties: fsType: description: |- diff --git a/config/examples/browser-config-multisidecar.yaml b/config/examples/browser-config-multisidecar.yaml new file mode 100644 index 0000000..562f93f --- /dev/null +++ b/config/examples/browser-config-multisidecar.yaml @@ -0,0 +1,196 @@ +apiVersion: selenosis.io/v1 +kind: BrowserConfig +metadata: + name: chrome-config-multisidecar +spec: + template: + labels: + app: browser + env: + - name: SCREEN_RESOLUTION + value: 1920x1080 + - name: DISPLAY + value: "127.0.0.1:0" + + workingDir: /home/user + + securityContext: + runAsGroup: 4096 + runAsUser: 4096 + + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "500m" + memory: "1Gi" + + sidecars: + - name: seleniferous + image: "alcounit/seleniferous:latest" + env: + - name: SESSION_CREATE_TIMEOUT + value: "10m" + - name: SESSION_IDLE_TIMEOUT + value: "10m" + - name: BROWSER_PATH + value: "/" + - name: BROWSER_PORT + value: "4444" + - name: DISPLAY + value: "127.0.0.1:0" + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + resources: + limits: + cpu: "0.2" + memory: "128Mi" + requests: + cpu: "0.1" + memory: "64Mi" + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /tmp + name: tmp + - mountPath: /etc/passwd + name: usergroup + subPath: passwd + - mountPath: /etc/group + name: usergroup + subPath: group + - mountPath: /home/user + name: home + - mountPath: /home/user/Downloads + name: downloads + - name: x-server + env: + - name: SCREEN_RESOLUTION + value: 1920x1080 + - name: DISPLAY + value: "127.0.0.1:0" + image: quay.io/aerokube/xvfb:21.1-1 + ports: + - containerPort: 6000 + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /tmp + name: tmp + - mountPath: /etc/group + name: usergroup + subPath: group + - mountPath: /etc/passwd + name: usergroup + subPath: passwd + - mountPath: /home/user + name: home + - mountPath: /home/user/Downloads + name: downloads + workingDir: /home/user + - name: window-manager + env: + - name: DISPLAY + value: "127.0.0.1:0" + image: quay.io/aerokube/openbox:3.6.1-1 + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /tmp + name: tmp + - mountPath: /etc/group + name: usergroup + subPath: group + - mountPath: /etc/passwd + name: usergroup + subPath: passwd + - mountPath: /home/user + name: home + - mountPath: /home/user/Downloads + name: downloads + workingDir: /home/user + - name: vnc-server + env: + - name: DISPLAY + value: "127.0.0.1:0" + image: quay.io/aerokube/x11vnc:0.9.16-1 + ports: + - containerPort: 5900 + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /tmp + name: tmp + - mountPath: /etc/group + name: usergroup + subPath: group + - mountPath: /etc/passwd + name: usergroup + subPath: passwd + - mountPath: /home/user + name: home + - mountPath: /home/user/Downloads + name: downloads + workingDir: /home/user + + volumes: + - emptyDir: + medium: Memory + name: dshm + - emptyDir: + medium: Memory + name: tmp + - emptyDir: + medium: Memory + name: home + - emptyDir: {} + name: downloads + - configMap: + defaultMode: 420 + name: usergroup + name: usergroup + + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /tmp + name: tmp + - mountPath: /etc/group + name: usergroup + subPath: group + - mountPath: /etc/passwd + name: usergroup + subPath: passwd + - mountPath: /home/user + name: home + - mountPath: /home/user/Downloads + name: downloads + + browsers: + chrome: + "139.0": + image: quay.io/browser/google-chrome-stable:139.0 diff --git a/config/examples/browser-config-singlesidecar.yaml b/config/examples/browser-config-singlesidecar.yaml new file mode 100644 index 0000000..dd49c56 --- /dev/null +++ b/config/examples/browser-config-singlesidecar.yaml @@ -0,0 +1,51 @@ +apiVersion: selenosis.io/v1 +kind: BrowserConfig +metadata: + name: chrome-config-singlesidecar +spec: + template: + labels: + app: browser + env: + - name: SE_VNC_PASSWORD + value: selenoid + + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "500m" + memory: "1Gi" + + sidecars: + - name: seleniferous + image: "192.168.1.101:30000/seleniferous:latest" + imagePullPolicy: IfNotPresent + env: + - name: SESSION_CREATE_TIMEOUT + value: "10m" + - name: SESSION_IDLE_TIMEOUT + value: "10m" + - name: BROWSER_PATH + value: "/" + - name: BROWSER_PORT + value: "4444" + - name: DISPLAY + value: "127.0.0.1:0" + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + resources: + limits: + cpu: "0.2" + memory: "128Mi" + requests: + cpu: "0.1" + memory: "64Mi" + + browsers: + chrome: + "143.0": + image: 192.168.1.101:30000/standalone-chrome:143.0-20251212 diff --git a/config/examples/usergroup_configmap.yaml b/config/examples/usergroup_configmap.yaml new file mode 100644 index 0000000..1d42eed --- /dev/null +++ b/config/examples/usergroup_configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +data: + group: | + root:x:0: + user:x:4096: + passwd: | + root:x:0:0:root:/root:/bin/bash + user:x:4096:4096::/home/user:/usr/sbin/nologin +kind: ConfigMap +metadata: + name: usergroup \ No newline at end of file diff --git a/controllers/browser/browser_reconciler.go b/controllers/browser/browser_reconciler.go index c02a5d0..140bc54 100644 --- a/controllers/browser/browser_reconciler.go +++ b/controllers/browser/browser_reconciler.go @@ -754,13 +754,41 @@ func buildBrowserPod(browser *browserv1.Browser, cfg *configv1.BrowserVersionCon pod.Spec.Containers = sidecarContainers + if browser.Labels != nil { + if pod.Labels == nil { + pod.Labels = map[string]string{} + } + for k, v := range browser.Labels { + pod.Labels[k] = v + } + } + // Pod-level fields if cfg.Labels != nil { - pod.Labels = *cfg.Labels + if pod.Labels == nil { + pod.Labels = map[string]string{} + } + for k, v := range *cfg.Labels { + pod.Labels[k] = v + } + } + + if browser.Annotations != nil { + if pod.Annotations == nil { + pod.Annotations = map[string]string{} + } + for k, v := range browser.Annotations { + pod.Annotations[k] = v + } } if cfg.Annotations != nil { - pod.Annotations = *cfg.Annotations + if pod.Annotations == nil { + pod.Annotations = map[string]string{} + } + for k, v := range *cfg.Annotations { + pod.Annotations[k] = v + } } if cfg.NodeSelector != nil { diff --git a/controllers/browser/browser_reconciler_test.go b/controllers/browser/browser_reconciler_test.go new file mode 100644 index 0000000..283e717 --- /dev/null +++ b/controllers/browser/browser_reconciler_test.go @@ -0,0 +1,2178 @@ +package browser + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + "unsafe" + + browserv1 "github.com/alcounit/browser-controller/apis/browser/v1" + configv1 "github.com/alcounit/browser-controller/apis/browserconfig/v1" + "github.com/alcounit/browser-controller/store" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func newBrowserScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1 scheme: %v", err) + } + if err := browserv1.AddToScheme(scheme); err != nil { + t.Fatalf("add browserv1 scheme: %v", err) + } + if err := configv1.AddToScheme(scheme); err != nil { + t.Fatalf("add configv1 scheme: %v", err) + } + return scheme +} + +func newBrowserClient(scheme *runtime.Scheme, objs ...client.Object) client.Client { + builder := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&browserv1.Browser{}) + if len(objs) > 0 { + builder = builder.WithObjects(objs...) + } + return builder.Build() +} + +func setStoreConfig(t *testing.T, cfgStore *store.BrowserConfigStore, key string, spec *configv1.BrowserVersionConfigSpec) { + t.Helper() + v := reflect.ValueOf(cfgStore).Elem().FieldByName("config") + if !v.IsValid() { + t.Fatalf("config field not found") + } + m := *(*map[string]*configv1.BrowserVersionConfigSpec)(unsafe.Pointer(v.UnsafeAddr())) + m[key] = spec +} + +func TestContainerStateEqual(t *testing.T) { + now := metav1.NewTime(time.Now().UTC()) + a := corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: now}} + b := corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: now}} + if !containerStateEqual(a, b) { + t.Fatalf("expected running states to be equal") + } + + b = corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "Init"}} + if containerStateEqual(a, b) { + t.Fatalf("expected different states to be unequal") + } + + a = corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "A", Message: "m"}} + b = corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "A", Message: "m"}} + if !containerStateEqual(a, b) { + t.Fatalf("expected waiting states to be equal") + } + + a = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Reason: "r"}} + b = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Reason: "r"}} + if !containerStateEqual(a, b) { + t.Fatalf("expected terminated states to be equal") + } + + a = corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: now}} + b = corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: metav1.NewTime(now.Add(1 * time.Second))}} + if containerStateEqual(a, b) { + t.Fatalf("expected running states to be different") + } + + a = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}} + b = corev1.ContainerState{} + if containerStateEqual(a, b) { + t.Fatalf("expected terminated presence mismatch to be different") + } +} + +func TestGetContainerPorts(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Ports: []corev1.ContainerPort{ + {Name: "http", ContainerPort: 4444}, + }, + }, + }, + }, + } + ports := getContainerPorts("main", pod) + if len(ports) != 1 || ports[0].ContainerPort != 4444 { + t.Fatalf("expected one port 4444, got %+v", ports) + } + ports = getContainerPorts("missing", pod) + if len(ports) != 0 { + t.Fatalf("expected no ports, got %+v", ports) + } +} + +func TestLenSidecars(t *testing.T) { + cfg := &configv1.BrowserVersionConfigSpec{} + if lenSidecars(cfg) != 0 { + t.Fatalf("expected 0 sidecars") + } + cfg.Sidecars = &[]configv1.Sidecar{{Name: "s1", Image: "i"}} + if lenSidecars(cfg) != 1 { + t.Fatalf("expected 1 sidecar") + } +} + +func TestBuildBrowserPod(t *testing.T) { + labels := map[string]string{"l": "v"} + annotations := map[string]string{"a": "b"} + priv := true + workingDir := "/work" + sidecars := []configv1.Sidecar{{Name: "seleniferous", Image: "sidecar"}} + cfg := &configv1.BrowserVersionConfigSpec{ + Image: "browser", + Labels: &labels, + Annotations: &annotations, + Sidecars: &sidecars, + Privileged: &priv, + WorkingDir: &workingDir, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Labels: map[string]string{"from": "browser"}, + }, + } + + pod := buildBrowserPod(brw, cfg) + if pod.Name != "b1" || pod.Namespace != "ns" { + t.Fatalf("unexpected pod identity") + } + if len(pod.Spec.Containers) != 2 { + t.Fatalf("expected 2 containers, got %d", len(pod.Spec.Containers)) + } + if pod.Spec.Containers[0].SecurityContext == nil || pod.Spec.Containers[0].SecurityContext.Privileged == nil || !*pod.Spec.Containers[0].SecurityContext.Privileged { + t.Fatalf("expected privileged security context") + } + if pod.Labels["from"] != "browser" || pod.Labels["l"] != "v" { + t.Fatalf("expected merged labels, got %+v", pod.Labels) + } + if pod.Annotations["a"] != "b" { + t.Fatalf("expected annotations to be set") + } +} + +func TestHandleMissingPodConfigNotFound(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, cfgStore, scheme) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + if err := cl.Create(context.Background(), brw); err != nil { + t.Fatalf("create browser: %v", err) + } + + _, err := r.handleMissingPod(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, got); err != nil { + t.Fatalf("get browser: %v", err) + } + if got.Status.Phase != corev1.PodFailed { + t.Fatalf("expected failed status, got %s", got.Status.Phase) + } +} + +func TestHandleMissingPodStatusUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{ + "selenosis.io/browser": "b1", + "selenosis.io/browser.name": "chrome", + "selenosis.io/browser.version": "120", + }, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, statusPatchErr: apierrors.NewInternalError(errors.New("patch"))}, cfgStore, scheme) + + _, err := r.handleMissingPod(context.Background(), brw) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestHandleMissingPodCreatesPod(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + spec := &configv1.BrowserVersionConfigSpec{Image: "img"} + setStoreConfig(t, cfgStore, "ns/chrome:120", spec) + + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, cfgStore, scheme) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + if err := cl.Create(context.Background(), brw); err != nil { + t.Fatalf("create browser: %v", err) + } + + res, err := r.handleMissingPod(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != quickCheck { + t.Fatalf("expected quick requeue, got %v", res.RequeueAfter) + } + + pod := &corev1.Pod{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, pod); err != nil { + t.Fatalf("expected pod to be created: %v", err) + } +} + +func TestUpdateBrowserStatusCriticalContainer(t *testing.T) { + scheme := newBrowserScheme(t) + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + } + if err := cl.Create(context.Background(), brw); err != nil { + t.Fatalf("create browser: %v", err) + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: browserContainerName, + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + Reason: "error", + Message: "boom", + }, + }, + }, + }, + }, + } + pod.Status.StartTime = &now + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, got); err == nil { + t.Fatalf("expected browser to be deleted") + } +} + +func TestUpdateBrowserStatusUpdatesFields(t *testing.T) { + scheme := newBrowserScheme(t) + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + if err := cl.Create(context.Background(), brw); err != nil { + t.Fatalf("create browser: %v", err) + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "browser"}, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + StartTime: &now, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "browser", RestartCount: 1}, + }, + }, + } + + res, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != periodicReconcile { + t.Fatalf("expected periodic requeue, got %v", res.RequeueAfter) + } + + got := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, got); err != nil { + t.Fatalf("get browser: %v", err) + } + if got.Status.PodIP != "10.0.0.1" || got.Status.Phase != corev1.PodRunning { + t.Fatalf("unexpected status: %+v", got.Status) + } + if len(got.Status.ContainerStatuses) != 1 || got.Status.ContainerStatuses[0].RestartCount != 1 { + t.Fatalf("unexpected container statuses: %+v", got.Status.ContainerStatuses) + } +} + +func TestReconcileNotFound(t *testing.T) { + scheme := newBrowserScheme(t) + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "missing"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcileAddsFinalizerAndLabels(t *testing.T) { + scheme := newBrowserScheme(t) + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + if err := cl.Create(context.Background(), brw); err != nil { + t.Fatalf("create browser: %v", err) + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, got); err != nil { + t.Fatalf("get browser: %v", err) + } + if !controllerutil.ContainsFinalizer(got, browserPodFinalizer) { + t.Fatalf("expected finalizer to be set") + } + if got.Labels["selenosis.io/browser"] != "b1" { + t.Fatalf("expected browser label to be set") + } +} + +func TestReconcileFailedBrowserRemovesFinalizer(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + Status: browserv1.BrowserStatus{ + Phase: corev1.PodFailed, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, got); err != nil { + t.Fatalf("get browser: %v", err) + } + if controllerutil.ContainsFinalizer(got, browserPodFinalizer) { + t.Fatalf("expected finalizer to be removed") + } +} + +func TestReconcileFailedBrowserFinalizerRemoveError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + Status: browserv1.BrowserStatus{ + Phase: corev1.PodFailed, + }, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, patchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestHandleDeletionNoFinalizer(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + now := metav1.NewTime(time.Now().UTC()) + brw.DeletionTimestamp = &now + + res, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != 0 { + t.Fatalf("expected no requeue, got %v", res.RequeueAfter) + } +} + +func TestHandleDeletionPodNotFound(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestHandleDeletionPodDeletionInProgress(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{"pod.finalizer"}, + }, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + res, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != quickCheck { + t.Fatalf("expected quick requeue, got %v", res.RequeueAfter) + } +} + +func TestHandleDeletionPodTimeout(t *testing.T) { + scheme := newBrowserScheme(t) + old := metav1.NewTime(time.Now().Add(-podDeletionTimeout - time.Second).UTC()) + now := metav1.NewTime(time.Now().UTC()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &old, + Finalizers: []string{"pod.finalizer"}, + }, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestHandleMissingPodAlreadyExists(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + spec := &configv1.BrowserVersionConfigSpec{Image: "img"} + setStoreConfig(t, cfgStore, "ns/chrome:120", spec) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, cfgStore, scheme) + + res, err := r.handleMissingPod(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != quickCheck { + t.Fatalf("expected quick requeue, got %v", res.RequeueAfter) + } +} + +func TestReconcilePodFailedUpdatesStatus(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + Reason: "Reason", + Message: "Message", + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, got); err != nil { + t.Fatalf("get browser: %v", err) + } + if got.Status.Phase != corev1.PodFailed { + t.Fatalf("expected failed status, got %s", got.Status.Phase) + } +} + +func TestReconcilePodPendingContainerTerminated(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + Reason: "Error", + }, + }, + }, + }, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcilePodPendingWaitingBadReason(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + CreationTimestamp: metav1.NewTime(time.Now().UTC()), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "CrashLoopBackOff", + Message: "boom", + }, + }, + }, + }, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcilePodPendingCreationTimeout(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + CreationTimestamp: metav1.NewTime(time.Now().Add(-podCreationTimeout - time.Second).UTC()), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "ContainerCreating", + }, + }, + }, + }, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestUpdateBrowserStatusNoChanges(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Status: browserv1.BrowserStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + StartTime: &now, + ContainerStatuses: []browserv1.ContainerStatus{ + {Name: "browser", RestartCount: 1}, + }, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "browser"}, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + StartTime: &now, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "browser", RestartCount: 1}, + }, + }, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestBuildBrowserPodWithInitContainersAndVolumes(t *testing.T) { + workDir := "/work" + init := []configv1.Sidecar{{Name: "init", Image: "img", WorkingDir: &workDir}} + volumes := []corev1.Volume{{Name: "v"}} + mounts := []corev1.VolumeMount{{Name: "v", MountPath: "/m"}} + cfg := &configv1.BrowserVersionConfigSpec{ + Image: "browser", + InitContainers: &init, + Volumes: &volumes, + VolumeMounts: &mounts, + WorkingDir: &workDir, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + + pod := buildBrowserPod(brw, cfg) + if len(pod.Spec.InitContainers) != 1 { + t.Fatalf("expected init container") + } + if len(pod.Spec.Volumes) != 1 { + t.Fatalf("expected volume") + } + if pod.Spec.Containers[0].WorkingDir != "/work" { + t.Fatalf("expected working dir") + } +} + +func TestBuildBrowserPodInitContainerFields(t *testing.T) { + workDir := "/work" + cmd := []string{"sh"} + ports := []corev1.ContainerPort{{ContainerPort: 8080}} + env := []corev1.EnvVar{{Name: "A", Value: "B"}} + mounts := []corev1.VolumeMount{{Name: "v", MountPath: "/m"}} + resources := corev1.ResourceRequirements{} + init := []configv1.Sidecar{{ + Name: "init", + Image: "img", + Command: &cmd, + WorkingDir: &workDir, + Ports: &ports, + Env: &env, + VolumeMounts: &mounts, + Resources: &resources, + ImagePullPolicy: corev1.PullAlways, + }} + cfg := &configv1.BrowserVersionConfigSpec{ + Image: "browser", + InitContainers: &init, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + + pod := buildBrowserPod(brw, cfg) + if len(pod.Spec.InitContainers) != 1 { + t.Fatalf("expected init container") + } + if len(pod.Spec.InitContainers[0].Env) != 1 || len(pod.Spec.InitContainers[0].Ports) != 1 { + t.Fatalf("expected init container fields to be set") + } +} + +func TestBuildBrowserPodAllFields(t *testing.T) { + workDir := "/work" + labels := map[string]string{"l": "v"} + annotations := map[string]string{"a": "b"} + env := []corev1.EnvVar{{Name: "ENV", Value: "v"}} + resources := corev1.ResourceRequirements{} + volumes := []corev1.Volume{{Name: "v"}} + mounts := []corev1.VolumeMount{{Name: "v", MountPath: "/m"}} + nodeSelector := map[string]string{"k": "v"} + affinity := &corev1.Affinity{} + tolerations := []corev1.Toleration{{Key: "k"}} + hostAliases := []corev1.HostAlias{{IP: "127.0.0.1", Hostnames: []string{"h"}}} + sidecarEnv := []corev1.EnvVar{{Name: "S", Value: "v"}} + sidecarPorts := []corev1.ContainerPort{{ContainerPort: 123}} + sidecarMounts := []corev1.VolumeMount{{Name: "v", MountPath: "/m"}} + sidecarCmd := []string{"run"} + sidecars := []configv1.Sidecar{{ + Name: "sidecar", + Image: "sidecar-img", + Command: &sidecarCmd, + WorkingDir: &workDir, + Ports: &sidecarPorts, + Env: &sidecarEnv, + VolumeMounts: &sidecarMounts, + Resources: &resources, + ImagePullPolicy: corev1.PullIfNotPresent, + }} + priv := true + pullSecrets := []corev1.LocalObjectReference{{Name: "sec"}} + dnsConfig := &corev1.PodDNSConfig{} + secCtx := &corev1.PodSecurityContext{} + + cfg := &configv1.BrowserVersionConfigSpec{ + Image: "browser", + Labels: &labels, + Annotations: &annotations, + Env: &env, + Resources: &resources, + Volumes: &volumes, + VolumeMounts: &mounts, + NodeSelector: &nodeSelector, + Affinity: affinity, + Tolerations: &tolerations, + HostAliases: &hostAliases, + Sidecars: &sidecars, + Privileged: &priv, + ImagePullSecrets: &pullSecrets, + DNSConfig: dnsConfig, + SecurityContext: secCtx, + WorkingDir: &workDir, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Labels: map[string]string{"from": "browser"}, + Annotations: map[string]string{"ba": "bv"}, + }, + } + + pod := buildBrowserPod(brw, cfg) + if pod.Spec.NodeSelector["k"] != "v" { + t.Fatalf("expected node selector") + } + if pod.Spec.Affinity == nil || pod.Spec.DNSConfig == nil { + t.Fatalf("expected pod spec fields to be set") + } + if len(pod.Spec.Tolerations) != 1 || len(pod.Spec.HostAliases) != 1 { + t.Fatalf("expected tolerations/hostAliases") + } + if len(pod.Spec.ImagePullSecrets) != 1 || pod.Spec.SecurityContext == nil { + t.Fatalf("expected image pull secrets/security context") + } + if pod.Spec.Containers[0].WorkingDir != "/work" { + t.Fatalf("expected working dir on main container") + } + if pod.Spec.Containers[1].Name != "sidecar" { + t.Fatalf("expected sidecar container") + } +} + +func TestBuildBrowserPodBrowserLabelsOnly(t *testing.T) { + cfg := &configv1.BrowserVersionConfigSpec{ + Image: "browser", + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Labels: map[string]string{"only": "browser"}, + }, + } + + pod := buildBrowserPod(brw, cfg) + if pod.Labels["only"] != "browser" { + t.Fatalf("expected browser labels to be applied") + } +} + +type errorClient struct { + client.Client + createErr error + deleteErr error +} + +func (e errorClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if e.createErr != nil { + return e.createErr + } + return e.Client.Create(ctx, obj, opts...) +} + +func (e errorClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if e.deleteErr != nil { + return e.deleteErr + } + return e.Client.Delete(ctx, obj, opts...) +} + +func TestDeletePodNotFound(t *testing.T) { + scheme := newBrowserScheme(t) + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + err := r.deletePod(context.Background(), &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "missing", Namespace: "ns"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDeletePodError(t *testing.T) { + scheme := newBrowserScheme(t) + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}} + base := newBrowserClient(scheme, pod) + cl := errorClient{Client: base, deleteErr: apierrors.NewInternalError(errors.New("delete"))} + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + err := r.deletePod(context.Background(), pod) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestHandleMissingPodCreateError(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + spec := &configv1.BrowserVersionConfigSpec{Image: "img"} + setStoreConfig(t, cfgStore, "ns/chrome:120", spec) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{ + "selenosis.io/browser": "b1", + "selenosis.io/browser.name": "chrome", + "selenosis.io/browser.version": "120", + }, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + base := newBrowserClient(scheme, brw) + cl := errorClient{Client: base, createErr: apierrors.NewInternalError(errors.New("boom"))} + r := NewBrowserReconciler(cl, cfgStore, scheme) + + _, err := r.handleMissingPod(context.Background(), brw) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReconcilePodDeletedDeletesBrowser(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{"pod.finalizer"}, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcilePodNotFoundBrowserFailed(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodFailed}, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcilePodPendingContainerCreatingNoTimeout(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{ + "selenosis.io/browser": "b1", + "selenosis.io/browser.name": "chrome", + "selenosis.io/browser.version": "120", + }, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + CreationTimestamp: metav1.NewTime(time.Now().UTC()), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{Reason: "ContainerCreating"}, + }, + }, + }, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +type patchErrorClient struct { + client.Client + patchErr error + statusPatchErr error + getErr error + getPodErr error +} + +func (p patchErrorClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if p.getErr != nil { + return p.getErr + } + if p.getPodErr != nil { + if _, ok := obj.(*corev1.Pod); ok { + return p.getPodErr + } + } + return p.Client.Get(ctx, key, obj, opts...) +} + +func (p patchErrorClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if p.patchErr != nil { + return p.patchErr + } + return p.Client.Patch(ctx, obj, patch, opts...) +} + +func (p patchErrorClient) Status() client.StatusWriter { + return &statusPatchErrorWriter{StatusWriter: p.Client.Status(), err: p.statusPatchErr} +} + +type statusPatchErrorWriter struct { + client.StatusWriter + err error +} + +func (s *statusPatchErrorWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if s.err != nil { + return s.err + } + return s.StatusWriter.Patch(ctx, obj, patch, opts...) +} + +func TestRetryUpdateGetError(t *testing.T) { + scheme := newBrowserScheme(t) + base := newBrowserClient(scheme) + r := NewBrowserReconciler(patchErrorClient{Client: base, getErr: apierrors.NewBadRequest("bad")}, store.NewBrowserConfigStore(), scheme) + + err := r.retryUpdate(context.Background(), &browserv1.Browser{ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}}, func(*browserv1.Browser) {}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestRetryUpdatePatchError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}} + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, patchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + err := r.retryUpdate(context.Background(), brw, func(b *browserv1.Browser) { b.Labels = map[string]string{"k": "v"} }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestRetryStatusUpdatePatchError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}} + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, statusPatchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + err := r.retryStatusUpdate(context.Background(), brw, func(b *browserv1.Browser) { b.Status.Phase = corev1.PodRunning }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestDeleteBrowserNoFinalizer(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}} + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.deleteBrowser(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, &browserv1.Browser{}); err == nil { + t.Fatalf("expected browser to be deleted") + } +} + +func TestDeleteBrowserDeleteError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + } + base := newBrowserClient(scheme, brw) + cl := errorClient{Client: base, deleteErr: apierrors.NewInternalError(errors.New("delete"))} + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.deleteBrowser(context.Background(), brw) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestHandleDeletionPodDeleteError(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + base := newBrowserClient(scheme, brw, pod) + cl := errorClient{Client: base, deleteErr: apierrors.NewInternalError(errors.New("delete"))} + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + res, err := r.handleDeletion(context.Background(), brw) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestHandleDeletionDeleteSuccess(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + res, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != quickCheck { + t.Fatalf("expected quick requeue, got %v", res.RequeueAfter) + } +} + +func TestHandleDeletionFailedPodGraceDelete(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + } + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + res, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != quickCheck { + t.Fatalf("expected quick requeue, got %v", res.RequeueAfter) + } +} + +func TestHandleDeletionPodGetError(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, getPodErr: apierrors.NewInternalError(errors.New("pod"))}, store.NewBrowserConfigStore(), scheme) + + _, err := r.handleDeletion(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestHandleDeletionFinalizerRemoveError(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, patchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + res, err := r.handleDeletion(context.Background(), brw) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcileDeletionTimestamp(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcileFinalizerAddError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, patchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReconcileLabelUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{"selenosis.io/browser": "wrong"}, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, patchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcilePendingStatusUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, statusPatchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReconcilePendingTerminatedStatusUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{ + "selenosis.io/browser": "b1", + "selenosis.io/browser.name": "chrome", + "selenosis.io/browser.version": "120", + }, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}, + }, + }, + }, + }, + } + base := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(patchErrorClient{Client: base, statusPatchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcilePendingCreationTimeoutStatusUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{ + "selenosis.io/browser": "b1", + "selenosis.io/browser.name": "chrome", + "selenosis.io/browser.version": "120", + }, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + CreationTimestamp: metav1.NewTime(time.Now().Add(-podCreationTimeout - time.Second).UTC()), + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{Reason: "ContainerCreating"}, + }, + }, + }, + }, + } + base := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(patchErrorClient{Client: base, statusPatchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcilePendingWaitingStatusUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + Labels: map[string]string{ + "selenosis.io/browser": "b1", + "selenosis.io/browser.name": "chrome", + "selenosis.io/browser.version": "120", + }, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{Reason: "CrashLoopBackOff"}, + }, + }, + }, + }, + } + base := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(patchErrorClient{Client: base, statusPatchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcileHandleMissingPodCreateError(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + spec := &configv1.BrowserVersionConfigSpec{Image: "img"} + setStoreConfig(t, cfgStore, "ns/chrome:120", spec) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + base := newBrowserClient(scheme, brw) + cl := errorClient{Client: base, createErr: apierrors.NewInternalError(errors.New("create"))} + r := NewBrowserReconciler(cl, cfgStore, scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReconcilePodDeletingDeleteBrowserError(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + DeletionTimestamp: &now, + Finalizers: []string{"pod.finalizer"}, + }, + } + base := newBrowserClient(scheme, brw, pod) + cl := errorClient{Client: base, deleteErr: apierrors.NewInternalError(errors.New("delete"))} + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReconcilePodPendingWaitingDeleteError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{Reason: "CrashLoopBackOff"}, + }, + }, + }, + }, + } + base := newBrowserClient(scheme, brw, pod) + cl := errorClient{Client: base, deleteErr: apierrors.NewInternalError(errors.New("delete"))} + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestUpdateBrowserStatusCriticalSidecar(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{ + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: sidecarContainerName, + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}, + }, + }, + }, + }, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDeleteBrowserFinalizerSuccess(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.deleteBrowser(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDeleteBrowserRetryUpdateError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, patchErr: apierrors.NewInternalError(errors.New("patch"))}, store.NewBrowserConfigStore(), scheme) + + _, err := r.deleteBrowser(context.Background(), brw) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestUpdateBrowserStatusCriticalAlreadyFailed(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Finalizers: []string{browserPodFinalizer}, + }, + Status: browserv1.BrowserStatus{Phase: corev1.PodFailed}, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{ + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: browserContainerName, + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}, + }, + }, + }, + }, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestUpdateBrowserStatusNoContainerStatuses(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcilePodFailedDeleteError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + } + base := newBrowserClient(scheme, brw, pod) + cl := errorClient{Client: base, deleteErr: apierrors.NewInternalError(errors.New("delete"))} + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcilePodPendingPodInitializing(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{Reason: "PodInitializing"}, + }, + }, + }, + }, + } + cl := newBrowserClient(scheme, brw, pod) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestRetryStatusUpdateGetError(t *testing.T) { + scheme := newBrowserScheme(t) + base := newBrowserClient(scheme) + r := NewBrowserReconciler(patchErrorClient{Client: base, getErr: apierrors.NewBadRequest("bad")}, store.NewBrowserConfigStore(), scheme) + + err := r.retryStatusUpdate(context.Background(), &browserv1.Browser{ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}}, func(*browserv1.Browser) {}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestUpdateBrowserStatusBrowserStatusChangedOnly(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: browserv1.BrowserStatus{ + Phase: corev1.PodPending, + PodIP: "", + StartTime: nil, + ContainerStatuses: []browserv1.ContainerStatus{ + {Name: "browser", RestartCount: 1}, + }, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "browser"}}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.2", + StartTime: &now, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "browser", RestartCount: 1}, + }, + }, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestUpdateBrowserStatusContainerStateChange(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: browserv1.BrowserStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []browserv1.ContainerStatus{ + {Name: "browser"}, + }, + }, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "browser"}}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "browser", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{Reason: "pull"}, + }, + }, + }, + }, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} +func TestReconcileBrowserGetError(t *testing.T) { + scheme := newBrowserScheme(t) + base := newBrowserClient(scheme) + r := NewBrowserReconciler(patchErrorClient{Client: base, getErr: apierrors.NewBadRequest("bad")}, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReconcilePodGetError(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "120"}, + } + base := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(patchErrorClient{Client: base, getPodErr: apierrors.NewInternalError(errors.New("pod"))}, store.NewBrowserConfigStore(), scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "ns", Name: "b1"}, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestUpdateBrowserStatusContainerStatusLengthChange(t *testing.T) { + scheme := newBrowserScheme(t) + now := metav1.NewTime(time.Now().UTC()) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Status: browserv1.BrowserStatus{Phase: corev1.PodPending}, + } + cl := newBrowserClient(scheme, brw) + r := NewBrowserReconciler(cl, store.NewBrowserConfigStore(), scheme) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "browser"}}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + StartTime: &now, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "browser", RestartCount: 2}, + }, + }, + } + + _, err := r.updateBrowserStatus(context.Background(), brw, pod) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +type conflictClient struct { + client.Client + patchCalls int + statusPatchCalls int +} + +func (c *conflictClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if c.patchCalls == 0 { + c.patchCalls++ + return apierrors.NewConflict(schema.GroupResource{Group: "selenosis.io", Resource: "browsers"}, obj.GetName(), nil) + } + return c.Client.Patch(ctx, obj, patch, opts...) +} + +func (c *conflictClient) Status() client.StatusWriter { + return &conflictStatusWriter{StatusWriter: c.Client.Status(), parent: c} +} + +type conflictStatusWriter struct { + client.StatusWriter + parent *conflictClient +} + +func (w *conflictStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if w.parent.statusPatchCalls == 0 { + w.parent.statusPatchCalls++ + return apierrors.NewConflict(schema.GroupResource{Group: "selenosis.io", Resource: "browsers"}, obj.GetName(), nil) + } + return w.StatusWriter.Patch(ctx, obj, patch, opts...) +} + +func TestRetryUpdateConflictThenSuccess(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + base := newBrowserClient(scheme, brw) + c := &conflictClient{Client: base} + r := NewBrowserReconciler(c, store.NewBrowserConfigStore(), scheme) + + err := r.retryUpdate(context.Background(), brw, func(b *browserv1.Browser) { + if b.Labels == nil { + b.Labels = map[string]string{} + } + b.Labels["k"] = "v" + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if c.patchCalls == 0 { + t.Fatalf("expected conflict patch to be invoked") + } +} + +func TestRetryStatusUpdateConflictThenSuccess(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + }, + } + base := newBrowserClient(scheme, brw) + c := &conflictClient{Client: base} + r := NewBrowserReconciler(c, store.NewBrowserConfigStore(), scheme) + + err := r.retryStatusUpdate(context.Background(), brw, func(b *browserv1.Browser) { + b.Status.Phase = corev1.PodRunning + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if c.statusPatchCalls == 0 { + t.Fatalf("expected conflict status patch to be invoked") + } +} + +type alwaysConflictClient struct { + client.Client +} + +func (c *alwaysConflictClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + return apierrors.NewConflict(schema.GroupResource{Group: "selenosis.io", Resource: "browsers"}, obj.GetName(), nil) +} + +func (c *alwaysConflictClient) Status() client.StatusWriter { + return &alwaysConflictStatusWriter{StatusWriter: c.Client.Status()} +} + +type alwaysConflictStatusWriter struct { + client.StatusWriter +} + +func (w *alwaysConflictStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + return apierrors.NewConflict(schema.GroupResource{Group: "selenosis.io", Resource: "browsers"}, obj.GetName(), nil) +} + +func TestRetryUpdateMaxConflict(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + } + base := newBrowserClient(scheme, brw) + c := &alwaysConflictClient{Client: base} + r := NewBrowserReconciler(c, store.NewBrowserConfigStore(), scheme) + + err := r.retryUpdate(context.Background(), brw, func(b *browserv1.Browser) { + if b.Labels == nil { + b.Labels = map[string]string{} + } + b.Labels["k"] = "v" + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestRetryStatusUpdateMaxConflict(t *testing.T) { + scheme := newBrowserScheme(t) + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{Name: "b1", Namespace: "ns"}, + } + base := newBrowserClient(scheme, brw) + c := &alwaysConflictClient{Client: base} + r := NewBrowserReconciler(c, store.NewBrowserConfigStore(), scheme) + + err := r.retryStatusUpdate(context.Background(), brw, func(b *browserv1.Browser) { + b.Status.Phase = corev1.PodRunning + }) + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/controllers/browserconfig/browserconfig_reconciler_test.go b/controllers/browserconfig/browserconfig_reconciler_test.go new file mode 100644 index 0000000..caa75eb --- /dev/null +++ b/controllers/browserconfig/browserconfig_reconciler_test.go @@ -0,0 +1,201 @@ +package browserconfig + +import ( + "context" + "errors" + "testing" + "time" + + configv1 "github.com/alcounit/browser-controller/apis/browserconfig/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1 scheme: %v", err) + } + if err := configv1.AddToScheme(scheme); err != nil { + t.Fatalf("add configv1 scheme: %v", err) + } + return scheme +} + +func TestReconcileNotFound(t *testing.T) { + scheme := newTestScheme(t) + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + r := NewBrowserConfigReconciler(cl, scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "default", Name: "missing"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestReconcileAddsFinalizer(t *testing.T) { + scheme := newTestScheme(t) + cfg := &configv1.BrowserConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cfg", + Namespace: "default", + }, + Spec: configv1.BrowserConfigSpec{ + Browsers: map[string]map[string]*configv1.BrowserVersionConfigSpec{ + "chrome": {"120": {Image: "img"}}, + }, + }, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cfg).Build() + r := NewBrowserConfigReconciler(cl, scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "default", Name: "cfg"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &configv1.BrowserConfig{} + if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: "cfg"}, got); err != nil { + t.Fatalf("failed to get config: %v", err) + } + if !controllerutil.ContainsFinalizer(got, browserConfigFinalizer) { + t.Fatalf("expected finalizer to be set") + } +} + +func TestReconcileRemovesFinalizerOnDelete(t *testing.T) { + scheme := newTestScheme(t) + now := metav1.NewTime(time.Now().UTC()) + cfg := &configv1.BrowserConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cfg", + Namespace: "default", + Finalizers: []string{browserConfigFinalizer}, + DeletionTimestamp: &now, + }, + Spec: configv1.BrowserConfigSpec{ + Browsers: map[string]map[string]*configv1.BrowserVersionConfigSpec{ + "chrome": {"120": {Image: "img"}}, + }, + }, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cfg).Build() + r := NewBrowserConfigReconciler(cl, scheme) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "default", Name: "cfg"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := &configv1.BrowserConfig{} + if err := cl.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: "cfg"}, got); err == nil { + if controllerutil.ContainsFinalizer(got, browserConfigFinalizer) { + t.Fatalf("expected finalizer to be removed") + } + } +} + +type errorClient struct { + client.Client + getErr error + updateErr error +} + +func (e errorClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if e.getErr != nil { + return e.getErr + } + return e.Client.Get(ctx, key, obj, opts...) +} + +func (e errorClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if e.updateErr != nil { + return e.updateErr + } + return e.Client.Update(ctx, obj, opts...) +} + +func TestReconcileGetError(t *testing.T) { + scheme := newTestScheme(t) + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + r := NewBrowserConfigReconciler(errorClient{Client: cl, getErr: errors.New("boom")}, scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "default", Name: "cfg"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != mediumRetry { + t.Fatalf("expected medium retry, got %v", res.RequeueAfter) + } +} + +func TestReconcileAddFinalizerUpdateError(t *testing.T) { + scheme := newTestScheme(t) + cfg := &configv1.BrowserConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cfg", + Namespace: "default", + }, + Spec: configv1.BrowserConfigSpec{ + Browsers: map[string]map[string]*configv1.BrowserVersionConfigSpec{ + "chrome": {"120": {Image: "img"}}, + }, + }, + } + base := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cfg).Build() + r := NewBrowserConfigReconciler(errorClient{Client: base, updateErr: errors.New("update")}, scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "default", Name: "cfg"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != shortRetry { + t.Fatalf("expected short retry, got %v", res.RequeueAfter) + } +} + +func TestReconcileRemoveFinalizerUpdateError(t *testing.T) { + scheme := newTestScheme(t) + now := metav1.NewTime(time.Now().UTC()) + cfg := &configv1.BrowserConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cfg", + Namespace: "default", + Finalizers: []string{browserConfigFinalizer}, + DeletionTimestamp: &now, + }, + Spec: configv1.BrowserConfigSpec{ + Browsers: map[string]map[string]*configv1.BrowserVersionConfigSpec{ + "chrome": {"120": {Image: "img"}}, + }, + }, + } + base := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cfg).Build() + r := NewBrowserConfigReconciler(errorClient{Client: base, updateErr: errors.New("update")}, scheme) + + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: "default", Name: "cfg"}, + }) + if err == nil { + t.Fatalf("expected error") + } + if res.RequeueAfter != shortRetry { + t.Fatalf("expected short retry, got %v", res.RequeueAfter) + } +} diff --git a/go.mod b/go.mod index 9712825..2ee959c 100644 --- a/go.mod +++ b/go.mod @@ -1,28 +1,27 @@ module github.com/alcounit/browser-controller -go 1.24.4 +go 1.25.0 require ( - github.com/go-logr/logr v1.4.2 - k8s.io/api v0.33.1 - k8s.io/apimachinery v0.33.1 - k8s.io/client-go v0.33.1 + github.com/go-logr/logr v1.4.3 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver v3.5.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -31,40 +30,41 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/rs/zerolog v1.34.0 - github.com/tebeka/selenium v0.9.9 sigs.k8s.io/controller-runtime v0.20.4 ) diff --git a/go.sum b/go.sum index ebfab8c..2f64e46 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,27 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.41.0/go.mod h1:OauMR7DV8fzvZIl2qg6rkaIhD/vmgk4iwEw/h6ercmg= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e h1:4ZrkT/RzpnROylmoQL57iVUL57wGKTR5O6KpVnbm2tA= -github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -49,45 +37,23 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v27 v27.0.4/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -111,14 +77,15 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= -github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= -github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -132,13 +99,13 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -148,165 +115,103 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tebeka/selenium v0.9.9 h1:cNziB+etNgyH/7KlNI7RMC1ua5aH1+5wUlFQyzeMh+w= -github.com/tebeka/selenium v0.9.9/go.mod h1:5Fr8+pUvU6B1OiPfkdCKdXZyr5znvVkxuPd0NOdZCQc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -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= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= -k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw= k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto= -k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= -k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= -k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=