Skip to content

Instantly share code, notes, and snippets.

@sttts
Created March 2, 2025 11:07
Show Gist options
  • Save sttts/0413e1cb2f0970d9a954cd75f1d055ce to your computer and use it in GitHub Desktop.
Save sttts/0413e1cb2f0970d9a954cd75f1d055ce to your computer and use it in GitHub Desktop.
diff --git a/.gitignore b/.gitignore
index 2ddc5a8b..4b09af17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,5 @@ tools/setup-envtest/out
junit-report.xml
/artifacts
+
+examples/kcp/.gitignore
diff --git a/.golangci.yml b/.golangci.yml
index 4c43665e..6dcbfb64 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -13,7 +13,6 @@ linters:
- exhaustive
- exportloopref
- ginkgolinter
- - goconst
- gocritic
- gocyclo
- gofmt
diff --git a/.prow.yaml b/.prow.yaml
new file mode 100644
index 00000000..1f9f775c
--- /dev/null
+++ b/.prow.yaml
@@ -0,0 +1,34 @@
+presubmits:
+ - name: pull-controller-runtime-everything
+ always_run: true
+ decorate: true
+ clone_uri: "ssh://[email protected]/kcp-dev/controller-runtime.git"
+ labels:
+ preset-goproxy: "true"
+ spec:
+ containers:
+ - image: ghcr.io/kcp-dev/infra/build:1.22.2-1
+ command:
+ - make
+ - test
+
+ - name: pull-controller-runtime-example-e2e
+ decorate: true
+ # only run e2e tests if code changed.
+ run_if_changed: "(pkg|examples|go.mod|go.sum|Makefile|.prow.yaml)"
+ clone_uri: "https://github.com/kcp-dev/controller-runtime"
+ labels:
+ preset-goproxy: "true"
+ spec:
+ containers:
+ - image: ghcr.io/kcp-dev/infra/build:1.22.2-1
+ env:
+ - name: KUBECONFIG
+ value: /home/prow/go/src/github.com/kcp-dev/controller-runtime/examples/kcp/.test/kcp.kubeconfig
+ command:
+ - make
+ - test-kcp-e2e
+ resources:
+ requests:
+ memory: 6Gi
+ cpu: 4
diff --git a/DOWNSTREAM_OWNERS b/DOWNSTREAM_OWNERS
new file mode 100644
index 00000000..3f5abef6
--- /dev/null
+++ b/DOWNSTREAM_OWNERS
@@ -0,0 +1,5 @@
+approvers:
+ - sttts
+ - xrstf
+ - mjudeikis
+ - embik
diff --git a/DOWNSTREAM_OWNERS_ALIASES b/DOWNSTREAM_OWNERS_ALIASES
new file mode 100644
index 00000000..e69de29b
diff --git a/Makefile b/Makefile
index 9d92b977..0575eb81 100644
--- a/Makefile
+++ b/Makefile
@@ -216,3 +216,6 @@ verify-apidiff: $(GO_APIDIFF) ## Check for API differences
go-version: ## Print the go version we use to compile our binaries and images
@echo $(GO_VERSION)
+.PHONY: test-kcp-e2e
+test-kcp-e2e:
+ cd examples/kcp && make kcp-server kcp-controller test
diff --git a/examples/builtins/main.go b/examples/builtins/main.go
index 5a6e313f..cd2ca0b4 100644
--- a/examples/builtins/main.go
+++ b/examples/builtins/main.go
@@ -42,6 +42,7 @@ func main() {
// Setup a Manager
entryLog.Info("setting up manager")
+
mgr, err := manager.New(config.GetConfigOrDie(), manager.Options{})
if err != nil {
entryLog.Error(err, "unable to set up overall controller manager")
diff --git a/examples/kcp/Makefile b/examples/kcp/Makefile
new file mode 100644
index 00000000..b0d167a9
--- /dev/null
+++ b/examples/kcp/Makefile
@@ -0,0 +1,91 @@
+SHELL := /bin/bash
+
+.PHONY: help
+help: ## Display this help.
+ @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
+
+GO_INSTALL = ./hack/go-install.sh
+
+LOCALBIN ?= $(shell pwd)/bin
+TOOLS_DIR=hack/tools
+TOOLS_BIN_DIR := $(abspath $(TOOLS_DIR)/bin)
+ARTIFACT_DIR ?= .test
+
+KCP ?= $(LOCALBIN)/kcp
+KUBECTL_KCP ?= $(LOCALBIN)/kubectl-kcp
+
+KCP_VERSION ?= 0.23.0
+CONTROLLER_GEN := $(TOOLS_BIN_DIR)/controller-gen
+export CONTROLLER_GEN # so hack scripts can use it
+
+KCP_APIGEN_VER := v0.21.0
+KCP_APIGEN_BIN := apigen
+KCP_APIGEN_GEN := $(TOOLS_BIN_DIR)/$(KCP_APIGEN_BIN)-$(KCP_APIGEN_VER)
+export KCP_APIGEN_GEN # so hack scripts can use it
+
+OS ?= $(shell go env GOOS )
+ARCH ?= $(shell go env GOARCH )
+
+$(KCP): ## Download kcp locally if necessary.
+ mkdir -p $(LOCALBIN)
+ curl -L -s -o - https://github.com/kcp-dev/kcp/releases/download/v$(KCP_VERSION)/kcp_$(KCP_VERSION)_$(OS)_$(ARCH).tar.gz | tar --directory $(LOCALBIN)/../ -xvzf - bin/kcp
+ touch $(KCP) # we download an "old" file, so make will re-download to refresh it unless we make it newer than the owning dir
+
+$(KUBECTL_KCP): ## Download kcp kubectl plugins locally if necessary.
+ curl -L -s -o - https://github.com/kcp-dev/kcp/releases/download/v$(KCP_VERSION)/kubectl-kcp-plugin_$(KCP_VERSION)_$(OS)_$(ARCH).tar.gz | tar --directory $(LOCALBIN)/../ -xvzf - bin
+ touch $(KUBECTL_KCP) # we download an "old" file, so make will re-download to refresh it unless we make it newer than the owning dir
+
+$(KCP_APIGEN_GEN):
+ GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) github.com/kcp-dev/kcp/sdk/cmd/apigen $(KCP_APIGEN_BIN) $(KCP_APIGEN_VER)
+
+$(CONTROLLER_GEN): $(TOOLS_DIR)/go.mod # Build controller-gen from tools folder.
+ cd $(TOOLS_DIR) && go build -tags=tools -o bin/controller-gen sigs.k8s.io/controller-tools/cmd/controller-gen
+
+build: $(KCP) $(KUBECTL_KCP) build-controller
+
+ifeq (,$(shell go env GOBIN))
+GOBIN=$(shell go env GOPATH)/bin
+else
+GOBIN=$(shell go env GOBIN)
+endif
+
+build-controller: ## Build the controller binary.
+ go build -o $(LOCALBIN)/kcp-controller ./main.go
+
+.PHONY: kcp-server
+kcp-server: $(KCP) $(ARTIFACT_DIR)/kcp ## Run the kcp server.
+ @if [[ ! -s $(ARTIFACT_DIR)/kcp.log ]]; then ( $(KCP) start -v 5 --root-directory $(ARTIFACT_DIR)/kcp --kubeconfig-path $(ARTIFACT_DIR)/kcp.kubeconfig --audit-log-maxsize 1024 --audit-log-mode=batch --audit-log-batch-max-wait=1s --audit-log-batch-max-size=1000 --audit-log-batch-buffer-size=10000 --audit-log-batch-throttle-burst=15 --audit-log-batch-throttle-enable=true --audit-log-batch-throttle-qps=10 --audit-policy-file ./test/e2e/audit-policy.yaml --audit-log-path $(ARTIFACT_DIR)/audit.log >$(ARTIFACT_DIR)/kcp.log 2>&1 & ); fi
+ @echo "Waiting for kcp server to generate kubeconfig..."
+ @while true; do if [[ ! -s $(ARTIFACT_DIR)/kcp.kubeconfig ]]; then sleep 0.2; else break; fi; done
+ @echo "Waiting for kcp server to be ready..."
+ @while true; do if ! kubectl --kubeconfig $(ARTIFACT_DIR)/kcp.kubeconfig get --raw /readyz >$(ARTIFACT_DIR)/kcp.probe.log 2>&1; then sleep 0.2; else break; fi; done
+ @echo "kcp server is ready and running in the background. To stop run 'make test-cleanup'"
+
+.PHONY: kcp-bootstrap
+kcp-bootstrap: ## Bootstrap the kcp server.
+ @go run ./config/main.go
+
+.PHONY: kcp-controller
+kcp-controller: build kcp-bootstrap ## Run the kcp-controller.
+ @echo "Starting kcp-controller in the background. To stop run 'make test-cleanup'"
+ @if [[ ! -s $(ARTIFACT_DIR)/controller.log ]]; then ( ./bin/kcp-controller >$(ARTIFACT_DIR)/controller.log 2>&1 & ); fi
+
+.PHONY: test-e2e-cleanup
+test-cleanup: ## Clean up processes and directories from an end-to-end test run.
+ rm -rf $(ARTIFACT_DIR) || true
+ pkill -sigterm kcp || true
+ pkill -sigterm kubectl || true
+ pkill -sigterm kcp-controller || true
+
+$(ARTIFACT_DIR)/kcp: ## Create a directory for the kcp server data.
+ mkdir -p $(ARTIFACT_DIR)/kcp
+
+generate: build $(CONTROLLER_GEN) $(KCP_APIGEN_GEN) # Generate code
+ ./hack/update-codegen-crds.sh
+
+run-local: build-controller kcp-bootstrap
+ ./bin/kcp-controller
+
+.PHONY: test # Run tests
+test:
+ go test ./... --workspace=root --kubeconfig=$(CURDIR)/$(ARTIFACT_DIR)/kcp.kubeconfig
diff --git a/examples/kcp/README.md b/examples/kcp/README.md
new file mode 100644
index 00000000..760f1410
--- /dev/null
+++ b/examples/kcp/README.md
@@ -0,0 +1,85 @@
+# controller-runtime-example
+An example project that is multi-cluster aware and works with [kcp](https://github.com/kcp-dev/kcp)
+
+## Description
+
+In this example, we intentionally not using advanced kubebuilder patterns to keep the example simple and easy to understand.
+In the future, we will add more advanced examples. Example covers 3 parts:
+1. KCP bootstrapping - creating APIExport & consumer workspaces
+ 1. Creating WorkspaceType for particular exports
+2. Running controller with APIExport aware configuration and reconciling multiple consumer workspaces
+
+
+This example contains an example project that works with APIExports and multiple kcp workspaces. It demonstrates
+two reconcilers:
+
+1. ConfigMap
+ 1. Get a ConfigMap for the key from the queue, from the correct logical cluster
+ 2. If the ConfigMap has labels["name"], set labels["response"] = "hello-$name" and save the changes
+ 3. List all ConfigMaps in the logical cluster and log each one's namespace and name
+ 4. If the ConfigMap from step 1 has data["namespace"] set, create a namespace whose name is the data value.
+ 5. If the ConfigMap from step 1 has data["secretData"] set, create a secret in the same namespace as the ConfigMap,
+ with an owner reference to the ConfigMap, and data["dataFromCM"] set to the data value.
+
+2. Widget
+ 1. Show how to list all Widget instances across all logical clusters
+ 2. Get a Widget for the key from the queue, from the correct logical cluster
+ 3. List all Widgets in the same logical cluster
+ 4. Count the number of Widgets (list length)
+ 5. Make sure `.status.total` matches the current count (via a `patch`)
+
+## Getting Started
+
+### Running on kcp
+
+1. Run KCP with the following command:
+
+```sh
+make kcp-server
+```
+
+From this point onwards you can inspect kcp configuration using kubeconfig:
+
+```sh
+export KUBECONFIG=.test/kcp.kubeconfig
+```
+
+1. Bootstrap the KCP server with the following command:
+
+```sh
+export KUBECONFIG=./.test/kcp.kubeconfig
+make kcp-bootstrap
+```
+
+1. Run controller:
+
+```sh
+export KUBECONFIG=./.test/kcp.kubeconfig
+make run-local
+```
+
+1. In separate shell you can run tests to exercise the controller:
+
+```sh
+export KUBECONFIG=./.test/kcp.kubeconfig
+make test
+```
+
+### Uninstall resources
+To delete the resources from kcp:
+
+```sh
+make test-clean
+```
+
+
+
+### How it works
+This project aims to follow the Kubernetes [Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
+
+It uses [Controllers](https://kubernetes.io/docs/concepts/architecture/controller/)
+which provides a reconcile function responsible for synchronizing resources until the desired state is reached.
+
+
+
+
diff --git a/examples/kcp/apis/v1alpha1/groupversion_info.go b/examples/kcp/apis/v1alpha1/groupversion_info.go
new file mode 100644
index 00000000..5e15b53b
--- /dev/null
+++ b/examples/kcp/apis/v1alpha1/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024.
+
+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 v1alpha1 contains API Schema definitions for the data v1alpha1 API group
+// +kubebuilder:object:generate=true
+// +groupName=data.my.domain
+package v1alpha1
+
+import (
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+ // GroupVersion is group version used to register these objects
+ GroupVersion = schema.GroupVersion{Group: "data.my.domain", Version: "v1alpha1"}
+
+ // SchemeBuilder is used to add go types to the GroupVersionKind scheme
+ SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+ // AddToScheme adds the types in this group-version to the given scheme.
+ AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/examples/kcp/apis/v1alpha1/widget_types.go b/examples/kcp/apis/v1alpha1/widget_types.go
new file mode 100644
index 00000000..038a1cee
--- /dev/null
+++ b/examples/kcp/apis/v1alpha1/widget_types.go
@@ -0,0 +1,56 @@
+/*
+Copyright 2024.
+
+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 v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// WidgetSpec defines the desired state of Widget
+type WidgetSpec struct {
+ Foo string `json:"foo,omitempty"`
+}
+
+// WidgetStatus defines the observed state of Widget
+type WidgetStatus struct {
+ Total int `json:"total,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// Widget is the Schema for the widgets API
+type Widget struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec WidgetSpec `json:"spec,omitempty"`
+ Status WidgetStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// WidgetList contains a list of Widget
+type WidgetList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []Widget `json:"items"`
+}
+
+func init() {
+ SchemeBuilder.Register(&Widget{}, &WidgetList{})
+}
diff --git a/examples/kcp/apis/v1alpha1/zz_generated.deepcopy.go b/examples/kcp/apis/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 00000000..e4892949
--- /dev/null
+++ b/examples/kcp/apis/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,98 @@
+//go:build !ignore_autogenerated
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Widget) DeepCopyInto(out *Widget) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Widget.
+func (in *Widget) DeepCopy() *Widget {
+ if in == nil {
+ return nil
+ }
+ out := new(Widget)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Widget) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WidgetList) DeepCopyInto(out *WidgetList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]Widget, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WidgetList.
+func (in *WidgetList) DeepCopy() *WidgetList {
+ if in == nil {
+ return nil
+ }
+ out := new(WidgetList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *WidgetList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WidgetSpec) DeepCopyInto(out *WidgetSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WidgetSpec.
+func (in *WidgetSpec) DeepCopy() *WidgetSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(WidgetSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WidgetStatus) DeepCopyInto(out *WidgetStatus) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WidgetStatus.
+func (in *WidgetStatus) DeepCopy() *WidgetStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(WidgetStatus)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/examples/kcp/config/consumers/bootstrap.go b/examples/kcp/config/consumers/bootstrap.go
new file mode 100644
index 00000000..ed68b8be
--- /dev/null
+++ b/examples/kcp/config/consumers/bootstrap.go
@@ -0,0 +1,43 @@
+/*
+Copyright 2024 The KCP 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 consumers
+
+import (
+ "context"
+ "embed"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ confighelpers "github.com/kcp-dev/controller-runtime/examples/kcp/config/helpers"
+)
+
+//go:embed *.yaml
+var fs embed.FS
+
+// Bootstrap creates resources in this package by continuously retrying the list.
+// This is blocking, i.e. it only returns (with error) when the context is closed or with nil when
+// the bootstrapping is successfully completed.
+func Bootstrap(
+ ctx context.Context,
+ client client.Client,
+) error {
+ log := log.FromContext(ctx)
+
+ log.Info("Bootstrapping consumers workspaces")
+ return confighelpers.Bootstrap(ctx, client, fs)
+}
diff --git a/examples/kcp/config/consumers/consumer1-workspace.yaml b/examples/kcp/config/consumers/consumer1-workspace.yaml
new file mode 100644
index 00000000..3161d713
--- /dev/null
+++ b/examples/kcp/config/consumers/consumer1-workspace.yaml
@@ -0,0 +1,14 @@
+apiVersion: tenancy.kcp.io/v1alpha1
+kind: Workspace
+metadata:
+ name: consumer1
+ annotations:
+ bootstrap.kcp.io/create-only: "true"
+spec:
+ type:
+ name: widgets
+ path: root
+ location:
+ selector:
+ matchLabels:
+ name: root
diff --git a/examples/kcp/config/consumers/consumer2-workspace.yaml b/examples/kcp/config/consumers/consumer2-workspace.yaml
new file mode 100644
index 00000000..e75e3121
--- /dev/null
+++ b/examples/kcp/config/consumers/consumer2-workspace.yaml
@@ -0,0 +1,14 @@
+apiVersion: tenancy.kcp.io/v1alpha1
+kind: Workspace
+metadata:
+ name: consumer2
+ annotations:
+ bootstrap.kcp.io/create-only: "true"
+spec:
+ type:
+ name: widgets
+ path: root
+ location:
+ selector:
+ matchLabels:
+ name: root
diff --git a/examples/kcp/config/crds/data.my.domain_widgets.yaml b/examples/kcp/config/crds/data.my.domain_widgets.yaml
new file mode 100644
index 00000000..02c5feaa
--- /dev/null
+++ b/examples/kcp/config/crds/data.my.domain_widgets.yaml
@@ -0,0 +1,55 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.14.0
+ name: widgets.data.my.domain
+spec:
+ group: data.my.domain
+ names:
+ kind: Widget
+ listKind: WidgetList
+ plural: widgets
+ singular: widget
+ scope: Namespaced
+ versions:
+ - name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: Widget is the Schema for the widgets API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: WidgetSpec defines the desired state of Widget
+ properties:
+ foo:
+ type: string
+ type: object
+ status:
+ description: WidgetStatus defines the observed state of Widget
+ properties:
+ total:
+ type: integer
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/examples/kcp/config/helpers/bootstrap.go b/examples/kcp/config/helpers/bootstrap.go
new file mode 100644
index 00000000..93dd2305
--- /dev/null
+++ b/examples/kcp/config/helpers/bootstrap.go
@@ -0,0 +1,143 @@
+package helpers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "embed"
+ "errors"
+ "fmt"
+ "io"
+ "text/template"
+ "time"
+
+ extensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/types"
+ apimachineryerrors "k8s.io/apimachinery/pkg/util/errors"
+ "k8s.io/apimachinery/pkg/util/wait"
+ kubeyaml "k8s.io/apimachinery/pkg/util/yaml"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// Bootstrap creates resources in a package's fs by
+// continuously retrying the list. This is blocking, i.e. it only returns (with error)
+// when the context is closed or with nil when the bootstrapping is successfully completed.
+func Bootstrap(ctx context.Context, client client.Client, fs embed.FS) error {
+ // bootstrap non-crd resources
+ return wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (bool, error) {
+ if err := CreateResourcesFromFS(ctx, client, fs); err != nil {
+ log.FromContext(ctx).WithValues("err", err).Info("failed to bootstrap resources, retrying")
+ return false, nil
+ }
+ return true, nil
+ })
+}
+
+// CreateResourcesFromFS creates all resources from a filesystem.
+func CreateResourcesFromFS(ctx context.Context, client client.Client, fs embed.FS) error {
+ files, err := fs.ReadDir(".")
+ if err != nil {
+ return err
+ }
+
+ var errs []error
+ for _, f := range files {
+ if f.IsDir() {
+ continue
+ }
+ if err := CreateResourceFromFS(ctx, client, f.Name(), fs); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return apimachineryerrors.NewAggregate(errs)
+}
+
+// CreateResourceFromFS creates given resource file.
+func CreateResourceFromFS(ctx context.Context, client client.Client, filename string, fs embed.FS) error {
+ raw, err := fs.ReadFile(filename)
+ if err != nil {
+ return fmt.Errorf("could not read %s: %w", filename, err)
+ }
+
+ if len(raw) == 0 {
+ return nil // ignore empty files
+ }
+
+ d := kubeyaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(raw)))
+ var errs []error
+ for i := 1; ; i++ {
+ doc, err := d.Read()
+ if errors.Is(err, io.EOF) {
+ break
+ } else if err != nil {
+ return err
+ }
+ if len(bytes.TrimSpace(doc)) == 0 {
+ continue
+ }
+
+ if err := createResourceFromFS(ctx, client, doc); err != nil {
+ errs = append(errs, fmt.Errorf("failed to create resource %s doc %d: %w", filename, i, err))
+ }
+ }
+ return apimachineryerrors.NewAggregate(errs)
+}
+
+func createResourceFromFS(ctx context.Context, client client.Client, raw []byte) error {
+ log := log.FromContext(ctx)
+
+ type Input struct {
+ Batteries map[string]bool
+ }
+ input := Input{
+ Batteries: map[string]bool{},
+ }
+ tmpl, err := template.New("manifest").Parse(string(raw))
+ if err != nil {
+ return fmt.Errorf("failed to parse manifest: %w", err)
+ }
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, input); err != nil {
+ return fmt.Errorf("failed to execute manifest: %w", err)
+ }
+
+ obj, gvk, err := extensionsapiserver.Codecs.UniversalDeserializer().Decode(buf.Bytes(), nil, &unstructured.Unstructured{})
+ if err != nil {
+ return fmt.Errorf("could not decode raw: %w", err)
+ }
+ u, ok := obj.(*unstructured.Unstructured)
+ if !ok {
+ return fmt.Errorf("decoded into incorrect type, got %T, wanted %T", obj, &unstructured.Unstructured{})
+ }
+
+ key := types.NamespacedName{
+ Namespace: u.GetNamespace(),
+ Name: u.GetName(),
+ }
+ err = client.Create(ctx, u)
+ if err != nil {
+ if apierrors.IsAlreadyExists(err) {
+ err = client.Get(ctx, key, u)
+ if err != nil {
+ return err
+ }
+
+ u.SetResourceVersion(u.GetResourceVersion())
+ err = client.Update(ctx, u)
+ if err != nil {
+ return fmt.Errorf("could not update %s %s: %w", gvk.Kind, key.String(), err)
+ } else {
+ log.WithValues("resource", u.GetName(), "kind", gvk.Kind).Info("updated object")
+ return nil
+ }
+ }
+ return err
+ }
+
+ log.WithValues("resource", u.GetName(), "kind", gvk.Kind).Info("created object")
+
+ return nil
+}
diff --git a/examples/kcp/config/main.go b/examples/kcp/config/main.go
new file mode 100644
index 00000000..f41a4931
--- /dev/null
+++ b/examples/kcp/config/main.go
@@ -0,0 +1,113 @@
+/*
+Copyright 2024 The KCP 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 main
+
+import (
+ "os"
+
+ kcpclienthelper "github.com/kcp-dev/apimachinery/v2/pkg/client"
+ apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
+ "github.com/kcp-dev/kcp/sdk/apis/core"
+ corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1"
+ tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1"
+ "github.com/kcp-dev/logicalcluster/v3"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/config"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+
+ "github.com/kcp-dev/controller-runtime/examples/kcp/config/consumers"
+ "github.com/kcp-dev/controller-runtime/examples/kcp/config/widgets"
+ widgetresources "github.com/kcp-dev/controller-runtime/examples/kcp/config/widgets/resources"
+ ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// config is bootstrap set of assets for the controller-runtime examples.
+// It includes the following assets:
+// - crds/* for the Widget type - autogenerated from the Widget type definition
+// - widgets/resources/* - a set of Widget resources for KCP to manage. Automatically generated by kcp apigen
+// see Makefile & hack/update-codegen-crds.sh for more details
+
+// It is intended to be running with higher privileges than the examples themselves
+// to ensure system (kcp) is bootstrapped. In real world scenarios, this would be
+// done by the platform operator to enable service providers to deploy their
+// controllers.
+
+func init() {
+ utilruntime.Must(tenancyv1alpha1.AddToScheme(clientgoscheme.Scheme))
+ utilruntime.Must(clientgoscheme.AddToScheme(clientgoscheme.Scheme))
+ utilruntime.Must(corev1alpha1.AddToScheme(clientgoscheme.Scheme))
+ utilruntime.Must(apisv1alpha1.AddToScheme(clientgoscheme.Scheme))
+}
+
+var (
+ // clusterName is the workspace to host common APIs.
+ clusterName = logicalcluster.NewPath("root:widgets")
+)
+
+func main() {
+ opts := zap.Options{
+ Development: true,
+ }
+ ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+
+ ctx := ctrl.SetupSignalHandler()
+ log := ctrllog.FromContext(ctx)
+
+ restConfig, err := config.GetConfigWithContext("base")
+ if err != nil {
+ log.Error(err, "unable to get config")
+ os.Exit(1)
+ }
+
+ restCopy := rest.CopyConfig(restConfig)
+ restRoot := rest.AddUserAgent(kcpclienthelper.SetCluster(restCopy, core.RootCluster.Path()), "bootstrap-root")
+ rootClient, err := client.New(restRoot, client.Options{})
+ if err != nil {
+ log.Error(err, "unable to create client")
+ os.Exit(1)
+ }
+
+ restCopy = rest.CopyConfig(restConfig)
+ restWidgets := rest.AddUserAgent(kcpclienthelper.SetCluster(restCopy, clusterName), "bootstrap-widgets")
+ widgetsClient, err := client.New(restWidgets, client.Options{})
+ if err != nil {
+ log.Error(err, "unable to create client")
+ os.Exit(1)
+ }
+
+ err = widgets.Bootstrap(ctx, rootClient)
+ if err != nil {
+ log.Error(err, "failed to bootstrap widgets")
+ os.Exit(1)
+ }
+
+ err = widgetresources.Bootstrap(ctx, widgetsClient)
+ if err != nil {
+ log.Error(err, "failed to bootstrap resources")
+ os.Exit(1)
+ }
+
+ err = consumers.Bootstrap(ctx, rootClient)
+ if err != nil {
+ log.Error(err, "failed to bootstrap consumers")
+ os.Exit(1)
+ }
+}
diff --git a/examples/kcp/config/widgets/bootstrap.go b/examples/kcp/config/widgets/bootstrap.go
new file mode 100644
index 00000000..f68414ea
--- /dev/null
+++ b/examples/kcp/config/widgets/bootstrap.go
@@ -0,0 +1,43 @@
+/*
+Copyright 2024 The KCP 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 widgets
+
+import (
+ "context"
+ "embed"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ confighelpers "github.com/kcp-dev/controller-runtime/examples/kcp/config/helpers"
+)
+
+//go:embed *.yaml
+var fs embed.FS
+
+// Bootstrap creates resources in this package by continuously retrying the list.
+// This is blocking, i.e. it only returns (with error) when the context is closed or with nil when
+// the bootstrapping is successfully completed.
+func Bootstrap(
+ ctx context.Context,
+ client client.Client,
+) error {
+ log := log.FromContext(ctx)
+
+ log.Info("Bootstrapping widgets workspace")
+ return confighelpers.Bootstrap(ctx, client, fs)
+}
diff --git a/examples/kcp/config/widgets/resources/apiexport-data.my.domain.yaml b/examples/kcp/config/widgets/resources/apiexport-data.my.domain.yaml
new file mode 100644
index 00000000..2b7bd641
--- /dev/null
+++ b/examples/kcp/config/widgets/resources/apiexport-data.my.domain.yaml
@@ -0,0 +1,16 @@
+apiVersion: apis.kcp.io/v1alpha1
+kind: APIExport
+metadata:
+ creationTimestamp: null
+ name: data.my.domain
+spec:
+ latestResourceSchemas:
+ - v240406-90e42b7b.widgets.data.my.domain
+ permissionClaims:
+ - all: true
+ resource: configmaps
+ - all: true
+ resource: secrets
+ - all: true
+ resource: namespaces
+status: {}
diff --git a/examples/kcp/config/widgets/resources/apiresourceschema-widgets.data.my.domain.yaml b/examples/kcp/config/widgets/resources/apiresourceschema-widgets.data.my.domain.yaml
new file mode 100644
index 00000000..a3d5bfbb
--- /dev/null
+++ b/examples/kcp/config/widgets/resources/apiresourceschema-widgets.data.my.domain.yaml
@@ -0,0 +1,52 @@
+apiVersion: apis.kcp.io/v1alpha1
+kind: APIResourceSchema
+metadata:
+ creationTimestamp: null
+ name: v240406-90e42b7b.widgets.data.my.domain
+spec:
+ group: data.my.domain
+ names:
+ kind: Widget
+ listKind: WidgetList
+ plural: widgets
+ singular: widget
+ scope: Namespaced
+ versions:
+ - name: v1alpha1
+ schema:
+ description: Widget is the Schema for the widgets API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: WidgetSpec defines the desired state of Widget
+ properties:
+ foo:
+ type: string
+ type: object
+ status:
+ description: WidgetStatus defines the observed state of Widget
+ properties:
+ total:
+ type: integer
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/examples/kcp/config/widgets/resources/bootstrap.go b/examples/kcp/config/widgets/resources/bootstrap.go
new file mode 100644
index 00000000..50daebb9
--- /dev/null
+++ b/examples/kcp/config/widgets/resources/bootstrap.go
@@ -0,0 +1,47 @@
+/*
+Copyright 2024 The KCP 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 resources
+
+import (
+ "context"
+ "embed"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ confighelpers "github.com/kcp-dev/controller-runtime/examples/kcp/config/helpers"
+)
+
+//go:embed *.yaml
+var fs embed.FS
+
+// Bootstrap creates resources in this package by continuously retrying the list.
+// This is blocking, i.e. it only returns (with error) when the context is closed or with nil when
+// the bootstrapping is successfully completed.
+func Bootstrap(
+ ctx context.Context,
+ client client.Client,
+) error {
+ log := log.FromContext(ctx)
+
+ log.Info("Bootstrapping widgets resources")
+ if err := confighelpers.Bootstrap(ctx, client, fs); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/examples/kcp/config/widgets/widgets-workspace.yaml b/examples/kcp/config/widgets/widgets-workspace.yaml
new file mode 100644
index 00000000..7b3c863c
--- /dev/null
+++ b/examples/kcp/config/widgets/widgets-workspace.yaml
@@ -0,0 +1,14 @@
+apiVersion: tenancy.kcp.io/v1alpha1
+kind: Workspace
+metadata:
+ name: widgets
+ annotations:
+ bootstrap.kcp.io/create-only: "true"
+spec:
+ type:
+ name: universal
+ path: root
+ location:
+ selector:
+ matchLabels:
+ name: root
diff --git a/examples/kcp/config/widgets/widgets-workspacetype.yaml b/examples/kcp/config/widgets/widgets-workspacetype.yaml
new file mode 100644
index 00000000..c9290aa3
--- /dev/null
+++ b/examples/kcp/config/widgets/widgets-workspacetype.yaml
@@ -0,0 +1,12 @@
+apiVersion: tenancy.kcp.io/v1alpha1
+kind: WorkspaceType
+metadata:
+ name: widgets
+spec:
+ extend:
+ with:
+ - name: universal
+ path: root
+ defaultAPIBindings:
+ - path: root:widgets
+ export: data.my.domain
diff --git a/examples/kcp/controllers/configmap/reconciler.go b/examples/kcp/controllers/configmap/reconciler.go
new file mode 100644
index 00000000..b08c3279
--- /dev/null
+++ b/examples/kcp/controllers/configmap/reconciler.go
@@ -0,0 +1,145 @@
+/*
+Copyright 2024 The KCP 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 configmap
+
+import (
+ "context"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/kcp"
+
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+type Reconciler struct {
+ Client client.Client
+}
+
+func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ log := log.FromContext(ctx).WithValues("cluster", req.ClusterName)
+
+ // Test get
+ var cm corev1.ConfigMap
+ if err := r.Client.Get(ctx, req.NamespacedName, &cm); err != nil {
+ log.Error(err, "unable to get configmap")
+ return ctrl.Result{}, nil
+ }
+
+ log.Info("Get: retrieved configMap")
+ if cm.Labels["name"] != "" {
+ response := fmt.Sprintf("hello-%s", cm.Labels["name"])
+
+ if cm.Labels["response"] != response {
+ cm.Labels["response"] = response
+
+ // Test Update
+ if err := r.Client.Update(ctx, &cm); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ log.Info("Update: updated configMap")
+ return ctrl.Result{}, nil
+ }
+ }
+
+ // Test list
+ var cms corev1.ConfigMapList
+ if err := r.Client.List(ctx, &cms); err != nil {
+ log.Error(err, "unable to list configmaps")
+ return ctrl.Result{}, nil
+ }
+ log.Info("List: got", "itemCount", len(cms.Items))
+ found := false
+ for _, other := range cms.Items {
+ cluster, ok := kontext.ClusterFrom(ctx)
+ if !ok {
+ log.Info("List: got", "clusterName", cluster.String(), "namespace", other.Namespace, "name", other.Name)
+ } else if other.Name == cm.Name && other.Namespace == cm.Namespace {
+ if found {
+ return ctrl.Result{}, fmt.Errorf("there should be listed only one configmap with the given name '%s' for the given namespace '%s' when the clusterName is not available", cm.Name, cm.Namespace)
+ }
+ found = true
+ log.Info("Found in listed configmaps", "namespace", cm.Namespace, "name", cm.Name)
+ }
+ }
+
+ // If the configmap has a namespace field, create the corresponding namespace
+ nsName, exists := cm.Data["namespace"]
+ if exists {
+ var namespace corev1.Namespace
+ if err := r.Client.Get(ctx, types.NamespacedName{Name: nsName}, &namespace); err != nil {
+ if !apierrors.IsNotFound(err) {
+ log.Error(err, "unable to get namespace")
+ return ctrl.Result{}, err
+ }
+
+ // Need to create ns
+ namespace.SetName(nsName)
+ if err = r.Client.Create(ctx, &namespace); err != nil {
+ log.Error(err, "unable to create namespace")
+ return ctrl.Result{}, err
+ }
+ log.Info("Create: created ", "namespace", nsName)
+ return ctrl.Result{Requeue: true}, nil
+ }
+ log.Info("Exists", "namespace", nsName)
+ }
+
+ // If the configmap has a secretData field, create a secret in the same namespace
+ // If the secret already exists but is out of sync, it will be non-destructively patched
+ secretData, exists := cm.Data["secretData"]
+ if exists {
+ var secret corev1.Secret
+ secret.SetName(cm.GetName())
+ secret.SetNamespace(cm.GetNamespace())
+ secret.SetOwnerReferences([]metav1.OwnerReference{{
+ Name: cm.GetName(),
+ UID: cm.GetUID(),
+ APIVersion: "v1",
+ Kind: "ConfigMap",
+ Controller: func() *bool { x := true; return &x }(),
+ }})
+ secret.Data = map[string][]byte{"dataFromCM": []byte(secretData)}
+
+ operationResult, err := controllerutil.CreateOrPatch(ctx, r.Client, &secret, func() error {
+ secret.Data["dataFromCM"] = []byte(secretData)
+ return nil
+ })
+ if err != nil {
+ log.Error(err, "unable to create or patch secret")
+ return ctrl.Result{}, err
+ }
+ log.Info(string(operationResult), "secret", secret.GetName())
+ }
+
+ return ctrl.Result{}, nil
+}
+
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&corev1.ConfigMap{}).
+ Owns(&corev1.Secret{}).
+ Complete(kcp.WithClusterInContext(r))
+}
diff --git a/examples/kcp/controllers/widget/reconciler.go b/examples/kcp/controllers/widget/reconciler.go
new file mode 100644
index 00000000..0e33f2f8
--- /dev/null
+++ b/examples/kcp/controllers/widget/reconciler.go
@@ -0,0 +1,83 @@
+/*
+Copyright 2024 The KCP 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 widget
+
+import (
+ "context"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/kcp"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ datav1alpha1 "github.com/kcp-dev/controller-runtime/examples/kcp/apis/v1alpha1"
+)
+
+// Reconciler reconciles a Widget object
+type Reconciler struct {
+ Client client.Client
+}
+
+// Reconcile TODO
+func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ log := log.FromContext(ctx)
+
+ // Include the clusterName from req.ObjectKey in the logger, similar to the namespace and name keys that are already
+ // there.
+ log = log.WithValues("clusterName", req.ClusterName)
+
+ // You probably wouldn't need to do this, but if you wanted to list all instances across all logical clusters:
+ var allWidgets datav1alpha1.WidgetList
+ if err := r.Client.List(ctx, &allWidgets); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ log.Info("Listed all widgets across all workspaces", "count", len(allWidgets.Items))
+
+ log.Info("Getting widget")
+ var w datav1alpha1.Widget
+ if err := r.Client.Get(ctx, req.NamespacedName, &w); err != nil {
+ if errors.IsNotFound(err) {
+ // Normal - was deleted
+ return ctrl.Result{}, nil
+ }
+ return ctrl.Result{}, err
+ }
+
+ log.Info("Listing all widgets in the current logical cluster")
+ var list datav1alpha1.WidgetList
+ if err := r.Client.List(ctx, &list); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ log.Info("Patching widget status to store total widget count in the current logical cluster")
+ orig := w.DeepCopy()
+ w.Status.Total = len(list.Items)
+ if err := r.Client.Status().Patch(ctx, &w, client.MergeFromWithOptions(orig, client.MergeFromWithOptimisticLock{})); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&datav1alpha1.Widget{}).
+ Complete(kcp.WithClusterInContext(r))
+}
diff --git a/examples/kcp/go.mod b/examples/kcp/go.mod
new file mode 100644
index 00000000..27002ea5
--- /dev/null
+++ b/examples/kcp/go.mod
@@ -0,0 +1,112 @@
+module github.com/kcp-dev/controller-runtime/examples/kcp
+
+go 1.22.0
+
+// IMPORTANT: This is only an example replace directive. This is so examples can be run with the latest version of controller-runtime.
+// In your own projects, you should not use replace directives like this. Instead, you should replace, but with kcp-dev/controller-runtime instead of ../../
+replace sigs.k8s.io/controller-runtime => ../../
+
+require (
+ github.com/google/go-cmp v0.6.0
+ github.com/kcp-dev/apimachinery/v2 v2.0.0
+ github.com/kcp-dev/kcp/sdk v0.24.0
+ github.com/kcp-dev/logicalcluster/v3 v3.0.5
+ k8s.io/api v0.31.1
+ k8s.io/apiextensions-apiserver v0.31.1
+ k8s.io/apimachinery v0.31.1
+ k8s.io/client-go v0.31.1
+ k8s.io/klog/v2 v2.130.1
+ sigs.k8s.io/controller-runtime v0.19.0
+)
+
+require (
+ github.com/NYTimes/gziphandler v1.1.1 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/blang/semver/v4 v4.0.0 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/coreos/go-semver v0.3.1 // indirect
+ github.com/coreos/go-systemd/v22 v22.5.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.12.1 // indirect
+ github.com/evanphx/json-patch/v5 v5.9.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // 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.21.0 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/cel-go v0.20.1 // indirect
+ github.com/google/gnostic-models v0.6.8 // indirect
+ github.com/google/gofuzz v1.2.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/imdario/mergo v0.3.16 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/onsi/gomega v1.33.1 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/prometheus/client_golang v1.19.1 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/spf13/cobra v1.8.1 // indirect
+ github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace // indirect
+ github.com/stoewer/go-strcase v1.3.0 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.etcd.io/etcd/api/v3 v3.5.14 // indirect
+ go.etcd.io/etcd/client/pkg/v3 v3.5.14 // indirect
+ go.etcd.io/etcd/client/v3 v3.5.14 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
+ go.opentelemetry.io/otel v1.28.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
+ go.opentelemetry.io/otel/metric v1.28.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.28.0 // indirect
+ go.opentelemetry.io/otel/trace v1.28.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.3.1 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.26.0 // indirect
+ golang.org/x/crypto v0.27.0 // indirect
+ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
+ golang.org/x/net v0.29.0 // indirect
+ golang.org/x/oauth2 v0.23.0 // indirect
+ golang.org/x/sync v0.8.0 // indirect
+ golang.org/x/sys v0.25.0 // indirect
+ golang.org/x/term v0.24.0 // indirect
+ golang.org/x/text v0.18.0 // indirect
+ golang.org/x/time v0.6.0 // indirect
+ gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
+ google.golang.org/grpc v1.65.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/apiserver v0.31.1 // indirect
+ k8s.io/component-base v0.31.1 // indirect
+ k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 // indirect
+ k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect
+ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect
+ sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
+ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
+ sigs.k8s.io/yaml v1.4.0 // indirect
+)
diff --git a/examples/kcp/go.sum b/examples/kcp/go.sum
new file mode 100644
index 00000000..8a043a34
--- /dev/null
+++ b/examples/kcp/go.sum
@@ -0,0 +1,308 @@
+github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
+github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
+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/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
+github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
+github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
+github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
+github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
+github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/go-logr/logr v1.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/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=
+github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
+github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
+github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+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.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
+github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
+github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84=
+github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg=
+github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
+github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/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-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
+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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
+github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
+github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
+github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
+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/kcp-dev/apimachinery/v2 v2.0.0 h1:hQuhBBh+AvUYYMRG+nDzo1VXxNCdMAE95wSD2uB7nxw=
+github.com/kcp-dev/apimachinery/v2 v2.0.0/go.mod h1:cXCx7fku8/rYK23PNEBRLQ5ByoABoA+CZeJNC81TO0g=
+github.com/kcp-dev/kcp/sdk v0.24.0 h1:ZTfStDOQshVU2cnrqjgMo9xb0VNblkmrgMRtl0PCQEY=
+github.com/kcp-dev/kcp/sdk v0.24.0/go.mod h1:Pd2xxw/qhgfF2xgHolVwheq9VOJwPtNrBmxgBlYmjfk=
+github.com/kcp-dev/logicalcluster/v3 v3.0.5 h1:JbYakokb+5Uinz09oTXomSUJVQsqfxEvU4RyHUYxHOU=
+github.com/kcp-dev/logicalcluster/v3 v3.0.5/go.mod h1:EWBUBxdr49fUB1cLMO4nOdBWmYifLbP1LfoL20KkXYY=
+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/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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/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.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
+github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
+github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
+github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
+github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
+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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
+github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace h1:9PNP1jnUjRhfmGMlkXHjYPishpcw4jpSt/V/xYY3FMA=
+github.com/spf13/pflag v1.0.6-0.20210604193023-d5e0c0615ace/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=
+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=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
+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/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
+go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
+go.etcd.io/etcd/api/v3 v3.5.14 h1:vHObSCxyB9zlF60w7qzAdTcGaglbJOpSj1Xj9+WGxq0=
+go.etcd.io/etcd/api/v3 v3.5.14/go.mod h1:BmtWcRlQvwa1h3G2jvKYwIQy4PkHlDej5t7uLMUdJUU=
+go.etcd.io/etcd/client/pkg/v3 v3.5.14 h1:SaNH6Y+rVEdxfpA2Jr5wkEvN6Zykme5+YnbCkxvuWxQ=
+go.etcd.io/etcd/client/pkg/v3 v3.5.14/go.mod h1:8uMgAokyG1czCtIdsq+AGyYQMvpIKnSvPjFMunkgeZI=
+go.etcd.io/etcd/client/v2 v2.305.13 h1:RWfV1SX5jTU0lbCvpVQe3iPQeAHETWdOTb6pxhd77C8=
+go.etcd.io/etcd/client/v2 v2.305.13/go.mod h1:iQnL7fepbiomdXMb3om1rHq96htNNGv2sJkEcZGDRRg=
+go.etcd.io/etcd/client/v3 v3.5.14 h1:CWfRs4FDaDoSz81giL7zPpZH2Z35tbOrAJkkjMqOupg=
+go.etcd.io/etcd/client/v3 v3.5.14/go.mod h1:k3XfdV/VIHy/97rqWjoUzrj9tk7GgJGH9J8L4dNXmAk=
+go.etcd.io/etcd/pkg/v3 v3.5.13 h1:st9bDWNsKkBNpP4PR1MvM/9NqUPfvYZx/YXegsYEH8M=
+go.etcd.io/etcd/pkg/v3 v3.5.13/go.mod h1:N+4PLrp7agI/Viy+dUYpX7iRtSPvKq+w8Y14d1vX+m0=
+go.etcd.io/etcd/raft/v3 v3.5.13 h1:7r/NKAOups1YnKcfro2RvGGo2PTuizF/xh26Z2CTAzA=
+go.etcd.io/etcd/raft/v3 v3.5.13/go.mod h1:uUFibGLn2Ksm2URMxN1fICGhk8Wu96EfDQyuLhAcAmw=
+go.etcd.io/etcd/server/v3 v3.5.13 h1:V6KG+yMfMSqWt+lGnhFpP5z5dRUj1BDRJ5k1fQ9DFok=
+go.etcd.io/etcd/server/v3 v3.5.13/go.mod h1:K/8nbsGupHqmr5MkgaZpLlH1QdX1pcNQLAkODy44XcQ=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
+go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
+go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
+go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
+go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
+go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
+go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
+go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
+go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
+go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
+go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
+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.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
+go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+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/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
+golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
+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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
+golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
+golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
+golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
+golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
+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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
+golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
+golang.org/x/time v0.6.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.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
+golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
+gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
+google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
+google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
+google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
+google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
+google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+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.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
+k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
+k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40=
+k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ=
+k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
+k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
+k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
+k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
+k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
+k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
+k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
+k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w=
+k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
+k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
+k8s.io/kms v0.31.1 h1:cGLyV3cIwb0ovpP/jtyIe2mEuQ/MkbhmeBF2IYCA9Io=
+k8s.io/kms v0.31.1/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94=
+k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 h1:1dWzkmJrrprYvjGwh9kEUxmcUV/CtNU8QM7h1FLWQOo=
+k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA=
+k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY9mD9fNT47QO6HI=
+k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY=
+sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
+sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
+sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
+sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
+sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
+sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
diff --git a/examples/kcp/hack/go-install.sh b/examples/kcp/hack/go-install.sh
new file mode 100755
index 00000000..dd323c56
--- /dev/null
+++ b/examples/kcp/hack/go-install.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+
+# Copyright 2024 The KCP 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.
+
+# Originally copied from
+# https://github.com/kubernetes-sigs/cluster-api-provider-gcp/blob/c26a68b23e9317323d5d37660fe9d29b3d2ff40c/scripts/go_install.sh
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+if [[ -z "${1:-}" ]]; then
+ echo "must provide module as first parameter"
+ exit 1
+fi
+
+if [[ -z "${2:-}" ]]; then
+ echo "must provide binary name as second parameter"
+ exit 1
+fi
+
+if [[ -z "${3:-}" ]]; then
+ echo "must provide version as third parameter"
+ exit 1
+fi
+
+if [[ -z "${GOBIN:-}" ]]; then
+ echo "GOBIN is not set. Must set GOBIN to install the bin in a specified directory."
+ exit 1
+fi
+
+mkdir -p "${GOBIN}"
+
+tmp_dir=$(mktemp -d -t goinstall_XXXXXXXXXX)
+function clean {
+ rm -rf "${tmp_dir}"
+}
+trap clean EXIT
+
+rm "${GOBIN}/${2}"* > /dev/null 2>&1 || true
+
+cd "${tmp_dir}"
+
+# create a new module in the tmp directory
+go mod init fake/mod
+
+# install the golang module specified as the first argument
+go install -tags kcptools "${1}@${3}"
+mv "${GOBIN}/${2}" "${GOBIN}/${2}-${3}"
+ln -sf "${GOBIN}/${2}-${3}" "${GOBIN}/${2}"
diff --git a/examples/kcp/hack/tools/go.mod b/examples/kcp/hack/tools/go.mod
new file mode 100644
index 00000000..e2c3eac3
--- /dev/null
+++ b/examples/kcp/hack/tools/go.mod
@@ -0,0 +1,61 @@
+module sigs.k8s.io/controller-runtime/hack/tools
+
+go 1.21
+
+toolchain go1.21.5
+
+require (
+ github.com/joelanford/go-apidiff v0.8.2
+ sigs.k8s.io/controller-tools v0.14.0
+)
+
+require (
+ dario.cat/mergo v1.0.0 // indirect
+ github.com/Microsoft/go-winio v0.6.1 // indirect
+ github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
+ github.com/cloudflare/circl v1.3.7 // indirect
+ github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/fatih/color v1.16.0 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.5.0 // indirect
+ github.com/go-git/go-git/v5 v5.11.0 // indirect
+ github.com/go-logr/logr v1.3.0 // indirect
+ github.com/gobuffalo/flect v1.0.2 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/google/gofuzz v1.2.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pjbgf/sha1cd v0.3.0 // indirect
+ github.com/sergi/go-diff v1.1.0 // indirect
+ github.com/skeema/knownhosts v1.2.1 // indirect
+ github.com/spf13/cobra v1.8.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ golang.org/x/crypto v0.18.0 // indirect
+ golang.org/x/exp v0.0.0-20230811145653-3b0b5b66b5f1 // indirect
+ golang.org/x/mod v0.14.0 // indirect
+ golang.org/x/net v0.20.0 // indirect
+ golang.org/x/sys v0.16.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ golang.org/x/tools v0.17.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/api v0.29.0 // indirect
+ k8s.io/apiextensions-apiserver v0.29.0 // indirect
+ k8s.io/apimachinery v0.29.0 // indirect
+ k8s.io/klog/v2 v2.110.1 // indirect
+ k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
+ sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
+ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
+ sigs.k8s.io/yaml v1.4.0 // indirect
+)
diff --git a/examples/kcp/hack/tools/go.sum b/examples/kcp/hack/tools/go.sum
new file mode 100644
index 00000000..151b3772
--- /dev/null
+++ b/examples/kcp/hack/tools/go.sum
@@ -0,0 +1,240 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
+github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
+github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
+github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
+github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
+github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
+github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
+github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA=
+github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/joelanford/go-apidiff v0.8.2 h1:AvHPY3vYINr6I2xGMHqhDKoszpdsDmH4VHZtit6NJKk=
+github.com/joelanford/go-apidiff v0.8.2/go.mod h1:3fPoVVLpPCaU8aOuR7X1xDABzcWbLGKeeMerR2Pxulk=
+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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
+github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
+github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
+github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/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=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+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/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
+golang.org/x/exp v0.0.0-20230811145653-3b0b5b66b5f1 h1:EFPukSCgigmk1W0azH8EMt97AoMjMOgtJ3Z3sGM9AGw=
+golang.org/x/exp v0.0.0-20230811145653-3b0b5b66b5f1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
+golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
+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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
+golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
+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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
+golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
+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=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+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.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A=
+k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA=
+k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0=
+k8s.io/apiextensions-apiserver v0.29.0/go.mod h1:TKmpy3bTS0mr9pylH0nOt/QzQRrW7/h7yLdRForMZwc=
+k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o=
+k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis=
+k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
+k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
+k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
+k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A=
+sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc=
+sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
+sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
+sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
+sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
+sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
diff --git a/examples/kcp/hack/tools/tools.go b/examples/kcp/hack/tools/tools.go
new file mode 100644
index 00000000..481a7c6f
--- /dev/null
+++ b/examples/kcp/hack/tools/tools.go
@@ -0,0 +1,25 @@
+// +build tools
+
+/*
+Copyright 2019 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.
+*/
+
+// This package imports things required by build scripts, to force `go mod` to see them as dependencies
+package tools
+
+import (
+ _ "github.com/joelanford/go-apidiff"
+ _ "sigs.k8s.io/controller-tools/cmd/controller-gen"
+)
diff --git a/examples/kcp/hack/update-codegen-crds.sh b/examples/kcp/hack/update-codegen-crds.sh
new file mode 100755
index 00000000..03524149
--- /dev/null
+++ b/examples/kcp/hack/update-codegen-crds.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+
+# Copyright 2024 The KCP 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.
+
+set -o errexit
+set -o nounset
+set -o pipefail
+set -o xtrace
+
+if [[ -z "${CONTROLLER_GEN:-}" ]]; then
+ echo "You must either set CONTROLLER_GEN to the path to controller-gen or invoke via make"
+ exit 1
+fi
+
+REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+
+# Update generated CRD YAML
+(
+ cd "${REPO_ROOT}/apis"
+ "${CONTROLLER_GEN}" \
+ crd \
+ rbac:roleName=manager-role \
+ webhook \
+ paths="./..." \
+ output:crd:artifacts:config="${REPO_ROOT}"/config/crds
+)
+
+for CRD in "${REPO_ROOT}"/config/crds/*.yaml; do
+ if [ -f "${CRD}-patch" ]; then
+ echo "Applying ${CRD}"
+ ${YAML_PATCH} -o "${CRD}-patch" < "${CRD}" > "${CRD}.patched"
+ mv "${CRD}.patched" "${CRD}"
+ fi
+done
+
+(
+ ${KCP_APIGEN_GEN} --input-dir "${REPO_ROOT}"/config/crds --output-dir "${REPO_ROOT}"/config/widgets/resources
+)
diff --git a/examples/kcp/main.go b/examples/kcp/main.go
new file mode 100644
index 00000000..64806434
--- /dev/null
+++ b/examples/kcp/main.go
@@ -0,0 +1,199 @@
+/*
+Copyright 2024 The KCP 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 main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+
+ kcpclienthelper "github.com/kcp-dev/apimachinery/v2/pkg/client"
+ "github.com/kcp-dev/controller-runtime/examples/kcp/controllers/configmap"
+ "github.com/kcp-dev/controller-runtime/examples/kcp/controllers/widget"
+ "k8s.io/apimachinery/pkg/types"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ "k8s.io/client-go/discovery"
+ "k8s.io/client-go/kubernetes/scheme"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+ "k8s.io/klog/v2"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/config"
+
+ datav1alpha1 "github.com/kcp-dev/controller-runtime/examples/kcp/apis/v1alpha1"
+ "github.com/kcp-dev/logicalcluster/v3"
+
+ // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
+ // to ensure that exec-entrypoint and run can make use of them.
+ _ "k8s.io/client-go/plugin/pkg/client/auth"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/healthz"
+ "sigs.k8s.io/controller-runtime/pkg/kcp"
+
+ apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
+)
+
+func init() {
+ utilruntime.Must(clientgoscheme.AddToScheme(scheme.Scheme))
+ utilruntime.Must(datav1alpha1.AddToScheme(scheme.Scheme))
+ utilruntime.Must(apisv1alpha1.AddToScheme(scheme.Scheme))
+}
+
+func main() {
+ var opts Options
+ opts.addFlags(flag.CommandLine)
+ flag.Parse()
+ flag.Lookup("v").Value.Set("6")
+
+ ctx := ctrl.SetupSignalHandler()
+ if err := runController(ctx, opts); err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(1)
+ }
+}
+
+type Options struct {
+ MetricsAddr string
+ EnableLeaderElection bool
+ ProbeAddr string
+ APIExportName string
+ KubeconfigContext string
+}
+
+func (o *Options) addFlags(fs *flag.FlagSet) {
+ fs.StringVar(&o.KubeconfigContext, "context", "", "kubeconfig context")
+ fs.StringVar(&o.APIExportName, "api-export-name", "data.my.domain", "The name of the APIExport.")
+ fs.StringVar(&o.MetricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
+ fs.StringVar(&o.ProbeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
+ fs.BoolVar(&o.EnableLeaderElection, "leader-elect", false,
+ "Enable leader election for controller manager. "+
+ "Enabling this will ensure there is only one active controller manager.")
+
+ klog.InitFlags(fs)
+}
+
+func runController(ctx context.Context, opts Options) error {
+ log := ctrl.Log.WithName("setup").WithValues("api-export-name", opts.APIExportName)
+
+ // Important: We use non-controller-runtime client loader so we can always
+ // be sure we have correct kubeconfig file. This ease the development and maintenance
+ // of the example. In production, you should use the controller-runtime client loader
+ // to load the kubeconfig file dedicated to workspace where APIExport is located.
+ // restConfig := ctrl.GetConfigOrDie()
+ widgetsCluster := logicalcluster.NewPath("root:widgets")
+ widgetsConfig, err := config.GetConfigWithContext("base")
+ if err != nil {
+ return fmt.Errorf("unable to get config: %w", err)
+ }
+ widgetsConfig = rest.AddUserAgent(kcpclienthelper.SetCluster(widgetsConfig, widgetsCluster), "kcp-controller-runtime-example")
+
+ ctrlOpts := ctrl.Options{
+ HealthProbeBindAddress: opts.ProbeAddr,
+ LeaderElection: opts.EnableLeaderElection,
+ LeaderElectionID: "68a0532d.my.domain",
+ LeaderElectionConfig: widgetsConfig,
+ }
+
+ // create a manager, either with or without kcp support
+ var mgr ctrl.Manager
+ if isKcp, err := kcpAPIsGroupPresent(widgetsConfig); err != nil {
+ return fmt.Errorf("error checking for kcp APIs group: %w", err)
+ } else if isKcp {
+ log.Info("Looking up virtual workspace URL")
+ exportConfig, err := restConfigForAPIExport(ctx, widgetsConfig, opts.APIExportName)
+ if err != nil {
+ return fmt.Errorf("error looking up virtual workspace URL: %w", err)
+ }
+ log.Info("Using virtual workspace URL", "url", exportConfig.Host)
+
+ mgr, err = kcp.NewClusterAwareManager(exportConfig, ctrlOpts)
+ if err != nil {
+ return fmt.Errorf("unable to create cluster aware manager: %w", err)
+ }
+ } else {
+ log.Info("The apis.kcp.dev group is not present - creating standard manager")
+ mgr, err = ctrl.NewManager(widgetsConfig, ctrlOpts)
+ if err != nil {
+ return fmt.Errorf("unable to create manager: %w", err)
+ }
+ }
+
+ // create controllers
+ if err = (&configmap.Reconciler{Client: mgr.GetClient()}).SetupWithManager(mgr); err != nil {
+ return fmt.Errorf("unable to create configmap controller: %w", err)
+ }
+ if err = (&widget.Reconciler{Client: mgr.GetClient()}).SetupWithManager(mgr); err != nil {
+ return fmt.Errorf("unable to create widget controller: %w", err)
+ }
+
+ if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
+ return fmt.Errorf("unable to set up health check: %w", err)
+ }
+ if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
+ return fmt.Errorf("unable to set up ready check: %w", err)
+ }
+
+ log.Info("Starting manager")
+ return mgr.Start(ctx)
+}
+
+// restConfigForAPIExport returns a *rest.Config properly configured to communicate with the endpoint for the
+// APIExport's virtual workspace.
+func restConfigForAPIExport(ctx context.Context, cfg *rest.Config, apiExportName string) (*rest.Config, error) {
+ apiExportClient, err := client.New(cfg, client.Options{})
+ if err != nil {
+ return nil, fmt.Errorf("error creating APIExport client: %w", err)
+ }
+
+ var apiExport apisv1alpha1.APIExport
+ if err := apiExportClient.Get(ctx, types.NamespacedName{Name: apiExportName}, &apiExport); err != nil {
+ return nil, fmt.Errorf("error getting APIExport %q: %w", apiExportName, err)
+ }
+
+ if len(apiExport.Status.VirtualWorkspaces) < 1 {
+ return nil, fmt.Errorf("APIExport %q status.virtualWorkspaces is empty", apiExportName)
+ }
+
+ // create a new rest.Config with the APIExport's virtual workspace URL
+ exportConfig := rest.CopyConfig(cfg)
+ exportConfig.Host = apiExport.Status.VirtualWorkspaces[0].URL // TODO(ncdc): sharding support
+
+ return exportConfig, nil
+}
+
+func kcpAPIsGroupPresent(cfg *rest.Config) (bool, error) {
+ discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg)
+ if err != nil {
+ return false, fmt.Errorf("failed to create discovery client: %w", err)
+ }
+ apiGroupList, err := discoveryClient.ServerGroups()
+ if err != nil {
+ return false, fmt.Errorf("failed to get server groups: %w", err)
+ }
+
+ for _, group := range apiGroupList.Groups {
+ if group.Name == apisv1alpha1.SchemeGroupVersion.Group {
+ for _, version := range group.Versions {
+ if version.Version == apisv1alpha1.SchemeGroupVersion.Version {
+ return true, nil
+ }
+ }
+ }
+ }
+ return false, nil
+}
diff --git a/examples/kcp/test/e2e/audit-policy.yaml b/examples/kcp/test/e2e/audit-policy.yaml
new file mode 100644
index 00000000..9b1b0384
--- /dev/null
+++ b/examples/kcp/test/e2e/audit-policy.yaml
@@ -0,0 +1,30 @@
+apiVersion: audit.k8s.io/v1
+kind: Policy
+omitStages:
+ - RequestReceived
+omitManagedFields: true
+rules:
+ - level: None
+ nonResourceURLs:
+ - "/api*"
+ - "/version"
+
+ - level: Metadata
+ resources:
+ - group: ""
+ resources: ["secrets", "configmaps"]
+ - group: "authorization.k8s.io"
+ resources: ["subjectaccessreviews"]
+
+ - level: Metadata
+ verbs: ["list", "watch"]
+
+ - level: Metadata
+ verbs: ["get", "delete"]
+ omitStages:
+ - ResponseStarted
+
+ - level: RequestResponse
+ verbs: ["create", "update", "patch"]
+ omitStages:
+ - ResponseStarted
diff --git a/examples/kcp/test/e2e/controller_test.go b/examples/kcp/test/e2e/controller_test.go
new file mode 100644
index 00000000..8a28ddf0
--- /dev/null
+++ b/examples/kcp/test/e2e/controller_test.go
@@ -0,0 +1,336 @@
+package e2e
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "math/rand"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+
+ kcpclienthelper "github.com/kcp-dev/apimachinery/v2/pkg/client"
+ apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
+ corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1"
+ tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1"
+ "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/util/conditions"
+
+ "github.com/kcp-dev/logicalcluster/v3"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/errors"
+ "k8s.io/apimachinery/pkg/util/wait"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/config"
+
+ datav1alpha1 "github.com/kcp-dev/controller-runtime/examples/kcp/apis/v1alpha1"
+)
+
+// The tests in this package expect to be called when:
+// - kcp is running
+// - the controller-manager from this repo is running
+//
+// We can then check that the controllers defined here are working as expected.
+
+var workspaceName string
+
+func init() {
+ rand.Seed(time.Now().Unix())
+ flag.StringVar(&workspaceName, "workspace", "", "Workspace in which to run these tests.")
+}
+
+func parentWorkspace(t *testing.T) logicalcluster.Path {
+ if workspaceName == "" {
+ t.Fatal("--workspace cannot be empty")
+ }
+
+ return logicalcluster.NewPath(workspaceName)
+}
+
+func loadClusterConfig(t *testing.T, clusterName logicalcluster.Path) *rest.Config {
+ t.Helper()
+ restConfig, err := config.GetConfigWithContext("base")
+ if err != nil {
+ t.Fatalf("failed to load *rest.Config: %v", err)
+ }
+ return rest.AddUserAgent(kcpclienthelper.SetCluster(restConfig, clusterName), t.Name())
+}
+
+func loadClient(t *testing.T, clusterName logicalcluster.Path) client.Client {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add client go to scheme: %v", err)
+ }
+ if err := tenancyv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add %s to scheme: %v", tenancyv1alpha1.SchemeGroupVersion, err)
+ }
+ if err := datav1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add %s to scheme: %v", datav1alpha1.GroupVersion, err)
+ }
+ if err := apisv1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add %s to scheme: %v", apisv1alpha1.SchemeGroupVersion, err)
+ }
+ tenancyClient, err := client.New(loadClusterConfig(t, clusterName), client.Options{Scheme: scheme})
+ if err != nil {
+ t.Fatalf("failed to create a client: %v", err)
+ }
+ return tenancyClient
+}
+
+func createWorkspace(t *testing.T, clusterName logicalcluster.Path) client.Client {
+ t.Helper()
+ parent, ok := clusterName.Parent()
+ if !ok {
+ t.Fatalf("cluster %s has no parent", clusterName)
+ }
+ c := loadClient(t, parent)
+ t.Logf("creating workspace %s", clusterName)
+ if err := c.Create(context.TODO(), &tenancyv1alpha1.Workspace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: clusterName.Base(),
+ },
+ Spec: tenancyv1alpha1.WorkspaceSpec{
+ Type: tenancyv1alpha1.WorkspaceTypeReference{
+ Name: "widgets",
+ Path: "root",
+ },
+ },
+ }); err != nil {
+ t.Fatalf("failed to create workspace: %s: %v", clusterName, err)
+ }
+
+ t.Logf("waiting for workspace %s to be ready", clusterName)
+ var workspace tenancyv1alpha1.Workspace
+ if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (done bool, err error) {
+ fetchErr := c.Get(context.TODO(), client.ObjectKey{Name: clusterName.Base()}, &workspace)
+ if fetchErr != nil {
+ t.Logf("failed to get workspace %s: %v", clusterName, err)
+ return false, fetchErr
+ }
+ var reason string
+ if actual, expected := workspace.Status.Phase, corev1alpha1.LogicalClusterPhaseReady; actual != expected {
+ reason = fmt.Sprintf("phase is %s, not %s", actual, expected)
+ t.Logf("not done waiting for workspace %s to be ready: %s", clusterName, reason)
+ }
+ return reason == "", nil
+ }); err != nil {
+ t.Fatalf("workspace %s never ready: %v", clusterName, err)
+ }
+
+ return waitingForAPIBinding(t, clusterName)
+}
+
+func waitingForAPIBinding(t *testing.T, workspaceCluster logicalcluster.Path) client.Client {
+ c := loadClient(t, workspaceCluster)
+ ctx := context.TODO()
+ apiNamePrefix := "data.my.domain" // matches bootstrapped name
+
+ list := &apisv1alpha1.APIBindingList{}
+ err := c.List(ctx, list)
+ if err != nil {
+ t.Fatalf("failed to list APIBindings: %v", err)
+ }
+
+ apiName := ""
+ for _, apiBinding := range list.Items {
+ if strings.HasPrefix(apiBinding.Name, apiNamePrefix) {
+ apiName = apiBinding.Name
+ break
+ }
+ }
+
+ t.Logf("waiting for APIBinding %s|%s to be bound", workspaceCluster, apiName)
+ var apiBinding apisv1alpha1.APIBinding
+ if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (done bool, err error) {
+ fetchErr := c.Get(context.TODO(), client.ObjectKey{Name: apiName}, &apiBinding)
+ if fetchErr != nil {
+ t.Logf("failed to get APIBinding %s|%s: %v", workspaceCluster, apiName, err)
+ return false, fetchErr
+ }
+ var reason string
+ if !conditions.IsTrue(&apiBinding, apisv1alpha1.InitialBindingCompleted) {
+ condition := conditions.Get(&apiBinding, apisv1alpha1.InitialBindingCompleted)
+ if condition != nil {
+ reason = fmt.Sprintf("%s: %s", condition.Reason, condition.Message)
+ } else {
+ reason = "no condition present"
+ }
+ t.Logf("not done waiting for APIBinding %s|%s to be bound: %s", workspaceCluster, apiName, reason)
+ }
+ return conditions.IsTrue(&apiBinding, apisv1alpha1.InitialBindingCompleted), nil
+ }); err != nil {
+ t.Fatalf("APIBinding %s|%s never bound: %v", workspaceCluster, apiName, err)
+ }
+
+ return c
+}
+
+const characters = "abcdefghijklmnopqrstuvwxyz"
+
+func randomName() string {
+ b := make([]byte, 10)
+ for i := range b {
+ b[i] = characters[rand.Intn(len(characters))]
+ }
+ return string(b)
+}
+
+// TestConfigMapController verifies that our ConfigMap behavior works.
+func TestConfigMapController(t *testing.T) {
+ t.Parallel()
+ for i := 0; i < 3; i++ {
+ t.Run(fmt.Sprintf("attempt-%d", i), func(t *testing.T) {
+ t.Parallel()
+ workspaceCluster := parentWorkspace(t).Join(randomName())
+ c := createWorkspace(t, workspaceCluster)
+
+ namespaceName := randomName()
+ t.Logf("creating namespace %s|%s", workspaceCluster, namespaceName)
+ if err := c.Create(context.TODO(), &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: namespaceName},
+ }); err != nil {
+ t.Fatalf("failed to create a namespace: %v", err)
+ }
+
+ otherNamespaceName := randomName()
+ data := randomName()
+ configmapName := randomName()
+ t.Logf("creating configmap %s|%s/%s", workspaceCluster, namespaceName, configmapName)
+ if err := c.Create(context.TODO(), &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: configmapName,
+ Namespace: namespaceName,
+ Labels: map[string]string{
+ "name": "timothy",
+ },
+ },
+ Data: map[string]string{
+ "namespace": otherNamespaceName,
+ "secretData": data,
+ },
+ }); err != nil {
+ t.Fatalf("failed to create a configmap: %v", err)
+ }
+
+ t.Logf("waiting for configmap %s|%s to have a response", workspaceCluster, configmapName)
+ var configmap corev1.ConfigMap
+ if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (done bool, err error) {
+ fetchErr := c.Get(context.TODO(), client.ObjectKey{Namespace: namespaceName, Name: configmapName}, &configmap)
+ if fetchErr != nil {
+ t.Logf("failed to get configmap %s|%s/%s: %v", workspaceCluster, namespaceName, configmapName, err)
+ return false, fetchErr
+ }
+ response, ok := configmap.Labels["response"]
+ if !ok {
+ t.Logf("configmap %s|%s/%s has no response set", workspaceCluster, namespaceName, configmapName)
+ }
+ diff := cmp.Diff(response, "hello-timothy")
+ if ok && diff != "" {
+ t.Logf("configmap %s|%s/%s has an invalid response: %v", workspaceCluster, namespaceName, configmapName, diff)
+ }
+ return diff == "", nil
+ }); err != nil {
+ t.Fatalf("configmap %s|%s/%s never got a response: %v", workspaceCluster, namespaceName, configmapName, err)
+ }
+
+ t.Logf("waiting for namespace %s|%s to exist", workspaceCluster, otherNamespaceName)
+ var otherNamespace corev1.Namespace
+ if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (done bool, err error) {
+ fetchErr := c.Get(context.TODO(), client.ObjectKey{Name: otherNamespaceName}, &otherNamespace)
+ if fetchErr != nil && !apierrors.IsNotFound(fetchErr) {
+ t.Logf("failed to get namespace %s|%s: %v", workspaceCluster, otherNamespaceName, fetchErr)
+ return false, fetchErr
+ }
+ return fetchErr == nil, nil
+ }); err != nil {
+ t.Fatalf("namespace %s|%s never created: %v", workspaceCluster, otherNamespaceName, err)
+ }
+
+ t.Logf("waiting for secret %s|%s/%s to exist and have correct data", workspaceCluster, namespaceName, configmapName)
+ var secret corev1.Secret
+ if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (done bool, err error) {
+ fetchErr := c.Get(context.TODO(), client.ObjectKey{Namespace: namespaceName, Name: configmapName}, &secret)
+ if fetchErr != nil && !apierrors.IsNotFound(fetchErr) {
+ t.Logf("failed to get secret %s|%s/%s: %v", workspaceCluster, namespaceName, configmapName, fetchErr)
+ return false, fetchErr
+ }
+ response, ok := secret.Data["dataFromCM"]
+ if !ok {
+ t.Logf("secret %s|%s/%s has no data set", workspaceCluster, namespaceName, configmapName)
+ }
+ diff := cmp.Diff(string(response), data)
+ if ok && diff != "" {
+ t.Logf("secret %s|%s/%s has invalid data: %v", workspaceCluster, namespaceName, configmapName, diff)
+ }
+ return diff == "", nil
+ }); err != nil {
+ t.Fatalf("secret %s|%s/%s never created: %v", workspaceCluster, namespaceName, configmapName, err)
+ }
+ })
+ }
+}
+
+// TestWidgetController verifies that our ConfigMap behavior works.
+func TestWidgetController(t *testing.T) {
+ t.Parallel()
+ for i := 0; i < 3; i++ {
+ t.Run(fmt.Sprintf("attempt-%d", i), func(t *testing.T) {
+ t.Parallel()
+ workspaceCluster := parentWorkspace(t).Join(randomName())
+ c := createWorkspace(t, workspaceCluster)
+
+ var totalWidgets int
+ for i := 0; i < 3; i++ {
+ namespaceName := randomName()
+ t.Logf("creating namespace %s|%s", workspaceCluster, namespaceName)
+ if err := c.Create(context.TODO(), &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: namespaceName},
+ }); err != nil {
+ t.Fatalf("failed to create a namespace: %v", err)
+ }
+ numWidgets := rand.Intn(10)
+ for i := 0; i < numWidgets; i++ {
+ if err := c.Create(context.TODO(), &datav1alpha1.Widget{
+ ObjectMeta: metav1.ObjectMeta{Namespace: namespaceName, Name: fmt.Sprintf("widget-%d", i)},
+ Spec: datav1alpha1.WidgetSpec{Foo: fmt.Sprintf("intended-%d", i)},
+ }); err != nil {
+ t.Fatalf("failed to create widget: %v", err)
+ }
+ }
+ totalWidgets += numWidgets
+ }
+
+ t.Logf("waiting for all widgets in cluster %s to have a correct status", workspaceCluster)
+ var allWidgets datav1alpha1.WidgetList
+ if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (done bool, err error) {
+ fetchErr := c.List(context.TODO(), &allWidgets)
+ if fetchErr != nil {
+ t.Logf("failed to get widgets in cluster %s: %v", workspaceCluster, err)
+ return false, fetchErr
+ }
+ var errs []error
+ for _, widget := range allWidgets.Items {
+ if actual, expected := widget.Status.Total, totalWidgets; actual != expected {
+ errs = append(errs, fmt.Errorf("widget %s|%s .status.total incorrect: %d != %d", workspaceCluster, widget.Name, actual, expected))
+ }
+ }
+ validationErr := errors.NewAggregate(errs)
+ if validationErr != nil {
+ t.Logf("widgets in cluster %s invalid: %v", workspaceCluster, validationErr)
+ }
+ return validationErr == nil, nil
+ }); err != nil {
+ t.Fatalf("widgets in cluster %s never got correct statuses: %v", workspaceCluster, err)
+ }
+ })
+ }
+}
diff --git a/examples/scratch-env/go.mod b/examples/scratch-env/go.mod
deleted file mode 100644
index dceb4d12..00000000
--- a/examples/scratch-env/go.mod
+++ /dev/null
@@ -1,69 +0,0 @@
-module sigs.k8s.io/controller-runtime/examples/scratch-env
-
-go 1.22.0
-
-require (
- github.com/spf13/pflag v1.0.5
- go.uber.org/zap v1.26.0
- sigs.k8s.io/controller-runtime v0.0.0-00010101000000-000000000000
-)
-
-require (
- github.com/beorn7/perks v1.0.1 // indirect
- github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/emicklei/go-restful/v3 v3.11.0 // indirect
- github.com/evanphx/json-patch/v5 v5.9.0 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/fxamacker/cbor/v2 v2.7.0 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
- github.com/go-logr/zapr v1.3.0 // indirect
- github.com/go-openapi/jsonpointer v0.19.6 // indirect
- github.com/go-openapi/jsonreference v0.20.2 // indirect
- github.com/go-openapi/swag v0.22.4 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/golang/protobuf v1.5.4 // indirect
- github.com/google/gnostic-models v0.6.8 // indirect
- github.com/google/go-cmp v0.6.0 // indirect
- github.com/google/gofuzz v1.2.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/imdario/mergo v0.3.6 // indirect
- github.com/josharian/intern v1.0.0 // indirect
- github.com/json-iterator/go v1.1.12 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
- github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
- github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/pkg/errors v0.9.1 // indirect
- github.com/prometheus/client_golang v1.19.1 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.55.0 // indirect
- github.com/prometheus/procfs v0.15.1 // indirect
- github.com/x448/float16 v0.8.4 // indirect
- go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
- golang.org/x/net v0.26.0 // indirect
- golang.org/x/oauth2 v0.21.0 // indirect
- golang.org/x/sys v0.21.0 // indirect
- golang.org/x/term v0.21.0 // indirect
- golang.org/x/text v0.16.0 // indirect
- golang.org/x/time v0.3.0 // indirect
- gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
- google.golang.org/protobuf v1.34.2 // indirect
- gopkg.in/inf.v0 v0.9.1 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/api v0.31.0 // indirect
- k8s.io/apiextensions-apiserver v0.31.0 // indirect
- k8s.io/apimachinery v0.31.0 // indirect
- k8s.io/client-go v0.31.0 // indirect
- k8s.io/klog/v2 v2.130.1 // indirect
- k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
- k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
- sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
- sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
- sigs.k8s.io/yaml v1.4.0 // indirect
-)
-
-replace sigs.k8s.io/controller-runtime => ../..
diff --git a/examples/scratch-env/go.sum b/examples/scratch-env/go.sum
deleted file mode 100644
index 89d30c15..00000000
--- a/examples/scratch-env/go.sum
+++ /dev/null
@@ -1,192 +0,0 @@
-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=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
-github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
-github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
-github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
-github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
-github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-github.com/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 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
-github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
-github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
-github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
-github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
-github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
-github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
-github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/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-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
-github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
-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/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
-github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/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.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
-github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
-github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
-github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
-github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
-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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
-github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
-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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/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=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-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.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
-go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
-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-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
-golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
-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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
-golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
-golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
-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/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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
-golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
-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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
-gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
-google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
-google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
-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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
-gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-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.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
-k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
-k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk=
-k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk=
-k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
-k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
-k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
-k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
-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-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
-k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
-sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
-sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
-sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
-sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
diff --git a/examples/scratch-env/main.go b/examples/scratch-env/main.go
deleted file mode 100644
index b8305ffe..00000000
--- a/examples/scratch-env/main.go
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
-Copyright 2021 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 main
-
-import (
- goflag "flag"
- "os"
-
- flag "github.com/spf13/pflag"
- "go.uber.org/zap"
-
- ctrl "sigs.k8s.io/controller-runtime"
- "sigs.k8s.io/controller-runtime/pkg/envtest"
- logzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
-)
-
-var (
- crdPaths = flag.StringSlice("crd-paths", nil, "paths to files or directories containing CRDs to install on start")
- webhookPaths = flag.StringSlice("webhook-paths", nil, "paths to files or directories containing webhook configurations to install on start")
- attachControlPlaneOut = flag.Bool("debug-env", false, "attach to test env (apiserver & etcd) output -- just a convinience flag to force KUBEBUILDER_ATTACH_CONTROL_PLANE_OUTPUT=true")
-)
-
-// have a separate function so we can return an exit code w/o skipping defers
-func runMain() int {
- loggerOpts := &logzap.Options{
- Development: true, // a sane default
- ZapOpts: []zap.Option{zap.AddCaller()},
- }
- {
- var goFlagSet goflag.FlagSet
- loggerOpts.BindFlags(&goFlagSet)
- flag.CommandLine.AddGoFlagSet(&goFlagSet)
- }
- flag.Parse()
- ctrl.SetLogger(logzap.New(logzap.UseFlagOptions(loggerOpts)))
- ctrl.Log.Info("Starting...")
-
- log := ctrl.Log.WithName("main")
-
- env := &envtest.Environment{}
- env.CRDInstallOptions.Paths = *crdPaths
- env.WebhookInstallOptions.Paths = *webhookPaths
-
- if *attachControlPlaneOut {
- os.Setenv("KUBEBUILDER_ATTACH_CONTROL_PLANE_OUTPUT", "true")
- }
-
- log.Info("Starting apiserver & etcd")
- cfg, err := env.Start()
- if err != nil {
- log.Error(err, "unable to start the test environment")
- // shut down the environment in case we started it and failed while
- // installing CRDs or provisioning users.
- if err := env.Stop(); err != nil {
- log.Error(err, "unable to stop the test environment after an error (this might be expected, but just though you should know)")
- }
- return 1
- }
-
- log.Info("apiserver running", "host", cfg.Host)
-
- // NB(directxman12): this group is unfortunately named, but various
- // kubernetes versions require us to use it to get "admin" access.
- user, err := env.ControlPlane.AddUser(envtest.User{
- Name: "envtest-admin",
- Groups: []string{"system:masters"},
- }, nil)
- if err != nil {
- log.Error(err, "unable to provision admin user, continuing on without it")
- return 1
- }
-
- // TODO(directxman12): add support for writing to a new context in an existing file
- kubeconfigFile, err := os.CreateTemp("", "scratch-env-kubeconfig-")
- if err != nil {
- log.Error(err, "unable to create kubeconfig file, continuing on without it")
- return 1
- }
- defer os.Remove(kubeconfigFile.Name())
-
- {
- log := log.WithValues("path", kubeconfigFile.Name())
- log.V(1).Info("Writing kubeconfig")
-
- kubeConfig, err := user.KubeConfig()
- if err != nil {
- log.Error(err, "unable to create kubeconfig")
- }
-
- if _, err := kubeconfigFile.Write(kubeConfig); err != nil {
- log.Error(err, "unable to save kubeconfig")
- return 1
- }
-
- log.Info("Wrote kubeconfig")
- }
-
- if opts := env.WebhookInstallOptions; opts.LocalServingPort != 0 {
- log.Info("webhooks configured for", "host", opts.LocalServingHost, "port", opts.LocalServingPort, "dir", opts.LocalServingCertDir)
- }
-
- ctx := ctrl.SetupSignalHandler()
- <-ctx.Done()
-
- log.Info("Shutting down apiserver & etcd")
- err = env.Stop()
- if err != nil {
- log.Error(err, "unable to stop the test environment")
- return 1
- }
-
- log.Info("Shutdown successful")
- return 0
-}
-
-func main() {
- os.Exit(runMain())
-}
diff --git a/go.mod b/go.mod
index 3fd1aa95..3aaf5814 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,12 @@ require (
sigs.k8s.io/yaml v1.4.0
)
+require (
+ github.com/hashicorp/golang-lru/v2 v2.0.7
+ github.com/kcp-dev/apimachinery/v2 v2.0.0-alpha.0.0.20230926071920-57d168bcbe34
+ github.com/kcp-dev/logicalcluster/v3 v3.0.5
+)
+
require (
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
diff --git a/go.sum b/go.sum
index cb957a9e..f5e45379 100644
--- a/go.sum
+++ b/go.sum
@@ -66,6 +66,8 @@ 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.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -74,6 +76,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
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/kcp-dev/apimachinery/v2 v2.0.0-alpha.0.0.20230926071920-57d168bcbe34 h1:tom0JX5OmAeOOmkGv8LaYHDtA1xAKDiQL5U0vhYYgdM=
+github.com/kcp-dev/apimachinery/v2 v2.0.0-alpha.0.0.20230926071920-57d168bcbe34/go.mod h1:cWoaYGHl1nlzdEM2xvMzIASkEZJZLSf5nhe17M7wDhw=
+github.com/kcp-dev/logicalcluster/v3 v3.0.5 h1:JbYakokb+5Uinz09oTXomSUJVQsqfxEvU4RyHUYxHOU=
+github.com/kcp-dev/logicalcluster/v3 v3.0.5/go.mod h1:EWBUBxdr49fUB1cLMO4nOdBWmYifLbP1LfoL20KkXYY=
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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go
index 706f9c6c..e89cddac 100644
--- a/pkg/cache/cache.go
+++ b/pkg/cache/cache.go
@@ -21,6 +21,7 @@ import (
"fmt"
"net/http"
"sort"
+ "strings"
"time"
"golang.org/x/exp/maps"
@@ -169,6 +170,14 @@ type Options struct {
// instead of `reconcile.Result{}`.
SyncPeriod *time.Duration
+ // NewInformerFunc is a function that is used to create SharedIndexInformers.
+ // Defaults to cache.NewSharedIndexInformer from client-go
+ NewInformerFunc client.NewInformerFunc
+
+ // Indexers is the indexers that the informers will be configured to use.
+ // Will always have the standard NamespaceIndex.
+ Indexers toolscache.Indexers
+
// ReaderFailOnMissingInformer configures the cache to return a ErrResourceNotCached error when a user
// requests, using Get() and List(), a resource the cache does not already have an informer for.
//
@@ -225,9 +234,6 @@ type Options struct {
// ByObject restricts the cache's ListWatch to the desired fields per GVK at the specified object.
// If unset, this will fall through to the Default* settings.
ByObject map[client.Object]ByObject
-
- // newInformer allows overriding of NewSharedIndexInformer for testing.
- newInformer *func(toolscache.ListerWatcher, runtime.Object, time.Duration, toolscache.Indexers) toolscache.SharedIndexInformer
}
// ByObject offers more fine-grained control over the cache's ListWatch by object.
@@ -398,9 +404,10 @@ func newCache(restConfig *rest.Config, opts Options) newCacheFunc {
Transform: config.Transform,
WatchErrorHandler: opts.DefaultWatchErrorHandler,
UnsafeDisableDeepCopy: ptr.Deref(config.UnsafeDisableDeepCopy, false),
- NewInformer: opts.newInformer,
+ NewInformer: opts.NewInformerFunc,
}),
readerFailOnMissingInformer: opts.ReaderFailOnMissingInformer,
+ clusterIndexes: strings.HasSuffix(restConfig.Host, "/clusters/*"),
}
}
}
@@ -507,6 +514,10 @@ func defaultOpts(config *rest.Config, opts Options) (Options, error) {
if opts.SyncPeriod == nil {
opts.SyncPeriod = &defaultSyncPeriod
}
+
+ if opts.NewInformerFunc == nil {
+ opts.NewInformerFunc = toolscache.NewSharedIndexInformer
+ }
return opts, nil
}
diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go
index 7a21c87c..1b37a2d9 100644
--- a/pkg/cache/cache_test.go
+++ b/pkg/cache/cache_test.go
@@ -544,14 +544,9 @@ func NonBlockingGetTest(createCacheFunc func(config *rest.Config, opts cache.Opt
Expect(err).NotTo(HaveOccurred())
By("creating the informer cache")
- v := reflect.ValueOf(&opts).Elem()
- newInformerField := v.FieldByName("newInformer")
- newFakeInformer := func(_ kcache.ListerWatcher, _ runtime.Object, _ time.Duration, _ kcache.Indexers) kcache.SharedIndexInformer {
+ opts.NewInformerFunc = func(_ kcache.ListerWatcher, _ runtime.Object, _ time.Duration, _ kcache.Indexers) kcache.SharedIndexInformer {
return &controllertest.FakeInformer{Synced: false}
}
- reflect.NewAt(newInformerField.Type(), newInformerField.Addr().UnsafePointer()).
- Elem().
- Set(reflect.ValueOf(&newFakeInformer))
informerCache, err = createCacheFunc(cfg, opts)
Expect(err).NotTo(HaveOccurred())
By("running the cache and waiting for it to sync")
diff --git a/pkg/cache/defaulting_test.go b/pkg/cache/defaulting_test.go
index 3c01bf84..930a1535 100644
--- a/pkg/cache/defaulting_test.go
+++ b/pkg/cache/defaulting_test.go
@@ -401,6 +401,9 @@ func TestDefaultOpts(t *testing.T) {
t.Fatal(err)
}
+ // We cannot reference kcp.NewInformerWithClusterIndexes due to import cycle.
+ defaulted.NewInformerFunc = nil
+
if diff := tc.verification(defaulted); diff != "" {
t.Errorf("expected config differs from actual: %s", diff)
}
diff --git a/pkg/cache/informer_cache.go b/pkg/cache/informer_cache.go
index 091667b7..71b94da1 100644
--- a/pkg/cache/informer_cache.go
+++ b/pkg/cache/informer_cache.go
@@ -31,6 +31,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/cache/internal"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+
+ "github.com/kcp-dev/logicalcluster/v3"
)
var (
@@ -68,6 +70,7 @@ type informerCache struct {
scheme *runtime.Scheme
*internal.Informers
readerFailOnMissingInformer bool
+ clusterIndexes bool
}
// Get implements Reader.
@@ -217,10 +220,10 @@ func (ic *informerCache) IndexField(ctx context.Context, obj client.Object, fiel
if err != nil {
return err
}
- return indexByField(informer, field, extractValue)
+ return indexByField(informer, field, extractValue, ic.clusterIndexes)
}
-func indexByField(informer Informer, field string, extractValue client.IndexerFunc) error {
+func indexByField(informer Informer, field string, extractValue client.IndexerFunc, clusterIndexes bool) error {
indexFunc := func(objRaw interface{}) ([]string, error) {
// TODO(directxman12): check if this is the correct type?
obj, isObj := objRaw.(client.Object)
@@ -233,6 +236,13 @@ func indexByField(informer Informer, field string, extractValue client.IndexerFu
}
ns := meta.GetNamespace()
+ keyFunc := internal.KeyToNamespacedKey
+ if clusterName := logicalcluster.From(obj); clusterIndexes && !clusterName.Empty() {
+ keyFunc = func(ns, val string) string {
+ return internal.KeyToClusteredKey(clusterName.String(), ns, val)
+ }
+ }
+
rawVals := extractValue(obj)
var vals []string
if ns == "" {
@@ -242,14 +252,15 @@ func indexByField(informer Informer, field string, extractValue client.IndexerFu
// if we need to add non-namespaced versions too, double the length
vals = make([]string, len(rawVals)*2)
}
+
for i, rawVal := range rawVals {
// save a namespaced variant, so that we can ask
// "what are all the object matching a given index *in a given namespace*"
- vals[i] = internal.KeyToNamespacedKey(ns, rawVal)
+ vals[i] = keyFunc(ns, rawVal)
if ns != "" {
// if we have a namespace, also inject a special index key for listing
// regardless of the object namespace
- vals[i+len(rawVals)] = internal.KeyToNamespacedKey("", rawVal)
+ vals[i+len(rawVals)] = keyFunc("", rawVal)
}
}
diff --git a/pkg/cache/internal/cache_reader.go b/pkg/cache/internal/cache_reader.go
index 81ee960b..aaf0966f 100644
--- a/pkg/cache/internal/cache_reader.go
+++ b/pkg/cache/internal/cache_reader.go
@@ -21,6 +21,9 @@ import (
"fmt"
"reflect"
+ kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache"
+ "github.com/kcp-dev/logicalcluster/v3"
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/fields"
@@ -31,6 +34,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/internal/field/selector"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
)
// CacheReader is a client.Reader.
@@ -54,12 +58,22 @@ type CacheReader struct {
}
// Get checks the indexer for the object and writes a copy of it if found.
-func (c *CacheReader) Get(_ context.Context, key client.ObjectKey, out client.Object, _ ...client.GetOption) error {
+func (c *CacheReader) Get(ctx context.Context, key client.ObjectKey, out client.Object, _ ...client.GetOption) error {
if c.scopeName == apimeta.RESTScopeNameRoot {
key.Namespace = ""
}
storeKey := objectKeyToStoreKey(key)
+ // create cluster-aware key for KCP
+ _, isClusterAware := c.indexer.GetIndexers()[kcpcache.ClusterAndNamespaceIndexName]
+ clusterName, _ := kontext.ClusterFrom(ctx)
+ if isClusterAware && clusterName.Empty() {
+ return fmt.Errorf("cluster-aware cache requires a cluster in context")
+ }
+ if isClusterAware {
+ storeKey = clusterName.String() + "|" + storeKey
+ }
+
// Lookup the object from the indexer cache
obj, exists, err := c.indexer.GetByKey(storeKey)
if err != nil {
@@ -105,7 +119,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 {
+func (c *CacheReader) List(ctx context.Context, out client.ObjectList, opts ...client.ListOption) error {
var objs []interface{}
var err error
@@ -116,6 +130,9 @@ func (c *CacheReader) List(_ context.Context, out client.ObjectList, opts ...cli
return fmt.Errorf("continue list option is not supported by the cache")
}
+ _, isClusterAware := c.indexer.GetIndexers()[kcpcache.ClusterAndNamespaceIndexName]
+ clusterName, _ := kontext.ClusterFrom(ctx)
+
switch {
case listOpts.FieldSelector != nil:
requiresExact := selector.RequiresExactMatch(listOpts.FieldSelector)
@@ -125,11 +142,19 @@ func (c *CacheReader) List(_ context.Context, out client.ObjectList, opts ...cli
// list all objects by the field selector. If this is namespaced and we have one, ask for the
// namespaced index key. Otherwise, ask for the non-namespaced variant by using the fake "all namespaces"
// namespace.
- objs, err = byIndexes(c.indexer, listOpts.FieldSelector.Requirements(), listOpts.Namespace)
+ objs, err = byIndexes(c.indexer, listOpts.FieldSelector.Requirements(), clusterName, listOpts.Namespace)
case listOpts.Namespace != "":
- objs, err = c.indexer.ByIndex(cache.NamespaceIndex, listOpts.Namespace)
+ if isClusterAware && !clusterName.Empty() {
+ objs, err = c.indexer.ByIndex(kcpcache.ClusterAndNamespaceIndexName, kcpcache.ClusterAndNamespaceIndexKey(clusterName, listOpts.Namespace))
+ } else {
+ objs, err = c.indexer.ByIndex(cache.NamespaceIndex, listOpts.Namespace)
+ }
default:
- objs = c.indexer.List()
+ if isClusterAware && !clusterName.Empty() {
+ objs, err = c.indexer.ByIndex(kcpcache.ClusterIndexName, kcpcache.ClusterIndexKey(clusterName))
+ } else {
+ objs = c.indexer.List()
+ }
}
if err != nil {
return err
@@ -177,16 +202,22 @@ func (c *CacheReader) List(_ context.Context, out client.ObjectList, opts ...cli
return apimeta.SetList(out, runtimeObjs)
}
-func byIndexes(indexer cache.Indexer, requires fields.Requirements, namespace string) ([]interface{}, error) {
+func byIndexes(indexer cache.Indexer, requires fields.Requirements, clusterName logicalcluster.Name, namespace string) ([]interface{}, error) {
var (
err error
objs []interface{}
vals []string
)
indexers := indexer.GetIndexers()
+ _, isClusterAware := indexers[kcpcache.ClusterAndNamespaceIndexName]
for idx, req := range requires {
indexName := FieldIndexName(req.Field)
- indexedValue := KeyToNamespacedKey(namespace, req.Value)
+ var indexedValue string
+ if isClusterAware {
+ indexedValue = KeyToClusteredKey(clusterName.String(), namespace, req.Value)
+ } else {
+ indexedValue = KeyToNamespacedKey(namespace, req.Value)
+ }
if idx == 0 {
// we use first require to get snapshot data
// TODO(halfcrazy): use complicated index when client-go provides byIndexes
@@ -253,3 +284,9 @@ func KeyToNamespacedKey(ns string, baseKey string) string {
}
return allNamespacesNamespace + "/" + baseKey
}
+
+// KeyToClusteredKey prefixes the given index key with a cluster name
+// for use in field selector indexes.
+func KeyToClusteredKey(clusterName string, ns string, baseKey string) string {
+ return clusterName + "|" + KeyToNamespacedKey(ns, baseKey)
+}
diff --git a/pkg/cache/internal/informers.go b/pkg/cache/internal/informers.go
index cd8c6774..ae10b2f6 100644
--- a/pkg/cache/internal/informers.go
+++ b/pkg/cache/internal/informers.go
@@ -47,7 +47,7 @@ type InformersOpts struct {
Mapper meta.RESTMapper
ResyncPeriod time.Duration
Namespace string
- NewInformer *func(cache.ListerWatcher, runtime.Object, time.Duration, cache.Indexers) cache.SharedIndexInformer
+ NewInformer func(cache.ListerWatcher, runtime.Object, time.Duration, cache.Indexers) cache.SharedIndexInformer
Selector Selector
Transform cache.TransformFunc
UnsafeDisableDeepCopy bool
@@ -58,7 +58,7 @@ type InformersOpts struct {
func NewInformers(config *rest.Config, options *InformersOpts) *Informers {
newInformer := cache.NewSharedIndexInformer
if options.NewInformer != nil {
- newInformer = *options.NewInformer
+ newInformer = options.NewInformer
}
return &Informers{
config: config,
diff --git a/pkg/cache/kcp_test.go b/pkg/cache/kcp_test.go
new file mode 100644
index 00000000..11c48457
--- /dev/null
+++ b/pkg/cache/kcp_test.go
@@ -0,0 +1,138 @@
+/*
+Copyright 2024 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 cache_test
+
+import (
+ "context"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/fields"
+ "sigs.k8s.io/controller-runtime/pkg/cache"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var _ = Describe("informer cache against a kube cluster", func() {
+ BeforeEach(func() {
+ By("Annotating the default namespace with kcp.io/cluster")
+ cl, err := client.New(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+ ns := &corev1.Namespace{}
+ err = cl.Get(context.Background(), client.ObjectKey{Name: "default"}, ns)
+ Expect(err).NotTo(HaveOccurred())
+ ns.Annotations = map[string]string{"kcp.io/cluster": "cluster1"}
+ err = cl.Update(context.Background(), ns)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ Describe("KCP cluster-unaware informer cache", func() {
+ // Test whether we can have a cluster-unaware informer cache against a single workspace.
+ // I.e. every object has a kcp.io/cluster annotation, but it should not be taken
+ // into consideration by the cache to compute the key.
+ It("should be able to get the default namespace despite kcp.io/cluster annotation", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ c, err := cache.New(cfg, cache.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ go c.Start(ctx) //nolint:errcheck // Start is blocking, and error not relevant here.
+ c.WaitForCacheSync(ctx)
+
+ By("By getting the default namespace with the informer")
+ ns := &corev1.Namespace{}
+ err = c.Get(ctx, client.ObjectKey{Name: "default"}, ns)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("should support indexes with cluster-less keys", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ c, err := cache.New(cfg, cache.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Indexing the default namespace by name")
+ err = c.IndexField(ctx, &corev1.Namespace{}, "name-clusterless", func(obj client.Object) []string {
+ return []string{"key-" + obj.GetName()}
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ go c.Start(ctx) //nolint:errcheck // Start is blocking, and error not relevant here.
+ c.WaitForCacheSync(ctx)
+
+ By("By getting the default namespace via the custom index")
+ nss := &corev1.NamespaceList{}
+ err = c.List(ctx, nss, client.MatchingFieldsSelector{
+ Selector: fields.OneTermEqualSelector("name-clusterless", "key-default"),
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(nss.Items).To(HaveLen(1))
+ })
+ })
+
+ // TODO: get envtest in place with kcp
+ /*
+ Describe("KCP cluster-aware informer cache", func() {
+ It("should be able to get the default namespace with kcp.io/cluster annotation", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ c, err := kcp.NewClusterAwareCache(cfg, cache.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ go c.Start(ctx) //nolint:errcheck // Start is blocking, and error not relevant here.
+ c.WaitForCacheSync(ctx)
+
+ By("By getting the default namespace with the informer, but cluster-less key should fail")
+ ns := &corev1.Namespace{}
+ err = c.Get(ctx, client.ObjectKey{Name: "default"}, ns)
+ Expect(err).To(HaveOccurred())
+
+ By("By getting the default namespace with the informer, but cluster-aware key should succeed")
+ err = c.Get(kontext.WithCluster(ctx, "cluster1"), client.ObjectKey{Name: "default", Namespace: "cluster1"}, ns)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("should support indexes with cluster-aware keys", func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ c, err := kcp.NewClusterAwareCache(cfg, cache.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Indexing the default namespace by name")
+ err = c.IndexField(ctx, &corev1.Namespace{}, "name-clusteraware", func(obj client.Object) []string {
+ return []string{"key-" + obj.GetName()}
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ go c.Start(ctx) //nolint:errcheck // Start is blocking, and error not relevant here.
+ c.WaitForCacheSync(ctx)
+
+ By("By getting the default namespace via the custom index")
+ nss := &corev1.NamespaceList{}
+ err = c.List(ctx, nss, client.MatchingFieldsSelector{
+ Selector: fields.OneTermEqualSelector("name-clusteraware", "key-default"),
+ })
+ Expect(err).NotTo(HaveOccurred())
+ Expect(nss.Items).To(HaveLen(1))
+ })
+ })
+ */
+})
diff --git a/pkg/client/client.go b/pkg/client/client.go
index fe9862b8..ab1e7bd3 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -23,6 +23,8 @@ import (
"net/http"
"strings"
+ lru "github.com/hashicorp/golang-lru/v2"
+ "github.com/kcp-dev/logicalcluster/v3"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -47,11 +49,19 @@ type Options struct {
// Mapper, if provided, will be used to map GroupVersionKinds to Resources
Mapper meta.RESTMapper
+ // MapperWithContext, if provided, will be used to map GroupVersionKinds to Resources.
+ // This overrides Mapper if set.
+ MapperWithContext func(context.Context) (meta.RESTMapper, error)
+
// Cache, if provided, is used to read objects from the cache.
Cache *CacheOptions
// DryRun instructs the client to only perform dry run requests.
DryRun *bool
+
+ // KcpClusterDiscoveryCacheSize is the size of the cache for cluster discovery
+ // information backing the client's REST mapper.
+ KcpClusterDiscoveryCacheSize int
}
// CacheOptions are options for creating a cache-backed client.
@@ -71,6 +81,14 @@ type CacheOptions struct {
// NewClientFunc allows a user to define how to create a client.
type NewClientFunc func(config *rest.Config, options Options) (Client, error)
+// NewAPIReaderFunc allows a user to define how to create an API server reader.
+type NewAPIReaderFunc func(config *rest.Config, options Options) (Reader, error)
+
+// NewAPIReader creates a new API server reader.
+func NewAPIReader(config *rest.Config, options Options) (Reader, error) {
+ return New(config, options)
+}
+
// New returns a new Client using the provided config and Options.
//
// By default, the client surfaces warnings returned by the server. To
@@ -145,16 +163,27 @@ func newClient(config *rest.Config, options Options) (*client, error) {
}
}
+ if options.KcpClusterDiscoveryCacheSize == 0 {
+ options.KcpClusterDiscoveryCacheSize = 1000
+ }
+
+ // Init a MapperWithContext if none provided
+ if options.MapperWithContext == nil {
+ options.MapperWithContext = func(context.Context) (meta.RESTMapper, error) { return options.Mapper, nil }
+ }
+
resources := &clientRestResources{
httpClient: options.HTTPClient,
config: config,
scheme: options.Scheme,
- mapper: options.Mapper,
+ mapper: options.MapperWithContext,
codecs: serializer.NewCodecFactory(options.Scheme),
-
- structuredResourceByType: make(map[schema.GroupVersionKind]*resourceMeta),
- unstructuredResourceByType: make(map[schema.GroupVersionKind]*resourceMeta),
}
+ cr, err := lru.New[logicalcluster.Path, clusterResources](options.KcpClusterDiscoveryCacheSize)
+ if err != nil {
+ return nil, err
+ }
+ resources.clusterResources = cr
rawMetaClient, err := metadata.NewForConfigAndClient(metadata.ConfigFor(config), options.HTTPClient)
if err != nil {
@@ -172,11 +201,16 @@ func newClient(config *rest.Config, options Options) (*client, error) {
},
metadataClient: metadataClient{
client: rawMetaClient,
- restMapper: options.Mapper,
+ restMapper: options.MapperWithContext,
},
scheme: options.Scheme,
mapper: options.Mapper,
}
+ mapperCache, err := lru.New[logicalcluster.Name, meta.RESTMapper](options.KcpClusterDiscoveryCacheSize)
+ if err != nil {
+ return nil, err
+ }
+ c.metadataClient.mapperCache = mapperCache
if options.Cache == nil || options.Cache.Reader == nil {
return c, nil
}
diff --git a/pkg/client/client_rest_resources.go b/pkg/client/client_rest_resources.go
index 2d078795..1247879e 100644
--- a/pkg/client/client_rest_resources.go
+++ b/pkg/client/client_rest_resources.go
@@ -17,10 +17,13 @@ limitations under the License.
package client
import (
+ "context"
"net/http"
"strings"
"sync"
+ lru "github.com/hashicorp/golang-lru/v2"
+ "github.com/kcp-dev/logicalcluster/v3"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -28,8 +31,18 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
)
+type clusterResources struct {
+ mapper meta.RESTMapper
+
+ // structuredResourceByType stores structured type metadata
+ structuredResourceByType map[schema.GroupVersionKind]*resourceMeta
+ // unstructuredResourceByType stores unstructured type metadata
+ unstructuredResourceByType map[schema.GroupVersionKind]*resourceMeta
+}
+
// clientRestResources creates and stores rest clients and metadata for Kubernetes types.
type clientRestResources struct {
// httpClient is the http client to use for requests
@@ -42,21 +55,18 @@ type clientRestResources struct {
scheme *runtime.Scheme
// mapper maps GroupVersionKinds to Resources
- mapper meta.RESTMapper
+ mapper func(ctx context.Context) (meta.RESTMapper, error)
// 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
+ clusterResources *lru.Cache[logicalcluster.Path, clusterResources]
+ mu sync.RWMutex
}
// newResource maps obj to a Kubernetes Resource and constructs a client for that Resource.
// If the object is a list, the resource represents the item's type instead.
-func (c *clientRestResources) newResource(gvk schema.GroupVersionKind, isList, isUnstructured bool) (*resourceMeta, error) {
+func (c *clientRestResources) newResource(gvk schema.GroupVersionKind, isList, isUnstructured bool, mapper meta.RESTMapper) (*resourceMeta, error) {
if strings.HasSuffix(gvk.Kind, "List") && isList {
// if this was a list, treat it as a request for the item's resource
gvk.Kind = gvk.Kind[:len(gvk.Kind)-4]
@@ -66,7 +76,7 @@ func (c *clientRestResources) newResource(gvk schema.GroupVersionKind, isList, i
if err != nil {
return nil, err
}
- mapping, err := c.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
+ mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil, err
}
@@ -75,7 +85,7 @@ func (c *clientRestResources) newResource(gvk schema.GroupVersionKind, isList, i
// getResource returns the resource meta information for the given type of object.
// If the object is a list, the resource represents the item's type instead.
-func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, error) {
+func (c *clientRestResources) getResource(ctx context.Context, obj runtime.Object) (*resourceMeta, error) {
gvk, err := apiutil.GVKForObject(obj, c.scheme)
if err != nil {
return nil, err
@@ -86,9 +96,25 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
// It's better to do creation work twice than to not let multiple
// people make requests at once
c.mu.RLock()
- resourceByType := c.structuredResourceByType
+ cluster, _ := kontext.ClusterFrom(ctx)
+ cr, found := c.clusterResources.Get(cluster.Path())
+ if !found {
+ m, err := c.mapper(ctx)
+ if err != nil {
+ c.mu.RUnlock()
+ return nil, err
+ }
+ cr = clusterResources{
+ mapper: m,
+ structuredResourceByType: make(map[schema.GroupVersionKind]*resourceMeta),
+ unstructuredResourceByType: make(map[schema.GroupVersionKind]*resourceMeta),
+ }
+ c.clusterResources.Purge()
+ c.clusterResources.Add(cluster.Path(), cr)
+ }
+ resourceByType := cr.structuredResourceByType
if isUnstructured {
- resourceByType = c.unstructuredResourceByType
+ resourceByType = cr.unstructuredResourceByType
}
r, known := resourceByType[gvk]
c.mu.RUnlock()
@@ -100,7 +126,7 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
// Initialize a new Client
c.mu.Lock()
defer c.mu.Unlock()
- r, err = c.newResource(gvk, meta.IsListType(obj), isUnstructured)
+ r, err = c.newResource(gvk, meta.IsListType(obj), isUnstructured, cr.mapper)
if err != nil {
return nil, err
}
@@ -109,8 +135,8 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
}
// getObjMeta returns objMeta containing both type and object metadata and state.
-func (c *clientRestResources) getObjMeta(obj runtime.Object) (*objMeta, error) {
- r, err := c.getResource(obj)
+func (c *clientRestResources) getObjMeta(ctx context.Context, obj runtime.Object) (*objMeta, error) {
+ r, err := c.getResource(ctx, obj)
if err != nil {
return nil, err
}
diff --git a/pkg/client/interfaces.go b/pkg/client/interfaces.go
index 3cd745e4..6c47fc35 100644
--- a/pkg/client/interfaces.go
+++ b/pkg/client/interfaces.go
@@ -18,9 +18,11 @@ package client
import (
"context"
+ "time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/tools/cache"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
@@ -44,6 +46,10 @@ type Patch interface {
Data(obj Object) ([]byte, error)
}
+// NewInformerFunc describes a function that creates SharedIndexInformers.
+// Its signature matches cache.NewSharedIndexInformer from client-go.
+type NewInformerFunc func(cache.ListerWatcher, runtime.Object, time.Duration, cache.Indexers) cache.SharedIndexInformer
+
// TODO(directxman12): is there a sane way to deal with get/delete options?
// Reader knows how to read and list Kubernetes objects.
diff --git a/pkg/client/metadata_client.go b/pkg/client/metadata_client.go
index d0c6b8e1..83680356 100644
--- a/pkg/client/metadata_client.go
+++ b/pkg/client/metadata_client.go
@@ -20,11 +20,15 @@ import (
"context"
"fmt"
"strings"
+ "sync"
+ lru "github.com/hashicorp/golang-lru/v2"
+ "github.com/kcp-dev/logicalcluster/v3"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/metadata"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
)
// TODO(directxman12): we could rewrite this on top of the low-level REST
@@ -34,12 +38,28 @@ import (
// metadataClient is a client that reads & writes metadata-only requests to/from the API server.
type metadataClient struct {
- client metadata.Interface
- restMapper meta.RESTMapper
+ client metadata.Interface
+ restMapper func(ctx context.Context) (meta.RESTMapper, error)
+ mu sync.Mutex
+ mapperCache *lru.Cache[logicalcluster.Name, meta.RESTMapper]
}
-func (mc *metadataClient) getResourceInterface(gvk schema.GroupVersionKind, ns string) (metadata.ResourceInterface, error) {
- mapping, err := mc.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
+func (mc *metadataClient) getResourceInterface(ctx context.Context, gvk schema.GroupVersionKind, ns string) (metadata.ResourceInterface, error) {
+ cluster, _ := kontext.ClusterFrom(ctx)
+ mc.mu.Lock()
+ mapper, _ := mc.mapperCache.Get(cluster)
+ if mapper == nil {
+ var err error
+ mapper, err = mc.restMapper(ctx)
+ if err != nil {
+ mc.mu.Unlock()
+ return nil, err
+ }
+ mc.mapperCache.Add(cluster, mapper)
+ }
+ mc.mu.Unlock()
+
+ mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil, err
}
@@ -56,7 +76,7 @@ func (mc *metadataClient) Delete(ctx context.Context, obj Object, opts ...Delete
return fmt.Errorf("metadata client did not understand object: %T", obj)
}
- resInt, err := mc.getResourceInterface(metadata.GroupVersionKind(), metadata.Namespace)
+ resInt, err := mc.getResourceInterface(ctx, metadata.GroupVersionKind(), metadata.Namespace)
if err != nil {
return err
}
@@ -77,7 +97,7 @@ func (mc *metadataClient) DeleteAllOf(ctx context.Context, obj Object, opts ...D
deleteAllOfOpts := DeleteAllOfOptions{}
deleteAllOfOpts.ApplyOptions(opts)
- resInt, err := mc.getResourceInterface(metadata.GroupVersionKind(), deleteAllOfOpts.ListOptions.Namespace)
+ resInt, err := mc.getResourceInterface(ctx, metadata.GroupVersionKind(), deleteAllOfOpts.ListOptions.Namespace)
if err != nil {
return err
}
@@ -93,7 +113,7 @@ func (mc *metadataClient) Patch(ctx context.Context, obj Object, patch Patch, op
}
gvk := metadata.GroupVersionKind()
- resInt, err := mc.getResourceInterface(gvk, metadata.Namespace)
+ resInt, err := mc.getResourceInterface(ctx, gvk, metadata.Namespace)
if err != nil {
return err
}
@@ -127,7 +147,7 @@ func (mc *metadataClient) Get(ctx context.Context, key ObjectKey, obj Object, op
getOpts := GetOptions{}
getOpts.ApplyOptions(opts)
- resInt, err := mc.getResourceInterface(gvk, key.Namespace)
+ resInt, err := mc.getResourceInterface(ctx, gvk, key.Namespace)
if err != nil {
return err
}
@@ -154,7 +174,7 @@ func (mc *metadataClient) List(ctx context.Context, obj ObjectList, opts ...List
listOpts := ListOptions{}
listOpts.ApplyOptions(opts)
- resInt, err := mc.getResourceInterface(gvk, listOpts.Namespace)
+ resInt, err := mc.getResourceInterface(ctx, gvk, listOpts.Namespace)
if err != nil {
return err
}
@@ -175,7 +195,7 @@ func (mc *metadataClient) PatchSubResource(ctx context.Context, obj Object, subR
}
gvk := metadata.GroupVersionKind()
- resInt, err := mc.getResourceInterface(gvk, metadata.Namespace)
+ resInt, err := mc.getResourceInterface(ctx, gvk, metadata.Namespace)
if err != nil {
return err
}
diff --git a/pkg/client/typed_client.go b/pkg/client/typed_client.go
index 92afd9a9..aff18d84 100644
--- a/pkg/client/typed_client.go
+++ b/pkg/client/typed_client.go
@@ -32,7 +32,7 @@ type typedClient struct {
// Create implements client.Client.
func (c *typedClient) Create(ctx context.Context, obj Object, opts ...CreateOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -51,7 +51,7 @@ func (c *typedClient) Create(ctx context.Context, obj Object, opts ...CreateOpti
// Update implements client.Client.
func (c *typedClient) Update(ctx context.Context, obj Object, opts ...UpdateOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -71,7 +71,7 @@ func (c *typedClient) Update(ctx context.Context, obj Object, opts ...UpdateOpti
// Delete implements client.Client.
func (c *typedClient) Delete(ctx context.Context, obj Object, opts ...DeleteOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -90,7 +90,7 @@ func (c *typedClient) Delete(ctx context.Context, obj Object, opts ...DeleteOpti
// DeleteAllOf implements client.Client.
func (c *typedClient) DeleteAllOf(ctx context.Context, obj Object, opts ...DeleteAllOfOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -109,7 +109,7 @@ func (c *typedClient) DeleteAllOf(ctx context.Context, obj Object, opts ...Delet
// Patch implements client.Client.
func (c *typedClient) Patch(ctx context.Context, obj Object, patch Patch, opts ...PatchOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -134,7 +134,7 @@ func (c *typedClient) Patch(ctx context.Context, obj Object, patch Patch, opts .
// Get implements client.Client.
func (c *typedClient) Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error {
- r, err := c.resources.getResource(obj)
+ r, err := c.resources.getResource(ctx, obj)
if err != nil {
return err
}
@@ -149,7 +149,7 @@ func (c *typedClient) Get(ctx context.Context, key ObjectKey, obj Object, opts .
// List implements client.Client.
func (c *typedClient) List(ctx context.Context, obj ObjectList, opts ...ListOption) error {
- r, err := c.resources.getResource(obj)
+ r, err := c.resources.getResource(ctx, obj)
if err != nil {
return err
}
@@ -166,7 +166,7 @@ func (c *typedClient) List(ctx context.Context, obj ObjectList, opts ...ListOpti
}
func (c *typedClient) GetSubResource(ctx context.Context, obj, subResourceObj Object, subResource string, opts ...SubResourceGetOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -189,7 +189,7 @@ func (c *typedClient) GetSubResource(ctx context.Context, obj, subResourceObj Ob
}
func (c *typedClient) CreateSubResource(ctx context.Context, obj Object, subResourceObj Object, subResource string, opts ...SubResourceCreateOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -214,7 +214,7 @@ func (c *typedClient) CreateSubResource(ctx context.Context, obj Object, subReso
// UpdateSubResource used by SubResourceWriter to write status.
func (c *typedClient) UpdateSubResource(ctx context.Context, obj Object, subResource string, opts ...SubResourceUpdateOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -249,7 +249,7 @@ func (c *typedClient) UpdateSubResource(ctx context.Context, obj Object, subReso
// PatchSubResource used by SubResourceWriter to write subresource.
func (c *typedClient) PatchSubResource(ctx context.Context, obj Object, subResource string, patch Patch, opts ...SubResourcePatchOption) error {
- o, err := c.resources.getObjMeta(obj)
+ o, err := c.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
diff --git a/pkg/client/unstructured_client.go b/pkg/client/unstructured_client.go
index 0d969517..872f3160 100644
--- a/pkg/client/unstructured_client.go
+++ b/pkg/client/unstructured_client.go
@@ -41,7 +41,7 @@ func (uc *unstructuredClient) Create(ctx context.Context, obj Object, opts ...Cr
gvk := u.GetObjectKind().GroupVersionKind()
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -70,7 +70,7 @@ func (uc *unstructuredClient) Update(ctx context.Context, obj Object, opts ...Up
gvk := u.GetObjectKind().GroupVersionKind()
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -97,7 +97,7 @@ func (uc *unstructuredClient) Delete(ctx context.Context, obj Object, opts ...De
return fmt.Errorf("unstructured client did not understand object: %T", obj)
}
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -120,7 +120,7 @@ func (uc *unstructuredClient) DeleteAllOf(ctx context.Context, obj Object, opts
return fmt.Errorf("unstructured client did not understand object: %T", obj)
}
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -143,7 +143,7 @@ func (uc *unstructuredClient) Patch(ctx context.Context, obj Object, patch Patch
return fmt.Errorf("unstructured client did not understand object: %T", obj)
}
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -178,7 +178,7 @@ func (uc *unstructuredClient) Get(ctx context.Context, key ObjectKey, obj Object
getOpts := GetOptions{}
getOpts.ApplyOptions(opts)
- r, err := uc.resources.getResource(obj)
+ r, err := uc.resources.getResource(ctx, obj)
if err != nil {
return err
}
@@ -206,7 +206,7 @@ func (uc *unstructuredClient) List(ctx context.Context, obj ObjectList, opts ...
gvk := u.GetObjectKind().GroupVersionKind()
gvk.Kind = strings.TrimSuffix(gvk.Kind, "List")
- r, err := uc.resources.getResource(obj)
+ r, err := uc.resources.getResource(ctx, obj)
if err != nil {
return err
}
@@ -235,7 +235,7 @@ func (uc *unstructuredClient) GetSubResource(ctx context.Context, obj, subResour
subResourceObj.SetName(obj.GetName())
}
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -266,7 +266,7 @@ func (uc *unstructuredClient) CreateSubResource(ctx context.Context, obj, subRes
subResourceObj.SetName(obj.GetName())
}
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -290,7 +290,7 @@ func (uc *unstructuredClient) UpdateSubResource(ctx context.Context, obj Object,
return fmt.Errorf("unstructured client did not understand object: %T", obj)
}
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
@@ -328,7 +328,7 @@ func (uc *unstructuredClient) PatchSubResource(ctx context.Context, obj Object,
gvk := u.GetObjectKind().GroupVersionKind()
- o, err := uc.resources.getObjMeta(obj)
+ o, err := uc.resources.getObjMeta(ctx, obj)
if err != nil {
return err
}
diff --git a/pkg/client/watch.go b/pkg/client/watch.go
index 181b22a6..317c87ff 100644
--- a/pkg/client/watch.go
+++ b/pkg/client/watch.go
@@ -67,7 +67,7 @@ func (w *watchingClient) metadataWatch(ctx context.Context, obj *metav1.PartialO
listOpts := w.listOpts(opts...)
- resInt, err := w.client.metadataClient.getResourceInterface(gvk, listOpts.Namespace)
+ resInt, err := w.client.metadataClient.getResourceInterface(ctx, gvk, listOpts.Namespace)
if err != nil {
return nil, err
}
@@ -76,7 +76,7 @@ func (w *watchingClient) metadataWatch(ctx context.Context, obj *metav1.PartialO
}
func (w *watchingClient) unstructuredWatch(ctx context.Context, obj runtime.Unstructured, opts ...ListOption) (watch.Interface, error) {
- r, err := w.client.unstructuredClient.resources.getResource(obj)
+ r, err := w.client.unstructuredClient.resources.getResource(ctx, obj)
if err != nil {
return nil, err
}
@@ -91,7 +91,7 @@ func (w *watchingClient) unstructuredWatch(ctx context.Context, obj runtime.Unst
}
func (w *watchingClient) typedWatch(ctx context.Context, obj ObjectList, opts ...ListOption) (watch.Interface, error) {
- r, err := w.client.typedClient.resources.getResource(obj)
+ r, err := w.client.typedClient.resources.getResource(ctx, obj)
if err != nil {
return nil, err
}
diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go
index 248893ea..8836658d 100644
--- a/pkg/cluster/cluster.go
+++ b/pkg/cluster/cluster.go
@@ -121,6 +121,11 @@ type Options struct {
// By default, the client will use the cache for reads and direct calls for writes.
Client client.Options
+ // NewAPIReaderFunc is the function that creates the APIReader client to be
+ // used by the manager. If not set this will use the default new APIReader
+ // function.
+ NewAPIReader client.NewAPIReaderFunc
+
// NewClient is the func that creates the client to be used by the manager.
// If not set this will create a Client backed by a Cache for read operations
// and a direct Client for write operations.
@@ -230,7 +235,7 @@ func New(config *rest.Config, opts ...Option) (Cluster, error) {
}
// Create the API Reader, a client with no cache.
- clientReader, err := client.New(config, client.Options{
+ clientReader, err := options.NewAPIReader(config, client.Options{
HTTPClient: options.HTTPClient,
Scheme: options.Scheme,
Mapper: mapper,
@@ -280,6 +285,10 @@ func setOptionsDefaults(options Options, config *rest.Config) (Options, error) {
options.MapperProvider = apiutil.NewDynamicRESTMapper
}
+ if options.NewAPIReader == nil {
+ options.NewAPIReader = client.NewAPIReader
+ }
+
// Allow users to define how to create a new client
if options.NewClient == nil {
options.NewClient = client.New
diff --git a/pkg/handler/enqueue.go b/pkg/handler/enqueue.go
index 1a1d1ab2..8c0e3733 100644
--- a/pkg/handler/enqueue.go
+++ b/pkg/handler/enqueue.go
@@ -20,6 +20,8 @@ import (
"context"
"reflect"
+ "github.com/kcp-dev/logicalcluster/v3"
+
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/workqueue"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -52,25 +54,16 @@ func (e *TypedEnqueueRequestForObject[T]) Create(ctx context.Context, evt event.
enqueueLog.Error(nil, "CreateEvent received with no metadata", "event", evt)
return
}
- q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
- Name: evt.Object.GetName(),
- Namespace: evt.Object.GetNamespace(),
- }})
+ q.Add(request(evt.Object))
}
// Update implements EventHandler.
func (e *TypedEnqueueRequestForObject[T]) Update(ctx context.Context, evt event.TypedUpdateEvent[T], q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
switch {
case !isNil(evt.ObjectNew):
- q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
- Name: evt.ObjectNew.GetName(),
- Namespace: evt.ObjectNew.GetNamespace(),
- }})
+ q.Add(request(evt.ObjectNew))
case !isNil(evt.ObjectOld):
- q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
- Name: evt.ObjectOld.GetName(),
- Namespace: evt.ObjectOld.GetNamespace(),
- }})
+ q.Add(request(evt.ObjectOld))
default:
enqueueLog.Error(nil, "UpdateEvent received with no metadata", "event", evt)
}
@@ -82,10 +75,7 @@ func (e *TypedEnqueueRequestForObject[T]) Delete(ctx context.Context, evt event.
enqueueLog.Error(nil, "DeleteEvent received with no metadata", "event", evt)
return
}
- q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
- Name: evt.Object.GetName(),
- Namespace: evt.Object.GetNamespace(),
- }})
+ q.Add(request(evt.Object))
}
// Generic implements EventHandler.
@@ -94,10 +84,18 @@ func (e *TypedEnqueueRequestForObject[T]) Generic(ctx context.Context, evt event
enqueueLog.Error(nil, "GenericEvent received with no metadata", "event", evt)
return
}
- q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
- Name: evt.Object.GetName(),
- Namespace: evt.Object.GetNamespace(),
- }})
+ q.Add(request(evt.Object))
+}
+
+func request(obj client.Object) reconcile.Request {
+ return reconcile.Request{
+ // TODO(kcp) Need to implement a non-kcp-specific way to support this
+ ClusterName: logicalcluster.From(obj).String(),
+ NamespacedName: types.NamespacedName{
+ Namespace: obj.GetNamespace(),
+ Name: obj.GetName(),
+ },
+ }
}
func isNil(arg any) bool {
diff --git a/pkg/handler/enqueue_owner.go b/pkg/handler/enqueue_owner.go
index 1680043b..bf0aa43d 100644
--- a/pkg/handler/enqueue_owner.go
+++ b/pkg/handler/enqueue_owner.go
@@ -20,6 +20,8 @@ import (
"context"
"fmt"
+ "github.com/kcp-dev/logicalcluster/v3"
+
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -181,9 +183,12 @@ func (e *enqueueRequestForOwner[object]) getOwnerReconcileRequest(obj metav1.Obj
// object in the event.
if ref.Kind == e.groupKind.Kind && refGV.Group == e.groupKind.Group {
// Match found - add a Request for the object referred to in the OwnerReference
- request := reconcile.Request{NamespacedName: types.NamespacedName{
- Name: ref.Name,
- }}
+ request := reconcile.Request{
+ ClusterName: logicalcluster.From(obj).String(),
+ NamespacedName: types.NamespacedName{
+ Name: ref.Name,
+ },
+ }
// if owner is not namespaced then we should not set the namespace
mapping, err := e.mapper.RESTMapping(e.groupKind, refGV.Version)
diff --git a/pkg/kcp/helper.go b/pkg/kcp/helper.go
new file mode 100644
index 00000000..85d39001
--- /dev/null
+++ b/pkg/kcp/helper.go
@@ -0,0 +1,34 @@
+/*
+Copyright 2024 The KCP 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 kcp
+
+import (
+ "context"
+
+ "github.com/kcp-dev/logicalcluster/v3"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+)
+
+// WithClusterInContext injects a cluster name into a context such that
+// cluster clients and cache work out of the box.
+func WithClusterInContext(r reconcile.Reconciler) reconcile.Reconciler {
+ return reconcile.Func(func(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
+ ctx = kontext.WithCluster(ctx, logicalcluster.Name(req.ClusterName))
+ return r.Reconcile(ctx, req)
+ })
+}
diff --git a/pkg/kcp/kcp_suite_test.go b/pkg/kcp/kcp_suite_test.go
new file mode 100644
index 00000000..b81a2019
--- /dev/null
+++ b/pkg/kcp/kcp_suite_test.go
@@ -0,0 +1,35 @@
+/*
+Copyright 2018 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 kcp_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/log/zap"
+)
+
+func TestKCP(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "kcp Suite")
+}
+
+var _ = BeforeSuite(func() {
+ logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
+})
diff --git a/pkg/kcp/kcp_test.go b/pkg/kcp/kcp_test.go
new file mode 100644
index 00000000..a4bfce85
--- /dev/null
+++ b/pkg/kcp/kcp_test.go
@@ -0,0 +1,339 @@
+package kcp
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ 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/types"
+ "k8s.io/apimachinery/pkg/util/sets"
+ "k8s.io/client-go/rest"
+ "sigs.k8s.io/controller-runtime/pkg/cache"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
+)
+
+var _ = Describe("NewClusterAwareClient", Ordered, func() {
+ var (
+ srv *httptest.Server
+ mu sync.Mutex
+ paths []string
+ cfg *rest.Config
+ )
+
+ BeforeAll(func() {
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ mu.Lock()
+ paths = append(paths, req.URL.Path)
+ mu.Unlock()
+
+ switch req.URL.Path {
+ case "/api/v1", "/clusters/root/api/v1", "/clusters/*/api/v1":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"kind":"APIResourceList","groupVersion":"v1","resources":[{"name":"pods","singularName":"pod","namespaced":true,"kind":"Pod","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["po"],"categories":["all"],"storageVersionHash":"xPOwRZ+Yhw8="}]}`))
+ case "/api/v1/pods", "/clusters/root/api/v1/pods", "/clusters/*/api/v1/pods":
+ if req.URL.Query().Get("watch") != "true" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"kind": "PodList","apiVersion": "v1","metadata": {"resourceVersion": "184126176"}, "items": [{"kind":"Pod","apiVersion":"v1","metadata":{"name":"foo","namespace":"default","resourceVersion":"184126176"}}]}`))
+ return
+ }
+ fallthrough
+ case "/api/v1/namespaces/default/pods/foo", "/clusters/root/api/v1/namespaces/default/pods/foo":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"kind":"Pod","apiVersion":"v1","metadata":{"name":"foo","namespace":"default","resourceVersion":"184126176"}}`))
+ default:
+ _, _ = w.Write([]byte(fmt.Sprintf("Not found %q", req.RequestURI)))
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+
+ cfg = &rest.Config{
+ Host: srv.URL,
+ }
+ Expect(rest.SetKubernetesDefaults(cfg)).To(Succeed())
+ })
+
+ BeforeEach(func() {
+ mu.Lock()
+ defer mu.Unlock()
+ paths = []string{}
+ })
+
+ AfterAll(func() {
+ srv.Close()
+ })
+
+ Describe("with typed list", func() {
+ It("should work with no cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &corev1.PodList{}
+ err = cl.List(ctx, pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ pod := &corev1.Pod{}
+ err = cl.Get(ctx, types.NamespacedName{Namespace: "default", Name: "foo"}, pod)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/api/v1", "/api/v1/pods", "/api/v1/namespaces/default/pods/foo"}))
+ })
+
+ It("should work with a cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &corev1.PodList{}
+ err = cl.List(kontext.WithCluster(ctx, "root"), pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ pod := &corev1.Pod{}
+ err = cl.Get(kontext.WithCluster(ctx, "root"), types.NamespacedName{Namespace: "default", Name: "foo"}, pod)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/root/api/v1", "/clusters/root/api/v1/pods", "/clusters/root/api/v1/namespaces/default/pods/foo"}))
+ })
+
+ It("should work with a wildcard cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &corev1.PodList{}
+ err = cl.List(kontext.WithCluster(ctx, "*"), pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/*/api/v1", "/clusters/*/api/v1/pods"}))
+ })
+ })
+
+ Describe("with unstructured list", func() {
+ It("should work with no cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &unstructured.UnstructuredList{}
+ pods.SetAPIVersion("v1")
+ pods.SetKind("PodList")
+ err = cl.List(ctx, pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ pod := &unstructured.Unstructured{}
+ pod.SetAPIVersion("v1")
+ pod.SetKind("Pod")
+ err = cl.Get(ctx, types.NamespacedName{Namespace: "default", Name: "foo"}, pod)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/api/v1", "/api/v1/pods", "/api/v1/namespaces/default/pods/foo"}))
+ })
+
+ It("should work with a cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &unstructured.UnstructuredList{}
+ pods.SetAPIVersion("v1")
+ pods.SetKind("PodList")
+ err = cl.List(kontext.WithCluster(ctx, "root"), pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ pod := &unstructured.Unstructured{}
+ pod.SetAPIVersion("v1")
+ pod.SetKind("Pod")
+ err = cl.Get(kontext.WithCluster(ctx, "root"), types.NamespacedName{Namespace: "default", Name: "foo"}, pod)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/root/api/v1", "/clusters/root/api/v1/pods", "/clusters/root/api/v1/namespaces/default/pods/foo"}))
+ })
+
+ It("should work with a wildcard cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &unstructured.UnstructuredList{}
+ pods.SetAPIVersion("v1")
+ pods.SetKind("PodList")
+ err = cl.List(kontext.WithCluster(ctx, "*"), pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/*/api/v1", "/clusters/*/api/v1/pods"}))
+ })
+ })
+
+ Describe("with a metadata object", func() {
+ It("should work with no cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &metav1.PartialObjectMetadataList{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PodList"}}
+ err = cl.List(ctx, pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/api/v1", "/api/v1/pods"}))
+ })
+
+ It("should work with a cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &metav1.PartialObjectMetadataList{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PodList"}}
+ err = cl.List(kontext.WithCluster(ctx, "root"), pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/root/api/v1", "/clusters/root/api/v1/pods"}))
+ })
+
+ It("should work with a wildcard cluster in the kontext", func(ctx context.Context) {
+ cl, err := NewClusterAwareClient(cfg, client.Options{})
+ Expect(err).NotTo(HaveOccurred())
+
+ pods := &metav1.PartialObjectMetadataList{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PodList"}}
+ err = cl.List(kontext.WithCluster(ctx, "*"), pods)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/*/api/v1", "/clusters/*/api/v1/pods"}))
+ })
+ })
+})
+
+var _ = Describe("NewClusterAwareCache", Ordered, func() {
+ var (
+ cancelCtx context.CancelFunc
+ srv *httptest.Server
+ mu sync.Mutex
+ paths []string
+ cfg *rest.Config
+ c cache.Cache
+ )
+
+ BeforeAll(func() {
+ var ctx context.Context
+ ctx, cancelCtx = context.WithCancel(context.Background())
+
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ mu.Lock()
+ pth := req.URL.Path
+ if req.URL.Query().Get("watch") == "true" {
+ pth += "?watch=true"
+ }
+ paths = append(paths, pth)
+ mu.Unlock()
+
+ switch {
+ case req.URL.Path == "/clusters/*/api/v1":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"kind":"APIResourceList","groupVersion":"v1","resources":[{"name":"pods","singularName":"pod","namespaced":true,"kind":"Pod","verbs":["create","delete","deletecollection","get","list","patch","update","watch"],"shortNames":["po"],"categories":["all"],"storageVersionHash":"xPOwRZ+Yhw8="}]}`))
+ case req.URL.Path == "/clusters/*/api/v1/pods" && req.URL.Query().Get("watch") != "true":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"kind": "PodList","apiVersion": "v1","metadata": {"resourceVersion": "184126176"}, "items": [
+ {"kind":"Pod","apiVersion":"v1","metadata":{"name":"foo","namespace":"default","resourceVersion":"184126176","annotations":{"kcp.io/cluster":"root"}}},
+ {"kind":"Pod","apiVersion":"v1","metadata":{"name":"foo","namespace":"default","resourceVersion":"184126093","annotations":{"kcp.io/cluster":"ws"}}}
+ ]}`))
+ case req.URL.Path == "/clusters/*/api/v1/pods" && req.URL.Query().Get("watch") == "true":
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Transfer-Encoding", "chunked")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"type":"ADDED","object":{"kind":"Pod","apiVersion":"v1","metadata":{"name":"bar","namespace":"default","resourceVersion":"184126177","annotations":{"kcp.io/cluster":"root"}}}}`))
+ _, _ = w.Write([]byte(`{"type":"ADDED","object":{"kind":"Pod","apiVersion":"v1","metadata":{"name":"bar","namespace":"default","resourceVersion":"184126178","annotations":{"kcp.io/cluster":"ws"}}}}`))
+ if w, ok := w.(http.Flusher); ok {
+ w.Flush()
+ }
+ time.Sleep(1 * time.Second)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(fmt.Sprintf("Not found %q", req.RequestURI)))
+ }
+ }))
+ go func() {
+ <-ctx.Done()
+ srv.Close()
+ }()
+
+ cfg = &rest.Config{
+ Host: srv.URL,
+ }
+ Expect(rest.SetKubernetesDefaults(cfg)).To(Succeed())
+
+ var err error
+ c, err = NewClusterAwareCache(cfg, cache.Options{})
+ Expect(err).NotTo(HaveOccurred())
+ go func() {
+ if err := c.Start(ctx); err != nil {
+ Expect(err).NotTo(HaveOccurred())
+ }
+ }()
+ c.WaitForCacheSync(ctx)
+ })
+
+ BeforeEach(func() {
+ mu.Lock()
+ defer mu.Unlock()
+ paths = []string{}
+ })
+
+ AfterAll(func() {
+ cancelCtx()
+ })
+
+ It("should always access wildcard clusters and serve other clusters from memory", func(ctx context.Context) {
+ pod := &corev1.Pod{}
+ err := c.Get(kontext.WithCluster(ctx, "root"), types.NamespacedName{Namespace: "default", Name: "foo"}, pod)
+ Expect(err).NotTo(HaveOccurred())
+
+ mu.Lock()
+ defer mu.Unlock()
+ Expect(paths).To(Equal([]string{"/clusters/*/api/v1", "/clusters/*/api/v1/pods", "/clusters/*/api/v1/pods?watch=true"}))
+ })
+
+ It("should return only the pods from the requested cluster", func(ctx context.Context) {
+ pod := &corev1.Pod{}
+ err := c.Get(kontext.WithCluster(ctx, "root"), types.NamespacedName{Namespace: "default", Name: "foo"}, pod)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pod.Annotations).To(HaveKeyWithValue("kcp.io/cluster", "root"))
+
+ pods := &corev1.PodList{}
+ err = c.List(kontext.WithCluster(ctx, "root"), pods)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pods.Items).To(HaveLen(2))
+ Expect(pods.Items[0].Annotations).To(HaveKeyWithValue("kcp.io/cluster", "root"))
+ Expect(pods.Items[1].Annotations).To(HaveKeyWithValue("kcp.io/cluster", "root"))
+ Expect(sets.New(pods.Items[0].Name, pods.Items[1].Name)).To(Equal(sets.New("foo", "bar")))
+ })
+
+ It("should return all pods from all clusters without cluster in context", func(ctx context.Context) {
+ pods := &corev1.PodList{}
+ err := c.List(ctx, pods)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pods.Items).To(HaveLen(4))
+ })
+})
diff --git a/pkg/kcp/wrappers.go b/pkg/kcp/wrappers.go
new file mode 100644
index 00000000..974cec58
--- /dev/null
+++ b/pkg/kcp/wrappers.go
@@ -0,0 +1,280 @@
+/*
+Copyright 2022 The KCP 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 kcp
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/rest"
+ k8scache "k8s.io/client-go/tools/cache"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/cache"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+ "sigs.k8s.io/controller-runtime/pkg/kontext"
+ "sigs.k8s.io/controller-runtime/pkg/manager"
+
+ kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache"
+ "github.com/kcp-dev/apimachinery/v2/third_party/informers"
+ "github.com/kcp-dev/logicalcluster/v3"
+)
+
+// NewClusterAwareManager returns a kcp-aware manager with appropriate defaults for cache and
+// client creation.
+func NewClusterAwareManager(cfg *rest.Config, options ctrl.Options) (manager.Manager, error) {
+ if options.NewCache == nil {
+ options.NewCache = NewClusterAwareCache
+ }
+
+ if options.NewAPIReader == nil {
+ options.NewAPIReader = NewClusterAwareAPIReader
+ }
+
+ if options.NewClient == nil {
+ options.NewClient = NewClusterAwareClient
+ }
+
+ if options.MapperProvider == nil {
+ options.MapperProvider = newWildcardClusterMapperProvider
+ }
+
+ cfg.Wrap(func(rt http.RoundTripper) http.RoundTripper {
+ return newClusterAwareRoundTripper(rt)
+ })
+ return ctrl.NewManager(cfg, options)
+}
+
+// NewInformerWithClusterIndexes returns a SharedIndexInformer that is configured
+// ClusterIndexName and ClusterAndNamespaceIndexName indexes.
+func NewInformerWithClusterIndexes(lw k8scache.ListerWatcher, obj runtime.Object, syncPeriod time.Duration, indexers k8scache.Indexers) k8scache.SharedIndexInformer {
+ indexers[kcpcache.ClusterIndexName] = kcpcache.ClusterIndexFunc
+ indexers[kcpcache.ClusterAndNamespaceIndexName] = kcpcache.ClusterAndNamespaceIndexFunc
+
+ return informers.NewSharedIndexInformer(lw, obj, syncPeriod, indexers)
+}
+
+// NewClusterAwareCache returns a cache.Cache that handles multi-cluster watches.
+func NewClusterAwareCache(config *rest.Config, opts cache.Options) (cache.Cache, error) {
+ c := rest.CopyConfig(config)
+ c.Host = strings.TrimSuffix(c.Host, "/") + "/clusters/*"
+
+ opts.NewInformerFunc = NewInformerWithClusterIndexes
+ return cache.New(c, opts)
+}
+
+// NewClusterAwareAPIReader returns a client.Reader that provides read-only access to the API server,
+// and is configured to use the context to scope requests to the proper cluster. To scope requests,
+// pass the request context with the cluster set.
+// Example:
+//
+// import (
+// "context"
+// kcpclient "github.com/kcp-dev/apimachinery/v2/pkg/client"
+// ctrl "sigs.k8s.io/controller-runtime"
+// )
+// func (r *reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+// ctx = kcpclient.WithCluster(ctx, req.ObjectKey.Cluster)
+// // from here on pass this context to all client calls
+// ...
+// }
+func NewClusterAwareAPIReader(config *rest.Config, opts client.Options) (client.Reader, error) {
+ if opts.HTTPClient == nil {
+ httpClient, err := NewClusterAwareHTTPClient(config)
+ if err != nil {
+ return nil, err
+ }
+ opts.HTTPClient = httpClient
+ }
+ if opts.Mapper == nil && opts.MapperWithContext == nil {
+ opts.MapperWithContext = NewClusterAwareMapperProvider(config, opts.HTTPClient)
+ }
+ return client.NewAPIReader(config, opts)
+}
+
+// NewClusterAwareClient returns a client.Client that is configured to use the context
+// to scope requests to the proper cluster. To scope requests, pass the request context with the cluster set.
+// Example:
+//
+// import (
+// "context"
+// kcpclient "github.com/kcp-dev/apimachinery/v2/pkg/client"
+// ctrl "sigs.k8s.io/controller-runtime"
+// )
+// func (r *reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+// ctx = kcpclient.WithCluster(ctx, req.ObjectKey.Cluster)
+// // from here on pass this context to all client calls
+// ...
+// }
+func NewClusterAwareClient(config *rest.Config, opts client.Options) (client.Client, error) {
+ opts, err := applyClientOptions(config, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return client.New(config, opts)
+}
+
+func applyClientOptions(config *rest.Config, opts client.Options) (client.Options, error) {
+ if opts.HTTPClient == nil {
+ httpClient, err := NewClusterAwareHTTPClient(config)
+ if err != nil {
+ return opts, err
+ }
+ opts.HTTPClient = httpClient
+ }
+ if opts.Mapper == nil && opts.MapperWithContext == nil {
+ opts.MapperWithContext = NewClusterAwareMapperProvider(config, opts.HTTPClient)
+ }
+
+ return opts, nil
+}
+
+// NewClusterAwareHTTPClient returns an http.Client with a cluster aware round tripper.
+func NewClusterAwareHTTPClient(config *rest.Config) (*http.Client, error) {
+ httpClient, err := rest.HTTPClientFor(config)
+ if err != nil {
+ return nil, err
+ }
+
+ httpClient.Transport = newClusterAwareRoundTripper(httpClient.Transport)
+ return httpClient, nil
+}
+
+// NewClusterAwareClientWithWatch returns a new WithWatch with a cluster aware client underneath.
+func NewClusterAwareClientWithWatch(config *rest.Config, options client.Options) (client.WithWatch, error) {
+ opts, err := applyClientOptions(config, options)
+ if err != nil {
+ return nil, err
+ }
+
+ return client.NewWithWatch(config, opts)
+}
+
+// NewClusterAwareMapperProvider returns a function producing RESTMapper for the
+// cluster specified in the context.
+func NewClusterAwareMapperProvider(c *rest.Config, httpClient *http.Client) func(ctx context.Context) (meta.RESTMapper, error) {
+ return func(ctx context.Context) (meta.RESTMapper, error) {
+ cluster, _ := kontext.ClusterFrom(ctx) // intentionally ignoring second "found" return value
+ cl := *httpClient
+ cl.Transport = clusterRoundTripper{cluster: cluster.Path(), delegate: httpClient.Transport}
+ return apiutil.NewDynamicRESTMapper(c, &cl)
+ }
+}
+
+// newWildcardClusterMapperProvider returns a RESTMapper that talks to the /clusters/* endpoint.
+func newWildcardClusterMapperProvider(c *rest.Config, httpClient *http.Client) (meta.RESTMapper, error) {
+ mapperCfg := rest.CopyConfig(c)
+ if !strings.HasSuffix(mapperCfg.Host, "/clusters/*") {
+ mapperCfg.Host += "/clusters/*"
+ }
+
+ return apiutil.NewDynamicRESTMapper(mapperCfg, httpClient)
+}
+
+// ClusterAwareBuilderWithOptions returns a cluster aware Cache constructor that will build
+// a cache honoring the options argument, this is useful to specify options like
+// SelectorsDefaultNamespaces
+// WARNING: If SelectorsByObject is specified, filtered out resources are not
+// returned.
+// WARNING: If UnsafeDisableDeepCopy is enabled, you must DeepCopy any object
+// returned from cache get/list before mutating it.
+func ClusterAwareBuilderWithOptions(options cache.Options) cache.NewCacheFunc {
+ return func(config *rest.Config, opts cache.Options) (cache.Cache, error) {
+ if options.Scheme == nil {
+ options.Scheme = opts.Scheme
+ }
+ if options.Mapper == nil {
+ options.Mapper = opts.Mapper
+ }
+ if options.SyncPeriod == nil {
+ options.SyncPeriod = opts.SyncPeriod
+ }
+ if opts.DefaultNamespaces == nil {
+ opts.DefaultNamespaces = options.DefaultNamespaces
+ }
+
+ return NewClusterAwareCache(config, options)
+ }
+}
+
+// clusterAwareRoundTripper is a cluster-aware wrapper around http.RoundTripper
+// taking the cluster from the context.
+type clusterAwareRoundTripper struct {
+ delegate http.RoundTripper
+}
+
+// newClusterAwareRoundTripper creates a new cluster aware round tripper.
+func newClusterAwareRoundTripper(delegate http.RoundTripper) *clusterAwareRoundTripper {
+ return &clusterAwareRoundTripper{
+ delegate: delegate,
+ }
+}
+
+func (c *clusterAwareRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ cluster, ok := kontext.ClusterFrom(req.Context())
+ if ok && !cluster.Empty() {
+ return clusterRoundTripper{cluster: cluster.Path(), delegate: c.delegate}.RoundTrip(req)
+ }
+ return c.delegate.RoundTrip(req)
+}
+
+// clusterRoundTripper is static cluster-aware wrapper around http.RoundTripper.
+type clusterRoundTripper struct {
+ cluster logicalcluster.Path
+ delegate http.RoundTripper
+}
+
+func (c clusterRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ if !c.cluster.Empty() {
+ req = req.Clone(req.Context())
+ req.URL.Path = generatePath(req.URL.Path, c.cluster)
+ req.URL.RawPath = generatePath(req.URL.RawPath, c.cluster)
+ }
+ return c.delegate.RoundTrip(req)
+}
+
+// apiRegex matches any string that has /api/ or /apis/ in it.
+var apiRegex = regexp.MustCompile(`(/api/|/apis/)`)
+
+// generatePath formats the request path to target the specified cluster.
+func generatePath(originalPath string, clusterPath logicalcluster.Path) string {
+ // If the originalPath already has cluster.Path() then the path was already modifed and no change needed
+ if strings.Contains(originalPath, clusterPath.RequestPath()) {
+ return originalPath
+ }
+ // If the originalPath has /api/ or /apis/ in it, it might be anywhere in the path, so we use a regex to find and
+ // replaces /api/ or /apis/ with $cluster/api/ or $cluster/apis/
+ if apiRegex.MatchString(originalPath) {
+ return apiRegex.ReplaceAllString(originalPath, fmt.Sprintf("%s$1", clusterPath.RequestPath()))
+ }
+ // Otherwise, we're just prepending /clusters/$name
+ path := clusterPath.RequestPath()
+ // if the original path is relative, add a / separator
+ if len(originalPath) > 0 && originalPath[0] != '/' {
+ path += "/"
+ }
+ // finally append the original path
+ path += originalPath
+ return path
+}
diff --git a/pkg/kontext/kontext.go b/pkg/kontext/kontext.go
new file mode 100644
index 00000000..4de151a7
--- /dev/null
+++ b/pkg/kontext/kontext.go
@@ -0,0 +1,24 @@
+package kontext
+
+import (
+ "context"
+
+ "github.com/kcp-dev/logicalcluster/v3"
+)
+
+type key int
+
+const (
+ keyCluster key = iota
+)
+
+// WithCluster injects a cluster name into a context.
+func WithCluster(ctx context.Context, cluster logicalcluster.Name) context.Context {
+ return context.WithValue(ctx, keyCluster, cluster)
+}
+
+// ClusterFrom extracts a cluster name from the context.
+func ClusterFrom(ctx context.Context) (logicalcluster.Name, bool) {
+ s, ok := ctx.Value(keyCluster).(logicalcluster.Name)
+ return s, ok
+}
diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go
index 3166f481..6abc0b04 100644
--- a/pkg/manager/manager.go
+++ b/pkg/manager/manager.go
@@ -126,6 +126,11 @@ type Options struct {
// Only use a custom NewCache if you know what you are doing.
NewCache cache.NewCacheFunc
+ // NewAPIReaderFunc is the function that creates the APIReader client to be
+ // used by the manager. If not set this will use the default new APIReader
+ // function.
+ NewAPIReader client.NewAPIReaderFunc
+
// Client is the client.Options that will be used to create the default Client.
// By default, the client will use the cache for reads and direct calls for writes.
Client client.Options
@@ -330,6 +335,7 @@ func New(config *rest.Config, options Options) (Manager, error) {
clusterOptions.MapperProvider = options.MapperProvider
clusterOptions.Logger = options.Logger
clusterOptions.NewCache = options.NewCache
+ clusterOptions.NewAPIReader = options.NewAPIReader
clusterOptions.NewClient = options.NewClient
clusterOptions.Cache = options.Cache
clusterOptions.Client = options.Client
diff --git a/pkg/reconcile/reconcile.go b/pkg/reconcile/reconcile.go
index ee63f681..3e24fadb 100644
--- a/pkg/reconcile/reconcile.go
+++ b/pkg/reconcile/reconcile.go
@@ -50,6 +50,10 @@ func (r *Result) IsZero() bool {
type Request struct {
// NamespacedName is the name and namespace of the object to reconcile.
types.NamespacedName
+
+ // ClusterName can be used for reconciling requests across multiple clusters,
+ // to prevent objects with the same name and namespace from conflicting
+ ClusterName string
}
/*
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment