diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 03de411cce..7c44a68de9 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -23,17 +23,17 @@ jobs: - "" - tools/setup-envtest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # tag=v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # tag=v6.0.1 - name: Calculate go version id: vars run: echo "go_version=$(make go-version)" >> $GITHUB_OUTPUT - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # tag=v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # tag=v6.1.0 with: go-version: ${{ steps.vars.outputs.go_version }} - name: golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # tag=v8.0.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # tag=v9.2.0 with: - version: v2.3.0 + version: v2.7.2 args: --output.text.print-linter-name=true --output.text.colors=true --timeout 10m working-directory: ${{matrix.working-directory}} diff --git a/.github/workflows/ossf-scorecard.yaml b/.github/workflows/ossf-scorecard.yaml index 671dbc88bd..d45d674957 100644 --- a/.github/workflows/ossf-scorecard.yaml +++ b/.github/workflows/ossf-scorecard.yaml @@ -26,12 +26,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # tag=v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # tag=v6.0.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # tag=v2.4.2 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # tag=v2.4.3 with: results_file: results.sarif results_format: sarif @@ -43,7 +43,7 @@ jobs: # Upload the results as artifacts. - name: "Upload artifact" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # tag=v6.0.0 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/pr-dependabot.yaml b/.github/workflows/pr-dependabot.yaml index b51cab0d8c..344b6ac4c7 100644 --- a/.github/workflows/pr-dependabot.yaml +++ b/.github/workflows/pr-dependabot.yaml @@ -19,12 +19,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # tag=v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # tag=v6.0.1 - name: Calculate go version id: vars run: echo "go_version=$(make go-version)" >> $GITHUB_OUTPUT - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # tag=v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # tag=v6.1.0 with: go-version: ${{ steps.vars.outputs.go_version }} - name: Update all modules diff --git a/.github/workflows/pr-gh-workflow-approve.yaml b/.github/workflows/pr-gh-workflow-approve.yaml index f493fd4003..28be4dac71 100644 --- a/.github/workflows/pr-gh-workflow-approve.yaml +++ b/.github/workflows/pr-gh-workflow-approve.yaml @@ -19,7 +19,7 @@ jobs: actions: write steps: - name: Update PR - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 continue-on-error: true with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 353a0a1c5c..edc14d0b95 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,19 +17,19 @@ jobs: - name: Set env run: echo "RELEASE_TAG=${GITHUB_REF:10}" >> $GITHUB_ENV - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # tag=v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # tag=v6.0.1 - name: Calculate go version id: vars run: echo "go_version=$(make go-version)" >> $GITHUB_OUTPUT - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # tag=v5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # tag=v6.1.0 with: go-version: ${{ steps.vars.outputs.go_version }} - name: Generate release binaries run: | make release - name: Release - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # tag=v2.3.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # tag=v2.5.0 with: draft: false files: tools/setup-envtest/out/* diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2168d72516..451f63c01c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # tag=v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # tag=v6.0.1 - name: Check if PR title is valid env: diff --git a/.golangci.yml b/.golangci.yml index 1741432a01..5c86af65a3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,6 @@ version: "2" run: - go: "1.24" + go: "1.25" timeout: 10m allow-parallel-runners: true linters: @@ -11,6 +11,7 @@ linters: - bidichk - bodyclose - copyloopvar + - depguard - dogsled - dupl - errcheck @@ -22,12 +23,15 @@ linters: - goconst - gocritic - gocyclo + - godoclint - goprintffuncname - govet - importas - ineffassign + - iotamixing - makezero - misspell + - modernize - nakedret - nilerr - nolintlint @@ -40,6 +44,12 @@ linters: - unused - whitespace settings: + depguard: + rules: + forbid-pkg-errors: + deny: + - pkg: sort + desc: Should be replaced with slices package forbidigo: forbid: - pattern: context.Background @@ -50,6 +60,7 @@ linters: disable: - fieldalignment - shadow + - buildtag enable-all: true importas: alias: @@ -66,6 +77,10 @@ linters: - pkg: sigs.k8s.io/controller-runtime alias: ctrl no-unaliased: true + modernize: + disable: + - omitzero + - fmtappendf revive: rules: # The following rules are recommended https://github.com/mgechev/revive#recommended-configuration diff --git a/Makefile b/Makefile index b8e9cfa877..1c1fb7f429 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ SHELL:=/usr/bin/env bash # # Go. # -GO_VERSION ?= 1.24.0 +GO_VERSION ?= 1.25.0 # Use GOPROXY environment variable if set GOPROXY := $(shell go env GOPROXY) @@ -69,18 +69,14 @@ help: ## Display this help ## -------------------------------------- .PHONY: test -test: test-tools ## Run the script check-everything.sh which will check all. +test: ## Run the script check-everything.sh which will check all. TRACE=1 ./hack/check-everything.sh -.PHONY: test-tools -test-tools: ## tests the tools codebase (setup-envtest) - cd tools/setup-envtest && go test ./... - ## -------------------------------------- ## Binaries ## -------------------------------------- -GO_APIDIFF_VER := v0.8.2 +GO_APIDIFF_VER := v0.8.3 GO_APIDIFF_BIN := go-apidiff GO_APIDIFF := $(abspath $(TOOLS_BIN_DIR)/$(GO_APIDIFF_BIN)-$(GO_APIDIFF_VER)) GO_APIDIFF_PKG := github.com/joelanford/go-apidiff @@ -88,7 +84,7 @@ GO_APIDIFF_PKG := github.com/joelanford/go-apidiff $(GO_APIDIFF): # Build go-apidiff from tools folder. GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) $(GO_APIDIFF_PKG) $(GO_APIDIFF_BIN) $(GO_APIDIFF_VER) -CONTROLLER_GEN_VER := v0.17.1 +CONTROLLER_GEN_VER := v0.20.0 CONTROLLER_GEN_BIN := controller-gen CONTROLLER_GEN := $(abspath $(TOOLS_BIN_DIR)/$(CONTROLLER_GEN_BIN)-$(CONTROLLER_GEN_VER)) CONTROLLER_GEN_PKG := sigs.k8s.io/controller-tools/cmd/controller-gen diff --git a/README.md b/README.md index 54bacad42e..8549f4e880 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Compatible k8s.io/*, client-go and minimum Go versions can be looked up in our [ | | k8s.io/*, client-go | minimum Go version | |----------|:-------------------:|:------------------:| +| CR v0.22 | v0.34 | 1.24 | | CR v0.21 | v0.33 | 1.24 | | CR v0.20 | v0.32 | 1.23 | | CR v0.19 | v0.31 | 1.22 | diff --git a/alias.go b/alias.go index 01ba012dcc..e2ac45a5e0 100644 --- a/alias.go +++ b/alias.go @@ -18,6 +18,7 @@ package controllerruntime import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client/config" @@ -104,15 +105,20 @@ var ( // NewControllerManagedBy returns a new controller builder that will be started by the provided Manager. NewControllerManagedBy = builder.ControllerManagedBy - // NewWebhookManagedBy returns a new webhook builder that will be started by the provided Manager. - NewWebhookManagedBy = builder.WebhookManagedBy - // NewManager returns a new Manager for creating Controllers. // Note that if ContentType in the given config is not set, "application/vnd.kubernetes.protobuf" // will be used for all built-in resources of Kubernetes, and "application/json" is for other types // including all CRD resources. NewManager = manager.New + // CreateOrPatch creates or patches the given object obj in the Kubernetes + // cluster. The object's desired state should be reconciled with the existing + // state using the passed in ReconcileFn. obj must be a struct pointer so that + // obj can be patched with the content returned by the Server. + // + // It returns the executed operation and an error. + CreateOrPatch = controllerutil.CreateOrPatch + // CreateOrUpdate creates or updates the given object obj in the Kubernetes // cluster. The object's desired state should be reconciled with the existing // state using the passed in ReconcileFn. obj must be a struct pointer so that @@ -155,3 +161,8 @@ var ( // SetLogger sets a concrete logging implementation for all deferred Loggers. SetLogger = log.SetLogger ) + +// NewWebhookManagedBy returns a new webhook builder for the provided type T. +func NewWebhookManagedBy[T runtime.Object](mgr manager.Manager, obj T) *builder.WebhookBuilder[T] { + return builder.WebhookManagedBy(mgr, obj) +} diff --git a/designs/warmreplicas.md b/designs/warmreplicas.md new file mode 100644 index 0000000000..db14247916 --- /dev/null +++ b/designs/warmreplicas.md @@ -0,0 +1,78 @@ +Add Support for Warm Replicas +============================= + +## Summary + +When controllers manage huge caches, failover takes minutes because follower replicas wait to win leader election before starting informers. “Warm replicas” allow controller-runtime to start sources while a manager instance is still on standby, so the new leader can immediately schedule workers with already-populated queues. This design documents the feature implemented in [PR #3192](https://github.com/kubernetes-sigs/controller-runtime/pull/3192) and answers the outstanding review questions. + +## Motivation + +Controllers reconcile every object from their sources at startup and after leader failover. For sources with millions of objects (e.g., Secrets, ConfigMaps, custom resources across all namespaces) the initial List+Watch can take tens of minutes, delaying recovery. Today a controller only starts its sources inside `Start`, which manager runs **after** acquiring the leader lock. That guarantees downtime equal to the cache warmup time whenever the leader rotates. + +## Goals + +- Allow controller authors to opt a controller (or all controllers) into warmup behavior with a single option (`EnableWarmup`). +- Ensure warmup never changes behavior when disabled. +- Keep the API surface minimal (no exported warmup interface yet). + +## Implemented Changes + +### Manager Warmup Phase + +Manager already buckets runnables (HTTP servers, caches, others, leader election). We added an internal `warmupRunnable` interface: + +```go +type warmupRunnable interface { + Warmup(context.Context) error +} +``` + +During `Start`, the manager now runs: + +1. HTTP servers +2. Webhooks +3. Caches +4. `Others` +5. **Warmup runnables (new)** +6. Leader election runnables once the lock is acquired + +Warmup runnables are also stopped in parallel with non-leader runnables during shutdown to avoid deadlocks. + +### Controller Opt-in + +Controllers expose the option via: + +- `ctrl.Options{Controller: config.Controller{EnableWarmup: ptr.To(true)}}` + +If both `EnableWarmup` and `NeedLeaderElection` are true, controller-runtime registers the controller as a warmup runnable. Calling `Warmup` launches the same event sources and cache sync logic as `Start`, but it does **not** start worker goroutines. Once the manager becomes leader, the controller’s normal `Start` simply spins up workers against the already-initialized queue. Enabling warmup on a controller that does not use leader election is a no-op, as the worker threads do not block on leader election being won. + +### Usage Example + +```go +mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Controller: config.Controller{ + EnableWarmup: ptr.To(true), // make every controller warm up + }, +}) +if err != nil { + panic(err) +} + +builder.ControllerManagedBy(mgr). + Named("slow-source"). + WithOptions(controller.Options{ + EnableWarmup: ptr.To(true), // optional per-controller override + }). + For(&examplev1.Example{}). + Complete(reconciler) +``` + +### Operational Considerations + +- **API server load** – Warm replicas temporarily duplicate List/Watch traffic: each standby replica performs the initial List and opens watches even though the current leader is already doing so. The additional load exists only while a replica is warming its caches, but on huge clusters this can still be expensive depending on the number of warm replicas. +- **Queue depth metrics** – Because warm replicas start their sources before workers run, the `workqueue_depth` metric spikes during warmup even though reconcilers have not begun processing. Alerting or SLOs based on that metric should either ignore the warmup window or switch to per-controller gauges that reset when workers start. + +### References + +- Implementation: [#3192](https://github.com/kubernetes-sigs/controller-runtime/pull/3192) +- Earlier context: [#2005](https://github.com/kubernetes-sigs/controller-runtime/pull/2005), [#2600](https://github.com/kubernetes-sigs/controller-runtime/issues/2600) diff --git a/examples/builtins/main.go b/examples/builtins/main.go index 3a47814d8c..f30c652583 100644 --- a/examples/builtins/main.go +++ b/examples/builtins/main.go @@ -60,8 +60,7 @@ func main() { os.Exit(1) } - if err := ctrl.NewWebhookManagedBy(mgr). - For(&corev1.Pod{}). + if err := ctrl.NewWebhookManagedBy(mgr, &corev1.Pod{}). WithDefaulter(&podAnnotator{}). WithValidator(&podValidator{}). Complete(); err != nil { diff --git a/examples/builtins/mutatingwebhook.go b/examples/builtins/mutatingwebhook.go index a588eba8f9..0f150c9b6c 100644 --- a/examples/builtins/mutatingwebhook.go +++ b/examples/builtins/mutatingwebhook.go @@ -18,10 +18,8 @@ package main import ( "context" - "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -31,12 +29,8 @@ import ( // podAnnotator annotates Pods type podAnnotator struct{} -func (a *podAnnotator) Default(ctx context.Context, obj runtime.Object) error { +func (a *podAnnotator) Default(ctx context.Context, pod *corev1.Pod) error { log := logf.FromContext(ctx) - pod, ok := obj.(*corev1.Pod) - if !ok { - return fmt.Errorf("expected a Pod but got a %T", obj) - } if pod.Annotations == nil { pod.Annotations = map[string]string{} diff --git a/examples/builtins/validatingwebhook.go b/examples/builtins/validatingwebhook.go index 1bee7f7c84..eb08159688 100644 --- a/examples/builtins/validatingwebhook.go +++ b/examples/builtins/validatingwebhook.go @@ -21,7 +21,6 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -33,12 +32,8 @@ import ( type podValidator struct{} // validate admits a pod if a specific annotation exists. -func (v *podValidator) validate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { +func (v *podValidator) validate(ctx context.Context, pod *corev1.Pod) (admission.Warnings, error) { log := logf.FromContext(ctx) - pod, ok := obj.(*corev1.Pod) - if !ok { - return nil, fmt.Errorf("expected a Pod but got a %T", obj) - } log.Info("Validating Pod") key := "example-mutating-admission-webhook" @@ -53,14 +48,14 @@ func (v *podValidator) validate(ctx context.Context, obj runtime.Object) (admiss return nil, nil } -func (v *podValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { +func (v *podValidator) ValidateCreate(ctx context.Context, obj *corev1.Pod) (admission.Warnings, error) { return v.validate(ctx, obj) } -func (v *podValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { +func (v *podValidator) ValidateUpdate(ctx context.Context, oldObj, newObj *corev1.Pod) (admission.Warnings, error) { return v.validate(ctx, newObj) } -func (v *podValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { +func (v *podValidator) ValidateDelete(ctx context.Context, obj *corev1.Pod) (admission.Warnings, error) { return v.validate(ctx, obj) } diff --git a/examples/crd/main.go b/examples/crd/main.go index 0bf65c9890..eec58abc01 100644 --- a/examples/crd/main.go +++ b/examples/crd/main.go @@ -129,8 +129,7 @@ func main() { os.Exit(1) } - err = ctrl.NewWebhookManagedBy(mgr). - For(&api.ChaosPod{}). + err = ctrl.NewWebhookManagedBy(mgr, &api.ChaosPod{}). Complete() if err != nil { setupLog.Error(err, "unable to create webhook") diff --git a/examples/crd/pkg/groupversion_info.go b/examples/crd/pkg/groupversion_info.go index 31dfbbc779..693d255b05 100644 --- a/examples/crd/pkg/groupversion_info.go +++ b/examples/crd/pkg/groupversion_info.go @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +// Package pkg contains API Schema definitions for the chaosapps v1 API group // +kubebuilder:object:generate=true // +groupName=chaosapps.metamagical.io package pkg diff --git a/examples/crd/pkg/zz_generated.deepcopy.go b/examples/crd/pkg/zz_generated.deepcopy.go index cd506a87c0..d2ba59e26d 100644 --- a/examples/crd/pkg/zz_generated.deepcopy.go +++ b/examples/crd/pkg/zz_generated.deepcopy.go @@ -1,3 +1,4 @@ +//go:build !ignore_autogenerated // +build !ignore_autogenerated /* diff --git a/examples/priorityqueue/main.go b/examples/priorityqueue/main.go index 8dacdcc9a3..1dc10c2cbe 100644 --- a/examples/priorityqueue/main.go +++ b/examples/priorityqueue/main.go @@ -24,7 +24,6 @@ import ( "go.uber.org/zap/zapcore" corev1 "k8s.io/api/core/v1" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/builder" kubeconfig "sigs.k8s.io/controller-runtime/pkg/client/config" "sigs.k8s.io/controller-runtime/pkg/config" @@ -52,7 +51,7 @@ func run() error { // Setup a Manager mgr, err := manager.New(kubeconfig.GetConfigOrDie(), manager.Options{ - Controller: config.Controller{UsePriorityQueue: ptr.To(true)}, + Controller: config.Controller{}, }) if err != nil { return fmt.Errorf("failed to set up controller-manager: %w", err) diff --git a/examples/scratch-env/go.mod b/examples/scratch-env/go.mod index a92a25b7d8..06f58bb989 100644 --- a/examples/scratch-env/go.mod +++ b/examples/scratch-env/go.mod @@ -1,9 +1,9 @@ module sigs.k8s.io/controller-runtime/examples/scratch-env -go 1.24.0 +go 1.25.0 require ( - github.com/spf13/pflag v1.0.6 + github.com/spf13/pflag v1.0.9 go.uber.org/zap v1.27.0 sigs.k8s.io/controller-runtime v0.0.0-00010101000000-000000000000 ) @@ -16,12 +16,11 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.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/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -32,36 +31,35 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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.0 // 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/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.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 + 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/api v0.34.0 // indirect - k8s.io/apiextensions-apiserver v0.34.0 // indirect - k8s.io/apimachinery v0.34.0 // indirect - k8s.io/client-go v0.34.0 // indirect + k8s.io/api v0.35.0 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect + k8s.io/apimachinery v0.35.0 // indirect + k8s.io/client-go v0.35.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // 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/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect diff --git a/examples/scratch-env/go.sum b/examples/scratch-env/go.sum index 703b352e28..ef2cb38f06 100644 --- a/examples/scratch-env/go.sum +++ b/examples/scratch-env/go.sum @@ -1,3 +1,5 @@ +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -17,8 +19,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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= @@ -31,8 +33,6 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= @@ -42,16 +42,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX 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/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/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/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= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -73,26 +71,26 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -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/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +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/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= @@ -102,93 +100,68 @@ 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/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.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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -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.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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -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/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.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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.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-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/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= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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/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= -k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= -k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= -k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= -k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= -k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= -k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= -k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +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/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/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= diff --git a/examples/tokenreview/tokenreview.go b/examples/tokenreview/tokenreview.go index cc64545e16..16e4151077 100644 --- a/examples/tokenreview/tokenreview.go +++ b/examples/tokenreview/tokenreview.go @@ -28,7 +28,7 @@ import ( type authenticator struct { } -// authenticator admits a request by the token. +// Handle admits a request by the token. func (a *authenticator) Handle(ctx context.Context, req authentication.Request) authentication.Response { if req.Spec.Token == "invalid" { return authentication.Unauthenticated("invalid is an invalid token", v1.UserInfo{}) diff --git a/go.mod b/go.mod index 36bce9c9e5..b06164f275 100644 --- a/go.mod +++ b/go.mod @@ -1,39 +1,40 @@ module sigs.k8s.io/controller-runtime -go 1.24.0 +go 1.25.0 require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/fsnotify/fsnotify v1.9.0 - github.com/go-logr/logr v1.4.2 + github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/btree v1.1.3 github.com/google/go-cmp v0.7.0 github.com/google/gofuzz v1.2.0 - github.com/onsi/ginkgo/v2 v2.22.0 - github.com/onsi/gomega v1.36.1 - github.com/prometheus/client_golang v1.22.0 - github.com/prometheus/client_model v0.6.1 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.27.0 - golang.org/x/mod v0.21.0 - golang.org/x/sync v0.12.0 - golang.org/x/sys v0.31.0 + golang.org/x/mod v0.29.0 + golang.org/x/sync v0.18.0 + golang.org/x/sys v0.38.0 gomodules.xyz/jsonpatch/v2 v2.4.0 - gopkg.in/evanphx/json-patch.v4 v4.12.0 // Using v4 to match upstream - k8s.io/api v0.34.0 - k8s.io/apiextensions-apiserver v0.34.0 - k8s.io/apimachinery v0.34.0 - k8s.io/apiserver v0.34.0 - k8s.io/client-go v0.34.0 + gopkg.in/evanphx/json-patch.v4 v4.13.0 // Using v4 to match upstream + k8s.io/api v0.35.0 + k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/apiserver v0.35.0 + k8s.io/client-go v0.35.0 k8s.io/klog/v2 v2.130.1 - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 sigs.k8s.io/structured-merge-diff/v6 v6.3.0 sigs.k8s.io/yaml v1.6.0 ) require ( cel.dev/expr v0.24.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -48,10 +49,9 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -62,42 +62,41 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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.0 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/cobra v1.10.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.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 - golang.org/x/tools v0.26.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.72.1 // indirect - google.golang.org/protobuf v1.36.5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + google.golang.org/grpc v1.72.2 // indirect + google.golang.org/protobuf v1.36.8 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/component-base v0.34.0 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/component-base v0.35.0 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 102a137d04..938f89efc2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +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/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -27,9 +29,15 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -44,8 +52,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -59,8 +67,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX 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/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/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= @@ -69,10 +77,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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/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= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -86,6 +94,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= 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= @@ -94,29 +106,30 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= +github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= +github.com/spf13/pflag v1.0.8/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/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -128,30 +141,36 @@ 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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -160,97 +179,72 @@ 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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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-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-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -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/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -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.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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -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/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.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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.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-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/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= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -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/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +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= -k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= -k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= -k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= -k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= -k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= -k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= -k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= -k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= -k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= -k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= -k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= 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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +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/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +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/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= diff --git a/hack/test-all.sh b/hack/test-all.sh index 34d841cfd0..9363fa5963 100755 --- a/hack/test-all.sh +++ b/hack/test-all.sh @@ -26,6 +26,7 @@ fi result=0 go test -v -race ${P_FLAG} ${MOD_OPT} ./... --ginkgo.fail-fast ${GINKGO_ARGS} || result=$? +(cd tools/setup-envtest && go test -v -race ${P_FLAG} ${MOD_OPT} ./... --ginkgo.fail-fast ${GINKGO_ARGS}) || result=$? if [[ -n ${ARTIFACTS:-} ]]; then mkdir -p ${ARTIFACTS} diff --git a/hack/tools/cmd/gomodcheck/main.go b/hack/tools/cmd/gomodcheck/main.go index 5cbaf377e2..afba18b624 100644 --- a/hack/tools/cmd/gomodcheck/main.go +++ b/hack/tools/cmd/gomodcheck/main.go @@ -162,7 +162,7 @@ func modulesFromUpstreamModGraph(upstreamRefList []string) (map[string]map[strin } modToVersionToUpstreamRef := make(map[string]map[string]string) - for _, line := range strings.Split(graph, "\n") { + for line := range strings.SplitSeq(graph, "\n") { ref := strings.SplitN(line, "@", 2)[0] if _, ok := upstreamRefs[ref]; !ok { diff --git a/pkg/builder/controller.go b/pkg/builder/controller.go index 6d906f6e52..840e27b679 100644 --- a/pkg/builder/controller.go +++ b/pkg/builder/controller.go @@ -312,7 +312,7 @@ func (blder *TypedBuilder[request]) doWatch() error { return err } - if reflect.TypeFor[request]() != reflect.TypeOf(reconcile.Request{}) { + if reflect.TypeFor[request]() != reflect.TypeFor[reconcile.Request]() { return fmt.Errorf("For() can only be used with reconcile.Request, got %T", *new(request)) } diff --git a/pkg/builder/controller_test.go b/pkg/builder/controller_test.go index b1c9c3de3b..1f9729d9f4 100644 --- a/pkg/builder/controller_test.go +++ b/pkg/builder/controller_test.go @@ -61,10 +61,10 @@ func (l *testLogger) Enabled(int) bool { return true } -func (l *testLogger) Info(level int, msg string, keysAndValues ...interface{}) { +func (l *testLogger) Info(level int, msg string, keysAndValues ...any) { } -func (l *testLogger) WithValues(keysAndValues ...interface{}) logr.LogSink { +func (l *testLogger) WithValues(keysAndValues ...any) logr.LogSink { return l } @@ -148,7 +148,7 @@ var _ = Describe("application", func() { Expect(instance).To(BeNil()) }) - It("should allow creating a controllerw without calling For", func() { + It("should allow creating a controller without calling For", func() { By("creating a controller manager") m, err := manager.New(cfg, manager.Options{}) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/builder/example_webhook_test.go b/pkg/builder/example_webhook_test.go index c26eba8a13..133da47272 100644 --- a/pkg/builder/example_webhook_test.go +++ b/pkg/builder/example_webhook_test.go @@ -40,8 +40,7 @@ func ExampleWebhookBuilder() { } err = builder. - WebhookManagedBy(mgr). // Create the WebhookManagedBy - For(&examplegroup.ChaosPod{}). // ChaosPod is a CRD. + WebhookManagedBy(mgr, &examplegroup.ChaosPod{}). // Create the WebhookManagedBy Complete() if err != nil { log.Error(err, "could not create webhook") diff --git a/pkg/builder/webhook.go b/pkg/builder/webhook.go index 6263f030a0..d9c57c5e8b 100644 --- a/pkg/builder/webhook.go +++ b/pkg/builder/webhook.go @@ -17,6 +17,7 @@ limitations under the License. package builder import ( + "context" "errors" "net/http" "net/url" @@ -36,63 +37,84 @@ import ( ) // WebhookBuilder builds a Webhook. -type WebhookBuilder struct { +type WebhookBuilder[T runtime.Object] struct { apiType runtime.Object - customDefaulter admission.CustomDefaulter + customDefaulter admission.CustomDefaulter //nolint:staticcheck + defaulter admission.Defaulter[T] customDefaulterOpts []admission.DefaulterOption - customValidator admission.CustomValidator + customValidator admission.CustomValidator //nolint:staticcheck + validator admission.Validator[T] customPath string customValidatorCustomPath string customDefaulterCustomPath string + converterConstructor func(*runtime.Scheme) (conversion.Converter, error) gvk schema.GroupVersionKind mgr manager.Manager config *rest.Config recoverPanic *bool logConstructor func(base logr.Logger, req *admission.Request) logr.Logger + contextFunc func(context.Context, *http.Request) context.Context err error } // WebhookManagedBy returns a new webhook builder. -func WebhookManagedBy(m manager.Manager) *WebhookBuilder { - return &WebhookBuilder{mgr: m} +func WebhookManagedBy[T runtime.Object](m manager.Manager, object T) *WebhookBuilder[T] { + return &WebhookBuilder[T]{mgr: m, apiType: object} } -// TODO(droot): update the GoDoc for conversion. - -// For takes a runtime.Object which should be a CR. -// If the given object implements the admission.Defaulter interface, a MutatingWebhook will be wired for this type. -// If the given object implements the admission.Validator interface, a ValidatingWebhook will be wired for this type. -func (blder *WebhookBuilder) For(apiType runtime.Object) *WebhookBuilder { - if blder.apiType != nil { - blder.err = errors.New("For(...) should only be called once, could not assign multiple objects for webhook registration") - } - blder.apiType = apiType +// WithCustomDefaulter takes an admission.CustomDefaulter interface, a MutatingWebhook with the provided opts (admission.DefaulterOption) +// will be wired for this type. +// +// Deprecated: Use WithDefaulter instead. +func (blder *WebhookBuilder[T]) WithCustomDefaulter(defaulter admission.CustomDefaulter, opts ...admission.DefaulterOption) *WebhookBuilder[T] { + blder.customDefaulter = defaulter + blder.customDefaulterOpts = opts return blder } -// WithDefaulter takes an admission.CustomDefaulter interface, a MutatingWebhook with the provided opts (admission.DefaulterOption) -// will be wired for this type. -func (blder *WebhookBuilder) WithDefaulter(defaulter admission.CustomDefaulter, opts ...admission.DefaulterOption) *WebhookBuilder { - blder.customDefaulter = defaulter +// WithDefaulter sets up the provided admission.Defaulter in a defaulting webhook. +func (blder *WebhookBuilder[T]) WithDefaulter(defaulter admission.Defaulter[T], opts ...admission.DefaulterOption) *WebhookBuilder[T] { + blder.defaulter = defaulter blder.customDefaulterOpts = opts return blder } -// WithValidator takes a admission.CustomValidator interface, a ValidatingWebhook will be wired for this type. -func (blder *WebhookBuilder) WithValidator(validator admission.CustomValidator) *WebhookBuilder { +// WithCustomValidator takes a admission.CustomValidator interface, a ValidatingWebhook will be wired for this type. +// +// Deprecated: Use WithValidator instead. +func (blder *WebhookBuilder[T]) WithCustomValidator(validator admission.CustomValidator) *WebhookBuilder[T] { blder.customValidator = validator return blder } +// WithValidator sets up the provided admission.Validator in a validating webhook. +func (blder *WebhookBuilder[T]) WithValidator(validator admission.Validator[T]) *WebhookBuilder[T] { + blder.validator = validator + return blder +} + +// WithConverter takes a func that constructs a converter.Converter. +// The Converter will then be used by the conversion endpoint for the type passed into NewWebhookManagedBy() +func (blder *WebhookBuilder[T]) WithConverter(converterConstructor func(*runtime.Scheme) (conversion.Converter, error)) *WebhookBuilder[T] { + blder.converterConstructor = converterConstructor + return blder +} + // WithLogConstructor overrides the webhook's LogConstructor. -func (blder *WebhookBuilder) WithLogConstructor(logConstructor func(base logr.Logger, req *admission.Request) logr.Logger) *WebhookBuilder { +func (blder *WebhookBuilder[T]) WithLogConstructor(logConstructor func(base logr.Logger, req *admission.Request) logr.Logger) *WebhookBuilder[T] { blder.logConstructor = logConstructor return blder } +// WithContextFunc overrides the webhook's WithContextFunc. +func (blder *WebhookBuilder[T]) WithContextFunc(contextFunc func(context.Context, *http.Request) context.Context) *WebhookBuilder[T] { + blder.contextFunc = contextFunc + return blder +} + // RecoverPanic indicates whether panics caused by the webhook should be recovered. // Defaults to true. -func (blder *WebhookBuilder) RecoverPanic(recoverPanic bool) *WebhookBuilder { +func (blder *WebhookBuilder[T]) RecoverPanic(recoverPanic bool) *WebhookBuilder[T] { blder.recoverPanic = &recoverPanic return blder } @@ -101,25 +123,25 @@ func (blder *WebhookBuilder) RecoverPanic(recoverPanic bool) *WebhookBuilder { // // Deprecated: WithCustomPath should not be used anymore. // Please use WithValidatorCustomPath or WithDefaulterCustomPath instead. -func (blder *WebhookBuilder) WithCustomPath(customPath string) *WebhookBuilder { +func (blder *WebhookBuilder[T]) WithCustomPath(customPath string) *WebhookBuilder[T] { blder.customPath = customPath return blder } // WithValidatorCustomPath overrides the path of the Validator. -func (blder *WebhookBuilder) WithValidatorCustomPath(customPath string) *WebhookBuilder { +func (blder *WebhookBuilder[T]) WithValidatorCustomPath(customPath string) *WebhookBuilder[T] { blder.customValidatorCustomPath = customPath return blder } // WithDefaulterCustomPath overrides the path of the Defaulter. -func (blder *WebhookBuilder) WithDefaulterCustomPath(customPath string) *WebhookBuilder { +func (blder *WebhookBuilder[T]) WithDefaulterCustomPath(customPath string) *WebhookBuilder[T] { blder.customDefaulterCustomPath = customPath return blder } // Complete builds the webhook. -func (blder *WebhookBuilder) Complete() error { +func (blder *WebhookBuilder[T]) Complete() error { // Set the Config blder.loadRestConfig() @@ -130,13 +152,13 @@ func (blder *WebhookBuilder) Complete() error { return blder.registerWebhooks() } -func (blder *WebhookBuilder) loadRestConfig() { +func (blder *WebhookBuilder[T]) loadRestConfig() { if blder.config == nil { blder.config = blder.mgr.GetConfig() } } -func (blder *WebhookBuilder) setLogConstructor() { +func (blder *WebhookBuilder[T]) setLogConstructor() { if blder.logConstructor == nil { blder.logConstructor = func(base logr.Logger, req *admission.Request) logr.Logger { log := base.WithValues( @@ -156,11 +178,11 @@ func (blder *WebhookBuilder) setLogConstructor() { } } -func (blder *WebhookBuilder) isThereCustomPathConflict() bool { +func (blder *WebhookBuilder[T]) isThereCustomPathConflict() bool { return (blder.customPath != "" && blder.customDefaulter != nil && blder.customValidator != nil) || (blder.customPath != "" && blder.customDefaulterCustomPath != "") || (blder.customPath != "" && blder.customValidatorCustomPath != "") } -func (blder *WebhookBuilder) registerWebhooks() error { +func (blder *WebhookBuilder[T]) registerWebhooks() error { typ, err := blder.getType() if err != nil { return err @@ -201,10 +223,14 @@ func (blder *WebhookBuilder) registerWebhooks() error { } // registerDefaultingWebhook registers a defaulting webhook if necessary. -func (blder *WebhookBuilder) registerDefaultingWebhook() error { - mwh := blder.getDefaultingWebhook() +func (blder *WebhookBuilder[T]) registerDefaultingWebhook() error { + mwh, err := blder.getDefaultingWebhook() + if err != nil { + return err + } if mwh != nil { mwh.LogConstructor = blder.logConstructor + mwh.WithContextFunc = blder.contextFunc path := generateMutatePath(blder.gvk) if blder.customDefaulterCustomPath != "" { generatedCustomPath, err := generateCustomPath(blder.customDefaulterCustomPath) @@ -227,22 +253,31 @@ func (blder *WebhookBuilder) registerDefaultingWebhook() error { return nil } -func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook { - if defaulter := blder.customDefaulter; defaulter != nil { - w := admission.WithCustomDefaulter(blder.mgr.GetScheme(), blder.apiType, defaulter, blder.customDefaulterOpts...) - if blder.recoverPanic != nil { - w = w.WithRecoverPanic(*blder.recoverPanic) +func (blder *WebhookBuilder[T]) getDefaultingWebhook() (*admission.Webhook, error) { + var w *admission.Webhook + if blder.defaulter != nil { + if blder.customDefaulter != nil { + return nil, errors.New("only one of Defaulter or CustomDefaulter can be set") } - return w + w = admission.WithDefaulter(blder.mgr.GetScheme(), blder.defaulter, blder.customDefaulterOpts...) + } else if blder.customDefaulter != nil { + w = admission.WithCustomDefaulter(blder.mgr.GetScheme(), blder.apiType, blder.customDefaulter, blder.customDefaulterOpts...) } - return nil + if w != nil && blder.recoverPanic != nil { + w = w.WithRecoverPanic(*blder.recoverPanic) + } + return w, nil } // registerValidatingWebhook registers a validating webhook if necessary. -func (blder *WebhookBuilder) registerValidatingWebhook() error { - vwh := blder.getValidatingWebhook() +func (blder *WebhookBuilder[T]) registerValidatingWebhook() error { + vwh, err := blder.getValidatingWebhook() + if err != nil { + return err + } if vwh != nil { vwh.LogConstructor = blder.logConstructor + vwh.WithContextFunc = blder.contextFunc path := generateValidatePath(blder.gvk) if blder.customValidatorCustomPath != "" { generatedCustomPath, err := generateCustomPath(blder.customValidatorCustomPath) @@ -265,41 +300,60 @@ func (blder *WebhookBuilder) registerValidatingWebhook() error { return nil } -func (blder *WebhookBuilder) getValidatingWebhook() *admission.Webhook { - if validator := blder.customValidator; validator != nil { - w := admission.WithCustomValidator(blder.mgr.GetScheme(), blder.apiType, validator) - if blder.recoverPanic != nil { - w = w.WithRecoverPanic(*blder.recoverPanic) +func (blder *WebhookBuilder[T]) getValidatingWebhook() (*admission.Webhook, error) { + var w *admission.Webhook + if blder.validator != nil { + if blder.customValidator != nil { + return nil, errors.New("only one of Validator or CustomValidator can be set") } - return w + w = admission.WithValidator(blder.mgr.GetScheme(), blder.validator) + } else if blder.customValidator != nil { + //nolint:staticcheck + w = admission.WithCustomValidator(blder.mgr.GetScheme(), blder.apiType, blder.customValidator) } - return nil + if w != nil && blder.recoverPanic != nil { + w = w.WithRecoverPanic(*blder.recoverPanic) + } + return w, nil } -func (blder *WebhookBuilder) registerConversionWebhook() error { - ok, err := conversion.IsConvertible(blder.mgr.GetScheme(), blder.apiType) - if err != nil { - log.Error(err, "conversion check failed", "GVK", blder.gvk) - return err - } - if ok { - if !blder.isAlreadyHandled("/convert") { - blder.mgr.GetWebhookServer().Register("/convert", conversion.NewWebhookHandler(blder.mgr.GetScheme())) +func (blder *WebhookBuilder[T]) registerConversionWebhook() error { + if blder.converterConstructor != nil { + converter, err := blder.converterConstructor(blder.mgr.GetScheme()) + if err != nil { + return err + } + + if err := blder.mgr.GetConverterRegistry().RegisterConverter(blder.gvk.GroupKind(), converter); err != nil { + return err } - log.Info("Conversion webhook enabled", "GVK", blder.gvk) + } else { + ok, err := conversion.IsConvertible(blder.mgr.GetScheme(), blder.apiType) + if err != nil { + log.Error(err, "conversion check failed", "GVK", blder.gvk) + return err + } + if !ok { + return nil + } + } + + if !blder.isAlreadyHandled("/convert") { + blder.mgr.GetWebhookServer().Register("/convert", conversion.NewWebhookHandler(blder.mgr.GetScheme(), blder.mgr.GetConverterRegistry())) } + log.Info("Conversion webhook enabled", "GVK", blder.gvk) return nil } -func (blder *WebhookBuilder) getType() (runtime.Object, error) { +func (blder *WebhookBuilder[T]) getType() (runtime.Object, error) { if blder.apiType != nil { return blder.apiType, nil } - return nil, errors.New("For() must be called with a valid object") + return nil, errors.New("NewWebhookManagedBy() must be called with a valid object") } -func (blder *WebhookBuilder) isAlreadyHandled(path string) bool { +func (blder *WebhookBuilder[T]) isAlreadyHandled(path string) bool { if blder.mgr.GetWebhookServer().WebhookMux() == nil { return false } diff --git a/pkg/builder/webhook_test.go b/pkg/builder/webhook_test.go index eb70af2e0a..0bb826ea24 100644 --- a/pkg/builder/webhook_test.go +++ b/pkg/builder/webhook_test.go @@ -49,8 +49,14 @@ const ( svcBaseAddr = "http://svc-name.svc-ns.svc" customPath = "/custom-path" + + userAgentHeader = "User-Agent" + userAgentCtxKey agentCtxKey = "UserAgent" + userAgentValue = "test" ) +type agentCtxKey string + var _ = Describe("webhook", func() { Describe("New", func() { Context("v1 AdmissionReview", func() { @@ -79,35 +85,36 @@ func runTests(admissionReviewVersion string) { close(stop) }) - It("should scaffold a custom defaulting webhook", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("scaffold a defaulting webhook", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestDefaulterObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} - builder.Register(&TestDefaulter{}, &TestDefaulterList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} + builder.Register(&TestDefaulterObject{}, &TestDefaulterList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestDefaulter{}). - WithDefaulter(&TestCustomDefaulter{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { - return admission.DefaultLogConstructor(testingLogger, req) - }). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + webhookBuilder := WebhookManagedBy(m, &TestDefaulterObject{}) + build(webhookBuilder) + err = webhookBuilder. + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"foo.test.org", "version":"v1", - "kind":"TestDefaulter" + "kind":"TestDefaulterObject" }, "resource":{ "group":"foo.test.org", @@ -124,68 +131,76 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a mutating webhook path") + path := generateMutatePath(testDefaulterGVK) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable fields") + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"patch":`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"code":200`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaulter"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a validating webhook path that doesn't exist") + path = generateValidatePath(testDefaulterGVK) + _, err = reader.Seek(0, 0) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } - - By("sending a request to a mutating webhook path") - path := generateMutatePath(testDefaulterGVK) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable fields") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaulter"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - - By("sending a request to a validating webhook path that doesn't exist") - path = generateValidatePath(testDefaulterGVK) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) - }) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + }, + Entry("Custom Defaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithCustomDefaulter(&TestCustomDefaulter{}) + }), + Entry("Defaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithDefaulter(&testDefaulter{}) + }), + ) - It("should scaffold a custom defaulting webhook with a custom path", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom defaulting webhook with a custom path", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestDefaulterObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} - builder.Register(&TestDefaulter{}, &TestDefaulterList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} + builder.Register(&TestDefaulterObject{}, &TestDefaulterList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - customPath := "/custom-defaulting-path" - err = WebhookManagedBy(m). - For(&TestDefaulter{}). - WithDefaulter(&TestCustomDefaulter{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { - return admission.DefaultLogConstructor(testingLogger, req) - }). - WithDefaulterCustomPath(customPath). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + customPath := "/custom-defaulting-path" + webhookBuilder := WebhookManagedBy(m, &TestDefaulterObject{}) + build(webhookBuilder) + err = webhookBuilder. + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + WithDefaulterCustomPath(customPath). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"foo.test.org", "version":"v1", - "kind":"TestDefaulter" + "kind":"TestDefaulterObject" }, "resource":{ "group":"foo.test.org", @@ -202,66 +217,73 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } - By("sending a request to a mutating webhook path that have been overriten by a custom path") - path, err := generateCustomPath(customPath) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable fields") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaulter"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - - By("sending a request to a mutating webhook path") - path = generateMutatePath(testDefaulterGVK) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) - }) + By("sending a request to a mutating webhook path that have been overriten by a custom path") + path, err := generateCustomPath(customPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable fields") + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"patch":`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"code":200`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaulter"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a mutating webhook path") + path = generateMutatePath(testDefaulterGVK) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + }, + Entry("Custom Defaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithCustomDefaulter(&TestCustomDefaulter{}) + }), + Entry("Defaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithDefaulter(&testDefaulter{}) + }), + ) - It("should scaffold a custom defaulting webhook which recovers from panics", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom defaulting webhook which recovers from panics", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestDefaulterObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} - builder.Register(&TestDefaulter{}, &TestDefaulterList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} + builder.Register(&TestDefaulterObject{}, &TestDefaulterList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestDefaulter{}). - WithDefaulter(&TestCustomDefaulter{}). - RecoverPanic(true). - // RecoverPanic defaults to true. - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + webhookBuilder := WebhookManagedBy(m, &TestDefaulterObject{}) + build(webhookBuilder) + err = webhookBuilder. + RecoverPanic(true). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"", "version":"v1", - "kind":"TestDefaulter" + "kind":"TestDefaulterObject" }, "resource":{ "group":"", @@ -278,55 +300,65 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } - - By("sending a request to a mutating webhook path") - path := generateMutatePath(testDefaulterGVK) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable fields") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":500`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"message":"panic: fake panic test [recovered]`)) - }) + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a mutating webhook path") + path := generateMutatePath(testDefaulterGVK) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable fields") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":500`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"message":"panic: fake panic test [recovered]`)) + }, + Entry("CustomDefaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithCustomDefaulter(&TestCustomDefaulter{}) + }), + Entry("Defaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithDefaulter(&testDefaulter{}) + }), + ) - It("should scaffold a custom validating webhook", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom validating webhook", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestValidatorObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestValidator{}, &TestValidatorList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestValidatorObject{}, &TestValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestValidator{}). - WithValidator(&TestCustomValidator{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + webhook := WebhookManagedBy(m, &TestValidatorObject{}) + build(webhook) + err = webhook.WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { return admission.DefaultLogConstructor(testingLogger, req) }). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + WithContextFunc(func(ctx context.Context, request *http.Request) context.Context { + return context.WithValue(ctx, userAgentCtxKey, request.Header.Get(userAgentHeader)) + }). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"foo.test.org", "version":"v1", - "kind":"TestValidator" + "kind":"TestValidatorObject" }, "resource":{ "group":"foo.test.org", @@ -344,68 +376,113 @@ func runTests(admissionReviewVersion string) { } } }`) + readerWithCxt := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + "request":{ + "uid":"07e52e8d-4513-11e9-a716-42010a800270", + "kind":{ + "group":"foo.test.org", + "version":"v1", + "kind":"TestValidatorObject" + }, + "resource":{ + "group":"foo.test.org", + "version":"v1", + "resource":"testvalidator" + }, + "namespace":"default", + "name":"foo", + "operation":"UPDATE", + "object":{ + "replica":1 + }, + "oldObject":{ + "replica":1 + } + } +}`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a mutating webhook path that doesn't exist") + path := generateMutatePath(testValidatorGVK) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + + By("sending a request to a validating webhook path") + path = generateValidatePath(testValidatorGVK) + _, err = reader.Seek(0, 0) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } - - By("sending a request to a mutating webhook path that doesn't exist") - path := generateMutatePath(testValidatorGVK) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) - - By("sending a request to a validating webhook path") - path = generateValidatePath(testValidatorGVK) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - }) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"code":403`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a validating webhook with context header validation") + path = generateValidatePath(testValidatorGVK) + _, err = readerWithCxt.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req = httptest.NewRequest("POST", svcBaseAddr+path, readerWithCxt) + req.Header.Add("Content-Type", "application/json") + req.Header.Add(userAgentHeader, userAgentValue) + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"code":200`)) + }, + Entry("CustomValidator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithCustomValidator(&TestCustomValidator{}) + }), + Entry("Validator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithValidator(&testValidator{}) + }), + ) - It("should scaffold a custom validating webhook with a custom path", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom validating webhook with a custom path", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestValidatorObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestValidator{}, &TestValidatorList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestValidatorObject{}, &TestValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - customPath := "/custom-validating-path" - err = WebhookManagedBy(m). - For(&TestValidator{}). - WithValidator(&TestCustomValidator{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + customPath := "/custom-validating-path" + webhookBuilder := WebhookManagedBy(m, &TestValidatorObject{}) + build(webhookBuilder) + err = webhookBuilder.WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { return admission.DefaultLogConstructor(testingLogger, req) }). - WithValidatorCustomPath(customPath). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + WithValidatorCustomPath(customPath). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"foo.test.org", "version":"v1", - "kind":"TestValidator" + "kind":"TestValidatorObject" }, "resource":{ "group":"foo.test.org", @@ -424,64 +501,71 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } - By("sending a request to a valiting webhook path that have been overriten by a custom path") - path, err := generateCustomPath(customPath) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - - By("sending a request to a validating webhook path") - path = generateValidatePath(testValidatorGVK) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) - }) + By("sending a request to a valiting webhook path that have been overriten by a custom path") + path, err := generateCustomPath(customPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body.String()).To(ContainSubstring(`"code":403`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a validating webhook path") + path = generateValidatePath(testValidatorGVK) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + }, + Entry("CustomValidator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithCustomValidator(&TestCustomValidator{}) + }), + Entry("Validator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithValidator(&testValidator{}) + }), + ) - It("should scaffold a custom validating webhook which recovers from panics", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom validating webhook which recovers from panics", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestValidatorObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestValidator{}, &TestValidatorList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestValidatorObject{}, &TestValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestValidator{}). - WithValidator(&TestCustomValidator{}). - RecoverPanic(true). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + webhookBuilder := WebhookManagedBy(m, &TestValidatorObject{}) + build(webhookBuilder) + err = webhookBuilder.RecoverPanic(true). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"", "version":"v1", - "kind":"TestValidator" + "kind":"TestValidatorObject" }, "resource":{ "group":"", @@ -497,56 +581,63 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } - By("sending a request to a validating webhook path") - path := generateValidatePath(testValidatorGVK) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":500`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"message":"panic: fake panic test [recovered]`)) - }) + By("sending a request to a validating webhook path") + path := generateValidatePath(testValidatorGVK) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":500`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"message":"panic: fake panic test [recovered]`)) + }, + Entry("CustomValidator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithCustomValidator(&TestCustomValidator{}) + }), + Entry("Validator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithValidator(&testValidator{}) + }), + ) - It("should scaffold a custom validating webhook to validate deletes", func(specCtx SpecContext) { - By("creating a controller manager") - ctx, cancel := context.WithCancel(specCtx) + DescribeTable("should scaffold a custom validating webhook to validate deletes", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestValidatorObject])) { + By("creating a controller manager") + ctx, cancel := context.WithCancel(specCtx) - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestValidator{}, &TestValidatorList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestValidatorObject{}, &TestValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestValidator{}). - WithValidator(&TestCustomValidator{}). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + webhookBuilder := WebhookManagedBy(m, &TestValidatorObject{}) + build(webhookBuilder) + err = webhookBuilder.Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"", "version":"v1", - "kind":"TestValidator" + "kind":"TestValidatorObject" }, "resource":{ "group":"", @@ -562,30 +653,30 @@ func runTests(admissionReviewVersion string) { } }`) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } - - By("sending a request to a validating webhook path to check for failed delete") - path := generateValidatePath(testValidatorGVK) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) - - reader = strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a validating webhook path to check for failed delete") + path := generateValidatePath(testValidatorGVK) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) + + reader = strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ "group":"", "version":"v1", - "kind":"TestValidator" + "kind":"TestValidatorObject" }, "resource":{ "group":"", @@ -600,60 +691,49 @@ func runTests(admissionReviewVersion string) { } } }`) - By("sending a request to a validating webhook path with correct request") - path = generateValidatePath(testValidatorGVK) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) - }) - - It("should send an error when trying to register a webhook with more than one For", func() { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testDefaultValidatorGVK.GroupVersion()} - builder.Register(&TestDefaulter{}, &TestDefaulterList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - err = WebhookManagedBy(m). - For(&TestDefaulter{}). - For(&TestDefaulter{}). - Complete() - Expect(err).To(HaveOccurred()) - }) + By("sending a request to a validating webhook path with correct request") + path = generateValidatePath(testValidatorGVK) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) + }, + Entry("CustomValidator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithCustomValidator(&TestCustomValidator{}) + }), + Entry("Validator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithValidator(&testValidator{}) + }), + ) - It("should scaffold a custom defaulting and validating webhook", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom defaulting and validating webhook", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestDefaultValidator])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestDefaultValidator{}, &TestDefaultValidatorList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestDefaultValidator{}, &TestDefaultValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestDefaultValidator{}). - WithDefaulter(&TestCustomDefaultValidator{}). - WithValidator(&TestCustomDefaultValidator{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { - return admission.DefaultLogConstructor(testingLogger, req) - }). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + webhookBuilder := WebhookManagedBy(m, &TestDefaultValidator{}) + build(webhookBuilder) + err = webhookBuilder. + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ @@ -678,69 +758,86 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("sending a request to a mutating webhook path") + path := generateMutatePath(testDefaultValidatorGVK) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable fields") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a validating webhook path") + path = generateValidatePath(testDefaultValidatorGVK) + _, err = reader.Seek(0, 0) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } - - By("sending a request to a mutating webhook path") - path := generateMutatePath(testDefaultValidatorGVK) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable fields") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - - By("sending a request to a validating webhook path") - path = generateValidatePath(testDefaultValidatorGVK) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - }) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + }, + Entry("CustomDefaulter + CustomValidator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithCustomDefaulter(&TestCustomDefaultValidator{}) + b.WithCustomValidator(&TestCustomDefaultValidator{}) + }), + Entry("CustomDefaulter + Validator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithCustomDefaulter(&TestCustomDefaultValidator{}) + b.WithValidator(&testDefaultValidatorValidator{}) + }), + Entry("Defaulter + CustomValidator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithDefaulter(&testValidatorDefaulter{}) + b.WithCustomValidator(&TestCustomDefaultValidator{}) + }), + Entry("Defaulter + Validator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithDefaulter(&testValidatorDefaulter{}) + b.WithValidator(&testDefaultValidatorValidator{}) + }), + ) - It("should scaffold a custom defaulting and validating webhook with a custom path for each of them", func(specCtx SpecContext) { - By("creating a controller manager") - m, err := manager.New(cfg, manager.Options{}) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + DescribeTable("should scaffold a custom defaulting and validating webhook with a custom path for each of them", + func(specCtx SpecContext, build func(*WebhookBuilder[*TestDefaultValidator])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestDefaultValidator{}, &TestDefaultValidatorList{}) - err = builder.AddToScheme(m.GetScheme()) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestDefaultValidator{}, &TestDefaultValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) - validatingCustomPath := "/custom-validating-path" - defaultingCustomPath := "/custom-defaulting-path" - err = WebhookManagedBy(m). - For(&TestDefaultValidator{}). - WithDefaulter(&TestCustomDefaultValidator{}). - WithValidator(&TestCustomDefaultValidator{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { - return admission.DefaultLogConstructor(testingLogger, req) - }). - WithValidatorCustomPath(validatingCustomPath). - WithDefaulterCustomPath(defaultingCustomPath). - Complete() - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - svr := m.GetWebhookServer() - ExpectWithOffset(1, svr).NotTo(BeNil()) + validatingCustomPath := "/custom-validating-path" + defaultingCustomPath := "/custom-defaulting-path" + webhookBuilder := WebhookManagedBy(m, &TestDefaultValidator{}) + build(webhookBuilder) + err = webhookBuilder. + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + WithValidatorCustomPath(validatingCustomPath). + WithDefaulterCustomPath(defaultingCustomPath). + Complete() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + svr := m.GetWebhookServer() + ExpectWithOffset(1, svr).NotTo(BeNil()) - reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", + reader := strings.NewReader(admissionReviewGV + admissionReviewVersion + `", "request":{ "uid":"07e52e8d-4513-11e9-a716-42010a800270", "kind":{ @@ -765,60 +862,77 @@ func runTests(admissionReviewVersion string) { } }`) - ctx, cancel := context.WithCancel(specCtx) - cancel() - err = svr.Start(ctx) - if err != nil && !os.IsNotExist(err) { - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - } + ctx, cancel := context.WithCancel(specCtx) + cancel() + err = svr.Start(ctx) + if err != nil && !os.IsNotExist(err) { + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } - By("sending a request to a mutating webhook path that have been overriten by the custom path") - path, err := generateCustomPath(defaultingCustomPath) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req := httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w := httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable fields") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - - By("sending a request to a mutating webhook path") - path = generateMutatePath(testDefaultValidatorGVK) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) - - By("sending a request to a valiting webhook path that have been overriten by a custom path") - path, err = generateCustomPath(validatingCustomPath) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - _, err = reader.Seek(0, 0) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) - By("sanity checking the response contains reasonable field") - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) - ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) - EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) - - By("sending a request to a validating webhook path") - path = generateValidatePath(testValidatorGVK) - req = httptest.NewRequest("POST", svcBaseAddr+path, reader) - req.Header.Add("Content-Type", "application/json") - w = httptest.NewRecorder() - svr.WebhookMux().ServeHTTP(w, req) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) - }) + By("sending a request to a mutating webhook path that have been overriten by the custom path") + path, err := generateCustomPath(defaultingCustomPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req := httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w := httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable fields") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":true`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"patch":`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":200`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Defaulting object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a mutating webhook path") + path = generateMutatePath(testDefaultValidatorGVK) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + + By("sending a request to a valiting webhook path that have been overriten by a custom path") + path, err = generateCustomPath(validatingCustomPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + _, err = reader.Seek(0, 0) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK)) + By("sanity checking the response contains reasonable field") + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`)) + ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`)) + EventuallyWithOffset(1, logBuffer).Should(gbytes.Say(`"msg":"Validating object","object":{"name":"foo","namespace":"default"},"namespace":"default","name":"foo","resource":{"group":"foo.test.org","version":"v1","resource":"testdefaultvalidator"},"user":"","requestID":"07e52e8d-4513-11e9-a716-42010a800270"`)) + + By("sending a request to a validating webhook path") + path = generateValidatePath(testValidatorGVK) + req = httptest.NewRequest("POST", svcBaseAddr+path, reader) + req.Header.Add("Content-Type", "application/json") + w = httptest.NewRecorder() + svr.WebhookMux().ServeHTTP(w, req) + ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound)) + }, + Entry("CustomDefaulter + CustomValidator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithCustomDefaulter(&TestCustomDefaultValidator{}) + b.WithCustomValidator(&TestCustomDefaultValidator{}) + }), + Entry("CustomDefaulter + Validator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithCustomDefaulter(&TestCustomDefaultValidator{}) + b.WithValidator(&testDefaultValidatorValidator{}) + }), + Entry("Defaulter + CustomValidator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithDefaulter(&testValidatorDefaulter{}) + b.WithCustomValidator(&TestCustomDefaultValidator{}) + }), + Entry("Defaulter + Validator", func(b *WebhookBuilder[*TestDefaultValidator]) { + b.WithDefaulter(&testValidatorDefaulter{}) + b.WithValidator(&testDefaultValidatorValidator{}) + }), + ) It("should not scaffold a custom defaulting and a custom validating webhook with the same custom path", func() { By("creating a controller manager") @@ -831,10 +945,9 @@ func runTests(admissionReviewVersion string) { err = builder.AddToScheme(m.GetScheme()) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestDefaultValidator{}). - WithDefaulter(&TestCustomDefaultValidator{}). - WithValidator(&TestCustomDefaultValidator{}). + err = WebhookManagedBy(m, &TestDefaultValidator{}). + WithCustomDefaulter(&TestCustomDefaultValidator{}). + WithCustomValidator(&TestCustomDefaultValidator{}). WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { return admission.DefaultLogConstructor(testingLogger, req) }). @@ -843,77 +956,126 @@ func runTests(admissionReviewVersion string) { ExpectWithOffset(1, err).To(HaveOccurred()) }) - It("should not scaffold a custom defaulting when setting a custom path and a defaulting custom path", func() { - By("creating a controller manager") + DescribeTable("should not scaffold a custom defaulting when setting a custom path and a defaulting custom path", + func(build func(*WebhookBuilder[*TestDefaulterObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} + builder.Register(&TestDefaulterObject{}, &TestDefaulterList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + webhookBuilder := WebhookManagedBy(m, &TestDefaulterObject{}) + build(webhookBuilder) + err = webhookBuilder. + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + WithDefaulterCustomPath(customPath). + WithCustomPath(customPath). + Complete() + ExpectWithOffset(1, err).To(HaveOccurred()) + }, + Entry("CustomDefaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithCustomDefaulter(&TestCustomDefaulter{}) + }), + Entry("Defaulter", func(b *WebhookBuilder[*TestDefaulterObject]) { + b.WithDefaulter(&testDefaulter{}) + }), + ) + + DescribeTable("should not scaffold a custom validating when setting a custom path and a validating custom path", + func(build func(*WebhookBuilder[*TestValidatorObject])) { + By("creating a controller manager") + m, err := manager.New(cfg, manager.Options{}) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("registering the type in the Scheme") + builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} + builder.Register(&TestValidatorObject{}, &TestValidatorList{}) + err = builder.AddToScheme(m.GetScheme()) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + webhookBuilder := WebhookManagedBy(m, &TestValidatorObject{}) + build(webhookBuilder) + err = webhookBuilder. + WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { + return admission.DefaultLogConstructor(testingLogger, req) + }). + WithValidatorCustomPath(customPath). + WithCustomPath(customPath). + Complete() + ExpectWithOffset(1, err).To(HaveOccurred()) + }, + Entry("CustomValidator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithCustomValidator(&TestCustomValidator{}) + }), + Entry("Validator", func(b *WebhookBuilder[*TestValidatorObject]) { + b.WithValidator(&testValidator{}) + }), + ) + + It("should error if both a defaulter and a custom defaulter are set", func() { m, err := manager.New(cfg, manager.Options{}) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") - builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestDefaultValidator{}, &TestDefaultValidatorList{}) + builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()} + builder.Register(&TestDefaulterObject{}, &TestDefaulterList{}) err = builder.AddToScheme(m.GetScheme()) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestDefaulter{}). - WithDefaulter(&TestCustomDefaulter{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { - return admission.DefaultLogConstructor(testingLogger, req) - }). - WithDefaulterCustomPath(customPath). - WithCustomPath(customPath). + err = WebhookManagedBy(m, &TestDefaulterObject{}). + WithDefaulter(&testDefaulter{}). + WithCustomDefaulter(&TestCustomDefaulter{}). Complete() ExpectWithOffset(1, err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("only one of Defaulter or CustomDefaulter can be set")) }) - - It("should not scaffold a custom defaulting when setting a custom path and a validating custom path", func() { - By("creating a controller manager") + It("should error if both a validator and a custom validator are set", func() { m, err := manager.New(cfg, manager.Options{}) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - By("registering the type in the Scheme") builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()} - builder.Register(&TestDefaultValidator{}, &TestDefaultValidatorList{}) + builder.Register(&TestValidatorObject{}, &TestValidatorList{}) err = builder.AddToScheme(m.GetScheme()) ExpectWithOffset(1, err).NotTo(HaveOccurred()) - err = WebhookManagedBy(m). - For(&TestValidator{}). - WithValidator(&TestCustomValidator{}). - WithLogConstructor(func(base logr.Logger, req *admission.Request) logr.Logger { - return admission.DefaultLogConstructor(testingLogger, req) - }). - WithDefaulterCustomPath(customPath). - WithCustomPath(customPath). + err = WebhookManagedBy(m, &TestValidatorObject{}). + WithValidator(&testValidator{}). + WithCustomValidator(&TestCustomValidator{}). Complete() ExpectWithOffset(1, err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("only one of Validator or CustomValidator can be set")) }) } // TestDefaulter. -var _ runtime.Object = &TestDefaulter{} +var _ runtime.Object = &TestDefaulterObject{} -const testDefaulterKind = "TestDefaulter" +const testDefaulterKind = "TestDefaulterObject" -type TestDefaulter struct { +type TestDefaulterObject struct { Replica int `json:"replica,omitempty"` Panic bool `json:"panic,omitempty"` } var testDefaulterGVK = schema.GroupVersionKind{Group: "foo.test.org", Version: "v1", Kind: testDefaulterKind} -func (d *TestDefaulter) GetObjectKind() schema.ObjectKind { return d } -func (d *TestDefaulter) DeepCopyObject() runtime.Object { - return &TestDefaulter{ +func (d *TestDefaulterObject) GetObjectKind() schema.ObjectKind { return d } +func (d *TestDefaulterObject) DeepCopyObject() runtime.Object { + return &TestDefaulterObject{ Replica: d.Replica, } } -func (d *TestDefaulter) GroupVersionKind() schema.GroupVersionKind { +func (d *TestDefaulterObject) GroupVersionKind() schema.GroupVersionKind { return testDefaulterGVK } -func (d *TestDefaulter) SetGroupVersionKind(gvk schema.GroupVersionKind) {} +func (d *TestDefaulterObject) SetGroupVersionKind(gvk schema.GroupVersionKind) {} var _ runtime.Object = &TestDefaulterList{} @@ -923,29 +1085,29 @@ func (*TestDefaulterList) GetObjectKind() schema.ObjectKind { return nil } func (*TestDefaulterList) DeepCopyObject() runtime.Object { return nil } // TestValidator. -var _ runtime.Object = &TestValidator{} +var _ runtime.Object = &TestValidatorObject{} -const testValidatorKind = "TestValidator" +const testValidatorKind = "TestValidatorObject" -type TestValidator struct { +type TestValidatorObject struct { Replica int `json:"replica,omitempty"` Panic bool `json:"panic,omitempty"` } var testValidatorGVK = schema.GroupVersionKind{Group: "foo.test.org", Version: "v1", Kind: testValidatorKind} -func (v *TestValidator) GetObjectKind() schema.ObjectKind { return v } -func (v *TestValidator) DeepCopyObject() runtime.Object { - return &TestValidator{ +func (v *TestValidatorObject) GetObjectKind() schema.ObjectKind { return v } +func (v *TestValidatorObject) DeepCopyObject() runtime.Object { + return &TestValidatorObject{ Replica: v.Replica, } } -func (v *TestValidator) GroupVersionKind() schema.GroupVersionKind { +func (v *TestValidatorObject) GroupVersionKind() schema.GroupVersionKind { return testValidatorGVK } -func (v *TestValidator) SetGroupVersionKind(gvk schema.GroupVersionKind) {} +func (v *TestValidatorObject) SetGroupVersionKind(gvk schema.GroupVersionKind) {} var _ runtime.Object = &TestValidatorList{} @@ -988,10 +1150,16 @@ type TestDefaultValidatorList struct{} func (*TestDefaultValidatorList) GetObjectKind() schema.ObjectKind { return nil } func (*TestDefaultValidatorList) DeepCopyObject() runtime.Object { return nil } -// TestCustomDefaulter. type TestCustomDefaulter struct{} func (*TestCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error { + d := obj.(*TestDefaulterObject) + return (&testDefaulter{}).Default(ctx, d) +} + +type testDefaulter struct{} + +func (*testDefaulter) Default(ctx context.Context, obj *TestDefaulterObject) error { logf.FromContext(ctx).Info("Defaulting object") req, err := admission.RequestFromContext(ctx) if err != nil { @@ -1001,24 +1169,41 @@ func (*TestCustomDefaulter) Default(ctx context.Context, obj runtime.Object) err return fmt.Errorf("expected Kind TestDefaulter got %q", req.Kind.Kind) } - d := obj.(*TestDefaulter) //nolint:ifshort - if d.Panic { + if obj.Panic { panic("fake panic test") } - if d.Replica < 2 { - d.Replica = 2 + if obj.Replica < 2 { + obj.Replica = 2 } + return nil } +//nolint:staticcheck var _ admission.CustomDefaulter = &TestCustomDefaulter{} -// TestCustomValidator. - type TestCustomValidator struct{} func (*TestCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + v := obj.(*TestValidatorObject) + return (&testValidator{}).ValidateCreate(ctx, v) +} + +func (*TestCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + v := newObj.(*TestValidatorObject) + old := oldObj.(*TestValidatorObject) + return (&testValidator{}).ValidateUpdate(ctx, old, v) +} + +func (*TestCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + v := obj.(*TestValidatorObject) + return (&testValidator{}).ValidateDelete(ctx, v) +} + +type testValidator struct{} + +func (*testValidator) ValidateCreate(ctx context.Context, obj *TestValidatorObject) (admission.Warnings, error) { logf.FromContext(ctx).Info("Validating object") req, err := admission.RequestFromContext(ctx) if err != nil { @@ -1028,17 +1213,17 @@ func (*TestCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Obje return nil, fmt.Errorf("expected Kind TestValidator got %q", req.Kind.Kind) } - v := obj.(*TestValidator) //nolint:ifshort - if v.Panic { + if obj.Panic { panic("fake panic test") } - if v.Replica < 0 { + if obj.Replica < 0 { return nil, errors.New("number of replica should be greater than or equal to 0") } + return nil, nil } -func (*TestCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { +func (*testValidator) ValidateUpdate(ctx context.Context, oldObj, newObj *TestValidatorObject) (admission.Warnings, error) { logf.FromContext(ctx).Info("Validating object") req, err := admission.RequestFromContext(ctx) if err != nil { @@ -1048,18 +1233,22 @@ func (*TestCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj r return nil, fmt.Errorf("expected Kind TestValidator got %q", req.Kind.Kind) } - v := newObj.(*TestValidator) - old := oldObj.(*TestValidator) - if v.Replica < 0 { + if newObj.Replica < 0 { return nil, errors.New("number of replica should be greater than or equal to 0") } - if v.Replica < old.Replica { - return nil, fmt.Errorf("new replica %v should not be fewer than old replica %v", v.Replica, old.Replica) + if newObj.Replica < oldObj.Replica { + return nil, fmt.Errorf("new replica %v should not be fewer than old replica %v", newObj.Replica, oldObj.Replica) + } + + userAgent, ok := ctx.Value(userAgentCtxKey).(string) + if ok && userAgent != userAgentValue { + return nil, fmt.Errorf("expected %s value is %q in TestCustomValidator got %q", userAgentCtxKey, userAgentValue, userAgent) } + return nil, nil } -func (*TestCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { +func (*testValidator) ValidateDelete(ctx context.Context, obj *TestValidatorObject) (admission.Warnings, error) { logf.FromContext(ctx).Info("Validating object") req, err := admission.RequestFromContext(ctx) if err != nil { @@ -1069,13 +1258,13 @@ func (*TestCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Obje return nil, fmt.Errorf("expected Kind TestValidator got %q", req.Kind.Kind) } - v := obj.(*TestValidator) //nolint:ifshort - if v.Replica > 0 { + if obj.Replica > 0 { return nil, errors.New("number of replica should be less than or equal to 0 to delete") } return nil, nil } +//nolint:staticcheck var _ admission.CustomValidator = &TestCustomValidator{} // TestCustomDefaultValidator for default @@ -1091,7 +1280,7 @@ func (*TestCustomDefaultValidator) Default(ctx context.Context, obj runtime.Obje return fmt.Errorf("expected Kind TestDefaultValidator got %q", req.Kind.Kind) } - d := obj.(*TestDefaultValidator) //nolint:ifshort + d := obj.(*TestDefaultValidator) if d.Replica < 2 { d.Replica = 2 @@ -1099,6 +1288,7 @@ func (*TestCustomDefaultValidator) Default(ctx context.Context, obj runtime.Obje return nil } +//nolint:staticcheck var _ admission.CustomDefaulter = &TestCustomDefaulter{} // TestCustomDefaultValidator for validation @@ -1113,7 +1303,7 @@ func (*TestCustomDefaultValidator) ValidateCreate(ctx context.Context, obj runti return nil, fmt.Errorf("expected Kind TestDefaultValidator got %q", req.Kind.Kind) } - v := obj.(*TestDefaultValidator) //nolint:ifshort + v := obj.(*TestDefaultValidator) if v.Replica < 0 { return nil, errors.New("number of replica should be greater than or equal to 0") } @@ -1151,11 +1341,32 @@ func (*TestCustomDefaultValidator) ValidateDelete(ctx context.Context, obj runti return nil, fmt.Errorf("expected Kind TestDefaultValidator got %q", req.Kind.Kind) } - v := obj.(*TestDefaultValidator) //nolint:ifshort + v := obj.(*TestDefaultValidator) if v.Replica > 0 { return nil, errors.New("number of replica should be less than or equal to 0 to delete") } return nil, nil } +//nolint:staticcheck var _ admission.CustomValidator = &TestCustomValidator{} + +type testValidatorDefaulter struct{} + +func (*testValidatorDefaulter) Default(ctx context.Context, obj *TestDefaultValidator) error { + return (&TestCustomDefaultValidator{}).Default(ctx, obj) +} + +type testDefaultValidatorValidator struct{} + +func (*testDefaultValidatorValidator) ValidateCreate(ctx context.Context, obj *TestDefaultValidator) (admission.Warnings, error) { + return (&TestCustomDefaultValidator{}).ValidateCreate(ctx, obj) +} + +func (*testDefaultValidatorValidator) ValidateUpdate(ctx context.Context, oldObj, newObj *TestDefaultValidator) (admission.Warnings, error) { + return (&TestCustomDefaultValidator{}).ValidateUpdate(ctx, oldObj, newObj) +} + +func (*testDefaultValidatorValidator) ValidateDelete(ctx context.Context, obj *TestDefaultValidator) (admission.Warnings, error) { + return (&TestCustomDefaultValidator{}).ValidateDelete(ctx, obj) +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index a7e491855a..b814170de1 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -22,7 +22,6 @@ import ( "maps" "net/http" "slices" - "sort" "time" corev1 "k8s.io/api/core/v1" @@ -308,6 +307,42 @@ type ByObject struct { // // Defaults to true. EnableWatchBookmarks *bool + + // SyncPeriod determines the minimum frequency at which watched resources are + // reconciled. A lower period will correct entropy more quickly, but reduce + // responsiveness to change if there are many watched resources. Change this + // value only if you know what you are doing. Defaults to 10 hours if unset. + // there will a 10 percent jitter between the SyncPeriod of all controllers + // so that all controllers will not send list requests simultaneously. + // + // This applies to all controllers. + // + // A period sync happens for two reasons: + // 1. To insure against a bug in the controller that causes an object to not + // be requeued, when it otherwise should be requeued. + // 2. To insure against an unknown bug in controller-runtime, or its dependencies, + // that causes an object to not be requeued, when it otherwise should be + // requeued, or to be removed from the queue, when it otherwise should not + // be removed. + // + // If you want + // 1. to insure against missed watch events, or + // 2. to poll services that cannot be watched, + // then we recommend that, instead of changing the default period, the + // controller requeue, with a constant duration `t`, whenever the controller + // is "done" with an object, and would otherwise not requeue it, i.e., we + // recommend the `Reconcile` function return `reconcile.Result{RequeueAfter: t}`, + // instead of `reconcile.Result{}`. + // + // SyncPeriod will locally trigger an artificial Update event with the same + // object in both ObjectOld and ObjectNew for everything that is in the + // cache. + // + // Predicates or Handlers that expect ObjectOld and ObjectNew to be different + // (such as GenerationChangedPredicate) will filter out this event, preventing + // it from triggering a reconciliation. + // SyncPeriod does not sync between the local cache and the server. + SyncPeriod *time.Duration } // Config describes all potential options for a given watch. @@ -343,6 +378,42 @@ type Config struct { // // Defaults to true. EnableWatchBookmarks *bool + + // SyncPeriod determines the minimum frequency at which watched resources are + // reconciled. A lower period will correct entropy more quickly, but reduce + // responsiveness to change if there are many watched resources. Change this + // value only if you know what you are doing. Defaults to 10 hours if unset. + // there will a 10 percent jitter between the SyncPeriod of all controllers + // so that all controllers will not send list requests simultaneously. + // + // This applies to all controllers. + // + // A period sync happens for two reasons: + // 1. To insure against a bug in the controller that causes an object to not + // be requeued, when it otherwise should be requeued. + // 2. To insure against an unknown bug in controller-runtime, or its dependencies, + // that causes an object to not be requeued, when it otherwise should be + // requeued, or to be removed from the queue, when it otherwise should not + // be removed. + // + // If you want + // 1. to insure against missed watch events, or + // 2. to poll services that cannot be watched, + // then we recommend that, instead of changing the default period, the + // controller requeue, with a constant duration `t`, whenever the controller + // is "done" with an object, and would otherwise not requeue it, i.e., we + // recommend the `Reconcile` function return `reconcile.Result{RequeueAfter: t}`, + // instead of `reconcile.Result{}`. + // + // SyncPeriod will locally trigger an artificial Update event with the same + // object in both ObjectOld and ObjectNew for everything that is in the + // cache. + // + // Predicates or Handlers that expect ObjectOld and ObjectNew to be different + // (such as GenerationChangedPredicate) will filter out this event, preventing + // it from triggering a reconciliation. + // SyncPeriod does not sync between the local cache and the server. + SyncPeriod *time.Duration } // NewCacheFunc - Function for creating a new cache from the options and a rest config. @@ -413,6 +484,7 @@ func optionDefaultsToConfig(opts *Options) Config { Transform: opts.DefaultTransform, UnsafeDisableDeepCopy: opts.DefaultUnsafeDisableDeepCopy, EnableWatchBookmarks: opts.DefaultEnableWatchBookmarks, + SyncPeriod: opts.SyncPeriod, } } @@ -423,6 +495,7 @@ func byObjectToConfig(byObject ByObject) Config { Transform: byObject.Transform, UnsafeDisableDeepCopy: byObject.UnsafeDisableDeepCopy, EnableWatchBookmarks: byObject.EnableWatchBookmarks, + SyncPeriod: byObject.SyncPeriod, } } @@ -436,7 +509,7 @@ func newCache(restConfig *rest.Config, opts Options) newCacheFunc { HTTPClient: opts.HTTPClient, Scheme: opts.Scheme, Mapper: opts.Mapper, - ResyncPeriod: *opts.SyncPeriod, + ResyncPeriod: ptr.Deref(config.SyncPeriod, defaultSyncPeriod), Namespace: namespace, Selector: internal.Selector{ Label: config.LabelSelector, @@ -534,6 +607,7 @@ func defaultOpts(config *rest.Config, opts Options) (Options, error) { byObject.Transform = defaultedConfig.Transform byObject.UnsafeDisableDeepCopy = defaultedConfig.UnsafeDisableDeepCopy byObject.EnableWatchBookmarks = defaultedConfig.EnableWatchBookmarks + byObject.SyncPeriod = defaultedConfig.SyncPeriod } opts.ByObject[obj] = byObject @@ -555,10 +629,6 @@ func defaultOpts(config *rest.Config, opts Options) (Options, error) { opts.DefaultNamespaces[namespace] = cfg } - // Default the resync period to 10 hours if unset - if opts.SyncPeriod == nil { - opts.SyncPeriod = &defaultSyncPeriod - } return opts, nil } @@ -578,12 +648,16 @@ func defaultConfig(toDefault, defaultFrom Config) Config { if toDefault.EnableWatchBookmarks == nil { toDefault.EnableWatchBookmarks = defaultFrom.EnableWatchBookmarks } + if toDefault.SyncPeriod == nil { + toDefault.SyncPeriod = defaultFrom.SyncPeriod + } return toDefault } func namespaceAllSelector(namespaces []string) []fields.Selector { selectors := make([]fields.Selector, 0, len(namespaces)-1) - sort.Strings(namespaces) + slices.Sort(namespaces) + for _, namespace := range namespaces { if namespace != metav1.NamespaceAll { selectors = append(selectors, fields.OneTermNotEqualSelector("metadata.namespace", namespace)) diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go index 2364eec3e1..a95836b90d 100644 --- a/pkg/cache/cache_test.go +++ b/pkg/cache/cache_test.go @@ -21,7 +21,7 @@ import ( "errors" "fmt" "reflect" - "sort" + "slices" "strconv" "strings" "time" @@ -216,7 +216,7 @@ var _ = Describe("Cache with transformers", func() { By("creating the informer cache") informerCache, err = cache.New(cfg, cache.Options{ - DefaultTransform: func(i interface{}) (interface{}, error) { + DefaultTransform: func(i any) (any, error) { obj := i.(runtime.Object) Expect(obj).NotTo(BeNil()) @@ -238,7 +238,7 @@ var _ = Describe("Cache with transformers", func() { }, ByObject: map[client.Object]cache.ByObject{ &corev1.Pod{}: { - Transform: func(i interface{}) (interface{}, error) { + Transform: func(i any) (any, error) { obj := i.(runtime.Object) Expect(obj).NotTo(BeNil()) accessor, err := meta.Accessor(obj) @@ -808,8 +808,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca By("verifying the pointer fields in pod have the same addresses") Expect(outList1.Items).To(HaveLen(len(outList2.Items))) - sort.SliceStable(outList1.Items, func(i, j int) bool { return outList1.Items[i].Name <= outList1.Items[j].Name }) - sort.SliceStable(outList2.Items, func(i, j int) bool { return outList2.Items[i].Name <= outList2.Items[j].Name }) + slices.SortStableFunc(outList1.Items, func(i, j corev1.Pod) int { return strings.Compare(i.Name, j.Name) }) + slices.SortStableFunc(outList2.Items, func(i, j corev1.Pod) int { return strings.Compare(i.Name, j.Name) }) for i := range outList1.Items { a := &outList1.Items[i] b := &outList2.Items[i] @@ -1103,7 +1103,7 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca Expect(out).To(Equal(uKnownPod2)) By("altering a field in the retrieved pod") - m, _ := out.Object["spec"].(map[string]interface{}) + m, _ := out.Object["spec"].(map[string]any) m["activeDeadlineSeconds"] = 4 By("verifying the pods are no longer equal") @@ -1134,8 +1134,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca By("verifying the pointer fields in pod have the same addresses") Expect(outList1.Items).To(HaveLen(len(outList2.Items))) - sort.SliceStable(outList1.Items, func(i, j int) bool { return outList1.Items[i].GetName() <= outList1.Items[j].GetName() }) - sort.SliceStable(outList2.Items, func(i, j int) bool { return outList2.Items[i].GetName() <= outList2.Items[j].GetName() }) + slices.SortStableFunc(outList1.Items, func(i, j unstructured.Unstructured) int { return strings.Compare(i.GetName(), j.GetName()) }) + slices.SortStableFunc(outList2.Items, func(i, j unstructured.Unstructured) int { return strings.Compare(i.GetName(), j.GetName()) }) for i := range outList1.Items { a := &outList1.Items[i] b := &outList2.Items[i] @@ -1519,8 +1519,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca By("verifying the pointer fields in pod have the same addresses") Expect(outList1.Items).To(HaveLen(len(outList2.Items))) - sort.SliceStable(outList1.Items, func(i, j int) bool { return outList1.Items[i].Name <= outList1.Items[j].Name }) - sort.SliceStable(outList2.Items, func(i, j int) bool { return outList2.Items[i].Name <= outList2.Items[j].Name }) + slices.SortStableFunc(outList1.Items, func(i, j metav1.PartialObjectMetadata) int { return strings.Compare(i.Name, j.Name) }) + slices.SortStableFunc(outList2.Items, func(i, j metav1.PartialObjectMetadata) int { return strings.Compare(i.Name, j.Name) }) for i := range outList1.Items { a := &outList1.Items[i] b := &outList2.Items[i] @@ -1954,8 +1954,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca Expect(sii.HasSynced()).To(BeTrue()) By("adding an event handler listening for object creation which sends the object to a channel") - out := make(chan interface{}) - addFunc := func(obj interface{}) { + out := make(chan any) + addFunc := func(obj any) { out <- obj } _, _ = sii.AddEventHandler(kcache.ResourceEventHandlerFuncs{AddFunc: addFunc}) @@ -2014,8 +2014,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca Expect(sii.HasSynced()).To(BeTrue()) By("adding an event handler listening for object creation which sends the object to a channel") - out := make(chan interface{}) - addFunc := func(obj interface{}) { + out := make(chan any) + addFunc := func(obj any) { out <- obj } _, _ = sii.AddEventHandler(kcache.ResourceEventHandlerFuncs{AddFunc: addFunc}) @@ -2196,9 +2196,9 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca By("getting a shared index informer for a pod") pod := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "containers": []map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "containers": []map[string]any{ { "name": "nginx", "image": "nginx", @@ -2220,8 +2220,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca Expect(sii.HasSynced()).To(BeTrue()) By("adding an event handler listening for object creation which sends the object to a channel") - out := make(chan interface{}) - addFunc := func(obj interface{}) { + out := make(chan any) + addFunc := func(obj any) { out <- obj } _, _ = sii.AddEventHandler(kcache.ResourceEventHandlerFuncs{AddFunc: addFunc}) @@ -2239,9 +2239,9 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca It("should be able to stop and restart informers", func(ctx SpecContext) { By("getting a shared index informer for a pod") pod := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "containers": []map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "containers": []map[string]any{ { "name": "nginx", "image": "nginx", @@ -2294,7 +2294,7 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca if !ok { return []string{} } - m, ok := s.(map[string]interface{}) + m, ok := s.(map[string]any) if !ok { return []string{} } @@ -2379,8 +2379,8 @@ func CacheTest(createCacheFunc func(config *rest.Config, opts cache.Options) (ca Expect(sii.HasSynced()).To(BeTrue()) By("adding an event handler listening for object creation which sends the object to a channel") - out := make(chan interface{}) - addFunc := func(obj interface{}) { + out := make(chan any) + addFunc := func(obj any) { out <- obj } _, _ = sii.AddEventHandler(kcache.ResourceEventHandlerFuncs{AddFunc: addFunc}) @@ -2568,7 +2568,6 @@ func ensureNode(ctx context.Context, name string, client client.Client) error { return err } -//nolint:interfacer func isKubeService(svc metav1.Object) bool { // grumble grumble linters grumble grumble return svc.GetNamespace() == "default" && svc.GetName() == "kubernetes" diff --git a/pkg/cache/defaulting_test.go b/pkg/cache/defaulting_test.go index d9d0dcceb3..b4b4dd030a 100644 --- a/pkg/cache/defaulting_test.go +++ b/pkg/cache/defaulting_test.go @@ -249,6 +249,30 @@ func TestDefaultOpts(t *testing.T) { return cmp.Diff(expected, o.ByObject[pod].EnableWatchBookmarks) }, }, + { + name: "ByObject.SyncPeriod gets defaulted from SyncPeriod", + in: Options{ + ByObject: map[client.Object]ByObject{pod: {}}, + SyncPeriod: ptr.To(5 * time.Minute), + }, + + verification: func(o Options) string { + expected := ptr.To(5 * time.Minute) + return cmp.Diff(expected, o.ByObject[pod].SyncPeriod) + }, + }, + { + name: "ByObject.SyncPeriod doesn't get defaulted when set", + in: Options{ + ByObject: map[client.Object]ByObject{pod: {SyncPeriod: ptr.To(1 * time.Minute)}}, + SyncPeriod: ptr.To(5 * time.Minute), + }, + + verification: func(o Options) string { + expected := ptr.To(1 * time.Minute) + return cmp.Diff(expected, o.ByObject[pod].SyncPeriod) + }, + }, { name: "DefaultNamespace label selector gets defaulted from DefaultLabelSelector", in: Options{ @@ -450,11 +474,9 @@ func TestDefaultOptsRace(t *testing.T) { // Start go routines which re-use the above options struct. wg := sync.WaitGroup{} for range 2 { - wg.Add(1) - go func() { + wg.Go(func() { _, _ = defaultOpts(&rest.Config{}, opts) - wg.Done() - }() + }) } // Wait for the go routines to finish. @@ -485,7 +507,7 @@ func TestDefaultConfigConsidersAllFields(t *testing.T) { }, ) - for i := 0; i < 100; i++ { + for range 100 { fuzzed := Config{} f.Fuzz(&fuzzed) diff --git a/pkg/cache/delegating_by_gvk_cache.go b/pkg/cache/delegating_by_gvk_cache.go index 46bd243c66..adc5d957a4 100644 --- a/pkg/cache/delegating_by_gvk_cache.go +++ b/pkg/cache/delegating_by_gvk_cache.go @@ -81,13 +81,11 @@ func (dbt *delegatingByGVKCache) Start(ctx context.Context) error { errs := make(chan error) for idx := range allCaches { cache := allCaches[idx] - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { if err := cache.Start(ctx); err != nil { errs <- err } - }() + }) } select { diff --git a/pkg/cache/informer_cache.go b/pkg/cache/informer_cache.go index 091667b7fa..5f0d88fdb0 100644 --- a/pkg/cache/informer_cache.go +++ b/pkg/cache/informer_cache.go @@ -221,7 +221,7 @@ func (ic *informerCache) IndexField(ctx context.Context, obj client.Object, fiel } func indexByField(informer Informer, field string, extractValue client.IndexerFunc) error { - indexFunc := func(objRaw interface{}) ([]string, error) { + indexFunc := func(objRaw any) ([]string, error) { // TODO(directxman12): check if this is the correct type? obj, isObj := objRaw.(client.Object) if !isObj { diff --git a/pkg/cache/informertest/fake_cache.go b/pkg/cache/informertest/fake_cache.go index a1a442316f..5f907d774b 100644 --- a/pkg/cache/informertest/fake_cache.go +++ b/pkg/cache/informertest/fake_cache.go @@ -116,7 +116,8 @@ func (c *FakeInformers) informerFor(gvk schema.GroupVersionKind, _ runtime.Objec return informer, nil } - c.InformersByGVK[gvk] = &controllertest.FakeInformer{} + // Set Synced to true by default so that WaitForCacheSync returns immediately + c.InformersByGVK[gvk] = &controllertest.FakeInformer{Synced: true} return c.InformersByGVK[gvk], nil } diff --git a/pkg/cache/internal/cache_reader.go b/pkg/cache/internal/cache_reader.go index eb6b544855..624869f590 100644 --- a/pkg/cache/internal/cache_reader.go +++ b/pkg/cache/internal/cache_reader.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "reflect" + "slices" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -109,7 +110,7 @@ func (c *CacheReader) Get(_ context.Context, key client.ObjectKey, out client.Ob // List lists items out of the indexer and writes them to out. func (c *CacheReader) List(_ context.Context, out client.ObjectList, opts ...client.ListOption) error { - var objs []interface{} + var objs []any var err error listOpts := client.ListOptions{} @@ -186,10 +187,10 @@ func (c *CacheReader) List(_ context.Context, out client.ObjectList, opts ...cli return nil } -func byIndexes(indexer cache.Indexer, requires fields.Requirements, namespace string) ([]interface{}, error) { +func byIndexes(indexer cache.Indexer, requires fields.Requirements, namespace string) ([]any, error) { var ( err error - objs []interface{} + objs []any vals []string ) indexers := indexer.GetIndexers() @@ -213,17 +214,14 @@ func byIndexes(indexer cache.Indexer, requires fields.Requirements, namespace st if !exist { return nil, fmt.Errorf("index with name %s does not exist", indexName) } - filteredObjects := make([]interface{}, 0, len(objs)) + filteredObjects := make([]any, 0, len(objs)) for _, obj := range objs { vals, err = fn(obj) if err != nil { return nil, err } - for _, val := range vals { - if val == indexedValue { - filteredObjects = append(filteredObjects, obj) - break - } + if slices.Contains(vals, indexedValue) { + filteredObjects = append(filteredObjects, obj) } } if len(filteredObjects) == 0 { diff --git a/pkg/cache/internal/informers.go b/pkg/cache/internal/informers.go index f216be0d9e..0f921ef63d 100644 --- a/pkg/cache/internal/informers.go +++ b/pkg/cache/internal/informers.go @@ -242,11 +242,9 @@ func (ip *Informers) startInformerLocked(cacheEntry *Cache) { return } - ip.waitGroup.Add(1) - go func() { - defer ip.waitGroup.Done() + ip.waitGroup.Go(func() { cacheEntry.Start(ip.ctx.Done()) - }() + }) } func (ip *Informers) waitForStarted(ctx context.Context) bool { diff --git a/pkg/certwatcher/certwatcher_test.go b/pkg/certwatcher/certwatcher_test.go index 9737925a6b..ba47afc112 100644 --- a/pkg/certwatcher/certwatcher_test.go +++ b/pkg/certwatcher/certwatcher_test.go @@ -255,7 +255,7 @@ var _ = Describe("CertWatcher", func() { }) func writeCerts(certPath, keyPath, ip string) error { - var priv interface{} + var priv any var err error priv, err = rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/pkg/client/apiutil/apimachinery.go b/pkg/client/apiutil/apimachinery.go index b132cb2d4d..217990dece 100644 --- a/pkg/client/apiutil/apimachinery.go +++ b/pkg/client/apiutil/apimachinery.go @@ -231,7 +231,7 @@ func (t targetZeroingDecoder) Decode(data []byte, defaults *schema.GroupVersionK } // zero zeros the value of a pointer. -func zero(x interface{}) { +func zero(x any) { if x == nil { return } diff --git a/pkg/client/apiutil/errors.go b/pkg/client/apiutil/errors.go index c216c49d2a..b00e071232 100644 --- a/pkg/client/apiutil/errors.go +++ b/pkg/client/apiutil/errors.go @@ -18,7 +18,7 @@ package apiutil import ( "fmt" - "sort" + "slices" "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -38,7 +38,7 @@ func (e *ErrResourceDiscoveryFailed) Error() string { for k, v := range *e { subErrors = append(subErrors, fmt.Sprintf("%s: %v", k, v)) } - sort.Strings(subErrors) + slices.Sort(subErrors) return fmt.Sprintf("unable to retrieve the complete list of server APIs: %s", strings.Join(subErrors, ", ")) } diff --git a/pkg/client/apiutil/restmapper_test.go b/pkg/client/apiutil/restmapper_test.go index 51807f12de..71fc1681db 100644 --- a/pkg/client/apiutil/restmapper_test.go +++ b/pkg/client/apiutil/restmapper_test.go @@ -746,7 +746,7 @@ func TestLazyRestMapperProvider(t *testing.T) { wg := sync.WaitGroup{} wg.Add(50) - for i := 0; i < 50; i++ { + for range 50 { go func() { defer wg.Done() httpClient, err := rest.HTTPClientFor(restCfg) @@ -811,7 +811,7 @@ type errorMatcher struct { message string } -func (e *errorMatcher) Match(actual interface{}) (success bool, err error) { +func (e *errorMatcher) Match(actual any) (success bool, err error) { if actual == nil { return false, nil } @@ -824,10 +824,10 @@ func (e *errorMatcher) Match(actual interface{}) (success bool, err error) { return e.checkFunc(actualErr), nil } -func (e *errorMatcher) FailureMessage(actual interface{}) (message string) { +func (e *errorMatcher) FailureMessage(actual any) (message string) { return format.Message(actual, fmt.Sprintf("to be %s error", e.message)) } -func (e *errorMatcher) NegatedFailureMessage(actual interface{}) (message string) { +func (e *errorMatcher) NegatedFailureMessage(actual any) (message string) { return format.Message(actual, fmt.Sprintf("not to be %s error", e.message)) } diff --git a/pkg/client/apiutil/restmapper_wb_test.go b/pkg/client/apiutil/restmapper_wb_test.go index 73c4236724..5c23b2e6a3 100644 --- a/pkg/client/apiutil/restmapper_wb_test.go +++ b/pkg/client/apiutil/restmapper_wb_test.go @@ -192,7 +192,7 @@ func TestLazyRestMapper_fetchGroupVersionResourcesLocked_CacheInvalidation(t *te g := gmg.NewWithT(t) m := &mapper{ mapper: restmapper.NewDiscoveryRESTMapper([]*restmapper.APIGroupResources{}), - client: &fakeAggregatedDiscoveryClient{DiscoveryInterface: fake.NewSimpleClientset().Discovery()}, + client: &fakeAggregatedDiscoveryClient{DiscoveryInterface: fake.NewClientset().Discovery()}, apiGroups: tt.cachedAPIGroups, knownGroups: tt.cachedKnownGroups, } diff --git a/pkg/client/client.go b/pkg/client/client.go index 092deb43d4..ad946daeaa 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -52,6 +52,25 @@ type Options struct { // DryRun instructs the client to only perform dry run requests. DryRun *bool + + // FieldOwner, if provided, sets the default field manager for all write operations + // (Create, Update, Patch, Apply) performed by this client. The field manager is used by + // the server for Server-Side Apply to track field ownership. + // For more details, see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#field-management + // + // This default can be overridden for a specific call by passing a [FieldOwner] option + // to the method. + FieldOwner string + + // FieldValidation sets the field validation strategy for all mutating operations performed by this client + // and subresource clients created from it. + // The exception are apply requests which are always strict, regardless of the FieldValidation setting. + // Available values for this option can be found in "k8s.io/apimachinery/pkg/apis/meta/v1" package and are: + // - FieldValidationIgnore + // - FieldValidationWarn + // - FieldValidationStrict + // For more details, see: https://kubernetes.io/docs/reference/using-api/api-concepts/#field-validation + FieldValidation string } // CacheOptions are options for creating a cache-backed client. @@ -99,6 +118,13 @@ func New(config *rest.Config, options Options) (c Client, err error) { if err == nil && options.DryRun != nil && *options.DryRun { c = NewDryRunClient(c) } + if fo := options.FieldOwner; fo != "" { + c = WithFieldOwner(c, fo) + } + if fv := options.FieldValidation; fv != "" { + c = WithFieldValidation(c, FieldValidation(fv)) + } + return c, err } @@ -151,8 +177,7 @@ func newClient(config *rest.Config, options Options) (*client, error) { mapper: options.Mapper, codecs: serializer.NewCodecFactory(options.Scheme), - structuredResourceByType: make(map[schema.GroupVersionKind]*resourceMeta), - unstructuredResourceByType: make(map[schema.GroupVersionKind]*resourceMeta), + resourceByType: make(map[cacheKey]*resourceMeta), } rawMetaClient, err := metadata.NewForConfigAndClient(metadata.ConfigFor(config), options.HTTPClient) @@ -544,6 +569,30 @@ func (po *SubResourcePatchOptions) ApplyToSubResourcePatch(o *SubResourcePatchOp } } +// SubResourceApplyOptions are the options for a subresource +// apply request. +type SubResourceApplyOptions struct { + ApplyOptions + SubResourceBody runtime.ApplyConfiguration +} + +// ApplyOpts applies the given options. +func (ao *SubResourceApplyOptions) ApplyOpts(opts []SubResourceApplyOption) *SubResourceApplyOptions { + for _, o := range opts { + o.ApplyToSubResourceApply(ao) + } + + return ao +} + +// ApplyToSubResourceApply applies the configuration on the given patch options. +func (ao *SubResourceApplyOptions) ApplyToSubResourceApply(o *SubResourceApplyOptions) { + ao.ApplyOptions.ApplyToApply(&o.ApplyOptions) + if ao.SubResourceBody != nil { + o.SubResourceBody = ao.SubResourceBody + } +} + func (sc *subResourceClient) Get(ctx context.Context, obj Object, subResource Object, opts ...SubResourceGetOption) error { switch obj.(type) { case runtime.Unstructured: @@ -595,3 +644,13 @@ func (sc *subResourceClient) Patch(ctx context.Context, obj Object, patch Patch, return sc.client.typedClient.PatchSubResource(ctx, obj, sc.subResource, patch, opts...) } } + +func (sc *subResourceClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...SubResourceApplyOption) error { + switch obj := obj.(type) { + case *unstructuredApplyConfiguration: + defer sc.client.resetGroupVersionKind(obj, obj.GetObjectKind().GroupVersionKind()) + return sc.client.unstructuredClient.ApplySubResource(ctx, obj, sc.subResource, opts...) + default: + return sc.client.typedClient.ApplySubResource(ctx, obj, sc.subResource, opts...) + } +} diff --git a/pkg/client/client_rest_resources.go b/pkg/client/client_rest_resources.go index acff7a46a4..d75d685cbb 100644 --- a/pkg/client/client_rest_resources.go +++ b/pkg/client/client_rest_resources.go @@ -48,11 +48,15 @@ type clientRestResources struct { // codecs are used to create a REST client for a gvk codecs serializer.CodecFactory - // structuredResourceByType stores structured type metadata - structuredResourceByType map[schema.GroupVersionKind]*resourceMeta - // unstructuredResourceByType stores unstructured type metadata - unstructuredResourceByType map[schema.GroupVersionKind]*resourceMeta - mu sync.RWMutex + // resourceByType stores type metadata + resourceByType map[cacheKey]*resourceMeta + + mu sync.RWMutex +} + +type cacheKey struct { + gvk schema.GroupVersionKind + forceDisableProtoBuf bool } // newResource maps obj to a Kubernetes Resource and constructs a client for that Resource. @@ -117,11 +121,11 @@ func (c *clientRestResources) getResource(obj any) (*resourceMeta, error) { // It's better to do creation work twice than to not let multiple // people make requests at once c.mu.RLock() - resourceByType := c.structuredResourceByType - if isUnstructured { - resourceByType = c.unstructuredResourceByType - } - r, known := resourceByType[gvk] + + cacheKey := cacheKey{gvk: gvk, forceDisableProtoBuf: forceDisableProtoBuf} + + r, known := c.resourceByType[cacheKey] + c.mu.RUnlock() if known { @@ -140,7 +144,7 @@ func (c *clientRestResources) getResource(obj any) (*resourceMeta, error) { if err != nil { return nil, err } - resourceByType[gvk] = r + c.resourceByType[cacheKey] = r return r, err } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index c775f28718..878927f467 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -43,6 +43,9 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + appsv1applyconfigurations "k8s.io/client-go/applyconfigurations/apps/v1" + autoscaling1applyconfigurations "k8s.io/client-go/applyconfigurations/autoscaling/v1" corev1applyconfigurations "k8s.io/client-go/applyconfigurations/core/v1" kscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -95,7 +98,7 @@ func deleteNamespace(ctx context.Context, ns *corev1.Namespace) { Expect(err).NotTo(HaveOccurred()) WAIT_LOOP: - for i := 0; i < 10; i++ { + for range 10 { ns, err = clientset.CoreV1().Namespaces().Get(ctx, ns.Name, metav1.GetOptions{}) if apierrors.IsNotFound(err) { // success! @@ -363,6 +366,65 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(cl.List(ctx, &corev1.NamespaceList{})).To(Succeed()) Expect(cache.Called).To(Equal(0)) }) + + It("should use the provided FieldOwner if provided", func(ctx SpecContext) { + cl, err := client.New(cfg, client.Options{FieldOwner: "test-owner"}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + // no explicit FieldOwner option set on Apply method call + err = cl.Apply(ctx, corev1applyconfigurations.ConfigMap("test-cm", "default").WithData(map[string]string{"foo": "bar"})) + Expect(err).NotTo(HaveOccurred()) + actual, err := clientset.CoreV1().ConfigMaps("default").Get(ctx, "test-cm", metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual.ManagedFields).To(HaveLen(1)) + Expect(actual.ManagedFields[0].Manager).To(Equal("test-owner")) + }) + + Context("with the FieldValidation option", func() { + It("should log warnings with FieldValidation equal to Warn", func(ctx SpecContext) { + restCfg := rest.CopyConfig(cfg) + var testLog bytes.Buffer + restCfg.WarningHandler = rest.NewWarningWriter(&testLog, rest.WarningWriterOptions{}) + + warnClient, err := client.New(restCfg, client.Options{FieldValidation: metav1.FieldValidationWarn}) + Expect(err).NotTo(HaveOccurred()) + Expect(warnClient).NotTo(BeNil()) + + unstrContent, err := runtime.DefaultUnstructuredConverter.ToUnstructured( + corev1applyconfigurations.ConfigMap("test-cm-"+rand.String(3), "default"). + WithData(map[string]string{"foo": "bar"}), + ) + Expect(err).NotTo(HaveOccurred()) + unstrContent["additionalField"] = "test" + cm := &unstructured.Unstructured{Object: unstrContent} + + err = warnClient.Create(ctx, cm) + Expect(err).NotTo(HaveOccurred()) + Expect(testLog.String()).To(ContainSubstring(`Warning: unknown field "additionalField"`)) + + }) + It("should fail write operation if FieldValidation equals Strict", func(ctx SpecContext) { + restCfg := rest.CopyConfig(cfg) + var testLog bytes.Buffer + restCfg.WarningHandler = rest.NewWarningWriter(&testLog, rest.WarningWriterOptions{}) + strictClient, err := client.New(restCfg, client.Options{FieldValidation: metav1.FieldValidationStrict}) + Expect(err).NotTo(HaveOccurred()) + + unstrContent, err := runtime.DefaultUnstructuredConverter.ToUnstructured( + corev1applyconfigurations.ConfigMap("test-cm-"+rand.String(3), "default"). + WithData(map[string]string{"foo": "bar"}), + ) + Expect(err).NotTo(HaveOccurred()) + unstrContent["additionalField"] = "test" + cm := &unstructured.Unstructured{Object: unstrContent} + + err = strictClient.Create(ctx, cm) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("unknown field \"additionalField\""))) + Expect(err).To(MatchError(ContainSubstring("strict decoding error"))) + Expect(testLog.String()).To(BeEmpty()) + }) + }) }) Describe("Create", func() { @@ -893,6 +955,7 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(actualData).To(BeComparableTo(data)) Expect(actualData).To(BeComparableTo(obj.Object["data"])) + // Apply with ResourceVersion set data = map[string]any{ "a-new-key": "a-new-value", } @@ -912,6 +975,28 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(actualData).To(BeComparableTo(data)) Expect(actualData).To(BeComparableTo(obj.Object["data"])) + + // Apply with ResourceVersion unset + obj.SetResourceVersion("") + data = map[string]any{ + "another-new-key": "another-new-value", + } + obj.Object["data"] = data + unstructured.RemoveNestedField(obj.Object, "metadata", "managedFields") + + err = cl.Apply(ctx, client.ApplyConfigurationFromUnstructured(obj), &client.ApplyOptions{FieldManager: "test-manager"}) + Expect(err).NotTo(HaveOccurred()) + + cm, err = clientset.CoreV1().ConfigMaps(obj.GetNamespace()).Get(ctx, obj.GetName(), metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + actualData = map[string]any{} + for k, v := range cm.Data { + actualData[k] = v + } + + Expect(actualData).To(BeComparableTo(data)) + Expect(actualData).To(BeComparableTo(obj.Object["data"])) }) }) @@ -937,6 +1022,7 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(cm.Data).To(BeComparableTo(data)) Expect(cm.Data).To(BeComparableTo(obj.Data)) + // Apply with ResourceVersion set data = map[string]string{ "a-new-key": "a-new-value", } @@ -950,6 +1036,68 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(cm.Data).To(BeComparableTo(data)) Expect(cm.Data).To(BeComparableTo(obj.Data)) + + // Apply with ResourceVersion unset + obj.ResourceVersion = ptr.To("") + data = map[string]string{ + "a-new-key": "a-new-value", + } + obj.Data = data + + err = cl.Apply(ctx, obj, &client.ApplyOptions{FieldManager: "test-manager"}) + Expect(err).NotTo(HaveOccurred()) + + cm, err = clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(ctx, ptr.Deref(obj.GetName(), ""), metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + Expect(cm.Data).To(BeComparableTo(data)) + Expect(cm.Data).To(BeComparableTo(obj.Data)) + }) + + It("should create a secret without SSA and later create update a secret using SSA", func(ctx SpecContext) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + data := map[string][]byte{ + "some-key": []byte("some-value"), + } + secretObject := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-one", + Namespace: "default", + }, + Data: data, + } + + secretApplyConfiguration := corev1applyconfigurations. + Secret("secret-two", "default"). + WithData(data) + + err = cl.Create(ctx, secretObject) + Expect(err).NotTo(HaveOccurred()) + + err = cl.Apply(ctx, secretApplyConfiguration, &client.ApplyOptions{FieldManager: "test-manager"}) + Expect(err).NotTo(HaveOccurred()) + + secret, err := clientset.CoreV1().Secrets(ptr.Deref(secretApplyConfiguration.GetNamespace(), "")).Get(ctx, ptr.Deref(secretApplyConfiguration.GetName(), ""), metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + Expect(secret.Data).To(BeComparableTo(data)) + Expect(secret.Data).To(BeComparableTo(secretApplyConfiguration.Data)) + + data = map[string][]byte{ + "some-key": []byte("some-new-value"), + } + secretApplyConfiguration.Data = data + + err = cl.Apply(ctx, secretApplyConfiguration, &client.ApplyOptions{FieldManager: "test-manager"}) + Expect(err).NotTo(HaveOccurred()) + + secret, err = clientset.CoreV1().Secrets(ptr.Deref(secretApplyConfiguration.GetNamespace(), "")).Get(ctx, ptr.Deref(secretApplyConfiguration.GetName(), ""), metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + Expect(secret.Data).To(BeComparableTo(data)) + Expect(secret.Data).To(BeComparableTo(secretApplyConfiguration.Data)) }) }) }) @@ -1127,6 +1275,34 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(err).NotTo(HaveOccurred()) Expect(*dep.Spec.Replicas).To(Equal(replicaCount)) }) + + It("should be able to apply the scale subresource", func(ctx SpecContext) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("Creating a deployment") + dep, err := clientset.AppsV1().Deployments(dep.Namespace).Create(ctx, dep, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + replicaCount := *dep.Spec.Replicas + 1 + + By("Applying the scale subresurce") + deploymentAC, err := appsv1applyconfigurations.ExtractDeployment(dep, "foo") + Expect(err).NotTo(HaveOccurred()) + scale := autoscaling1applyconfigurations.Scale(). + WithSpec(autoscaling1applyconfigurations.ScaleSpec().WithReplicas(replicaCount)) + err = cl.SubResource("scale").Apply(ctx, deploymentAC, + &client.SubResourceApplyOptions{SubResourceBody: scale}, + client.FieldOwner("foo"), + client.ForceOwnership, + ) + Expect(err).NotTo(HaveOccurred()) + + By("Asserting replicas got updated") + dep, err = clientset.AppsV1().Deployments(dep.Namespace).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(*dep.Spec.Replicas).To(Equal(replicaCount)) + }) }) Context("with unstructured objects", func() { @@ -1139,7 +1315,7 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC dep, err := clientset.AppsV1().Deployments(dep.Namespace).Create(ctx, dep, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) dep.APIVersion = appsv1.SchemeGroupVersion.String() - dep.Kind = reflect.TypeOf(dep).Elem().Name() + dep.Kind = reflect.TypeFor[appsv1.Deployment]().Name() depUnstructured, err := toUnstructured(dep) Expect(err).NotTo(HaveOccurred()) @@ -1322,8 +1498,8 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC By("Creating a deployment") dep, err := clientset.AppsV1().Deployments(dep.Namespace).Create(ctx, dep, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) - dep.APIVersion = "apps/v1" - dep.Kind = "Deployment" + dep.APIVersion = appsv1.SchemeGroupVersion.String() + dep.Kind = "Deployment" //nolint:goconst depUnstructured, err := toUnstructured(dep) Expect(err).NotTo(HaveOccurred()) @@ -1374,6 +1550,41 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(err).NotTo(HaveOccurred()) Expect(*dep.Spec.Replicas).To(Equal(replicaCount)) }) + + It("should be able to apply the scale subresource", func(ctx SpecContext) { + cl, err := client.New(cfg, client.Options{Scheme: runtime.NewScheme()}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("Creating a deployment") + dep, err := clientset.AppsV1().Deployments(dep.Namespace).Create(ctx, dep, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + dep.APIVersion = "apps/v1" + dep.Kind = "Deployment" + depUnstructured, err := toUnstructured(dep) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the scale subresurce") + replicaCount := *dep.Spec.Replicas + 1 + scale := &unstructured.Unstructured{} + scale.SetAPIVersion("autoscaling/v1") + scale.SetKind("Scale") + Expect(unstructured.SetNestedField(scale.Object, int64(replicaCount), "spec", "replicas")).NotTo(HaveOccurred()) + err = cl.SubResource("scale").Apply(ctx, + client.ApplyConfigurationFromUnstructured(depUnstructured), + &client.SubResourceApplyOptions{SubResourceBody: client.ApplyConfigurationFromUnstructured(scale)}, + client.FieldOwner("foo"), + client.ForceOwnership, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(scale.GetAPIVersion()).To(Equal("autoscaling/v1")) + Expect(scale.GetKind()).To(Equal("Scale")) + + By("Asserting replicas got updated") + dep, err = clientset.AppsV1().Deployments(dep.Namespace).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(*dep.Spec.Replicas).To(Equal(replicaCount)) + }) }) }) @@ -1440,6 +1651,29 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(dep.GroupVersionKind()).To(Equal(depGvk)) }) + It("should apply status", func(ctx SpecContext) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Deployment") + dep, err := clientset.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(dep.Status.Replicas).To(BeEquivalentTo(0)) + + By("applying the status of Deployment") + deploymentAC, err := appsv1applyconfigurations.ExtractDeployment(dep, "foo") + Expect(err).NotTo(HaveOccurred()) + deploymentAC.WithStatus(&appsv1applyconfigurations.DeploymentStatusApplyConfiguration{ + Replicas: ptr.To(int32(1)), + }) + Expect(cl.Status().Apply(ctx, deploymentAC, client.FieldOwner("foo"))).To(Succeed()) + + dep, err = clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(dep.Status.Replicas).To(BeEquivalentTo(1)) + }) + It("should not update spec of an existing object", func(ctx SpecContext) { cl, err := client.New(cfg, client.Options{}) Expect(err).NotTo(HaveOccurred()) @@ -1592,6 +1826,34 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(actual.Status.Replicas).To(BeEquivalentTo(1)) }) + It("should apply status and preserve type information", func(ctx SpecContext) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Deployment") + dep, err := clientset.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(dep.Status.Replicas).To(BeEquivalentTo(0)) + + By("applying the status of Deployment") + dep.Status.Replicas = 1 + dep.ManagedFields = nil // Must be unset in SSA requests + u := &unstructured.Unstructured{} + Expect(scheme.Convert(dep, u, nil)).To(Succeed()) + err = cl.Status().Apply(ctx, client.ApplyConfigurationFromUnstructured(u), client.FieldOwner("foo")) + Expect(err).NotTo(HaveOccurred()) + + By("validating updated Deployment has type information") + Expect(u.GroupVersionKind()).To(Equal(depGvk)) + + By("validating patched Deployment has new status") + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.Status.Replicas).To(BeEquivalentTo(1)) + }) + It("should not update spec of an existing object", func(ctx SpecContext) { cl, err := client.New(cfg, client.Options{}) Expect(err).NotTo(HaveOccurred()) @@ -2354,6 +2616,43 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC Expect(hasDep).To(BeTrue()) }) + It("should fetch unstructured collection of objects when setting a non-list kind", func(ctx SpecContext) { + // While it is not ideal to omit the List suffix it can easily happen. + // As the client is using TrimSuffix(gvk.Kind,"List") this also works. + // As it works it is part of our API and we should test it accordingly. + By("create an initial object") + _, err := clientset.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + + By("listing all objects of that type in the cluster") + deps := &unstructured.UnstructuredList{} + deps.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }) + err = cl.List(ctx, deps) + Expect(err).NotTo(HaveOccurred()) + + Expect(deps.Items).NotTo(BeEmpty()) + hasDep := false + for _, item := range deps.Items { + Expect(item.GroupVersionKind()).To(Equal(schema.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + })) + if item.GetName() == dep.Name && item.GetNamespace() == dep.Namespace { + hasDep = true + break + } + } + Expect(hasDep).To(BeTrue()) + }) + It("should fetch unstructured collection of objects, even if scheme is empty", func(ctx SpecContext) { By("create an initial object") _, err := clientset.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}) diff --git a/pkg/client/config/config.go b/pkg/client/config/config.go index 70389dfa90..1c39f4d854 100644 --- a/pkg/client/config/config.go +++ b/pkg/client/config/config.go @@ -64,9 +64,6 @@ func RegisterFlags(fs *flag.FlagSet) { // The returned `*rest.Config` has client-side ratelimting disabled as we can rely on API priority and // fairness. Set its QPS to a value equal or bigger than 0 to re-enable it. // -// It also applies saner defaults for QPS and burst based on the Kubernetes -// controller manager defaults (20 QPS, 30 burst) -// // Config precedence: // // * --kubeconfig flag pointing at a file @@ -87,9 +84,6 @@ func GetConfig() (*rest.Config, error) { // The returned `*rest.Config` has client-side ratelimting disabled as we can rely on API priority and // fairness. Set its QPS to a value equal or bigger than 0 to re-enable it. // -// It also applies saner defaults for QPS and burst based on the Kubernetes -// controller manager defaults (20 QPS, 30 burst) -// // Config precedence: // // * --kubeconfig flag pointing at a file diff --git a/pkg/client/dryrun.go b/pkg/client/dryrun.go index a185860d33..fb7012200f 100644 --- a/pkg/client/dryrun.go +++ b/pkg/client/dryrun.go @@ -132,3 +132,7 @@ func (sw *dryRunSubResourceClient) Update(ctx context.Context, obj Object, opts func (sw *dryRunSubResourceClient) Patch(ctx context.Context, obj Object, patch Patch, opts ...SubResourcePatchOption) error { return sw.client.Patch(ctx, obj, patch, append(opts, DryRunAll)...) } + +func (sw *dryRunSubResourceClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...SubResourceApplyOption) error { + return sw.client.Apply(ctx, obj, append(opts, DryRunAll)...) +} diff --git a/pkg/client/dryrun_test.go b/pkg/client/dryrun_test.go index 912a4a10dc..35a9b63869 100644 --- a/pkg/client/dryrun_test.go +++ b/pkg/client/dryrun_test.go @@ -27,6 +27,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + appsv1applyconfigurations "k8s.io/client-go/applyconfigurations/apps/v1" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -260,4 +261,36 @@ var _ = Describe("DryRunClient", func() { Expect(actual).NotTo(BeNil()) Expect(actual).To(BeEquivalentTo(dep)) }) + + It("should not change objects via status apply", func(ctx SpecContext) { + deploymentAC, err := appsv1applyconfigurations.ExtractDeployment(dep, "test-owner") + Expect(err).NotTo(HaveOccurred()) + deploymentAC.WithStatus(&appsv1applyconfigurations.DeploymentStatusApplyConfiguration{ + Replicas: ptr.To(int32(99)), + }) + + Expect(getClient().Status().Apply(ctx, deploymentAC, client.FieldOwner("test-owner"))).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via status apply with opts", func(ctx SpecContext) { + deploymentAC, err := appsv1applyconfigurations.ExtractDeployment(dep, "test-owner") + Expect(err).NotTo(HaveOccurred()) + deploymentAC.WithStatus(&appsv1applyconfigurations.DeploymentStatusApplyConfiguration{ + Replicas: ptr.To(int32(99)), + }) + + opts := &client.SubResourceApplyOptions{ApplyOptions: client.ApplyOptions{DryRun: []string{"Bye", "Pippa"}}} + + Expect(getClient().Status().Apply(ctx, deploymentAC, client.FieldOwner("test-owner"), opts)).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) }) diff --git a/pkg/client/example_test.go b/pkg/client/example_test.go index 390dc10143..f50328b2cd 100644 --- a/pkg/client/example_test.go +++ b/pkg/client/example_test.go @@ -25,7 +25,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" corev1ac "k8s.io/client-go/applyconfigurations/core/v1" @@ -122,24 +121,24 @@ func ExampleClient_create() { // Using a unstructured object. u := &unstructured.Unstructured{} - u.Object = map[string]interface{}{ - "metadata": map[string]interface{}{ + u.Object = map[string]any{ + "metadata": map[string]any{ "name": "name", "namespace": "namespace", }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "replicas": 2, - "selector": map[string]interface{}{ - "matchLabels": map[string]interface{}{ + "selector": map[string]any{ + "matchLabels": map[string]any{ "foo": "bar", }, }, - "template": map[string]interface{}{ - "labels": map[string]interface{}{ + "template": map[string]any{ + "labels": map[string]any{ "foo": "bar", }, - "spec": map[string]interface{}{ - "containers": []map[string]interface{}{ + "spec": map[string]any{ + "containers": []map[string]any{ { "name": "nginx", "image": "nginx", @@ -218,17 +217,30 @@ func ExampleClient_patch() { func ExampleClient_apply() { // Using a typed object. configMap := corev1ac.ConfigMap("name", "namespace").WithData(map[string]string{"key": "value"}) - // c is a created client. - u := &unstructured.Unstructured{} - u.Object, _ = runtime.DefaultUnstructuredConverter.ToUnstructured(configMap) - _ = c.Patch(context.Background(), u, client.Apply, client.ForceOwnership, client.FieldOwner("field-owner")) + _ = c.Apply(context.Background(), configMap) + + // Using a unstructured object. + u := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "name", + "namespace": "namespace", + }, + "data": map[string]any{ + "key": "value", + }, + }, + } + _ = c.Apply(context.Background(), client.ApplyConfigurationFromUnstructured(u)) } // This example shows how to use the client with typed and unstructured objects to patch objects' status. func ExampleClient_patchStatus() { u := &unstructured.Unstructured{} - u.Object = map[string]interface{}{ - "metadata": map[string]interface{}{ + u.Object = map[string]any{ + "metadata": map[string]any{ "name": "foo", "namespace": "namespace", }, diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index 45f9e00e18..2a07bd40b2 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -17,13 +17,11 @@ limitations under the License. package fake import ( - "bytes" "context" "errors" "fmt" "reflect" - "runtime/debug" - "strconv" + "slices" "strings" "sync" "time" @@ -65,7 +63,6 @@ import ( utilrand "k8s.io/apimachinery/pkg/util/rand" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/strategicpatch" - "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/watch" clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations" "k8s.io/client-go/kubernetes/scheme" @@ -79,13 +76,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/internal/objectutil" ) -type versionedTracker struct { - testing.ObjectTracker - scheme *runtime.Scheme - withStatusSubresource sets.Set[schema.GroupVersionKind] - usesFieldManagedObjectTracker bool -} - type fakeClient struct { // trackerWriteLock must be acquired before writing to // the tracker or performing reads that affect a following @@ -141,6 +131,7 @@ type ClientBuilder struct { interceptorFuncs *interceptor.Funcs typeConverters []managedfields.TypeConverter returnManagedFields bool + isBuilt bool // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. // The inner map maps from index name to IndexerFunc. @@ -267,6 +258,9 @@ func (f *ClientBuilder) WithReturnManagedFields() *ClientBuilder { // Build builds and returns a new fake client. func (f *ClientBuilder) Build() client.WithWatch { + if f.isBuilt { + panic("Build() must not be called multiple times when creating a ClientBuilder") + } if f.scheme == nil { f.scheme = scheme.Scheme } @@ -309,7 +303,7 @@ func (f *ClientBuilder) Build() client.WithWatch { usesFieldManagedObjectTracker = true } tracker := versionedTracker{ - ObjectTracker: f.objectTracker, + upstream: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource, usesFieldManagedObjectTracker: usesFieldManagedObjectTracker, @@ -344,88 +338,12 @@ func (f *ClientBuilder) Build() client.WithWatch { result = interceptor.NewClient(result, *f.interceptorFuncs) } + f.isBuilt = true return result } const trackerAddResourceVersion = "999" -func (t versionedTracker) Add(obj runtime.Object) error { - var objects []runtime.Object - if meta.IsListType(obj) { - var err error - objects, err = meta.ExtractList(obj) - if err != nil { - return err - } - } else { - objects = []runtime.Object{obj} - } - for _, obj := range objects { - accessor, err := meta.Accessor(obj) - if err != nil { - return fmt.Errorf("failed to get accessor for object: %w", err) - } - if accessor.GetDeletionTimestamp() != nil && len(accessor.GetFinalizers()) == 0 { - return fmt.Errorf("refusing to create obj %s with metadata.deletionTimestamp but no finalizers", accessor.GetName()) - } - if accessor.GetResourceVersion() == "" { - // We use a "magic" value of 999 here because this field - // is parsed as uint and and 0 is already used in Update. - // As we can't go lower, go very high instead so this can - // be recognized - accessor.SetResourceVersion(trackerAddResourceVersion) - } - - obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) - if err != nil { - return err - } - - // If the fieldManager can not decode fields, it will just silently clear them. This is pretty - // much guaranteed not to be what someone that initializes a fake client with objects that - // have them set wants, so validate them here. - // Ref https://github.com/kubernetes/kubernetes/blob/a956ef4862993b825bcd524a19260192ff1da72d/staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/fieldmanager.go#L105 - if t.usesFieldManagedObjectTracker { - if err := managedfields.ValidateManagedFields(accessor.GetManagedFields()); err != nil { - return fmt.Errorf("invalid managedFields on %T: %w", obj, err) - } - } - if err := t.ObjectTracker.Add(obj); err != nil { - return err - } - } - - return nil -} - -func (t versionedTracker) Create(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.CreateOptions) error { - accessor, err := meta.Accessor(obj) - if err != nil { - return fmt.Errorf("failed to get accessor for object: %w", err) - } - if accessor.GetName() == "" { - gvk, _ := apiutil.GVKForObject(obj, t.scheme) - return apierrors.NewInvalid( - gvk.GroupKind(), - accessor.GetName(), - field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) - } - if accessor.GetResourceVersion() != "" { - return apierrors.NewBadRequest("resourceVersion can not be set for Create requests") - } - accessor.SetResourceVersion("1") - obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) - if err != nil { - return err - } - if err := t.ObjectTracker.Create(gvr, obj, ns, opts...); err != nil { - accessor.SetResourceVersion("") - return err - } - - return nil -} - // convertFromUnstructuredIfNecessary will convert runtime.Unstructured for a GVK that is recognized // by the schema into the whatever the schema produces with New() for said GVK. // This is required because the tracker unconditionally saves on manipulations, but its List() implementation @@ -460,151 +378,6 @@ func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (ru return typed, nil } -func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.UpdateOptions) error { - updateOpts, err := getSingleOrZeroOptions(opts) - if err != nil { - return err - } - - return t.update(gvr, obj, ns, false, false, updateOpts) -} - -func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, opts metav1.UpdateOptions) error { - gvk, err := apiutil.GVKForObject(obj, t.scheme) - if err != nil { - return err - } - obj, err = t.updateObject(gvr, obj, ns, isStatus, deleting, opts.DryRun) - if err != nil { - return err - } - if obj == nil { - return nil - } - - if u, unstructured := obj.(*unstructured.Unstructured); unstructured { - u.SetGroupVersionKind(gvk) - } - - return t.ObjectTracker.Update(gvr, obj, ns, opts) -} - -func (t versionedTracker) Patch(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.PatchOptions) error { - patchOptions, err := getSingleOrZeroOptions(opts) - if err != nil { - return err - } - - // We apply patches using a client-go reaction that ends up calling the trackers Patch. As we can't change - // that reaction, we use the callstack to figure out if this originated from the status client. - isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) - - obj, err = t.updateObject(gvr, obj, ns, isStatus, false, patchOptions.DryRun) - if err != nil { - return err - } - if obj == nil { - return nil - } - - return t.ObjectTracker.Patch(gvr, obj, ns, patchOptions) -} - -func (t versionedTracker) updateObject(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, dryRun []string) (runtime.Object, error) { - accessor, err := meta.Accessor(obj) - if err != nil { - return nil, fmt.Errorf("failed to get accessor for object: %w", err) - } - - if accessor.GetName() == "" { - gvk, _ := apiutil.GVKForObject(obj, t.scheme) - return nil, apierrors.NewInvalid( - gvk.GroupKind(), - accessor.GetName(), - field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) - } - - gvk, err := apiutil.GVKForObject(obj, t.scheme) - if err != nil { - return nil, err - } - - oldObject, err := t.ObjectTracker.Get(gvr, ns, accessor.GetName()) - if err != nil { - // If the resource is not found and the resource allows create on update, issue a - // create instead. - if apierrors.IsNotFound(err) && allowsCreateOnUpdate(gvk) { - return nil, t.Create(gvr, obj, ns) - } - return nil, err - } - - if t.withStatusSubresource.Has(gvk) { - if isStatus { // copy everything but status and metadata.ResourceVersion from original object - if err := copyStatusFrom(obj, oldObject); err != nil { - return nil, fmt.Errorf("failed to copy non-status field for object with status subresouce: %w", err) - } - passedRV := accessor.GetResourceVersion() - if err := copyFrom(oldObject, obj); err != nil { - return nil, fmt.Errorf("failed to restore non-status fields: %w", err) - } - accessor.SetResourceVersion(passedRV) - } else { // copy status from original object - if err := copyStatusFrom(oldObject, obj); err != nil { - return nil, fmt.Errorf("failed to copy the status for object with status subresource: %w", err) - } - } - } else if isStatus { - return nil, apierrors.NewNotFound(gvr.GroupResource(), accessor.GetName()) - } - - oldAccessor, err := meta.Accessor(oldObject) - if err != nil { - return nil, err - } - - // If the new object does not have the resource version set and it allows unconditional update, - // default it to the resource version of the existing resource - if accessor.GetResourceVersion() == "" { - switch { - case allowsUnconditionalUpdate(gvk): - accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) - // This is needed because if the patch explicitly sets the RV to null, the client-go reaction we use - // to apply it and whose output we process here will have it unset. It is not clear why the Kubernetes - // apiserver accepts such a patch, but it does so we just copy that behavior. - // Kubernetes apiserver behavior can be checked like this: - // `kubectl patch configmap foo --patch '{"metadata":{"annotations":{"foo":"bar"},"resourceVersion":null}}' -v=9` - case bytes. - Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Patch")): - // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change - // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Patch" func. - accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) - } - } - - if accessor.GetResourceVersion() != oldAccessor.GetResourceVersion() { - return nil, apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), errors.New("object was modified")) - } - if oldAccessor.GetResourceVersion() == "" { - oldAccessor.SetResourceVersion("0") - } - intResourceVersion, err := strconv.ParseUint(oldAccessor.GetResourceVersion(), 10, 64) - if err != nil { - return nil, fmt.Errorf("can not convert resourceVersion %q to int: %w", oldAccessor.GetResourceVersion(), err) - } - intResourceVersion++ - accessor.SetResourceVersion(strconv.FormatUint(intResourceVersion, 10)) - - if !deleting && !deletionTimestampEqual(accessor, oldAccessor) { - return nil, fmt.Errorf("error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName()) - } - - if !accessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 { - return nil, t.ObjectTracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName(), metav1.DeleteOptions{DryRun: dryRun}) - } - return convertFromUnstructuredIfNecessary(t.scheme, obj) -} - func (c *fakeClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { return err @@ -816,13 +589,7 @@ func (c *fakeClient) objMatchesFieldSelector(o runtime.Object, extractIndex clie panic(fmt.Errorf("expected object %v to be of type client.Object, but it's not", o)) } - for _, extractedVal := range extractIndex(obj) { - if extractedVal == val { - return true - } - } - - return false + return slices.Contains(extractIndex(obj), val) } func (c *fakeClient) Scheme() *runtime.Scheme { @@ -854,10 +621,8 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie createOptions := &client.CreateOptions{} createOptions.ApplyOptions(opts) - for _, dryRunOpt := range createOptions.DryRun { - if dryRunOpt == metav1.DryRunAll { - return nil - } + if slices.Contains(createOptions.DryRun, metav1.DryRunAll) { + return nil } gvr, err := getGVRFromObject(obj, c.scheme) @@ -921,10 +686,8 @@ func (c *fakeClient) Delete(ctx context.Context, obj client.Object, opts ...clie delOptions := client.DeleteOptions{} delOptions.ApplyOptions(opts) - for _, dryRunOpt := range delOptions.DryRun { - if dryRunOpt == metav1.DryRunAll { - return nil - } + if slices.Contains(delOptions.DryRun, metav1.DryRunAll) { + return nil } c.trackerWriteLock.Lock() @@ -970,10 +733,8 @@ func (c *fakeClient) DeleteAllOf(ctx context.Context, obj client.Object, opts .. dcOptions := client.DeleteAllOfOptions{} dcOptions.ApplyOptions(opts) - for _, dryRunOpt := range dcOptions.DryRun { - if dryRunOpt == metav1.DryRunAll { - return nil - } + if slices.Contains(dcOptions.DryRun, metav1.DryRunAll) { + return nil } c.trackerWriteLock.Lock() @@ -1021,10 +782,8 @@ func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.Upd updateOptions := &client.UpdateOptions{} updateOptions.ApplyOptions(opts) - for _, dryRunOpt := range updateOptions.DryRun { - if dryRunOpt == metav1.DryRunAll { - return nil - } + if slices.Contains(updateOptions.DryRun, metav1.DryRunAll) { + return nil } gvr, err := getGVRFromObject(obj, c.scheme) @@ -1136,10 +895,8 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client c.schemeLock.RLock() defer c.schemeLock.RUnlock() - for _, dryRunOpt := range patchOptions.DryRun { - if dryRunOpt == metav1.DryRunAll { - return nil - } + if slices.Contains(patchOptions.DryRun, metav1.DryRunAll) { + return nil } gvr, err := getGVRFromObject(obj, c.scheme) @@ -1171,9 +928,15 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client return err } - // SSA deletionTimestamp updates are silently ignored - if patch.Type() == types.ApplyPatchType && !isApplyCreate { - obj.SetDeletionTimestamp(oldAccessor.GetDeletionTimestamp()) + if patch.Type() == types.ApplyPatchType { + if isApplyCreate { + // Overwrite it unconditionally, this matches the apiserver behavior + // which allows to set it on create, but will then ignore it. + obj.SetResourceVersion("1") + } else { + // SSA deletionTimestamp updates are silently ignored + obj.SetDeletionTimestamp(oldAccessor.GetDeletionTimestamp()) + } } data, err := patch.Data(obj) @@ -1222,6 +985,15 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client reaction := testing.ObjectReaction(c.tracker) handled, o, err := reaction(action) if err != nil { + // The reaction calls tracker.Get after tracker.Apply to return the object, + // but we may have deleted it in tracker.Apply if there was no finalizer + // left. + if apierrors.IsNotFound(err) && + patch.Type() == types.ApplyPatchType && + oldAccessor.GetDeletionTimestamp() != nil && + len(obj.GetFinalizers()) == 0 { + return nil + } return err } if !handled { @@ -1548,6 +1320,42 @@ func (sw *fakeSubResourceClient) statusPatch(body client.Object, patch client.Pa return sw.client.patch(body, patch, &patchOptions.PatchOptions) } +func (sw *fakeSubResourceClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + if sw.subResource != "status" { + return errors.New("fakeSubResourceClient currently only supports Apply for status subresource") + } + + applyOpts := &client.SubResourceApplyOptions{} + applyOpts.ApplyOpts(opts) + + data, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal apply configuration: %w", err) + } + + u := &unstructured.Unstructured{} + if err := json.Unmarshal(data, u); err != nil { + return fmt.Errorf("failed to unmarshal apply configuration: %w", err) + } + + patchOpts := &client.SubResourcePatchOptions{} + patchOpts.Raw = applyOpts.AsPatchOptions() + + if applyOpts.SubResourceBody != nil { + subResourceBodySerialized, err := json.Marshal(applyOpts.SubResourceBody) + if err != nil { + return fmt.Errorf("failed to serialize subresource body: %w", err) + } + subResourceBody := &unstructured.Unstructured{} + if err := json.Unmarshal(subResourceBodySerialized, subResourceBody); err != nil { + return fmt.Errorf("failed to unmarshal subresource body: %w", err) + } + patchOpts.SubResourceBody = subResourceBody + } + + return sw.Patch(ctx, u, &fakeApplyPatch{}, patchOpts) +} + func allowsUnconditionalUpdate(gvk schema.GroupVersionKind) bool { switch gvk.Group { case "apps": @@ -1684,7 +1492,7 @@ func inTreeResourcesWithStatus() []schema.GroupVersionKind { } // zero zeros the value of a pointer. -func zero(x interface{}) { +func zero(x any) { if x == nil { return } @@ -1878,6 +1686,12 @@ func (c *fakeClient) addToSchemeIfUnknownAndUnstructuredOrPartial(obj runtime.Ob return err } + if isUnstructuredList || isPartialList { + if !strings.HasSuffix(gvk.Kind, "List") { + gvk.Kind += "List" + } + } + if !c.scheme.Recognizes(gvk) { c.scheme.AddKnownTypeWithName(gvk, obj) } diff --git a/pkg/client/fake/client_test.go b/pkg/client/fake/client_test.go index beb8d38433..209ccc67fe 100644 --- a/pkg/client/fake/client_test.go +++ b/pkg/client/fake/client_test.go @@ -660,6 +660,19 @@ var _ = Describe("Fake client", func() { Expect(obj.ObjectMeta.ResourceVersion).To(Equal(trackerAddResourceVersion)) }) + It("should reject apply with non-matching ResourceVersion", func(ctx SpecContext) { + cl := NewClientBuilder().WithRuntimeObjects(cm).Build() + applyCM := corev1applyconfigurations.ConfigMap(cm.Name, cm.Namespace).WithResourceVersion("0") + err := cl.Apply(ctx, applyCM, client.FieldOwner("test")) + Expect(apierrors.IsConflict(err)).To(BeTrue()) + + obj := &corev1.ConfigMap{} + err = cl.Get(ctx, client.ObjectKeyFromObject(cm), obj) + Expect(err).ToNot(HaveOccurred()) + Expect(obj).To(Equal(cm)) + Expect(obj.ObjectMeta.ResourceVersion).To(Equal(trackerAddResourceVersion)) + }) + It("should reject Delete with a mismatched ResourceVersion", func(ctx SpecContext) { bogusRV := "bogus" By("Deleting with a mismatched ResourceVersion Precondition") @@ -714,6 +727,35 @@ var _ = Describe("Fake client", func() { Expect(list.Items).To(ConsistOf(*dep2)) }) + It("should handle finalizers in Apply ", func(ctx SpecContext) { + cl := client.WithFieldOwner(cl, "test") + + By("Creating the object with a finalizer") + cm := corev1applyconfigurations.ConfigMap("test-cm", "delete-with-finalizers"). + WithFinalizers("finalizers.sigs.k8s.io/test") + Expect(cl.Apply(ctx, cm)).To(Succeed()) + + By("Deleting the object") + obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: *cm.Name, + Namespace: *cm.Namespace, + }} + Expect(cl.Delete(ctx, obj)).NotTo(HaveOccurred()) + + By("Getting the object") + Expect(cl.Get(ctx, client.ObjectKeyFromObject(obj), obj)).NotTo(HaveOccurred()) + Expect(obj.DeletionTimestamp).NotTo(BeNil()) + + By("Removing the finalizer through SSA") + cm.ResourceVersion = nil + cm.Finalizers = nil + Expect(cl.Apply(ctx, cm)).NotTo(HaveOccurred()) + + By("Getting the object") + err := cl.Get(ctx, client.ObjectKeyFromObject(obj), &corev1.ConfigMap{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + It("should handle finalizers on Update", func(ctx SpecContext) { namespacedName := types.NamespacedName{ Name: "test-cm", @@ -815,7 +857,7 @@ var _ = Describe("Fake client", func() { }) It("should handle finalizers deleting a collection", func(ctx SpecContext) { - for i := 0; i < 5; i++ { + for i := range 5 { namespacedName := types.NamespacedName{ Name: fmt.Sprintf("test-cm-%d", i), Namespace: "delete-collection-with-finalizers", @@ -950,9 +992,9 @@ var _ = Describe("Fake client", func() { It("should be able to Patch", func(ctx SpecContext) { By("Patching a deployment") - mergePatch, err := json.Marshal(map[string]interface{}{ - "metadata": map[string]interface{}{ - "annotations": map[string]interface{}{ + mergePatch, err := json.Marshal(map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ "foo": "bar", }, }, @@ -1167,8 +1209,8 @@ var _ = Describe("Fake client", func() { Expect(err).ToNot(HaveOccurred()) By("Removing the finalizer") - mergePatch, err := json.Marshal(map[string]interface{}{ - "metadata": map[string]interface{}{ + mergePatch, err := json.Marshal(map[string]any{ + "metadata": map[string]any{ "$deleteFromPrimitiveList/finalizers": []string{ "finalizers.sigs.k8s.io/test", }, @@ -1447,7 +1489,7 @@ var _ = Describe("Fake client", func() { }) It("should be able to build with given tracker and get resource", func(ctx SpecContext) { - clientSet := fake.NewSimpleClientset(dep) + clientSet := fake.NewClientset(dep) cl := NewClientBuilder().WithRuntimeObjects(dep2).WithObjectTracker(clientSet.Tracker()).Build() By("Getting a deployment") @@ -1733,6 +1775,65 @@ var _ = Describe("Fake client", func() { Expect(cmp.Diff(objOriginal, actual)).To(BeEmpty()) }) + It("should not change the status of objects with status subresource when creating through apply ", func(ctx SpecContext) { + obj := corev1applyconfigurations. + Pod("node", ""). + WithStatus( + corev1applyconfigurations.PodStatus().WithPhase("Running"), + ) + + cl := NewClientBuilder().WithStatusSubresource(&corev1.Pod{}).Build() + Expect(cl.Apply(ctx, obj, client.FieldOwner("test"))).To(Succeed()) + + p := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: *obj.Name}} + Expect(cl.Get(ctx, client.ObjectKeyFromObject(p), p)).To(Succeed()) + + Expect(p.Status).To(BeComparableTo(corev1.PodStatus{})) + }) + + It("should not change the status of objects with status subresource when updating through apply ", func(ctx SpecContext) { + + cl := NewClientBuilder().WithStatusSubresource(&corev1.Pod{}).Build() + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod"}} + Expect(cl.Create(ctx, pod)).NotTo(HaveOccurred()) + + obj := corev1applyconfigurations. + Pod(pod.Name, ""). + WithStatus( + corev1applyconfigurations.PodStatus().WithPhase("Running"), + ) + Expect(cl.Apply(ctx, obj, client.FieldOwner("test"))).To(Succeed()) + + Expect(cl.Get(ctx, client.ObjectKeyFromObject(pod), pod)).To(Succeed()) + + Expect(pod.Status).To(BeComparableTo(corev1.PodStatus{})) + }) + + It("should only change status on status apply", func(ctx SpecContext) { + initial := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + }, + Spec: corev1.NodeSpec{ + PodCIDR: "old-cidr", + }, + } + cl := NewClientBuilder().WithStatusSubresource(&corev1.Node{}).WithObjects(initial).Build() + + ac := corev1applyconfigurations.Node(initial.Name). + WithSpec(corev1applyconfigurations.NodeSpec().WithPodCIDR(initial.Spec.PodCIDR + "-updated")). + WithStatus(corev1applyconfigurations.NodeStatus().WithPhase(corev1.NodeRunning)) + + Expect(cl.Status().Apply(ctx, ac, client.FieldOwner("test-owner"))).To(Succeed()) + + actual := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: initial.Name}} + Expect(cl.Get(ctx, client.ObjectKeyFromObject(actual), actual)).To(Succeed()) + + initial.ResourceVersion = actual.ResourceVersion + initial.Status = actual.Status + Expect(initial).To(BeComparableTo(actual)) + }) + It("should Unmarshal the schemaless object with int64 to preserve ints", func(ctx SpecContext) { schemeBuilder := &scheme.Builder{GroupVersion: schema.GroupVersion{Group: "test", Version: "v1"}} schemeBuilder.Register(&WithSchemalessSpec{}) @@ -1911,7 +2012,7 @@ var _ = Describe("Fake client", func() { Expect(cl.Create(ctx, obj)).To(Succeed()) original := obj.DeepCopy() - err := unstructured.SetNestedField(obj.Object, map[string]interface{}{"count": int64(2)}, "status") + err := unstructured.SetNestedField(obj.Object, map[string]any{"count": int64(2)}, "status") Expect(err).ToNot(HaveOccurred()) Expect(cl.Patch(ctx, obj, client.MergeFrom(original))).To(Succeed()) @@ -2774,11 +2875,31 @@ var _ = Describe("Fake client", func() { Expect(cl.Get(ctx, client.ObjectKeyFromObject(cm), cm)).To(Succeed()) Expect(cm.Data).To(BeComparableTo(map[string]string{"some": "data"})) + // Apply with ResourceVersion set obj.Data = map[string]string{"other": "data"} Expect(cl.Apply(ctx, obj, &client.ApplyOptions{FieldManager: "test-manager"})).To(Succeed()) Expect(cl.Get(ctx, client.ObjectKeyFromObject(cm), cm)).To(Succeed()) Expect(cm.Data).To(BeComparableTo(map[string]string{"other": "data"})) + + // Apply with ResourceVersion unset + obj.ResourceVersion = ptr.To("") + obj.Data = map[string]string{"another": "data"} + Expect(cl.Apply(ctx, obj, &client.ApplyOptions{FieldManager: "test-manager"})).To(Succeed()) + + Expect(cl.Get(ctx, client.ObjectKeyFromObject(cm), cm)).To(Succeed()) + Expect(cm.Data).To(BeComparableTo(map[string]string{"another": "data"})) + }) + + It("returns a conflict when trying to Create an object with UID set through Apply", func(ctx SpecContext) { + cl := NewClientBuilder().Build() + obj := corev1applyconfigurations. + ConfigMap("foo", "default"). + WithUID("123") + + err := cl.Apply(ctx, obj, &client.ApplyOptions{FieldManager: "test-manager"}) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsConflict(err)).To(BeTrue()) }) It("errors when trying to server-side apply an object without configuring a FieldManager", func(ctx SpecContext) { @@ -2819,6 +2940,50 @@ var _ = Describe("Fake client", func() { Expect(cl.Get(ctx, client.ObjectKeyFromObject(result), result)).To(Succeed()) Expect(result.Object["spec"]).To(Equal(map[string]any{"some": "data"})) + // Apply with ResourceVersion set + Expect(unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "spec")).To(Succeed()) + applyConfig2 := client.ApplyConfigurationFromUnstructured(obj) + Expect(cl.Apply(ctx, applyConfig2, &client.ApplyOptions{FieldManager: "test-manager"})).To(Succeed()) + + Expect(cl.Get(ctx, client.ObjectKeyFromObject(result), result)).To(Succeed()) + Expect(result.Object["spec"]).To(Equal(map[string]any{"other": "data"})) + + // Apply with ResourceVersion unset + obj.SetResourceVersion("") + Expect(unstructured.SetNestedField(obj.Object, map[string]any{"another": "data"}, "spec")).To(Succeed()) + applyConfig3 := client.ApplyConfigurationFromUnstructured(obj) + Expect(cl.Apply(ctx, applyConfig3, &client.ApplyOptions{FieldManager: "test-manager"})).To(Succeed()) + + Expect(cl.Get(ctx, client.ObjectKeyFromObject(result), result)).To(Succeed()) + Expect(result.Object["spec"]).To(Equal(map[string]any{"another": "data"})) + }) + + It("supports server-side apply of a custom resource via Apply method after List with a non-list kind", func(ctx SpecContext) { + cl := NewClientBuilder().Build() + + // Previously this List call lead to addToSchemeIfUnknownAndUnstructuredOrPartial adding FakeResource as UnstructuredList to the scheme. + // This broke the subsequent SSA call as it was trying to create a new FakeResource object as an UnstructuredList. + // After a fix this List call leads to addToSchemeIfUnknownAndUnstructuredOrPartial adding FakeResourceList as UnstructuredList to the + // scheme even if the UnstructuredList here is missing the List suffix. + objList := &unstructured.UnstructuredList{} + objList.SetAPIVersion("custom/v1") + objList.SetKind("FakeResource") + Expect(cl.List(ctx, objList)).To(Succeed()) + + obj := &unstructured.Unstructured{} + obj.SetAPIVersion("custom/v1") + obj.SetKind("FakeResource") + obj.SetName("foo") + result := obj.DeepCopy() + + Expect(unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "spec")).To(Succeed()) + + applyConfig := client.ApplyConfigurationFromUnstructured(obj) + Expect(cl.Apply(ctx, applyConfig, &client.ApplyOptions{FieldManager: "test-manager"})).To(Succeed()) + + Expect(cl.Get(ctx, client.ObjectKeyFromObject(result), result)).To(Succeed()) + Expect(result.Object["spec"]).To(Equal(map[string]any{"some": "data"})) + Expect(unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "spec")).To(Succeed()) applyConfig2 := client.ApplyConfigurationFromUnstructured(obj) Expect(cl.Apply(ctx, applyConfig2, &client.ApplyOptions{FieldManager: "test-manager"})).To(Succeed()) @@ -2827,7 +2992,7 @@ var _ = Describe("Fake client", func() { Expect(result.Object["spec"]).To(Equal(map[string]any{"other": "data"})) }) - It("sets managed fields through all methods", func(ctx SpecContext) { + It("sets the fieldManager in create, patch and update", func(ctx SpecContext) { owner := "test-owner" cl := client.WithFieldOwner( NewClientBuilder().WithReturnManagedFields().Build(), @@ -2861,6 +3026,20 @@ var _ = Describe("Fake client", func() { } }) + It("sets the fieldManager when creating through update", func(ctx SpecContext) { + owner := "test-owner" + cl := client.WithFieldOwner( + NewClientBuilder().WithReturnManagedFields().Build(), + owner, + ) + + obj := &corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "foo"}} + Expect(cl.Update(ctx, obj, client.FieldOwner(owner))).NotTo(HaveOccurred()) + for _, f := range obj.ManagedFields { + Expect(f.Manager).To(BeEquivalentTo(owner)) + } + }) + // GH-3267 It("Doesn't leave stale data when updating an object through SSA", func(ctx SpecContext) { obj := corev1applyconfigurations. @@ -2877,6 +3056,29 @@ var _ = Describe("Fake client", func() { Expect(len(cms.Items)).To(BeEquivalentTo(1)) }) + It("sets resourceVersion on SSA create", func(ctx SpecContext) { + obj := corev1applyconfigurations. + ConfigMap("foo", "default"). + WithData(map[string]string{"some": "data"}) + + cl := NewClientBuilder().Build() + Expect(cl.Apply(ctx, obj, client.FieldOwner("foo"))).NotTo(HaveOccurred()) + // Ideally we should only test for it to not be empty, realistically we will + // break ppl if we ever start setting a different value. + Expect(obj.ResourceVersion).To(BeEquivalentTo(ptr.To("1"))) + }) + + It("ignores a passed resourceVersion on SSA create", func(ctx SpecContext) { + obj := corev1applyconfigurations. + ConfigMap("foo", "default"). + WithData(map[string]string{"some": "data"}). + WithResourceVersion("1234") + + cl := NewClientBuilder().Build() + Expect(cl.Apply(ctx, obj, client.FieldOwner("foo"))).NotTo(HaveOccurred()) + Expect(obj.ResourceVersion).To(BeEquivalentTo(ptr.To("1"))) + }) + It("allows to set deletionTimestamp on an object during SSA create", func(ctx SpecContext) { now := metav1.Time{Time: time.Now().Round(time.Second)} obj := corev1applyconfigurations. @@ -2909,12 +3111,12 @@ var _ = Describe("Fake client", func() { }) It("will error out if an object with invalid managedFields is added", func(ctx SpecContext) { - fieldV1Map := map[string]interface{}{ - "f:metadata": map[string]interface{}{ - "f:name": map[string]interface{}{}, - "f:labels": map[string]interface{}{}, - "f:annotations": map[string]interface{}{}, - "f:finalizers": map[string]interface{}{}, + fieldV1Map := map[string]any{ + "f:metadata": map[string]any{ + "f:name": map[string]any{}, + "f:labels": map[string]any{}, + "f:annotations": map[string]any{}, + "f:finalizers": map[string]any{}, }, } fieldV1, err := json.Marshal(fieldV1Map) @@ -2937,12 +3139,12 @@ var _ = Describe("Fake client", func() { }) It("allows adding an object with managedFields", func(ctx SpecContext) { - fieldV1Map := map[string]interface{}{ - "f:metadata": map[string]interface{}{ - "f:name": map[string]interface{}{}, - "f:labels": map[string]interface{}{}, - "f:annotations": map[string]interface{}{}, - "f:finalizers": map[string]interface{}{}, + fieldV1Map := map[string]any{ + "f:metadata": map[string]any{ + "f:name": map[string]any{}, + "f:labels": map[string]any{}, + "f:annotations": map[string]any{}, + "f:finalizers": map[string]any{}, }, } fieldV1, err := json.Marshal(fieldV1Map) @@ -2964,12 +3166,12 @@ var _ = Describe("Fake client", func() { }) It("allows adding an object with invalid managedFields when not using the FieldManagedObjectTracker", func(ctx SpecContext) { - fieldV1Map := map[string]interface{}{ - "f:metadata": map[string]interface{}{ - "f:name": map[string]interface{}{}, - "f:labels": map[string]interface{}{}, - "f:annotations": map[string]interface{}{}, - "f:finalizers": map[string]interface{}{}, + fieldV1Map := map[string]any{ + "f:metadata": map[string]any{ + "f:name": map[string]any{}, + "f:labels": map[string]any{}, + "f:annotations": map[string]any{}, + "f:finalizers": map[string]any{}, }, } fieldV1, err := json.Marshal(fieldV1Map) @@ -3087,7 +3289,7 @@ var _ = Describe("Fake client", func() { } }) -type Schemaless map[string]interface{} +type Schemaless map[string]any type WithSchemalessSpec struct { metav1.TypeMeta `json:",inline"` @@ -3159,4 +3361,13 @@ var _ = Describe("Fake client builder", func() { Expect(err).NotTo(HaveOccurred()) Expect(called).To(BeTrue()) }) + + It("should panic when calling build more than once", func() { + cb := NewClientBuilder() + anotherCb := cb + cb.Build() + Expect(func() { + anotherCb.Build() + }).To(Panic()) + }) }) diff --git a/pkg/client/fake/versioned_tracker.go b/pkg/client/fake/versioned_tracker.go new file mode 100644 index 0000000000..bbe3ac9b0d --- /dev/null +++ b/pkg/client/fake/versioned_tracker.go @@ -0,0 +1,364 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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. +*/ + +package fake + +import ( + "bytes" + "errors" + "fmt" + "runtime/debug" + "strconv" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/managedfields" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/testing" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +var _ testing.ObjectTracker = (*versionedTracker)(nil) + +type versionedTracker struct { + upstream testing.ObjectTracker + scheme *runtime.Scheme + withStatusSubresource sets.Set[schema.GroupVersionKind] + usesFieldManagedObjectTracker bool +} + +func (t versionedTracker) Add(obj runtime.Object) error { + var objects []runtime.Object + if meta.IsListType(obj) { + var err error + objects, err = meta.ExtractList(obj) + if err != nil { + return err + } + } else { + objects = []runtime.Object{obj} + } + for _, obj := range objects { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetDeletionTimestamp() != nil && len(accessor.GetFinalizers()) == 0 { + return fmt.Errorf("refusing to create obj %s with metadata.deletionTimestamp but no finalizers", accessor.GetName()) + } + if accessor.GetResourceVersion() == "" { + // We use a "magic" value of 999 here because this field + // is parsed as uint and and 0 is already used in Update. + // As we can't go lower, go very high instead so this can + // be recognized + accessor.SetResourceVersion(trackerAddResourceVersion) + } + + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + + // If the fieldManager can not decode fields, it will just silently clear them. This is pretty + // much guaranteed not to be what someone that initializes a fake client with objects that + // have them set wants, so validate them here. + // Ref https://github.com/kubernetes/kubernetes/blob/a956ef4862993b825bcd524a19260192ff1da72d/staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/fieldmanager.go#L105 + if t.usesFieldManagedObjectTracker { + if err := managedfields.ValidateManagedFields(accessor.GetManagedFields()); err != nil { + return fmt.Errorf("invalid managedFields on %T: %w", obj, err) + } + } + if err := t.upstream.Add(obj); err != nil { + return err + } + } + + return nil +} + +func (t versionedTracker) Create(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.CreateOptions) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetName() == "" { + gvk, _ := apiutil.GVKForObject(obj, t.scheme) + return apierrors.NewInvalid( + gvk.GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + if accessor.GetResourceVersion() != "" { + return apierrors.NewBadRequest("resourceVersion can not be set for Create requests") + } + accessor.SetResourceVersion("1") + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + if err := t.upstream.Create(gvr, obj, ns, opts...); err != nil { + accessor.SetResourceVersion("") + return err + } + + return nil +} + +func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.UpdateOptions) error { + updateOpts, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + return t.update(gvr, obj, ns, false, false, updateOpts) +} + +func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, opts metav1.UpdateOptions) error { + gvk, err := apiutil.GVKForObject(obj, t.scheme) + if err != nil { + return err + } + obj, needsCreate, err := t.updateObject(gvr, gvk, obj, ns, isStatus, deleting, allowsCreateOnUpdate(gvk), opts.DryRun) + if err != nil { + return err + } + + if needsCreate { + opts := metav1.CreateOptions{DryRun: opts.DryRun, FieldManager: opts.FieldManager} + return t.Create(gvr, obj, ns, opts) + } + + if obj == nil { // Object was deleted in updateObject + return nil + } + + if u, unstructured := obj.(*unstructured.Unstructured); unstructured { + u.SetGroupVersionKind(gvk) + } + + return t.upstream.Update(gvr, obj, ns, opts) +} + +func (t versionedTracker) Patch(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.PatchOptions) error { + patchOptions, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + gvk, err := apiutil.GVKForObject(obj, t.scheme) + if err != nil { + return err + } + + // We apply patches using a client-go reaction that ends up calling the trackers Patch. As we can't change + // that reaction, we use the callstack to figure out if this originated from the status client. + isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) + + obj, needsCreate, err := t.updateObject(gvr, gvk, obj, ns, isStatus, false, allowsCreateOnUpdate(gvk), patchOptions.DryRun) + if err != nil { + return err + } + if needsCreate { + opts := metav1.CreateOptions{DryRun: patchOptions.DryRun, FieldManager: patchOptions.FieldManager} + return t.Create(gvr, obj, ns, opts) + } + + if obj == nil { // Object was deleted in updateObject + return nil + } + + return t.upstream.Patch(gvr, obj, ns, patchOptions) +} + +// updateObject performs a number of validations and changes related to +// object updates, such as checking and updating the resourceVersion. +func (t versionedTracker) updateObject( + gvr schema.GroupVersionResource, + gvk schema.GroupVersionKind, + obj runtime.Object, + ns string, + isStatus bool, + deleting bool, + allowCreateOnUpdate bool, + dryRun []string, +) (result runtime.Object, needsCreate bool, _ error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, false, fmt.Errorf("failed to get accessor for object: %w", err) + } + + if accessor.GetName() == "" { + return nil, false, apierrors.NewInvalid( + gvk.GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + + oldObject, err := t.Get(gvr, ns, accessor.GetName()) + if err != nil { + // If the resource is not found and the resource allows create on update, issue a + // create instead. + if apierrors.IsNotFound(err) && allowCreateOnUpdate { + // Pass this info to the caller rather than create, because in the SSA case it + // must be created by calling Apply in the upstream tracker, not Create. + // This is because SSA considers Apply and Non-Apply operations to be different + // even when they use the same fieldManager. This behavior is also observable + // with a real Kubernetes apiserver. + // + // Ref https://kubernetes.slack.com/archives/C0EG7JC6T/p1757868204458989?thread_ts=1757808656.002569&cid=C0EG7JC6T + return obj, true, nil + } + return obj, false, err + } + + if t.withStatusSubresource.Has(gvk) { + if isStatus { // copy everything but status and metadata.ResourceVersion from original object + if err := copyStatusFrom(obj, oldObject); err != nil { + return nil, false, fmt.Errorf("failed to copy non-status field for object with status subresouce: %w", err) + } + passedRV := accessor.GetResourceVersion() + if err := copyFrom(oldObject, obj); err != nil { + return nil, false, fmt.Errorf("failed to restore non-status fields: %w", err) + } + accessor.SetResourceVersion(passedRV) + } else { // copy status from original object + if err := copyStatusFrom(oldObject, obj); err != nil { + return nil, false, fmt.Errorf("failed to copy the status for object with status subresource: %w", err) + } + } + } else if isStatus { + return nil, false, apierrors.NewNotFound(gvr.GroupResource(), accessor.GetName()) + } + + oldAccessor, err := meta.Accessor(oldObject) + if err != nil { + return nil, false, err + } + + // If the new object does not have the resource version set and it allows unconditional update, + // default it to the resource version of the existing resource + if accessor.GetResourceVersion() == "" { + switch { + case allowsUnconditionalUpdate(gvk): + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + // This is needed because if the patch explicitly sets the RV to null, the client-go reaction we use + // to apply it and whose output we process here will have it unset. It is not clear why the Kubernetes + // apiserver accepts such a patch, but it does so we just copy that behavior. + // Kubernetes apiserver behavior can be checked like this: + // `kubectl patch configmap foo --patch '{"metadata":{"annotations":{"foo":"bar"},"resourceVersion":null}}' -v=9` + case bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Patch")): + // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change + // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Patch" func. + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + case bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Apply")): + // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change + // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Apply" func. + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + } + } + + if accessor.GetResourceVersion() != oldAccessor.GetResourceVersion() { + return nil, false, apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), errors.New("object was modified")) + } + if oldAccessor.GetResourceVersion() == "" { + oldAccessor.SetResourceVersion("0") + } + intResourceVersion, err := strconv.ParseUint(oldAccessor.GetResourceVersion(), 10, 64) + if err != nil { + return nil, false, fmt.Errorf("can not convert resourceVersion %q to int: %w", oldAccessor.GetResourceVersion(), err) + } + intResourceVersion++ + accessor.SetResourceVersion(strconv.FormatUint(intResourceVersion, 10)) + + if !deleting && !deletionTimestampEqual(accessor, oldAccessor) { + return nil, false, fmt.Errorf("error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName()) + } + + if !accessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 { + return nil, false, t.Delete(gvr, accessor.GetNamespace(), accessor.GetName(), metav1.DeleteOptions{DryRun: dryRun}) + } + + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + return obj, false, err +} + +func (t versionedTracker) Apply(gvr schema.GroupVersionResource, applyConfiguration runtime.Object, ns string, opts ...metav1.PatchOptions) error { + patchOptions, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + gvk, err := apiutil.GVKForObject(applyConfiguration, t.scheme) + if err != nil { + return err + } + isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) + + applyConfiguration, needsCreate, err := t.updateObject(gvr, gvk, applyConfiguration, ns, isStatus, false, true, patchOptions.DryRun) + if err != nil { + return err + } + + if needsCreate { + // https://github.com/kubernetes/kubernetes/blob/81affffa1b8d8079836f4cac713ea8d1b2bbf10f/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L606 + accessor, err := meta.Accessor(applyConfiguration) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetUID() != "" { + return apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), fmt.Errorf("uid mismatch: the provided object specified uid %s, and no existing object was found", accessor.GetUID())) + } + + if t.withStatusSubresource.Has(gvk) { + // Clear out status for create, for update this is handled in updateObject + if err := copyStatusFrom(&unstructured.Unstructured{}, applyConfiguration); err != nil { + return err + } + } + } + + if applyConfiguration == nil { // Object was deleted in updateObject + return nil + } + + if isStatus { + // We restore everything but status from the tracker where we don't put GVK + // into the object but it must be set for the ManagedFieldsObjectTracker + applyConfiguration.GetObjectKind().SetGroupVersionKind(gvk) + } + return t.upstream.Apply(gvr, applyConfiguration, ns, opts...) +} + +func (t versionedTracker) Delete(gvr schema.GroupVersionResource, ns, name string, opts ...metav1.DeleteOptions) error { + return t.upstream.Delete(gvr, ns, name, opts...) +} + +func (t versionedTracker) Get(gvr schema.GroupVersionResource, ns, name string, opts ...metav1.GetOptions) (runtime.Object, error) { + return t.upstream.Get(gvr, ns, name, opts...) +} + +func (t versionedTracker) List(gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, ns string, opts ...metav1.ListOptions) (runtime.Object, error) { + return t.upstream.List(gvr, gvk, ns, opts...) +} + +func (t versionedTracker) Watch(gvr schema.GroupVersionResource, ns string, opts ...metav1.ListOptions) (watch.Interface, error) { + return t.upstream.Watch(gvr, ns, opts...) +} diff --git a/pkg/client/fieldowner.go b/pkg/client/fieldowner.go index 93274f9500..5d9437ba91 100644 --- a/pkg/client/fieldowner.go +++ b/pkg/client/fieldowner.go @@ -108,3 +108,7 @@ func (f *subresourceClientWithFieldOwner) Update(ctx context.Context, obj Object func (f *subresourceClientWithFieldOwner) Patch(ctx context.Context, obj Object, patch Patch, opts ...SubResourcePatchOption) error { return f.subresourceWriter.Patch(ctx, obj, patch, append([]SubResourcePatchOption{FieldOwner(f.owner)}, opts...)...) } + +func (f *subresourceClientWithFieldOwner) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...SubResourceApplyOption) error { + return f.subresourceWriter.Apply(ctx, obj, append([]SubResourceApplyOption{FieldOwner(f.owner)}, opts...)...) +} diff --git a/pkg/client/fieldowner_test.go b/pkg/client/fieldowner_test.go index 95cb4e0f91..069abbc115 100644 --- a/pkg/client/fieldowner_test.go +++ b/pkg/client/fieldowner_test.go @@ -21,6 +21,8 @@ import ( "testing" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + corev1applyconfigurations "k8s.io/client-go/applyconfigurations/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" @@ -33,18 +35,22 @@ func TestWithFieldOwner(t *testing.T) { ctx := t.Context() dummyObj := &corev1.Namespace{} + dummyObjectAC := corev1applyconfigurations.Namespace(dummyObj.Name) _ = wrappedClient.Create(ctx, dummyObj) _ = wrappedClient.Update(ctx, dummyObj) _ = wrappedClient.Patch(ctx, dummyObj, nil) + _ = wrappedClient.Apply(ctx, dummyObjectAC) _ = wrappedClient.Status().Create(ctx, dummyObj, dummyObj) _ = wrappedClient.Status().Update(ctx, dummyObj) _ = wrappedClient.Status().Patch(ctx, dummyObj, nil) + _ = wrappedClient.Status().Apply(ctx, dummyObjectAC) _ = wrappedClient.SubResource("some-subresource").Create(ctx, dummyObj, dummyObj) _ = wrappedClient.SubResource("some-subresource").Update(ctx, dummyObj) _ = wrappedClient.SubResource("some-subresource").Patch(ctx, dummyObj, nil) + _ = wrappedClient.SubResource("some-subresource").Apply(ctx, dummyObjectAC) - if expectedCalls := 9; calls != expectedCalls { + if expectedCalls := 12; calls != expectedCalls { t.Fatalf("wrong number of calls to assertions: expected=%d; got=%d", expectedCalls, calls) } } @@ -57,18 +63,22 @@ func TestWithFieldOwnerOverridden(t *testing.T) { ctx := t.Context() dummyObj := &corev1.Namespace{} + dummyObjectAC := corev1applyconfigurations.Namespace(dummyObj.Name) _ = wrappedClient.Create(ctx, dummyObj, client.FieldOwner("new-field-manager")) _ = wrappedClient.Update(ctx, dummyObj, client.FieldOwner("new-field-manager")) _ = wrappedClient.Patch(ctx, dummyObj, nil, client.FieldOwner("new-field-manager")) + _ = wrappedClient.Apply(ctx, dummyObjectAC, client.FieldOwner("new-field-manager")) _ = wrappedClient.Status().Create(ctx, dummyObj, dummyObj, client.FieldOwner("new-field-manager")) _ = wrappedClient.Status().Update(ctx, dummyObj, client.FieldOwner("new-field-manager")) _ = wrappedClient.Status().Patch(ctx, dummyObj, nil, client.FieldOwner("new-field-manager")) + _ = wrappedClient.Status().Apply(ctx, dummyObjectAC, client.FieldOwner("new-field-manager")) _ = wrappedClient.SubResource("some-subresource").Create(ctx, dummyObj, dummyObj, client.FieldOwner("new-field-manager")) _ = wrappedClient.SubResource("some-subresource").Update(ctx, dummyObj, client.FieldOwner("new-field-manager")) _ = wrappedClient.SubResource("some-subresource").Patch(ctx, dummyObj, nil, client.FieldOwner("new-field-manager")) + _ = wrappedClient.SubResource("some-subresource").Apply(ctx, dummyObjectAC, client.FieldOwner("new-field-manager")) - if expectedCalls := 9; calls != expectedCalls { + if expectedCalls := 12; calls != expectedCalls { t.Fatalf("wrong number of calls to assertions: expected=%d; got=%d", expectedCalls, calls) } } @@ -144,5 +154,27 @@ func testClient(t *testing.T, expectedFieldManager string, callback func()) clie } return nil }, + Apply: func(ctx context.Context, c client.WithWatch, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error { + callback() + out := &client.ApplyOptions{} + for _, f := range opts { + f.ApplyToApply(out) + } + if got := out.FieldManager; expectedFieldManager != got { + t.Fatalf("wrong field manager: expected=%q; got=%q", expectedFieldManager, got) + } + return nil + }, + SubResourceApply: func(ctx context.Context, c client.Client, subResourceName string, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + callback() + out := &client.SubResourceApplyOptions{} + for _, f := range opts { + f.ApplyToSubResourceApply(out) + } + if got := out.FieldManager; expectedFieldManager != got { + t.Fatalf("wrong field manager: expected=%q; got=%q", expectedFieldManager, got) + } + return nil + }, }).Build() } diff --git a/pkg/client/fieldvalidation.go b/pkg/client/fieldvalidation.go index ce8d0576c7..b0f660854e 100644 --- a/pkg/client/fieldvalidation.go +++ b/pkg/client/fieldvalidation.go @@ -27,6 +27,9 @@ import ( // WithFieldValidation wraps a Client and configures field validation, by // default, for all write requests from this client. Users can override field // validation for individual write requests. +// +// This wrapper has no effect on apply requests, as they do not support a +// custom fieldValidation setting, it is always strict. func WithFieldValidation(c Client, validation FieldValidation) Client { return &clientWithFieldValidation{ validation: validation, @@ -108,3 +111,7 @@ func (c *subresourceClientWithFieldValidation) Update(ctx context.Context, obj O func (c *subresourceClientWithFieldValidation) Patch(ctx context.Context, obj Object, patch Patch, opts ...SubResourcePatchOption) error { return c.subresourceWriter.Patch(ctx, obj, patch, append([]SubResourcePatchOption{c.validation}, opts...)...) } + +func (c *subresourceClientWithFieldValidation) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...SubResourceApplyOption) error { + return c.subresourceWriter.Apply(ctx, obj, opts...) +} diff --git a/pkg/client/fieldvalidation_test.go b/pkg/client/fieldvalidation_test.go index d32ee5717d..6e6e9e5d17 100644 --- a/pkg/client/fieldvalidation_test.go +++ b/pkg/client/fieldvalidation_test.go @@ -92,6 +92,15 @@ var _ = Describe("ClientWithFieldValidation", func() { err = wrappedClient.SubResource("status").Patch(ctx, invalidStatusNode, patch) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("strict decoding error: unknown field \"status.invalidStatusField\"")) + + invalidApplyConfig := client.ApplyConfigurationFromUnstructured(invalidStatusNode) + err = wrappedClient.Status().Apply(ctx, invalidApplyConfig, client.FieldOwner("test-owner")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field not declared in schema")) + + err = wrappedClient.SubResource("status").Apply(ctx, invalidApplyConfig, client.FieldOwner("test-owner")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field not declared in schema")) }) }) @@ -110,11 +119,13 @@ func TestWithStrictFieldValidation(t *testing.T) { _ = wrappedClient.Status().Create(ctx, dummyObj, dummyObj) _ = wrappedClient.Status().Update(ctx, dummyObj) _ = wrappedClient.Status().Patch(ctx, dummyObj, nil) + _ = wrappedClient.Status().Apply(ctx, corev1applyconfigurations.Namespace(""), nil) _ = wrappedClient.SubResource("some-subresource").Create(ctx, dummyObj, dummyObj) _ = wrappedClient.SubResource("some-subresource").Update(ctx, dummyObj) _ = wrappedClient.SubResource("some-subresource").Patch(ctx, dummyObj, nil) + _ = wrappedClient.SubResource("some-subresource").Apply(ctx, corev1applyconfigurations.Namespace(""), nil) - if expectedCalls := 10; calls != expectedCalls { + if expectedCalls := 12; calls != expectedCalls { t.Fatalf("wrong number of calls to assertions: expected=%d; got=%d", expectedCalls, calls) } } @@ -278,5 +289,9 @@ func testFieldValidationClient(t *testing.T, expectedFieldValidation string, cal } return nil }, + SubResourceApply: func(ctx context.Context, c client.Client, subResourceName string, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + callback() + return nil + }, }).Build() } diff --git a/pkg/client/interceptor/intercept.go b/pkg/client/interceptor/intercept.go index 7ff73bd8da..b98af1a693 100644 --- a/pkg/client/interceptor/intercept.go +++ b/pkg/client/interceptor/intercept.go @@ -26,6 +26,7 @@ type Funcs struct { SubResourceCreate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error SubResourceUpdate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error SubResourcePatch func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error + SubResourceApply func(ctx context.Context, client client.Client, subResourceName string, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error } // NewClient returns a new interceptor client that calls the functions in funcs instead of the underlying client's methods, if they are not nil. @@ -173,3 +174,10 @@ func (s subResourceInterceptor) Patch(ctx context.Context, obj client.Object, pa } return s.client.SubResource(s.subResourceName).Patch(ctx, obj, patch, opts...) } + +func (s subResourceInterceptor) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + if s.funcs.SubResourceApply != nil { + return s.funcs.SubResourceApply(ctx, s.client, s.subResourceName, obj, opts...) + } + return s.client.SubResource(s.subResourceName).Apply(ctx, obj, opts...) +} diff --git a/pkg/client/interceptor/intercept_test.go b/pkg/client/interceptor/intercept_test.go index 26ea5b057e..fb58dfeac1 100644 --- a/pkg/client/interceptor/intercept_test.go +++ b/pkg/client/interceptor/intercept_test.go @@ -351,6 +351,31 @@ var _ = Describe("NewSubResourceClient", func() { _ = client2.SubResource("foo").Create(ctx, nil, nil) Expect(called).To(BeTrue()) }) + It("should call the provided Apply function", func(ctx SpecContext) { + var called bool + client := NewClient(c, Funcs{ + SubResourceApply: func(_ context.Context, client client.Client, subResourceName string, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + called = true + Expect(subResourceName).To(BeEquivalentTo("foo")) + return nil + }, + }) + _ = client.SubResource("foo").Apply(ctx, nil) + Expect(called).To(BeTrue()) + }) + It("should call the underlying client if the provided Apply function is nil", func(ctx SpecContext) { + var called bool + client1 := NewClient(c, Funcs{ + SubResourceApply: func(_ context.Context, client client.Client, subResourceName string, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + called = true + Expect(subResourceName).To(BeEquivalentTo("foo")) + return nil + }, + }) + client2 := NewClient(client1, Funcs{}) + _ = client2.SubResource("foo").Apply(ctx, nil) + Expect(called).To(BeTrue()) + }) }) type dummyClient struct{} diff --git a/pkg/client/interfaces.go b/pkg/client/interfaces.go index 61559ecbe1..1af1f3a368 100644 --- a/pkg/client/interfaces.go +++ b/pkg/client/interfaces.go @@ -155,6 +155,9 @@ type SubResourceWriter interface { // pointer so that obj can be updated with the content returned by the // Server. Patch(ctx context.Context, obj Object, patch Patch, opts ...SubResourcePatchOption) error + + // Apply applies the given apply configurations subresource. + Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...SubResourceApplyOption) error } // SubResourceClient knows how to perform CRU operations on Kubernetes objects. diff --git a/pkg/client/namespaced_client.go b/pkg/client/namespaced_client.go index cacba4a9c6..ebbbc4fddf 100644 --- a/pkg/client/namespaced_client.go +++ b/pkg/client/namespaced_client.go @@ -150,7 +150,7 @@ func (n *namespacedClient) Patch(ctx context.Context, obj Object, patch Patch, o return n.client.Patch(ctx, obj, patch, opts...) } -func (n *namespacedClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error { +func (n *namespacedClient) setNamespaceForApplyConfigIfNamespaceScoped(obj runtime.ApplyConfiguration) error { var gvk schema.GroupVersionKind switch o := obj.(type) { case applyConfiguration: @@ -193,6 +193,14 @@ func (n *namespacedClient) Apply(ctx context.Context, obj runtime.ApplyConfigura } } + return nil +} + +func (n *namespacedClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error { + if err := n.setNamespaceForApplyConfigIfNamespaceScoped(obj); err != nil { + return err + } + return n.client.Apply(ctx, obj, opts...) } @@ -213,7 +221,12 @@ func (n *namespacedClient) Get(ctx context.Context, key ObjectKey, obj Object, o // List implements client.Client. func (n *namespacedClient) List(ctx context.Context, obj ObjectList, opts ...ListOption) error { - if n.namespace != "" { + isNamespaceScoped, err := n.IsObjectNamespaced(obj) + if err != nil { + return fmt.Errorf("error finding the scope of the object: %w", err) + } + + if isNamespaceScoped && n.namespace != "" { opts = append(opts, InNamespace(n.namespace)) } return n.client.List(ctx, obj, opts...) @@ -226,7 +239,10 @@ func (n *namespacedClient) Status() SubResourceWriter { // SubResource implements client.SubResourceClient. func (n *namespacedClient) SubResource(subResource string) SubResourceClient { - return &namespacedClientSubResourceClient{client: n.client.SubResource(subResource), namespace: n.namespace, namespacedclient: n} + return &namespacedClientSubResourceClient{ + client: n.client.SubResource(subResource), + namespacedclient: n, + } } // ensure namespacedClientSubResourceClient implements client.SubResourceClient. @@ -234,8 +250,7 @@ var _ SubResourceClient = &namespacedClientSubResourceClient{} type namespacedClientSubResourceClient struct { client SubResourceClient - namespace string - namespacedclient Client + namespacedclient *namespacedClient } func (nsw *namespacedClientSubResourceClient) Get(ctx context.Context, obj, subResource Object, opts ...SubResourceGetOption) error { @@ -245,12 +260,12 @@ func (nsw *namespacedClientSubResourceClient) Get(ctx context.Context, obj, subR } objectNamespace := obj.GetNamespace() - if objectNamespace != nsw.namespace && objectNamespace != "" { - return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespace) + if objectNamespace != nsw.namespacedclient.namespace && objectNamespace != "" { + return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespacedclient.namespace) } if isNamespaceScoped && objectNamespace == "" { - obj.SetNamespace(nsw.namespace) + obj.SetNamespace(nsw.namespacedclient.namespace) } return nsw.client.Get(ctx, obj, subResource, opts...) @@ -263,12 +278,12 @@ func (nsw *namespacedClientSubResourceClient) Create(ctx context.Context, obj, s } objectNamespace := obj.GetNamespace() - if objectNamespace != nsw.namespace && objectNamespace != "" { - return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespace) + if objectNamespace != nsw.namespacedclient.namespace && objectNamespace != "" { + return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespacedclient.namespace) } if isNamespaceScoped && objectNamespace == "" { - obj.SetNamespace(nsw.namespace) + obj.SetNamespace(nsw.namespacedclient.namespace) } return nsw.client.Create(ctx, obj, subResource, opts...) @@ -282,12 +297,12 @@ func (nsw *namespacedClientSubResourceClient) Update(ctx context.Context, obj Ob } objectNamespace := obj.GetNamespace() - if objectNamespace != nsw.namespace && objectNamespace != "" { - return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespace) + if objectNamespace != nsw.namespacedclient.namespace && objectNamespace != "" { + return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespacedclient.namespace) } if isNamespaceScoped && objectNamespace == "" { - obj.SetNamespace(nsw.namespace) + obj.SetNamespace(nsw.namespacedclient.namespace) } return nsw.client.Update(ctx, obj, opts...) } @@ -300,12 +315,19 @@ func (nsw *namespacedClientSubResourceClient) Patch(ctx context.Context, obj Obj } objectNamespace := obj.GetNamespace() - if objectNamespace != nsw.namespace && objectNamespace != "" { - return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespace) + if objectNamespace != nsw.namespacedclient.namespace && objectNamespace != "" { + return fmt.Errorf("namespace %s of the object %s does not match the namespace %s on the client", objectNamespace, obj.GetName(), nsw.namespacedclient.namespace) } if isNamespaceScoped && objectNamespace == "" { - obj.SetNamespace(nsw.namespace) + obj.SetNamespace(nsw.namespacedclient.namespace) } return nsw.client.Patch(ctx, obj, patch, opts...) } + +func (nsw *namespacedClientSubResourceClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...SubResourceApplyOption) error { + if err := nsw.namespacedclient.setNamespaceForApplyConfigIfNamespaceScoped(obj); err != nil { + return err + } + return nsw.client.Apply(ctx, obj, opts...) +} diff --git a/pkg/client/namespaced_client_test.go b/pkg/client/namespaced_client_test.go index cf28289e72..d7be6eeee6 100644 --- a/pkg/client/namespaced_client_test.go +++ b/pkg/client/namespaced_client_test.go @@ -37,12 +37,14 @@ import ( corev1applyconfigurations "k8s.io/client-go/applyconfigurations/core/v1" metav1applyconfigurations "k8s.io/client-go/applyconfigurations/meta/v1" rbacv1applyconfigurations "k8s.io/client-go/applyconfigurations/rbac/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" ) var _ = Describe("NamespacedClient", func() { var dep *appsv1.Deployment + var nameSpace *corev1.Namespace var acDep *appsv1applyconfigurations.DeploymentApplyConfiguration var ns = "default" var count uint64 = 0 @@ -53,6 +55,8 @@ var _ = Describe("NamespacedClient", func() { err := rbacv1.AddToScheme(sch) Expect(err).ToNot(HaveOccurred()) + err = corev1.AddToScheme(sch) + Expect(err).ToNot(HaveOccurred()) err = appsv1.AddToScheme(sch) Expect(err).ToNot(HaveOccurred()) @@ -80,6 +84,12 @@ var _ = Describe("NamespacedClient", func() { }, }, } + nameSpace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("namespace-%v", count), + Labels: map[string]string{"name": fmt.Sprintf("namespace-%v", count)}, + }, + } acDep = appsv1applyconfigurations.Deployment(dep.Name, ""). WithLabels(dep.Labels). WithSpec(appsv1applyconfigurations.DeploymentSpec(). @@ -131,10 +141,13 @@ var _ = Describe("NamespacedClient", func() { var err error dep, err = clientset.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) + nameSpace, err = clientset.CoreV1().Namespaces().Create(ctx, nameSpace, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) }) AfterEach(func(ctx SpecContext) { deleteDeployment(ctx, dep, ns) + deleteNamespace(ctx, nameSpace) }) It("should successfully List objects when namespace is not specified with the object", func(ctx SpecContext) { @@ -146,6 +159,13 @@ var _ = Describe("NamespacedClient", func() { Expect(result.Items[0]).To(BeEquivalentTo(*dep)) }) + It("should successfully List objects when object is not namespaced scoped", func(ctx SpecContext) { + result := &corev1.NamespaceList{} + opts := &client.ListOptions{} + Expect(getClient().List(ctx, result, opts)).NotTo(HaveOccurred()) + Expect(result.Items).NotTo(BeEmpty()) + }) + It("should List objects from the namespace specified in the client", func(ctx SpecContext) { result := &appsv1.DeploymentList{} opts := client.InNamespace("non-default") @@ -613,6 +633,48 @@ var _ = Describe("NamespacedClient", func() { Expect(getClient().SubResource("status").Patch(ctx, changedDep, client.MergeFrom(dep))).To(HaveOccurred()) }) + + It("should change objects via status apply", func(ctx SpecContext) { + deploymentAC, err := appsv1applyconfigurations.ExtractDeployment(dep, "test-owner") + Expect(err).NotTo(HaveOccurred()) + deploymentAC.WithStatus(&appsv1applyconfigurations.DeploymentStatusApplyConfiguration{ + Replicas: ptr.To(int32(99)), + }) + + Expect(getClient().SubResource("status").Apply(ctx, deploymentAC, client.FieldOwner("test-owner"))).To(Succeed()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.GetNamespace()).To(BeEquivalentTo(ns)) + Expect(actual.Status.Replicas).To(BeEquivalentTo(99)) + }) + + It("should set namespace on ApplyConfiguration when applying via SubResource", func(ctx SpecContext) { + deploymentAC := appsv1applyconfigurations.Deployment(dep.Name, "") + deploymentAC.WithStatus(&appsv1applyconfigurations.DeploymentStatusApplyConfiguration{ + Replicas: ptr.To(int32(50)), + }) + + Expect(getClient().SubResource("status").Apply(ctx, deploymentAC, client.FieldOwner("test-owner"))).To(Succeed()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.GetNamespace()).To(BeEquivalentTo(ns)) + Expect(actual.Status.Replicas).To(BeEquivalentTo(50)) + }) + + It("should fail when applying via SubResource with conflicting namespace", func(ctx SpecContext) { + deploymentAC := appsv1applyconfigurations.Deployment(dep.Name, "different-namespace") + deploymentAC.WithStatus(&appsv1applyconfigurations.DeploymentStatusApplyConfiguration{ + Replicas: ptr.To(int32(25)), + }) + + err := getClient().SubResource("status").Apply(ctx, deploymentAC, client.FieldOwner("test-owner")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("namespace")) + }) }) Describe("Test on invalid objects", func() { @@ -644,9 +706,9 @@ var _ = Describe("NamespacedClient", func() { }) func generatePatch() []byte { - mergePatch, err := json.Marshal(map[string]interface{}{ - "metadata": map[string]interface{}{ - "annotations": map[string]interface{}{ + mergePatch, err := json.Marshal(map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ "foo": "bar", }, }, diff --git a/pkg/client/options.go b/pkg/client/options.go index 33c460738c..a6b921171a 100644 --- a/pkg/client/options.go +++ b/pkg/client/options.go @@ -97,6 +97,12 @@ type SubResourcePatchOption interface { ApplyToSubResourcePatch(*SubResourcePatchOptions) } +// SubResourceApplyOption configures a subresource apply request. +type SubResourceApplyOption interface { + // ApplyToSubResourceApply applies the configuration on the given patch options. + ApplyToSubResourceApply(*SubResourceApplyOptions) +} + // }}} // {{{ Multi-Type Options @@ -148,6 +154,10 @@ func (dryRunAll) ApplyToSubResourcePatch(opts *SubResourcePatchOptions) { opts.DryRun = []string{metav1.DryRunAll} } +func (dryRunAll) ApplyToSubResourceApply(opts *SubResourceApplyOptions) { + opts.DryRun = []string{metav1.DryRunAll} +} + // FieldOwner set the field manager name for the given server-side apply patch. type FieldOwner string @@ -186,6 +196,11 @@ func (f FieldOwner) ApplyToSubResourceUpdate(opts *SubResourceUpdateOptions) { opts.FieldManager = string(f) } +// ApplyToSubResourceApply applies this configuration to the given apply options. +func (f FieldOwner) ApplyToSubResourceApply(opts *SubResourceApplyOptions) { + opts.FieldManager = string(f) +} + // FieldValidation configures field validation for the given requests. type FieldValidation string @@ -949,6 +964,10 @@ func (forceOwnership) ApplyToApply(opts *ApplyOptions) { opts.Force = ptr.To(true) } +func (forceOwnership) ApplyToSubResourceApply(opts *SubResourceApplyOptions) { + opts.Force = ptr.To(true) +} + // }}} // {{{ DeleteAllOf Options diff --git a/pkg/client/options_test.go b/pkg/client/options_test.go index 0aa6a74007..88ef4a1839 100644 --- a/pkg/client/options_test.go +++ b/pkg/client/options_test.go @@ -307,7 +307,7 @@ var _ = Describe("MatchingLabels", func() { r, _ := listOpts.LabelSelector.Requirements() _, err := labels.NewRequirement(r[0].Key(), r[0].Operator(), r[0].Values().List()) Expect(err).To(HaveOccurred()) - expectedErrMsg := `values[0][k]: Invalid value: "axahm2EJ8Phiephe2eixohbee9eGeiyees1thuozi1xoh0GiuH3diewi8iem7Nui": must be no more than 63 characters` + expectedErrMsg := `values[0][k]: Invalid value: "axahm2EJ8Phiephe2eixohbee9eGeiyees1thuozi1xoh0GiuH3diewi8iem7Nui": must be no more than 63 bytes` Expect(err.Error()).To(Equal(expectedErrMsg)) }) @@ -374,6 +374,12 @@ var _ = Describe("DryRunAll", func() { t.ApplyToSubResourceUpdate(o) Expect(o.DryRun).To(Equal([]string{metav1.DryRunAll})) }) + It("Should apply to SubResourceApplyOptions", func() { + o := &client.SubResourceApplyOptions{ApplyOptions: client.ApplyOptions{DryRun: []string{"server"}}} + t := client.DryRunAll + t.ApplyToSubResourceApply(o) + Expect(o.DryRun).To(Equal([]string{metav1.DryRunAll})) + }) }) var _ = Describe("FieldOwner", func() { @@ -419,6 +425,12 @@ var _ = Describe("FieldOwner", func() { t.ApplyToSubResourceUpdate(o) Expect(o.FieldManager).To(Equal("foo")) }) + It("Should apply to SubResourceApplyOptions", func() { + o := &client.SubResourceApplyOptions{ApplyOptions: client.ApplyOptions{FieldManager: "bar"}} + t := client.FieldOwner("foo") + t.ApplyToSubResourceApply(o) + Expect(o.FieldManager).To(Equal("foo")) + }) }) var _ = Describe("ForceOwnership", func() { @@ -440,6 +452,12 @@ var _ = Describe("ForceOwnership", func() { t.ApplyToApply(o) Expect(*o.Force).To(BeTrue()) }) + It("Should apply to SubResourceApplyOptions", func() { + o := &client.SubResourceApplyOptions{} + t := client.ForceOwnership + t.ApplyToSubResourceApply(o) + Expect(*o.Force).To(BeTrue()) + }) }) var _ = Describe("HasLabels", func() { diff --git a/pkg/client/patch.go b/pkg/client/patch.go index ec55861080..3d914eea22 100644 --- a/pkg/client/patch.go +++ b/pkg/client/patch.go @@ -28,7 +28,7 @@ import ( var ( // Apply uses server-side apply to patch the given object. // - // Deprecated: Use client.Client.Apply() instead. + // Deprecated: Use client.Client.Apply() and client.Client.SubResource("subrsource").Apply() instead. Apply Patch = applyPatch{} // Merge uses the raw object as a merge patch, without modifications. @@ -88,7 +88,7 @@ type MergeFromOptions struct { type mergeFromPatch struct { patchType types.PatchType - createPatch func(originalJSON, modifiedJSON []byte, dataStruct interface{}) ([]byte, error) + createPatch func(originalJSON, modifiedJSON []byte, dataStruct any) ([]byte, error) from Object opts MergeFromOptions } @@ -134,11 +134,11 @@ func (s *mergeFromPatch) Data(obj Object) ([]byte, error) { return data, nil } -func createMergePatch(originalJSON, modifiedJSON []byte, _ interface{}) ([]byte, error) { +func createMergePatch(originalJSON, modifiedJSON []byte, _ any) ([]byte, error) { return jsonpatch.CreateMergePatch(originalJSON, modifiedJSON) } -func createStrategicMergePatch(originalJSON, modifiedJSON []byte, dataStruct interface{}) ([]byte, error) { +func createStrategicMergePatch(originalJSON, modifiedJSON []byte, dataStruct any) ([]byte, error) { return strategicpatch.CreateTwoWayMergePatch(originalJSON, modifiedJSON, dataStruct) } diff --git a/pkg/client/typed_client.go b/pkg/client/typed_client.go index 3bd762a638..66ae2e4a5c 100644 --- a/pkg/client/typed_client.go +++ b/pkg/client/typed_client.go @@ -304,3 +304,36 @@ func (c *typedClient) PatchSubResource(ctx context.Context, obj Object, subResou Do(ctx). Into(body) } + +func (c *typedClient) ApplySubResource(ctx context.Context, obj runtime.ApplyConfiguration, subResource string, opts ...SubResourceApplyOption) error { + o, err := c.resources.getObjMeta(obj) + if err != nil { + return err + } + + applyOpts := &SubResourceApplyOptions{} + applyOpts.ApplyOpts(opts) + + body := obj + if applyOpts.SubResourceBody != nil { + body = applyOpts.SubResourceBody + } + + req, err := apply.NewRequest(o, body) + if err != nil { + return fmt.Errorf("failed to create apply request: %w", err) + } + + return req. + NamespaceIfScoped(o.namespace, o.isNamespaced()). + Resource(o.resource()). + Name(o.name). + SubResource(subResource). + VersionedParams(applyOpts.AsPatchOptions(), c.paramCodec). + Do(ctx). + // This is hacky, it is required because `Into` takes a `runtime.Object` and + // that is not implemented by the ApplyConfigurations. The generated clients + // don't have this problem because they deserialize into the api type, not the + // apply configuration: https://github.com/kubernetes/kubernetes/blob/22f5e01a37c0bc6a5f494dec14dd4e3688ee1d55/staging/src/k8s.io/client-go/gentype/type.go#L296-L317 + Into(runtimeObjectFromApplyConfiguration(obj)) +} diff --git a/pkg/client/unstructured_client.go b/pkg/client/unstructured_client.go index e636c3beef..d2ea6d7a32 100644 --- a/pkg/client/unstructured_client.go +++ b/pkg/client/unstructured_client.go @@ -386,3 +386,35 @@ func (uc *unstructuredClient) PatchSubResource(ctx context.Context, obj Object, u.GetObjectKind().SetGroupVersionKind(gvk) return result } + +func (uc *unstructuredClient) ApplySubResource(ctx context.Context, obj runtime.ApplyConfiguration, subResource string, opts ...SubResourceApplyOption) error { + unstructuredApplyConfig, ok := obj.(*unstructuredApplyConfiguration) + if !ok { + return fmt.Errorf("bug: unstructured client got an applyconfiguration that was not %T but %T", &unstructuredApplyConfiguration{}, obj) + } + o, err := uc.resources.getObjMeta(unstructuredApplyConfig.Unstructured) + if err != nil { + return err + } + + applyOpts := &SubResourceApplyOptions{} + applyOpts.ApplyOpts(opts) + + body := obj + if applyOpts.SubResourceBody != nil { + body = applyOpts.SubResourceBody + } + req, err := apply.NewRequest(o, body) + if err != nil { + return fmt.Errorf("failed to create apply request: %w", err) + } + + return req. + NamespaceIfScoped(o.namespace, o.isNamespaced()). + Resource(o.resource()). + Name(o.name). + SubResource(subResource). + VersionedParams(applyOpts.AsPatchOptions(), uc.paramCodec). + Do(ctx). + Into(unstructuredApplyConfig.Unstructured) +} diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index 0603f4cde5..ee14638c3f 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -19,13 +19,16 @@ package cluster import ( "context" "errors" + "fmt" "net/http" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" + eventsv1client "k8s.io/client-go/kubernetes/typed/events/v1" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -33,10 +36,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/apiutil" logf "sigs.k8s.io/controller-runtime/pkg/internal/log" intrec "sigs.k8s.io/controller-runtime/pkg/internal/recorder" + "sigs.k8s.io/controller-runtime/pkg/recorder" ) // Cluster provides various methods to interact with a cluster. type Cluster interface { + recorder.Provider + // GetHTTPClient returns an HTTP client that can be used to talk to the apiserver GetHTTPClient() *http.Client @@ -58,9 +64,6 @@ type Cluster interface { // GetFieldIndexer returns a client.FieldIndexer configured with the client GetFieldIndexer() client.FieldIndexer - // GetEventRecorderFor returns a new EventRecorder for the provided name - GetEventRecorderFor(name string) record.EventRecorder - // GetRESTMapper returns a RESTMapper GetRESTMapper() meta.RESTMapper @@ -160,8 +163,7 @@ func New(config *rest.Config, opts ...Option) (Cluster, error) { } options, err := setOptionsDefaults(options, config) if err != nil { - options.Logger.Error(err, "Failed to set defaults") - return nil, err + return nil, fmt.Errorf("failed setting cluster default options: %w", err) } // Create the mapper provider @@ -281,16 +283,24 @@ func setOptionsDefaults(options Options, config *rest.Config) (Options, error) { options.newRecorderProvider = intrec.NewProvider } + // This is duplicated with pkg/manager, we need it here to provide + // the user with an EventBroadcaster and there for the Leader election + evtCl, err := eventsv1client.NewForConfigAndClient(config, options.HTTPClient) + if err != nil { + return options, err + } + // This is duplicated with pkg/manager, we need it here to provide // the user with an EventBroadcaster and there for the Leader election if options.EventBroadcaster == nil { // defer initialization to avoid leaking by default - options.makeBroadcaster = func() (record.EventBroadcaster, bool) { - return record.NewBroadcaster(), true + options.makeBroadcaster = func() (record.EventBroadcaster, events.EventBroadcaster, bool) { + return record.NewBroadcaster(), events.NewBroadcaster(&events.EventSinkImpl{Interface: evtCl}), true } } else { - options.makeBroadcaster = func() (record.EventBroadcaster, bool) { - return options.EventBroadcaster, false + // keep supporting the options.EventBroadcaster in the old API, but do not introduce it for the new one. + options.makeBroadcaster = func() (record.EventBroadcaster, events.EventBroadcaster, bool) { + return options.EventBroadcaster, events.NewBroadcaster(&events.EventSinkImpl{Interface: evtCl}), false } } diff --git a/pkg/cluster/cluster_test.go b/pkg/cluster/cluster_test.go index c08a742403..c275ff0bf5 100644 --- a/pkg/cluster/cluster_test.go +++ b/pkg/cluster/cluster_test.go @@ -40,7 +40,6 @@ var _ = Describe("cluster.Cluster", func() { c, err := New(nil) Expect(c).To(BeNil()) Expect(err.Error()).To(ContainSubstring("must specify Config")) - }) It("should return an error if it can't create a RestMapper", func() { @@ -50,7 +49,6 @@ var _ = Describe("cluster.Cluster", func() { }) Expect(c).To(BeNil()) Expect(err).To(Equal(expected)) - }) It("should return an error it can't create a client.Client", func() { @@ -96,7 +94,6 @@ var _ = Describe("cluster.Cluster", func() { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("expected error")) }) - }) Describe("Start", func() { @@ -160,7 +157,13 @@ var _ = Describe("cluster.Cluster", func() { It("should provide a function to get the EventRecorder", func() { c, err := New(cfg) Expect(err).NotTo(HaveOccurred()) - Expect(c.GetEventRecorderFor("test")).NotTo(BeNil()) + Expect(c.GetEventRecorder("test")).NotTo(BeNil()) + }) + + It("should provide a function to get the deprecated EventRecorder", func() { + c, err := New(cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(c.GetEventRecorderFor("test")).NotTo(BeNil()) //nolint:staticcheck }) It("should provide a function to get the APIReader", func() { c, err := New(cfg) diff --git a/pkg/cluster/internal.go b/pkg/cluster/internal.go index 2742764231..755f83b546 100644 --- a/pkg/cluster/internal.go +++ b/pkg/cluster/internal.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -87,6 +88,10 @@ func (c *cluster) GetEventRecorderFor(name string) record.EventRecorder { return c.recorderProvider.GetEventRecorderFor(name) } +func (c *cluster) GetEventRecorder(name string) events.EventRecorder { + return c.recorderProvider.GetEventRecorder(name) +} + func (c *cluster) GetRESTMapper() meta.RESTMapper { return c.mapper } diff --git a/pkg/config/controller.go b/pkg/config/controller.go index 3dafaef93b..5eea2965f6 100644 --- a/pkg/config/controller.go +++ b/pkg/config/controller.go @@ -79,7 +79,7 @@ type Controller struct { // UsePriorityQueue configures the controllers queue to use the controller-runtime provided // priority queue. // - // Note: This flag is disabled by default until a future version. This feature is currently in beta. + // Note: This flag is enabled by default. // For more details, see: https://github.com/kubernetes-sigs/controller-runtime/issues/2374. UsePriorityQueue *bool diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index afa15aebec..853788d52f 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -91,7 +91,7 @@ type TypedOptions[request comparable] struct { // UsePriorityQueue configures the controllers queue to use the controller-runtime provided // priority queue. // - // Note: This flag is disabled by default until a future version. This feature is currently in beta. + // Note: This flag is enabled by default. // For more details, see: https://github.com/kubernetes-sigs/controller-runtime/issues/2374. UsePriorityQueue *bool @@ -250,7 +250,7 @@ func NewTypedUnmanaged[request comparable](name string, options TypedOptions[req } if options.RateLimiter == nil { - if ptr.Deref(options.UsePriorityQueue, false) { + if ptr.Deref(options.UsePriorityQueue, true) { options.RateLimiter = workqueue.NewTypedItemExponentialFailureRateLimiter[request](5*time.Millisecond, 1000*time.Second) } else { options.RateLimiter = workqueue.DefaultTypedControllerRateLimiter[request]() @@ -259,7 +259,7 @@ func NewTypedUnmanaged[request comparable](name string, options TypedOptions[req if options.NewQueue == nil { options.NewQueue = func(controllerName string, rateLimiter workqueue.TypedRateLimiter[request]) workqueue.TypedRateLimitingInterface[request] { - if ptr.Deref(options.UsePriorityQueue, false) { + if ptr.Deref(options.UsePriorityQueue, true) { return priorityqueue.New(controllerName, func(o *priorityqueue.Opts[request]) { o.Log = options.Logger.WithValues("controller", controllerName) o.RateLimiter = rateLimiter diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 335e6d830e..06138a476b 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -439,9 +439,9 @@ var _ = Describe("controller.Controller", func() { Expect(ok).To(BeTrue()) }) - It("should configure a priority queue if UsePriorityQueue is set", func() { + It("should configure a priority queue per default", func() { m, err := manager.New(cfg, manager.Options{ - Controller: config.Controller{UsePriorityQueue: ptr.To(true)}, + Controller: config.Controller{}, }) Expect(err).NotTo(HaveOccurred()) @@ -458,12 +458,13 @@ var _ = Describe("controller.Controller", func() { Expect(ok).To(BeTrue()) }) - It("should not configure a priority queue if UsePriorityQueue is not set", func() { + It("should not configure a priority queue if UsePriorityQueue is set to false", func() { m, err := manager.New(cfg, manager.Options{}) Expect(err).NotTo(HaveOccurred()) c, err := controller.New("new-controller-17", m, controller.Options{ - Reconciler: rec, + Reconciler: rec, + UsePriorityQueue: ptr.To(false), }) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/controller/controllertest/util.go b/pkg/controller/controllertest/util.go index 2c9a248899..8df24fcf57 100644 --- a/pkg/controller/controllertest/util.go +++ b/pkg/controller/controllertest/util.go @@ -37,6 +37,16 @@ type FakeInformer struct { handlers []cache.ResourceEventHandler } +// fakeHandlerRegistration implements cache.ResourceEventHandlerRegistration for testing. +type fakeHandlerRegistration struct { + informer *FakeInformer +} + +// HasSynced implements cache.ResourceEventHandlerRegistration. +func (f *fakeHandlerRegistration) HasSynced() bool { + return f.informer.Synced +} + // AddIndexers does nothing. TODO(community): Implement this. func (f *FakeInformer) AddIndexers(indexers cache.Indexers) error { return nil @@ -60,19 +70,19 @@ func (f *FakeInformer) HasSynced() bool { // AddEventHandler implements the Informer interface. Adds an EventHandler to the fake Informers. TODO(community): Implement Registration. func (f *FakeInformer) AddEventHandler(handler cache.ResourceEventHandler) (cache.ResourceEventHandlerRegistration, error) { f.handlers = append(f.handlers, handler) - return nil, nil + return &fakeHandlerRegistration{informer: f}, nil } // AddEventHandlerWithResyncPeriod implements the Informer interface. Adds an EventHandler to the fake Informers (ignores resyncPeriod). TODO(community): Implement Registration. func (f *FakeInformer) AddEventHandlerWithResyncPeriod(handler cache.ResourceEventHandler, _ time.Duration) (cache.ResourceEventHandlerRegistration, error) { f.handlers = append(f.handlers, handler) - return nil, nil + return &fakeHandlerRegistration{informer: f}, nil } // AddEventHandlerWithOptions implements the Informer interface. Adds an EventHandler to the fake Informers (ignores options). TODO(community): Implement Registration. func (f *FakeInformer) AddEventHandlerWithOptions(handler cache.ResourceEventHandler, _ cache.HandlerOptions) (cache.ResourceEventHandlerRegistration, error) { f.handlers = append(f.handlers, handler) - return nil, nil + return &fakeHandlerRegistration{informer: f}, nil } // Run implements the Informer interface. Increments f.RunCount. diff --git a/pkg/controller/controllerutil/controllerutil.go b/pkg/controller/controllerutil/controllerutil.go index 0088f88e5d..0f12b934ee 100644 --- a/pkg/controller/controllerutil/controllerutil.go +++ b/pkg/controller/controllerutil/controllerutil.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "reflect" + "slices" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -501,10 +502,8 @@ type MutateFn func() error // It returns an indication of whether it updated the object's list of finalizers. func AddFinalizer(o client.Object, finalizer string) (finalizersUpdated bool) { f := o.GetFinalizers() - for _, e := range f { - if e == finalizer { - return false - } + if slices.Contains(f, finalizer) { + return false } o.SetFinalizers(append(f, finalizer)) return true @@ -517,7 +516,7 @@ func RemoveFinalizer(o client.Object, finalizer string) (finalizersUpdated bool) length := len(f) index := 0 - for i := 0; i < length; i++ { + for i := range length { if f[i] == finalizer { continue } @@ -531,10 +530,5 @@ func RemoveFinalizer(o client.Object, finalizer string) (finalizersUpdated bool) // ContainsFinalizer checks an Object that the provided finalizer is present. func ContainsFinalizer(o client.Object, finalizer string) bool { f := o.GetFinalizers() - for _, e := range f { - if e == finalizer { - return true - } - } - return false + return slices.Contains(f, finalizer) } diff --git a/pkg/controller/priorityqueue/priorityqueue.go b/pkg/controller/priorityqueue/priorityqueue.go index 49942186c0..fd10a6c050 100644 --- a/pkg/controller/priorityqueue/priorityqueue.go +++ b/pkg/controller/priorityqueue/priorityqueue.go @@ -29,6 +29,13 @@ type AddOpts struct { // internally de-duplicates all items that are added to // it. It will use the max of the passed priorities and the // min of possible durations. +// +// When an item that is already enqueued at a lower priority +// is re-enqueued with a higher priority, it will be placed at +// the end among items of the new priority, in order to +// preserve FIFO semantics within each priority level. +// The effective duration (i.e. the ready time) is still +// computed as the minimum across all enqueues. type PriorityQueue[T comparable] interface { workqueue.TypedRateLimitingInterface[T] AddWithOpts(o AddOpts, Items ...T) @@ -47,6 +54,11 @@ type Opts[T comparable] struct { // Opt allows to configure a PriorityQueue. type Opt[T comparable] func(*Opts[T]) +type bufferItem[T comparable] struct { + opts AddOpts + items []T +} + // New constructs a new PriorityQueue. func New[T comparable](name string, o ...Opt[T]) PriorityQueue[T] { opts := &Opts[T]{} @@ -63,25 +75,29 @@ func New[T comparable](name string, o ...Opt[T]) PriorityQueue[T] { } pq := &priorityqueue[T]{ - log: opts.Log, - items: map[T]*item[T]{}, - queue: btree.NewG(32, less[T]), - becameReady: sets.Set[T]{}, - metrics: newQueueMetrics[T](opts.MetricProvider, name, clock.RealClock{}), - // itemOrWaiterAdded indicates that an item or + log: opts.Log, + itemAddedToAddBuffer: make(chan struct{}, 1), + items: map[T]*item[T]{}, + ready: btree.NewG(32, lessReady[T]), + waiting: btree.NewG(32, lessWaiting[T]), + metrics: newQueueMetrics[T](opts.MetricProvider, name, clock.RealClock{}), + // readyItemOrWaiterAdded indicates that a ready item or // waiter was added. It must be buffered, because // if we currently process items we can't tell // if that included the new item/waiter. - itemOrWaiterAdded: make(chan struct{}, 1), - rateLimiter: opts.RateLimiter, - locked: sets.Set[T]{}, - done: make(chan struct{}), - get: make(chan item[T]), - now: time.Now, - tick: time.Tick, + readyItemOrWaiterAdded: make(chan struct{}, 1), + waitingItemAddedOrUpdated: make(chan struct{}, 1), + rateLimiter: opts.RateLimiter, + locked: sets.Set[T]{}, + done: make(chan struct{}), + get: make(chan item[T]), + now: time.Now, + tick: time.Tick, } - go pq.spin() + go pq.handleAddBuffer() + go pq.handleReadyItems() + go pq.handleWaitingItems() go pq.logState() if _, ok := pq.metrics.(noMetrics[T]); !ok { go pq.updateUnfinishedWorkLoop() @@ -92,30 +108,33 @@ func New[T comparable](name string, o ...Opt[T]) PriorityQueue[T] { type priorityqueue[T comparable] struct { log logr.Logger - // lock has to be acquired for any access any of items, queue, addedCounter - // or becameReady - lock sync.Mutex - items map[T]*item[T] - queue bTree[*item[T]] + + addBufferLock sync.Mutex + addBuffer []bufferItem[T] + itemAddedToAddBuffer chan struct{} + + // lock has to be acquired for any access to any of items, ready, waiting, + // addedCounter or waiters. + lock sync.Mutex + items map[T]*item[T] + ready bTree[*item[T]] + waiting bTree[*item[T]] // addedCounter is a counter of elements added, we need it - // because unixNano is not guaranteed to be unique. + // to provide FIFO semantics. addedCounter uint64 - // becameReady holds items that are in the queue, were added - // with non-zero after and became ready. We need it to call the - // metrics add exactly once for them. - becameReady sets.Set[T] - metrics queueMetrics[T] + metrics queueMetrics[T] - itemOrWaiterAdded chan struct{} + readyItemOrWaiterAdded chan struct{} + waitingItemAddedOrUpdated chan struct{} rateLimiter workqueue.TypedRateLimiter[T] // locked contains the keys we handed out through Get() and that haven't // yet been returned through Done(). locked sets.Set[T] - lockedLock sync.RWMutex + lockedLock sync.Mutex shutdown atomic.Bool done chan struct{} @@ -123,8 +142,8 @@ type priorityqueue[T comparable] struct { get chan item[T] // waiters is the number of routines blocked in Get, we use it to determine - // if we can push items. - waiters atomic.Int64 + // if we can push items. Every manipulation has to be protected with the lock. + waiters int64 // Configurable for testing now func() time.Time @@ -136,8 +155,52 @@ func (w *priorityqueue[T]) AddWithOpts(o AddOpts, items ...T) { return } - w.lock.Lock() - defer w.lock.Unlock() + if len(items) == 0 { + return + } + + w.addBufferLock.Lock() + w.addBuffer = append(w.addBuffer, bufferItem[T]{ + opts: o, + items: items, + }) + w.addBufferLock.Unlock() + + w.notifyItemAddedToAddBuffer() +} + +func (w *priorityqueue[T]) handleAddBuffer() { + for { + select { + case <-w.done: + return + case <-w.itemAddedToAddBuffer: + } + + w.lock.Lock() + w.lockedFlushAddBuffer() + w.lock.Unlock() + } +} + +func (w *priorityqueue[T]) lockedFlushAddBuffer() { + w.addBufferLock.Lock() + buffer := w.addBuffer + w.addBuffer = make([]bufferItem[T], 0, len(buffer)) + w.addBufferLock.Unlock() + + for _, v := range buffer { + w.lockedAddWithOpts(v.opts, v.items...) + } +} + +func (w *priorityqueue[T]) lockedAddWithOpts(o AddOpts, items ...T) { + if w.shutdown.Load() { + return + } + + var readyItemAdded bool + var waitingItemAddedOrUpdated bool for _, key := range items { after := o.After @@ -160,49 +223,90 @@ func (w *priorityqueue[T]) AddWithOpts(o AddOpts, items ...T) { Priority: ptr.Deref(o.Priority, 0), ReadyAt: readyAt, } + w.addedCounter++ w.items[key] = item - w.queue.ReplaceOrInsert(item) - if item.ReadyAt == nil { + if readyAt != nil { + w.waiting.ReplaceOrInsert(item) + waitingItemAddedOrUpdated = true + } else { + w.ready.ReplaceOrInsert(item) w.metrics.add(key, item.Priority) + readyItemAdded = true } - w.addedCounter++ continue } - // The b-tree de-duplicates based on ordering and any change here - // will affect the order - Just delete and re-add. - item, _ := w.queue.Delete(w.items[key]) - if newPriority := ptr.Deref(o.Priority, 0); newPriority > item.Priority { - // Update depth metric only if the item in the queue was already added to the depth metric. - if item.ReadyAt == nil || w.becameReady.Has(key) { - w.metrics.updateDepthWithPriorityMetric(item.Priority, newPriority) - } - item.Priority = newPriority + if w.items[key].ReadyAt == nil { + readyAt = nil + } else if readyAt != nil && w.items[key].ReadyAt.Before(*readyAt) { + readyAt = w.items[key].ReadyAt } - if item.ReadyAt != nil && (readyAt == nil || readyAt.Before(*item.ReadyAt)) { - if readyAt == nil && !w.becameReady.Has(key) { - w.metrics.add(key, item.Priority) + priority := w.items[key].Priority + addedCounter := w.items[key].AddedCounter + if newPriority := ptr.Deref(o.Priority, 0); newPriority > w.items[key].Priority { + // Update depth metric only if the item was already ready + if w.items[key].ReadyAt == nil { + w.metrics.updateDepthWithPriorityMetric(w.items[key].Priority, newPriority) } - item.ReadyAt = readyAt + priority = newPriority + addedCounter = w.addedCounter + w.addedCounter++ } - w.queue.ReplaceOrInsert(item) + var tree, previousTree bTree[*item[T]] + switch { + case readyAt == nil && w.items[key].ReadyAt == nil: + tree, previousTree = w.ready, w.ready + case readyAt == nil && w.items[key].ReadyAt != nil: + tree, previousTree = w.ready, w.waiting + readyItemAdded = true + w.metrics.add(key, priority) + case readyAt != nil: + // We are in the update path and we set readyAt to nil if the + // existing item has a nil readyAt, so we can be sure here that + // it has a non-nil readyAt/is in w.waiting. + tree, previousTree = w.waiting, w.waiting + waitingItemAddedOrUpdated = true + } + + item, _ := previousTree.Delete(w.items[key]) + item.ReadyAt = readyAt + item.Priority = priority + item.AddedCounter = addedCounter + tree.ReplaceOrInsert(item) + } + + if readyItemAdded { + w.notifyReadyItemOrWaiterAdded() + } + if waitingItemAddedOrUpdated { + w.notifyWaitingItemAddedOrUpdated() } +} - if len(items) > 0 { - w.notifyItemOrWaiterAdded() +func (w *priorityqueue[T]) notifyItemAddedToAddBuffer() { + select { + case w.itemAddedToAddBuffer <- struct{}{}: + default: } } -func (w *priorityqueue[T]) notifyItemOrWaiterAdded() { +func (w *priorityqueue[T]) notifyReadyItemOrWaiterAdded() { select { - case w.itemOrWaiterAdded <- struct{}{}: + case w.readyItemOrWaiterAdded <- struct{}{}: default: } } -func (w *priorityqueue[T]) spin() { +func (w *priorityqueue[T]) notifyWaitingItemAddedOrUpdated() { + select { + case w.waitingItemAddedOrUpdated <- struct{}{}: + default: + } +} + +func (w *priorityqueue[T]) handleWaitingItems() { blockForever := make(chan time.Time) var nextReady <-chan time.Time nextReady = blockForever @@ -211,40 +315,78 @@ func (w *priorityqueue[T]) spin() { select { case <-w.done: return - case <-w.itemOrWaiterAdded: + case <-w.waitingItemAddedOrUpdated: case <-nextReady: + nextReady = blockForever } - nextReady = blockForever + func() { + w.lock.Lock() + defer w.lock.Unlock() + + var toMove []*item[T] + w.waiting.Ascend(func(item *item[T]) bool { + readyIn := item.ReadyAt.Sub(w.now()) // Store this to prevent TOCTOU issues + if readyIn <= 0 { + toMove = append(toMove, item) + return true + } + + nextReady = w.tick(readyIn) + return false + }) + + // Don't manipulate the tree from within Ascend + for _, toMove := range toMove { + w.waiting.Delete(toMove) + toMove.ReadyAt = nil + + // Bump added counter so items get sorted by when + // they became ready, not when they were added. + toMove.AddedCounter = w.addedCounter + w.addedCounter++ + + w.metrics.add(toMove.Key, toMove.Priority) + w.ready.ReplaceOrInsert(toMove) + } + + if len(toMove) > 0 { + w.notifyReadyItemOrWaiterAdded() + } + }() + } +} + +func (w *priorityqueue[T]) handleReadyItems() { + for { + select { + case <-w.done: + return + case <-w.readyItemOrWaiterAdded: + } func() { w.lock.Lock() defer w.lock.Unlock() + // Flush is performed before reading items to avoid errors caused by asynchronous behavior, + // primarily for unit testing purposes. + // Successfully adding a ready item may result in an additional call to handleReadyItems(), + // but the cost is negligible. + w.lockedFlushAddBuffer() + + if w.waiters == 0 { + return + } + w.lockedLock.Lock() defer w.lockedLock.Unlock() // manipulating the tree from within Ascend might lead to panics, so // track what we want to delete and do it after we are done ascending. var toDelete []*item[T] - w.queue.Ascend(func(item *item[T]) bool { - if item.ReadyAt != nil { - if readyAt := item.ReadyAt.Sub(w.now()); readyAt > 0 { - nextReady = w.tick(readyAt) - return false - } - if !w.becameReady.Has(item.Key) { - w.metrics.add(item.Key, item.Priority) - w.becameReady.Insert(item.Key) - } - } - - if w.waiters.Load() == 0 { - // Have to keep iterating here to ensure we update metrics - // for further items that became ready and set nextReady. - return true - } + w.ready.Ascend(func(item *item[T]) bool { // Item is locked, we can not hand it out if w.locked.Has(item.Key) { return true @@ -252,17 +394,16 @@ func (w *priorityqueue[T]) spin() { w.metrics.get(item.Key, item.Priority) w.locked.Insert(item.Key) - w.waiters.Add(-1) + w.waiters-- delete(w.items, item.Key) toDelete = append(toDelete, item) - w.becameReady.Delete(item.Key) w.get <- *item - return true + return w.waiters > 0 }) for _, item := range toDelete { - w.queue.Delete(item) + w.ready.Delete(item) } }() } @@ -286,13 +427,24 @@ func (w *priorityqueue[T]) GetWithPriority() (_ T, priority int, shutdown bool) return zero, 0, true } - w.waiters.Add(1) - - w.notifyItemOrWaiterAdded() + w.lock.Lock() + w.waiters++ + w.lock.Unlock() - item := <-w.get + w.notifyReadyItemOrWaiterAdded() - return item.Key, item.Priority, w.shutdown.Load() + select { + case <-w.done: + // Return if the queue was shutdown while we were already waiting for an item here. + // For example controller workers are continuously calling GetWithPriority and + // GetWithPriority is blocking the workers if there are no items in the queue. + // If the controller and accordingly the queue is then shut down, without this code + // branch the controller workers remain blocked here and are unable to shut down. + var zero T + return zero, 0, true + case item := <-w.get: + return item.Key, item.Priority, w.shutdown.Load() + } } func (w *priorityqueue[T]) Get() (item T, shutdown bool) { @@ -317,7 +469,7 @@ func (w *priorityqueue[T]) Done(item T) { defer w.lockedLock.Unlock() w.locked.Delete(item) w.metrics.done(item) - w.notifyItemOrWaiterAdded() + w.notifyReadyItemOrWaiterAdded() } func (w *priorityqueue[T]) ShutDown() { @@ -338,16 +490,11 @@ func (w *priorityqueue[T]) Len() int { w.lock.Lock() defer w.lock.Unlock() - var result int - w.queue.Ascend(func(item *item[T]) bool { - if item.ReadyAt == nil || item.ReadyAt.Compare(w.now()) <= 0 { - result++ - return true - } - return false - }) + // Flush is performed before reading items to avoid errors caused by asynchronous behavior, + // primarily for unit testing purposes. + w.lockedFlushAddBuffer() - return result + return w.ready.Len() } func (w *priorityqueue[T]) logState() { @@ -367,7 +514,11 @@ func (w *priorityqueue[T]) logState() { } w.lock.Lock() items := make([]*item[T], 0, len(w.items)) - w.queue.Ascend(func(item *item[T]) bool { + w.waiting.Ascend(func(item *item[T]) bool { + items = append(items, item) + return true + }) + w.ready.Ascend(func(item *item[T]) bool { items = append(items, item) return true }) @@ -377,20 +528,17 @@ func (w *priorityqueue[T]) logState() { } } -func less[T comparable](a, b *item[T]) bool { - if a.ReadyAt == nil && b.ReadyAt != nil { - return true - } - if b.ReadyAt == nil && a.ReadyAt != nil { - return false - } - if a.ReadyAt != nil && b.ReadyAt != nil && !a.ReadyAt.Equal(*b.ReadyAt) { +func lessWaiting[T comparable](a, b *item[T]) bool { + if !a.ReadyAt.Equal(*b.ReadyAt) { return a.ReadyAt.Before(*b.ReadyAt) } + return lessReady(a, b) +} + +func lessReady[T comparable](a, b *item[T]) bool { if a.Priority != b.Priority { return a.Priority > b.Priority } - return a.AddedCounter < b.AddedCounter } @@ -414,7 +562,8 @@ func (w *priorityqueue[T]) updateUnfinishedWorkLoop() { } type bTree[T any] interface { - ReplaceOrInsert(item T) (_ T, _ bool) + ReplaceOrInsert(item T) (T, bool) Delete(item T) (T, bool) Ascend(iterator btree.ItemIteratorG[T]) + Len() int } diff --git a/pkg/controller/priorityqueue/priorityqueue_test.go b/pkg/controller/priorityqueue/priorityqueue_test.go index 13cf59b7e8..89c3216fbf 100644 --- a/pkg/controller/priorityqueue/priorityqueue_test.go +++ b/pkg/controller/priorityqueue/priorityqueue_test.go @@ -3,8 +3,10 @@ package priorityqueue import ( "fmt" "math/rand/v2" + "strconv" "sync" "testing" + "testing/synctest" "time" fuzz "github.com/google/gofuzz" @@ -93,16 +95,39 @@ var _ = Describe("Controllerworkqueue", func() { q.AddWithOpts(AddOpts{}, "foo") q.AddWithOpts(AddOpts{}, "foo") - Consistently(q.Len).Should(Equal(1)) + Expect(q.Len()).To(Equal(1)) - cwq := q.(*priorityqueue[string]) - cwq.lockedLock.Lock() - Expect(cwq.locked.Len()).To(Equal(0)) + q.lockedLock.Lock() + Expect(q.locked.Len()).To(Equal(0)) Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 1})) Expect(metrics.adds["test"]).To(Equal(1)) }) + It("enqueues a locked item", func() { + q, metrics := newQueue() + defer q.ShutDown() + + q.AddWithOpts(AddOpts{}, "foo") + Expect(q.Len()).To(Equal(1)) + item, priority, shutdown := q.GetWithPriority() + Expect(item).To(Equal("foo")) + Expect(priority).To(Equal(0)) + Expect(shutdown).To(BeFalse()) + + q.AddWithOpts(AddOpts{}, "foo") + Expect(q.Len()).To(Equal(1)) + q.Done("foo") + + item, priority, shutdown = q.GetWithPriority() + Expect(item).To(Equal("foo")) + Expect(priority).To(Equal(0)) + Expect(shutdown).To(BeFalse()) + + Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + Expect(metrics.adds["test"]).To(Equal(2)) + }) + It("retains the highest priority", func() { q, metrics := newQueue() defer q.ShutDown() @@ -120,6 +145,23 @@ var _ = Describe("Controllerworkqueue", func() { Expect(metrics.adds["test"]).To(Equal(1)) }) + It("will not decrease the priority", func() { + q, metrics := newQueue() + defer q.ShutDown() + + q.AddWithOpts(AddOpts{Priority: ptr.To(2)}, "foo") + q.AddWithOpts(AddOpts{Priority: ptr.To(1)}, "foo") + + item, priority, _ := q.GetWithPriority() + Expect(item).To(Equal("foo")) + Expect(priority).To(Equal(2)) + + Expect(q.Len()).To(Equal(0)) + + Expect(metrics.depth["test"]).To(Equal(map[int]int{2: 0})) + Expect(metrics.adds["test"]).To(Equal(1)) + }) + It("gets pushed to the front if the priority increases", func() { q, metrics := newQueue() defer q.ShutDown() @@ -155,138 +197,6 @@ var _ = Describe("Controllerworkqueue", func() { Expect(metrics.adds["test"]).To(Equal(1)) }) - It("returns an item only after after has passed", func() { - q, metrics := newQueue() - defer q.ShutDown() - - now := time.Now().Round(time.Second) - nowLock := sync.Mutex{} - tick := make(chan time.Time) - - cwq := q.(*priorityqueue[string]) - cwq.now = func() time.Time { - nowLock.Lock() - defer nowLock.Unlock() - return now - } - cwq.tick = func(d time.Duration) <-chan time.Time { - Expect(d).To(Equal(time.Second)) - return tick - } - - retrievedItem := make(chan struct{}) - - go func() { - defer GinkgoRecover() - q.GetWithPriority() - close(retrievedItem) - }() - - q.AddWithOpts(AddOpts{After: time.Second}, "foo") - - Consistently(retrievedItem).ShouldNot(BeClosed()) - - nowLock.Lock() - now = now.Add(time.Second) - nowLock.Unlock() - tick <- now - Eventually(retrievedItem).Should(BeClosed()) - - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) - Expect(metrics.adds["test"]).To(Equal(1)) - Expect(metrics.retries["test"]).To(Equal(1)) - }) - - It("returns an item to a waiter as soon as it has one", func() { - q, metrics := newQueue() - defer q.ShutDown() - - retrieved := make(chan struct{}) - go func() { - defer GinkgoRecover() - item, _, _ := q.GetWithPriority() - Expect(item).To(Equal("foo")) - close(retrieved) - }() - - // We are waiting for the GetWithPriority() call to be blocked - // on retrieving an item. As golang doesn't provide a way to - // check if something is listening on a channel without - // sending them a message, I can't think of a way to do this - // without sleeping. - time.Sleep(time.Second) - q.AddWithOpts(AddOpts{}, "foo") - Eventually(retrieved).Should(BeClosed()) - - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) - Expect(metrics.adds["test"]).To(Equal(1)) - }) - - It("returns multiple items with after in correct order", func() { - q, metrics := newQueue() - defer q.ShutDown() - - now := time.Now().Round(time.Second) - nowLock := sync.Mutex{} - tick := make(chan time.Time) - - cwq := q.(*priorityqueue[string]) - cwq.now = func() time.Time { - nowLock.Lock() - defer nowLock.Unlock() - return now - } - cwq.tick = func(d time.Duration) <-chan time.Time { - // What a bunch of bs. Deferring in here causes - // ginkgo to deadlock, presumably because it - // never returns after the defer. Not deferring - // hides the actual assertion result and makes - // it complain that there should be a defer. - // Move the assertion into a goroutine just to - // get around that mess. - done := make(chan struct{}) - go func() { - defer GinkgoRecover() - defer close(done) - - // This is not deterministic and depends on which of - // Add() or Spin() gets the lock first. - Expect(d).To(Or(Equal(200*time.Millisecond), Equal(time.Second))) - }() - <-done - return tick - } - - retrievedItem := make(chan struct{}) - retrievedSecondItem := make(chan struct{}) - - go func() { - defer GinkgoRecover() - first, _, _ := q.GetWithPriority() - Expect(first).To(Equal("bar")) - close(retrievedItem) - - second, _, _ := q.GetWithPriority() - Expect(second).To(Equal("foo")) - close(retrievedSecondItem) - }() - - q.AddWithOpts(AddOpts{After: time.Second}, "foo") - q.AddWithOpts(AddOpts{After: 200 * time.Millisecond}, "bar") - - Consistently(retrievedItem).ShouldNot(BeClosed()) - - nowLock.Lock() - now = now.Add(time.Second) - nowLock.Unlock() - tick <- now - Eventually(retrievedItem).Should(BeClosed()) - Eventually(retrievedSecondItem).Should(BeClosed()) - - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) - Expect(metrics.adds["test"]).To(Equal(2)) - }) - It("doesn't include non-ready items in Len()", func() { q, metrics := newQueue() defer q.ShutDown() @@ -321,53 +231,6 @@ var _ = Describe("Controllerworkqueue", func() { Expect(isShutDown).To(BeTrue()) }) - It("items are included in Len() and the queueDepth metric once they are ready", func() { - q, metrics := newQueue() - defer q.ShutDown() - - q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "foo") - q.AddWithOpts(AddOpts{}, "baz") - q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "bar") - q.AddWithOpts(AddOpts{}, "bal") - - Expect(q.Len()).To(Equal(2)) - metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 2})) - metrics.mu.Unlock() - time.Sleep(time.Second) - Expect(q.Len()).To(Equal(4)) - metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 4})) - metrics.mu.Unlock() - - // Drain queue - for range 4 { - item, _ := q.Get() - q.Done(item) - } - Expect(q.Len()).To(Equal(0)) - metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) - metrics.mu.Unlock() - - // Validate that doing it again still works to notice bugs with removing - // it from the queues becameReady tracking. - q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "foo") - q.AddWithOpts(AddOpts{}, "baz") - q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "bar") - q.AddWithOpts(AddOpts{}, "bal") - - Expect(q.Len()).To(Equal(2)) - metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 2})) - metrics.mu.Unlock() - time.Sleep(time.Second) - Expect(q.Len()).To(Equal(4)) - metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 4})) - metrics.mu.Unlock() - }) - It("returns many items", func() { // This test ensures the queue is able to drain a large queue without panic'ing. // In a previous version of the code we were calling queue.Delete within q.Ascend @@ -388,14 +251,12 @@ var _ = Describe("Controllerworkqueue", func() { wg := sync.WaitGroup{} for range 100 { // The panic only occurred relatively frequently with a high number of go routines. - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for range 10 { obj, _, _ := q.GetWithPriority() q.Done(obj) } - }() + }) } wg.Wait() @@ -431,109 +292,62 @@ var _ = Describe("Controllerworkqueue", func() { metrics.mu.Unlock() }) - It("Updates metrics correctly for an item whose requeueAfter expired that gets added again without requeueAfter", func() { + It("updates metrics correctly when an item with after is re-added with higher priority and no after", func() { q, metrics := newQueue() defer q.ShutDown() - q.AddWithOpts(AddOpts{After: 50 * time.Millisecond}, "foo") - time.Sleep(100 * time.Millisecond) - - Expect(q.Len()).To(Equal(1)) + q.AddWithOpts(AddOpts{After: time.Hour, Priority: ptr.To(0)}, "foo") + Expect(q.Len()).To(Equal(0)) metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 1})) + Expect(metrics.depth["test"]).To(Equal(map[int]int{})) metrics.mu.Unlock() - q.AddWithOpts(AddOpts{}, "foo") + q.AddWithOpts(AddOpts{Priority: ptr.To(1)}, "foo") + Expect(q.Len()).To(Equal(1)) metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 1})) + Expect(metrics.depth["test"]).To(Equal(map[int]int{1: 1})) metrics.mu.Unlock() - // Get the item to ensure the codepath in - // `spin` for the metrics is passed by so - // that this starts failing if it incorrectly - // calls `metrics.add` again. - item, _ := q.Get() + item, priority, _ := q.GetWithPriority() Expect(item).To(Equal("foo")) + Expect(priority).To(Equal(1)) Expect(q.Len()).To(Equal(0)) + metrics.mu.Lock() - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) - metrics.mu.Unlock() + Expect(metrics.depth["test"][1]).To(Equal(0)) + for _, depth := range metrics.depth["test"] { + Expect(depth).To(Equal(0)) + } }) - It("When adding items with rateLimit, previous items' rateLimit should not affect subsequent items", func() { - q, metrics := newQueue() + It("follows FIFO order in the new priority queue when item priority changes", func() { + q, _ := newQueue() defer q.ShutDown() - now := time.Now().Round(time.Second) - nowLock := sync.Mutex{} - tick := make(chan time.Time) - - cwq := q.(*priorityqueue[string]) - cwq.rateLimiter = workqueue.NewTypedItemExponentialFailureRateLimiter[string](5*time.Millisecond, 1000*time.Second) - cwq.now = func() time.Time { - nowLock.Lock() - defer nowLock.Unlock() - return now - } - cwq.tick = func(d time.Duration) <-chan time.Time { - done := make(chan struct{}) - go func() { - defer GinkgoRecover() - defer close(done) - - Expect(d).To(Or(Equal(5*time.Millisecond), Equal(635*time.Millisecond))) - }() - <-done - return tick - } - - retrievedItem := make(chan struct{}) - retrievedSecondItem := make(chan struct{}) - - go func() { - defer GinkgoRecover() - first, _, _ := q.GetWithPriority() - Expect(first).To(Equal("foo")) - close(retrievedItem) - - second, _, _ := q.GetWithPriority() - Expect(second).To(Equal("bar")) - close(retrievedSecondItem) - }() - - // after 7 calls, the next When("bar") call will return 640ms. - for range 7 { - cwq.rateLimiter.When("bar") - } - q.AddWithOpts(AddOpts{RateLimited: true}, "foo", "bar") - - Consistently(retrievedItem).ShouldNot(BeClosed()) - nowLock.Lock() - now = now.Add(5 * time.Millisecond) - nowLock.Unlock() - tick <- now - Eventually(retrievedItem).Should(BeClosed()) + q.AddWithOpts(AddOpts{Priority: ptr.To(0)}, "foo") + q.AddWithOpts(AddOpts{Priority: ptr.To(1)}, "bar") + q.AddWithOpts(AddOpts{Priority: ptr.To(1)}, "foo") + Expect(q.Len()).To(Equal(2)) - Consistently(retrievedSecondItem).ShouldNot(BeClosed()) - nowLock.Lock() - now = now.Add(635 * time.Millisecond) - nowLock.Unlock() - tick <- now - Eventually(retrievedSecondItem).Should(BeClosed()) + item, priority, _ := q.GetWithPriority() + Expect(item).To(Equal("bar")) + Expect(priority).To(Equal(1)) + Expect(q.Len()).To(Equal(1)) - Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) - Expect(metrics.adds["test"]).To(Equal(2)) - Expect(metrics.retries["test"]).To(Equal(2)) + item, priority, _ = q.GetWithPriority() + Expect(item).To(Equal("foo")) + Expect(priority).To(Equal(1)) + Expect(q.Len()).To(Equal(0)) }) }) func BenchmarkAddGetDone(b *testing.B) { q := New[int]("") defer q.ShutDown() - b.ResetTimer() - for n := 0; n < b.N; n++ { - for i := 0; i < 1000; i++ { + + for b.Loop() { + for i := range 1000 { q.Add(i) } for range 1000 { @@ -546,9 +360,9 @@ func BenchmarkAddGetDone(b *testing.B) { func BenchmarkAddOnly(b *testing.B) { q := New[int]("") defer q.ShutDown() - b.ResetTimer() - for n := 0; n < b.N; n++ { - for i := 0; i < 1000; i++ { + + for b.Loop() { + for i := range 1000 { q.Add(i) } } @@ -557,15 +371,23 @@ func BenchmarkAddOnly(b *testing.B) { func BenchmarkAddLockContended(b *testing.B) { q := New[int]("") defer q.ShutDown() - go func() { - for range 1000 { - item, _ := q.Get() - q.Done(item) - } - }() - b.ResetTimer() - for n := 0; n < b.N; n++ { - for i := 0; i < 1000; i++ { + + for i := range 1000000 { + q.Add(i) + } + + for range 1000 { + go func() { + for { + item, _ := q.Get() + time.Sleep(1 * time.Millisecond) + q.Done(item) + } + }() + } + + for b.Loop() { + for i := range 1000 { q.Add(i) } } @@ -604,10 +426,7 @@ func TestFuzzPriorityQueue(t *testing.T) { q, metrics := newQueue() for range 10 { - wg.Add(1) - go func() { - defer wg.Done() - + wg.Go(func() { for range 1000 { opts, item := AddOpts{}, "" @@ -629,15 +448,11 @@ func TestFuzzPriorityQueue(t *testing.T) { } }() } - }() + }) } for range 100 { - wg.Add(1) - - go func() { - defer wg.Done() - + wg.Go(func() { for { item, cont := func() (string, bool) { inQueueLock.Lock() @@ -686,19 +501,115 @@ func TestFuzzPriorityQueue(t *testing.T) { q.Done(item) }() } - }() + }) } wg.Wait() } -func newQueue() (PriorityQueue[string], *fakeMetricsProvider) { +func newQueueWithTimeForwarder() (_ *priorityqueue[string], _ *fakeMetricsProvider, forwardQueueTime func(time.Duration)) { + q, m := newQueue() + + now := time.Now().Round(time.Second) + nowLock := sync.Mutex{} + tick := make(chan time.Time) + + q.now = func() time.Time { + nowLock.Lock() + defer nowLock.Unlock() + return now + } + q.tick = func(d time.Duration) <-chan time.Time { + return tick + } + + return q, m, func(d time.Duration) { + nowLock.Lock() + now = now.Add(d) + nowLock.Unlock() + tick <- now + } +} + +func TestHighPriorityItemsAreReturnedBeforeLowPriorityItemMultipleTimes(t *testing.T) { + t.Parallel() + for _, after := range []time.Duration{-time.Second, 0, time.Second} { + t.Run(fmt.Sprintf("after=%v", after), func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + const itemsPerPriority = 1000 + lowPriority := -10 + lowMiddlePriority := -5 + middlePriority := 0 + upperMiddlePriority := 5 + highPriority := 10 + for i := range itemsPerPriority { + q.AddWithOpts(AddOpts{Priority: &highPriority, After: after}, fmt.Sprintf("high-%d", i)) + q.AddWithOpts(AddOpts{Priority: &upperMiddlePriority, After: after}, fmt.Sprintf("upperMiddle-%d", i)) + q.AddWithOpts(AddOpts{Priority: &middlePriority, After: after}, fmt.Sprintf("middle-%d", i)) + q.AddWithOpts(AddOpts{Priority: &lowMiddlePriority, After: after}, fmt.Sprintf("lowMiddle-%d", i)) + q.AddWithOpts(AddOpts{Priority: &lowPriority, After: after}, fmt.Sprintf("low-%d", i)) + } + synctest.Wait() + if after > 0 { + forwardQueueTimeBy(after) + synctest.Wait() + } + + for i := range itemsPerPriority { + key, prio, _ := q.GetWithPriority() + g.Expect(prio).To(Equal(highPriority)) + g.Expect(key).To(Equal("high-" + strconv.Itoa(i))) + } + for i := range itemsPerPriority { + key, prio, _ := q.GetWithPriority() + g.Expect(prio).To(Equal(upperMiddlePriority)) + g.Expect(key).To(Equal("upperMiddle-" + strconv.Itoa(i))) + } + for i := range itemsPerPriority { + key, prio, _ := q.GetWithPriority() + g.Expect(prio).To(Equal(middlePriority)) + g.Expect(key).To(Equal("middle-" + strconv.Itoa(i))) + } + for i := range itemsPerPriority { + key, prio, _ := q.GetWithPriority() + g.Expect(prio).To(Equal(lowMiddlePriority)) + g.Expect(key).To(Equal("lowMiddle-" + strconv.Itoa(i))) + } + for i := range itemsPerPriority { + key, prio, _ := q.GetWithPriority() + g.Expect(prio).To(Equal(lowPriority)) + g.Expect(key).To(Equal("low-" + strconv.Itoa(i))) + } + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{-10: 0, -5: 0, 0: 0, 5: 0, 10: 0})) + g.Expect(metrics.adds["test"]).To(Equal(itemsPerPriority * 5)) + expectedRetries := 0 + if after > 0 { + expectedRetries = itemsPerPriority * 5 + } + g.Expect(metrics.retries["test"]).To(Equal(expectedRetries)) + }) + }) + } +} + +func newQueue() (*priorityqueue[string], *fakeMetricsProvider) { metrics := newFakeMetricsProvider() q := New("test", func(o *Opts[string]) { o.MetricProvider = metrics }) - q.(*priorityqueue[string]).queue = &btreeInteractionValidator{ - bTree: q.(*priorityqueue[string]).queue, + q.(*priorityqueue[string]).waiting = &btreeInteractionValidator{ + bTree: q.(*priorityqueue[string]).waiting, + shouldHaveReadyAt: true, + } + q.(*priorityqueue[string]).ready = &btreeInteractionValidator{ + bTree: q.(*priorityqueue[string]).ready, + shouldHaveReadyAt: false, } // validate that tick always gets a positive value as it will just return @@ -710,14 +621,18 @@ func newQueue() (PriorityQueue[string], *fakeMetricsProvider) { } return upstreamTick(d) } - return q, metrics + return q.(*priorityqueue[string]), metrics } type btreeInteractionValidator struct { bTree[*item[string]] + shouldHaveReadyAt bool } func (b *btreeInteractionValidator) ReplaceOrInsert(item *item[string]) (*item[string], bool) { + if hasReadyAt := item.ReadyAt != nil; hasReadyAt != b.shouldHaveReadyAt { + panic(fmt.Sprintf("ReplaceOrInsert: item has unexpected ReadyAt presence: %v, expected: %v", hasReadyAt, b.shouldHaveReadyAt)) + } // There is no codepath that updates an item item, alreadyExist := b.bTree.ReplaceOrInsert(item) if alreadyExist { @@ -734,3 +649,361 @@ func (b *btreeInteractionValidator) Delete(item *item[string]) (*item[string], b } return old, existed } + +func TestItemIsOnlyReturnedAfterAfterHasPassed(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + originalTick := q.tick + q.tick = func(d time.Duration) <-chan time.Time { + g.Expect(d).To(Equal(time.Second)) + return originalTick(d) + } + + retrievedItem := make(chan struct{}) + go func() { + q.GetWithPriority() + close(retrievedItem) + }() + + q.AddWithOpts(AddOpts{After: time.Second}, "foo") + synctest.Wait() + + g.Expect(retrievedItem).ShouldNot(BeClosed()) + + forwardQueueTimeBy(time.Second) + synctest.Wait() + g.Expect(retrievedItem).Should(BeClosed()) + + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + g.Expect(metrics.adds["test"]).To(Equal(1)) + g.Expect(metrics.retries["test"]).To(Equal(1)) + }) +} + +func TestHighPriorityItemThatBecameReadyIsReturnedBeforeLowPriorityItem(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + tickSetup := make(chan any) + originalTick := q.tick + q.tick = func(d time.Duration) <-chan time.Time { + g.Expect(d).To(Equal(time.Second)) + close(tickSetup) + return originalTick(d) + } + + lowPriority := -100 + highPriority := 0 + q.AddWithOpts(AddOpts{After: 0, Priority: &lowPriority}, "foo") + q.AddWithOpts(AddOpts{After: time.Second, Priority: &highPriority}, "prio") + synctest.Wait() + + g.Expect(tickSetup).To(BeClosed()) + + forwardQueueTimeBy(1 * time.Second) + synctest.Wait() + key, prio, _ := q.GetWithPriority() + + g.Expect(key).To(Equal("prio")) + g.Expect(prio).To(Equal(0)) + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{-100: 1, 0: 0})) + g.Expect(metrics.adds["test"]).To(Equal(2)) + g.Expect(metrics.retries["test"]).To(Equal(1)) + }) +} + +func TestItemIsReturnedAsSoonAsPossible(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics := newQueue() + defer q.ShutDown() + + retrieved := make(chan struct{}) + go func() { + item, _, _ := q.GetWithPriority() + g.Expect(item).To(Equal("foo")) + close(retrieved) + }() + synctest.Wait() // Wait for the above goroutine to be blocked + + q.AddWithOpts(AddOpts{}, "foo") + synctest.Wait() // Wait until the priorityqueue and the above goroutine finish running + + g.Expect(retrieved).Should(BeClosed()) + + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + g.Expect(metrics.adds["test"]).To(Equal(1)) + }) +} + +func TestWaitingItemIsReturnedRightAfterReadWithoutAfter(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics := newQueue() + defer q.ShutDown() + + retrieved := make(chan struct{}) + go func() { + item, _, _ := q.GetWithPriority() + g.Expect(item).To(Equal("foo")) + close(retrieved) + }() + synctest.Wait() // Wait for the above goroutine to be blocked + + q.AddWithOpts(AddOpts{After: time.Minute}, "foo") + synctest.Wait() // Wait until the priorityqueue and the above goroutine finish running + + g.Expect(retrieved).ShouldNot(BeClosed()) + + q.AddWithOpts(AddOpts{}, "foo") + synctest.Wait() // Wait until the priorityqueue and the above goroutine finish running + g.Expect(retrieved).Should(BeClosed()) + + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + g.Expect(metrics.adds["test"]).To(Equal(1)) + }) +} + +func TestMultipleItemsWithAfterAreReturnedInCorrectOrder(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + originalTick := q.tick + q.tick = func(d time.Duration) <-chan time.Time { + // This is not deterministic and depends on which of + // Add() or Spin() gets the lock first. + g.Expect(d).To(Or(Equal(200*time.Millisecond), Equal(time.Second))) + return originalTick(d) + } + + retrievedItem := make(chan struct{}) + retrievedSecondItem := make(chan struct{}) + + go func() { + first, _, _ := q.GetWithPriority() + g.Expect(first).To(Equal("bar")) + close(retrievedItem) + + second, _, _ := q.GetWithPriority() + g.Expect(second).To(Equal("foo")) + close(retrievedSecondItem) + }() + + q.AddWithOpts(AddOpts{After: time.Second}, "foo") + q.AddWithOpts(AddOpts{After: 200 * time.Millisecond}, "bar") + synctest.Wait() // Block until the adds are processed + + g.Expect(retrievedItem).NotTo(BeClosed()) + + forwardQueueTimeBy(time.Second) + synctest.Wait() // Block until the priorityqueue finished processing + + g.Expect(retrievedItem).Should(BeClosed()) + g.Expect(retrievedSecondItem).Should(BeClosed()) + + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + g.Expect(metrics.adds["test"]).To(Equal(2)) + }) +} + +func TestGetFromPriorityQueueIsUnblockedOnShutdown(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, _ := newQueue() + + getUnblocked := make(chan struct{}) + + go func() { + defer close(getUnblocked) + + item, priority, isShutDown := q.GetWithPriority() + g.Expect(item).To(Equal("")) + g.Expect(priority).To(Equal(0)) + g.Expect(isShutDown).To(BeTrue()) + }() + synctest.Wait() // Wait for the above goroutine to be blocked + + g.Expect(getUnblocked).NotTo(BeClosed()) + + // shut down + q.ShutDown() + synctest.Wait() + + // Verify the shutdown unblocked the go routine. + g.Expect(getUnblocked).To(BeClosed()) + }) +} + +func TestItemsAreInludedInLenAndMetricsOnceTheyAreReady(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "foo") + q.AddWithOpts(AddOpts{}, "baz") + q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "bar") + q.AddWithOpts(AddOpts{}, "bal") + // Block here until spin finished, otherwise it is possible it + // checks now() after forwardQueueTimeBy updated it, does then + // not listen on tick and causes the write to tick from forwardQueueTimeBy + // to lock up the test. + synctest.Wait() + + g.Expect(q.Len()).To(Equal(2)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 2})) + metrics.mu.Unlock() + + forwardQueueTimeBy(time.Second) + synctest.Wait() + + g.Expect(q.Len()).To(Equal(4)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 4})) + metrics.mu.Unlock() + + // Drain queue + for range 4 { + item, _ := q.Get() + q.Done(item) + } + g.Expect(q.Len()).To(Equal(0)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + metrics.mu.Unlock() + + // Validate that doing it again still works to notice bugs with removing + // it from the queues becameReady tracking. + q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "foo") + q.AddWithOpts(AddOpts{}, "baz") + q.AddWithOpts(AddOpts{After: 500 * time.Millisecond}, "bar") + q.AddWithOpts(AddOpts{}, "bal") + // Block here until spin finished, otherwise it is possible it + // checks now() after forwardQueueTimeBy updated it, does then + // not listen on tick and causes the write to tick from forwardQueueTimeBy + // to lock up the test. + synctest.Wait() + + g.Expect(q.Len()).To(Equal(2)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 2})) + metrics.mu.Unlock() + + forwardQueueTimeBy(time.Second) + synctest.Wait() + + g.Expect(q.Len()).To(Equal(4)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 4})) + metrics.mu.Unlock() + }) +} + +func TestMetricsAreUpdatedForItemWhoseRequeueAfterExpiredThatGetsAddedAgainWithoutRequeueAfter(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + q.AddWithOpts(AddOpts{After: 50 * time.Millisecond}, "foo") + synctest.Wait() + forwardQueueTimeBy(50 * time.Millisecond) + synctest.Wait() + + g.Expect(q.Len()).To(Equal(1)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 1})) + metrics.mu.Unlock() + + q.AddWithOpts(AddOpts{}, "foo") + g.Expect(q.Len()).To(Equal(1)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 1})) + metrics.mu.Unlock() + + // Get the item to ensure the codepath in + // `spin` for the metrics is passed by so + // that this starts failing if it incorrectly + // calls `metrics.add` again. + item, _ := q.Get() + g.Expect(item).To(Equal("foo")) + g.Expect(q.Len()).To(Equal(0)) + metrics.mu.Lock() + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + metrics.mu.Unlock() + }) +} + +func TestWhenAddingMultipleItemsWithRatelimitTrueTheyDontAffectEachOther(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + + q, metrics, forwardQueueTimeBy := newQueueWithTimeForwarder() + defer q.ShutDown() + + q.rateLimiter = workqueue.NewTypedItemExponentialFailureRateLimiter[string](5*time.Millisecond, 1000*time.Second) + originalTick := q.tick + q.tick = func(d time.Duration) <-chan time.Time { + g.Expect(d).To(Or(Equal(5*time.Millisecond), Equal(635*time.Millisecond))) + return originalTick(d) + } + + retrievedItem := make(chan struct{}) + retrievedSecondItem := make(chan struct{}) + + go func() { + first, _, _ := q.GetWithPriority() + g.Expect(first).To(Equal("foo")) + close(retrievedItem) + + second, _, _ := q.GetWithPriority() + g.Expect(second).To(Equal("bar")) + close(retrievedSecondItem) + }() + + // after 7 calls, the next When("bar") call will return 640ms. + for range 7 { + q.rateLimiter.When("bar") + } + q.AddWithOpts(AddOpts{RateLimited: true}, "foo", "bar") + synctest.Wait() // Block until the adds are processed + g.Expect(retrievedItem).NotTo(BeClosed()) + + forwardQueueTimeBy(5 * time.Millisecond) + synctest.Wait() + g.Expect(retrievedItem).To(BeClosed()) + g.Expect(retrievedSecondItem).NotTo(BeClosed()) + + forwardQueueTimeBy(635 * time.Millisecond) + synctest.Wait() + g.Expect(retrievedSecondItem).To(BeClosed()) + + g.Expect(metrics.depth["test"]).To(Equal(map[int]int{0: 0})) + g.Expect(metrics.adds["test"]).To(Equal(2)) + g.Expect(metrics.retries["test"]).To(Equal(2)) + }) +} diff --git a/pkg/envtest/envtest_test.go b/pkg/envtest/envtest_test.go index ce3e9a4d3f..806c9f43cc 100644 --- a/pkg/envtest/envtest_test.go +++ b/pkg/envtest/envtest_test.go @@ -963,4 +963,56 @@ var _ = Describe("Test", func() { Expect(env.WebhookInstallOptions.LocalServingCertDir).ShouldNot(BeADirectory()) }) }) + + Describe("Binary Path Handling", func() { + It("should respect pre-configured binary paths when not downloading", func() { + // Setup custom paths + customAPIServerPath := "/custom/path/to/kube-apiserver" + customEtcdPath := "/custom/path/to/etcd" + customKubectlPath := "/custom/path/to/kubectl" + + // Create an environment with pre-configured paths + testEnv := &Environment{} + testEnv.ControlPlane.GetAPIServer().Path = customAPIServerPath + testEnv.ControlPlane.Etcd = &Etcd{} + testEnv.ControlPlane.Etcd.Path = customEtcdPath + testEnv.ControlPlane.KubectlPath = customKubectlPath + + // Set BinaryAssetsDirectory to ensure it's not using defaults + testEnv.BinaryAssetsDirectory = "/should/not/be/used" + testEnv.DownloadBinaryAssets = false + + // Call configureBinaryPaths to test the path configuration logic + err := testEnv.configureBinaryPaths() + Expect(err).NotTo(HaveOccurred()) + + // Verify paths were preserved (not overwritten) + apiServer := testEnv.ControlPlane.GetAPIServer() + Expect(apiServer.Path).To(Equal(customAPIServerPath)) + Expect(testEnv.ControlPlane.Etcd.Path).To(Equal(customEtcdPath)) + Expect(testEnv.ControlPlane.KubectlPath).To(Equal(customKubectlPath)) + }) + + It("should auto-configure binary paths when not pre-configured", func() { + // Create an environment without pre-configured paths + testEnv := &Environment{} + testEnv.BinaryAssetsDirectory = "/test/assets" + testEnv.DownloadBinaryAssets = false + + // Call configureBinaryPaths + err := testEnv.configureBinaryPaths() + Expect(err).NotTo(HaveOccurred()) + + // Verify paths were set using BinPathFinder + apiServer := testEnv.ControlPlane.GetAPIServer() + Expect(apiServer.Path).NotTo(BeEmpty()) + Expect(testEnv.ControlPlane.Etcd.Path).NotTo(BeEmpty()) + Expect(testEnv.ControlPlane.KubectlPath).NotTo(BeEmpty()) + + // Verify the paths contain the binary names + Expect(apiServer.Path).To(ContainSubstring("kube-apiserver")) + Expect(testEnv.ControlPlane.Etcd.Path).To(ContainSubstring("etcd")) + Expect(testEnv.ControlPlane.KubectlPath).To(ContainSubstring("kubectl")) + }) + }) }) diff --git a/pkg/envtest/komega/equalobject.go b/pkg/envtest/komega/equalobject.go index a931c2718a..0295d204ab 100644 --- a/pkg/envtest/komega/equalobject.go +++ b/pkg/envtest/komega/equalobject.go @@ -74,7 +74,7 @@ func EqualObject(original runtime.Object, opts ...EqualObjectOption) types.Gomeg // Match compares the current object to the passed object and returns true if the objects are the same according to // the Matcher and MatchOptions. -func (m *equalObjectMatcher) Match(actual interface{}) (success bool, err error) { +func (m *equalObjectMatcher) Match(actual any) (success bool, err error) { // Nil checks required first here for: // 1) Nil equality which returns true // 2) One object nil which returns an error @@ -93,13 +93,13 @@ func (m *equalObjectMatcher) Match(actual interface{}) (success bool, err error) } // FailureMessage returns a message comparing the full objects after an unexpected failure to match has occurred. -func (m *equalObjectMatcher) FailureMessage(actual interface{}) (message string) { +func (m *equalObjectMatcher) FailureMessage(actual any) (message string) { return fmt.Sprintf("the following fields were expected to match but did not:\n%v\n%s", m.diffPaths, format.Message(actual, "expected to match", m.original)) } // NegatedFailureMessage returns a string stating that all fields matched, even though that was not expected. -func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) { +func (m *equalObjectMatcher) NegatedFailureMessage(actual any) (message string) { return "it was expected that some fields do not match, but all of them did" } @@ -167,8 +167,8 @@ func (r *diffReporter) PopStep() { // calculateDiff calculates the difference between two objects and returns the // paths of the fields that do not match. -func (m *equalObjectMatcher) calculateDiff(actual interface{}) []diffPath { - var original interface{} = m.original +func (m *equalObjectMatcher) calculateDiff(actual any) []diffPath { + var original any = m.original // Remove the wrapping Object from unstructured.Unstructured to make comparison behave similar to // regular objects. if u, isUnstructured := actual.(runtime.Unstructured); isUnstructured { diff --git a/pkg/envtest/komega/equalobject_test.go b/pkg/envtest/komega/equalobject_test.go index 9fe10d1779..6163610a41 100644 --- a/pkg/envtest/komega/equalobject_test.go +++ b/pkg/envtest/komega/equalobject_test.go @@ -132,16 +132,16 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "works with unstructured.Unstructured", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "something", "namespace": "test", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "somethingelse", "namespace": "test", }, @@ -159,15 +159,15 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Equal field (spec) both in original and in modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "foo": "bar", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "foo": "bar", }, }, @@ -178,10 +178,10 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Equal nested field both in original and in modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ "A": "A", }, }, @@ -189,10 +189,10 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ "A": "A", }, }, @@ -206,15 +206,15 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal field both in original and in modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "foo": "bar-changed", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "foo": "bar", }, }, @@ -224,10 +224,10 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal nested field both in original and modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ "A": "A-Changed", }, }, @@ -235,10 +235,10 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ "A": "A", }, }, @@ -251,8 +251,8 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Value of type map with different values", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "map": map[string]string{ "A": "A-changed", "B": "B", @@ -262,8 +262,8 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "map": map[string]string{ "A": "A", // B missing @@ -278,8 +278,8 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Value of type Array or Slice with same length but different values", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "slice": []string{ "D", "C", @@ -289,8 +289,8 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "slice": []string{ "A", "B", @@ -306,22 +306,22 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Creation timestamp set to empty value on both original and modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "selfLink": "foo", "creationTimestamp": metav1.Time{}, }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "selfLink": "foo", "creationTimestamp": metav1.Time{}, }, @@ -334,11 +334,11 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Field only in modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "foo": "bar", }, }, @@ -348,13 +348,13 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Nested field only in modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ "A": "A", }, }, @@ -366,18 +366,18 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Creation timestamp exists on modified but not on original", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "selfLink": "foo", "creationTimestamp": "2021-11-03T11:05:17Z", }, @@ -390,24 +390,24 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Field only in original", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "foo": "bar", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, want: false, }, { name: "Nested field only in original", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ "A": "A", }, }, @@ -415,26 +415,26 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, want: false, }, { name: "Creation timestamp exists on original but not on modified", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "selfLink": "foo", "creationTimestamp": "2021-11-03T11:05:17Z", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, }, @@ -447,18 +447,18 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal Metadata fields computed by the system or in status", original: &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "selfLink": "foo", "uid": "foo", "resourceVersion": "foo", "generation": "foo", "managedFields": "foo", }, - "status": map[string]interface{}{ + "status": map[string]any{ "foo": "bar", }, }, @@ -468,15 +468,15 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal labels and annotations", original: &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "labels": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ "foo": "bar", }, - "annotations": map[string]interface{}{ + "annotations": map[string]any{ "foo": "bar", }, }, @@ -489,15 +489,15 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal metadata fields ignored by IgnorePaths MatchOption", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "test", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "test", "selfLink": "foo", "uid": "foo", @@ -513,20 +513,20 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal labels and annotations ignored by IgnorePaths MatchOption", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "test", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "test", - "labels": map[string]interface{}{ + "labels": map[string]any{ "foo": "bar", }, - "annotations": map[string]interface{}{ + "annotations": map[string]any{ "foo": "bar", }, }, @@ -538,14 +538,14 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Ignore fields are not compared", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{}, + Object: map[string]any{ + "spec": map[string]any{}, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ - "controlPlaneEndpoint": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ + "controlPlaneEndpoint": map[string]any{ "host": "", "port": 0, }, @@ -558,16 +558,16 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Not-ignored fields are still compared", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "annotations": map[string]interface{}{}, + Object: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{}, }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "annotations": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ "ignored": "somevalue", "superflous": "shouldcausefailure", }, @@ -582,18 +582,18 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Unequal metadata fields not compared by setting MatchPaths MatchOption", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", }, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "selfLink": "foo", "uid": "foo", }, @@ -607,8 +607,8 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "No changes", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", "B": "B", "C": "C", // C only in original @@ -616,8 +616,8 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", "B": "B", }, @@ -628,8 +628,8 @@ func TestEqualObjectMatcher(t *testing.T) { { name: "Many changes", original: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", // B missing "C": "C", // C only in original @@ -637,8 +637,8 @@ func TestEqualObjectMatcher(t *testing.T) { }, }, modified: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "spec": map[string]interface{}{ + Object: map[string]any{ + "spec": map[string]any{ "A": "A", "B": "B", }, diff --git a/pkg/envtest/server.go b/pkg/envtest/server.go index 9bb81ed2ab..c9f19da977 100644 --- a/pkg/envtest/server.go +++ b/pkg/envtest/server.go @@ -109,7 +109,11 @@ var ( // Environment creates a Kubernetes test environment that will start / stop the Kubernetes control plane and // install extension APIs. type Environment struct { - // ControlPlane is the ControlPlane including the apiserver and etcd + // ControlPlane is the ControlPlane including the apiserver and etcd. + // Binary paths (APIServer.Path, Etcd.Path, KubectlPath) can be pre-configured in ControlPlane. + // If DownloadBinaryAssets is true, the downloaded paths will always be used. + // If DownloadBinaryAssets is false and paths are not pre-configured (default is empty), they will be + // automatically resolved using BinaryAssetsDirectory. ControlPlane controlplane.ControlPlane // Scheme is used to determine if conversion webhooks should be enabled @@ -211,6 +215,40 @@ func (te *Environment) Stop() error { return te.ControlPlane.Stop() } +// configureBinaryPaths configures the binary paths for the API server, etcd, and kubectl. +// If DownloadBinaryAssets is true, it downloads and uses those paths. +// If DownloadBinaryAssets is false, it only sets paths that are not already configured (empty). +func (te *Environment) configureBinaryPaths() error { + apiServer := te.ControlPlane.GetAPIServer() + + if te.ControlPlane.Etcd == nil { + te.ControlPlane.Etcd = &controlplane.Etcd{} + } + + if te.DownloadBinaryAssets { + apiServerPath, etcdPath, kubectlPath, err := downloadBinaryAssets(context.TODO(), + te.BinaryAssetsDirectory, te.DownloadBinaryAssetsVersion, te.DownloadBinaryAssetsIndexURL) + if err != nil { + return err + } + + apiServer.Path = apiServerPath + te.ControlPlane.Etcd.Path = etcdPath + te.ControlPlane.KubectlPath = kubectlPath + } else { + if apiServer.Path == "" { + apiServer.Path = process.BinPathFinder("kube-apiserver", te.BinaryAssetsDirectory) + } + if te.ControlPlane.Etcd.Path == "" { + te.ControlPlane.Etcd.Path = process.BinPathFinder("etcd", te.BinaryAssetsDirectory) + } + if te.ControlPlane.KubectlPath == "" { + te.ControlPlane.KubectlPath = process.BinPathFinder("kubectl", te.BinaryAssetsDirectory) + } + } + return nil +} + // Start starts a local Kubernetes server and updates te.ApiserverPort with the port it is listening on. func (te *Environment) Start() (*rest.Config, error) { if te.useExistingCluster() { @@ -229,10 +267,6 @@ func (te *Environment) Start() (*rest.Config, error) { } else { apiServer := te.ControlPlane.GetAPIServer() - if te.ControlPlane.Etcd == nil { - te.ControlPlane.Etcd = &controlplane.Etcd{} - } - if os.Getenv(envAttachOutput) == "true" { te.AttachControlPlaneOutput = true } @@ -243,6 +277,9 @@ func (te *Environment) Start() (*rest.Config, error) { if apiServer.Err == nil { apiServer.Err = os.Stderr } + if te.ControlPlane.Etcd == nil { + te.ControlPlane.Etcd = &controlplane.Etcd{} + } if te.ControlPlane.Etcd.Out == nil { te.ControlPlane.Etcd.Out = os.Stdout } @@ -251,20 +288,8 @@ func (te *Environment) Start() (*rest.Config, error) { } } - if te.DownloadBinaryAssets { - apiServerPath, etcdPath, kubectlPath, err := downloadBinaryAssets(context.TODO(), - te.BinaryAssetsDirectory, te.DownloadBinaryAssetsVersion, te.DownloadBinaryAssetsIndexURL) - if err != nil { - return nil, err - } - - apiServer.Path = apiServerPath - te.ControlPlane.Etcd.Path = etcdPath - te.ControlPlane.KubectlPath = kubectlPath - } else { - apiServer.Path = process.BinPathFinder("kube-apiserver", te.BinaryAssetsDirectory) - te.ControlPlane.Etcd.Path = process.BinPathFinder("etcd", te.BinaryAssetsDirectory) - te.ControlPlane.KubectlPath = process.BinPathFinder("kubectl", te.BinaryAssetsDirectory) + if err := te.configureBinaryPaths(); err != nil { + return nil, fmt.Errorf("failed to configure binary paths: %w", err) } if err := te.defaultTimeouts(); err != nil { diff --git a/pkg/handler/eventhandler_test.go b/pkg/handler/eventhandler_test.go index 2a7453f761..2023156146 100644 --- a/pkg/handler/eventhandler_test.go +++ b/pkg/handler/eventhandler_test.go @@ -183,7 +183,7 @@ var _ = Describe("Eventhandler", func() { i1, _ := q.Get() i2, _ := q.Get() - Expect([]interface{}{i1, i2}).To(ConsistOf( + Expect([]any{i1, i2}).To(ConsistOf( reconcile.Request{ NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}, reconcile.Request{ @@ -215,7 +215,7 @@ var _ = Describe("Eventhandler", func() { i1, _ := q.Get() i2, _ := q.Get() - Expect([]interface{}{i1, i2}).To(ConsistOf( + Expect([]any{i1, i2}).To(ConsistOf( reconcile.Request{ NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}, reconcile.Request{ @@ -280,7 +280,7 @@ var _ = Describe("Eventhandler", func() { i1, _ := q.Get() i2, _ := q.Get() - Expect([]interface{}{i1, i2}).To(ConsistOf( + Expect([]any{i1, i2}).To(ConsistOf( reconcile.Request{ NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}, reconcile.Request{ @@ -362,7 +362,7 @@ var _ = Describe("Eventhandler", func() { i1, _ := q.Get() i2, _ := q.Get() - Expect([]interface{}{i1, i2}).To(ConsistOf( + Expect([]any{i1, i2}).To(ConsistOf( reconcile.Request{ NamespacedName: types.NamespacedName{Namespace: pod.GetNamespace(), Name: "foo1-parent"}}, reconcile.Request{ @@ -602,7 +602,7 @@ var _ = Describe("Eventhandler", func() { i1, _ := q.Get() i2, _ := q.Get() i3, _ := q.Get() - Expect([]interface{}{i1, i2, i3}).To(ConsistOf( + Expect([]any{i1, i2, i3}).To(ConsistOf( reconcile.Request{ NamespacedName: types.NamespacedName{Namespace: pod.GetNamespace(), Name: "foo1-parent"}}, reconcile.Request{ diff --git a/pkg/healthz/healthz.go b/pkg/healthz/healthz.go index cfb5dc8d02..149b02ec98 100644 --- a/pkg/healthz/healthz.go +++ b/pkg/healthz/healthz.go @@ -20,7 +20,7 @@ import ( "fmt" "net/http" "path" - "sort" + "slices" "strings" "k8s.io/apimachinery/pkg/util/sets" @@ -75,7 +75,7 @@ func (h *Handler) serveAggregated(resp http.ResponseWriter, req *http.Request) { } // ...sort to be consistent... - sort.Slice(parts, func(i, j int) bool { return parts[i].name < parts[j].name }) + slices.SortStableFunc(parts, func(i, j checkStatus) int { return strings.Compare(i.name, j.name) }) // ...and write out the result // TODO(directxman12): this should also accept a request for JSON content (via a accept header) diff --git a/pkg/internal/controller/controller.go b/pkg/internal/controller/controller.go index ea79681862..f2638b9d9b 100644 --- a/pkg/internal/controller/controller.go +++ b/pkg/internal/controller/controller.go @@ -39,6 +39,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" ) +// errReconciliationTimeout is the error used as the cause when the ReconciliationTimeout guardrail fires. +// This allows us to distinguish wrapper timeouts from user-initiated context cancellations. +var errReconciliationTimeout = errors.New("reconciliation timeout") + // Options are the arguments for creating a new Controller. type Options[request comparable] struct { // Reconciler is a function that can be called at any time with the Name / Namespace of an object and @@ -207,13 +211,26 @@ func (c *Controller[request]) Reconcile(ctx context.Context, req request) (_ rec } }() + var timeoutCause error if c.ReconciliationTimeout > 0 { + timeoutCause = errReconciliationTimeout var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, c.ReconciliationTimeout) + ctx, cancel = context.WithTimeoutCause(ctx, c.ReconciliationTimeout, timeoutCause) defer cancel() } - return c.Do.Reconcile(ctx, req) + res, err := c.Do.Reconcile(ctx, req) + + // Check if the reconciliation timed out due to our wrapper timeout guardrail. + // We check ctx.Err() == context.DeadlineExceeded first to ensure the context was actually + // cancelled due to a deadline (not parent cancellation or other reasons), then verify it was + // our specific timeout cause. This prevents false positives from parent context cancellations + // or other timeout scenarios. + if timeoutCause != nil && ctx.Err() == context.DeadlineExceeded && errors.Is(context.Cause(ctx), timeoutCause) { + ctrlmetrics.ReconcileTimeouts.WithLabelValues(c.Name).Inc() + } + + return res, err } // Watch implements controller.Controller. @@ -437,6 +454,7 @@ func (c *Controller[request]) initMetrics() { ctrlmetrics.ReconcileErrors.WithLabelValues(c.Name).Add(0) ctrlmetrics.TerminalReconcileErrors.WithLabelValues(c.Name).Add(0) ctrlmetrics.ReconcilePanics.WithLabelValues(c.Name).Add(0) + ctrlmetrics.ReconcileTimeouts.WithLabelValues(c.Name).Add(0) ctrlmetrics.WorkerCount.WithLabelValues(c.Name).Set(float64(c.MaxConcurrentReconciles)) ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Set(0) } @@ -459,6 +477,9 @@ func (c *Controller[request]) reconcileHandler(ctx context.Context, req request, // resource to be synced. log.V(5).Info("Reconciling") result, err := c.Reconcile(ctx, req) + if result.Priority != nil { + priority = *result.Priority + } switch { case err != nil: if errors.Is(err, reconcile.TerminalError(nil)) { @@ -468,8 +489,8 @@ func (c *Controller[request]) reconcileHandler(ctx context.Context, req request, } ctrlmetrics.ReconcileErrors.WithLabelValues(c.Name).Inc() ctrlmetrics.ReconcileTotal.WithLabelValues(c.Name, labelError).Inc() - if !result.IsZero() { - log.Info("Warning: Reconciler returned both a non-zero result and a non-nil error. The result will always be ignored if the error is non-nil and the non-nil error causes requeuing with exponential backoff. For more details, see: https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile#Reconciler") + if result.RequeueAfter > 0 || result.Requeue { //nolint: staticcheck // We have to handle Requeue until it is removed + log.Info("Warning: Reconciler returned both a result with either RequeueAfter or Requeue set and a non-nil error. RequeueAfter and Requeue will always be ignored if the error is non-nil. For more details, see: https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile#Reconciler") } log.Error(err, "Reconciler error") case result.RequeueAfter > 0: diff --git a/pkg/internal/controller/controller_test.go b/pkg/internal/controller/controller_test.go index 306e0b0126..90ff30502c 100644 --- a/pkg/internal/controller/controller_test.go +++ b/pkg/internal/controller/controller_test.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "strconv" "sync" "sync/atomic" "time" @@ -155,6 +156,128 @@ var _ = Describe("controller", func() { Expect(err).To(Equal(context.DeadlineExceeded)) }) + Context("prometheus metric reconcile_timeouts", func() { + var reconcileTimeouts dto.Metric + + BeforeEach(func() { + ctrlmetrics.ReconcileTimeouts.Reset() + reconcileTimeouts.Reset() + ctrl.Name = testControllerName + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(0.0)) + }) + + It("should increment when ReconciliationTimeout context timeout hits DeadlineExceeded", func(ctx SpecContext) { + ctrl.ReconciliationTimeout = time.Duration(1) + ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + <-ctx.Done() + return reconcile.Result{}, ctx.Err() + }) + _, err := ctrl.Reconcile(ctx, + reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}) + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(context.DeadlineExceeded)) + + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(1.0)) + }) + + It("should not increment when user code cancels context earlier than the ReconciliationTimeout", func(ctx SpecContext) { + ctrl.ReconciliationTimeout = 10 * time.Second + userCancelCause := errors.New("user cancellation") + ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + // User code creates its own timeout with a different cause + userCtx, cancel := context.WithTimeoutCause(ctx, time.Millisecond, userCancelCause) + defer cancel() + <-userCtx.Done() + return reconcile.Result{}, context.Cause(userCtx) + }) + _, err := ctrl.Reconcile(ctx, + reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, userCancelCause)).To(BeTrue()) + + // Metric should not be incremented because the context timeout didn't hit DeadlineExceeded + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(0.0)) + }) + + It("should not increment when reconciliation completes before timeout", func(ctx SpecContext) { + ctrl.ReconciliationTimeout = 10 * time.Second + ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + // Reconcile completes successfully before timeout + return reconcile.Result{}, nil + }) + _, err := ctrl.Reconcile(ctx, + reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}) + Expect(err).NotTo(HaveOccurred()) + + // Metric should not be incremented because the timeout was not exceeded + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(0.0)) + }) + + It("should increment multiple times when multiple reconciles timeout", func(ctx SpecContext) { + ctrl.ReconciliationTimeout = time.Duration(1) + ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + <-ctx.Done() + return reconcile.Result{}, ctx.Err() + }) + + const numTimeouts = 3 + // Call Reconcile multiple times, each should timeout and increment the metric + for i := range numTimeouts { + _, err := ctrl.Reconcile(ctx, + reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "foo", Name: fmt.Sprintf("bar%d", i)}}) + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(context.DeadlineExceeded)) + } + + // Metric should be incremented 3 times + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(float64(numTimeouts))) + }) + + It("should not increment when parent context is cancelled", func(ctx SpecContext) { + parentCtx, cancel := context.WithCancel(ctx) + defer cancel() + + ctrl.ReconciliationTimeout = 10 * time.Second + ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + // Wait for parent cancellation + <-ctx.Done() + return reconcile.Result{}, ctx.Err() + }) + + // Cancel parent context immediately + cancel() + + _, err := ctrl.Reconcile(parentCtx, + reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}) + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(context.Canceled)) + + // Metric should not be incremented because the wrapper timeout didn't fire + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(0.0)) + }) + + It("should not increment when ReconciliationTimeout is zero", func(ctx SpecContext) { + // Ensure ReconciliationTimeout is zero (not set) + ctrl.ReconciliationTimeout = 0 + ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + return reconcile.Result{}, nil + }) + _, err := ctrl.Reconcile(ctx, + reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "foo", Name: "bar"}}) + Expect(err).NotTo(HaveOccurred()) + + // Metric should not be incremented because ReconciliationTimeout is not set + Expect(ctrlmetrics.ReconcileTimeouts.WithLabelValues(ctrl.Name).Write(&reconcileTimeouts)).To(Succeed()) + Expect(reconcileTimeouts.GetCounter().GetValue()).To(Equal(0.0)) + }) + }) + It("should not configure a timeout if ReconciliationTimeout is zero", func(ctx SpecContext) { ctrl.Do = reconcile.Func(func(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { defer GinkgoRecover() @@ -264,6 +387,96 @@ var _ = Describe("controller", func() { <-sourceSynced }) + It("should not call Reconcile until all event handlers have processed initial objects", func(specCtx SpecContext) { + nPods := 20 + pods := make([]*corev1.Pod, nPods) + for i := range nPods { + pods[i] = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: strconv.Itoa(i), + Namespace: "default", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test", Image: "test"}}, + }, + } + _, err := clientset.CoreV1().Pods("default").Create(specCtx, pods[i], metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + } + defer func() { + for _, pod := range pods { + _ = clientset.CoreV1().Pods("default").Delete(specCtx, pod.Name, metav1.DeleteOptions{}) + } + }() + + testCache, err := cache.New(cfg, cache.Options{}) + Expect(err).NotTo(HaveOccurred()) + + ctx, cancel := context.WithCancel(specCtx) + defer cancel() + go func() { + defer GinkgoRecover() + _ = testCache.Start(ctx) + }() + + // Tracks how many objects have been processed by the event handler. + var handlerProcessedCount atomic.Int32 + + // Channel to block one of the event handlers to simulate slow event handler processing. + blockHandler := make(chan struct{}) + + // Tracks whether Reconcile was called. + var reconcileCalled atomic.Bool + + // Create the controller. + testCtrl := New(Options[reconcile.Request]{ + MaxConcurrentReconciles: 1, + CacheSyncTimeout: 10 * time.Second, + NewQueue: func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { + return &controllertest.Queue{ + TypedInterface: workqueue.NewTyped[reconcile.Request](), + } + }, + Name: "test-reconcile-order", + LogConstructor: func(_ *reconcile.Request) logr.Logger { + return log.RuntimeLog.WithName("test-reconcile-order") + }, + Do: reconcile.Func(func(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + // handlerProcessedCount should be equal to the number of pods created since we are waiting + // for the handlers to finish processing before reconciling. + Expect(handlerProcessedCount.Load()).To(Equal(int32(nPods))) + reconcileCalled.Store(true) + return reconcile.Result{}, nil + })}, + ) + + err = testCtrl.Watch(source.Kind(testCache, &corev1.Pod{}, handler.TypedFuncs[*corev1.Pod, reconcile.Request]{ + CreateFunc: func(ctx context.Context, evt event.TypedCreateEvent[*corev1.Pod], q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + <-blockHandler + handlerProcessedCount.Add(1) + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Name: evt.Object.GetName(), Namespace: evt.Object.GetNamespace()}}) + }, + })) + Expect(err).NotTo(HaveOccurred()) + + controllerDone := make(chan error) + go func() { + defer GinkgoRecover() + controllerDone <- testCtrl.Start(ctx) + }() + + // Give the controller time to start the reconciler. We asserts + // in there that all events have been processed, so if we start it + // prematurely, that assertion will fail. We can not get rid of the + // sleep unless we stop using envtest for this test. + time.Sleep(1 * time.Second) + close(blockHandler) + Eventually(reconcileCalled.Load).Should(BeTrue()) + + cancel() + Eventually(controllerDone, 5*time.Second).Should(Receive()) + }) + It("should process events from source.Channel", func(ctx SpecContext) { ctrl.CacheSyncTimeout = 10 * time.Second // channel to be closed when event is processed @@ -514,14 +727,12 @@ var _ = Describe("controller", func() { By("Calling startEventSourcesAndQueueLocked multiple times in parallel") var wg sync.WaitGroup - for i := 1; i <= 5; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 5 { + wg.Go(func() { err := ctrl.startEventSourcesAndQueueLocked(ctx) // All calls should return the same nil error Expect(err).NotTo(HaveOccurred()) - }() + }) } wg.Wait() @@ -745,7 +956,36 @@ var _ = Describe("controller", func() { }})) }) - It("should requeue a Request after a duration (but not rate-limitted) if the Result sets RequeueAfter (regardless of Requeue)", func(ctx SpecContext) { + It("should use the priority from Result when the reconciler requests a requeue", func(ctx SpecContext) { + q := &fakePriorityQueue{PriorityQueue: priorityqueue.New[reconcile.Request]("controller1")} + ctrl.NewQueue = func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { + return q + } + + go func() { + defer GinkgoRecover() + Expect(ctrl.Start(ctx)).NotTo(HaveOccurred()) + }() + + q.PriorityQueue.AddWithOpts(priorityqueue.AddOpts{Priority: ptr.To(10)}, request) + + By("Invoking Reconciler which will request a requeue") + fakeReconcile.AddResult(reconcile.Result{Requeue: true, Priority: ptr.To(99)}, nil) + Expect(<-reconciled).To(Equal(request)) + Eventually(func() []priorityQueueAddition { + q.lock.Lock() + defer q.lock.Unlock() + return q.added + }).Should(Equal([]priorityQueueAddition{{ + AddOpts: priorityqueue.AddOpts{ + RateLimited: true, + Priority: ptr.To(99), + }, + items: []reconcile.Request{request}, + }})) + }) + + It("should requeue a Request after a duration (but not rate-limited) if the Result sets RequeueAfter (regardless of Requeue)", func(ctx SpecContext) { dq := &DelegatingQueue{TypedRateLimitingInterface: ctrl.NewQueue("controller1", nil)} ctrl.NewQueue = func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { return dq @@ -775,7 +1015,7 @@ var _ = Describe("controller", func() { Eventually(func() int { return dq.NumRequeues(request) }).Should(Equal(0)) }) - It("should retain the priority with RequeAfter", func(ctx SpecContext) { + It("should retain the priority with RequeueAfter", func(ctx SpecContext) { q := &fakePriorityQueue{PriorityQueue: priorityqueue.New[reconcile.Request]("controller1")} ctrl.NewQueue = func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { return q @@ -804,6 +1044,35 @@ var _ = Describe("controller", func() { }})) }) + It("should use the priority from Result with RequeueAfter", func(ctx SpecContext) { + q := &fakePriorityQueue{PriorityQueue: priorityqueue.New[reconcile.Request]("controller1")} + ctrl.NewQueue = func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { + return q + } + + go func() { + defer GinkgoRecover() + Expect(ctrl.Start(ctx)).NotTo(HaveOccurred()) + }() + + q.PriorityQueue.AddWithOpts(priorityqueue.AddOpts{Priority: ptr.To(10)}, request) + + By("Invoking Reconciler which will ask for RequeueAfter") + fakeReconcile.AddResult(reconcile.Result{RequeueAfter: time.Millisecond * 100, Priority: ptr.To(99)}, nil) + Expect(<-reconciled).To(Equal(request)) + Eventually(func() []priorityQueueAddition { + q.lock.Lock() + defer q.lock.Unlock() + return q.added + }).Should(Equal([]priorityQueueAddition{{ + AddOpts: priorityqueue.AddOpts{ + After: time.Millisecond * 100, + Priority: ptr.To(99), + }, + items: []reconcile.Request{request}, + }})) + }) + It("should perform error behavior if error is not nil, regardless of RequeueAfter", func(ctx SpecContext) { dq := &DelegatingQueue{TypedRateLimitingInterface: ctrl.NewQueue("controller1", nil)} ctrl.NewQueue = func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { @@ -862,6 +1131,35 @@ var _ = Describe("controller", func() { }})) }) + It("should use the priority from Result when there was an error", func(ctx SpecContext) { + q := &fakePriorityQueue{PriorityQueue: priorityqueue.New[reconcile.Request]("controller1")} + ctrl.NewQueue = func(string, workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] { + return q + } + + go func() { + defer GinkgoRecover() + Expect(ctrl.Start(ctx)).NotTo(HaveOccurred()) + }() + + q.PriorityQueue.AddWithOpts(priorityqueue.AddOpts{Priority: ptr.To(10)}, request) + + By("Invoking Reconciler which will return an error") + fakeReconcile.AddResult(reconcile.Result{Priority: ptr.To(99)}, errors.New("oups, I did it again")) + Expect(<-reconciled).To(Equal(request)) + Eventually(func() []priorityQueueAddition { + q.lock.Lock() + defer q.lock.Unlock() + return q.added + }).Should(Equal([]priorityQueueAddition{{ + AddOpts: priorityqueue.AddOpts{ + RateLimited: true, + Priority: ptr.To(99), + }, + items: []reconcile.Request{request}, + }})) + }) + PIt("should return if the queue is shutdown", func() { // TODO(community): write this test }) @@ -1416,7 +1714,7 @@ var _ = Describe("controller", func() { } var wg sync.WaitGroup - for i := 0; i < 5; i++ { + for range 5 { wg.Add(1) go func() { defer GinkgoRecover() diff --git a/pkg/internal/controller/metrics/metrics.go b/pkg/internal/controller/metrics/metrics.go index 450e9ae25b..39b435c453 100644 --- a/pkg/internal/controller/metrics/metrics.go +++ b/pkg/internal/controller/metrics/metrics.go @@ -80,6 +80,15 @@ var ( Name: "controller_runtime_active_workers", Help: "Number of currently used workers per controller", }, []string{"controller"}) + + // ReconcileTimeouts is a prometheus counter metric which holds the total + // number of reconciliations that timed out due to the ReconciliationTimeout + // context timeout. This metric only increments when the wrapper timeout fires, + // not when user reconcilers cancels the context or completes before the timeout. + ReconcileTimeouts = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "controller_runtime_reconcile_timeouts_total", + Help: "Total number of reconciliation timeouts per controller", + }, []string{"controller"}) ) func init() { @@ -91,6 +100,7 @@ func init() { ReconcileTime, WorkerCount, ActiveWorkers, + ReconcileTimeouts, // expose process metrics like CPU, Memory, file descriptor usage etc. collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), // expose all Go runtime metrics like GC stats, memory stats etc. diff --git a/pkg/internal/flock/flock_other.go b/pkg/internal/flock/flock_other.go index 069a5b3a2c..1def472197 100644 --- a/pkg/internal/flock/flock_other.go +++ b/pkg/internal/flock/flock_other.go @@ -1,4 +1,4 @@ -// +build !linux,!darwin,!freebsd,!openbsd,!netbsd,!dragonfly +//go:build !linux && !darwin && !freebsd && !openbsd && !netbsd && !dragonfly /* Copyright 2016 The Kubernetes Authors. diff --git a/pkg/internal/flock/flock_unix.go b/pkg/internal/flock/flock_unix.go index 71ec576df2..be2a8c2cfd 100644 --- a/pkg/internal/flock/flock_unix.go +++ b/pkg/internal/flock/flock_unix.go @@ -1,5 +1,4 @@ //go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly -// +build linux darwin freebsd openbsd netbsd dragonfly /* Copyright 2016 The Kubernetes Authors. diff --git a/pkg/internal/metrics/workqueue.go b/pkg/internal/metrics/workqueue.go index 402319817b..49180457a6 100644 --- a/pkg/internal/metrics/workqueue.go +++ b/pkg/internal/metrics/workqueue.go @@ -18,9 +18,11 @@ package metrics import ( "strconv" + "sync" "time" "github.com/prometheus/client_golang/prometheus" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/workqueue" "sigs.k8s.io/controller-runtime/pkg/metrics" ) @@ -154,17 +156,55 @@ type DepthMetricWithPriority interface { var _ MetricsProviderWithPriority = WorkqueueMetricsProvider{} func (WorkqueueMetricsProvider) NewDepthMetricWithPriority(name string) DepthMetricWithPriority { - return &depthWithPriorityMetric{lvs: []string{name, name}} + return &depthWithPriorityMetric{depth: depth, lvs: []string{name, name}, observedPriorities: sets.Set[int]{}} } +type prometheusGaugeVec interface { + WithLabelValues(lvs ...string) prometheus.Gauge +} + +const ( + priorityCardinalityExceededPlaceholder = "exceeded_cardinality_limit" + // maxRecommendedUniquePriorities is not scientifically chosen, we assume + // that the 99% use-case is to only use the two priorities that c-r itself + // uses and then leave a bit of leeway for other use-cases. + // We may decide to update this value in the future if we find that a + // a different value is more appropriate. + maxRecommendedUniquePriorities = 25 +) + type depthWithPriorityMetric struct { - lvs []string + depth prometheusGaugeVec + lvs []string + + observedPrioritiesLock sync.Mutex + priorityCardinalityLimitReached bool + observedPriorities sets.Set[int] +} + +func (g *depthWithPriorityMetric) priorityLabel(priority int) string { + g.observedPrioritiesLock.Lock() + defer g.observedPrioritiesLock.Unlock() + + if g.priorityCardinalityLimitReached { + return priorityCardinalityExceededPlaceholder + } + + g.observedPriorities.Insert(priority) + + if g.observedPriorities.Len() > maxRecommendedUniquePriorities { + g.observedPriorities = nil + g.priorityCardinalityLimitReached = true + return priorityCardinalityExceededPlaceholder + } + + return strconv.Itoa(priority) } func (g *depthWithPriorityMetric) Inc(priority int) { - depth.WithLabelValues(append(g.lvs, strconv.Itoa(priority))...).Inc() + g.depth.WithLabelValues(append(g.lvs, g.priorityLabel(priority))...).Inc() } func (g *depthWithPriorityMetric) Dec(priority int) { - depth.WithLabelValues(append(g.lvs, strconv.Itoa(priority))...).Dec() + g.depth.WithLabelValues(append(g.lvs, g.priorityLabel(priority))...).Dec() } diff --git a/pkg/internal/metrics/workqueue_suite_test.go b/pkg/internal/metrics/workqueue_suite_test.go new file mode 100644 index 0000000000..d647cf64bf --- /dev/null +++ b/pkg/internal/metrics/workqueue_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright The Kubernetes Authors. + +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. +*/ + +package metrics + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWorkqueue(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Workqueue Metrics Suite") +} diff --git a/pkg/internal/metrics/workqueue_test.go b/pkg/internal/metrics/workqueue_test.go new file mode 100644 index 0000000000..c3cb7378e7 --- /dev/null +++ b/pkg/internal/metrics/workqueue_test.go @@ -0,0 +1,134 @@ +/* +Copyright The Kubernetes Authors. + +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. +*/ + +package metrics + +import ( + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/client_golang/prometheus" + "k8s.io/apimachinery/pkg/util/sets" +) + +type fakeGauge struct { + prometheus.Gauge + inc func() +} + +func (f *fakeGauge) Inc() { f.inc() } + +type fakeGaugeVec struct { + gauges map[string]int + mu sync.Mutex +} + +func (f *fakeGaugeVec) WithLabelValues(lvs ...string) prometheus.Gauge { + f.mu.Lock() + defer f.mu.Unlock() + + key := "" + for _, lv := range lvs { + key += lv + "|" + } + if _, ok := f.gauges[key]; !ok { + f.gauges[key] = 0 + } + return &fakeGauge{inc: func() { + f.mu.Lock() + f.gauges[key]++ + f.mu.Unlock() + }} +} + +var _ = Describe("depthWithPriorityMetric", func() { + Describe("CardinalityLimit", func() { + type testCase struct { + numUniquePriorities int + expectedKeyCount int + expectedCardinalityExceededVal int + } + + DescribeTable("should respect cardinality limits", + func(tc testCase) { + fakeVec := &fakeGaugeVec{gauges: make(map[string]int)} + m := &depthWithPriorityMetric{ + depth: fakeVec, + lvs: []string{"test", "test"}, + observedPriorities: sets.Set[int]{}, + } + + wg := &sync.WaitGroup{} + wg.Add(tc.numUniquePriorities) + for i := 1; i <= tc.numUniquePriorities; i++ { + go func() { + m.Inc(i) + wg.Done() + }() + } + wg.Wait() + + Expect(fakeVec.gauges).To(HaveLen(tc.expectedKeyCount)) + + placeholderKey := "test|test|" + priorityCardinalityExceededPlaceholder + "|" + Expect(fakeVec.gauges[placeholderKey]).To(Equal(tc.expectedCardinalityExceededVal)) + }, + Entry("under limit does not use placeholder", testCase{ + numUniquePriorities: 10, + expectedKeyCount: 10, + expectedCardinalityExceededVal: 0, + }), + Entry("at limit does not use placeholder", testCase{ + numUniquePriorities: 25, + expectedKeyCount: 25, + expectedCardinalityExceededVal: 0, + }), + Entry("exceeding limit uses placeholder", testCase{ + numUniquePriorities: 26, + expectedKeyCount: 26, + expectedCardinalityExceededVal: 1, + }), + Entry("well over limit uses placeholder for all excess", testCase{ + numUniquePriorities: 30, + expectedKeyCount: 26, + expectedCardinalityExceededVal: 5, + }), + ) + }) + + It("same priority many adds does not trigger cardinality limit", func() { + fakeVec := &fakeGaugeVec{gauges: make(map[string]int)} + m := &depthWithPriorityMetric{ + depth: fakeVec, + lvs: []string{"test", "test"}, + observedPriorities: sets.Set[int]{}, + } + + wg := &sync.WaitGroup{} + wg.Add(200) + for range 200 { + go func() { + m.Inc(1) + wg.Done() + }() + } + wg.Wait() + + Expect(fakeVec.gauges).To(HaveLen(1)) + Expect(fakeVec.gauges["test|test|1|"]).To(Equal(200)) + }) +}) diff --git a/pkg/internal/recorder/recorder.go b/pkg/internal/recorder/recorder.go index 21f0146ba3..bbc1604835 100644 --- a/pkg/internal/recorder/recorder.go +++ b/pkg/internal/recorder/recorder.go @@ -24,16 +24,19 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" "k8s.io/apimachinery/pkg/runtime" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/record" ) // EventBroadcasterProducer makes an event broadcaster, returning // whether or not the broadcaster should be stopped with the Provider, // or not (e.g. if it's shared, it shouldn't be stopped with the Provider). -type EventBroadcasterProducer func() (caster record.EventBroadcaster, stopWithProvider bool) +// This producer currently produces both an old API and a new API broadcaster. +type EventBroadcasterProducer func() (deprecatedCaster record.EventBroadcaster, caster events.EventBroadcaster, stopWithProvider bool) // Provider is a recorder.Provider that records events to the k8s API server // and to a logr Logger. @@ -48,9 +51,13 @@ type Provider struct { evtClient corev1client.EventInterface makeBroadcaster EventBroadcasterProducer - broadcasterOnce sync.Once - broadcaster record.EventBroadcaster - stopBroadcaster bool + broadcasterOnce sync.Once + broadcaster events.EventBroadcaster + cancelSinkRecordingFunc context.CancelFunc + stopWatcherFunc func() + // Deprecated: will be removed in a future release. Use the broadcaster above instead. + deprecatedBroadcaster record.EventBroadcaster + stopBroadcaster bool } // NB(directxman12): this manually implements Stop instead of Being a runnable because we need to @@ -71,10 +78,13 @@ func (p *Provider) Stop(shutdownCtx context.Context) { // almost certainly already been started (e.g. by leader election). We // need to invoke this to ensure that we don't inadvertently race with // an invocation of getBroadcaster. - broadcaster := p.getBroadcaster() + deprecatedBroadcaster, broadcaster := p.getBroadcaster() if p.stopBroadcaster { p.lock.Lock() broadcaster.Shutdown() + p.cancelSinkRecordingFunc() + p.stopWatcherFunc() + deprecatedBroadcaster.Shutdown() p.stopped = true p.lock.Unlock() } @@ -89,7 +99,7 @@ func (p *Provider) Stop(shutdownCtx context.Context) { // getBroadcaster ensures that a broadcaster is started for this // provider, and returns it. It's threadsafe. -func (p *Provider) getBroadcaster() record.EventBroadcaster { +func (p *Provider) getBroadcaster() (record.EventBroadcaster, events.EventBroadcaster) { // NB(directxman12): this can technically still leak if something calls // "getBroadcaster" (i.e. Emits an Event) but never calls Start, but if we // create the broadcaster in start, we could race with other things that @@ -97,17 +107,37 @@ func (p *Provider) getBroadcaster() record.EventBroadcaster { // silently swallowing events and more locking, but that seems suboptimal. p.broadcasterOnce.Do(func() { - broadcaster, stop := p.makeBroadcaster() - broadcaster.StartRecordingToSink(&corev1client.EventSinkImpl{Interface: p.evtClient}) - broadcaster.StartEventWatcher( + p.deprecatedBroadcaster, p.broadcaster, p.stopBroadcaster = p.makeBroadcaster() + + // init deprecated broadcaster + p.deprecatedBroadcaster.StartRecordingToSink(&corev1client.EventSinkImpl{Interface: p.evtClient}) + p.deprecatedBroadcaster.StartEventWatcher( func(e *corev1.Event) { p.logger.V(1).Info(e.Message, "type", e.Type, "object", e.InvolvedObject, "reason", e.Reason) }) - p.broadcaster = broadcaster - p.stopBroadcaster = stop + + // init new broadcaster + ctx, cancel := context.WithCancel(context.Background()) + p.cancelSinkRecordingFunc = cancel + if err := p.broadcaster.StartRecordingToSinkWithContext(ctx); err != nil { + p.logger.Error(err, "error starting recording for broadcaster") + return + } + + stopWatcher, err := p.broadcaster.StartEventWatcher(func(event runtime.Object) { + e, isEvt := event.(*eventsv1.Event) + if isEvt { + p.logger.V(1).Info(e.Note, "type", e.Type, "object", e.Related, "action", e.Action, "reason", e.Reason) + } + }) + if err != nil { + p.logger.Error(err, "error starting event watcher for broadcaster") + } + + p.stopWatcherFunc = stopWatcher }) - return p.broadcaster + return p.deprecatedBroadcaster, p.broadcaster } // NewProvider create a new Provider instance. @@ -128,6 +158,15 @@ func NewProvider(config *rest.Config, httpClient *http.Client, scheme *runtime.S // GetEventRecorderFor returns an event recorder that broadcasts to this provider's // broadcaster. All events will be associated with a component of the given name. func (p *Provider) GetEventRecorderFor(name string) record.EventRecorder { + return &deprecatedRecorder{ + prov: p, + name: name, + } +} + +// GetEventRecorder returns an event recorder that broadcasts to this provider's +// broadcaster. All events will be associated with a component of the given name. +func (p *Provider) GetEventRecorder(name string) events.EventRecorder { return &lazyRecorder{ prov: p, name: name, @@ -141,18 +180,47 @@ type lazyRecorder struct { name string recOnce sync.Once - rec record.EventRecorder + rec events.EventRecorder } // ensureRecording ensures that a concrete recorder is populated for this recorder. func (l *lazyRecorder) ensureRecording() { l.recOnce.Do(func() { - broadcaster := l.prov.getBroadcaster() - l.rec = broadcaster.NewRecorder(l.prov.scheme, corev1.EventSource{Component: l.name}) + _, broadcaster := l.prov.getBroadcaster() + l.rec = broadcaster.NewRecorder(l.prov.scheme, l.name) }) } -func (l *lazyRecorder) Event(object runtime.Object, eventtype, reason, message string) { +func (l *lazyRecorder) Eventf(regarding runtime.Object, related runtime.Object, eventtype, reason, action, note string, args ...any) { + l.ensureRecording() + + l.prov.lock.RLock() + if !l.prov.stopped { + l.rec.Eventf(regarding, related, eventtype, reason, action, note, args...) + } + l.prov.lock.RUnlock() +} + +// deprecatedRecorder implements the old events API during the tranisiton and will be removed in a future release. +// +// Deprecated: will be removed in a future release. +type deprecatedRecorder struct { + prov *Provider + name string + + recOnce sync.Once + rec record.EventRecorder +} + +// ensureRecording ensures that a concrete recorder is populated for this recorder. +func (l *deprecatedRecorder) ensureRecording() { + l.recOnce.Do(func() { + deprecatedBroadcaster, _ := l.prov.getBroadcaster() + l.rec = deprecatedBroadcaster.NewRecorder(l.prov.scheme, corev1.EventSource{Component: l.name}) + }) +} + +func (l *deprecatedRecorder) Event(object runtime.Object, eventtype, reason, message string) { l.ensureRecording() l.prov.lock.RLock() @@ -161,7 +229,8 @@ func (l *lazyRecorder) Event(object runtime.Object, eventtype, reason, message s } l.prov.lock.RUnlock() } -func (l *lazyRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{}) { + +func (l *deprecatedRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...any) { l.ensureRecording() l.prov.lock.RLock() @@ -170,7 +239,8 @@ func (l *lazyRecorder) Eventf(object runtime.Object, eventtype, reason, messageF } l.prov.lock.RUnlock() } -func (l *lazyRecorder) AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{}) { + +func (l *deprecatedRecorder) AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...any) { l.ensureRecording() l.prov.lock.RLock() diff --git a/pkg/internal/recorder/recorder_integration_test.go b/pkg/internal/recorder/recorder_integration_test.go index c278fbde79..72c8335cf7 100644 --- a/pkg/internal/recorder/recorder_integration_test.go +++ b/pkg/internal/recorder/recorder_integration_test.go @@ -21,7 +21,9 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/kubernetes/scheme" ref "k8s.io/client-go/tools/reference" @@ -36,20 +38,22 @@ import ( ) var _ = Describe("recorder", func() { - Describe("recorder", func() { + Describe("deprecated recorder", func() { It("should publish events", func(ctx SpecContext) { By("Creating the Manager") cm, err := manager.New(cfg, manager.Options{}) Expect(err).NotTo(HaveOccurred()) By("Creating the Controller") - recorder := cm.GetEventRecorderFor("test-recorder") + deprecatedRecorder := cm.GetEventRecorder("test-deprecated-recorder") + recorder := cm.GetEventRecorder("test-recorder") instance, err := controller.New("foo-controller", cm, controller.Options{ Reconciler: reconcile.Func( func(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { dp, err := clientset.AppsV1().Deployments(request.Namespace).Get(ctx, request.Name, metav1.GetOptions{}) Expect(err).NotTo(HaveOccurred()) - recorder.Event(dp, corev1.EventTypeNormal, "test-reason", "test-msg") + deprecatedRecorder.Eventf(dp, nil, corev1.EventTypeNormal, "deprecated-test-reason", "deprecatedAction", "deprecated-test-msg") + recorder.Eventf(dp, nil, corev1.EventTypeNormal, "test-reason", "test-action", "test-note") return reconcile.Result{}, nil }), }) @@ -66,7 +70,7 @@ var _ = Describe("recorder", func() { }() deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: "deployment-name"}, + ObjectMeta: metav1.ObjectMeta{Name: "deprecated-deployment-name"}, Spec: appsv1.DeploymentSpec{ Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{"foo": "bar"}, @@ -89,23 +93,42 @@ var _ = Describe("recorder", func() { deployment, err = clientset.AppsV1().Deployments("default").Create(ctx, deployment, metav1.CreateOptions{}) Expect(err).NotTo(HaveOccurred()) - By("Validate event is published as expected") - evtWatcher, err := clientset.CoreV1().Events("default").Watch(ctx, metav1.ListOptions{}) + // watch both deprecated and new events based on the reason + By("Validate deprecated event is published as expected") + deprecatedEvtWatcher, err := clientset.CoreV1().Events("default").Watch(ctx, + metav1.ListOptions{FieldSelector: fields.OneTermEqualSelector("reason", "deprecated-test-reason").String()}) Expect(err).NotTo(HaveOccurred()) - resultEvent := <-evtWatcher.ResultChan() + resultEvent := <-deprecatedEvtWatcher.ResultChan() Expect(resultEvent.Type).To(Equal(watch.Added)) - evt, isEvent := resultEvent.Object.(*corev1.Event) + deprecatedEvt, isEvent := resultEvent.Object.(*corev1.Event) Expect(isEvent).To(BeTrue()) dpRef, err := ref.GetReference(scheme.Scheme, deployment) Expect(err).NotTo(HaveOccurred()) - Expect(evt.InvolvedObject).To(Equal(*dpRef)) + Expect(deprecatedEvt.InvolvedObject).To(Equal(*dpRef)) + Expect(deprecatedEvt.Type).To(Equal(corev1.EventTypeNormal)) + Expect(deprecatedEvt.Reason).To(Equal("deprecated-test-reason")) + Expect(deprecatedEvt.Message).To(Equal("deprecated-test-msg")) + + By("Validate event is published as expected") + evtWatcher, err := clientset.EventsV1().Events("default").Watch(ctx, + metav1.ListOptions{FieldSelector: fields.OneTermEqualSelector("reason", "test-reason").String()}) + Expect(err).NotTo(HaveOccurred()) + + resultEvent = <-evtWatcher.ResultChan() + + Expect(resultEvent.Type).To(Equal(watch.Added)) + evt, isEvent := resultEvent.Object.(*eventsv1.Event) + Expect(isEvent).To(BeTrue()) + + Expect(evt.Regarding).To(Equal(*dpRef)) Expect(evt.Type).To(Equal(corev1.EventTypeNormal)) Expect(evt.Reason).To(Equal("test-reason")) - Expect(evt.Message).To(Equal("test-msg")) + Expect(evt.Action).To(Equal("test-action")) + Expect(evt.Note).To(Equal("test-note")) }) }) }) diff --git a/pkg/internal/recorder/recorder_test.go b/pkg/internal/recorder/recorder_test.go index e226e165a3..e592a1e189 100644 --- a/pkg/internal/recorder/recorder_test.go +++ b/pkg/internal/recorder/recorder_test.go @@ -21,15 +21,16 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes/scheme" + eventsv1client "k8s.io/client-go/kubernetes/typed/events/v1" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/internal/recorder" ) var _ = Describe("recorder.Provider", func() { - makeBroadcaster := func() (record.EventBroadcaster, bool) { return record.NewBroadcaster(), true } Describe("NewProvider", func() { It("should return a provider instance and a nil error.", func() { - provider, err := recorder.NewProvider(cfg, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster) + provider, err := recorder.NewProvider(cfg, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster()) Expect(provider).NotTo(BeNil()) Expect(err).NotTo(HaveOccurred()) }) @@ -38,18 +39,36 @@ var _ = Describe("recorder.Provider", func() { // Invalid the config cfg1 := *cfg cfg1.Host = "invalid host" - _, err := recorder.NewProvider(&cfg1, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster) + _, err := recorder.NewProvider(&cfg1, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster()) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to init client")) }) }) + Describe("GetEventRecorderFor", func() { + It("should return a deprecated recorder instance.", func() { + provider, err := recorder.NewProvider(cfg, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster()) + Expect(err).NotTo(HaveOccurred()) + + recorder := provider.GetEventRecorderFor("test") + Expect(recorder).NotTo(BeNil()) + }) + }) Describe("GetEventRecorder", func() { It("should return a recorder instance.", func() { - provider, err := recorder.NewProvider(cfg, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster) + provider, err := recorder.NewProvider(cfg, httpClient, scheme.Scheme, logr.Discard(), makeBroadcaster()) Expect(err).NotTo(HaveOccurred()) - recorder := provider.GetEventRecorderFor("test") + recorder := provider.GetEventRecorder("test") Expect(recorder).NotTo(BeNil()) }) }) }) + +func makeBroadcaster() func() (record.EventBroadcaster, events.EventBroadcaster, bool) { + evtCl, err := eventsv1client.NewForConfigAndClient(cfg, httpClient) + Expect(err).NotTo(HaveOccurred()) + + return func() (record.EventBroadcaster, events.EventBroadcaster, bool) { + return record.NewBroadcaster(), events.NewBroadcaster(&events.EventSinkImpl{Interface: evtCl}), true + } +} diff --git a/pkg/internal/source/event_handler.go b/pkg/internal/source/event_handler.go index 7cc8c51555..9d614f34a5 100644 --- a/pkg/internal/source/event_handler.go +++ b/pkg/internal/source/event_handler.go @@ -60,7 +60,7 @@ type EventHandler[object client.Object, request comparable] struct { } // OnAdd creates CreateEvent and calls Create on EventHandler. -func (e *EventHandler[object, request]) OnAdd(obj interface{}, isInInitialList bool) { +func (e *EventHandler[object, request]) OnAdd(obj any, isInInitialList bool) { c := event.TypedCreateEvent[object]{ IsInInitialList: isInInitialList, } @@ -87,7 +87,7 @@ func (e *EventHandler[object, request]) OnAdd(obj interface{}, isInInitialList b } // OnUpdate creates UpdateEvent and calls Update on EventHandler. -func (e *EventHandler[object, request]) OnUpdate(oldObj, newObj interface{}) { +func (e *EventHandler[object, request]) OnUpdate(oldObj, newObj any) { u := event.TypedUpdateEvent[object]{} if o, ok := oldObj.(object); ok { @@ -120,7 +120,7 @@ func (e *EventHandler[object, request]) OnUpdate(oldObj, newObj interface{}) { } // OnDelete creates DeleteEvent and calls Delete on EventHandler. -func (e *EventHandler[object, request]) OnDelete(obj interface{}) { +func (e *EventHandler[object, request]) OnDelete(obj any) { d := event.TypedDeleteEvent[object]{} // Deal with tombstone events by pulling the object out. Tombstone events wrap the object in a diff --git a/pkg/internal/source/kind.go b/pkg/internal/source/kind.go index 2854244523..a28aeb177e 100644 --- a/pkg/internal/source/kind.go +++ b/pkg/internal/source/kind.go @@ -91,16 +91,23 @@ func (ks *Kind[object, request]) Start(ctx context.Context, queue workqueue.Type return } - _, err := i.AddEventHandlerWithOptions(NewEventHandler(ctx, queue, ks.Handler, ks.Predicates), toolscache.HandlerOptions{ + handlerRegistration, err := i.AddEventHandlerWithOptions(NewEventHandler(ctx, queue, ks.Handler, ks.Predicates), toolscache.HandlerOptions{ Logger: &logKind, }) if err != nil { ks.startedErr <- err return } + // First, wait for the cache to sync. For real caches this waits for startup. + // For fakes with Synced=false, this returns immediately allowing fast failure. if !ks.Cache.WaitForCacheSync(ctx) { - // Would be great to return something more informative here ks.startedErr <- errors.New("cache did not sync") + close(ks.startedErr) + return + } + // Then wait for this specific handler to receive all initial events. + if !toolscache.WaitForCacheSync(ctx.Done(), handlerRegistration.HasSynced) { + ks.startedErr <- errors.New("handler did not sync") } close(ks.startedErr) }() diff --git a/pkg/internal/testing/addr/manager.go b/pkg/internal/testing/addr/manager.go index ffa33a8861..e8f1f10d2a 100644 --- a/pkg/internal/testing/addr/manager.go +++ b/pkg/internal/testing/addr/manager.go @@ -124,14 +124,16 @@ func suggest(listenHost string) (*net.TCPListener, int, string, error) { // Suggest suggests an address a process can listen on. It returns // a tuple consisting of a free port and the hostname resolved to its IP. // It makes sure that new port allocated does not conflict with old ports -// allocated within 1 minute. +// allocated within 2 minute. func Suggest(listenHost string) (int, string, error) { - for i := 0; i < portConflictRetry; i++ { + for range portConflictRetry { listener, port, resolvedHost, err := suggest(listenHost) if err != nil { return -1, "", err } - defer listener.Close() + if err := listener.Close(); err != nil { + return -1, "", err + } if ok, err := cache.add(port); ok { return port, resolvedHost, nil } else if err != nil { diff --git a/pkg/internal/testing/certs/tinyca_test.go b/pkg/internal/testing/certs/tinyca_test.go index 5d84de56fb..11207a2930 100644 --- a/pkg/internal/testing/certs/tinyca_test.go +++ b/pkg/internal/testing/certs/tinyca_test.go @@ -21,7 +21,7 @@ import ( "encoding/pem" "math/big" "net" - "sort" + "slices" "time" . "github.com/onsi/ginkgo/v2" @@ -67,10 +67,11 @@ var _ = Describe("TinyCA", func() { secondCerts.Cert.SerialNumber, thirdCerts.Cert.SerialNumber, } - // quick uniqueness check of numbers: sort, then you only have to compare sequential entries - sort.Slice(serials, func(i, j int) bool { - return serials[i].Cmp(serials[j]) == -1 + // quick uniqueness check of numbers: slices sort, then you only have to compare sequential entries + slices.SortStableFunc(serials, func(i, j *big.Int) int { + return i.Cmp(j) }) + Expect(serials[1].Cmp(serials[0])).NotTo(Equal(0), "serials shouldn't be equal") Expect(serials[2].Cmp(serials[1])).NotTo(Equal(0), "serials shouldn't be equal") }) @@ -119,7 +120,7 @@ var _ = Describe("TinyCA", func() { localhostAddrs, err := net.LookupHost("localhost") Expect(err).NotTo(HaveOccurred(), "should be able to find IPs for localhost") - localhostIPs := make([]interface{}, len(localhostAddrs)) + localhostIPs := make([]any, len(localhostAddrs)) for i, addr := range localhostAddrs { // normalize the elements with To16 so we can compare them to the output of // of ParseIP safely (the alternative is a custom matcher that calls Equal, diff --git a/pkg/internal/testing/process/arguments.go b/pkg/internal/testing/process/arguments.go index 391eec1fac..caa417d2c2 100644 --- a/pkg/internal/testing/process/arguments.go +++ b/pkg/internal/testing/process/arguments.go @@ -19,14 +19,14 @@ package process import ( "bytes" "html/template" - "sort" + "slices" "strings" ) // RenderTemplates returns an []string to render the templates // // Deprecated: will be removed in favor of Arguments. -func RenderTemplates(argTemplates []string, data interface{}) (args []string, err error) { +func RenderTemplates(argTemplates []string, data any) (args []string, err error) { var t *template.Template for _, arg := range argTemplates { @@ -82,7 +82,7 @@ func SliceToArguments(sliceArgs []string, args *Arguments) []string { // Deprecated: will be removed when RenderTemplates is removed. type TemplateDefaults struct { // Data will be used to render the template. - Data interface{} + Data any // Defaults will be used to default structured arguments if no template is passed. Defaults map[string][]string // MinimalDefaults will be used to default structured arguments if a template is passed. @@ -230,7 +230,7 @@ func (a *Arguments) AsStrings(defaults map[string][]string) []string { for key := range a.values { keysInOrder = append(keysInOrder, key) } - sort.Strings(keysInOrder) + slices.Sort(keysInOrder) var res []string for _, key := range keysInOrder { diff --git a/pkg/internal/testing/process/procattr_other.go b/pkg/internal/testing/process/procattr_other.go index df13b341a4..e65ddc5f40 100644 --- a/pkg/internal/testing/process/procattr_other.go +++ b/pkg/internal/testing/process/procattr_other.go @@ -1,5 +1,4 @@ //go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !zos -// +build !aix,!darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!solaris,!zos /* Copyright 2016 The Kubernetes Authors. diff --git a/pkg/internal/testing/process/procattr_unix.go b/pkg/internal/testing/process/procattr_unix.go index 83ad509af0..2bdf0c7c47 100644 --- a/pkg/internal/testing/process/procattr_unix.go +++ b/pkg/internal/testing/process/procattr_unix.go @@ -1,5 +1,4 @@ //go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos -// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris zos /* Copyright 2023 The Kubernetes Authors. diff --git a/pkg/leaderelection/leader_election.go b/pkg/leaderelection/leader_election.go index 6c013e7992..7f59d82897 100644 --- a/pkg/leaderelection/leader_election.go +++ b/pkg/leaderelection/leader_election.go @@ -103,10 +103,7 @@ func NewResourceLock(config *rest.Config, recorderProvider recorder.Provider, op // RenewDeadline to keep a single hung request from forcing a leader loss. // Setting it to max(time.Second, RenewDeadline/2) as a reasonable heuristic. if options.RenewDeadline != 0 { - timeout := options.RenewDeadline / 2 - if timeout < time.Second { - timeout = time.Second - } + timeout := max(options.RenewDeadline/2, time.Second) config.Timeout = timeout } @@ -127,8 +124,10 @@ func NewResourceLock(config *rest.Config, recorderProvider recorder.Provider, op corev1Client, coordinationClient, resourcelock.ResourceLockConfig{ - Identity: id, - EventRecorder: recorderProvider.GetEventRecorderFor(id), + Identity: id, + // TODO(clebs): Replace with the new events API after leader election is updated upstream. + // REF: https://github.com/kubernetes/kubernetes/issues/82846 + EventRecorder: recorderProvider.GetEventRecorderFor(id), //nolint:staticcheck }, options.LeaderLabels, ) diff --git a/pkg/log/deleg.go b/pkg/log/deleg.go index 6eb551d3b6..948330b01d 100644 --- a/pkg/log/deleg.go +++ b/pkg/log/deleg.go @@ -30,7 +30,7 @@ type loggerPromise struct { promisesLock sync.Mutex name *string - tags []interface{} + tags []any } func (p *loggerPromise) WithName(l *delegatingLogSink, name string) *loggerPromise { @@ -47,7 +47,7 @@ func (p *loggerPromise) WithName(l *delegatingLogSink, name string) *loggerPromi } // WithValues provides a new Logger with the tags appended. -func (p *loggerPromise) WithValues(l *delegatingLogSink, tags ...interface{}) *loggerPromise { +func (p *loggerPromise) WithValues(l *delegatingLogSink, tags ...any) *loggerPromise { res := &loggerPromise{ logger: l, tags: tags, @@ -120,7 +120,7 @@ func (l *delegatingLogSink) Enabled(level int) bool { // the log line. The key/value pairs can then be used to add additional // variable information. The key/value pairs should alternate string // keys and arbitrary values. -func (l *delegatingLogSink) Info(level int, msg string, keysAndValues ...interface{}) { +func (l *delegatingLogSink) Info(level int, msg string, keysAndValues ...any) { eventuallyFulfillRoot() l.lock.RLock() defer l.lock.RUnlock() @@ -135,7 +135,7 @@ func (l *delegatingLogSink) Info(level int, msg string, keysAndValues ...interfa // The msg field should be used to add context to any underlying error, // while the err field should be used to attach the actual error that // triggered this log line, if present. -func (l *delegatingLogSink) Error(err error, msg string, keysAndValues ...interface{}) { +func (l *delegatingLogSink) Error(err error, msg string, keysAndValues ...any) { eventuallyFulfillRoot() l.lock.RLock() defer l.lock.RUnlock() @@ -164,7 +164,7 @@ func (l *delegatingLogSink) WithName(name string) logr.LogSink { } // WithValues provides a new Logger with the tags appended. -func (l *delegatingLogSink) WithValues(tags ...interface{}) logr.LogSink { +func (l *delegatingLogSink) WithValues(tags ...any) logr.LogSink { eventuallyFulfillRoot() l.lock.RLock() defer l.lock.RUnlock() diff --git a/pkg/log/log.go b/pkg/log/log.go index ade21d6fb5..48a4d490d3 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -88,7 +88,7 @@ var ( ) // FromContext returns a logger with predefined values from a context.Context. -func FromContext(ctx context.Context, keysAndValues ...interface{}) logr.Logger { +func FromContext(ctx context.Context, keysAndValues ...any) logr.Logger { log := Log if ctx != nil { if logger, err := logr.FromContext(ctx); err == nil { diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go index 404a859e4d..f56aef3706 100644 --- a/pkg/log/log_test.go +++ b/pkg/log/log_test.go @@ -29,7 +29,7 @@ var _ logr.LogSink = &delegatingLogSink{} // logInfo is the information for a particular fakeLogger message. type logInfo struct { name []string - tags []interface{} + tags []any msg string } @@ -43,7 +43,7 @@ type fakeLoggerRoot struct { // just records the name. type fakeLogger struct { name []string - tags []interface{} + tags []any root *fakeLoggerRoot } @@ -61,8 +61,8 @@ func (f *fakeLogger) WithName(name string) logr.LogSink { } } -func (f *fakeLogger) WithValues(vals ...interface{}) logr.LogSink { - tags := append([]interface{}(nil), f.tags...) +func (f *fakeLogger) WithValues(vals ...any) logr.LogSink { + tags := append([]any(nil), f.tags...) tags = append(tags, vals...) return &fakeLogger{ name: f.name, @@ -71,8 +71,8 @@ func (f *fakeLogger) WithValues(vals ...interface{}) logr.LogSink { } } -func (f *fakeLogger) Error(err error, msg string, vals ...interface{}) { - tags := append([]interface{}(nil), f.tags...) +func (f *fakeLogger) Error(err error, msg string, vals ...any) { + tags := append([]any(nil), f.tags...) tags = append(tags, "error", err) tags = append(tags, vals...) f.root.messages = append(f.root.messages, logInfo{ @@ -82,8 +82,8 @@ func (f *fakeLogger) Error(err error, msg string, vals ...interface{}) { }) } -func (f *fakeLogger) Info(level int, msg string, vals ...interface{}) { - tags := append([]interface{}(nil), f.tags...) +func (f *fakeLogger) Info(level int, msg string, vals ...any) { + tags := append([]any(nil), f.tags...) tags = append(tags, vals...) f.root.messages = append(f.root.messages, logInfo{ name: append([]string(nil), f.name...), @@ -113,8 +113,8 @@ var _ = Describe("logging", func() { By("ensuring that messages after the logger was set were logged") Expect(logger.root.messages).To(ConsistOf( - logInfo{name: []string{"runtimeLog"}, tags: []interface{}{"newtag", "newvalue1"}, msg: "after msg 1"}, - logInfo{name: []string{"runtimeLog"}, tags: []interface{}{"newtag", "newvalue2"}, msg: "after msg 2"}, + logInfo{name: []string{"runtimeLog"}, tags: []any{"newtag", "newvalue1"}, msg: "after msg 1"}, + logInfo{name: []string{"runtimeLog"}, tags: []any{"newtag", "newvalue2"}, msg: "after msg 2"}, )) }) }) @@ -259,10 +259,10 @@ var _ = Describe("logging", func() { By("ensuring that the messages are appropriately named") Expect(root.messages).To(ConsistOf( - logInfo{tags: []interface{}{"tag1", "val1"}, msg: "after 1"}, - logInfo{tags: []interface{}{"tag1", "val1", "tag2", "val2"}, msg: "after 2"}, - logInfo{tags: []interface{}{"tag1", "val1", "tag3", "val3"}, msg: "after 3"}, - logInfo{tags: []interface{}{"tag3", "val3"}, msg: "after 4"}, + logInfo{tags: []any{"tag1", "val1"}, msg: "after 1"}, + logInfo{tags: []any{"tag1", "val1", "tag2", "val2"}, msg: "after 2"}, + logInfo{tags: []any{"tag1", "val1", "tag3", "val3"}, msg: "after 3"}, + logInfo{tags: []any{"tag3", "val3"}, msg: "after 4"}, )) }) @@ -329,7 +329,7 @@ var _ = Describe("logging", func() { gotLog.Info("test message") Expect(root.messages).To(ConsistOf( - logInfo{name: []string{"my-logger"}, tags: []interface{}{"tag1", "value1"}, msg: "test message"}, + logInfo{name: []string{"my-logger"}, tags: []any{"tag1", "value1"}, msg: "test message"}, )) }) }) diff --git a/pkg/log/null.go b/pkg/log/null.go index f3e81074fe..f8dd84ca65 100644 --- a/pkg/log/null.go +++ b/pkg/log/null.go @@ -34,7 +34,7 @@ func (log NullLogSink) Init(logr.RuntimeInfo) { } // Info implements logr.InfoLogger. -func (NullLogSink) Info(_ int, _ string, _ ...interface{}) { +func (NullLogSink) Info(_ int, _ string, _ ...any) { // Do nothing. } @@ -44,7 +44,7 @@ func (NullLogSink) Enabled(level int) bool { } // Error implements logr.Logger. -func (NullLogSink) Error(_ error, _ string, _ ...interface{}) { +func (NullLogSink) Error(_ error, _ string, _ ...any) { // Do nothing. } @@ -54,6 +54,6 @@ func (log NullLogSink) WithName(_ string) logr.LogSink { } // WithValues implements logr.Logger. -func (log NullLogSink) WithValues(_ ...interface{}) logr.LogSink { +func (log NullLogSink) WithValues(_ ...any) logr.LogSink { return log } diff --git a/pkg/log/zap/flags.go b/pkg/log/zap/flags.go index 2c88ad42ab..4ebac57dcb 100644 --- a/pkg/log/zap/flags.go +++ b/pkg/log/zap/flags.go @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package zap contains helpers for setting up a new logr.Logger instance -// using the Zap logging framework. package zap import ( diff --git a/pkg/log/zap/zap_test.go b/pkg/log/zap/zap_test.go index 3e80113a65..1ff5e902d9 100644 --- a/pkg/log/zap/zap_test.go +++ b/pkg/log/zap/zap_test.go @@ -54,7 +54,7 @@ func (w *fakeSyncWriter) Sync() error { // logInfo is the information for a particular fakeLogger message. type logInfo struct { name []string - tags []interface{} + tags []any msg string } @@ -70,7 +70,7 @@ var _ logr.LogSink = &fakeLogger{} // just records the name. type fakeLogger struct { name []string - tags []interface{} + tags []any root *fakeLoggerRoot } @@ -88,8 +88,8 @@ func (f *fakeLogger) WithName(name string) logr.LogSink { } } -func (f *fakeLogger) WithValues(vals ...interface{}) logr.LogSink { - tags := append([]interface{}(nil), f.tags...) +func (f *fakeLogger) WithValues(vals ...any) logr.LogSink { + tags := append([]any(nil), f.tags...) tags = append(tags, vals...) return &fakeLogger{ name: f.name, @@ -98,8 +98,8 @@ func (f *fakeLogger) WithValues(vals ...interface{}) logr.LogSink { } } -func (f *fakeLogger) Error(err error, msg string, vals ...interface{}) { - tags := append([]interface{}(nil), f.tags...) +func (f *fakeLogger) Error(err error, msg string, vals ...any) { + tags := append([]any(nil), f.tags...) tags = append(tags, "error", err) tags = append(tags, vals...) f.root.messages = append(f.root.messages, logInfo{ @@ -109,8 +109,8 @@ func (f *fakeLogger) Error(err error, msg string, vals ...interface{}) { }) } -func (f *fakeLogger) Info(level int, msg string, vals ...interface{}) { - tags := append([]interface{}(nil), f.tags...) +func (f *fakeLogger) Info(level int, msg string, vals ...any) { + tags := append([]any(nil), f.tags...) tags = append(tags, vals...) f.root.messages = append(f.root.messages, logInfo{ name: append([]string(nil), f.name...), @@ -159,10 +159,10 @@ var _ = Describe("Zap logger setup", func() { logger.Info("here's a kubernetes object", "thing", pod) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) - Expect(res).To(HaveKeyWithValue("thing", map[string]interface{}{ + Expect(res).To(HaveKeyWithValue("thing", map[string]any{ "name": pod.Name, "namespace": pod.Namespace, })) @@ -171,7 +171,7 @@ var _ = Describe("Zap logger setup", func() { It("should work fine with normal stringers", func() { logger.Info("here's a non-kubernetes stringer", "thing", testStringer{}) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) Expect(res).To(HaveKeyWithValue("thing", "value")) @@ -183,10 +183,10 @@ var _ = Describe("Zap logger setup", func() { logger.Info("here's a kubernetes object", "thing", node) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) - Expect(res).To(HaveKeyWithValue("thing", map[string]interface{}{ + Expect(res).To(HaveKeyWithValue("thing", map[string]any{ "name": node.Name, })) }) @@ -199,10 +199,10 @@ var _ = Describe("Zap logger setup", func() { logger.Info("here's a kubernetes object", "thing", node) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) - Expect(res).To(HaveKeyWithValue("thing", map[string]interface{}{ + Expect(res).To(HaveKeyWithValue("thing", map[string]any{ "name": node.Name, "apiVersion": "v1", "kind": "Node", @@ -214,18 +214,18 @@ var _ = Describe("Zap logger setup", func() { logger.Info("here's a kubernetes object", "thing", name) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) - Expect(res).To(HaveKeyWithValue("thing", map[string]interface{}{ + Expect(res).To(HaveKeyWithValue("thing", map[string]any{ "name": name.Name, })) }) It("should log an unstructured Kubernetes object", func() { pod := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": "some-pod", "namespace": "some-ns", }, @@ -234,10 +234,10 @@ var _ = Describe("Zap logger setup", func() { logger.Info("here's a kubernetes object", "thing", pod) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) - Expect(res).To(HaveKeyWithValue("thing", map[string]interface{}{ + Expect(res).To(HaveKeyWithValue("thing", map[string]any{ "name": "some-pod", "namespace": "some-ns", })) @@ -248,10 +248,10 @@ var _ = Describe("Zap logger setup", func() { logger.Info("here's a kubernetes object", "thing", name) outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) - Expect(res).To(HaveKeyWithValue("thing", map[string]interface{}{ + Expect(res).To(HaveKeyWithValue("thing", map[string]any{ "name": name.Name, "namespace": name.Namespace, })) @@ -550,7 +550,7 @@ var _ = Describe("Zap log level flag options setup", func() { outRaw := logOut.Bytes() - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) Expect(res["ts"]).Should(MatchRegexp(iso8601Pattern)) }) @@ -569,7 +569,7 @@ var _ = Describe("Zap log level flag options setup", func() { log.Info("This is a test message") outRaw := logOut.Bytes() // Assert for JSON Encoder - res := map[string]interface{}{} + res := map[string]any{} Expect(json.Unmarshal(outRaw, &res)).To(Succeed()) // Assert for MessageKey Expect(string(outRaw)).Should(ContainSubstring("MillisTimeFormat")) diff --git a/pkg/manager/internal.go b/pkg/manager/internal.go index a9f91cbdd5..187d4f56c2 100644 --- a/pkg/manager/internal.go +++ b/pkg/manager/internal.go @@ -32,9 +32,11 @@ import ( "k8s.io/apimachinery/pkg/runtime" kerrors "k8s.io/apimachinery/pkg/util/errors" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/leaderelection" "k8s.io/client-go/tools/leaderelection/resourcelock" "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/webhook/conversion" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" @@ -129,6 +131,9 @@ type controllerManager struct { // webhookServer if unset, and Add() it to controllerManager. webhookServerOnce sync.Once + // converterRegistry stores conversion.Converter for the conversion endpoint. + converterRegistry conversion.Registry + // leaderElectionID is the name of the resource that leader election // will use for holding the leader lock. leaderElectionID string @@ -256,7 +261,11 @@ func (cm *controllerManager) GetCache() cache.Cache { } func (cm *controllerManager) GetEventRecorderFor(name string) record.EventRecorder { - return cm.cluster.GetEventRecorderFor(name) + return cm.cluster.GetEventRecorderFor(name) //nolint:staticcheck +} + +func (cm *controllerManager) GetEventRecorder(name string) events.EventRecorder { + return cm.cluster.GetEventRecorder(name) } func (cm *controllerManager) GetRESTMapper() meta.RESTMapper { @@ -279,6 +288,10 @@ func (cm *controllerManager) GetWebhookServer() webhook.Server { return cm.webhookServer } +func (cm *controllerManager) GetConverterRegistry() conversion.Registry { + return cm.converterRegistry +} + func (cm *controllerManager) GetLogger() logr.Logger { return cm.logger } @@ -446,13 +459,16 @@ func (cm *controllerManager) Start(ctx context.Context) (err error) { // Start the leader election and all required runnables. { - ctx, cancel := context.WithCancel(context.Background()) + // Create a context that inherits all keys from the parent context + // but can be cancelled independently for leader election management + baseCtx := context.WithoutCancel(ctx) + leaderCtx, cancel := context.WithCancel(baseCtx) cm.leaderElectionCancel = cancel if leaderElector != nil { // Start the leader elector process go func() { - leaderElector.Run(ctx) - <-ctx.Done() + leaderElector.Run(leaderCtx) + <-leaderCtx.Done() close(cm.leaderElectionStopped) }() } else { diff --git a/pkg/manager/internal/integration/manager_test.go b/pkg/manager/internal/integration/manager_test.go index c83eead3c1..570c932abc 100644 --- a/pkg/manager/internal/integration/manager_test.go +++ b/pkg/manager/internal/integration/manager_test.go @@ -262,7 +262,7 @@ type ConversionWebhook struct { } func createConversionWebhook(mgr manager.Manager) *ConversionWebhook { - conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme()) + conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme(), mgr.GetConverterRegistry()) httpClient := http.Client{ // Setting a timeout to not get stuck when calling the readiness probe. Timeout: 5 * time.Second, diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index e0e94245e7..af532ea741 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -29,11 +29,14 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" + eventsv1client "k8s.io/client-go/kubernetes/typed/events/v1" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/leaderelection/resourcelock" "k8s.io/client-go/tools/record" "k8s.io/utils/ptr" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook/conversion" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" @@ -95,6 +98,10 @@ type Manager interface { // GetControllerOptions returns controller global configuration options. GetControllerOptions() config.Controller + + // GetConverterRegistry returns the converter registry that is used to store conversion.Converter + // for the conversion endpoint. + GetConverterRegistry() conversion.Registry } // Options are the arguments for creating a new Manager. @@ -337,7 +344,10 @@ func New(config *rest.Config, options Options) (Manager, error) { return nil, errors.New("must specify Config") } // Set default values for options fields - options = setOptionsDefaults(options) + options, err := setOptionsDefaults(config, options) + if err != nil { + return nil, fmt.Errorf("failed setting manager default options: %w", err) + } cluster, err := cluster.New(config, func(clusterOptions *cluster.Options) { clusterOptions.Scheme = options.Scheme @@ -445,6 +455,7 @@ func New(config *rest.Config, options Options) (Manager, error) { logger: options.Logger, elected: make(chan struct{}), webhookServer: options.WebhookServer, + converterRegistry: conversion.NewRegistry(), leaderElectionID: options.LeaderElectionID, leaseDuration: *options.LeaseDuration, renewDeadline: *options.RenewDeadline, @@ -493,7 +504,7 @@ func defaultBaseContext() context.Context { } // setOptionsDefaults set default values for Options fields. -func setOptionsDefaults(options Options) Options { +func setOptionsDefaults(config *rest.Config, options Options) (Options, error) { // Allow newResourceLock to be mocked if options.newResourceLock == nil { options.newResourceLock = leaderelection.NewResourceLock @@ -507,14 +518,25 @@ func setOptionsDefaults(options Options) Options { // This is duplicated with pkg/cluster, we need it here // for the leader election and there to provide the user with // an EventBroadcaster + httpClient, err := rest.HTTPClientFor(config) + if err != nil { + return options, err + } + + evtCl, err := eventsv1client.NewForConfigAndClient(config, httpClient) + if err != nil { + return options, err + } + if options.EventBroadcaster == nil { // defer initialization to avoid leaking by default - options.makeBroadcaster = func() (record.EventBroadcaster, bool) { - return record.NewBroadcaster(), true + options.makeBroadcaster = func() (record.EventBroadcaster, events.EventBroadcaster, bool) { + return record.NewBroadcaster(), events.NewBroadcaster(&events.EventSinkImpl{Interface: evtCl}), true } } else { - options.makeBroadcaster = func() (record.EventBroadcaster, bool) { - return options.EventBroadcaster, false + // keep supporting the options.EventBroadcaster in the old API, but do not introduce it for the new one. + options.makeBroadcaster = func() (record.EventBroadcaster, events.EventBroadcaster, bool) { + return options.EventBroadcaster, events.NewBroadcaster(&events.EventSinkImpl{Interface: evtCl}), false } } @@ -571,5 +593,5 @@ func setOptionsDefaults(options Options) Options { options.WebhookServer = webhook.NewServer(webhook.Options{}) } - return options + return options, nil } diff --git a/pkg/manager/manager_test.go b/pkg/manager/manager_test.go index 4363d62f59..4bf553572d 100644 --- a/pkg/manager/manager_test.go +++ b/pkg/manager/manager_test.go @@ -36,7 +36,10 @@ import ( "go.uber.org/goleak" coordinationv1 "k8s.io/api/coordination/v1" corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/rest" @@ -60,7 +63,6 @@ var _ = Describe("manger.Manager", func() { m, err := New(nil, Options{}) Expect(m).To(BeNil()) Expect(err.Error()).To(ContainSubstring("must specify Config")) - }) It("should return an error if it can't create a RestMapper", func() { @@ -70,7 +72,6 @@ var _ = Describe("manger.Manager", func() { }) Expect(m).To(BeNil()) Expect(err).To(Equal(expected)) - }) It("should return an error it can't create a client.Client", func() { @@ -207,7 +208,6 @@ var _ = Describe("manger.Manager", func() { } // Don't leak routines <-mgrDone - }) It("should disable gracefulShutdown when stopping to lead", func(ctx SpecContext) { m, err := New(cfg, Options{ @@ -443,7 +443,6 @@ var _ = Describe("manger.Manager", func() { Expect(ok).To(BeTrue()) _, isLeaseLock := cm.resourceLock.(*resourcelock.LeaseLock) Expect(isLeaseLock).To(BeTrue()) - }) It("should use the specified ResourceLock", func() { m, err := New(cfg, Options{ @@ -671,7 +670,7 @@ var _ = Describe("manger.Manager", func() { }) Describe("Start", func() { - var startSuite = func(options Options, callbacks ...func(Manager)) { + startSuite := func(options Options, callbacks ...func(Manager)) { It("should Start each Component", func(ctx SpecContext) { m, err := New(cfg, options) Expect(err).NotTo(HaveOccurred()) @@ -1256,7 +1255,6 @@ var _ = Describe("manger.Manager", func() { <-managerStopDone Expect(time.Since(beforeDone)).To(BeNumerically(">=", 1500*time.Millisecond)) }) - } Context("with defaults", func() { @@ -1790,7 +1788,6 @@ var _ = Describe("manger.Manager", func() { err = m.Start(ctx) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(Equal("manager already started")) - }) }) @@ -1810,7 +1807,7 @@ var _ = Describe("manger.Manager", func() { Eventually(func() error { return goleak.Find(currentGRs) }).Should(Succeed()) }) - It("should not leak goroutines if the default event broadcaster is used & events are emitted", func(specCtx SpecContext) { + It("should not leak goroutines if the deprecated event broadcaster is used & events are emitted", func(specCtx SpecContext) { currentGRs := goleak.IgnoreCurrent() m, err := New(cfg, Options{ /* implicit: default setting for EventBroadcaster */ }) @@ -1820,7 +1817,7 @@ var _ = Describe("manger.Manager", func() { ns := corev1.Namespace{} ns.Name = "default" - recorder := m.GetEventRecorderFor("rock-and-roll") + recorder := m.GetEventRecorderFor("rock-and-roll") //nolint:staticcheck Expect(m.Add(RunnableFunc(func(_ context.Context) error { recorder.Event(&ns, "Warning", "BallroomBlitz", "yeah, yeah, yeah-yeah-yeah") return nil @@ -1858,6 +1855,60 @@ var _ = Describe("manger.Manager", func() { Eventually(func() error { return goleak.Find(currentGRs) }).Should(Succeed()) }) + It("should not leak goroutines if the default event broadcaster is used & events are emitted", func(specCtx SpecContext) { + currentGRs := goleak.IgnoreCurrent() + + m, err := New(cfg, Options{ /* implicit: default setting for EventBroadcaster */ }) + Expect(err).NotTo(HaveOccurred()) + + By("adding a runnable that emits an event") + ns := corev1.Namespace{} + ns.Name = "default" + + recorder := m.GetEventRecorder("rock-and-roll") + Expect(m.Add(RunnableFunc(func(_ context.Context) error { + recorder.Eventf(&ns, nil, "Warning", "BallroomBlitz", "dance action", "yeah, yeah, yeah-yeah-yeah") + return nil + }))).To(Succeed()) + + By("starting the manager & waiting till we've sent our event") + ctx, cancel := context.WithCancel(specCtx) + doneCh := make(chan struct{}) + go func() { + defer GinkgoRecover() + defer close(doneCh) + Expect(m.Start(ctx)).To(Succeed()) + }() + <-m.Elected() + + Eventually(func() *eventsv1.Event { + evts, err := clientset.EventsV1().Events("").List(ctx, + metav1.ListOptions{FieldSelector: fields.OneTermEqualSelector("regarding.name", ns.Name).String()}) + Expect(err).NotTo(HaveOccurred()) + + for i, evt := range evts.Items { + if evt.Reason == "BallroomBlitz" { + return &evts.Items[i] + } + } + return nil + }).ShouldNot(BeNil()) + + // Sleep between broadcasting start and shutdown to prevent a race condition + // that causes goroutines to leak. + // See pkg/internal/recorder/recorder.go:103 for more info. + time.Sleep(3 * time.Second) + + By("making sure there's no extra go routines still running after we stop") + cancel() + <-doneCh + + // force-close keep-alive connections. These'll time anyway (after + // like 30s or so) but force it to speed up the tests. + clientTransport.CloseIdleConnections() + Eventually(func() error { return goleak.Find(currentGRs) }).Should(Succeed()) + }) + It("should not leak goroutines when a runnable returns error slowly after being signaled to stop", func(specCtx SpecContext) { // This test reproduces the race condition where the manager's Start method // exits due to context cancellation, leaving no one to drain errChan @@ -1872,7 +1923,7 @@ var _ = Describe("manger.Manager", func() { Expect(err).NotTo(HaveOccurred()) // Add the slow runnable that will return an error after some delay - for i := 0; i < 3; i++ { + for range 3 { slowRunnable := RunnableFunc(func(c context.Context) error { <-c.Done() @@ -1938,10 +1989,15 @@ var _ = Describe("manger.Manager", func() { Expect(m.GetFieldIndexer()).To(Equal(mgr.cluster.GetFieldIndexer())) }) + It("should provide a function to get the deprecated EventRecorder", func() { + m, err := New(cfg, Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(m.GetEventRecorderFor("test")).NotTo(BeNil()) //nolint:staticcheck + }) It("should provide a function to get the EventRecorder", func() { m, err := New(cfg, Options{}) Expect(err).NotTo(HaveOccurred()) - Expect(m.GetEventRecorderFor("test")).NotTo(BeNil()) + Expect(m.GetEventRecorder("test")).NotTo(BeNil()) }) It("should provide a function to get the APIReader", func() { m, err := New(cfg, Options{}) @@ -2020,8 +2076,7 @@ var _ = Describe("manger.Manager", func() { }) }) -type runnableError struct { -} +type runnableError struct{} func (runnableError) Error() string { return "not feeling like that" diff --git a/pkg/manager/runnable_group_test.go b/pkg/manager/runnable_group_test.go index 6f9b879e0e..9a278a1495 100644 --- a/pkg/manager/runnable_group_test.go +++ b/pkg/manager/runnable_group_test.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "sync/atomic" + "testing" + "testing/synctest" "time" . "github.com/onsi/ginkgo/v2" @@ -110,30 +112,6 @@ var _ = Describe("runnables", func() { Expect(r.Others.startQueue).To(BeEmpty()) }) - It("should execute the Warmup function when Warmup group is started", func(ctx SpecContext) { - var warmupExecuted atomic.Bool - - warmupRunnable := newWarmupRunnableFunc( - func(c context.Context) error { - <-c.Done() - return nil - }, - func(c context.Context) error { - warmupExecuted.Store(true) - return nil - }, - ) - - r := newRunnables(defaultBaseContext, errCh) - Expect(r.Add(warmupRunnable)).To(Succeed()) - - // Start the Warmup group - Expect(r.Warmup.Start(ctx)).To(Succeed()) - - // Verify warmup function was called - Expect(warmupExecuted.Load()).To(BeTrue()) - }) - It("should propagate errors from Warmup function to error channel", func(ctx SpecContext) { expectedErr := fmt.Errorf("expected warmup error") @@ -203,7 +181,7 @@ var _ = Describe("runnableGroup", func() { Expect(rg.Start(ctx)).To(Succeed()) }() - for i := 0; i < 20; i++ { + for i := range 20 { go func(i int) { defer GinkgoRecover() @@ -221,7 +199,7 @@ var _ = Describe("runnableGroup", func() { exited := ptr.To(int64(0)) rg := newRunnableGroup(defaultBaseContext, errCh) - for i := 0; i < 10; i++ { + for i := range 10 { Expect(rg.Add(RunnableFunc(func(c context.Context) error { defer atomic.AddInt64(exited, 1) <-ctx.Done() @@ -253,7 +231,7 @@ var _ = Describe("runnableGroup", func() { Expect(rg.Start(ctx)).To(Succeed()) }() - for i := 0; i < 20; i++ { + for i := range 20 { go func(i int) { defer GinkgoRecover() @@ -286,7 +264,7 @@ var _ = Describe("runnableGroup", func() { rg.StopAndWait(ctx) }() - for i := 0; i < 200; i++ { + for i := range 200 { go func(i int) { defer GinkgoRecover() @@ -315,7 +293,7 @@ var _ = Describe("runnableGroup", func() { Expect(rg.Start(ctx)).To(Succeed()) }() - for i := 0; i < 20; i++ { + for i := range 20 { go func(i int) { defer GinkgoRecover() @@ -384,3 +362,33 @@ func newLeaderElectionAndWarmupRunnable( func (r leaderElectionAndWarmupRunnable) NeedLeaderElection() bool { return r.needLeaderElection } + +func TestWarmupFunctionIsExecutedWhenWarmupGroupIsStarted(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + var warmupExecuted atomic.Bool + + warmupRunnable := newWarmupRunnableFunc( + func(c context.Context) error { + <-c.Done() + return nil + }, + func(c context.Context) error { + warmupExecuted.Store(true) + return nil + }, + ) + + r := newRunnables(defaultBaseContext, make(chan error)) + g.Expect(r.Add(warmupRunnable)).To(Succeed()) + + // Start the Warmup group + g.Expect(r.Warmup.Start(t.Context())).To(Succeed()) + synctest.Wait() + + // Verify warmup function was called + g.Expect(warmupExecuted.Load()).To(BeTrue()) + r.Warmup.StopAndWait(t.Context()) + }) +} diff --git a/pkg/manager/signals/signal_posix.go b/pkg/manager/signals/signal_posix.go index a0f00a7321..2b24faa428 100644 --- a/pkg/manager/signals/signal_posix.go +++ b/pkg/manager/signals/signal_posix.go @@ -1,5 +1,4 @@ //go:build !windows -// +build !windows /* Copyright 2017 The Kubernetes Authors. diff --git a/pkg/manager/signals/signal_test.go b/pkg/manager/signals/signal_test.go index 134937e012..f2e3c4d02d 100644 --- a/pkg/manager/signals/signal_test.go +++ b/pkg/manager/signals/signal_test.go @@ -69,7 +69,7 @@ func (t *Task) Run(c chan os.Signal) { } func handle() { - for i := 0; i < 5; i++ { + for range 5 { fmt.Print("#") time.Sleep(time.Millisecond * 100) } diff --git a/pkg/reconcile/reconcile.go b/pkg/reconcile/reconcile.go index c98b1864ef..88303ae781 100644 --- a/pkg/reconcile/reconcile.go +++ b/pkg/reconcile/reconcile.go @@ -44,6 +44,11 @@ type Result struct { // RequeueAfter if greater than 0, tells the Controller to requeue the reconcile key after the Duration. // Implies that Requeue is true, there is no need to set Requeue to true at the same time as RequeueAfter. RequeueAfter time.Duration + + // Priority is the priority that will be used if the item gets re-enqueued (also if an error is returned). + // If Priority is not set the original Priority of the request is preserved. + // Note: Priority is only respected if the controller is using a priorityqueue.PriorityQueue. + Priority *int } // IsZero returns true if this result is empty. @@ -174,7 +179,7 @@ type terminalError struct { err error } -// This function will return nil if te.err is nil. +// Unwrap returns nil if te.err is nil. func (te *terminalError) Unwrap() error { return te.err } diff --git a/pkg/reconcile/reconcile_test.go b/pkg/reconcile/reconcile_test.go index bb5644b87c..27e9eab471 100644 --- a/pkg/reconcile/reconcile_test.go +++ b/pkg/reconcile/reconcile_test.go @@ -103,10 +103,10 @@ var _ = Describe("reconcile", func() { }) It("should allow unwrapping inner error from terminal error", func() { - inner := apierrors.NewGone("") + inner := apierrors.NewResourceExpired("") terminalError := reconcile.TerminalError(inner) - Expect(apierrors.IsGone(terminalError)).To(BeTrue()) + Expect(apierrors.IsResourceExpired(terminalError)).To(BeTrue()) }) It("should handle nil terminal errors properly", func() { diff --git a/pkg/recorder/example_test.go b/pkg/recorder/example_test.go index 969420d817..47f14ff715 100644 --- a/pkg/recorder/example_test.go +++ b/pkg/recorder/example_test.go @@ -18,31 +18,39 @@ package recorder_test import ( corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" _ "github.com/onsi/ginkgo/v2" "sigs.k8s.io/controller-runtime/pkg/recorder" ) var ( - recorderProvider recorder.Provider - somePod *corev1.Pod // the object you're reconciling, for example + recorderProvider recorder.Provider + somePod *corev1.Pod // the object you're reconciling, for example + someRelatedObject runtime.Object // another object related to the reconciled object and the event. ) func Example_event() { // recorderProvider is a recorder.Provider - recorder := recorderProvider.GetEventRecorderFor("my-controller") + deprecatedRecorder := recorderProvider.GetEventRecorderFor("my-controller") // emit an event with a fixed message - recorder.Event(somePod, corev1.EventTypeWarning, + deprecatedRecorder.Event(somePod, corev1.EventTypeWarning, "WrongTrousers", "It's the wrong trousers, Gromit!") } func Example_eventf() { // recorderProvider is a recorder.Provider - recorder := recorderProvider.GetEventRecorderFor("my-controller") + deprecatedRecorder := recorderProvider.GetEventRecorderFor("my-controller") // emit an event with a variable message mildCheese := "Wensleydale" - recorder.Eventf(somePod, corev1.EventTypeNormal, + deprecatedRecorder.Eventf(somePod, corev1.EventTypeNormal, "DislikesCheese", "Not even %s?", mildCheese) + + recorder := recorderProvider.GetEventRecorder("my-controller") + + // emit an event with a fixed message + recorder.Eventf(somePod, someRelatedObject, corev1.EventTypeWarning, + "WrongTrousers", "getting dressed", "It's the wrong trousers, Gromit!") } diff --git a/pkg/recorder/recorder.go b/pkg/recorder/recorder.go index f093f0a726..b34fecb525 100644 --- a/pkg/recorder/recorder.go +++ b/pkg/recorder/recorder.go @@ -21,11 +21,16 @@ limitations under the License. package recorder import ( + "k8s.io/client-go/tools/events" "k8s.io/client-go/tools/record" ) // Provider knows how to generate new event recorders with given name. type Provider interface { - // NewRecorder returns an EventRecorder with given name. + // GetEventRecorderFor returns an EventRecorder for the old events API. + // + // Deprecated: this uses the old events API and will be removed in a future release. Please use GetEventRecorder instead. GetEventRecorderFor(name string) record.EventRecorder + // GetEventRecorder returns a EventRecorder with given name. + GetEventRecorder(name string) events.EventRecorder } diff --git a/pkg/scheme/scheme_test.go b/pkg/scheme/scheme_test.go index 37c6766e6f..0f68fb0e2b 100644 --- a/pkg/scheme/scheme_test.go +++ b/pkg/scheme/scheme_test.go @@ -41,8 +41,8 @@ var _ = Describe("Scheme", func() { internalGv := schema.GroupVersion{Group: "core", Version: "__internal"} emptyGv := schema.GroupVersion{Group: "", Version: "v1"} Expect(s.AllKnownTypes()).To(MatchAllKeys(Keys{ - gv.WithKind("Pod"): Equal(reflect.TypeOf(corev1.Pod{})), - gv.WithKind("PodList"): Equal(reflect.TypeOf(corev1.PodList{})), + gv.WithKind("Pod"): Equal(reflect.TypeFor[corev1.Pod]()), + gv.WithKind("PodList"): Equal(reflect.TypeFor[corev1.PodList]()), // Base types gv.WithKind("CreateOptions"): Ignore(), @@ -80,12 +80,12 @@ var _ = Describe("Scheme", func() { emptyGv := schema.GroupVersion{Group: "", Version: "v1"} Expect(s.AllKnownTypes()).To(MatchAllKeys(Keys{ // Types from b1 - gv1.WithKind("Pod"): Equal(reflect.TypeOf(corev1.Pod{})), - gv1.WithKind("PodList"): Equal(reflect.TypeOf(corev1.PodList{})), + gv1.WithKind("Pod"): Equal(reflect.TypeFor[corev1.Pod]()), + gv1.WithKind("PodList"): Equal(reflect.TypeFor[corev1.PodList]()), // Types from b2 - gv2.WithKind("Deployment"): Equal(reflect.TypeOf(appsv1.Deployment{})), - gv2.WithKind("DeploymentList"): Equal(reflect.TypeOf(appsv1.DeploymentList{})), + gv2.WithKind("Deployment"): Equal(reflect.TypeFor[appsv1.Deployment]()), + gv2.WithKind("DeploymentList"): Equal(reflect.TypeFor[appsv1.DeploymentList]()), // Base types gv1.WithKind("CreateOptions"): Ignore(), diff --git a/pkg/source/source_integration_test.go b/pkg/source/source_integration_test.go index cc0ba530ec..afe39c3bcf 100644 --- a/pkg/source/source_integration_test.go +++ b/pkg/source/source_integration_test.go @@ -41,7 +41,7 @@ var _ = Describe("Source", func() { var instance1, instance2 source.Source var obj client.Object var q workqueue.TypedRateLimitingInterface[reconcile.Request] - var c1, c2 chan interface{} + var c1, c2 chan any var ns string count := 0 @@ -59,8 +59,8 @@ var _ = Describe("Source", func() { workqueue.TypedRateLimitingQueueConfig[reconcile.Request]{ Name: "test", }) - c1 = make(chan interface{}) - c2 = make(chan interface{}) + c1 = make(chan any) + c2 = make(chan any) }) AfterEach(func(ctx SpecContext) { @@ -101,7 +101,7 @@ var _ = Describe("Source", func() { } // Create an event handler to verify the events - newHandler := func(c chan interface{}) handler.Funcs { + newHandler := func(c chan any) handler.Funcs { return handler.Funcs{ CreateFunc: func(ctx context.Context, evt event.CreateEvent, rli workqueue.TypedRateLimitingInterface[reconcile.Request]) { defer GinkgoRecover() diff --git a/pkg/webhook/admission/decode.go b/pkg/webhook/admission/decode.go index 55f1cafb5e..576262cf70 100644 --- a/pkg/webhook/admission/decode.go +++ b/pkg/webhook/admission/decode.go @@ -79,7 +79,7 @@ func (d *decoder) DecodeRaw(rawObj runtime.RawExtension, into runtime.Object) er } if unstructuredInto, isUnstructured := into.(runtime.Unstructured); isUnstructured { // unmarshal into unstructured's underlying object to avoid calling the decoder - var object map[string]interface{} + var object map[string]any if err := json.Unmarshal(rawObj.Raw, &object); err != nil { return err } diff --git a/pkg/webhook/admission/decode_test.go b/pkg/webhook/admission/decode_test.go index 130308800f..c8a8208f67 100644 --- a/pkg/webhook/admission/decode_test.go +++ b/pkg/webhook/admission/decode_test.go @@ -139,7 +139,7 @@ var _ = Describe("Admission Webhook Decoder", func() { Expect(decoder.Decode(req, &target)).To(Succeed()) By("sanity-checking the metadata on the output object") - Expect(target.Object["metadata"]).To(Equal(map[string]interface{}{ + Expect(target.Object["metadata"]).To(Equal(map[string]any{ "name": "foo", "namespace": "default", })) @@ -149,7 +149,7 @@ var _ = Describe("Admission Webhook Decoder", func() { Expect(decoder.DecodeRaw(req.Object, &target2)).To(Succeed()) By("sanity-checking the metadata on the output object") - Expect(target2.Object["metadata"]).To(Equal(map[string]interface{}{ + Expect(target2.Object["metadata"]).To(Equal(map[string]any{ "name": "foo", "namespace": "default", })) diff --git a/pkg/webhook/admission/defaulter_custom.go b/pkg/webhook/admission/defaulter_custom.go index a703cbd2c5..d946966d4e 100644 --- a/pkg/webhook/admission/defaulter_custom.go +++ b/pkg/webhook/admission/defaulter_custom.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "net/http" + "reflect" "slices" "gomodules.xyz/jsonpatch/v2" @@ -31,11 +32,16 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ) -// CustomDefaulter defines functions for setting defaults on resources. -type CustomDefaulter interface { - Default(ctx context.Context, obj runtime.Object) error +// Defaulter defines functions for setting defaults on resources. +type Defaulter[T runtime.Object] interface { + Default(ctx context.Context, obj T) error } +// CustomDefaulter defines functions for setting defaults on resources. +// +// Deprecated: CustomDefaulter is deprecated, use Defaulter instead +type CustomDefaulter = Defaulter[runtime.Object] + type defaulterOptions struct { removeUnknownOrOmitableFields bool } @@ -50,6 +56,29 @@ func DefaulterRemoveUnknownOrOmitableFields(o *defaulterOptions) { o.removeUnknownOrOmitableFields = true } +// WithDefaulter creates a new Webhook for a Defaulter interface. +func WithDefaulter[T runtime.Object](scheme *runtime.Scheme, defaulter Defaulter[T], opts ...DefaulterOption) *Webhook { + options := &defaulterOptions{} + for _, o := range opts { + o(options) + } + return &Webhook{ + Handler: &defaulterForType[T]{ + defaulter: defaulter, + decoder: NewDecoder(scheme), + removeUnknownOrOmitableFields: options.removeUnknownOrOmitableFields, + new: func() T { + var zero T + typ := reflect.TypeOf(zero) + if typ.Kind() == reflect.Ptr { + return reflect.New(typ.Elem()).Interface().(T) + } + return zero + }, + }, + } +} + // WithCustomDefaulter creates a new Webhook for a CustomDefaulter interface. func WithCustomDefaulter(scheme *runtime.Scheme, obj runtime.Object, defaulter CustomDefaulter, opts ...DefaulterOption) *Webhook { options := &defaulterOptions{} @@ -57,33 +86,30 @@ func WithCustomDefaulter(scheme *runtime.Scheme, obj runtime.Object, defaulter C o(options) } return &Webhook{ - Handler: &defaulterForType{ - object: obj, + Handler: &defaulterForType[runtime.Object]{ defaulter: defaulter, decoder: NewDecoder(scheme), removeUnknownOrOmitableFields: options.removeUnknownOrOmitableFields, + new: func() runtime.Object { return obj.DeepCopyObject() }, }, } } -type defaulterForType struct { - defaulter CustomDefaulter - object runtime.Object +type defaulterForType[T runtime.Object] struct { + defaulter Defaulter[T] decoder Decoder removeUnknownOrOmitableFields bool + new func() T } // Handle handles admission requests. -func (h *defaulterForType) Handle(ctx context.Context, req Request) Response { +func (h *defaulterForType[T]) Handle(ctx context.Context, req Request) Response { if h.decoder == nil { panic("decoder should never be nil") } if h.defaulter == nil { panic("defaulter should never be nil") } - if h.object == nil { - panic("object should never be nil") - } // Always skip when a DELETE operation received in custom mutation handler. if req.Operation == admissionv1.Delete { @@ -98,15 +124,15 @@ func (h *defaulterForType) Handle(ctx context.Context, req Request) Response { ctx = NewContextWithRequest(ctx, req) // Get the object in the request - obj := h.object.DeepCopyObject() + obj := h.new() if err := h.decoder.Decode(req, obj); err != nil { return Errored(http.StatusBadRequest, err) } // Keep a copy of the object if needed - var originalObj runtime.Object + var originalObj T if !h.removeUnknownOrOmitableFields { - originalObj = obj.DeepCopyObject() + originalObj = obj.DeepCopyObject().(T) } // Default the object @@ -131,7 +157,7 @@ func (h *defaulterForType) Handle(ctx context.Context, req Request) Response { return handlerResponse } -func (h *defaulterForType) dropSchemeRemovals(r Response, original runtime.Object, raw []byte) Response { +func (h *defaulterForType[T]) dropSchemeRemovals(r Response, original T, raw []byte) Response { const opRemove = "remove" if !r.Allowed || r.PatchType == nil { return r diff --git a/pkg/webhook/admission/validator_custom.go b/pkg/webhook/admission/validator_custom.go index ef1be52a8f..f8401571d0 100644 --- a/pkg/webhook/admission/validator_custom.go +++ b/pkg/webhook/admission/validator_custom.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "net/http" + "reflect" v1 "k8s.io/api/admission/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -30,54 +31,79 @@ import ( // Warnings represents warning messages. type Warnings []string -// CustomValidator defines functions for validating an operation. +// Validator defines functions for validating an operation. // The object to be validated is passed into methods as a parameter. -type CustomValidator interface { +type Validator[T runtime.Object] interface { // ValidateCreate validates the object on creation. // The optional warnings will be added to the response as warning messages. // Return an error if the object is invalid. - ValidateCreate(ctx context.Context, obj runtime.Object) (warnings Warnings, err error) + ValidateCreate(ctx context.Context, obj T) (warnings Warnings, err error) // ValidateUpdate validates the object on update. // The optional warnings will be added to the response as warning messages. // Return an error if the object is invalid. - ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings Warnings, err error) + ValidateUpdate(ctx context.Context, oldObj, newObj T) (warnings Warnings, err error) // ValidateDelete validates the object on deletion. // The optional warnings will be added to the response as warning messages. // Return an error if the object is invalid. - ValidateDelete(ctx context.Context, obj runtime.Object) (warnings Warnings, err error) + ValidateDelete(ctx context.Context, obj T) (warnings Warnings, err error) +} + +// CustomValidator defines functions for validating an operation. +// +// Deprecated: CustomValidator is deprecated, use Validator instead +type CustomValidator = Validator[runtime.Object] + +// WithValidator creates a new Webhook for validating the provided type. +func WithValidator[T runtime.Object](scheme *runtime.Scheme, validator Validator[T]) *Webhook { + return &Webhook{ + Handler: &validatorForType[T]{ + validator: validator, + decoder: NewDecoder(scheme), + new: func() T { + var zero T + typ := reflect.TypeOf(zero) + if typ.Kind() == reflect.Ptr { + return reflect.New(typ.Elem()).Interface().(T) + } + return zero + }, + }, + } } -// WithCustomValidator creates a new Webhook for validating the provided type. +// WithCustomValidator creates a new Webhook for a CustomValidator. +// +// Deprecated: WithCustomValidator is deprecated, use WithValidator instead func WithCustomValidator(scheme *runtime.Scheme, obj runtime.Object, validator CustomValidator) *Webhook { return &Webhook{ - Handler: &validatorForType{object: obj, validator: validator, decoder: NewDecoder(scheme)}, + Handler: &validatorForType[runtime.Object]{ + validator: validator, + decoder: NewDecoder(scheme), + new: func() runtime.Object { return obj.DeepCopyObject() }, + }, } } -type validatorForType struct { - validator CustomValidator - object runtime.Object +type validatorForType[T runtime.Object] struct { + validator Validator[T] decoder Decoder + new func() T } // Handle handles admission requests. -func (h *validatorForType) Handle(ctx context.Context, req Request) Response { +func (h *validatorForType[T]) Handle(ctx context.Context, req Request) Response { if h.decoder == nil { panic("decoder should never be nil") } if h.validator == nil { panic("validator should never be nil") } - if h.object == nil { - panic("object should never be nil") - } ctx = NewContextWithRequest(ctx, req) - // Get the object in the request - obj := h.object.DeepCopyObject() + obj := h.new() var err error var warnings []string @@ -93,7 +119,7 @@ func (h *validatorForType) Handle(ctx context.Context, req Request) Response { warnings, err = h.validator.ValidateCreate(ctx, obj) case v1.Update: - oldObj := obj.DeepCopyObject() + oldObj := h.new() if err := h.decoder.DecodeRaw(req.Object, obj); err != nil { return Errored(http.StatusBadRequest, err) } diff --git a/pkg/webhook/alias.go b/pkg/webhook/alias.go index 2882e7bab3..518d52f364 100644 --- a/pkg/webhook/alias.go +++ b/pkg/webhook/alias.go @@ -24,10 +24,14 @@ import ( // define some aliases for common bits of the webhook functionality // CustomDefaulter defines functions for setting defaults on resources. -type CustomDefaulter = admission.CustomDefaulter +// +// Deprecated: Use admission.Defaulter instead. +type CustomDefaulter = admission.CustomDefaulter //nolint:staticcheck // CustomValidator defines functions for validating an operation. -type CustomValidator = admission.CustomValidator +// +// Deprecated: Use admission.Validator instead. +type CustomValidator = admission.CustomValidator //nolint:staticcheck // AdmissionRequest defines the input for an admission handler. // It contains information to identify the object in diff --git a/pkg/webhook/conversion/conversion.go b/pkg/webhook/conversion/conversion.go index a26fa348bb..3f98fb7ba7 100644 --- a/pkg/webhook/conversion/conversion.go +++ b/pkg/webhook/conversion/conversion.go @@ -43,14 +43,15 @@ var ( log = logf.Log.WithName("conversion-webhook") ) -func NewWebhookHandler(scheme *runtime.Scheme) http.Handler { - return &webhook{scheme: scheme, decoder: NewDecoder(scheme)} +func NewWebhookHandler(scheme *runtime.Scheme, registry Registry) http.Handler { + return &webhook{scheme: scheme, decoder: NewDecoder(scheme), registry: registry} } // webhook implements a CRD conversion webhook HTTP handler. type webhook struct { - scheme *runtime.Scheme - decoder *Decoder + scheme *runtime.Scheme + decoder *Decoder + registry Registry } // ensure Webhook implements http.Handler @@ -119,7 +120,7 @@ func (wh *webhook) handleConvertRequest(ctx context.Context, req *apix.Conversio if err != nil { return nil, err } - err = wh.convertObject(src, dst) + err = wh.convertObject(ctx, src, dst) if err != nil { return nil, err } @@ -137,7 +138,7 @@ func (wh *webhook) handleConvertRequest(ctx context.Context, req *apix.Conversio // convertObject will convert given a src object to dst object. // Note(droot): couldn't find a way to reduce the cyclomatic complexity under 10 // without compromising readability, so disabling gocyclo linter -func (wh *webhook) convertObject(src, dst runtime.Object) error { +func (wh *webhook) convertObject(ctx context.Context, src, dst runtime.Object) error { srcGVK := src.GetObjectKind().GroupVersionKind() dstGVK := dst.GetObjectKind().GroupVersionKind() @@ -149,6 +150,10 @@ func (wh *webhook) convertObject(src, dst runtime.Object) error { return fmt.Errorf("conversion is not allowed between same type %T", src) } + if converter, ok := wh.registry.GetConverter(srcGVK.GroupKind()); ok { + return converter.ConvertObject(ctx, src, dst) + } + srcIsHub, dstIsHub := isHub(src), isHub(dst) srcIsConvertible, dstIsConvertible := isConvertible(src), isConvertible(dst) diff --git a/pkg/webhook/conversion/conversion_hubspoke.go b/pkg/webhook/conversion/conversion_hubspoke.go new file mode 100644 index 0000000000..b33af92ff4 --- /dev/null +++ b/pkg/webhook/conversion/conversion_hubspoke.go @@ -0,0 +1,173 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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. +*/ + +package conversion + +import ( + "context" + "fmt" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +func NewHubSpokeConverter[hubObject runtime.Object](hub hubObject, spokeConverter ...SpokeConverter[hubObject]) func(scheme *runtime.Scheme) (Converter, error) { + return func(scheme *runtime.Scheme) (Converter, error) { + hubGVK, err := apiutil.GVKForObject(hub, scheme) + if err != nil { + return nil, fmt.Errorf("failed to create hub spoke converter: failed to get GroupVersionKind for hub: %w", err) + } + allGVKs, err := objectGVKs(scheme, hub) + if err != nil { + return nil, fmt.Errorf("failed to create hub spoke converter for %s: %w", hubGVK.Kind, err) + } + spokeVersions := sets.New[string]() + for _, gvk := range allGVKs { + if gvk != hubGVK { + spokeVersions.Insert(gvk.Version) + } + } + + c := &hubSpokeConverter[hubObject]{ + scheme: scheme, + hubGVK: hubGVK, + spokeConverterByGVK: map[schema.GroupVersionKind]SpokeConverter[hubObject]{}, + } + + spokeConverterVersions := sets.New[string]() + for _, sc := range spokeConverter { + spokeGVK, err := apiutil.GVKForObject(sc.GetSpoke(), scheme) + if err != nil { + return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+ + "failed to get GroupVersionKind for spoke converter: %w", + hubGVK.Kind, err) + } + if hubGVK.GroupKind() != spokeGVK.GroupKind() { + return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+ + "spoke converter GroupKind %s does not match hub GroupKind %s", + hubGVK.Kind, spokeGVK.GroupKind(), hubGVK.GroupKind()) + } + + if _, ok := c.spokeConverterByGVK[spokeGVK]; ok { + return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+ + "duplicate spoke converter for version %s", + hubGVK.Kind, spokeGVK.Version) + } + c.spokeConverterByGVK[spokeGVK] = sc + spokeConverterVersions.Insert(spokeGVK.Version) + } + + if !spokeConverterVersions.Equal(spokeVersions) { + return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+ + "expected spoke converter for %s got spoke converter for %s", + hubGVK.Kind, sortAndJoin(spokeVersions), sortAndJoin(spokeConverterVersions)) + } + + return c, nil + } +} + +func sortAndJoin(set sets.Set[string]) string { + list := set.UnsortedList() + slices.Sort(list) + return strings.Join(list, ",") +} + +type hubSpokeConverter[hubObject runtime.Object] struct { + scheme *runtime.Scheme + hubGVK schema.GroupVersionKind + spokeConverterByGVK map[schema.GroupVersionKind]SpokeConverter[hubObject] +} + +func (c hubSpokeConverter[hubObject]) ConvertObject(ctx context.Context, src, dst runtime.Object) error { + srcGVK := src.GetObjectKind().GroupVersionKind() + dstGVK := dst.GetObjectKind().GroupVersionKind() + + if srcGVK.GroupKind() != dstGVK.GroupKind() { + return fmt.Errorf("src %T and dst %T does not belong to same API Group", src, dst) + } + + if srcGVK == dstGVK { + return fmt.Errorf("conversion is not allowed between same type %T", src) + } + + srcIsHub := c.hubGVK == srcGVK + dstIsHub := c.hubGVK == dstGVK + _, srcIsConvertible := c.spokeConverterByGVK[srcGVK] + _, dstIsConvertible := c.spokeConverterByGVK[dstGVK] + + switch { + case srcIsHub && dstIsConvertible: + return c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, src.(hubObject), dst) + case dstIsHub && srcIsConvertible: + return c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, dst.(hubObject)) + case srcIsConvertible && dstIsConvertible: + hub, err := c.scheme.New(c.hubGVK) + if err != nil { + return fmt.Errorf("failed to allocate an instance for GroupVersionKind %s: %w", c.hubGVK, err) + } + if err := c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, hub.(hubObject)); err != nil { + return fmt.Errorf("failed to convert spoke %s to hub %s : %w", srcGVK, c.hubGVK, err) + } + if err := c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, hub.(hubObject), dst); err != nil { + return fmt.Errorf("failed to convert hub %s to spoke %s : %w", c.hubGVK, dstGVK, err) + } + return nil + default: + return fmt.Errorf("failed to convert %s to %s: not convertible", srcGVK, dstGVK) + } +} + +type SpokeConverter[hubObject runtime.Object] interface { + GetSpoke() runtime.Object + ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error + ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error +} + +func NewSpokeConverter[hubObject, spokeObject client.Object]( + spoke spokeObject, + convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error, + convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error, +) SpokeConverter[hubObject] { + return &spokeConverter[hubObject, spokeObject]{ + spoke: spoke, + convertSpokeToHubFunc: convertSpokeToHubFunc, + convertHubToSpokeFunc: convertHubToSpokeFunc, + } +} + +type spokeConverter[hubObject, spokeObject runtime.Object] struct { + spoke spokeObject + convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error + convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error +} + +func (c spokeConverter[hubObject, spokeObject]) GetSpoke() runtime.Object { + return c.spoke +} + +func (c spokeConverter[hubObject, spokeObject]) ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error { + return c.convertHubToSpokeFunc(ctx, hub, spoke.(spokeObject)) +} + +func (c spokeConverter[hubObject, spokeObject]) ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error { + return c.convertSpokeToHubFunc(ctx, spoke.(spokeObject), hub) +} diff --git a/pkg/webhook/conversion/conversion_registry.go b/pkg/webhook/conversion/conversion_registry.go new file mode 100644 index 0000000000..6e68b5ffa6 --- /dev/null +++ b/pkg/webhook/conversion/conversion_registry.go @@ -0,0 +1,57 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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. +*/ + +package conversion + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type Converter interface { + ConvertObject(ctx context.Context, src, dst runtime.Object) error +} + +type Registry interface { + RegisterConverter(gk schema.GroupKind, converter Converter) error + GetConverter(gk schema.GroupKind) (Converter, bool) +} + +type registry struct { + converterByGK map[schema.GroupKind]Converter +} + +func NewRegistry() Registry { + return registry{ + converterByGK: map[schema.GroupKind]Converter{}, + } +} +func (r registry) RegisterConverter(gk schema.GroupKind, converter Converter) error { + if _, ok := r.converterByGK[gk]; ok { + return fmt.Errorf("failed to register Converter for GroupKind %s: converter already registered", gk) + } + + r.converterByGK[gk] = converter + return nil +} + +func (r registry) GetConverter(gk schema.GroupKind) (Converter, bool) { + c, ok := r.converterByGK[gk] + return c, ok +} diff --git a/pkg/webhook/conversion/conversion_test.go b/pkg/webhook/conversion/conversion_test.go index 489689bccb..046ab44ced 100644 --- a/pkg/webhook/conversion/conversion_test.go +++ b/pkg/webhook/conversion/conversion_test.go @@ -18,6 +18,7 @@ package conversion_test import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -25,7 +26,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - + appsv1 "k8s.io/api/apps/v1" appsv1beta1 "k8s.io/api/apps/v1beta1" apix "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,285 +40,370 @@ import ( jobsv3 "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/testdata/api/v3" ) -var _ = Describe("Conversion Webhook", func() { - - var respRecorder *httptest.ResponseRecorder - var decoder *conversion.Decoder - var scheme *runtime.Scheme - var wh http.Handler - - BeforeEach(func() { - respRecorder = &httptest.ResponseRecorder{ - Body: bytes.NewBuffer(nil), - } - - scheme = runtime.NewScheme() - Expect(kscheme.AddToScheme(scheme)).To(Succeed()) - Expect(jobsv1.AddToScheme(scheme)).To(Succeed()) - Expect(jobsv2.AddToScheme(scheme)).To(Succeed()) - Expect(jobsv3.AddToScheme(scheme)).To(Succeed()) - - decoder = conversion.NewDecoder(scheme) - wh = conversion.NewWebhookHandler(scheme) - }) - - doRequest := func(convReq *apix.ConversionReview) *apix.ConversionReview { - var payload bytes.Buffer +var _ = Describe("Conversion with Hub/ConvertTo/ConvertFrom methods", func() { + ConversionTest(false) +}) - Expect(json.NewEncoder(&payload).Encode(convReq)).Should(Succeed()) +var _ = Describe("Conversion with HubSpokeConverter", func() { + ConversionTest(true) +}) - convReview := &apix.ConversionReview{} - req := &http.Request{ - Body: io.NopCloser(bytes.NewReader(payload.Bytes())), +func ConversionTest(withHubSpokeConverter bool) { + Describe("Conversion Webhook", func() { + var respRecorder *httptest.ResponseRecorder + var decoder *conversion.Decoder + var scheme *runtime.Scheme + var wh http.Handler + + BeforeEach(func() { + respRecorder = &httptest.ResponseRecorder{ + Body: bytes.NewBuffer(nil), + } + + scheme = runtime.NewScheme() + Expect(kscheme.AddToScheme(scheme)).To(Succeed()) + Expect(jobsv1.AddToScheme(scheme)).To(Succeed()) + Expect(jobsv2.AddToScheme(scheme)).To(Succeed()) + Expect(jobsv3.AddToScheme(scheme)).To(Succeed()) + + decoder = conversion.NewDecoder(scheme) + registry := conversion.NewRegistry() + + if withHubSpokeConverter { + converter, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{}, + conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub), + conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub), + )(scheme) + Expect(err).ToNot(HaveOccurred()) + Expect(registry.RegisterConverter(jobsv2.GroupVersion.WithKind("ExternalJob").GroupKind(), converter)).To(Succeed()) + } + + wh = conversion.NewWebhookHandler(scheme, registry) + }) + + doRequest := func(convReq *apix.ConversionReview) *apix.ConversionReview { + var payload bytes.Buffer + + Expect(json.NewEncoder(&payload).Encode(convReq)).Should(Succeed()) + + convReview := &apix.ConversionReview{} + req := &http.Request{ + Body: io.NopCloser(bytes.NewReader(payload.Bytes())), + } + wh.ServeHTTP(respRecorder, req) + Expect(json.NewDecoder(respRecorder.Result().Body).Decode(convReview)).To(Succeed()) + return convReview } - wh.ServeHTTP(respRecorder, req) - Expect(json.NewDecoder(respRecorder.Result().Body).Decode(convReview)).To(Succeed()) - return convReview - } - makeV1Obj := func() *jobsv1.ExternalJob { - return &jobsv1.ExternalJob{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExternalJob", - APIVersion: "jobs.testprojects.kb.io/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "obj-1", - }, - Spec: jobsv1.ExternalJobSpec{ - RunAt: "every 2 seconds", - }, + makeV1Obj := func() *jobsv1.ExternalJob { + return &jobsv1.ExternalJob{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExternalJob", + APIVersion: "jobs.testprojects.kb.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "obj-1", + }, + Spec: jobsv1.ExternalJobSpec{ + RunAt: "every 2 seconds", + }, + } } - } - makeV2Obj := func() *jobsv2.ExternalJob { - return &jobsv2.ExternalJob{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExternalJob", - APIVersion: "jobs.testprojects.kb.io/v2", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "obj-1", - }, - Spec: jobsv2.ExternalJobSpec{ - ScheduleAt: "every 2 seconds", - }, + makeV2Obj := func() *jobsv2.ExternalJob { + return &jobsv2.ExternalJob{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExternalJob", + APIVersion: "jobs.testprojects.kb.io/v2", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "obj-1", + }, + Spec: jobsv2.ExternalJobSpec{ + ScheduleAt: "every 2 seconds", + }, + } } - } - It("should convert spoke to hub successfully", func() { + It("should convert spoke to hub successfully", func() { - v1Obj := makeV1Obj() + v1Obj := makeV1Obj() - expected := &jobsv2.ExternalJob{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExternalJob", - APIVersion: "jobs.testprojects.kb.io/v2", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "obj-1", - }, - Spec: jobsv2.ExternalJobSpec{ - ScheduleAt: "every 2 seconds", - }, - } - - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - DesiredAPIVersion: "jobs.testprojects.kb.io/v2", - Objects: []runtime.RawExtension{ - { - Object: v1Obj, + expected := &jobsv2.ExternalJob{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExternalJob", + APIVersion: "jobs.testprojects.kb.io/v2", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "obj-1", + }, + Spec: jobsv2.ExternalJobSpec{ + ScheduleAt: "every 2 seconds", + }, + } + + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + DesiredAPIVersion: "jobs.testprojects.kb.io/v2", + Objects: []runtime.RawExtension{ + { + Object: v1Obj, + }, }, }, - }, - } + } - convReview := doRequest(convReq) + convReview := doRequest(convReq) - Expect(convReview.Response.ConvertedObjects).To(HaveLen(1)) - Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess)) - got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw) - Expect(err).NotTo(HaveOccurred()) - Expect(got).To(Equal(expected)) - }) + Expect(convReview.Response.ConvertedObjects).To(HaveLen(1)) + Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess)) + got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(expected)) + }) - It("should convert hub to spoke successfully", func() { + It("should convert hub to spoke successfully", func() { - v2Obj := makeV2Obj() + v2Obj := makeV2Obj() - expected := &jobsv1.ExternalJob{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExternalJob", - APIVersion: "jobs.testprojects.kb.io/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "obj-1", - }, - Spec: jobsv1.ExternalJobSpec{ - RunAt: "every 2 seconds", - }, - } - - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - DesiredAPIVersion: "jobs.testprojects.kb.io/v1", - Objects: []runtime.RawExtension{ - { - Object: v2Obj, + expected := &jobsv1.ExternalJob{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExternalJob", + APIVersion: "jobs.testprojects.kb.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "obj-1", + }, + Spec: jobsv1.ExternalJobSpec{ + RunAt: "every 2 seconds", + }, + } + + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + DesiredAPIVersion: "jobs.testprojects.kb.io/v1", + Objects: []runtime.RawExtension{ + { + Object: v2Obj, + }, }, }, - }, - } + } - convReview := doRequest(convReq) + convReview := doRequest(convReq) - Expect(convReview.Response.ConvertedObjects).To(HaveLen(1)) - Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess)) - got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw) - Expect(err).NotTo(HaveOccurred()) - Expect(got).To(Equal(expected)) - }) + Expect(convReview.Response.ConvertedObjects).To(HaveLen(1)) + Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess)) + got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(expected)) + }) - It("should convert spoke to spoke successfully", func() { + It("should convert spoke to spoke successfully", func() { - v1Obj := makeV1Obj() + v1Obj := makeV1Obj() - expected := &jobsv3.ExternalJob{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExternalJob", - APIVersion: "jobs.testprojects.kb.io/v3", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "obj-1", - }, - Spec: jobsv3.ExternalJobSpec{ - DeferredAt: "every 2 seconds", - }, - } - - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - DesiredAPIVersion: "jobs.testprojects.kb.io/v3", - Objects: []runtime.RawExtension{ - { - Object: v1Obj, + expected := &jobsv3.ExternalJob{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExternalJob", + APIVersion: "jobs.testprojects.kb.io/v3", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "obj-1", + }, + Spec: jobsv3.ExternalJobSpec{ + DeferredAt: "every 2 seconds", + }, + } + + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + DesiredAPIVersion: "jobs.testprojects.kb.io/v3", + Objects: []runtime.RawExtension{ + { + Object: v1Obj, + }, }, }, - }, - } + } + + convReview := doRequest(convReq) + + Expect(convReview.Response.ConvertedObjects).To(HaveLen(1)) + Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess)) + got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(expected)) + }) + + It("should return error when dest/src objects belong to different API groups", func() { + v1Obj := makeV1Obj() + + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + // request conversion for different group + DesiredAPIVersion: "jobss.example.org/v2", + Objects: []runtime.RawExtension{ + { + Object: v1Obj, + }, + }, + }, + } - convReview := doRequest(convReq) + convReview := doRequest(convReq) + Expect(convReview.Response.Result.Status).To(Equal("Failure")) + Expect(convReview.Response.ConvertedObjects).To(BeEmpty()) + }) - Expect(convReview.Response.ConvertedObjects).To(HaveLen(1)) - Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess)) - got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw) - Expect(err).NotTo(HaveOccurred()) - Expect(got).To(Equal(expected)) - }) + It("should return error when dest/src objects are of same type", func() { + + v1Obj := makeV1Obj() - It("should return error when dest/src objects belong to different API groups", func() { - v1Obj := makeV1Obj() - - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - // request conversion for different group - DesiredAPIVersion: "jobss.example.org/v2", - Objects: []runtime.RawExtension{ - { - Object: v1Obj, + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + DesiredAPIVersion: "jobs.testprojects.kb.io/v1", + Objects: []runtime.RawExtension{ + { + Object: v1Obj, + }, }, }, - }, - } - - convReview := doRequest(convReq) - Expect(convReview.Response.Result.Status).To(Equal("Failure")) - Expect(convReview.Response.ConvertedObjects).To(BeEmpty()) - }) + } - It("should return error when dest/src objects are of same type", func() { + convReview := doRequest(convReq) + Expect(convReview.Response.Result.Status).To(Equal("Failure")) + Expect(convReview.Response.ConvertedObjects).To(BeEmpty()) + }) - v1Obj := makeV1Obj() + It("should return error when the API group does not have a hub defined", func() { - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - DesiredAPIVersion: "jobs.testprojects.kb.io/v1", - Objects: []runtime.RawExtension{ - { - Object: v1Obj, + v1Obj := &appsv1beta1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: "apps/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "obj-1", + }, + } + + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + DesiredAPIVersion: "apps/v1", + Objects: []runtime.RawExtension{ + { + Object: v1Obj, + }, }, }, - }, - } + } + + convReview := doRequest(convReq) + Expect(convReview.Response.Result.Status).To(Equal("Failure")) + Expect(convReview.Response.ConvertedObjects).To(BeEmpty()) + }) + + It("should return error on panic in conversion", func() { + + v1Obj := makeV1Obj() + v1Obj.Spec.PanicInConversion = true + + convReq := &apix.ConversionReview{ + TypeMeta: metav1.TypeMeta{}, + Request: &apix.ConversionRequest{ + DesiredAPIVersion: "jobs.testprojects.kb.io/v3", + Objects: []runtime.RawExtension{ + { + Object: v1Obj, + }, + }, + }, + } - convReview := doRequest(convReq) - Expect(convReview.Response.Result.Status).To(Equal("Failure")) - Expect(convReview.Response.ConvertedObjects).To(BeEmpty()) - }) + convReview := doRequest(convReq) - It("should return error when the API group does not have a hub defined", func() { + Expect(convReview.Response.ConvertedObjects).To(HaveLen(0)) + Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusFailure)) + Expect(convReview.Response.Result.Message).To(Equal("internal error occurred during conversion")) + }) + }) +} - v1Obj := &appsv1beta1.Deployment{ - TypeMeta: metav1.TypeMeta{ - Kind: "Deployment", - APIVersion: "apps/v1beta1", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "obj-1", - }, - } +var _ = Describe("NewHubSpokeConverter", func() { + var scheme *runtime.Scheme - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - DesiredAPIVersion: "apps/v1", - Objects: []runtime.RawExtension{ - { - Object: v1Obj, - }, - }, - }, - } + BeforeEach(func() { + scheme = runtime.NewScheme() + Expect(jobsv1.AddToScheme(scheme)).To(Succeed()) + Expect(jobsv2.AddToScheme(scheme)).To(Succeed()) + Expect(jobsv3.AddToScheme(scheme)).To(Succeed()) + }) - convReview := doRequest(convReq) - Expect(convReview.Response.Result.Status).To(Equal("Failure")) - Expect(convReview.Response.ConvertedObjects).To(BeEmpty()) + It("should succeed if all converter are specified", func() { + _, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{}, + conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub), + conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub), + )(scheme) + Expect(err).ToNot(HaveOccurred()) }) - It("should return error on panic in conversion", func() { + It("should return error if hub is not registered in the scheme", func() { + _, err := conversion.NewHubSpokeConverter(&appsv1.Deployment{})(scheme) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("failed to create hub spoke converter: failed to get GroupVersionKind for hub: no kind is registered for the type v1.Deployment in scheme \"pkg/runtime/scheme.go:111\"")) + }) - v1Obj := makeV1Obj() - v1Obj.Spec.PanicInConversion = true + It("should return error if spoke is not registered in the scheme", func() { + _, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{}, + conversion.NewSpokeConverter(&appsv1.Deployment{}, + func(context.Context, *jobsv2.ExternalJob, *appsv1.Deployment) error { return nil }, + func(context.Context, *appsv1.Deployment, *jobsv2.ExternalJob) error { return nil }), + )(scheme) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: failed to get GroupVersionKind for spoke converter: no kind is registered for the type v1.Deployment in scheme \"pkg/runtime/scheme.go:111\"")) + }) - convReq := &apix.ConversionReview{ - TypeMeta: metav1.TypeMeta{}, - Request: &apix.ConversionRequest{ - DesiredAPIVersion: "jobs.testprojects.kb.io/v3", - Objects: []runtime.RawExtension{ - { - Object: v1Obj, - }, - }, - }, - } + It("should return error if spoke does not have the same GroupKind as the hub", func() { + _ = kscheme.AddToScheme(scheme) + _, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{}, + conversion.NewSpokeConverter(&appsv1.Deployment{}, + func(context.Context, *jobsv2.ExternalJob, *appsv1.Deployment) error { return nil }, + func(context.Context, *appsv1.Deployment, *jobsv2.ExternalJob) error { return nil }), + )(scheme) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: spoke converter GroupKind Deployment.apps does not match hub GroupKind ExternalJob.jobs.testprojects.kb.io")) + }) - convReview := doRequest(convReq) + It("should return error if same spoke is specified twice", func() { + _, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{}, + conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub), + conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub), // duplicate + conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub), // duplicate + )(scheme) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: duplicate spoke converter for version v3")) + }) - Expect(convReview.Response.ConvertedObjects).To(HaveLen(0)) - Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusFailure)) - Expect(convReview.Response.Result.Message).To(Equal("internal error occurred during conversion")) + It("should return error if a converter is missing", func() { + _, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{}, + conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub), + // jobsv3.ExternalJob converter is missing + )(scheme) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: expected spoke converter for v1,v3 got spoke converter for v1")) }) }) @@ -381,3 +467,19 @@ var _ = Describe("IsConvertible", func() { Expect(ok).ToNot(BeTrue()) }) }) + +func convertV1ToHub(_ context.Context, src *jobsv1.ExternalJob, dst *jobsv2.ExternalJob) error { + return src.ConvertTo(dst) +} + +func convertHubToV1(_ context.Context, src *jobsv2.ExternalJob, dst *jobsv1.ExternalJob) error { + return dst.ConvertFrom(src) +} + +func convertV3ToHub(_ context.Context, src *jobsv3.ExternalJob, dst *jobsv2.ExternalJob) error { + return src.ConvertTo(dst) +} + +func convertHubToV3(_ context.Context, src *jobsv2.ExternalJob, dst *jobsv3.ExternalJob) error { + return dst.ConvertFrom(src) +} diff --git a/pkg/webhook/internal/metrics/metrics.go b/pkg/webhook/internal/metrics/metrics.go index f1e6ce68f5..a8ff400954 100644 --- a/pkg/webhook/internal/metrics/metrics.go +++ b/pkg/webhook/internal/metrics/metrics.go @@ -33,6 +33,7 @@ var ( prometheus.HistogramOpts{ Name: "controller_runtime_webhook_latency_seconds", Help: "Histogram of the latency of processing admission requests", + Buckets: prometheus.ExponentialBuckets(10e-9, 10, 12), NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, diff --git a/tools/setup-envtest/README.md b/tools/setup-envtest/README.md index a4de6f3eae..9cbf185cec 100644 --- a/tools/setup-envtest/README.md +++ b/tools/setup-envtest/README.md @@ -4,11 +4,14 @@ This is a small tool that manages binaries for envtest. It can be used to download new binaries, list currently installed and available ones, and clean up versions. -To use it, just go-install it with Golang 1.24+ (it's a separate, self-contained -module): +To use it, download the binary from the [release page.](https://github.com/kubernetes-sigs/controller-runtime/releases) + +If you want to install this with Golang, you can install a release by using a release branch instead. + +NOTE: Each release branch may prefer a different version of Golang when installing. ```shell -go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest +go install sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.22 ``` If you are using Golang 1.23, use the `release-0.20` branch instead: diff --git a/tools/setup-envtest/env/env.go b/tools/setup-envtest/env/env.go index 6168739eb6..cfcad3505b 100644 --- a/tools/setup-envtest/env/env.go +++ b/tools/setup-envtest/env/env.go @@ -10,7 +10,7 @@ import ( "io" "io/fs" "path/filepath" - "sort" + "slices" "strings" "text/tabwriter" @@ -121,9 +121,10 @@ func (e *Env) ListVersions(ctx context.Context) { if !e.Version.Matches(set.Version) { continue } - sort.Slice(set.Platforms, func(i, j int) bool { - return orderPlatforms(set.Platforms[i].Platform, set.Platforms[j].Platform) + slices.SortStableFunc(set.Platforms, func(i, j versions.PlatformItem) int { + return orderPlatforms(i.Platform, j.Platform) }) + for _, plat := range set.Platforms { if e.Platform.Matches(plat.Platform) { fmt.Fprintf(out, "(available)\tv%s\t%s\n", set.Version, plat) @@ -246,7 +247,7 @@ func (e *Env) EnsureVersionIsSet(ctx context.Context) { serverVer, platform := e.LatestVersion(ctx) // if we're not forcing a download, and we have a newer local version, just use that - if !e.ForceDownload && localVer != nil && localVer.NewerThan(serverVer) { + if !e.ForceDownload && localVer != nil && localVer.Compare(serverVer) > 0 { e.Platform.Platform = localPlat // update our data with hash e.Version.MakeConcrete(*localVer) return diff --git a/tools/setup-envtest/env/exit.go b/tools/setup-envtest/env/exit.go index ae393b593b..640e65a4fc 100644 --- a/tools/setup-envtest/env/exit.go +++ b/tools/setup-envtest/env/exit.go @@ -12,7 +12,7 @@ import ( // Exit exits with the given code and error message. // // Defer HandleExitWithCode in main to catch this and get the right behavior. -func Exit(code int, msg string, args ...interface{}) { +func Exit(code int, msg string, args ...any) { panic(&exitCode{ code: code, err: fmt.Errorf(msg, args...), @@ -23,7 +23,7 @@ func Exit(code int, msg string, args ...interface{}) { // wrapping the underlying error passed as well. // // Defer HandleExitWithCode in main to catch this and get the right behavior. -func ExitCause(code int, err error, msg string, args ...interface{}) { +func ExitCause(code int, err error, msg string, args ...any) { args = append(args, err) panic(&exitCode{ code: code, @@ -48,7 +48,7 @@ func (c *exitCode) Unwrap() error { // asExit checks if the given (panic) value is an exitCode error, // and if so stores it in the given pointer. It's roughly analogous // to errors.As, except it works on recover() values. -func asExit(val interface{}, exit **exitCode) bool { +func asExit(val any, exit **exitCode) bool { if val == nil { return false } @@ -81,7 +81,7 @@ func HandleExitWithCode() { // the cause. // // It's mainly useful for testing, normally you'd use HandleExitWithCode. -func CheckRecover(cause interface{}, cb func(int, error)) bool { +func CheckRecover(cause any, cb func(int, error)) bool { if cause == nil { return false } diff --git a/tools/setup-envtest/env/helpers.go b/tools/setup-envtest/env/helpers.go index 2c98c88d95..8572088517 100644 --- a/tools/setup-envtest/env/helpers.go +++ b/tools/setup-envtest/env/helpers.go @@ -5,17 +5,18 @@ package env import ( "fmt" + "strings" "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" ) // orderPlatforms orders platforms by OS then arch. -func orderPlatforms(first, second versions.Platform) bool { +func orderPlatforms(first, second versions.Platform) int { // sort by OS, then arch if first.OS != second.OS { - return first.OS < second.OS + return strings.Compare(first.OS, second.OS) } - return first.Arch < second.Arch + return strings.Compare(first.Arch, second.Arch) } // PrintFormat indicates how to print out fetch and switch results. diff --git a/tools/setup-envtest/go.mod b/tools/setup-envtest/go.mod index 5cb31d8bf2..053ce8b99c 100644 --- a/tools/setup-envtest/go.mod +++ b/tools/setup-envtest/go.mod @@ -1,29 +1,32 @@ module sigs.k8s.io/controller-runtime/tools/setup-envtest -go 1.24.0 +go 1.25.0 require ( - github.com/go-logr/logr v1.4.2 + github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 - github.com/onsi/ginkgo/v2 v2.22.2 - github.com/onsi/gomega v1.36.2 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 github.com/spf13/afero v1.12.0 - github.com/spf13/pflag v1.0.6 + github.com/spf13/pflag v1.0.9 go.uber.org/zap v1.27.0 - k8s.io/apimachinery v0.34.0 + k8s.io/apimachinery v0.35.0 sigs.k8s.io/yaml v1.6.0 ) require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect go.uber.org/multierr v1.10.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/tools v0.28.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/tools/setup-envtest/go.sum b/tools/setup-envtest/go.sum index c9dcc6499b..bbcf8021f5 100644 --- a/tools/setup-envtest/go.sum +++ b/tools/setup-envtest/go.sum @@ -1,52 +1,87 @@ +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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -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/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= -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= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= -k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/tools/setup-envtest/remote/http_client.go b/tools/setup-envtest/remote/http_client.go index 0339654a82..7cbe3ee1d4 100644 --- a/tools/setup-envtest/remote/http_client.go +++ b/tools/setup-envtest/remote/http_client.go @@ -9,7 +9,7 @@ import ( "io" "net/http" "net/url" - "sort" + "slices" "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/tools/setup-envtest/versions" @@ -87,9 +87,8 @@ func (c *HTTPClient) ListVersions(ctx context.Context) ([]versions.Set, error) { res = append(res, versions.Set{Version: ver, Platforms: details}) } // sort in inverse order so that the newest one is first - sort.Slice(res, func(i, j int) bool { - first, second := res[i].Version, res[j].Version - return first.NewerThan(second) + slices.SortStableFunc(res, func(i, j versions.Set) int { + return j.Version.Compare(i.Version) }) return res, nil diff --git a/tools/setup-envtest/store/store.go b/tools/setup-envtest/store/store.go index bb5a1f7bcd..1e8053e0cc 100644 --- a/tools/setup-envtest/store/store.go +++ b/tools/setup-envtest/store/store.go @@ -12,7 +12,8 @@ import ( "io" "os" "path/filepath" - "sort" + "slices" + "strings" "github.com/go-logr/logr" "github.com/spf13/afero" @@ -102,11 +103,12 @@ func (s *Store) List(ctx context.Context, matching Filter) ([]Item, error) { return nil, fmt.Errorf("unable to list version-platform pairs in store: %w", err) } - sort.Slice(res, func(i, j int) bool { - if !res[i].Version.Matches(res[j].Version) { - return res[i].Version.NewerThan(res[j].Version) + slices.SortStableFunc(res, func(i, j Item) int { + if !i.Version.Matches(j.Version) { + // sort in inverse order so that the newest one is first + return j.Version.Compare(i.Version) } - return orderPlatforms(res[i].Platform, res[j].Platform) + return orderPlatforms(i.Platform, j.Platform) }) return res, nil @@ -296,10 +298,10 @@ func (s *Store) removeItem(itemDir afero.Fs) error { } // orderPlatforms orders platforms by OS then arch. -func orderPlatforms(first, second versions.Platform) bool { +func orderPlatforms(first, second versions.Platform) int { // sort by OS, then arch if first.OS != second.OS { - return first.OS < second.OS + return strings.Compare(first.OS, second.OS) } - return first.Arch < second.Arch + return strings.Compare(first.Arch, second.Arch) } diff --git a/tools/setup-envtest/versions/misc_test.go b/tools/setup-envtest/versions/misc_test.go index a609f4dc60..187218f82e 100644 --- a/tools/setup-envtest/versions/misc_test.go +++ b/tools/setup-envtest/versions/misc_test.go @@ -17,6 +17,9 @@ limitations under the License. package versions_test import ( + "math/rand/v2" + "slices" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,13 +39,30 @@ var _ = Describe("Concrete", func() { Describe("when ordering relative to other versions", func() { ver1163 := Concrete{Major: 1, Minor: 16, Patch: 3} Specify("newer patch should be newer", func() { - Expect(ver1163.NewerThan(Concrete{Major: 1, Minor: 16})).To(BeTrue()) + Expect(ver1163.Compare(Concrete{Major: 1, Minor: 16})).To(Equal(1)) }) Specify("newer minor should be newer", func() { - Expect(ver1163.NewerThan(Concrete{Major: 1, Minor: 15, Patch: 3})).To(BeTrue()) + Expect(ver1163.Compare(Concrete{Major: 1, Minor: 15, Patch: 3})).To(Equal(1)) }) Specify("newer major should be newer", func() { - Expect(ver1163.NewerThan(Concrete{Major: 0, Minor: 16, Patch: 3})).To(BeTrue()) + Expect(ver1163.Compare(Concrete{Major: 0, Minor: 16, Patch: 3})).To(Equal(1)) + }) + + Describe("sorting", func() { + ver16 := Concrete{Major: 1, Minor: 16} + ver17 := Concrete{Major: 1, Minor: 17} + many := []Concrete{ver16, ver17, ver1163} + + BeforeEach(func() { + rand.Shuffle(len(many), func(i, j int) { + many[i], many[j] = many[j], many[i] + }) + }) + + Specify("newer versions are later", func() { + slices.SortStableFunc(many, Concrete.Compare) + Expect(many).To(Equal([]Concrete{ver16, ver1163, ver17})) + }) }) }) }) diff --git a/tools/setup-envtest/versions/version.go b/tools/setup-envtest/versions/version.go index 945a95006f..bad05c3218 100644 --- a/tools/setup-envtest/versions/version.go +++ b/tools/setup-envtest/versions/version.go @@ -27,15 +27,24 @@ func (c Concrete) AsConcrete() *Concrete { return &c } -// NewerThan checks if the given other version is newer than this one. -func (c Concrete) NewerThan(other Concrete) bool { +// Compare checks if the given other version is newer than this one. +func (c Concrete) Compare(other Concrete) int { + IntCompare := func(a, b int) int { + if a > b { + return 1 + } else if a < b { + return -1 + } + return 0 + } + if c.Major != other.Major { - return c.Major > other.Major + return IntCompare(c.Major, other.Major) } if c.Minor != other.Minor { - return c.Minor > other.Minor + return IntCompare(c.Minor, other.Minor) } - return c.Patch > other.Patch + return IntCompare(c.Patch, other.Patch) } // Matches checks if this version is equal to the other one. diff --git a/tools/setup-envtest/workflows/workflows_test.go b/tools/setup-envtest/workflows/workflows_test.go index 435ae24285..eaa1c8c0a3 100644 --- a/tools/setup-envtest/workflows/workflows_test.go +++ b/tools/setup-envtest/workflows/workflows_test.go @@ -9,7 +9,7 @@ import ( "io/fs" "path/filepath" "runtime/debug" - "sort" + "slices" "strings" . "github.com/onsi/ginkgo/v2" @@ -333,7 +333,7 @@ var _ = Describe("Workflows", func() { archiveNames = append(archiveNames, archiveName) } } - sort.Strings(archiveNames) + slices.Sort(archiveNames) archiveNamesSet := sets.Set[string]{}.Insert(archiveNames[:7]...) // Delete all other archives for _, release := range remoteHTTPItems.index.Releases {