diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7457664 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,16 @@ +--- +name: Bug Report +about: Report a bug +labels: kind/bug + +--- + +**What happened**: + +**What you expected to happen**: + +**How to reproduce it (as minimally and precisely as possible)**: + +**Anything else we need to know**: + +**Environment**: diff --git a/.github/ISSUE_TEMPLATE/enhancement_request.md b/.github/ISSUE_TEMPLATE/enhancement_request.md new file mode 100644 index 0000000..4179e17 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement_request.md @@ -0,0 +1,10 @@ +--- +name: Enhancement Request +about: Suggest an enhancement +labels: kind/enhancement + +--- + +**What would you like to be added**: + +**Why is this needed**: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1fe33e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +**What this PR does / why we need it**: + +**Which issue(s) this PR fixes**: +Fixes # + +**Special notes for your reviewer**: + +**Release note**: + +```feature user + +``` diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..309f298 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,33 @@ +name: ci +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: make tidy + run: | + make tidy + git diff --exit-code + + - name: make verify + run: make verify + + - name: make test + run: make test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed0fe68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +cover.html +*.out + +# Go workspace file +go.work +go.work.sum + +tmp/ +.vscode/ +.idea diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..083aa42 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,8 @@ +run: + concurrency: 4 + timeout: 10m + +issues: + exclude-files: + - "zz_generated.*\\.go$" + - "tmp/.*" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cb0cfef --- /dev/null +++ b/Makefile @@ -0,0 +1,136 @@ +PROJECT_FULL_NAME := controller-utils +REPO_ROOT := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +EFFECTIVE_VERSION := $(shell $(REPO_ROOT)/hack/get-version.sh) + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# CONTAINER_TOOL defines the container tool to be used for building images. +# Be aware that the target commands are only tested with Docker which is +# scaffolded by default. However, you might want to replace it to use other +# tools. (i.e. podman) +CONTAINER_TOOL ?= docker + +# Setting SHELL to bash allows bash commands to be executed by recipes. +# Options are set to exit when a recipe line exits non-zero or a piped command fails. +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +ROOT_CODE_DIRS := $(REPO_ROOT)/pkg/... + +##@ General + +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\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) + +##@ Development + +.PHONY: tidy +tidy: ## Runs 'go mod tidy' for all modules in this repo. + @$(REPO_ROOT)/hack/tidy.sh + +.PHONY: format +format: goimports ## Formats the imports. + @FORMATTER=$(FORMATTER) $(REPO_ROOT)/hack/format.sh $(ROOT_CODE_DIRS) + +.PHONY: verify +verify: golangci-lint goimports ## Runs linter, 'go vet', and checks if the formatter has been run. + @( echo "> Verifying root module ..." && \ + pushd $(REPO_ROOT) &>/dev/null && \ + go vet $(ROOT_CODE_DIRS) && \ + $(LINTER) run -c $(REPO_ROOT)/.golangci.yaml $(ROOT_CODE_DIRS) && \ + popd &>/dev/null ) + @test "$(SKIP_FORMATTING_CHECK)" = "true" || \ + ( echo "> Checking for unformatted files ..." && \ + FORMATTER=$(FORMATTER) $(REPO_ROOT)/hack/format.sh --verify $(ROOT_CODE_DIRS) ) + +.PHONY: test +test: ## Run tests. + go test $(ROOT_CODE_DIRS) -coverprofile cover.out + go tool cover --html=cover.out -o cover.html + go tool cover -func cover.out | tail -n 1 + +##@ Release + +.PHONY: prepare-release +prepare-release: tidy format verify test + +.PHONY: release-major +release-major: prepare-release ## Creates a new major release. + @$(REPO_ROOT)/hack/release.sh major + +.PHONY: release-minor +release-minor: prepare-release ## Creates a new minor release. + @$(REPO_ROOT)/hack/release.sh minor + +.PHONY: release-patch +release-patch: prepare-release ## Creates a new patch release. + @$(REPO_ROOT)/hack/release.sh patch + +##@ Build Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(REPO_ROOT)/bin + +## Tool Binaries +FORMATTER ?= $(LOCALBIN)/goimports +LINTER ?= $(LOCALBIN)/golangci-lint + +## Tool Versions +FORMATTER_VERSION ?= v0.22.0 +LINTER_VERSION ?= 1.61.0 + +.PHONY: localbin +localbin: + @test -d $(LOCALBIN) || mkdir -p $(LOCALBIN) + +.PHONY: goimports +goimports: localbin ## Download goimports locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(FORMATTER) && test -s ./hack/goimports_version && cat ./hack/goimports_version | grep -q $(FORMATTER_VERSION) || \ + ( echo "Installing goimports $(FORMATTER_VERSION) ..."; \ + GOBIN=$(LOCALBIN) go install golang.org/x/tools/cmd/goimports@$(FORMATTER_VERSION) && \ + echo $(FORMATTER_VERSION) > ./hack/goimports_version ) + +.PHONY: golangci-lint +golangci-lint: localbin ## Download golangci-lint locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(LINTER) && $(LINTER) --version | grep -q $(LINTER_VERSION) || \ + ( echo "Installing golangci-lint $(LINTER_VERSION) ..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LOCALBIN) v$(LINTER_VERSION) ) + + + + + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +CONTROLLER_TOOLS_VERSION ?= v0.15.0 + +.PHONY: generate +generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + +.PHONY: controller-gen +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. If wrong version is installed, it will be overwritten. +$(CONTROLLER_GEN): $(LOCALBIN) + test -s $(LOCALBIN)/controller-gen && $(LOCALBIN)/controller-gen --version | grep -q $(CONTROLLER_TOOLS_VERSION) || \ + GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..268b033 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v0.3.0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6c5a642 --- /dev/null +++ b/go.mod @@ -0,0 +1,82 @@ +module github.com/openmcp-project/controller-utils + +go 1.23.0 + +require ( + github.com/go-logr/logr v1.4.2 + github.com/go-logr/zapr v1.3.0 + github.com/onsi/ginkgo v1.16.5 + github.com/onsi/ginkgo/v2 v2.22.0 + github.com/onsi/gomega v1.36.1 + github.com/openmcp-project/controller-utils/api v0.0.0-00010101000000-000000000000 + github.com/spf13/pflag v1.0.6 + github.com/stretchr/testify v1.10.0 + go.uber.org/zap v1.27.0 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.32.2 + k8s.io/apiextensions-apiserver v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 + k8s.io/utils v0.0.0-20241210054802-24370beab758 + sigs.k8s.io/controller-runtime v0.20.2 + sigs.k8s.io/yaml v1.4.0 +) + +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.12.1 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.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/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/mailru/easyjson v0.9.0 // 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/nxadm/tail v1.4.8 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/oauth2 v0.26.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 // indirect + golang.org/x/time v0.10.0 // indirect + golang.org/x/tools v0.26.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect +) + +replace github.com/openmcp-project/controller-utils/api => ./pkg/api diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..788f3dc --- /dev/null +++ b/go.sum @@ -0,0 +1,230 @@ +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/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.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= +github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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.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 v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +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/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +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/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= +github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +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.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.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.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.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-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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 v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +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/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.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= +k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= +k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= +k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= +k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= +k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +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-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= +sigs.k8s.io/controller-runtime v0.20.2/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +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/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000..e69de29 diff --git a/hack/format.sh b/hack/format.sh new file mode 100755 index 0000000..ea42ba2 --- /dev/null +++ b/hack/format.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail +PROJECT_ROOT="$(realpath $(dirname $0)/..)" + +if [[ -z ${LOCALBIN:-} ]]; then + LOCALBIN="$PROJECT_ROOT/bin" +fi +if [[ -z ${FORMATTER:-} ]]; then + FORMATTER="$LOCALBIN/goimports" +fi + +write_mode="-w" +if [[ ${1:-} == "--verify" ]]; then + write_mode="" + shift +fi + +tmp=$("${FORMATTER}" -l $write_mode -local=github.com/openmcp-project/controller-utils $("$PROJECT_ROOT/hack/unfold.sh" --clean --no-unfold "$@")) + +if [[ -z ${write_mode} ]] && [[ ${tmp} ]]; then + echo "unformatted files detected, please run 'make format'" 1>&2 + echo "$tmp" 1>&2 + exit 1 +fi + +if [[ ${tmp} ]]; then + echo "> Formatting imports ..." + echo "$tmp" +fi diff --git a/hack/get-version.sh b/hack/get-version.sh new file mode 100755 index 0000000..1b09e29 --- /dev/null +++ b/hack/get-version.sh @@ -0,0 +1,22 @@ +#!/bin/bash -eu + +set -euo pipefail + +if [[ -n ${EFFECTIVE_VERSION:-""} ]] ; then + # running in the pipeline use the provided EFFECTIVE_VERSION + echo "$EFFECTIVE_VERSION" + exit 0 +fi + +PROJECT_ROOT="$(realpath $(dirname $0)/..)" +VERSION="$(cat "${PROJECT_ROOT}/VERSION")" + +( + cd "$PROJECT_ROOT" + + if [[ "$VERSION" = *-dev ]] ; then + VERSION="$VERSION-$(git rev-parse HEAD)" + fi + + echo "$VERSION" +) diff --git a/hack/goimports_version b/hack/goimports_version new file mode 100644 index 0000000..4f27943 --- /dev/null +++ b/hack/goimports_version @@ -0,0 +1 @@ +v0.22.0 diff --git a/hack/release.sh b/hack/release.sh new file mode 100755 index 0000000..8d62ac6 --- /dev/null +++ b/hack/release.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +set -euo pipefail +PROJECT_ROOT="$(realpath $(dirname $0)/..)" + +HACK_DIR="$PROJECT_ROOT/hack" + +VERSION=$("$HACK_DIR/get-version.sh") + +echo "> Finding latest release" +major=${VERSION%%.*} +major=${major#v} +minor=${VERSION#*.} +minor=${minor%%.*} +patch=${VERSION##*.} +patch=${patch%%-*} +echo "v${major}.${minor}.${patch}" +echo + +semver=${1:-"minor"} + +case "$semver" in + ("major") + major=$((major + 1)) + minor=0 + patch=0 + ;; + ("minor") + minor=$((minor + 1)) + patch=0 + ;; + ("patch") + patch=$((patch + 1)) + ;; + (*) + echo "invalid argument: $semver" + exit 1 + ;; +esac + +release_version="v$major.$minor.$patch" + +echo "The release version will be $release_version. Please confirm with 'yes' or 'y':" +read confirm + +if [[ "$confirm" != "yes" ]] && [[ "$confirm" != "y" ]]; then + echo "Release not confirmed." + exit 0 +fi +echo + +echo "> Updating version to release version" +"$HACK_DIR/set-version.sh" $release_version +echo + +git add --all +git commit -m "release $release_version" +git push +echo + +echo "> Successfully finished" diff --git a/hack/set-version.sh b/hack/set-version.sh new file mode 100755 index 0000000..ca45417 --- /dev/null +++ b/hack/set-version.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -euo pipefail +PROJECT_ROOT="$(realpath $(dirname $0)/..)" + +HACK_DIR="$PROJECT_ROOT/hack" + +VERSION=$1 + +# update VERSION file +echo "$VERSION" > "$PROJECT_ROOT/VERSION" diff --git a/hack/tidy.sh b/hack/tidy.sh new file mode 100755 index 0000000..572ab71 --- /dev/null +++ b/hack/tidy.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail +PROJECT_ROOT="$(realpath $(dirname $0)/..)" + +function tidy() { + go mod tidy -e +} + +echo "Tidy root module ..." +( + cd "$PROJECT_ROOT" + tidy +) diff --git a/hack/unfold.sh b/hack/unfold.sh new file mode 100755 index 0000000..c2620c2 --- /dev/null +++ b/hack/unfold.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -euo pipefail + +# This is a small helper script that takes a list of paths and unfolds them: +# If the path ends with '/...', the path itself (without '/...') and all of its subfolders are printed. +# Otherwise, only the path is printed. +# +# Paths that don't exist will cause an error. +# +# Options: +# +# --absolute +# If active, converts all paths to absolute paths. Overrides --clean. +# +# --clean +# If active, all paths are printed relative to the working directory, with './' and '../' resolved where possible. +# +# --no-unfold +# If active, does simply remove '/...' suffixes instead of unfolding the corresponding paths. +# +# Note that each option's flag +# - toggles that option between active and inactive (with inactive being the default when no flag for that option is specified) +# - can be used multiple times, toggling the option on and off as described above +# - affects only the paths that are specified after it in the command + +# 'toggle X' flips $X between 'true' and 'false'. +function toggle() { + if eval \$$1; then + eval "$1=false" + else + eval "$1=true" + fi +} + +absolute=false +clean=false +no_unfold=false +for f in "$@"; do + case "$f" in + "--absolute") + toggle absolute + ;; + "--clean") + toggle clean + ;; + "--no-unfold") + toggle no_unfold + ;; + *) + depth_mod="" + if [[ "$f" == */... ]]; then + f="${f%/...}" # cut off '/...' + if $no_unfold; then + depth_mod="-maxdepth 0" + fi + else + depth_mod="-maxdepth 0" + fi + if $absolute; then + f="$(realpath "$f")" + elif $clean; then + f="$(realpath --relative-base="$PWD" "$f")" + fi + if tmp=$(find "$f" $depth_mod -type d 2>&1); then + echo "$tmp" + else + echo "error unfolding path '$f': $tmp" >&2 + exit 1 + fi + ;; + esac +done \ No newline at end of file diff --git a/pkg/api/go.mod b/pkg/api/go.mod new file mode 100644 index 0000000..9cebfc2 --- /dev/null +++ b/pkg/api/go.mod @@ -0,0 +1,26 @@ +module github.com/openmcp-project/controller-utils/api + +go 1.21 + +require ( + k8s.io/api v0.28.4 + k8s.io/apiextensions-apiserver v0.28.4 +) + +require ( + github.com/go-logr/logr v1.2.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/text v0.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apimachinery v0.28.4 // indirect + k8s.io/klog/v2 v2.100.1 // indirect + k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect +) diff --git a/pkg/api/go.sum b/pkg/api/go.sum new file mode 100644 index 0000000..b406f30 --- /dev/null +++ b/pkg/api/go.sum @@ -0,0 +1,93 @@ +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/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/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/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.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/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/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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +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/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.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +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/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-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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= +k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= +k8s.io/apiextensions-apiserver v0.28.4 h1:AZpKY/7wQ8n+ZYDtNHbAJBb+N4AXXJvyZx6ww6yAJvU= +k8s.io/apiextensions-apiserver v0.28.4/go.mod h1:pgQIZ1U8eJSMQcENew/0ShUTlePcSGFq6dxSxf2mwPM= +k8s.io/apimachinery v0.28.4 h1:zOSJe1mc+GxuMnFzD4Z/U1wst50X28ZNsn5bhgIIao8= +k8s.io/apimachinery v0.28.4/go.mod h1:wI37ncBvfAoswfq626yPTe6Bz1c22L7uaJ8dho83mgg= +k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= +k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk= +k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/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.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/pkg/api/target.go b/pkg/api/target.go new file mode 100644 index 0000000..d1c7140 --- /dev/null +++ b/pkg/api/target.go @@ -0,0 +1,58 @@ +// +kubebuilder:object:generate=true +package api + +import ( + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +type Target struct { + // Kubeconfig is an inline kubeconfig. + // +kubebuilder:pruning:PreserveUnknownFields + Kubeconfig *apiextensionsv1.JSON `json:"kubeconfig,omitempty"` + + // KubeconfigFile is a path to a file containing a kubeconfig. + KubeconfigFile *string `json:"kubeconfigFile,omitempty"` + + // KubeconfigRef is a reference to a Kubernetes secret that contains a kubeconfig. + KubeconfigRef *KubeconfigReference `json:"kubeconfigRef,omitempty"` + + // ServiceAccount references a local service account. + ServiceAccount *ServiceAccountConfig `json:"serviceAccount,omitempty"` +} + +type KubeconfigReference struct { + corev1.SecretReference `json:",inline"` + + // The key of the secret to select from. Must be a valid secret key. + // +kubebuilder:default="kubeconfig" + Key string `json:"key"` +} + +type ServiceAccountConfig struct { + // Name is the name of the service account. + // This value is optional. If not provided, the pod's service account will be used. + Name string `json:"name,omitempty"` + + // Namespace is the name of the service account. + // This value is optional. If not provided, the pod's service account will be used. + Namespace string `json:"namespace,omitempty"` + + // Host must be a host string, a host:port pair, or a URL to the base of the apiserver. + // This value is optional. If not provided, the local API server will be used. + Host string `json:"host,omitempty"` + + // CAFile points to a file containing the root certificates for the API server. + // This value is optional. If not provided, the value of CAData will be used. + CAFile *string `json:"caFile,omitempty"` + + // CAData holds (Base64-)PEM-encoded bytes. + // CAData takes precedence over CAFile. + // This value is optional. If not provided, the CAData of the in-cluster config will be used. + // Providing an empty string means that the operating system's defaults root certificates will be used. + CAData *string `json:"caData,omitempty"` + + // TokenFile points to a file containing a bearer token (e.g. projected service account token (PSAT) with custom audience) to be used for authentication against the API server. + // If provided, all other authentication methods (Basic, client-side TLS, etc.) will be disabled. + TokenFile string `json:"tokenFile,omitempty"` +} diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go new file mode 100644 index 0000000..266e113 --- /dev/null +++ b/pkg/api/zz_generated.deepcopy.go @@ -0,0 +1,85 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package api + +import ( + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeconfigReference) DeepCopyInto(out *KubeconfigReference) { + *out = *in + out.SecretReference = in.SecretReference +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeconfigReference. +func (in *KubeconfigReference) DeepCopy() *KubeconfigReference { + if in == nil { + return nil + } + out := new(KubeconfigReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountConfig) DeepCopyInto(out *ServiceAccountConfig) { + *out = *in + if in.CAFile != nil { + in, out := &in.CAFile, &out.CAFile + *out = new(string) + **out = **in + } + if in.CAData != nil { + in, out := &in.CAData, &out.CAData + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountConfig. +func (in *ServiceAccountConfig) DeepCopy() *ServiceAccountConfig { + if in == nil { + return nil + } + out := new(ServiceAccountConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Target) DeepCopyInto(out *Target) { + *out = *in + if in.Kubeconfig != nil { + in, out := &in.Kubeconfig, &out.Kubeconfig + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } + if in.KubeconfigFile != nil { + in, out := &in.KubeconfigFile, &out.KubeconfigFile + *out = new(string) + **out = **in + } + if in.KubeconfigRef != nil { + in, out := &in.KubeconfigRef, &out.KubeconfigRef + *out = new(KubeconfigReference) + **out = **in + } + if in.ServiceAccount != nil { + in, out := &in.ServiceAccount, &out.ServiceAccount + *out = new(ServiceAccountConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Target. +func (in *Target) DeepCopy() *Target { + if in == nil { + return nil + } + out := new(Target) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/clientconfig/clientconfig.go b/pkg/clientconfig/clientconfig.go new file mode 100644 index 0000000..b112393 --- /dev/null +++ b/pkg/clientconfig/clientconfig.go @@ -0,0 +1,260 @@ +package clientconfig + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/controller-utils/api" +) + +const ( + pemPrefix = "-----BEGIN" +) + +var ( + scheme = runtime.NewScheme() + + ErrInvalidConnectionMethod = errors.New("exactly one connection method has to be specified") + ErrServiceAccountNamespaceEmpty = errors.New("service account namespace must be specified") + + reloadNoOp ReloadFunc = func() error { return nil } +) + +type ReloadFunc func() error + +func init() { + _ = clientgoscheme.AddToScheme(scheme) +} + +func New(target api.Target) *Config { + return &Config{Target: target} +} + +type Config struct { + api.Target +} + +func (c *Config) validate() error { + methods := 0 + if c.Kubeconfig != nil { + methods++ + } + if c.KubeconfigFile != nil { + methods++ + } + if c.KubeconfigRef != nil { + methods++ + } + if c.ServiceAccount != nil { + methods++ + } + + if methods != 1 { + return ErrInvalidConnectionMethod + } + + return nil +} + +// GetRESTConfig creates a *rest.Config for the given API target. +// The second return value is a function which can be used to reload the config. +// This reload func is a no-op for "Kubeconfig" and "ServiceAccount" target types. +func (c *Config) GetRESTConfig() (*rest.Config, ReloadFunc, error) { + if err := c.validate(); err != nil { + return nil, nil, err + } + + if c.Kubeconfig != nil { + return c.handleKubeconfig() + } + + if c.KubeconfigFile != nil { + return c.handleKubeconfigFile() + } + + if c.KubeconfigRef != nil { + return c.handleKubeconfigRef() + } + + if c.ServiceAccount != nil { + return c.handleServiceAccount() + } + + return nil, nil, ErrInvalidConnectionMethod +} + +func (c *Config) handleKubeconfig() (*rest.Config, ReloadFunc, error) { + config, err := clientcmd.RESTConfigFromKubeConfig(c.Kubeconfig.Raw) + return config, reloadNoOp, err +} + +func (c *Config) handleKubeconfigFile() (*rest.Config, ReloadFunc, error) { + remoteConfig := &rest.Config{} + + reloadFunc := func() error { + configBytes, err := os.ReadFile(*c.KubeconfigFile) + if err != nil { + return err + } + + config, err := clientcmd.RESTConfigFromKubeConfig(configBytes) + if err != nil { + return err + } + + copyRestConfig(config, remoteConfig) + return nil + } + + return remoteConfig, reloadFunc, reloadFunc() +} + +func (c *Config) handleKubeconfigRef() (*rest.Config, ReloadFunc, error) { + inClusterConfig, err := ctrl.GetConfig() + if err != nil { + return nil, nil, err + } + + inClusterClient, err := client.New(inClusterConfig, client.Options{Scheme: scheme}) + if err != nil { + return nil, nil, err + } + + remoteConfig := &rest.Config{} + + reloadFunc := func() error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: c.KubeconfigRef.Name, + Namespace: c.KubeconfigRef.Namespace, + }, + } + + if err := inClusterClient.Get(context.TODO(), client.ObjectKeyFromObject(secret), secret); err != nil { + return err + } + + config, err := clientcmd.RESTConfigFromKubeConfig(secret.Data[c.KubeconfigRef.Key]) + if err != nil { + return err + } + + copyRestConfig(config, remoteConfig) + return nil + } + + return remoteConfig, reloadFunc, reloadFunc() +} + +func (c *Config) handleServiceAccount() (*rest.Config, ReloadFunc, error) { + cfg, err := ctrl.GetConfig() + if err != nil { + return nil, nil, err + } + + if c.ServiceAccount.TokenFile != "" { + // Disable other authentication types + cfg = rest.AnonymousClientConfig(cfg) + + cfg.BearerTokenFile = c.ServiceAccount.TokenFile + } + + if c.ServiceAccount.Name != "" { + if c.ServiceAccount.Namespace == "" { + return nil, nil, ErrServiceAccountNamespaceEmpty + } + + cfg.Impersonate = rest.ImpersonationConfig{ + UserName: fmt.Sprintf("system:serviceaccount:%s:%s", c.ServiceAccount.Name, c.ServiceAccount.Namespace), + } + } + + if c.ServiceAccount.Host != "" { + cfg.APIPath = "" + cfg.Host = c.ServiceAccount.Host + } + + if c.ServiceAccount.CAFile != nil { + cfg.CAFile = *c.ServiceAccount.CAFile + } + + if c.ServiceAccount.CAData != nil { + // Check if CAData is raw (not base64-encoded) + if strings.HasPrefix(strings.TrimSpace(*c.ServiceAccount.CAData), pemPrefix) { + cfg.CAData = []byte(*c.ServiceAccount.CAData) + } else { + decoded, err := base64.StdEncoding.DecodeString(*c.ServiceAccount.CAData) + if err != nil { + return nil, nil, err + } + cfg.CAData = decoded + } + } + + return cfg, reloadNoOp, nil +} + +// GetClient creates a client.Client for the given API target. +// The second return value is a function which can be used to reload the config. +// This reload func is a no-op for "Kubeconfig" and "ServiceAccount" target types. +func (c *Config) GetClient(options client.Options) (client.Client, ReloadFunc, error) { + restConfig, reloadFunc, err := c.GetRESTConfig() + if err != nil { + return nil, nil, err + } + + client, err := client.New(restConfig, options) + return client, reloadFunc, err +} + +// copyRestConfig copies all fields from one *rest.Config to the other. +// rest.CopyConfig was used as a template. +func copyRestConfig(from, to *rest.Config) { + to.Host = from.Host + to.APIPath = from.APIPath + to.ContentConfig = from.ContentConfig + to.Username = from.Username + to.Password = from.Password + to.BearerToken = from.BearerToken + to.BearerTokenFile = from.BearerTokenFile + to.Impersonate.UserName = from.Impersonate.UserName + to.Impersonate.UID = from.Impersonate.UID + to.Impersonate.Groups = from.Impersonate.Groups + to.Impersonate.Extra = from.Impersonate.Extra + to.AuthProvider = from.AuthProvider + to.AuthConfigPersister = from.AuthConfigPersister + to.ExecProvider = from.ExecProvider + to.TLSClientConfig.Insecure = from.TLSClientConfig.Insecure + to.TLSClientConfig.ServerName = from.TLSClientConfig.ServerName + to.TLSClientConfig.CertFile = from.TLSClientConfig.CertFile + to.TLSClientConfig.KeyFile = from.TLSClientConfig.KeyFile + to.TLSClientConfig.CAFile = from.TLSClientConfig.CAFile + to.TLSClientConfig.CertData = from.TLSClientConfig.CertData + to.TLSClientConfig.KeyData = from.TLSClientConfig.KeyData + to.TLSClientConfig.CAData = from.TLSClientConfig.CAData + to.TLSClientConfig.NextProtos = from.TLSClientConfig.NextProtos + to.UserAgent = from.UserAgent + to.DisableCompression = from.DisableCompression + to.Transport = from.Transport + to.WrapTransport = from.WrapTransport + to.QPS = from.QPS + to.Burst = from.Burst + to.RateLimiter = from.RateLimiter + to.WarningHandler = from.WarningHandler + to.Timeout = from.Timeout + to.Dial = from.Dial + to.Proxy = from.Proxy +} diff --git a/pkg/clientconfig/clientconfig_test.go b/pkg/clientconfig/clientconfig_test.go new file mode 100644 index 0000000..72f4fa2 --- /dev/null +++ b/pkg/clientconfig/clientconfig_test.go @@ -0,0 +1,237 @@ +package clientconfig + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/controller-utils/api" +) + +type test_input struct { + config api.Target + kubeconfigFile string +} + +type test_want struct { + err error + host string + impersonation rest.ImpersonationConfig + token string + tokenFile string + caFile string + caData string +} + +var ( + noerror = test_want{ + err: nil, + host: "https://api.example.com", + token: "G1FUzrd3FCgLVhIy6kj7", + caData: `-----BEGIN CERTIFICATE----- +MIID5jCCAk6gAwIBAgIQc3k9EZlBPzaqkHrAFNDOyjANBgkqhkiG9w0BAQsFADAN +MQswCQYDVQQDEwJjYTAeFw0yMzExMTMxMjE0MjBaFw0zMzExMTMxMjE0MjBaMA0x +CzAJBgNVBAMTAmNhMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAv/FZ +jnFA9LDXPUcFQJgeIHEDJ5pfK6hw1Q133AfIyRFYpbTiKbh10z4g7JK2oLzL5CKg +W8yj8d909Ysbf40N4B+yl5nn1M143Tg6Z3QMmaGg7CLbXHSgbIXwD9veRvW9AN81 +g444UVSijLkp7SSZWuaMdn6tP9DPpFHOqE6SpnGfHd/iQ3XkFuLyPdrCRlcUuRno +6rf+ANli5deamqjXS1KEsilmhSCYRPQ61nOsbTteWjivPaBxlxYMhNk1l017kV/C +z8/1cpauLJCmgPuNNqMI8CvduWpFtgW7420DPC2vFH4JCqqHIhH+hF7B+jzMEvcz +14oOXMOq9+AsVlK1Epqg0yvGb7wscbrJL5IJ6vaUNB3v43sZAHxGzsoCCf4wI6dB +l1xmwm+kctE6bxEA9ynAJLeog2bYZuKwZm0QBqSUXumPb6ctMRvX2JA4jRrRqTnV +nj8u0cChLe1Ij0IFMkJDGf+VHQB+9Q52BvKVg1gqWjlJ3o+n17fhknDhHnz7AgMB +AAGjQjBAMA4GA1UdDwEB/wQEAwIBpjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBR0hDfRF7z2nr8nIUx+y8UkuKJcYzANBgkqhkiG9w0BAQsFAAOCAYEAuNH9T+Aq +0mzNrwFhwc/nUqC+0F921VVryIbR6I2amt56GFXO0QPy837WISFqkKKC7bM02uRN +4ORNHYhwedSrR6NkQihYHpq52CruKjKn296lyCxlyEWzH8poYW+kjfuzugwJ+Ih9 +RIgGnKZiNWwzc3PLOW4zUzfyWVQVUkGZuN4qTqwoBn2dJwnIBqep3gkdPZbZZpGI +UOpVlZu0zDtJH+F1QzUftJdWeqbMl/YTbOfBKasDepqUbrZioDWnuXHzhF7iqMnN +6k/jHbJ3kTRgH1d262iGgbGOjO3ZRLt1sijxucKfIMjM2H4yW9zmUWuYGdZsJTu2 +oQYRIgCpagRDwiQI7gBPLwdIgWbiFMUUbaaNzeQSBlxGzwpwKTB/kGSjCOJN/p8c +Jj6XYmmcvVurcBUlce+YThzpBND4YrYCbfyjH+WAZkDP458JONbLojjjLNRDtN4f +l0nUqSl1FdVvsCo8hUS8XZuciDluhp4Lq6fXx5002SWC1jP4rOvZvs7F +-----END CERTIFICATE----- +`, + } +) + +func Test_GetRESTConfig(t *testing.T) { + testCases := []struct { + desc string + input test_input + want test_want + }{ + { + desc: "should read inline kubeconfig", + input: test_input{ + config: api.Target{ + Kubeconfig: &v1.JSON{ + Raw: []byte(`apiVersion: v1 +kind: Config +current-context: shoot--unit-test +contexts: + - name: shoot--unit-test + context: + cluster: shoot--unit-test + user: shoot--unit-test +clusters: + - name: shoot--unit-test + cluster: + server: https://api.example.com + certificate-authority-data: >- + LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQ1akNDQWs2Z0F3SUJBZ0lRYzNrOUVabEJQemFxa0hyQUZORE95akFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWVGdzB5TXpFeE1UTXhNakUwTWpCYUZ3MHpNekV4TVRNeE1qRTBNakJhTUEweApDekFKQmdOVkJBTVRBbU5oTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUF2L0ZaCmpuRkE5TERYUFVjRlFKZ2VJSEVESjVwZks2aHcxUTEzM0FmSXlSRllwYlRpS2JoMTB6NGc3Sksyb0x6TDVDS2cKVzh5ajhkOTA5WXNiZjQwTjRCK3lsNW5uMU0xNDNUZzZaM1FNbWFHZzdDTGJYSFNnYklYd0Q5dmVSdlc5QU44MQpnNDQ0VVZTaWpMa3A3U1NaV3VhTWRuNnRQOURQcEZIT3FFNlNwbkdmSGQvaVEzWGtGdUx5UGRyQ1JsY1V1Um5vCjZyZitBTmxpNWRlYW1xalhTMUtFc2lsbWhTQ1lSUFE2MW5Pc2JUdGVXaml2UGFCeGx4WU1oTmsxbDAxN2tWL0MKejgvMWNwYXVMSkNtZ1B1Tk5xTUk4Q3ZkdVdwRnRnVzc0MjBEUEMydkZINEpDcXFISWhIK2hGN0IranpNRXZjegoxNG9PWE1PcTkrQXNWbEsxRXBxZzB5dkdiN3dzY2JySkw1SUo2dmFVTkIzdjQzc1pBSHhHenNvQ0NmNHdJNmRCCmwxeG13bStrY3RFNmJ4RUE5eW5BSkxlb2cyYlladUt3Wm0wUUJxU1VYdW1QYjZjdE1SdlgySkE0alJyUnFUblYKbmo4dTBjQ2hMZTFJajBJRk1rSkRHZitWSFFCKzlRNTJCdktWZzFncVdqbEozbytuMTdmaGtuRGhIbno3QWdNQgpBQUdqUWpCQU1BNEdBMVVkRHdFQi93UUVBd0lCcGpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXCkJCUjBoRGZSRjd6Mm5yOG5JVXgreThVa3VLSmNZekFOQmdrcWhraUc5dzBCQVFzRkFBT0NBWUVBdU5IOVQrQXEKMG16TnJ3Rmh3Yy9uVXFDKzBGOTIxVlZyeUliUjZJMmFtdDU2R0ZYTzBRUHk4MzdXSVNGcWtLS0M3Yk0wMnVSTgo0T1JOSFlod2VkU3JSNk5rUWloWUhwcTUyQ3J1S2pLbjI5Nmx5Q3hseUVXekg4cG9ZVytramZ1enVnd0orSWg5ClJJZ0duS1ppTld3emMzUExPVzR6VXpmeVdWUVZVa0dadU40cVRxd29CbjJkSnduSUJxZXAzZ2tkUFpiWlpwR0kKVU9wVmxadTB6RHRKSCtGMVF6VWZ0SmRXZXFiTWwvWVRiT2ZCS2FzRGVwcVViclppb0RXbnVYSHpoRjdpcU1uTgo2ay9qSGJKM2tUUmdIMWQyNjJpR2diR09qTzNaUkx0MXNpanh1Y0tmSU1qTTJINHlXOXptVVd1WUdkWnNKVHUyCm9RWVJJZ0NwYWdSRHdpUUk3Z0JQTHdkSWdXYmlGTVVVYmFhTnplUVNCbHhHendwd0tUQi9rR1NqQ09KTi9wOGMKSmo2WFltbWN2VnVyY0JVbGNlK1lUaHpwQk5ENFlyWUNiZnlqSCtXQVprRFA0NThKT05iTG9qampMTlJEdE40ZgpsMG5VcVNsMUZkVnZzQ284aFVTOFhadWNpRGx1aHA0THE2Zlh4NTAwMlNXQzFqUDRyT3ZadnM3RgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== +users: + - name: shoot--unit-test + user: + token: >- + G1FUzrd3FCgLVhIy6kj7 +`), + }, + }, + }, + want: noerror, + }, + { + desc: "should fail because multiple methods are configured", + input: test_input{ + config: api.Target{ + Kubeconfig: &v1.JSON{Raw: []byte("hello world")}, + KubeconfigRef: &api.KubeconfigReference{}, + ServiceAccount: &api.ServiceAccountConfig{}, + }, + }, + want: test_want{ + err: ErrInvalidConnectionMethod, + }, + }, + { + desc: "should fail because no methods are configured", + input: test_input{ + config: api.Target{}, + }, + want: test_want{ + err: ErrInvalidConnectionMethod, + }, + }, + { + desc: "should read kubeconfig from file", + input: test_input{ + kubeconfigFile: "testdata/valid.yaml", + config: api.Target{ + ServiceAccount: &api.ServiceAccountConfig{}, + }, + }, + want: noerror, + }, + { + desc: "should read kubeconfig from file (explicitly)", + input: test_input{ + config: api.Target{ + KubeconfigFile: ptr.To("testdata/valid.yaml"), + }, + }, + want: noerror, + }, + { + desc: "should read kubeconfig from file and set custom values", + input: test_input{ + kubeconfigFile: "testdata/valid.yaml", + config: api.Target{ + ServiceAccount: &api.ServiceAccountConfig{ + Name: "myuser", + Namespace: "mynamespace", + Host: "https://custom-api.example.com", + CAFile: ptr.To("/etc/custom/ca.crt"), + CAData: ptr.To("LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQ1akNDQWs2Z0F3SUJBZ0lRYzNrOUVabEJQemFxa0hyQUZORE95akFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWVGdzB5TXpFeE1UTXhNakUwTWpCYUZ3MHpNekV4TVRNeE1qRTBNakJhTUEweApDekFKQmdOVkJBTVRBbU5oTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUF2L0ZaCmpuRkE5TERYUFVjRlFKZ2VJSEVESjVwZks2aHcxUTEzM0FmSXlSRllwYlRpS2JoMTB6NGc3Sksyb0x6TDVDS2cKVzh5ajhkOTA5WXNiZjQwTjRCK3lsNW5uMU0xNDNUZzZaM1FNbWFHZzdDTGJYSFNnYklYd0Q5dmVSdlc5QU44MQpnNDQ0VVZTaWpMa3A3U1NaV3VhTWRuNnRQOURQcEZIT3FFNlNwbkdmSGQvaVEzWGtGdUx5UGRyQ1JsY1V1Um5vCjZyZitBTmxpNWRlYW1xalhTMUtFc2lsbWhTQ1lSUFE2MW5Pc2JUdGVXaml2UGFCeGx4WU1oTmsxbDAxN2tWL0MKejgvMWNwYXVMSkNtZ1B1Tk5xTUk4Q3ZkdVdwRnRnVzc0MjBEUEMydkZINEpDcXFISWhIK2hGN0IranpNRXZjegoxNG9PWE1PcTkrQXNWbEsxRXBxZzB5dkdiN3dzY2JySkw1SUo2dmFVTkIzdjQzc1pBSHhHenNvQ0NmNHdJNmRCCmwxeG13bStrY3RFNmJ4RUE5eW5BSkxlb2cyYlladUt3Wm0wUUJxU1VYdW1QYjZjdE1SdlgySkE0alJyUnFUblYKbmo4dTBjQ2hMZTFJajBJRk1rSkRHZitWSFFCKzlRNTJCdktWZzFncVdqbEozbytuMTdmaGtuRGhIbno3QWdNQgpBQUdqUWpCQU1BNEdBMVVkRHdFQi93UUVBd0lCcGpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXCkJCUjBoRGZSRjd6Mm5yOG5JVXgreThVa3VLSmNZekFOQmdrcWhraUc5dzBCQVFzRkFBT0NBWUVBdU5IOVQrQXEKMG16TnJ3Rmh3Yy9uVXFDKzBGOTIxVlZyeUliUjZJMmFtdDU2R0ZYTzBRUHk4MzdXSVNGcWtLS0M3Yk0wMnVSTgo0T1JOSFlod2VkU3JSNk5rUWloWUhwcTUyQ3J1S2pLbjI5Nmx5Q3hseUVXekg4cG9ZVytramZ1enVnd0orSWg5ClJJZ0duS1ppTld3emMzUExPVzR6VXpmeVdWUVZVa0dadU40cVRxd29CbjJkSnduSUJxZXAzZ2tkUFpiWlpwR0kKVU9wVmxadTB6RHRKSCtGMVF6VWZ0SmRXZXFiTWwvWVRiT2ZCS2FzRGVwcVViclppb0RXbnVYSHpoRjdpcU1uTgo2ay9qSGJKM2tUUmdIMWQyNjJpR2diR09qTzNaUkx0MXNpanh1Y0tmSU1qTTJINHlXOXptVVd1WUdkWnNKVHUyCm9RWVJJZ0NwYWdSRHdpUUk3Z0JQTHdkSWdXYmlGTVVVYmFhTnplUVNCbHhHendwd0tUQi9rR1NqQ09KTi9wOGMKSmo2WFltbWN2VnVyY0JVbGNlK1lUaHpwQk5ENFlyWUNiZnlqSCtXQVprRFA0NThKT05iTG9qampMTlJEdE40ZgpsMG5VcVNsMUZkVnZzQ284aFVTOFhadWNpRGx1aHA0THE2Zlh4NTAwMlNXQzFqUDRyT3ZadnM3RgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="), + TokenFile: "testdata/token", + }, + }, + }, + want: test_want{ + err: nil, + host: "https://custom-api.example.com", + impersonation: rest.ImpersonationConfig{ + UserName: "system:serviceaccount:myuser:mynamespace", + }, + token: "", + tokenFile: "testdata/token", + caFile: "/etc/custom/ca.crt", + caData: noerror.caData, + }, + }, + { + desc: "should read kubeconfig from file and set custom pem-encoded CA data", + input: test_input{ + kubeconfigFile: "testdata/valid.yaml", + config: api.Target{ + ServiceAccount: &api.ServiceAccountConfig{ + CAData: ptr.To(noerror.caData), + }, + }, + }, + want: noerror, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + if tC.input.kubeconfigFile != "" { + os.Setenv("KUBECONFIG", tC.input.kubeconfigFile) + } else { + os.Unsetenv("KUBECONFIG") + } + + wrapped := New(tC.input.config) + conf, reloadFunc, err := wrapped.GetRESTConfig() + client, reloadFunc2, clienterr := wrapped.GetClient(client.Options{}) + + if err != nil { + assert.ErrorIs(t, err, tC.want.err) + // GetClient should return the same error + assert.ErrorIs(t, clienterr, tC.want.err) + assert.Nil(t, client) + return + } + + assert.NoError(t, err) + // GetClient should not return any error + assert.NoError(t, clienterr) + assert.NotNil(t, client) + assert.Equal(t, tC.want.host, conf.Host) + assert.Equal(t, tC.want.impersonation, conf.Impersonate) + assert.Equal(t, tC.want.token, conf.BearerToken) + assert.Equal(t, tC.want.tokenFile, conf.BearerTokenFile) + assert.Equal(t, tC.want.caFile, conf.CAFile) + assert.Equal(t, tC.want.caData, string(conf.CAData)) + + if assert.NotNil(t, reloadFunc) { + assert.NoError(t, reloadFunc()) + } + if assert.NotNil(t, reloadFunc2) { + assert.NoError(t, reloadFunc2()) + } + }) + } +} + +func Test_KubeconfigFile_Reload(t *testing.T) { + wrapped := New(api.Target{ + KubeconfigFile: ptr.To("testdata/valid.yaml"), + }) + + conf, reloadFunc, err := wrapped.GetRESTConfig() + assert.NoError(t, err) + assert.NotNil(t, conf) + assert.NotNil(t, reloadFunc) + assert.Equal(t, "https://api.example.com", conf.Host) + assert.Equal(t, "G1FUzrd3FCgLVhIy6kj7", conf.BearerToken) + + wrapped.KubeconfigFile = ptr.To("testdata/valid2.yaml") + assert.NoError(t, reloadFunc()) + assert.Equal(t, "https://api.example.org", conf.Host) + assert.Equal(t, "vp98rIsJJZ3qcoHAsUhg", conf.BearerToken) +} diff --git a/pkg/clientconfig/testdata/token b/pkg/clientconfig/testdata/token new file mode 100644 index 0000000..9619197 --- /dev/null +++ b/pkg/clientconfig/testdata/token @@ -0,0 +1 @@ +abcdef12345 diff --git a/pkg/clientconfig/testdata/valid.yaml b/pkg/clientconfig/testdata/valid.yaml new file mode 100644 index 0000000..11ff36b --- /dev/null +++ b/pkg/clientconfig/testdata/valid.yaml @@ -0,0 +1,18 @@ +kind: Config +current-context: shoot--unit-test +contexts: + - name: shoot--unit-test + context: + cluster: shoot--unit-test + user: shoot--unit-test +clusters: + - name: shoot--unit-test + cluster: + server: https://api.example.com + certificate-authority-data: >- + LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQ1akNDQWs2Z0F3SUJBZ0lRYzNrOUVabEJQemFxa0hyQUZORE95akFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWVGdzB5TXpFeE1UTXhNakUwTWpCYUZ3MHpNekV4TVRNeE1qRTBNakJhTUEweApDekFKQmdOVkJBTVRBbU5oTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUF2L0ZaCmpuRkE5TERYUFVjRlFKZ2VJSEVESjVwZks2aHcxUTEzM0FmSXlSRllwYlRpS2JoMTB6NGc3Sksyb0x6TDVDS2cKVzh5ajhkOTA5WXNiZjQwTjRCK3lsNW5uMU0xNDNUZzZaM1FNbWFHZzdDTGJYSFNnYklYd0Q5dmVSdlc5QU44MQpnNDQ0VVZTaWpMa3A3U1NaV3VhTWRuNnRQOURQcEZIT3FFNlNwbkdmSGQvaVEzWGtGdUx5UGRyQ1JsY1V1Um5vCjZyZitBTmxpNWRlYW1xalhTMUtFc2lsbWhTQ1lSUFE2MW5Pc2JUdGVXaml2UGFCeGx4WU1oTmsxbDAxN2tWL0MKejgvMWNwYXVMSkNtZ1B1Tk5xTUk4Q3ZkdVdwRnRnVzc0MjBEUEMydkZINEpDcXFISWhIK2hGN0IranpNRXZjegoxNG9PWE1PcTkrQXNWbEsxRXBxZzB5dkdiN3dzY2JySkw1SUo2dmFVTkIzdjQzc1pBSHhHenNvQ0NmNHdJNmRCCmwxeG13bStrY3RFNmJ4RUE5eW5BSkxlb2cyYlladUt3Wm0wUUJxU1VYdW1QYjZjdE1SdlgySkE0alJyUnFUblYKbmo4dTBjQ2hMZTFJajBJRk1rSkRHZitWSFFCKzlRNTJCdktWZzFncVdqbEozbytuMTdmaGtuRGhIbno3QWdNQgpBQUdqUWpCQU1BNEdBMVVkRHdFQi93UUVBd0lCcGpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXCkJCUjBoRGZSRjd6Mm5yOG5JVXgreThVa3VLSmNZekFOQmdrcWhraUc5dzBCQVFzRkFBT0NBWUVBdU5IOVQrQXEKMG16TnJ3Rmh3Yy9uVXFDKzBGOTIxVlZyeUliUjZJMmFtdDU2R0ZYTzBRUHk4MzdXSVNGcWtLS0M3Yk0wMnVSTgo0T1JOSFlod2VkU3JSNk5rUWloWUhwcTUyQ3J1S2pLbjI5Nmx5Q3hseUVXekg4cG9ZVytramZ1enVnd0orSWg5ClJJZ0duS1ppTld3emMzUExPVzR6VXpmeVdWUVZVa0dadU40cVRxd29CbjJkSnduSUJxZXAzZ2tkUFpiWlpwR0kKVU9wVmxadTB6RHRKSCtGMVF6VWZ0SmRXZXFiTWwvWVRiT2ZCS2FzRGVwcVViclppb0RXbnVYSHpoRjdpcU1uTgo2ay9qSGJKM2tUUmdIMWQyNjJpR2diR09qTzNaUkx0MXNpanh1Y0tmSU1qTTJINHlXOXptVVd1WUdkWnNKVHUyCm9RWVJJZ0NwYWdSRHdpUUk3Z0JQTHdkSWdXYmlGTVVVYmFhTnplUVNCbHhHendwd0tUQi9rR1NqQ09KTi9wOGMKSmo2WFltbWN2VnVyY0JVbGNlK1lUaHpwQk5ENFlyWUNiZnlqSCtXQVprRFA0NThKT05iTG9qampMTlJEdE40ZgpsMG5VcVNsMUZkVnZzQ284aFVTOFhadWNpRGx1aHA0THE2Zlh4NTAwMlNXQzFqUDRyT3ZadnM3RgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== +users: + - name: shoot--unit-test + user: + token: >- + G1FUzrd3FCgLVhIy6kj7 diff --git a/pkg/clientconfig/testdata/valid2.yaml b/pkg/clientconfig/testdata/valid2.yaml new file mode 100644 index 0000000..78d3bae --- /dev/null +++ b/pkg/clientconfig/testdata/valid2.yaml @@ -0,0 +1,18 @@ +kind: Config +current-context: shoot--unit-test +contexts: + - name: shoot--unit-test + context: + cluster: shoot--unit-test + user: shoot--unit-test +clusters: + - name: shoot--unit-test + cluster: + server: https://api.example.org + certificate-authority-data: >- + LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQ1akNDQWs2Z0F3SUJBZ0lRYzNrOUVabEJQemFxa0hyQUZORE95akFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWVGdzB5TXpFeE1UTXhNakUwTWpCYUZ3MHpNekV4TVRNeE1qRTBNakJhTUEweApDekFKQmdOVkJBTVRBbU5oTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUF2L0ZaCmpuRkE5TERYUFVjRlFKZ2VJSEVESjVwZks2aHcxUTEzM0FmSXlSRllwYlRpS2JoMTB6NGc3Sksyb0x6TDVDS2cKVzh5ajhkOTA5WXNiZjQwTjRCK3lsNW5uMU0xNDNUZzZaM1FNbWFHZzdDTGJYSFNnYklYd0Q5dmVSdlc5QU44MQpnNDQ0VVZTaWpMa3A3U1NaV3VhTWRuNnRQOURQcEZIT3FFNlNwbkdmSGQvaVEzWGtGdUx5UGRyQ1JsY1V1Um5vCjZyZitBTmxpNWRlYW1xalhTMUtFc2lsbWhTQ1lSUFE2MW5Pc2JUdGVXaml2UGFCeGx4WU1oTmsxbDAxN2tWL0MKejgvMWNwYXVMSkNtZ1B1Tk5xTUk4Q3ZkdVdwRnRnVzc0MjBEUEMydkZINEpDcXFISWhIK2hGN0IranpNRXZjegoxNG9PWE1PcTkrQXNWbEsxRXBxZzB5dkdiN3dzY2JySkw1SUo2dmFVTkIzdjQzc1pBSHhHenNvQ0NmNHdJNmRCCmwxeG13bStrY3RFNmJ4RUE5eW5BSkxlb2cyYlladUt3Wm0wUUJxU1VYdW1QYjZjdE1SdlgySkE0alJyUnFUblYKbmo4dTBjQ2hMZTFJajBJRk1rSkRHZitWSFFCKzlRNTJCdktWZzFncVdqbEozbytuMTdmaGtuRGhIbno3QWdNQgpBQUdqUWpCQU1BNEdBMVVkRHdFQi93UUVBd0lCcGpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXCkJCUjBoRGZSRjd6Mm5yOG5JVXgreThVa3VLSmNZekFOQmdrcWhraUc5dzBCQVFzRkFBT0NBWUVBdU5IOVQrQXEKMG16TnJ3Rmh3Yy9uVXFDKzBGOTIxVlZyeUliUjZJMmFtdDU2R0ZYTzBRUHk4MzdXSVNGcWtLS0M3Yk0wMnVSTgo0T1JOSFlod2VkU3JSNk5rUWloWUhwcTUyQ3J1S2pLbjI5Nmx5Q3hseUVXekg4cG9ZVytramZ1enVnd0orSWg5ClJJZ0duS1ppTld3emMzUExPVzR6VXpmeVdWUVZVa0dadU40cVRxd29CbjJkSnduSUJxZXAzZ2tkUFpiWlpwR0kKVU9wVmxadTB6RHRKSCtGMVF6VWZ0SmRXZXFiTWwvWVRiT2ZCS2FzRGVwcVViclppb0RXbnVYSHpoRjdpcU1uTgo2ay9qSGJKM2tUUmdIMWQyNjJpR2diR09qTzNaUkx0MXNpanh1Y0tmSU1qTTJINHlXOXptVVd1WUdkWnNKVHUyCm9RWVJJZ0NwYWdSRHdpUUk3Z0JQTHdkSWdXYmlGTVVVYmFhTnplUVNCbHhHendwd0tUQi9rR1NqQ09KTi9wOGMKSmo2WFltbWN2VnVyY0JVbGNlK1lUaHpwQk5ENFlyWUNiZnlqSCtXQVprRFA0NThKT05iTG9qampMTlJEdE40ZgpsMG5VcVNsMUZkVnZzQ284aFVTOFhadWNpRGx1aHA0THE2Zlh4NTAwMlNXQzFqUDRyT3ZadnM3RgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== +users: + - name: shoot--unit-test + user: + token: >- + vp98rIsJJZ3qcoHAsUhg diff --git a/pkg/collections/collection-abstract.go b/pkg/collections/collection-abstract.go new file mode 100644 index 0000000..07c8a0c --- /dev/null +++ b/pkg/collections/collection-abstract.go @@ -0,0 +1,172 @@ +package collections + +import ( + "fmt" + + "github.com/openmcp-project/controller-utils/pkg/collections/iterators" +) + +var _ Collection[any] = &abstractCollection[any]{} + +var ErrNotImplemented = fmt.Errorf("not implemented") + +type abstractCollection[T any] struct { + // workarounds so that the functions that are implemented here use the actual implementations and not this struct's ones + funcIterator func() iterators.Iterator[T] + funcAdd func(elements ...T) bool + funcClear func() + funcContains func(element T) bool + funcRemove func(elements ...T) bool + funcRemoveIf func(filter Predicate[T]) bool + funcSize func() int + funcNew func() Collection[T] +} + +func (ac *abstractCollection[T]) Add(elements ...T) bool { + if ac.funcAdd != nil { + return ac.funcAdd(elements...) + } + panic(ErrNotImplemented) +} + +// AddAll adds all elements from the given collection to this one. +// Returns true if the collection changed as a result of the operation. +func (ac *abstractCollection[T]) AddAll(c Collection[T]) bool { + it := c.Iterator() + changed := false + for it.HasNext() { + changed = ac.Add(it.Next()) || changed + } + return changed +} + +// Clear removes all of the elements from this collection. +func (ac *abstractCollection[T]) Clear() { + if ac.funcClear != nil { + ac.funcClear() + return + } + panic(ErrNotImplemented) +} + +// Returns true if this collection contains the specified element. +func (ac *abstractCollection[T]) Contains(element T) bool { + if ac.funcContains != nil { + return ac.funcContains(element) + } + it := ac.Iterator() + for it.HasNext() { + if Equals(element, it.Next()) { + return true + } + } + return false +} + +// ContainsAll returns true if this collection contains all of the elements in the specified collection. +func (ac *abstractCollection[T]) ContainsAll(c Collection[T]) bool { + it := c.Iterator() + for it.HasNext() { + if !ac.Contains(it.Next()) { + return false + } + } + return true +} + +// Compares the specified object with this collection for equality. +func (ac *abstractCollection[T]) Equals(c Collection[T]) bool { + if ac.Size() != c.Size() { + return false + } + lit := ac.Iterator() + cit := c.Iterator() + for lit.HasNext() { + if !Equals(lit.Next(), cit.Next()) { + return false + } + } + return true +} + +// IsEmpty returns true if this collection contains no elements. +func (ac *abstractCollection[T]) IsEmpty() bool { + return ac.Size() == 0 +} + +// Returns an iterator over the elements in this collection. +func (ac *abstractCollection[T]) Iterator() iterators.Iterator[T] { + if ac.funcIterator != nil { + return ac.funcIterator() + } + panic(ErrNotImplemented) +} + +// Removes all given elements from the collection, if they are present. +// Each specified element is removed only once, even if it is contained multiple times in the collection. +// Returns true if the collection changed as a result of the operation. +func (ac *abstractCollection[T]) Remove(elements ...T) bool { + if ac.funcRemove != nil { + return ac.funcRemove(elements...) + } + panic(ErrNotImplemented) +} + +// RemoveAllOf removes all instances of the given elements from the collection. +// Returns true if the collection changed as a result of the operation. +func (ac *abstractCollection[T]) RemoveAllOf(elements ...T) bool { + panic(ErrNotImplemented) +} + +// Removes all of this collection's elements that are also contained in the specified collection. +// Each element is removed only as often as it is in the specified collection (or less). +// Returns true if the collection changed as a result of the operation. +func (ac *abstractCollection[T]) RemoveAll(c Collection[T]) bool { + it := c.Iterator() + changed := false + for it.HasNext() { + changed = ac.Remove(it.Next()) || changed + } + return changed +} + +// RetainAll removes all elements from the collection that are not contained in the specified collection. +// Returns true if the collection changed as a result of the operation. +func (ac *abstractCollection[T]) RetainAll(c Collection[T]) bool { + return ac.RemoveIf(func(e T) bool { return !c.Contains(e) }) +} + +// RemoveIf removes all elements from the collection that satisfy the given predicate. +// Returns true if the collection changed as a result of the operation. +func (ac *abstractCollection[T]) RemoveIf(filter Predicate[T]) bool { + if ac.funcRemoveIf != nil { + return ac.funcRemoveIf(filter) + } + panic(ErrNotImplemented) +} + +// Size returns the number of elements in this collection. +func (ac *abstractCollection[T]) Size() int { + if ac.funcSize != nil { + return ac.funcSize() + } + panic(ErrNotImplemented) +} + +// ToSlice returns a slice containing the elements in this collection. +func (ac *abstractCollection[T]) ToSlice() []T { + res := make([]T, ac.Size()) + it := ac.Iterator() + for i := range res { + res[i] = it.Next() + } + return res +} + +// New returns a new Collection of the same type. +func (ac *abstractCollection[T]) New() Collection[T] { + if ac.funcNew != nil { + return ac.funcNew() + } + panic(ErrNotImplemented) +} diff --git a/pkg/collections/collection.go b/pkg/collections/collection.go new file mode 100644 index 0000000..dd3f69d --- /dev/null +++ b/pkg/collections/collection.go @@ -0,0 +1,93 @@ +package collections + +import ( + "fmt" + "reflect" + + "github.com/openmcp-project/controller-utils/pkg/collections/iterators" +) + +// Collection represents a collection of elements. +// Note that T must be either comparable or implement the Comparable interface, otherwise runtime panics could occur. +type Collection[T any] interface { + iterators.Iterable[T] + + // Add ensures that this collection contains the specified elements. + // Returns true if the collection changed as a result of the operation. + // (returns false if the collection does not support duplicates and already contained all given elements). + Add(elements ...T) bool + + // AddAll adds all elements from the given collection to this one. + // Returns true if the collection changed as a result of the operation. + AddAll(c Collection[T]) bool + + // Clear removes all of the elements from this collection. + Clear() + + // Returns true if this collection contains the specified element. + Contains(element T) bool + + // ContainsAll returns true if this collection contains all of the elements in the specified collection. + ContainsAll(c Collection[T]) bool + + // Compares the specified object with this collection for equality. + Equals(c Collection[T]) bool + + // IsEmpty returns true if this collection contains no elements. + IsEmpty() bool + + // Removes all given elements from the collection, if they are present. + // Each specified element is removed only once, even if it is contained multiple times in the collection. + // Returns true if the collection changed as a result of the operation. + Remove(elements ...T) bool + + // RemoveAllOf removes all instances of the given elements from the collection. + // Returns true if the collection changed as a result of the operation. + RemoveAllOf(elements ...T) bool + + // Removes all of this collection's elements that are also contained in the specified collection. + // Returns true if the collection changed as a result of the operation. + RemoveAll(c Collection[T]) bool + + // RetainAll removes all elements from the collection that are not contained in the specified collection. + // Returns true if the collection changed as a result of the operation. + RetainAll(c Collection[T]) bool + + // RemoveIf removes all elements from the collection that satisfy the given predicate. + // Returns true if the collection changed as a result of the operation. + RemoveIf(filter Predicate[T]) bool + + // Size returns the number of elements in this collection. + Size() int + + // ToSlice returns a slice containing the elements in this collection. + ToSlice() []T + + // New returns a new Collection of the same type. + New() Collection[T] +} + +type Predicate[T any] func(T) bool + +// Comparable is an interface that can be implemented to use types as generic argument which don't satisfy the 'comparable' constraint. +type Comparable[T any] interface { + // Equals returns true if the receiver and the argument are equal. + Equals(T) bool +} + +// Equals compares two values of the same type. +// If the type implements the Comparable[T] interface, its Equals method is used for comparison. +// Otherwise, the standard comparison is used. +// Panics if the type is not comparable and does not implement Comparable[T]. +func Equals[T any](a, b T) bool { + aComp, ok := any(a).(Comparable[T]) + if ok { + return aComp.Equals(b) + } + valOfA := reflect.ValueOf(a) + if valOfA.Comparable() { + valOfB := reflect.ValueOf(b) + return valOfA.Equal(valOfB) + } + panic(fmt.Sprintf("type '%s' is neither comparable nor does it implement the Comparable interface", valOfA.Type().String())) +} diff --git a/pkg/collections/collection_test.go b/pkg/collections/collection_test.go new file mode 100644 index 0000000..d9a7a98 --- /dev/null +++ b/pkg/collections/collection_test.go @@ -0,0 +1,226 @@ +package collections_test + +import ( + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections" +) + +type collectionImplementation struct { + Name string + Constructor func(elems ...int) collections.Collection[int] + AllowsDuplicates bool +} + +var _ = Describe("Collection Tests", func() { + + for _, impl := range []*collectionImplementation{ + { + Name: "LinkedList", + Constructor: func(elems ...int) collections.Collection[int] { return collections.NewLinkedList[int](elems...) }, + AllowsDuplicates: true, + }, + } { + runCollectionTests(impl) + } + +}) + +func runCollectionTests(impl *collectionImplementation) { + var col collections.Collection[int] + + BeforeEach(func() { + col = impl.Constructor(baseData...) + }) + + AfterEach(func() { + Expect(baseData).To(Equal([]int{1, 3, 2, 4}), "array passed into constructor should not be modified") + }) + + Context(impl.Name, func() { + + Context("Converting to slice", func() { + + It("should convert an empty collection into an empty slice", func() { + Expect(impl.Constructor().ToSlice()).To(Equal([]int{})) + }) + + It("should convert the collection into a slice", func() { + Expect(col.ToSlice()).To(Equal(baseData)) + }) + + }) + + Context("Iterator", func() { + + It("should return a working iterator", func() { + it := col.Iterator() + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(1)) + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(3)) + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(2)) + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(4)) + Expect(it.HasNext()).To(BeFalse()) + }) + + }) + + Context("Adding elements", func() { + + It("should add a single element", func() { + oldSize := col.Size() + Expect(col.Add(5)).To(BeTrue()) + Expect(col.Size()).To(Equal(oldSize + 1)) + Expect(col.ToSlice()).To(Equal(append(baseData, 5))) + }) + + It("should add multiple elements", func() { + oldSize := col.Size() + Expect(col.Add(5, 6, 7)).To(BeTrue()) + Expect(col.Size()).To(Equal(oldSize + 3)) + Expect(col.ToSlice()).To(Equal(append(baseData, 5, 6, 7))) + }) + + It("should construct an empty list and add multiple elements to it", func() { + col2 := impl.Constructor() + Expect(col2.Size()).To(Equal(0)) + Expect(col2.AddAll(col)).To(BeTrue()) + Expect(col2.ToSlice()).To(Equal(baseData)) + }) + + if !impl.AllowsDuplicates { + + It("should not add duplicates to the collection [no duplicates]", func() { + Expect(col.Add(2, 3)).To(BeFalse()) + Expect(col.ToSlice()).To(Equal(baseData)) + Expect(col.Add(2, 3, -3)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal(append(baseData, -3))) + }) + + } + + }) + + Context("Comparing collections and elements", func() { + + It("should check if element is contained in collection", func() { + Expect(col.Contains(4)).To(BeTrue()) + Expect(col.Contains(8)).To(BeFalse()) + }) + + It("should check if multiple elements are contained in collection", func() { + Expect(col.ContainsAll(col)).To(BeTrue()) + Expect(col.ContainsAll(collections.NewLinkedList(1, 2, 3, 4, -3))).To(BeFalse()) + }) + + It("should compare two collections", func() { + Expect(col.Equals(col)).To(BeTrue()) + Expect(col.Equals(collections.NewLinkedList(baseData...))).To(BeTrue()) + Expect(col.Equals(collections.NewLinkedList[int]())).To(BeFalse()) + }) + + }) + + Context("Size", func() { + + It("should return the size of the collection", func() { + Expect(col.Size()).To(Equal(len(baseData))) + Expect(impl.Constructor().Size()).To(BeZero()) + }) + + It("should check if the collection is empty", func() { + Expect(col.IsEmpty()).To(BeFalse()) + Expect(impl.Constructor().IsEmpty()).To(BeTrue()) + }) + + }) + + Context("Removing elements", func() { + + It("should clear the collection", func() { + col.Clear() + Expect(col.Size()).To(BeZero()) + Expect(col.IsEmpty()).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{})) + }) + + It("should do nothing if the to-be-removed element does not exist", func() { + Expect(col.Remove(-3)).To(BeFalse()) + Expect(col.ToSlice()).To(Equal(baseData)) + }) + + It("should remove a single element", func() { + oldSize := col.Size() + Expect(col.Remove(3)).To(BeTrue()) + Expect(col.Size()).To(Equal(oldSize - 1)) + Expect(col.ToSlice()).To(Equal([]int{1, 2, 4})) + }) + + It("should remove all elements of another collection", func() { + col2 := impl.Constructor(2, 3) + Expect(col.RemoveAll(col2)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{1, 4})) + }) + + It("should retain all elements of another collection", func() { + col2 := impl.Constructor(2, 3) + Expect(col.RetainAll(col2)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{3, 2})) + }) + + It("should remove all elements that match the given predicate", func() { + col.Add(3) + Expect(col.RemoveIf(func(i int) bool { return i > 2 })).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{1, 2})) + }) + + if impl.AllowsDuplicates { + + It("should remove a single element [duplicates]", func() { + Expect(col.Add(3)).To(BeTrue()) + Expect(col.Remove(3)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{1, 2, 4, 3})) + }) + + It("should remove all elements of another collection [duplicates]", func() { + col2 := impl.Constructor(2, 3) + Expect(col.Add(3)).To(BeTrue()) + Expect(col.RemoveAll(col2)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{1, 4, 3})) + }) + + It("should remove all instances of the element [duplicates]", func() { + Expect(col.Add(3)).To(BeTrue()) + Expect(col.RemoveAllOf(3)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{1, 2, 4})) + }) + + It("should retain all elements of another collection [duplicates]", func() { + col2 := impl.Constructor(2, 3) + Expect(col.Add(3)).To(BeTrue()) + Expect(col.RetainAll(col2)).To(BeTrue()) + Expect(col.ToSlice()).To(Equal([]int{3, 2, 3})) + }) + + } + + }) + + Context("New", func() { + + It("should return a new collection of the same type", func() { + n := col.New() + Expect(reflect.TypeOf(n)).To(Equal(reflect.TypeOf(col))) + }) + + }) + + }) + +} diff --git a/pkg/collections/errors/errors.go b/pkg/collections/errors/errors.go new file mode 100644 index 0000000..da660c6 --- /dev/null +++ b/pkg/collections/errors/errors.go @@ -0,0 +1,41 @@ +package errors + +import "fmt" + +type CollectionEmptyError struct{} + +func (CollectionEmptyError) Error() string { + return "collection is empty" +} + +func NewCollectionEmptyError() CollectionEmptyError { + return CollectionEmptyError{} +} + +type CollectionFullError struct { + Capacity int +} + +func (e CollectionFullError) Error() string { + return fmt.Sprintf("collection is full (capacity: %d)", e.Capacity) +} + +func NewCollectionFullError(cap int) CollectionFullError { + return CollectionFullError{ + Capacity: cap, + } +} + +type IndexOutOfBoundsError struct { + Index int +} + +func (err IndexOutOfBoundsError) Error() string { + return fmt.Sprintf("index out of bounds: %d", err.Index) +} + +func NewIndexOutOfBoundsError(i int) IndexOutOfBoundsError { + return IndexOutOfBoundsError{ + Index: i, + } +} diff --git a/pkg/collections/filters/filter.go b/pkg/collections/filters/filter.go new file mode 100644 index 0000000..78d6a3e --- /dev/null +++ b/pkg/collections/filters/filter.go @@ -0,0 +1,50 @@ +package filters + +import "github.com/openmcp-project/controller-utils/pkg/collections" + +type Filter func(args ...any) bool + +// FilterSlice filters a slice by applying a filter function to each entry. +// Only the entries for which the filter function returns true are kept the copy. +// The original slice is not modified. +// Note that the entries are not deep-copied. +func FilterSlice[T any](s []T, filter Filter) []T { + res := make([]T, 0, len(s)) + for _, entry := range s { + if filter(entry) { + res = append(res, entry) + } + } + return res +} + +// FilterMap filters a map by applying a filter function to each key-value pair. +// Only the entries for which the filter function returns true are kept in the copy. +// The original map is not modified. +// Passes the key as first and the value as second argument into the filter function. +// Note that the values are not deep-copied. +func FilterMap[K comparable, V any](m map[K]V, filter Filter) map[K]V { + res := make(map[K]V) + for k, v := range m { + if filter(k, v) { + res[k] = v + } + } + return res +} + +// FilterCollection filters a collection by applying a filter function to each entry. +// Only the entries for which the filter function returns true are kept in the copy. +// The original collection is not modified. +// Note that the entries are not deep-copied. +func FilterCollection[T any](c collections.Collection[T], filter Filter) collections.Collection[T] { + res := c.New() + it := c.Iterator() + for it.HasNext() { + entry := it.Next() + if filter(entry) { + res.Add(entry) + } + } + return res +} diff --git a/pkg/collections/filters/filter_test.go b/pkg/collections/filters/filter_test.go new file mode 100644 index 0000000..490eaa2 --- /dev/null +++ b/pkg/collections/filters/filter_test.go @@ -0,0 +1,77 @@ +package filters_test + +import ( + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections" + "github.com/openmcp-project/controller-utils/pkg/collections/filters" +) + +var _ = Describe("Filter Tests", func() { + + Context("FilterSlice", func() { + + origin := []int{-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5} // comparison value for example + example := []int{-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5} + + It("should filter the slice", func() { + Expect(filters.FilterSlice(example, filters.NumericallyGreaterThan(0))).To(Equal([]int{1, 2, 3, 4, 5})) + Expect(example).To(Equal(origin), "original slice should not be modified") + }) + + It("should return an empty slice if no elements match the filter", func() { + Expect(filters.FilterSlice(example, filters.NumericallyGreaterThan(10))).To(BeEmpty()) + Expect(example).To(Equal(origin), "original slice should not be modified") + }) + + }) + + Context("FilterMap", func() { + + origin := map[int]int{-5: 5, -4: 4, -3: 3, -2: 2, -1: 1, 0: 0, 1: -1, 2: -2, 3: -3, 4: -4, 5: -5} // comparison value for example + example := map[int]int{-5: 5, -4: 4, -3: 3, -2: 2, -1: 1, 0: 0, 1: -1, 2: -2, 3: -3, 4: -4, 5: -5} + + It("should filter the map", func() { + Expect(filters.FilterMap(example, func(args ...any) bool { + return args[0] == args[1] + })).To(Equal(map[int]int{0: 0})) + Expect(example).To(Equal(origin), "original map should not be modified") + }) + + It("should filter the map based on the key", func() { + Expect(filters.FilterMap(example, filters.ApplyToNthArgument(0, filters.NumericallyGreaterThan(0)))).To(Equal(map[int]int{1: -1, 2: -2, 3: -3, 4: -4, 5: -5})) + Expect(example).To(Equal(origin), "original map should not be modified") + }) + + It("should filter the map based on the value", func() { + Expect(filters.FilterMap(example, filters.ApplyToNthArgument(1, filters.NumericallyGreaterThan(0)))).To(Equal(map[int]int{-5: 5, -4: 4, -3: 3, -2: 2, -1: 1})) + Expect(example).To(Equal(origin), "original map should not be modified") + }) + + }) + + Context("FilterCollection", func() { + + origin := collections.NewLinkedList(-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5) // comparison value for example + example := collections.NewLinkedList(-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5) + + It("should filter the collection", func() { + filtered := filters.FilterCollection(example, filters.NumericallyGreaterThan(0)) + Expect(filtered.ToSlice()).To(Equal([]int{1, 2, 3, 4, 5})) + Expect(reflect.TypeOf(filtered)).To(Equal(reflect.TypeOf(example))) + Expect(example.ToSlice()).To(Equal(origin.ToSlice()), "original collection should not be modified") + }) + + It("should return an empty collection if no elements match the filter", func() { + filtered := filters.FilterCollection(example, filters.NumericallyGreaterThan(10)) + Expect(filtered.ToSlice()).To(BeEmpty()) + Expect(reflect.TypeOf(filtered)).To(Equal(reflect.TypeOf(example))) + Expect(example.ToSlice()).To(Equal(origin.ToSlice()), "original collection should not be modified") + }) + + }) + +}) diff --git a/pkg/collections/filters/numbers.go b/pkg/collections/filters/numbers.go new file mode 100644 index 0000000..d5a2a60 --- /dev/null +++ b/pkg/collections/filters/numbers.go @@ -0,0 +1,46 @@ +package filters + +import "golang.org/x/exp/constraints" + +type Number interface { + constraints.Integer | constraints.Float +} + +// NumericallyGreaterThan returns a Filter that returns true for any value greater than n. +// The Filter panics if the value is not a number. +func NumericallyGreaterThan[T Number](n T) Filter { + return func(args ...any) bool { + x := args[0].(T) + return x > n + } +} + +// NumericallyLessThan returns a Filter that returns true for any value less than n. +// The Filter panics if the value is not a number. +func NumericallyLessThan[T Number](n T) Filter { + return func(args ...any) bool { + x := args[0].(T) + return n > x + } +} + +// NumericallyEqualTo returns a Filter that returns true for any value equal to n. +// The Filter panics if the value is not a number. +func NumericallyEqualTo[T Number](n T) Filter { + return func(args ...any) bool { + x := args[0].(T) + return n == x + } +} + +// NumericallyGreaterThanOrEqualTo returns a Filter that returns true for any value greater or equal to n. +// The Filter panics if the value is not a number. +func NumericallyGreaterThanOrEqualTo[T Number](n T) Filter { + return Or(NumericallyGreaterThan(n), NumericallyEqualTo(n)) +} + +// NumericallyLessThanOrEqualTo returns a Filter that returns true for any value less or equal to n. +// The Filter panics if the value is not a number. +func NumericallyLessThanOrEqualTo[T Number](n T) Filter { + return Or(NumericallyLessThan(n), NumericallyEqualTo(n)) +} diff --git a/pkg/collections/filters/numbers_test.go b/pkg/collections/filters/numbers_test.go new file mode 100644 index 0000000..15a4fb6 --- /dev/null +++ b/pkg/collections/filters/numbers_test.go @@ -0,0 +1,62 @@ +package filters_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections/filters" +) + +var _ = Describe("Number Filters Tests", func() { + + Context("NumericallyGreaterThan", func() { + + It("should return true if the number is greater than the comparison value", func() { + Expect(filters.NumericallyGreaterThan(0)(1)).To(BeTrue()) + Expect(filters.NumericallyGreaterThan(0)(0)).To(BeFalse()) + Expect(filters.NumericallyGreaterThan(0)(-1)).To(BeFalse()) + }) + + }) + + Context("NumericallyLessThan", func() { + + It("should return true if the number is less than the comparison value", func() { + Expect(filters.NumericallyLessThan(0)(-1)).To(BeTrue()) + Expect(filters.NumericallyLessThan(0)(0)).To(BeFalse()) + Expect(filters.NumericallyLessThan(0)(1)).To(BeFalse()) + }) + + }) + + Context("NumericallyEqualTo", func() { + + It("should return true if the number is equal to the comparison value", func() { + Expect(filters.NumericallyEqualTo(0)(0)).To(BeTrue()) + Expect(filters.NumericallyEqualTo(0)(1)).To(BeFalse()) + Expect(filters.NumericallyEqualTo(0)(-1)).To(BeFalse()) + }) + + }) + + Context("NumericallyGreaterThanOrEqualTo", func() { + + It("should return true if the number is greater than or equal to the comparison value", func() { + Expect(filters.NumericallyGreaterThanOrEqualTo(0)(1)).To(BeTrue()) + Expect(filters.NumericallyGreaterThanOrEqualTo(0)(0)).To(BeTrue()) + Expect(filters.NumericallyGreaterThanOrEqualTo(0)(-1)).To(BeFalse()) + }) + + }) + + Context("NumericallyLessThanOrEqualTo", func() { + + It("should return true if the number is less than or equal to the comparison value", func() { + Expect(filters.NumericallyLessThanOrEqualTo(0)(-1)).To(BeTrue()) + Expect(filters.NumericallyLessThanOrEqualTo(0)(0)).To(BeTrue()) + Expect(filters.NumericallyLessThanOrEqualTo(0)(1)).To(BeFalse()) + }) + + }) + +}) diff --git a/pkg/collections/filters/predefined.go b/pkg/collections/filters/predefined.go new file mode 100644 index 0000000..e41727d --- /dev/null +++ b/pkg/collections/filters/predefined.go @@ -0,0 +1,76 @@ +package filters + +import "reflect" + +// And returns a filter that returns true if all given filters return true. +func And(filters ...Filter) Filter { + return func(args ...any) bool { + for _, filter := range filters { + if !filter(args...) { + return false + } + } + return true + } +} + +// Or returns a filter that returns true if at least one of the given filters returns true. +func Or(filters ...Filter) Filter { + return func(args ...any) bool { + for _, filter := range filters { + if filter(args...) { + return true + } + } + return false + } +} + +// Not returns the negation of the given filter. +func Not(filter Filter) Filter { + return func(args ...any) bool { + return !filter(args...) + } +} + +// True returns a filter that always returns true. +func True(args ...any) bool { + return true +} + +// False returns a filter that always returns false. +func False(args ...any) bool { + return false +} + +// ApplyToNthArgument applies feeds only the nth argument of the given arguments to the given filter. +// Indexing starts with 0. +func ApplyToNthArgument(n int, filter Filter) Filter { + return func(args ...any) bool { + return filter(args[n]) + } +} + +// Wrap takes a function that returns a bool and turns it into a filter. +// If staticArgs is not nil or empty, the provided values are passed to the function at the given positions when the filter is called. +// This function panics in several cases: +// - the given value is not a function +// - the given function does not return a bool as first return value (further return values are ignored) +// - the args when calling the filter do not match the function's signature +// - the indices of the staticArgs are out of bounds for the function's signature +func Wrap(f any, staticArgs map[int]any) Filter { + return func(args ...any) bool { + vsLen := len(args) + len(staticArgs) + vs := make([]reflect.Value, vsLen) + j := 0 + for i := range vsLen { + if sarg, ok := staticArgs[i]; ok { + vs[i] = reflect.ValueOf(sarg) + } else { + vs[i] = reflect.ValueOf(args[j]) + j++ + } + } + return reflect.ValueOf(f).Call(vs)[0].Bool() + } +} diff --git a/pkg/collections/filters/predefined_test.go b/pkg/collections/filters/predefined_test.go new file mode 100644 index 0000000..85ecfd4 --- /dev/null +++ b/pkg/collections/filters/predefined_test.go @@ -0,0 +1,69 @@ +package filters_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections/filters" +) + +var _ = Describe("Predefined Filters Tests", func() { + + Context("And", func() { + + It("should return true if all filters return true", func() { + Expect(filters.And(filters.True, filters.True)(nil)).To(BeTrue()) + }) + + It("should return false if any filter returns false", func() { + Expect(filters.And(filters.True, filters.False)(nil)).To(BeFalse()) + }) + + }) + + Context("Or", func() { + + It("should return true if any filter returns true", func() { + Expect(filters.Or(filters.True, filters.False)(nil)).To(BeTrue()) + }) + + It("should return false if all filters return false", func() { + Expect(filters.Or(filters.False, filters.False)(nil)).To(BeFalse()) + }) + + }) + + Context("Not", func() { + + It("should return the negation of the filter", func() { + Expect(filters.Not(filters.True)(nil)).To(BeFalse()) + Expect(filters.Not(filters.False)(nil)).To(BeTrue()) + }) + + }) + + Context("ApplyToNthArgument", func() { + + equalsMinusThree := func(args ...any) bool { + return args[0] == -3 + } + + It("should apply the filter to the nth argument", func() { + Expect(filters.ApplyToNthArgument(2, equalsMinusThree)(-1, -2, -3, -4, -5)).To(BeTrue()) + Expect(filters.ApplyToNthArgument(2, equalsMinusThree)(-3, -2, -1)).To(BeFalse()) + }) + + }) + + Context("Wrap", func() { + + It("should wrap a function into a filter", func() { + Expect(filters.Wrap(strings.HasPrefix, map[int]any{1: "x"})("xyz")).To(BeTrue()) + Expect(filters.Wrap(strings.HasPrefix, map[int]any{1: "x"})("abc")).To(BeFalse()) + }) + + }) + +}) diff --git a/pkg/collections/filters/suite_test.go b/pkg/collections/filters/suite_test.go new file mode 100644 index 0000000..aab4d2a --- /dev/null +++ b/pkg/collections/filters/suite_test.go @@ -0,0 +1,14 @@ +package filters_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFilters(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Filter Test Suite") +} diff --git a/pkg/collections/iterators/iterator-linked.go b/pkg/collections/iterators/iterator-linked.go new file mode 100644 index 0000000..a59384d --- /dev/null +++ b/pkg/collections/iterators/iterator-linked.go @@ -0,0 +1,33 @@ +package iterators + +var _ Iterator[any] = &LinkedIterator[any]{} + +type LinkedIterator[T any] struct { + current LinkedIteratorElement[T] + isValid func(LinkedIteratorElement[T]) bool +} + +type LinkedIteratorElement[T any] interface { + // Next returns the next element. + Next() LinkedIteratorElement[T] + // Value returns the value of the element. + Value() T +} + +// NewLinkedIterator returns a new LinkedIterator. +func NewLinkedIterator[T any](start LinkedIteratorElement[T], isValidFunc func(LinkedIteratorElement[T]) bool) *LinkedIterator[T] { + return &LinkedIterator[T]{ + current: start, + isValid: isValidFunc, + } +} + +func (i *LinkedIterator[T]) Next() T { + res := i.current.Value() + i.current = i.current.Next() + return res +} + +func (i *LinkedIterator[T]) HasNext() bool { + return i.isValid(i.current) +} diff --git a/pkg/collections/iterators/iterator-linked_test.go b/pkg/collections/iterators/iterator-linked_test.go new file mode 100644 index 0000000..b01b0d7 --- /dev/null +++ b/pkg/collections/iterators/iterator-linked_test.go @@ -0,0 +1,58 @@ +package iterators_test + +import ( + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections/iterators" +) + +type linkedDummy struct { + value int + next *linkedDummy +} + +var _ iterators.LinkedIteratorElement[int] = &linkedDummy{} + +func (d *linkedDummy) Value() int { + return d.value +} + +func (d *linkedDummy) Next() iterators.LinkedIteratorElement[int] { + return d.next +} + +func testDummy(vs ...int) *linkedDummy { + if len(vs) == 0 { + return nil + } + return &linkedDummy{vs[0], testDummy(vs[1:]...)} +} + +var testDummyIsValidFunc = func(lie iterators.LinkedIteratorElement[int]) bool { + return !reflect.ValueOf(lie).IsNil() +} + +var _ = Describe("LinkedIterator Tests", func() { + + It("should iterate over the linked struct", func() { + example := testDummy(2, 1, 0) + it := iterators.NewLinkedIterator(example, testDummyIsValidFunc) + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(2)) + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(1)) + Expect(it.HasNext()).To(BeTrue()) + Expect(it.Next()).To(Equal(0)) + Expect(it.HasNext()).To(BeFalse()) + }) + + It("should return an 'empty' iterator for an empty linked struct", func() { + example := testDummy() + it := iterators.NewLinkedIterator(example, testDummyIsValidFunc) + Expect(it.HasNext()).To(BeFalse()) + }) + +}) diff --git a/pkg/collections/iterators/iterator.go b/pkg/collections/iterators/iterator.go new file mode 100644 index 0000000..06add40 --- /dev/null +++ b/pkg/collections/iterators/iterator.go @@ -0,0 +1,23 @@ +package iterators + +import "fmt" + +var ( + ErrNoNextElement error = fmt.Errorf("Next() called on empty iterator") +) + +type Iterable[T any] interface { + // Iterator returns an Iterator over the contained elements. + // Note that the behavior is undefined if the underlying collection is modified while being iterated over. + Iterator() Iterator[T] +} + +type Iterator[T any] interface { + + // Next returns the next element of the iterated collection. + // Panics if there is no next element. + Next() T + + // HasNext returns true if there is a next element. + HasNext() bool +} diff --git a/pkg/collections/iterators/suite_test.go b/pkg/collections/iterators/suite_test.go new file mode 100644 index 0000000..929fcb8 --- /dev/null +++ b/pkg/collections/iterators/suite_test.go @@ -0,0 +1,14 @@ +package iterators_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIterators(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Iterator Test Suite") +} diff --git a/pkg/collections/list-abstract.go b/pkg/collections/list-abstract.go new file mode 100644 index 0000000..e4b34a4 --- /dev/null +++ b/pkg/collections/list-abstract.go @@ -0,0 +1,48 @@ +package collections + +import ( + "encoding/json" + + cerr "github.com/openmcp-project/controller-utils/pkg/collections/errors" +) + +var _ List[any] = &abstractList[any]{} + +type abstractList[T any] struct { + abstractCollection[T] +} + +func (al *abstractList[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(al.ToSlice()) +} + +func (al *abstractList[T]) UnmarshalJSON(data []byte) error { + raw := &[]T{} + err := json.Unmarshal(data, raw) + if err != nil { + return err + } + al.Clear() + al.Add((*raw)...) + return nil +} + +func (al *abstractList[T]) AddIndex(element T, idx int) error { + panic(ErrNotImplemented) +} + +func (al *abstractList[T]) RemoveIndex(idx int) error { + panic(ErrNotImplemented) +} + +func (al *abstractList[T]) Get(idx int) (T, error) { + var res T + if idx < 0 || idx >= al.Size() { + return res, cerr.NewIndexOutOfBoundsError(idx) + } + it := al.Iterator() + for range idx { + it.Next() + } + return it.Next(), nil +} diff --git a/pkg/collections/list-linked.go b/pkg/collections/list-linked.go new file mode 100644 index 0000000..5ba997e --- /dev/null +++ b/pkg/collections/list-linked.go @@ -0,0 +1,295 @@ +package collections + +import ( + cerr "github.com/openmcp-project/controller-utils/pkg/collections/errors" + "github.com/openmcp-project/controller-utils/pkg/collections/iterators" +) + +var _ List[any] = &LinkedList[any]{} +var _ Queue[any] = &LinkedList[any]{} + +type LinkedList[T any] struct { + abstractList[T] + // dummy is start and end of the list + // dummy.next points to the first element of the list + // dummy.prev points to the last element of the list + // if the list is empty, dummy points to itself + dummy *llElement[T] + size int +} + +var _ iterators.LinkedIteratorElement[any] = &llElement[any]{} + +type llElement[T any] struct { + prev, next *llElement[T] + value T +} + +func (e *llElement[T]) Next() iterators.LinkedIteratorElement[T] { + return e.next +} + +func (e *llElement[T]) Value() T { + return e.value +} + +func NewLinkedList[T any](elements ...T) *LinkedList[T] { + res := &LinkedList[T]{ + dummy: &llElement[T]{}, + } + res.dummy.next = res.dummy + res.dummy.prev = res.dummy + + res.abstractCollection.funcIterator = res.Iterator + res.funcAdd = res.Add + res.funcClear = res.Clear + res.funcRemove = res.Remove + res.funcRemoveIf = res.RemoveIf + res.funcSize = res.Size + + res.Add(elements...) + + return res +} + +func NewLinkedListFromCollection[T any](c Collection[T]) *LinkedList[T] { + res := NewLinkedList[T]() + res.AddAll(c) + return res +} + +/////////////////////////////// +// COLLECTION IMPLEMENTATION // +/////////////////////////////// + +// Add ensures that this collection contains the specified elements. +// Returns true if the collection changed as a result of the operation. +// (returns false if the collection does not support duplicates and already contained all given elements). +func (l *LinkedList[T]) Add(elements ...T) bool { + if len(elements) == 0 { + return false + } + for i := range elements { + elem := &llElement[T]{ + value: elements[i], + } + l.dummy.prev.next = elem + elem.prev = l.dummy.prev + elem.next = l.dummy + l.dummy.prev = elem + l.size++ + } + return true +} + +// AddAll adds all elements from the given collection to this one. +// Returns true if the collection changed as a result of the operation. +func (l *LinkedList[T]) AddAll(c Collection[T]) bool { + it := c.Iterator() + changed := false + for it.HasNext() { + changed = l.Add(it.Next()) || changed + } + return changed +} + +// Clear removes all of the elements from this collection. +func (l *LinkedList[T]) Clear() { + l.dummy.next = l.dummy + l.dummy.prev = l.dummy + l.size = 0 +} + +// Returns an iterator over the elements in this collection. +func (l *LinkedList[T]) Iterator() iterators.Iterator[T] { + dummy := l.dummy.prev.Next() + return iterators.NewLinkedIterator[T](l.dummy.next, func(e iterators.LinkedIteratorElement[T]) bool { return e != dummy }) +} + +// Removes all given elements from the collection, if they are present. +// Each specified element is removed only once, even if it is contained multiple times in the collection. +// Returns true if the collection changed as a result of the operation. +func (l *LinkedList[T]) Remove(elements ...T) bool { + return l.remove(false, elements...) +} + +// RemoveAllOf removes all instances of the given elements from the collection. +// Returns true if the collection changed as a result of the operation. +func (l *LinkedList[T]) RemoveAllOf(elements ...T) bool { + return l.remove(true, elements...) +} + +// RemoveIf removes all elements from the collection that satisfy the given predicate. +// Returns true if the collection changed as a result of the operation. +func (l *LinkedList[T]) RemoveIf(filter Predicate[T]) bool { + oldSize := l.size + elem := l.dummy.next + for elem != l.dummy { + if filter(elem.value) { + elem.remove() + l.size-- + } + elem = elem.next + } + return l.size != oldSize +} + +// Size returns the number of elements in this collection. +func (l *LinkedList[T]) Size() int { + return l.size +} + +// ToSlice returns a slice containing the elements in this collection. +func (l *LinkedList[T]) ToSlice() []T { + res := make([]T, l.size) + elem := l.dummy.next + i := 0 + for elem != l.dummy { + res[i] = elem.value + i++ + elem = elem.next + } + return res +} + +///////////////////////// +// LIST IMPLEMENTATION // +///////////////////////// + +func (l *LinkedList[T]) AddIndex(element T, idx int) error { + if idx == l.size { + l.Add(element) + return nil + } + elem := l.elementAt(idx) + if elem == nil { + return cerr.NewIndexOutOfBoundsError(idx) + } + newElem := &llElement[T]{ + value: element, + prev: elem.prev, + next: elem, + } + elem.prev.next = newElem + elem.prev = newElem + l.size++ + return nil +} + +func (l *LinkedList[T]) RemoveIndex(idx int) error { + elem := l.elementAt(idx) + if elem == nil { + return cerr.NewIndexOutOfBoundsError(idx) + } + elem.remove() + l.size-- + return nil +} + +func (l *LinkedList[T]) Get(idx int) (T, error) { + var res T + elem := l.elementAt(idx) + if elem == nil { + return res, cerr.NewIndexOutOfBoundsError(idx) + } + return elem.value, nil +} + +////////////////////////// +// QUEUE IMPLEMENTATION // +////////////////////////// + +func (l *LinkedList[T]) Push(elements ...T) error { + l.Add(elements...) + return nil +} + +func (l *LinkedList[T]) Peek() T { + if l.IsEmpty() { + var zero T + return zero + } + return l.dummy.next.value +} + +func (l *LinkedList[T]) Element() (T, error) { + if l.IsEmpty() { + var zero T + return zero, cerr.NewCollectionEmptyError() + } + return l.dummy.next.value, nil +} + +func (l *LinkedList[T]) Poll() T { + if l.IsEmpty() { + var zero T + return zero + } + elem := l.dummy.next + elem.remove() + l.size-- + return elem.value +} + +func (l *LinkedList[T]) Fetch() (T, error) { + if l.IsEmpty() { + var zero T + return zero, cerr.NewCollectionEmptyError() + } + elem := l.dummy.next + elem.remove() + l.size-- + return elem.value, nil +} + +// New returns a new Collection of the same type. +func (l *LinkedList[T]) New() Collection[T] { + return NewLinkedList[T]() +} + +///////////////////////// +// AUXILIARY FUNCTIONS // +///////////////////////// + +// remove removes the element itself from the chain by linking its previous element to its next element. +// The element's pointers are not modified. +func (e *llElement[T]) remove() { + e.prev.next = e.next + e.next.prev = e.prev +} + +// remove removes all specified elements. +// If all is true, all instances of these elements are removed, otherwise only one. +func (l *LinkedList[T]) remove(all bool, elements ...T) bool { + oldSize := l.size + for _, tbr := range elements { + elem := l.dummy.next + for elem != l.dummy { + if Equals(elem.value, tbr) { + elem.remove() + l.size-- + if !all { + break + } + } + elem = elem.next + } + } + return l.size != oldSize +} + +func (l *LinkedList[T]) elementAt(idx int) *llElement[T] { + if idx < 0 || idx >= l.size { + return nil + } + elem := l.dummy.next + i := 0 + for elem != l.dummy { + if i == idx { + return elem + } + i++ + elem = elem.next + } + return nil +} diff --git a/pkg/collections/list-linked_test.go b/pkg/collections/list-linked_test.go new file mode 100644 index 0000000..3cb451d --- /dev/null +++ b/pkg/collections/list-linked_test.go @@ -0,0 +1,53 @@ +package collections + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LinkedList Integrity Validation", func() { + + It("should not violate the list's integrity", func() { + li := NewLinkedList[int](1, 2, 3, 4) + li.validateIntegrity("after creation") + li.Add(5) + li.validateIntegrity("after Add") + li.Remove(2) + li.validateIntegrity("after Remove") + Expect(li.RemoveIndex(0)).To(Succeed()) + li.validateIntegrity("after RemoveIndex(0)") + Expect(li.RemoveIndex(li.size - 1)).To(Succeed()) + li.validateIntegrity("after RemoveIndex(size-1)") + Expect(li.AddIndex(0, 0)).To(Succeed()) + li.validateIntegrity("after AddIndex to beginning of list") + Expect(li.AddIndex(8, li.size)).To(Succeed()) + li.validateIntegrity("after AddIndex to end of list") + Expect(li.Push(5, 6)).To(Succeed()) + li.validateIntegrity("after Push") + li.Poll() + li.validateIntegrity("after Poll") + _, err := li.Fetch() + Expect(err).ToNot(HaveOccurred()) + li.validateIntegrity("after Fetch") + }) + +}) + +// validateIntegrity checks that for each element e +// - e.next.prev == e +// - e.prev.next == e +func (l *LinkedList[T]) validateIntegrity(msg string) { + elem := l.dummy + Expect(elem.next).ToNot(BeNil(), msg) + Expect(elem.prev).ToNot(BeNil(), msg) + Expect(elem.next.prev).To(Equal(elem), msg) + Expect(elem.prev.next).To(Equal(elem), msg) + elem = elem.next + for elem != l.dummy { + Expect(elem.next).ToNot(BeNil(), msg) + Expect(elem.prev).ToNot(BeNil(), msg) + Expect(elem.next.prev).To(Equal(elem), msg) + Expect(elem.prev.next).To(Equal(elem), msg) + elem = elem.next + } +} diff --git a/pkg/collections/list.go b/pkg/collections/list.go new file mode 100644 index 0000000..8405158 --- /dev/null +++ b/pkg/collections/list.go @@ -0,0 +1,23 @@ +package collections + +import ( + "encoding/json" +) + +type List[T any] interface { + Collection[T] + json.Marshaler + json.Unmarshaler + + // AddIndex adds the given element at the specified index. + // Returns an IndexOutOfBoundsError if the index is not within the list's size or equal to l.Size(). + AddIndex(element T, idx int) error + + // RemoveIndex removes the element at the specified index. + // Returns an IndexOutOfBoundsError if the index is not within the list's size. + RemoveIndex(idx int) error + + // Get returns the element at the specified index. + // Returns an IndexOutOfBoundsError if the index is not within the list's size. + Get(idx int) (T, error) +} diff --git a/pkg/collections/list_test.go b/pkg/collections/list_test.go new file mode 100644 index 0000000..84c6a4f --- /dev/null +++ b/pkg/collections/list_test.go @@ -0,0 +1,116 @@ +package collections_test + +import ( + "bytes" + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections" +) + +var baseDataJSON []byte + +type listImplementation struct { + Name string + Constructor func(elems ...int) collections.List[int] + AllowsDuplicates bool +} + +var _ = Describe("List Tests", func() { + + var err error + baseDataJSON, err = json.Marshal(baseData) + Expect(err).ToNot(HaveOccurred(), "unable to create baseDataJSON") + + for _, impl := range []*listImplementation{ + { + Name: "LinkedList", + Constructor: func(elems ...int) collections.List[int] { return collections.NewLinkedList[int](elems...) }, + AllowsDuplicates: true, + }, + } { + runListTests(impl) + } + +}) + +func runListTests(impl *listImplementation) { + var li collections.List[int] + + BeforeEach(func() { + li = impl.Constructor(baseData...) + }) + + AfterEach(func() { + Expect(baseData).To(Equal([]int{1, 3, 2, 4}), "array passed into constructor should not be modified") + }) + + Context(impl.Name, func() { + + Context("List-specific functionality", func() { + + It("should add an element at a specific index", func() { + Expect(li.AddIndex(0, 0)).To(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{0, 1, 3, 2, 4})) + Expect(li.AddIndex(5, li.Size())).To(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{0, 1, 3, 2, 4, 5})) + Expect(li.AddIndex(8, 3)).To(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{0, 1, 3, 8, 2, 4, 5})) + Expect(li.AddIndex(0, -1)).ToNot(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{0, 1, 3, 8, 2, 4, 5})) + Expect(li.AddIndex(0, li.Size()+1)).ToNot(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{0, 1, 3, 8, 2, 4, 5})) + }) + + It("should remove an element from a specific index", func() { + Expect(li.RemoveIndex(0)).To(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{3, 2, 4})) + Expect(li.RemoveIndex(1)).To(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{3, 4})) + Expect(li.RemoveIndex(1)).To(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{3})) + Expect(li.RemoveIndex(-1)).ToNot(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{3})) + Expect(li.RemoveIndex(li.Size())).ToNot(Succeed()) + Expect(li.ToSlice()).To(Equal([]int{3})) + }) + + It("should get the element at a specific index", func() { + elem, err := li.Get(0) + Expect(err).ToNot(HaveOccurred()) + Expect(elem).To(Equal(1)) + elem, err = li.Get(2) + Expect(err).ToNot(HaveOccurred()) + Expect(elem).To(Equal(2)) + elem, err = li.Get(li.Size() - 1) + Expect(err).ToNot(HaveOccurred()) + Expect(elem).To(Equal(baseData[len(baseData)-1])) + _, err = li.Get(-1) + Expect(err).To(HaveOccurred()) + _, err = li.Get(li.Size()) + Expect(err).To(HaveOccurred()) + }) + + }) + + Context("JSON conversion", func() { + + It("should marshal a list into a JSON list", func() { + jsonContent, err := json.Marshal(li) + Expect(err).ToNot(HaveOccurred()) + Expect(bytes.Equal(jsonContent, baseDataJSON)).To(BeTrue()) + }) + + It("should unmarshal a JSON list in a list", func() { + li2 := impl.Constructor(8, 4, 0, 10) + Expect(json.Unmarshal(baseDataJSON, li2)).To(Succeed()) + Expect(li2.ToSlice()).To(Equal(baseData)) + }) + + }) + + }) + +} diff --git a/pkg/collections/maps/suite_test.go b/pkg/collections/maps/suite_test.go new file mode 100644 index 0000000..d7c341c --- /dev/null +++ b/pkg/collections/maps/suite_test.go @@ -0,0 +1,14 @@ +package maps_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIterators(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Maps Test Suite") +} diff --git a/pkg/collections/maps/utils.go b/pkg/collections/maps/utils.go new file mode 100644 index 0000000..38f33e5 --- /dev/null +++ b/pkg/collections/maps/utils.go @@ -0,0 +1,52 @@ +package maps + +import "github.com/openmcp-project/controller-utils/pkg/collections/filters" + +// Filter filters a map by applying a filter function to each key-value pair. +// Only the entries for which the filter function returns true are kept in the copy. +// The original map is not modified. +// Passes the key as first and the value as second argument into the filter function. +// Note that the values are not deep-copied. +// This is a convience alias for filters.FilterMap. +func Filter[K comparable, V any](m map[K]V, fil filters.Filter) map[K]V { + return filters.FilterMap(m, fil) +} + +// Merge merges multiple maps into a single one. +// The original maps are not modified. +// Note that the values are not deep-copied. +// If multiple maps contain the same key, the value from the last map in the list is used. +func Merge[K comparable, V any](maps ...map[K]V) map[K]V { + res := map[K]V{} + + for _, m := range maps { + for k, v := range m { + res[k] = v + } + } + + return res +} + +// Intersect takes one source and any number of comparison maps and returns a new map containing only +// the entries of source for which keys exist in all comparison maps. +// The original maps are not modified. +// Note that the values are not deep-copied. +func Intersect[K comparable, V any](source map[K]V, maps ...map[K]V) map[K]V { + res := map[K]V{} + + for k, v := range source { + exists := true + for _, m := range maps { + if _, ok := m[k]; !ok { + exists = false + break + } + } + if exists { + res[k] = v + } + } + + return res +} diff --git a/pkg/collections/maps/utils_test.go b/pkg/collections/maps/utils_test.go new file mode 100644 index 0000000..e3f456b --- /dev/null +++ b/pkg/collections/maps/utils_test.go @@ -0,0 +1,63 @@ +package maps_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections/maps" +) + +var _ = Describe("LinkedIterator Tests", func() { + + Context("Merge", func() { + + It("should merge multiple maps", func() { + m1 := map[string]string{"foo": "bar"} + m2 := map[string]string{"bar": "baz", "foobar": "foobaz"} + m3 := map[string]string{"abc": "def", "xyz": "uvw"} + + merged := maps.Merge(m1, m2, m3) + + Expect(merged).To(Equal(map[string]string{"foo": "bar", "bar": "baz", "foobar": "foobaz", "abc": "def", "xyz": "uvw"})) + }) + + It("should merge multiple maps with overlapping keys", func() { + m1 := map[string]string{"foo": "bar"} + m2 := map[string]string{"foo": "baz", "foobar": "foobaz"} + m3 := map[string]string{"foo": "fooo", "xyz": "uvw"} + + merged := maps.Merge(m1, m2, m3) + + Expect(merged).To(Equal(map[string]string{"foo": "fooo", "foobar": "foobaz", "xyz": "uvw"})) + }) + + It("should ignore nil and empty maps", func() { + merged := maps.Merge(nil, map[string]string{}, map[string]string{"foo": "bar"}) + Expect(merged).To(Equal(map[string]string{"foo": "bar"})) + }) + + }) + + Context("Intersect", func() { + + It("should intersect multiple maps", func() { + m1 := map[string]string{"foo": "bar", "bar": "baz", "foobar": "foobaz"} + m2 := map[string]string{"bar": "baz", "foobar": "foobaz", "abc": "def"} + m3 := map[string]string{"bar": "baz", "foobar": "foobaz", "abc": "def"} + + intersected := maps.Intersect(m1, m2, m3) + + Expect(intersected).To(Equal(map[string]string{"bar": "baz", "foobar": "foobaz"})) + }) + + It("should remove all entries if one map is empty", func() { + intersected := maps.Intersect(map[string]string{"foo": "bar"}, map[string]string{}) + Expect(intersected).To(BeEmpty()) + + intersected = maps.Intersect(map[string]string{}, map[string]string{"foo": "bar"}) + Expect(intersected).To(BeEmpty()) + }) + + }) + +}) diff --git a/pkg/collections/queue.go b/pkg/collections/queue.go new file mode 100644 index 0000000..5428ada --- /dev/null +++ b/pkg/collections/queue.go @@ -0,0 +1,21 @@ +package collections + +type Queue[T any] interface { + Collection[T] + + // Push adds the given elements to the queue. + // Returns an error, if the queue's size restriction prevents the elements from being added. + Push(elements ...T) error + // Peek returns the first element without removing it. + // Returns T's zero value, if the queue is empty. + Peek() T + // Element returns the first element without removing it. + // Returns an error, if the queue is empty. + Element() (T, error) + // Poll returns the first element and removes it from the queue. + // Returns T's zero value, if the queue is empty. + Poll() T + // Fetch returns the first element and removes it from the queue. + // Returns an error, if the queue is empty. + Fetch() (T, error) +} diff --git a/pkg/collections/queue_test.go b/pkg/collections/queue_test.go new file mode 100644 index 0000000..c2b5e57 --- /dev/null +++ b/pkg/collections/queue_test.go @@ -0,0 +1,100 @@ +package collections_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/collections" + cerr "github.com/openmcp-project/controller-utils/pkg/collections/errors" +) + +type queueImplementation struct { + Name string + Constructor func(elems ...int) collections.Queue[int] + AllowsDuplicates bool +} + +var _ = Describe("Queue Tests", func() { + + for _, impl := range []*queueImplementation{ + { + Name: "LinkedList", + Constructor: func(elems ...int) collections.Queue[int] { return collections.NewLinkedList[int](elems...) }, + AllowsDuplicates: true, + }, + } { + runQueueTests(impl) + } + +}) + +func runQueueTests(impl *queueImplementation) { + var q collections.Queue[int] + + BeforeEach(func() { + q = impl.Constructor(baseData...) + }) + + AfterEach(func() { + Expect(baseData).To(Equal([]int{1, 3, 2, 4}), "array passed into constructor should not be modified") + }) + + Context(impl.Name, func() { + + Context("Queue-specific functionality", func() { + + It("should add elements to the queue", func() { + Expect(q.Push(5)).To(Succeed()) + Expect(q.ToSlice()).To(Equal([]int{1, 3, 2, 4, 5})) + Expect(q.Push(6, 7)).To(Succeed()) + Expect(q.ToSlice()).To(Equal([]int{1, 3, 2, 4, 5, 6, 7})) + }) + + It("should retrieve the first element without modifying it for peek/element", func() { + Expect(q.Peek()).To(Equal(1)) + Expect(q.ToSlice()).To(Equal([]int{1, 3, 2, 4})) + val, err := q.Element() + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(1)) + }) + + It("should show correct error behavior for peek/element", func() { + var eq collections.Queue[int] = collections.NewLinkedList[int]() + Expect(eq.Peek()).To(Equal(0)) + _, err := eq.Element() + Expect(err).To(HaveOccurred()) + Expect(err).To(BeEquivalentTo(cerr.CollectionEmptyError{})) + }) + + It("should remove and return the first element for poll/fetch", func() { + s := q.Size() + Expect(q.Poll()).To(Equal(1)) + Expect(q.Size()).To(Equal(s - 1)) + s = q.Size() + Expect(q.Poll()).To(Equal(3)) + Expect(q.Size()).To(Equal(s - 1)) + s = q.Size() + val, err := q.Fetch() + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(2)) + Expect(q.Size()).To(Equal(s - 1)) + s = q.Size() + val, err = q.Fetch() + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(4)) + Expect(q.Size()).To(Equal(s - 1)) + }) + + It("should show correct error behavior for poll/fetch", func() { + var eq collections.Queue[int] = collections.NewLinkedList[int]() + Expect(eq.Poll()).To(Equal(0)) + _, err := eq.Fetch() + Expect(err).To(HaveOccurred()) + Expect(err).To(BeEquivalentTo(cerr.CollectionEmptyError{})) + }) + + }) + + }) + +} diff --git a/pkg/collections/suite_test.go b/pkg/collections/suite_test.go new file mode 100644 index 0000000..42cd383 --- /dev/null +++ b/pkg/collections/suite_test.go @@ -0,0 +1,16 @@ +package collections_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var baseData = []int{1, 3, 2, 4} + +func TestCollections(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Collection Test Suite") +} diff --git a/pkg/controller/annotation_label.go b/pkg/controller/annotation_label.go new file mode 100644 index 0000000..f08b5e3 --- /dev/null +++ b/pkg/controller/annotation_label.go @@ -0,0 +1,204 @@ +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +///////////////////////// +/// BOILERPLATE STUFF /// +///////////////////////// + +type metadataEntryType interface { + // Name returns the name of the metadata type. + // Should return either 'annotation' or 'label'. + Name() string + // GetData returns the metadata map. + GetData(obj client.Object) map[string]string + // SetData sets the metadata map. + SetData(obj client.Object, data map[string]string) +} + +type annotationMetadata struct{} + +func (*annotationMetadata) Name() string { + return "annotation" +} + +func (*annotationMetadata) GetData(obj client.Object) map[string]string { + return obj.GetAnnotations() +} + +func (*annotationMetadata) SetData(obj client.Object, data map[string]string) { + obj.SetAnnotations(data) +} + +type labelMetadata struct{} + +func (*labelMetadata) Name() string { + return "label" +} + +func (*labelMetadata) GetData(obj client.Object) map[string]string { + return obj.GetLabels() +} + +func (*labelMetadata) SetData(obj client.Object, data map[string]string) { + obj.SetLabels(data) +} + +var ( + ANNOTATION = &annotationMetadata{} + LABEL = &labelMetadata{} +) + +//////////////////////////////////////// +/// MODIFYING ANNOTATIONS AND LABELS /// +//////////////////////////////////////// + +type MetadataEntryAlreadyExistsError struct { + MType metadataEntryType + Key string + DesiredValue string + ActualValue string +} + +func NewMetadataEntryAlreadyExistsError(mType metadataEntryType, key, desired, actual string) *MetadataEntryAlreadyExistsError { + return &MetadataEntryAlreadyExistsError{ + MType: mType, + Key: key, + DesiredValue: desired, + ActualValue: actual, + } +} + +func (e *MetadataEntryAlreadyExistsError) Error() string { + return fmt.Sprintf("%s '%s' already exists on the object and value '%s' could not be updated to '%s'", e.MType.Name(), e.Key, e.ActualValue, e.DesiredValue) +} + +func IsMetadataEntryAlreadyExistsError(err error) bool { + _, ok := err.(*MetadataEntryAlreadyExistsError) + return ok +} + +// EnsureAnnotation ensures that the given annotation has the desired state on the object. +// If the annotation already exists with the expected value or doesn't exist when deletion is desired, this is a no-op. +// If the annotation already exists with a different value, a MetadataEntryAlreadyExistsError is returned, unless mode OVERWRITE is set. +// To remove an annotation, set mode to DELETE. The given annValue does not matter in this case. +// If patch is set to true, the object will be patched in the cluster immediately, otherwise only the in-memory object is modified. client may be nil when patch is false. +func EnsureAnnotation(ctx context.Context, c client.Client, obj client.Object, annKey, annValue string, patch bool, mode ...ModifyMetadataEntryMode) error { + return ensureMetadataEntry(ANNOTATION, ctx, c, obj, annKey, annValue, patch, mode...) +} + +// EnsureLabel ensures that the given label has the desired state on the object. +// If the label already exists with the expected value or doesn't exist when deletion is desired, this is a no-op. +// If the label already exists with a different value, a MetadataEntryAlreadyExistsError is returned, unless mode OVERWRITE is set. +// To remove an label, set mode to DELETE. The given labelValue does not matter in this case. +// If patch is set to true, the object will be patched in the cluster immediately, otherwise only the in-memory object is modified. client may be nil when patch is false. +func EnsureLabel(ctx context.Context, c client.Client, obj client.Object, labelKey, labelValue string, patch bool, mode ...ModifyMetadataEntryMode) error { + return ensureMetadataEntry(LABEL, ctx, c, obj, labelKey, labelValue, patch, mode...) +} + +// ensureMetadataEntry is the common base method for EnsureAnnotation and EnsureLabel. +// This is mainly exposed for testing purposes, usually it is statically known whether an annotation or label is to be modified and the respective method should be used. +func ensureMetadataEntry(mType metadataEntryType, ctx context.Context, c client.Client, obj client.Object, key, value string, patch bool, mode ...ModifyMetadataEntryMode) error { + modeDelete := false + modeOverwrite := false + for _, m := range mode { + switch m { + case DELETE: + modeDelete = true + case OVERWRITE: + modeOverwrite = true + } + } + quote := "\"" + data := mType.GetData(obj) + if data == nil { + data = map[string]string{} + } + val, ok := data[key] + if (ok && val == value && !modeDelete) || (!ok && modeDelete) { + // annotation/label already exists on the object, nothing to do + return nil + } + if modeDelete { + // delete annotation/label + value = "null" + quote = "" + delete(data, key) + } else { + if ok && !modeOverwrite { + return NewMetadataEntryAlreadyExistsError(mType, key, value, val) + } + // add annotation/label to obj + data[key] = value + } + mType.SetData(obj, data) + if patch { + // patch annotation/label to in-cluster object + if err := c.Patch(ctx, obj, client.RawPatch(types.MergePatchType, []byte(fmt.Sprintf(`{"metadata":{"%ss":{"%s":%s%s%s}}}`, mType.Name(), key, quote, value, quote)))); err != nil { + return err + } + } + return nil +} + +type ModifyMetadataEntryMode string + +const ( + OVERWRITE ModifyMetadataEntryMode = "overwrite" + DELETE ModifyMetadataEntryMode = "delete" +) + +////////////////////////////////////// +/// GETTING ANNOTATIONS AND LABELS /// +////////////////////////////////////// + +// getMetadataEntry returns the value of the given annotation/label key on the object and whether it exists. +// This is mainly exposed for testing purposes, usually it is statically known whether an annotation or label is to be fetched and the respective method should be used. +func getMetadataEntry(mType metadataEntryType, obj client.Object, key string) (string, bool) { + data := mType.GetData(obj) + if data == nil { + return "", false + } + val, ok := data[key] + return val, ok +} + +// HasAnnotation returns true if the given annotation key exists on the object. +func HasAnnotation(obj client.Object, key string) bool { + _, ok := getMetadataEntry(ANNOTATION, obj, key) + return ok +} + +// HasLabel returns true if the given label key exists on the object. +func HasLabel(obj client.Object, key string) bool { + _, ok := getMetadataEntry(LABEL, obj, key) + return ok +} + +// HasAnnotationWithValue returns true if the given annotation key exists on the object and has the given value. +func HasAnnotationWithValue(obj client.Object, key, value string) bool { + val, ok := getMetadataEntry(ANNOTATION, obj, key) + return ok && val == value +} + +// HasLabelWithValue returns true if the given label key exists on the object and has the given value. +func HasLabelWithValue(obj client.Object, key, value string) bool { + val, ok := getMetadataEntry(LABEL, obj, key) + return ok && val == value +} + +// GetAnnotation returns the value of the given annotation key on the object and whether it exists. +func GetAnnotation(obj client.Object, key string) (string, bool) { + return getMetadataEntry(ANNOTATION, obj, key) +} + +// GetLabel returns the value of the given label key on the object and whether it exists. +func GetLabel(obj client.Object, key string) (string, bool) { + return getMetadataEntry(LABEL, obj, key) +} diff --git a/pkg/controller/annotation_label_test.go b/pkg/controller/annotation_label_test.go new file mode 100644 index 0000000..801f6c8 --- /dev/null +++ b/pkg/controller/annotation_label_test.go @@ -0,0 +1,404 @@ +package controller_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller" + testutils "github.com/openmcp-project/controller-utils/pkg/testing" +) + +var _ = Describe("Annotation/Label Library", func() { + + Context("Annotations", func() { + + Context("IsMetadataEntryAlreadyExistsError", func() { + + It("should return true if the error is of type IsMetadataEntryAlreadyExistsError", func() { + var err error = ctrlutils.NewMetadataEntryAlreadyExistsError(ctrlutils.ANNOTATION, "test-annotation", "desired-value", "actual-value") + Expect(ctrlutils.IsMetadataEntryAlreadyExistsError(err)).To(BeTrue()) + }) + + It("should return false if the error is not of type IsMetadataEntryAlreadyExistsError", func() { + var err error = fmt.Errorf("test-error") + Expect(ctrlutils.IsMetadataEntryAlreadyExistsError(err)).To(BeFalse()) + }) + + }) + + Context("Fetch", func() { + + Context("GetAnnotation", func() { + + It("should return the value of the annotation, if it exists", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + value, ok := ctrlutils.GetAnnotation(ns, "foo.bar.baz/foo") + Expect(ok).To(BeTrue()) + Expect(value).To(Equal(ns.GetAnnotations()["foo.bar.baz/foo"])) + }) + + It("should return an empty string and false if the annotation does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-annotation"}, ns)).To(Succeed()) + value, ok := ctrlutils.GetAnnotation(ns, "foo.bar.baz/foo") + Expect(ok).To(BeFalse()) + Expect(value).To(Equal("")) + }) + + }) + + Context("HasAnnotation", func() { + + It("should return true if the annotation exists", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.HasAnnotation(ns, "foo.bar.baz/foo")).To(BeTrue()) + }) + + It("should return false if the annotation does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.HasAnnotation(ns, "foo.bar.baz/foo")).To(BeFalse()) + }) + + }) + + Context("HasAnnotationWithValue", func() { + + It("should return true if the annotation exists and has the expected value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.HasAnnotationWithValue(ns, "foo.bar.baz/foo", "bar")).To(BeTrue()) + }) + + It("should return false if the annotation exists but does not have the expected value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.HasAnnotationWithValue(ns, "foo.bar.baz/foo", "asdf")).To(BeFalse()) + }) + + It("should return false if the annotation does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.HasAnnotationWithValue(ns, "foo.bar.baz/foo", "asdf")).To(BeFalse()) + }) + + }) + + }) + + Context("Modify in memory only", func() { + + It("should patch the annotation on the object, if it does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "bar", false)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).ToNot(HaveKey("foo.bar.baz/foo")) + }) + + It("should not fail if the annotation already exists with the desired value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + oldNs := ns.DeepCopy() + Expect(ctrlutils.EnsureAnnotation(env.Ctx, nil, ns, "foo.bar.baz/foo", "bar", false)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(ns).To(Equal(oldNs)) + // client is nil, so trying to patch in cluster will cause a panic + }) + + It("should return a MetadataEntryAlreadyExistsError if the annotation already exists with a different value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", false)).To(MatchError(ctrlutils.NewMetadataEntryAlreadyExistsError(ctrlutils.ANNOTATION, "foo.bar.baz/foo", "baz", "bar"))) + }) + + It("should overwrite the annotation if the mode is set to OVERWRITE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", false, ctrlutils.OVERWRITE)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "baz")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + }) + + It("should delete the annotation if the mode is set to DELETE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "", false, ctrlutils.DELETE)).To(Succeed()) + Expect(ns.GetAnnotations()).NotTo(HaveKey("foo.bar.baz/foo")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + }) + + }) + + Context("Modify in memory and in cluster", func() { + + It("should patch the annotation on the object, if it does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "bar", true)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + }) + + It("should not fail if the annotation already exists with the desired value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + oldNs := ns.DeepCopy() + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "bar", true)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(ns).To(Equal(oldNs)) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(ns).To(Equal(oldNs)) + }) + + It("should return a MetadataEntryAlreadyExistsError if the annotation already exists with a different value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", true)).To(MatchError(ctrlutils.NewMetadataEntryAlreadyExistsError(ctrlutils.ANNOTATION, "foo.bar.baz/foo", "baz", "bar"))) + }) + + It("should overwrite the annotation if the mode is set to OVERWRITE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", true, ctrlutils.OVERWRITE)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "baz")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).To(HaveKeyWithValue("foo.bar.baz/foo", "baz")) + }) + + It("should delete the annotation if the mode is set to DELETE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-annotation"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureAnnotation(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "", true, ctrlutils.DELETE)).To(Succeed()) + Expect(ns.GetAnnotations()).NotTo(HaveKey("foo.bar.baz/foo")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetAnnotations()).NotTo(HaveKey("foo.bar.baz/foo")) + }) + + }) + + }) + + Context("Labels", func() { + + Context("IsMetadataEntryAlreadyExistsError", func() { + + It("should return true if the error is of type IsMetadataEntryAlreadyExistsError", func() { + var err error = ctrlutils.NewMetadataEntryAlreadyExistsError(ctrlutils.LABEL, "test-label", "desired-value", "actual-value") + Expect(ctrlutils.IsMetadataEntryAlreadyExistsError(err)).To(BeTrue()) + }) + + It("should return false if the error is not of type IsMetadataEntryAlreadyExistsError", func() { + var err error = fmt.Errorf("test-error") + Expect(ctrlutils.IsMetadataEntryAlreadyExistsError(err)).To(BeFalse()) + }) + + }) + + Context("Fetch", func() { + + Context("GetLabel", func() { + + It("should return the value of the label, if it exists", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + value, ok := ctrlutils.GetLabel(ns, "foo.bar.baz/foo") + Expect(ok).To(BeTrue()) + Expect(value).To(Equal(ns.GetLabels()["foo.bar.baz/foo"])) + }) + + It("should return an empty string and false if the label does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-label"}, ns)).To(Succeed()) + value, ok := ctrlutils.GetLabel(ns, "foo.bar.baz/foo") + Expect(ok).To(BeFalse()) + Expect(value).To(Equal("")) + }) + + }) + + Context("HasLabel", func() { + + It("should return true if the label exists", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.HasLabel(ns, "foo.bar.baz/foo")).To(BeTrue()) + }) + + It("should return false if the label does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-label"}, ns)).To(Succeed()) + Expect(ctrlutils.HasLabel(ns, "foo.bar.baz/foo")).To(BeFalse()) + }) + + }) + + Context("HasLabelWithValue", func() { + + It("should return true if the label exists and has the expected value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.HasLabelWithValue(ns, "foo.bar.baz/foo", "bar")).To(BeTrue()) + }) + + It("should return false if the label exists but does not have the expected value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.HasLabelWithValue(ns, "foo.bar.baz/foo", "asdf")).To(BeFalse()) + }) + + It("should return false if the label does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-label"}, ns)).To(Succeed()) + Expect(ctrlutils.HasLabelWithValue(ns, "foo.bar.baz/foo", "asdf")).To(BeFalse()) + }) + + }) + + }) + + Context("Modify in memory only", func() { + + It("should patch the label on the object, if it does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "bar", false)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).ToNot(HaveKey("foo.bar.baz/foo")) + }) + + It("should not fail if the label already exists with the desired value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + oldNs := ns.DeepCopy() + Expect(ctrlutils.EnsureLabel(env.Ctx, nil, ns, "foo.bar.baz/foo", "bar", false)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(ns).To(Equal(oldNs)) + // client is nil, so trying to patch in cluster will cause a panic + }) + + It("should return a MetadataEntryAlreadyExistsError if the label already exists with a different value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", false)).To(MatchError(ctrlutils.NewMetadataEntryAlreadyExistsError(ctrlutils.LABEL, "foo.bar.baz/foo", "baz", "bar"))) + }) + + It("should overwrite the label if the mode is set to OVERWRITE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", false, ctrlutils.OVERWRITE)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "baz")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + }) + + It("should delete the label if the mode is set to DELETE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "", false, ctrlutils.DELETE)).To(Succeed()) + Expect(ns.GetLabels()).NotTo(HaveKey("foo.bar.baz/foo")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + }) + + }) + + Context("Modify in memory and in cluster", func() { + + It("should patch the label on the object, if it does not exist", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "no-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "bar", true)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + }) + + It("should not fail if the label already exists with the desired value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + oldNs := ns.DeepCopy() + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "bar", true)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(ns).To(Equal(oldNs)) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "bar")) + Expect(ns).To(Equal(oldNs)) + }) + + It("should return a MetadataEntryAlreadyExistsError if the label already exists with a different value", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", true)).To(MatchError(ctrlutils.NewMetadataEntryAlreadyExistsError(ctrlutils.LABEL, "foo.bar.baz/foo", "baz", "bar"))) + }) + + It("should overwrite the label if the mode is set to OVERWRITE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "baz", true, ctrlutils.OVERWRITE)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "baz")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).To(HaveKeyWithValue("foo.bar.baz/foo", "baz")) + }) + + It("should delete the label if the mode is set to DELETE", func() { + env := testutils.NewEnvironmentBuilder().WithInitObjectPath("testdata", "test-01").Build() + ns := &corev1.Namespace{} + Expect(env.Client().Get(env.Ctx, client.ObjectKey{Name: "foo-label"}, ns)).To(Succeed()) + Expect(ctrlutils.EnsureLabel(env.Ctx, env.Client(), ns, "foo.bar.baz/foo", "", true, ctrlutils.DELETE)).To(Succeed()) + Expect(ns.GetLabels()).NotTo(HaveKey("foo.bar.baz/foo")) + Expect(env.Client().Get(env.Ctx, client.ObjectKeyFromObject(ns), ns)).To(Succeed()) + Expect(ns.GetLabels()).NotTo(HaveKey("foo.bar.baz/foo")) + }) + + }) + + }) + +}) diff --git a/pkg/controller/owners.go b/pkg/controller/owners.go new file mode 100644 index 0000000..b18c8fe --- /dev/null +++ b/pkg/controller/owners.go @@ -0,0 +1,46 @@ +package controller + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +// HasOwnerReference returns the index of the owner reference if the 'owned' object has a owner reference pointing to the 'owner' object. +// If not, -1 is returned. +// Note that name and uid are only compared if set in the owner object. This means that the function will return a positive index +// for an owner object with empty name and uid if the owned object contains a owner reference which fits just apiversion and kind. +// The scheme argument may be nil if the owners GVK is populated. +func HasOwnerReference(owned, owner client.Object, scheme *runtime.Scheme) (int, error) { + if owned == nil || owner == nil { + return -1, fmt.Errorf("neither dependent nor owner may be nil when checking for owner references") + } + if owner.GetNamespace() != owned.GetNamespace() && owner.GetNamespace() != "" { + // cross-namespace owner references are not possible + return -1, nil + } + gvk := owner.GetObjectKind().GroupVersionKind() + if gvk.Version == "" || gvk.Kind == "" { + if scheme == nil { + return -1, fmt.Errorf("scheme must be provided if owner's GVK is not populated") + } + var err error + gvk, err = apiutil.GVKForObject(owner, scheme) + if err != nil { + return -1, fmt.Errorf("unable to determine owner's GVK: %w", err) + } + } + gv := gvk.GroupVersion().String() + for idx, or := range owned.GetOwnerReferences() { + if (owner.GetName() != "" && or.Name != owner.GetName()) || + (owner.GetUID() != "" && or.UID != owner.GetUID()) || + (or.APIVersion != gv) || + (or.Kind != gvk.Kind) { + continue + } + return idx, nil + } + return -1, nil +} diff --git a/pkg/controller/owners_test.go b/pkg/controller/owners_test.go new file mode 100644 index 0000000..cfd9116 --- /dev/null +++ b/pkg/controller/owners_test.go @@ -0,0 +1,83 @@ +package controller_test + +import ( + . "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/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller" +) + +var _ = Describe("Owners", func() { + + Context("HasOwnerReference", func() { + + It("should correctly identify the owner id", func() { + owner1 := &corev1.Secret{} + owner1.SetName("owner1") + owner1.SetUID(types.UID("owner1-uid")) + owner2 := &corev1.Secret{} + owner2.SetName("owner2") + owner2.SetUID(types.UID("owner2-uid")) + nonOwner := &corev1.Secret{} + nonOwner.SetName("non-owner") + nonOwner.SetUID(types.UID("non-owner-uid")) + owned := &corev1.Secret{} + owned.SetName("owned") + owned.SetUID(types.UID("owned-uid")) + owned.SetOwnerReferences([]metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Secret", + Name: owner1.Name, + UID: owner1.UID, + }, + { + APIVersion: "v1", + Kind: "Secret", + Name: owner2.Name, + UID: owner2.UID, + }, + }) + + sc := runtime.NewScheme() + Expect(clientgoscheme.AddToScheme(sc)).To(Succeed()) + + idx, err := ctrlutils.HasOwnerReference(owned, owner1, sc) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(Equal(0)) + + idx, err = ctrlutils.HasOwnerReference(owned, owner2, sc) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(Equal(1)) + + idx, err = ctrlutils.HasOwnerReference(owned, nonOwner, sc) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(Equal(-1)) + + // test with nil scheme + owner1.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) + owner2.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) + nonOwner.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) + + idx, err = ctrlutils.HasOwnerReference(owned, owner1, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(Equal(0)) + + idx, err = ctrlutils.HasOwnerReference(owned, owner2, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(Equal(1)) + + idx, err = ctrlutils.HasOwnerReference(owned, nonOwner, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(Equal(-1)) + }) + + }) + +}) diff --git a/pkg/controller/predicates.go b/pkg/controller/predicates.go new file mode 100644 index 0000000..b5549e1 --- /dev/null +++ b/pkg/controller/predicates.go @@ -0,0 +1,174 @@ +package controller + +// This package contains predicates which can be used for constructing controllers. + +import ( + "reflect" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +///////////////////////////////////// +/// DELETION TIMESTAMP PREDICATES /// +///////////////////////////////////// + +// DeletionTimestampChangedPredicate reacts to changes of the deletion timestamp. +type DeletionTimestampChangedPredicate struct { + predicate.Funcs +} + +var _ predicate.Predicate = DeletionTimestampChangedPredicate{} + +func (DeletionTimestampChangedPredicate) Update(e event.UpdateEvent) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return false + } + oldDel := e.ObjectOld.GetDeletionTimestamp() + newDel := e.ObjectNew.GetDeletionTimestamp() + return !reflect.DeepEqual(newDel, oldDel) +} + +/////////////////////////////////////// +/// ANNOTATION AND LABEL PREDICATES /// +/////////////////////////////////////// + +func hasMetadataEntryPredicate(mType metadataEntryType, key, val string) predicate.Predicate { + return predicate.NewPredicateFuncs(func(obj client.Object) bool { + if obj == nil { + return false + } + actual, ok := getMetadataEntry(mType, obj, key) + return ok && (val == "" || actual == val) + }) +} + +type metadataEntryChangedPredicate struct { + predicate.Funcs + mType metadataEntryType // type of metadata entry (annotation or label) + key string // key of the metadata entry + value string // value of the metadata entry (empty string if value doesn't matter) + mod int // positive means the predicate returns true if the entry was added (includes being set to correct value), negative if it was removed, 0 if either happened +} + +func (p metadataEntryChangedPredicate) Update(e event.UpdateEvent) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return false + } + + oldValue, ok := getMetadataEntry(p.mType, e.ObjectOld, p.key) + oldHasEntry := ok && (p.value == "" || oldValue == p.value) + + newValue, ok := getMetadataEntry(p.mType, e.ObjectNew, p.key) + newHasEntry := ok && (p.value == "" || newValue == p.value) + + if p.mod > 0 { + // check if entry was added + return !oldHasEntry && newHasEntry + } else if p.mod < 0 { + // check if entry was removed + return oldHasEntry && !newHasEntry + } + // check if entry was changed (added, removed, or value changed) + return oldHasEntry != newHasEntry +} + +// HasAnnotationPredicate reacts if the resource has the specified annotation. +// If val is empty, the value of the annotation doesn't matter, only its existence. +// Otherwise, true is only returned if the annotation has the specified value. +// Note that GotAnnotationPredicate can be used to check if a resource just got a specific annotation. +func HasAnnotationPredicate(key, val string) predicate.Predicate { + return hasMetadataEntryPredicate(ANNOTATION, key, val) +} + +// GotAnnotationPredicate reacts if the specified annotation was added to the resource. +// If val is empty, the value of the annotation doesn't matter, just that it was added. +// Otherwise, true is only returned if the annotation was added (or changed) with the specified value. +func GotAnnotationPredicate(key, val string) predicate.Predicate { + return metadataEntryChangedPredicate{ + mType: ANNOTATION, + key: key, + value: val, + mod: 1, + } +} + +// LostAnnotationPredicate reacts if the specified annotation was removed from the resource. +// If val is empty, this predicate returns true if the annotation was removed completely, independent of which value it had. +// Otherwise, true is returned if the annotation had the specified value before and now lost it, either by being removed or by being set to a different value. +func LostAnnotationPredicate(key, val string) predicate.Predicate { + return metadataEntryChangedPredicate{ + mType: ANNOTATION, + key: key, + value: val, + mod: -1, + } +} + +// HasLabelPredicate reacts if the resource has the specified label. +// If val is empty, the value of the label doesn't matter, only its existence. +// Otherwise, true is only returned if the label has the specified value. +// Note that GotLabelPredicate can be used to check if a resource just got a specific label. +func HasLabelPredicate(key, val string) predicate.Predicate { + return hasMetadataEntryPredicate(LABEL, key, val) +} + +// GotLabelPredicate reacts if the specified label was added to the resource. +// If val is empty, the value of the label doesn't matter, just that it was added. +// Otherwise, true is only returned if the label was added (or changed) with the specified value. +func GotLabelPredicate(key, val string) predicate.Predicate { + return metadataEntryChangedPredicate{ + mType: LABEL, + key: key, + value: val, + mod: 1, + } +} + +// LostLabelPredicate reacts if the specified label was removed from the resource. +// If val is empty, this predicate returns true if the label was removed completely, independent of which value it had. +// Otherwise, true is returned if the label had the specified value before and now lost it, either by being removed or by being set to a different value. +func LostLabelPredicate(key, val string) predicate.Predicate { + return metadataEntryChangedPredicate{ + mType: LABEL, + key: key, + value: val, + mod: -1, + } +} + +///////////////////////// +/// STATUS PREDICATES /// +///////////////////////// + +var _ predicate.Predicate = StatusChangedPredicate{} + +// StatusChangedPredicate returns true if the object's status changed. +// Getting the status is done via reflection and only works if the corresponding field is named 'Status'. +// If getting the status fails, this predicate always returns true. +type StatusChangedPredicate struct { + predicate.Funcs +} + +func (p StatusChangedPredicate) Update(e event.UpdateEvent) bool { + oldStatus := getStatus(e.ObjectOld) + newStatus := getStatus(e.ObjectNew) + if oldStatus == nil || newStatus == nil { + return true + } + return !reflect.DeepEqual(oldStatus, newStatus) +} + +func getStatus(obj any) any { + if obj == nil { + return nil + } + val := reflect.ValueOf(obj).Elem() + for i := 0; i < val.NumField(); i++ { + if val.Type().Field(i).Name == "Status" { + return val.Field(i).Interface() + } + } + return nil +} diff --git a/pkg/controller/predicates_test.go b/pkg/controller/predicates_test.go new file mode 100644 index 0000000..b7ddca3 --- /dev/null +++ b/pkg/controller/predicates_test.go @@ -0,0 +1,211 @@ +package controller_test + +import ( + . "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/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + + ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller" +) + +var _ = Describe("Predicates", func() { + + var base *corev1.Service + var changed *corev1.Service + + BeforeEach(func() { + base = &corev1.Service{} + base.SetName("foo") + base.SetNamespace("bar") + base.SetGeneration(1) + changed = base.DeepCopy() + }) + + Context("DeletionTimestamp", func() { + + It("should detect changes to the deletion timestamp", func() { + p := ctrlutils.DeletionTimestampChangedPredicate{} + Expect(p.Update(updateEvent(base, changed))).To(BeFalse(), "DeletionTimestampChangedPredicate should return false if the deletion timestamp did not change") + changed.SetDeletionTimestamp(ptr.To(metav1.Now())) + Expect(p.Update(updateEvent(base, changed))).To(BeTrue(), "DeletionTimestampChangedPredicate should return true if the deletion timestamp did change") + }) + + }) + + Context("Annotations", func() { + + It("should detect changes to the annotations", func() { + pHasFoo := ctrlutils.HasAnnotationPredicate("foo", "") + pHasBar := ctrlutils.HasAnnotationPredicate("bar", "") + pHasFooWithFoo := ctrlutils.HasAnnotationPredicate("foo", "foo") + pHasFooWithBar := ctrlutils.HasAnnotationPredicate("foo", "bar") + pGotFoo := ctrlutils.GotAnnotationPredicate("foo", "") + pGotFooWithFoo := ctrlutils.GotAnnotationPredicate("foo", "foo") + pGotFooWithBar := ctrlutils.GotAnnotationPredicate("foo", "bar") + pLostFoo := ctrlutils.LostAnnotationPredicate("foo", "") + pLostFooWithFoo := ctrlutils.LostAnnotationPredicate("foo", "foo") + pLostFooWithBar := ctrlutils.LostAnnotationPredicate("foo", "bar") + By("old and new resource are equal") + e := updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if there are no annotations") + Expect(pHasFooWithFoo.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if there are no annotations") + Expect(pHasFooWithBar.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if there are no annotations") + Expect(pGotFoo.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if there are no annotations") + Expect(pGotFooWithFoo.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if there are no annotations") + Expect(pGotFooWithBar.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if there are no annotations") + Expect(pLostFoo.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if there never were annotations") + Expect(pLostFooWithFoo.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if there never were annotations") + Expect(pLostFooWithBar.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if there never were annotations") + By("add annotation foo=foo") + changed.SetAnnotations(map[string]string{ + "foo": "foo", + }) + e = updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeTrue(), "HasAnnotationPredicate should return true if the annotation is there") + Expect(pHasBar.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if the annotation is not there") + Expect(pHasFooWithFoo.Update(e)).To(BeTrue(), "HasAnnotationPredicate should return true if the annotation is there and has the fitting value") + Expect(pHasFooWithBar.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if the annotation is there but has the wrong value") + Expect(pGotFoo.Update(e)).To(BeTrue(), "GotAnnotationPredicate should return true if the annotation was added") + Expect(pGotFooWithFoo.Update(e)).To(BeTrue(), "GotAnnotationPredicate should return true if the annotation was added with the correct value") + Expect(pGotFooWithBar.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if the annotation was added but with the wrong value") + Expect(pLostFoo.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if the annotation wasn't there before") + Expect(pLostFooWithFoo.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if the annotation wasn't there before") + Expect(pLostFooWithBar.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if the annotation wasn't there before") + By("change annotation to foo=bar") + base = changed.DeepCopy() + changed.SetAnnotations(map[string]string{ + "foo": "bar", + }) + e = updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeTrue(), "HasAnnotationPredicate should return true if the annotation is there") + Expect(pHasBar.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if the annotation is not there") + Expect(pHasFooWithFoo.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if the annotation is there but has the wrong value") + Expect(pHasFooWithBar.Update(e)).To(BeTrue(), "HasAnnotationPredicate should return true if the annotation is there and has the fitting value") + Expect(pGotFoo.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if the annotation was there before") + Expect(pGotFooWithFoo.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if the annotation was changed but to the wrong value") + Expect(pGotFooWithBar.Update(e)).To(BeTrue(), "GotAnnotationPredicate should return true if the annotation was changed to the correct value") + Expect(pLostFoo.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if the annotation was there before and is still there") + Expect(pLostFooWithFoo.Update(e)).To(BeTrue(), "LostAnnotationPredicate should return true if the annotation had the correct value before and it was changed") + Expect(pLostFooWithBar.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if the annotation didn't have the correct value before") + By("remove annotation foo") + base = changed.DeepCopy() + changed.SetAnnotations(nil) + e = updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if there are no annotations") + Expect(pHasFooWithFoo.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if there are no annotations") + Expect(pHasFooWithBar.Update(e)).To(BeFalse(), "HasAnnotationPredicate should return false if there are no annotations") + Expect(pGotFoo.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if there are no annotations") + Expect(pGotFooWithFoo.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if there are no annotations") + Expect(pGotFooWithBar.Update(e)).To(BeFalse(), "GotAnnotationPredicate should return false if there are no annotations") + Expect(pLostFoo.Update(e)).To(BeTrue(), "LostAnnotationPredicate should return true if the annotation was there before and got removed") + Expect(pLostFooWithFoo.Update(e)).To(BeFalse(), "LostAnnotationPredicate should return false if the annotation was removed but didn't have the correct value before") + Expect(pLostFooWithBar.Update(e)).To(BeTrue(), "LostAnnotationPredicate should return true if the annotation had the correct value before and was removed") + }) + + }) + + Context("Labels", func() { + + It("should detect changes to the labels", func() { + pHasFoo := ctrlutils.HasLabelPredicate("foo", "") + pHasBar := ctrlutils.HasLabelPredicate("bar", "") + pHasFooWithFoo := ctrlutils.HasLabelPredicate("foo", "foo") + pHasFooWithBar := ctrlutils.HasLabelPredicate("foo", "bar") + pGotFoo := ctrlutils.GotLabelPredicate("foo", "") + pGotFooWithFoo := ctrlutils.GotLabelPredicate("foo", "foo") + pGotFooWithBar := ctrlutils.GotLabelPredicate("foo", "bar") + pLostFoo := ctrlutils.LostLabelPredicate("foo", "") + pLostFooWithFoo := ctrlutils.LostLabelPredicate("foo", "foo") + pLostFooWithBar := ctrlutils.LostLabelPredicate("foo", "bar") + By("old and new resource are equal") + e := updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if there are no labels") + Expect(pHasFooWithFoo.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if there are no labels") + Expect(pHasFooWithBar.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if there are no labels") + Expect(pGotFoo.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if there are no labels") + Expect(pGotFooWithFoo.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if there are no labels") + Expect(pGotFooWithBar.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if there are no labels") + Expect(pLostFoo.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if there never were labels") + Expect(pLostFooWithFoo.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if there never were labels") + Expect(pLostFooWithBar.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if there never were labels") + By("add label foo=foo") + changed.SetLabels(map[string]string{ + "foo": "foo", + }) + e = updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeTrue(), "HasLabelPredicate should return true if the label is there") + Expect(pHasBar.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if the label is not there") + Expect(pHasFooWithFoo.Update(e)).To(BeTrue(), "HasLabelPredicate should return true if the label is there and has the fitting value") + Expect(pHasFooWithBar.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if the label is there but has the wrong value") + Expect(pGotFoo.Update(e)).To(BeTrue(), "GotLabelPredicate should return true if the label was added") + Expect(pGotFooWithFoo.Update(e)).To(BeTrue(), "GotLabelPredicate should return true if the label was added with the correct value") + Expect(pGotFooWithBar.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if the label was added but with the wrong value") + Expect(pLostFoo.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if the label wasn't there before") + Expect(pLostFooWithFoo.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if the label wasn't there before") + Expect(pLostFooWithBar.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if the label wasn't there before") + By("change label to foo=bar") + base = changed.DeepCopy() + changed.SetLabels(map[string]string{ + "foo": "bar", + }) + e = updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeTrue(), "HasLabelPredicate should return true if the label is there") + Expect(pHasBar.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if the label is not there") + Expect(pHasFooWithFoo.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if the label is there but has the wrong value") + Expect(pHasFooWithBar.Update(e)).To(BeTrue(), "HasLabelPredicate should return true if the label is there and has the fitting value") + Expect(pGotFoo.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if the label was there before") + Expect(pGotFooWithFoo.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if the label was changed but to the wrong value") + Expect(pGotFooWithBar.Update(e)).To(BeTrue(), "GotLabelPredicate should return true if the label was changed to the correct value") + Expect(pLostFoo.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if the label was there before and is still there") + Expect(pLostFooWithFoo.Update(e)).To(BeTrue(), "LostLabelPredicate should return true if the label had the correct value before and it was changed") + Expect(pLostFooWithBar.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if the label didn't have the correct value before") + By("remove label foo") + base = changed.DeepCopy() + changed.SetLabels(nil) + e = updateEvent(base, changed) + Expect(pHasFoo.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if there are no labels") + Expect(pHasFooWithFoo.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if there are no labels") + Expect(pHasFooWithBar.Update(e)).To(BeFalse(), "HasLabelPredicate should return false if there are no labels") + Expect(pGotFoo.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if there are no labels") + Expect(pGotFooWithFoo.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if there are no labels") + Expect(pGotFooWithBar.Update(e)).To(BeFalse(), "GotLabelPredicate should return false if there are no labels") + Expect(pLostFoo.Update(e)).To(BeTrue(), "LostLabelPredicate should return true if the label was there before and got removed") + Expect(pLostFooWithFoo.Update(e)).To(BeFalse(), "LostLabelPredicate should return false if the label was removed but didn't have the correct value before") + Expect(pLostFooWithBar.Update(e)).To(BeTrue(), "LostLabelPredicate should return true if the label had the correct value before and was removed") + }) + + }) + + Context("Status", func() { + + It("should detect changes to the status", func() { + p := ctrlutils.StatusChangedPredicate{} + Expect(p.Update(updateEvent(base, changed))).To(BeFalse(), "StatusChangedPredicate should return false if the status did not change") + By("change status") + changed.Status = corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + { + IP: "127.0.0.1", + }, + }, + }, + } + Expect(p.Update(updateEvent(base, changed))).To(BeTrue(), "StatusChangedPredicate should return true if the status changed") + }) + + }) + +}) + +func updateEvent(old, new client.Object) event.UpdateEvent { + return event.UpdateEvent{ + ObjectOld: old, + ObjectNew: new, + } +} diff --git a/pkg/controller/setup.go b/pkg/controller/setup.go new file mode 100644 index 0000000..af41718 --- /dev/null +++ b/pkg/controller/setup.go @@ -0,0 +1,52 @@ +package controller + +import ( + "fmt" + "os" + "path" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// LoadKubeconfig loads a cluster configuration from the given path. +// If the path points to a single file, this file is expected to contain a kubeconfig which is then loaded. +// If the path points to a directory which contains a file named "kubeconfig", that file is used. +// If the path points to a directory which does not contain a "kubeconfig" file, there must be "host", "token", and "ca.crt" files present, +// which are used to configure cluster access based on an OIDC trust relationship. +// If the path is empty, the in-cluster config is returned. +func LoadKubeconfig(configPath string) (*rest.Config, error) { + if configPath == "" { + return rest.InClusterConfig() + } + fi, err := os.Stat(configPath) + if err != nil { + return nil, err + } + if fi.IsDir() { + if kfi, err := os.Stat(path.Join(configPath, "kubeconfig")); err == nil && !kfi.IsDir() { + // there is a kubeconfig file in the specified folder + // point configPath to the kubeconfig + configPath = path.Join(configPath, "kubeconfig") + } else { + // no kubeconfig file present, load OIDC trust configuration + host, err := os.ReadFile(path.Join(configPath, "host")) + if err != nil { + return nil, fmt.Errorf("error reading host file: %w", err) + } + return &rest.Config{ + Host: string(host), + BearerTokenFile: path.Join(configPath, "token"), + TLSClientConfig: rest.TLSClientConfig{ + CAFile: path.Join(configPath, "ca.crt"), + }, + }, nil + } + } + // at this point, configPath points to a single file which is expected to contain a kubeconfig + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + return clientcmd.RESTConfigFromKubeConfig(data) +} diff --git a/pkg/controller/suite_test.go b/pkg/controller/suite_test.go new file mode 100644 index 0000000..71d159d --- /dev/null +++ b/pkg/controller/suite_test.go @@ -0,0 +1,14 @@ +package controller_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestComponentUtils(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Utils Suite") +} diff --git a/pkg/controller/testdata/test-01/ns-foo-annotation.yaml b/pkg/controller/testdata/test-01/ns-foo-annotation.yaml new file mode 100644 index 0000000..8fd8973 --- /dev/null +++ b/pkg/controller/testdata/test-01/ns-foo-annotation.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: foo-annotation + annotations: + foo.bar.baz/foo: "bar" diff --git a/pkg/controller/testdata/test-01/ns-foo-label.yaml b/pkg/controller/testdata/test-01/ns-foo-label.yaml new file mode 100644 index 0000000..897c29d --- /dev/null +++ b/pkg/controller/testdata/test-01/ns-foo-label.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: foo-label + labels: + foo.bar.baz/foo: "bar" diff --git a/pkg/controller/testdata/test-01/ns-no-annotation.yaml b/pkg/controller/testdata/test-01/ns-no-annotation.yaml new file mode 100644 index 0000000..53e0be6 --- /dev/null +++ b/pkg/controller/testdata/test-01/ns-no-annotation.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: no-annotation diff --git a/pkg/controller/testdata/test-01/ns-no-label.yaml b/pkg/controller/testdata/test-01/ns-no-label.yaml new file mode 100644 index 0000000..2e00c54 --- /dev/null +++ b/pkg/controller/testdata/test-01/ns-no-label.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: no-label diff --git a/pkg/envtest/envtest.go b/pkg/envtest/envtest.go new file mode 100644 index 0000000..c099dff --- /dev/null +++ b/pkg/envtest/envtest.go @@ -0,0 +1,99 @@ +package envtest + +import ( + "bytes" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var ( + errFailedToGetWD = errors.New("failed to get working directory") + errMakefileIsDir = errors.New("expected Makefile to be a file but it is a directory") + errMakefileNotFound = errors.New("reached fs root and did not find Makefile") + errFailedToRunMake = errors.New("failed to run make") + errFailedToRunSetupEnvtest = errors.New("failed to run setup-envtest") +) + +// Install uses make to install the envtest dependencies and sets the +// KUBEBUILDER_ASSETS environment variable. +// k8sVersion is the version of Kubernetes to install, e.g. "1.30.0" or "latest". +func Install(k8sVersion string) error { + wd, err := os.Getwd() + if err != nil { + return errors.Join(errFailedToGetWD, err) + } + + makefilePath, err := findMakefile(wd) + if err != nil { + return err + } + repoDir := filepath.Dir(makefilePath) + + if err := runMakeEnvtest(repoDir); err != nil { + return err + } + + assetsDir, err := runSetupEnvtest(repoDir, k8sVersion) + if err != nil { + return err + } + + return os.Setenv("KUBEBUILDER_ASSETS", assetsDir) +} + +func runMakeEnvtest(repoDir string) error { + cmd := exec.Command("make", "envtest") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + return errors.Join(errFailedToRunMake, err) + } + return nil +} + +func runSetupEnvtest(workingDir, k8sVersion string) (string, error) { + binDir := filepath.Join(workingDir, "bin") + binary := filepath.Join(binDir, "setup-envtest") + cmd := exec.Command(binary, "use", k8sVersion, "--bin-dir", binDir, "-p", "path") + stdout := &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = os.Stderr + cmd.Dir = workingDir + if err := cmd.Run(); err != nil { + return "", errors.Join(errFailedToRunSetupEnvtest, err) + } + return strings.TrimSpace(stdout.String()), nil +} + +func findMakefile(root string) (string, error) { + if !filepath.IsAbs(root) { + var err error + if root, err = filepath.Abs(root); err != nil { + return "", err + } + } + + if root == "/" { + return "", errMakefileNotFound + } + + makefilePath := filepath.Join(root, "Makefile") + finfo, err := os.Stat(makefilePath) + if errors.Is(err, fs.ErrNotExist) { + parent := filepath.Dir(root) + return findMakefile(parent) + } + if err != nil { + return "", err + } + if finfo.IsDir() { + return "", errMakefileIsDir + } + + return makefilePath, nil +} diff --git a/pkg/init/crds/flags.go b/pkg/init/crds/flags.go new file mode 100644 index 0000000..2ab6af1 --- /dev/null +++ b/pkg/init/crds/flags.go @@ -0,0 +1,38 @@ +package crds + +import ( + "flag" + "strconv" +) + +type Flags struct { + Install bool + InstallOptions []installOption +} + +//nolint:lll +func BindFlags(fs *flag.FlagSet) *Flags { + result := &Flags{} + fs.BoolVar(&result.Install, "install-crds", false, "Install CRDs") + + fs.BoolFunc("crd-conversion-without-ca", "Do not include CA data in the CRD conversion webhook, e.g. when using a custom URL that has a valid certificate from a well-known CA.", func(s string) error { + val, err := strconv.ParseBool(s) + if val { + result.InstallOptions = append(result.InstallOptions, WithoutCA) + } + return err + }) + + fs.Func("crd-conversion-base-url", "Base URL to the CRD conversion webhook service, e.g. when calling the webhook from another cluster", func(s string) error { + result.InstallOptions = append(result.InstallOptions, WithCustomBaseURL(s)) + return nil + }) + + fs.Func("crd-conversion-service-port", "Port of the CRD conversion webhook service (not the webhook server itself)", func(s string) error { + port, err := strconv.Atoi(s) + result.InstallOptions = append(result.InstallOptions, WithWebhookServicePort(port)) + return err + }) + + return result +} diff --git a/pkg/init/crds/flags_test.go b/pkg/init/crds/flags_test.go new file mode 100644 index 0000000..99b379b --- /dev/null +++ b/pkg/init/crds/flags_test.go @@ -0,0 +1,34 @@ +package crds + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + testValidArgs = []string{ + "-install-crds=true", + "--crd-conversion-without-ca", + "-crd-conversion-base-url=https://webhooks.example.com", + "--crd-conversion-service-port", "1234", + } +) + +func Test_BindFlags(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + flags := BindFlags(fs) + err := fs.Parse(testValidArgs) + + assert.NoError(t, err) + assert.NotNil(t, flags) + assert.Equal(t, &Flags{ + Install: true, + InstallOptions: []installOption{ + WithoutCA, + WithCustomBaseURL("https://webhooks.example.com"), + WithWebhookServicePort(1234), + }, + }, flags) +} diff --git a/pkg/init/crds/init.go b/pkg/init/crds/init.go new file mode 100644 index 0000000..611eeac --- /dev/null +++ b/pkg/init/crds/init.go @@ -0,0 +1,119 @@ +package crds + +import ( + "bytes" + "context" + "embed" + "errors" + "log" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +var ( + errSecretHasNoTLSCert = errors.New("secret is missing " + corev1.TLSCertKey) +) + +const ( + webhookPath = "/convert" +) + +func Install(ctx context.Context, c client.Client, crdFiles embed.FS, options ...installOption) error { + opts := &installOptions{ + localClient: c, + remoteClient: c, + webhookService: getWebhookServiceFromEnv(), + webhookSecret: getWebhookSecretFromEnv(), + webhookServicePort: 443, + } + for _, io := range options { + io.ApplyToInstallOptions(opts) + } + + if !opts.noResolveCA { + secret := &corev1.Secret{} + if err := opts.localClient.Get(ctx, opts.webhookSecret, secret); err != nil { + return err + } + + if _, ok := secret.Data[corev1.TLSCertKey]; !ok { + return errSecretHasNoTLSCert + } + + opts.caData = secret.Data[corev1.TLSCertKey] + } + + // make sure that the client knows about the CustomResourceDefinition type + utilruntime.Must(apiextensionsv1.AddToScheme(c.Scheme())) + + log.Println("Reading CRD files") + contents, err := readAllFiles(crdFiles, ".") + if err != nil { + return err + } + + for _, v := range contents { + crd, err := decodeYaml(v) + if err != nil { + return err + } + + if err := applyCRD(ctx, opts, crd); err != nil { + return err + } + } + return nil +} + +func decodeYaml(yamlBytes []byte) (*apiextensionsv1.CustomResourceDefinition, error) { + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(yamlBytes), 100) + crd := &apiextensionsv1.CustomResourceDefinition{} + return crd, decoder.Decode(crd) +} + +func applyCRD(ctx context.Context, opts *installOptions, crd *apiextensionsv1.CustomResourceDefinition) error { + copy := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: crd.Name, + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, opts.remoteClient, copy, func() error { + copy.Spec = crd.Spec + applyConversionConfig(copy, opts) + return nil + }) + log.Println("CRD", crd.Name, result) + return err +} + +func applyConversionConfig(crd *apiextensionsv1.CustomResourceDefinition, opts *installOptions) { + conv := crd.Spec.Conversion + if conv != nil && conv.Strategy == apiextensionsv1.WebhookConverter { + if conv.Webhook == nil { + conv.Webhook = &apiextensionsv1.WebhookConversion{ConversionReviewVersions: []string{"v1"}} + } + + conv.Webhook.ClientConfig = &apiextensionsv1.WebhookClientConfig{ + CABundle: opts.caData, + } + + if opts.customBaseUrl != nil { + conv.Webhook.ClientConfig.URL = ptr.To(*opts.customBaseUrl + webhookPath) + } else { + conv.Webhook.ClientConfig.Service = &apiextensionsv1.ServiceReference{ + Name: opts.webhookService.Name, + Namespace: opts.webhookService.Namespace, + Path: ptr.To(webhookPath), + Port: &opts.webhookServicePort, + } + } + } +} diff --git a/pkg/init/crds/init_test.go b/pkg/init/crds/init_test.go new file mode 100644 index 0000000..6e15143 --- /dev/null +++ b/pkg/init/crds/init_test.go @@ -0,0 +1,181 @@ +package crds + +import ( + "context" + "embed" + "os" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var ( + //go:embed "testdata/crds" + crdFiles embed.FS +) + +func setEnv() { + os.Setenv("WEBHOOK_SERVICE_NAME", "myservice") + os.Setenv("WEBHOOK_SERVICE_NAMESPACE", "mynamespace") + os.Setenv("WEBHOOK_SECRET_NAME", "mysecret") + os.Setenv("WEBHOOK_SECRET_NAMESPACE", "mynamespace") +} + +func createWebhookSecret(ctx context.Context, c client.Client) error { + setEnv() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysecret", + Namespace: "mynamespace", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("abc"), + corev1.TLSPrivateKeyKey: []byte("def"), + }, + } + return c.Create(ctx, secret) +} + +func Test_Install(t *testing.T) { + testCases := []struct { + desc string + setup func(ctx context.Context, c client.Client) error + validate func(ctx context.Context, c client.Client, t *testing.T, testErr error) error + options []installOption + }{ + { + desc: "should create CRD with and without conversion", + setup: createWebhookSecret, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + crdNoConversion := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crontabs.stable.example.com", + }, + } + err := c.Get(ctx, client.ObjectKeyFromObject(crdNoConversion), crdNoConversion) + assert.NoError(t, err) + assert.Nil(t, crdNoConversion.Spec.Conversion) + + crdConversion := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crontabsconversion.stable.example.com", + }, + } + err = c.Get(ctx, client.ObjectKeyFromObject(crdConversion), crdConversion) + assert.NoError(t, err) + assert.Equal(t, crdConversion.Spec.Conversion, &apiextensionsv1.CustomResourceConversion{ + Strategy: apiextensionsv1.WebhookConverter, + Webhook: &apiextensionsv1.WebhookConversion{ + ConversionReviewVersions: []string{"v1"}, + ClientConfig: &apiextensionsv1.WebhookClientConfig{ + URL: nil, + CABundle: []byte("abc"), + Service: &apiextensionsv1.ServiceReference{ + Name: "myservice", + Namespace: "mynamespace", + Path: ptr.To("/convert"), + Port: ptr.To[int32](443), + }, + }, + }, + }) + + return nil + }, + }, + { + desc: "should create CRD with custom webhook for conversion", + options: []installOption{ + WithWebhookService{Name: "myotherservice", Namespace: "myothernamespace"}, + WithWebhookServicePort(1234), + }, + setup: createWebhookSecret, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + crdConversion := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crontabsconversion.stable.example.com", + }, + } + err := c.Get(ctx, client.ObjectKeyFromObject(crdConversion), crdConversion) + assert.NoError(t, err) + assert.Equal(t, crdConversion.Spec.Conversion, &apiextensionsv1.CustomResourceConversion{ + Strategy: apiextensionsv1.WebhookConverter, + Webhook: &apiextensionsv1.WebhookConversion{ + ConversionReviewVersions: []string{"v1"}, + ClientConfig: &apiextensionsv1.WebhookClientConfig{ + URL: nil, + CABundle: []byte("abc"), + Service: &apiextensionsv1.ServiceReference{ + Name: "myotherservice", + Namespace: "myothernamespace", + Path: ptr.To("/convert"), + Port: ptr.To[int32](1234), + }, + }, + }, + }) + + return nil + }, + }, + { + desc: "should create CRD with custom URL for conversion", + options: []installOption{ + WithCustomBaseURL("https://webhooks.example.com"), + WithoutCA, + }, + setup: createWebhookSecret, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + crdConversion := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crontabsconversion.stable.example.com", + }, + } + err := c.Get(ctx, client.ObjectKeyFromObject(crdConversion), crdConversion) + assert.NoError(t, err) + assert.Equal(t, crdConversion.Spec.Conversion, &apiextensionsv1.CustomResourceConversion{ + Strategy: apiextensionsv1.WebhookConverter, + Webhook: &apiextensionsv1.WebhookConversion{ + ConversionReviewVersions: []string{"v1"}, + ClientConfig: &apiextensionsv1.WebhookClientConfig{ + URL: ptr.To("https://webhooks.example.com/convert"), + CABundle: nil, + Service: nil, + }, + }, + }) + + return nil + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().Build() + ctx := context.Background() + + if err := tC.setup(ctx, c); err != nil { + t.Fatal(err) + } + + testErr := Install(ctx, c, crdFiles, tC.options...) + + if err := tC.validate(ctx, c, t, testErr); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/pkg/init/crds/options.go b/pkg/init/crds/options.go new file mode 100644 index 0000000..faa99d2 --- /dev/null +++ b/pkg/init/crds/options.go @@ -0,0 +1,102 @@ +package crds + +import ( + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// +// CRD Install Options +// + +type installOptions struct { + localClient client.Client + remoteClient client.Client + caData []byte + noResolveCA bool + customBaseUrl *string + webhookService types.NamespacedName + webhookSecret types.NamespacedName + webhookServicePort int32 +} + +type installOption interface { + ApplyToInstallOptions(o *installOptions) +} + +// +// Remote Client +// + +type WithRemoteClient struct { + Client client.Client +} + +func (opt WithRemoteClient) ApplyToInstallOptions(o *installOptions) { + o.remoteClient = opt.Client +} + +// +// Custom Base URL +// + +type WithCustomBaseURL string + +func (opt WithCustomBaseURL) ApplyToInstallOptions(o *installOptions) { + o.customBaseUrl = ptr.To(string(opt)) +} + +// +// Custom Certificate Authority +// + +type WithCustomCA []byte + +func (opt WithCustomCA) ApplyToInstallOptions(o *installOptions) { + o.caData = []byte(opt) + o.noResolveCA = true +} + +// +// Don't resolve Certificate Authority +// + +type withoutCA struct{} + +var WithoutCA = withoutCA{} + +func (withoutCA) ApplyToInstallOptions(o *installOptions) { + o.caData = nil + o.noResolveCA = true +} + +// +// Webhook Service Port +// + +type WithWebhookServicePort int32 + +func (opt WithWebhookServicePort) ApplyToInstallOptions(o *installOptions) { + o.webhookServicePort = int32(opt) +} + +// +// Webhook Secret Reference +// + +type WithWebhookSecret types.NamespacedName + +func (opt WithWebhookSecret) ApplyToInstallOptions(o *installOptions) { + o.webhookSecret = types.NamespacedName(opt) +} + +// +// Webhook Service Reference +// + +type WithWebhookService types.NamespacedName + +func (opt WithWebhookService) ApplyToInstallOptions(o *installOptions) { + o.webhookService = types.NamespacedName(opt) +} diff --git a/pkg/init/crds/testdata/crds/resourcedefinition.yaml b/pkg/init/crds/testdata/crds/resourcedefinition.yaml new file mode 100644 index 0000000..22d891d --- /dev/null +++ b/pkg/init/crds/testdata/crds/resourcedefinition.yaml @@ -0,0 +1,41 @@ +# Source: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + # name must match the spec fields below, and be in the form: . + name: crontabs.stable.example.com +spec: + # group name to use for REST API: /apis// + group: stable.example.com + # list of versions supported by this CustomResourceDefinition + versions: + - name: v1 + # Each version can be enabled/disabled by Served flag. + served: true + # One and only one version must be marked as the storage version. + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + cronSpec: + type: string + image: + type: string + replicas: + type: integer + # either Namespaced or Cluster + scope: Namespaced + names: + # plural name to be used in the URL: /apis/// + plural: crontabs + # singular name to be used as an alias on the CLI and for display + singular: crontab + # kind is normally the CamelCased singular type. Your resource manifests use this. + kind: CronTab + # shortNames allow shorter string to match your resource on the CLI + shortNames: + - ct diff --git a/pkg/init/crds/testdata/crds/resourcedefinition_conversion.yaml b/pkg/init/crds/testdata/crds/resourcedefinition_conversion.yaml new file mode 100644 index 0000000..99ee5ce --- /dev/null +++ b/pkg/init/crds/testdata/crds/resourcedefinition_conversion.yaml @@ -0,0 +1,46 @@ +# Source: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + # name must match the spec fields below, and be in the form: . + name: crontabsconversion.stable.example.com +spec: + conversion: + strategy: "Webhook" + webhook: + conversionReviewVersions: + - "v1" + # group name to use for REST API: /apis// + group: stable.example.com + # list of versions supported by this CustomResourceDefinition + versions: + - name: v1 + # Each version can be enabled/disabled by Served flag. + served: true + # One and only one version must be marked as the storage version. + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + cronSpec: + type: string + image: + type: string + replicas: + type: integer + # either Namespaced or Cluster + scope: Namespaced + names: + # plural name to be used in the URL: /apis/// + plural: crontabs + # singular name to be used as an alias on the CLI and for display + singular: crontab + # kind is normally the CamelCased singular type. Your resource manifests use this. + kind: CronTab + # shortNames allow shorter string to match your resource on the CLI + shortNames: + - ct diff --git a/pkg/init/crds/utils.go b/pkg/init/crds/utils.go new file mode 100644 index 0000000..aa0fe8c --- /dev/null +++ b/pkg/init/crds/utils.go @@ -0,0 +1,52 @@ +package crds + +import ( + "embed" + "os" + "path" + + "k8s.io/apimachinery/pkg/types" +) + +func readAllFiles(fs embed.FS, dir string) ([][]byte, error) { + fileContents := [][]byte{} + + entries, err := fs.ReadDir(dir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + fullpath := path.Join(dir, entry.Name()) + if entry.IsDir() { + subdirContents, err := readAllFiles(fs, fullpath) + if err != nil { + return nil, err + } + fileContents = append(fileContents, subdirContents...) + continue + } + + content, err := fs.ReadFile(fullpath) + if err != nil { + return nil, err + } + fileContents = append(fileContents, content) + } + + return fileContents, nil +} + +func getWebhookServiceFromEnv() types.NamespacedName { + return types.NamespacedName{ + Name: os.Getenv("WEBHOOK_SERVICE_NAME"), + Namespace: os.Getenv("WEBHOOK_SERVICE_NAMESPACE"), + } +} + +func getWebhookSecretFromEnv() types.NamespacedName { + return types.NamespacedName{ + Name: os.Getenv("WEBHOOK_SECRET_NAME"), + Namespace: os.Getenv("WEBHOOK_SECRET_NAMESPACE"), + } +} diff --git a/pkg/init/webhooks/apply.go b/pkg/init/webhooks/apply.go new file mode 100644 index 0000000..78d4299 --- /dev/null +++ b/pkg/init/webhooks/apply.go @@ -0,0 +1,130 @@ +package webhooks + +import ( + "context" + "fmt" + "log" + "strings" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func applyValidatingWebhook(ctx context.Context, opts *installOptions, obj client.Object) error { + gvk, err := apiutil.GVKForObject(obj, opts.scheme) + if err != nil { + return err + } + webhookPath := generateValidatePath(gvk) + + cfg := &admissionregistrationv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateValidateName(gvk), + }, + } + + resource := strings.ToLower(gvk.Kind + "s") + + result, err := controllerutil.CreateOrUpdate(ctx, opts.remoteClient, cfg, func() error { + webhook := admissionregistrationv1.ValidatingWebhook{ + Name: strings.ToLower(fmt.Sprintf("v%s.%s", gvk.Kind, gvk.Group)), + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + AdmissionReviewVersions: []string{"v1"}, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + CABundle: opts.caData, + }, + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + admissionregistrationv1.Update, + admissionregistrationv1.Delete, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{gvk.Group}, + APIVersions: []string{gvk.Version}, + Resources: []string{resource}, + }, + }, + }, + } + + if opts.customBaseUrl != nil { + webhook.ClientConfig.URL = ptr.To(*opts.customBaseUrl + webhookPath) + } else { + webhook.ClientConfig.Service = &admissionregistrationv1.ServiceReference{ + Name: opts.webhookService.Name, + Namespace: opts.webhookService.Namespace, + Path: ptr.To(webhookPath), + Port: &opts.webhookServicePort, + } + } + + cfg.Webhooks = []admissionregistrationv1.ValidatingWebhook{webhook} + return nil + }) + log.Println("Validating webhook config", cfg.Name, result) + return err +} + +func applyMutatingWebhook(ctx context.Context, opts *installOptions, obj client.Object) error { + gvk, err := apiutil.GVKForObject(obj, opts.scheme) + if err != nil { + return err + } + webhookPath := generateMutatePath(gvk) + + cfg := &admissionregistrationv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateMutateName(gvk), + }, + } + + resource := strings.ToLower(gvk.Kind + "s") + + result, err := controllerutil.CreateOrUpdate(ctx, opts.remoteClient, cfg, func() error { + webhook := admissionregistrationv1.MutatingWebhook{ + Name: strings.ToLower(fmt.Sprintf("m%s.%s", gvk.Kind, gvk.Group)), + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + AdmissionReviewVersions: []string{"v1"}, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + CABundle: opts.caData, + }, + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + admissionregistrationv1.Update, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{gvk.Group}, + APIVersions: []string{gvk.Version}, + Resources: []string{resource}, + }, + }, + }, + } + + if opts.customBaseUrl == nil { + webhook.ClientConfig.Service = &admissionregistrationv1.ServiceReference{ + Name: opts.webhookService.Name, + Namespace: opts.webhookService.Namespace, + Path: ptr.To(webhookPath), + Port: &opts.webhookServicePort, + } + } else { + webhook.ClientConfig.URL = ptr.To(*opts.customBaseUrl + webhookPath) + } + + cfg.Webhooks = []admissionregistrationv1.MutatingWebhook{webhook} + return nil + }) + log.Println("Mutating webhook config", cfg.Name, result) + return err +} diff --git a/pkg/init/webhooks/certs.go b/pkg/init/webhooks/certs.go new file mode 100644 index 0000000..8832448 --- /dev/null +++ b/pkg/init/webhooks/certs.go @@ -0,0 +1,98 @@ +package webhooks + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math" + "math/big" + "time" + + "k8s.io/apimachinery/pkg/types" +) + +type generatedCert struct { + privateKey []byte + publicKey []byte + expiresAt time.Time +} + +func generateCert(webhookService types.NamespacedName, additionalDNSNames []string) (*generatedCert, error) { + serial, err := rand.Int(rand.Reader, new(big.Int).SetInt64(math.MaxInt64)) + if err != nil { + return nil, err + } + + dnsNames := getServiceCertDNSNames(webhookService) + dnsNames = append(dnsNames, additionalDNSNames...) + expiresAt := time.Now().Add(10 * 365 * 24 * time.Hour).UTC() // 10 years + cert := x509.Certificate{ + Subject: pkix.Name{ + CommonName: dnsNames[0], + }, + DNSNames: dnsNames, + NotBefore: time.Now().UTC(), + NotAfter: expiresAt, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + SerialNumber: serial, + } + + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, err + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &cert, &cert, &key.PublicKey, key) + if err != nil { + return nil, err + } + + encodedKey, err := encodePrivateKey(key) + if err != nil { + return nil, err + } + + encodedCert, err := encodeCertificate(certBytes) + if err != nil { + return nil, err + } + + return &generatedCert{ + privateKey: encodedKey, + publicKey: encodedCert, + expiresAt: expiresAt, + }, nil +} + +func getServiceCertDNSNames(webhookService types.NamespacedName) []string { + return []string{ + fmt.Sprintf("%s.%s.svc", webhookService.Name, webhookService.Namespace), + fmt.Sprintf("%s.%s.svc.cluster.local", webhookService.Name, webhookService.Namespace), + } +} + +func encodePrivateKey(key any) ([]byte, error) { + keyBytes, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + keyPEM := &bytes.Buffer{} + err = pem.Encode(keyPEM, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyBytes, + }) + return keyPEM.Bytes(), err +} + +func encodeCertificate(certBytes []byte) ([]byte, error) { + certPEM := &bytes.Buffer{} + err := pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + return certPEM.Bytes(), err +} diff --git a/pkg/init/webhooks/flags.go b/pkg/init/webhooks/flags.go new file mode 100644 index 0000000..27d9b07 --- /dev/null +++ b/pkg/init/webhooks/flags.go @@ -0,0 +1,66 @@ +package webhooks + +import ( + "flag" + "strconv" + "strings" +) + +const ( + defaultPort = 9443 +) + +type Flags struct { + Install bool + InstallOptions []installOption + CertOptions []certOption + BindHost string + BindPort int +} + +//nolint:lll +func BindFlags(fs *flag.FlagSet) *Flags { + result := &Flags{ + BindPort: defaultPort, + } + fs.BoolVar(&result.Install, "install-webhooks", false, "Install webhooks") + + fs.BoolFunc("webhooks-without-ca", "Do not include CA data in the webhooks, e.g. when using a custom URL that has a valid certificate from a well-known CA.", func(s string) error { + val, err := strconv.ParseBool(s) + if val { + result.InstallOptions = append(result.InstallOptions, WithoutCA) + } + return err + }) + + fs.Func("webhooks-base-url", "Base URL to the webhooks service, e.g. when calling the webhook from another cluster", func(s string) error { + result.InstallOptions = append(result.InstallOptions, WithCustomBaseURL(s)) + return nil + }) + + fs.Func("webhooks-service-port", "Port of the webhooks service (not the webhook server itself)", func(s string) error { + port, err := strconv.Atoi(s) + result.InstallOptions = append(result.InstallOptions, WithWebhookServicePort(port)) + return err + }) + + fs.Func("webhooks-additional-sans", "Additional Subject Alternative Names (SANs) that should be added to the self-signed webhook certificate. Multiple domains can be specified as comma-separated values.", func(s string) error { + result.CertOptions = append(result.CertOptions, WithAdditionalDNSNames(strings.Split(s, ","))) + return nil + }) + + fs.Func("webhooks-bind-address", "The address the webhook endpoint binds to.", func(s string) error { + host, portStr, found := strings.Cut(s, ":") + result.BindHost = host + if found { + port, err := strconv.Atoi(portStr) + if err != nil { + return err + } + result.BindPort = port + } + return nil + }) + + return result +} diff --git a/pkg/init/webhooks/flags_test.go b/pkg/init/webhooks/flags_test.go new file mode 100644 index 0000000..f0bd3a6 --- /dev/null +++ b/pkg/init/webhooks/flags_test.go @@ -0,0 +1,41 @@ +package webhooks + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + testValidArgs = []string{ + "-install-webhooks=true", + "--webhooks-without-ca", + "-webhooks-base-url=https://webhooks.example.com", + "--webhooks-service-port", "1234", + "-webhooks-additional-sans", "webhooks.example.com,webhooks.example.org", + "--webhooks-bind-address=someaddr:4567", + } +) + +func Test_BindFlags(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + flags := BindFlags(fs) + err := fs.Parse(testValidArgs) + + assert.NoError(t, err) + assert.NotNil(t, flags) + assert.Equal(t, &Flags{ + Install: true, + InstallOptions: []installOption{ + WithoutCA, + WithCustomBaseURL("https://webhooks.example.com"), + WithWebhookServicePort(1234), + }, + CertOptions: []certOption{ + WithAdditionalDNSNames{"webhooks.example.com", "webhooks.example.org"}, + }, + BindHost: "someaddr", + BindPort: 4567, + }, flags) +} diff --git a/pkg/init/webhooks/init.go b/pkg/init/webhooks/init.go new file mode 100644 index 0000000..fb27d71 --- /dev/null +++ b/pkg/init/webhooks/init.go @@ -0,0 +1,118 @@ +package webhooks + +import ( + "context" + "errors" + "log" + + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +var ( + errSecretHasNoTLSCert = errors.New("secret is missing " + corev1.TLSCertKey) +) + +// GenerateCertificate +func GenerateCertificate(ctx context.Context, c client.Client, options ...certOption) error { + opts := &certOptions{ + webhookService: getWebhookServiceFromEnv(), + webhookSecret: getWebhookSecretFromEnv(), + } + for _, co := range options { + co.ApplyToCertOptions(opts) + } + + log.Printf("Webhook Service: %+v", opts.webhookService) + log.Printf("Webhook Secret: %+v", opts.webhookSecret) + + secret := &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: opts.webhookSecret.Name, + Namespace: opts.webhookSecret.Namespace, + }, + } + if err := c.Get(ctx, client.ObjectKeyFromObject(secret), secret); client.IgnoreNotFound(err) != nil { + return err + } + + if _, ok := secret.Data[corev1.TLSCertKey]; ok { + // cert exists + log.Println("Webhook secret exists. Doing nothing.") + return nil + } + + log.Println("Generating webhook certificate...") + cert, err := generateCert(opts.webhookService, opts.additionalDNSNames) + if err != nil { + return err + } + + log.Println("Storing webhook certificate in secret...") + result, err := controllerutil.CreateOrUpdate(ctx, c, secret, func() error { + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + secret.Data[corev1.TLSPrivateKeyKey] = cert.privateKey + secret.Data[corev1.TLSCertKey] = cert.publicKey + return nil + }) + log.Println("Webhook secret", client.ObjectKeyFromObject(secret), result) + return err +} + +func Install( + ctx context.Context, + c client.Client, + scheme *runtime.Scheme, + apiTypes []client.Object, + options ...installOption, +) error { + opts := &installOptions{ + localClient: c, + remoteClient: c, + scheme: scheme, + webhookService: getWebhookServiceFromEnv(), + webhookSecret: getWebhookSecretFromEnv(), + webhookServicePort: 443, + } + for _, io := range options { + io.ApplyToInstallOptions(opts) + } + + if !opts.noResolveCA { + secret := &corev1.Secret{} + if err := opts.localClient.Get(ctx, opts.webhookSecret, secret); err != nil { + return err + } + + if _, ok := secret.Data[corev1.TLSCertKey]; !ok { + return errSecretHasNoTLSCert + } + + opts.caData = secret.Data[corev1.TLSCertKey] + } + + for _, o := range apiTypes { + _, isCustomValidator := o.(webhook.CustomValidator) + if isCustomValidator { + if err := applyValidatingWebhook(ctx, opts, o); err != nil { + return err + } + } + _, isCustomDefaulter := o.(webhook.CustomDefaulter) + if isCustomDefaulter { + if err := applyMutatingWebhook(ctx, opts, o); err != nil { + return err + } + } + } + + log.Println("Webhooks initialized") + return nil +} diff --git a/pkg/init/webhooks/init_test.go b/pkg/init/webhooks/init_test.go new file mode 100644 index 0000000..e1e8f5a --- /dev/null +++ b/pkg/init/webhooks/init_test.go @@ -0,0 +1,340 @@ +package webhooks + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func setEnv() { + os.Setenv("WEBHOOK_SERVICE_NAME", "myservice") + os.Setenv("WEBHOOK_SERVICE_NAMESPACE", "mynamespace") + os.Setenv("WEBHOOK_SECRET_NAME", "mysecret") + os.Setenv("WEBHOOK_SECRET_NAMESPACE", "mynamespace") +} + +func Test_GenerateCertificate(t *testing.T) { + testCases := []struct { + desc string + setup func(ctx context.Context, c client.Client) error + validate func(ctx context.Context, c client.Client, t *testing.T, testErr error) error + options []certOption + }{ + { + desc: "should generate certificate", + setup: func(ctx context.Context, c client.Client) error { + setEnv() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mynamespace", + }, + } + return c.Create(ctx, ns) + }, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysecret", + Namespace: "mynamespace", + }, + } + if err := c.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { + return err + } + + assert.NotEmpty(t, secret.Data[corev1.TLSCertKey]) + assert.NotEmpty(t, secret.Data[corev1.TLSPrivateKeyKey]) + return nil + }, + }, + { + desc: "should generate certificate with custom object names", + options: []certOption{ + WithWebhookSecret{Name: "myothersecret", Namespace: "myothernamespace"}, + WithWebhookService{Name: "myotherservice", Namespace: "myothernamespace"}, + WithAdditionalDNSNames{"some.other.name.example.com"}, + }, + setup: func(ctx context.Context, c client.Client) error { + setEnv() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myothernamespace", + }, + } + return c.Create(ctx, ns) + }, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myothersecret", + Namespace: "myothernamespace", + }, + } + if err := c.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { + return err + } + + assert.NotEmpty(t, secret.Data[corev1.TLSCertKey]) + assert.NotEmpty(t, secret.Data[corev1.TLSPrivateKeyKey]) + return nil + }, + }, + { + desc: "should not override existing certificate", + setup: func(ctx context.Context, c client.Client) error { + setEnv() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mynamespace", + }, + } + if err := c.Create(ctx, ns); err != nil { + return err + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysecret", + Namespace: "mynamespace", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("abc"), + corev1.TLSPrivateKeyKey: []byte("def"), + }, + } + return c.Create(ctx, secret) + }, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysecret", + Namespace: "mynamespace", + }, + } + if err := c.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { + return err + } + + assert.Equal(t, []byte("abc"), secret.Data[corev1.TLSCertKey]) + assert.Equal(t, []byte("def"), secret.Data[corev1.TLSPrivateKeyKey]) + return nil + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().Build() + ctx := context.Background() + + if err := tC.setup(ctx, c); err != nil { + t.Fatal(err) + } + + testErr := GenerateCertificate(ctx, c, tC.options...) + + if err := tC.validate(ctx, c, t, testErr); err != nil { + t.Fatal(err) + } + }) + } +} + +func Test_Install(t *testing.T) { + testCases := []struct { + desc string + setup func(ctx context.Context, c client.Client) error + validate func(ctx context.Context, c client.Client, t *testing.T, testErr error) error + options []installOption + }{ + { + desc: "should create webhook configurations for TestObj", + setup: func(ctx context.Context, c client.Client) error { + setEnv() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysecret", + Namespace: "mynamespace", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("abc"), + corev1.TLSPrivateKeyKey: []byte("def"), + }, + } + return c.Create(ctx, secret) + }, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + vwc := &admissionregistrationv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateValidateName(testObjGVK), + }, + } + err := c.Get(ctx, client.ObjectKeyFromObject(vwc), vwc) + assert.NoError(t, err) + assert.Len(t, vwc.Webhooks, 1) + assert.Equal(t, vwc.Webhooks[0].ClientConfig.CABundle, []byte("abc")) + assert.Equal(t, vwc.Webhooks[0].ClientConfig.Service, &admissionregistrationv1.ServiceReference{ + Name: "myservice", + Namespace: "mynamespace", + Path: ptr.To(generateValidatePath(testObjGVK)), + Port: ptr.To[int32](443), + }) + + mwc := &admissionregistrationv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateMutateName(testObjGVK), + }, + } + err = c.Get(ctx, client.ObjectKeyFromObject(mwc), mwc) + assert.NoError(t, err) + assert.Len(t, mwc.Webhooks, 1) + assert.Equal(t, mwc.Webhooks[0].ClientConfig.CABundle, []byte("abc")) + assert.Equal(t, mwc.Webhooks[0].ClientConfig.Service, &admissionregistrationv1.ServiceReference{ + Name: "myservice", + Namespace: "mynamespace", + Path: ptr.To(generateMutatePath(testObjGVK)), + Port: ptr.To[int32](443), + }) + + return nil + }, + }, + { + desc: "should create webhook configurations for TestObj with custom values", + options: []installOption{ + WithoutCA, + WithCustomBaseURL("https://webhooks.example.com"), + }, + setup: func(ctx context.Context, c client.Client) error { return nil }, + validate: func(ctx context.Context, c client.Client, t *testing.T, testErr error) error { + assert.NoError(t, testErr) + + vwc := &admissionregistrationv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateValidateName(testObjGVK), + }, + } + err := c.Get(ctx, client.ObjectKeyFromObject(vwc), vwc) + assert.NoError(t, err) + assert.Len(t, vwc.Webhooks, 1) + assert.Nil(t, vwc.Webhooks[0].ClientConfig.CABundle) + assert.Nil(t, vwc.Webhooks[0].ClientConfig.Service) + assert.Equal(t, *vwc.Webhooks[0].ClientConfig.URL, "https://webhooks.example.com"+generateValidatePath(testObjGVK)) + + mwc := &admissionregistrationv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: generateMutateName(testObjGVK), + }, + } + err = c.Get(ctx, client.ObjectKeyFromObject(mwc), mwc) + assert.NoError(t, err) + assert.Len(t, mwc.Webhooks, 1) + assert.Len(t, mwc.Webhooks, 1) + assert.Nil(t, mwc.Webhooks[0].ClientConfig.CABundle) + assert.Nil(t, mwc.Webhooks[0].ClientConfig.Service) + assert.Equal(t, *mwc.Webhooks[0].ClientConfig.URL, "https://webhooks.example.com"+generateMutatePath(testObjGVK)) + + return nil + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().Build() + c.Scheme().AddKnownTypes(groupVersion, &TestObj{}) + ctx := context.Background() + + if err := tC.setup(ctx, c); err != nil { + t.Fatal(err) + } + + testErr := Install(ctx, c, c.Scheme(), []client.Object{&TestObj{}}, tC.options...) + + if err := tC.validate(ctx, c, t, testErr); err != nil { + t.Fatal(err) + } + }) + } +} + +var ( + groupVersion = schema.GroupVersion{Group: "example.org", Version: "v1alpha1"} + testObjGVK = groupVersion.WithKind("TestObj") +) + +var _ client.Object = &TestObj{} +var _ webhook.CustomValidator = &TestObj{} +var _ webhook.CustomDefaulter = &TestObj{} + +type TestObj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` +} + +// Default implements admission.Defaulter. +func (*TestObj) Default(_ context.Context, _ runtime.Object) error { + panic("unimplemented") +} + +// ValidateCreate implements admission.Validator. +func (*TestObj) ValidateCreate(_ context.Context, _ runtime.Object) (warnings admission.Warnings, err error) { + panic("unimplemented") +} + +// ValidateDelete implements admission.Validator. +func (*TestObj) ValidateDelete(_ context.Context, _ runtime.Object) (warnings admission.Warnings, err error) { + panic("unimplemented") +} + +// ValidateUpdate implements admission.Validator. +func (*TestObj) ValidateUpdate(_ context.Context, _ runtime.Object, _ runtime.Object) (warnings admission.Warnings, err error) { + panic("unimplemented") +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestObj) DeepCopyInto(out *TestObj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoffeeBean. +func (in *TestObj) DeepCopy() *TestObj { + if in == nil { + return nil + } + out := new(TestObj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestObj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/init/webhooks/options.go b/pkg/init/webhooks/options.go new file mode 100644 index 0000000..b1be813 --- /dev/null +++ b/pkg/init/webhooks/options.go @@ -0,0 +1,136 @@ +package webhooks + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// +// Webhook Install Options +// + +type installOptions struct { + localClient client.Client + scheme *runtime.Scheme + remoteClient client.Client + caData []byte + noResolveCA bool + customBaseUrl *string + webhookService types.NamespacedName + webhookSecret types.NamespacedName + webhookServicePort int32 +} + +type installOption interface { + ApplyToInstallOptions(o *installOptions) +} + +// +// Certificate Generation Options +// + +type certOptions struct { + webhookService types.NamespacedName + webhookSecret types.NamespacedName + additionalDNSNames []string +} + +type certOption interface { + ApplyToCertOptions(o *certOptions) +} + +// +// Remote Client +// + +type WithRemoteClient struct { + Client client.Client +} + +func (opt WithRemoteClient) ApplyToInstallOptions(o *installOptions) { + o.remoteClient = opt.Client +} + +// +// Custom Base URL +// + +type WithCustomBaseURL string + +func (opt WithCustomBaseURL) ApplyToInstallOptions(o *installOptions) { + o.customBaseUrl = ptr.To(string(opt)) +} + +// +// Custom Certificate Authority +// + +type WithCustomCA []byte + +func (opt WithCustomCA) ApplyToInstallOptions(o *installOptions) { + o.caData = []byte(opt) + o.noResolveCA = true +} + +// +// Don't resolve Certificate Authority +// + +type withoutCA struct{} + +var WithoutCA = withoutCA{} + +func (withoutCA) ApplyToInstallOptions(o *installOptions) { + o.caData = nil + o.noResolveCA = true +} + +// +// Webhook Service Port +// + +type WithWebhookServicePort int32 + +func (opt WithWebhookServicePort) ApplyToInstallOptions(o *installOptions) { + o.webhookServicePort = int32(opt) +} + +// +// Webhook Secret Reference +// + +type WithWebhookSecret types.NamespacedName + +func (opt WithWebhookSecret) ApplyToInstallOptions(o *installOptions) { + o.webhookSecret = types.NamespacedName(opt) +} + +func (opt WithWebhookSecret) ApplyToCertOptions(o *certOptions) { + o.webhookSecret = types.NamespacedName(opt) +} + +// +// Webhook Service Reference +// + +type WithWebhookService types.NamespacedName + +func (opt WithWebhookService) ApplyToInstallOptions(o *installOptions) { + o.webhookService = types.NamespacedName(opt) +} + +func (opt WithWebhookService) ApplyToCertOptions(o *certOptions) { + o.webhookService = types.NamespacedName(opt) +} + +// +// Additional DNS Names +// + +type WithAdditionalDNSNames []string + +func (opt WithAdditionalDNSNames) ApplyToCertOptions(o *certOptions) { + o.additionalDNSNames = opt +} diff --git a/pkg/init/webhooks/utils.go b/pkg/init/webhooks/utils.go new file mode 100644 index 0000000..878e8e1 --- /dev/null +++ b/pkg/init/webhooks/utils.go @@ -0,0 +1,43 @@ +package webhooks + +import ( + "os" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +func generateMutatePath(gvk schema.GroupVersionKind) string { + return "/mutate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" + + gvk.Version + "-" + strings.ToLower(gvk.Kind) +} + +func generateMutateName(gvk schema.GroupVersionKind) string { + return "mutate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" + + gvk.Version + "-" + strings.ToLower(gvk.Kind) +} + +func generateValidatePath(gvk schema.GroupVersionKind) string { + return "/validate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" + + gvk.Version + "-" + strings.ToLower(gvk.Kind) +} + +func generateValidateName(gvk schema.GroupVersionKind) string { + return "validate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" + + gvk.Version + "-" + strings.ToLower(gvk.Kind) +} + +func getWebhookServiceFromEnv() types.NamespacedName { + return types.NamespacedName{ + Name: os.Getenv("WEBHOOK_SERVICE_NAME"), + Namespace: os.Getenv("WEBHOOK_SERVICE_NAMESPACE"), + } +} + +func getWebhookSecretFromEnv() types.NamespacedName { + return types.NamespacedName{ + Name: os.Getenv("WEBHOOK_SECRET_NAME"), + Namespace: os.Getenv("WEBHOOK_SECRET_NAMESPACE"), + } +} diff --git a/pkg/init/webhooks/utils_test.go b/pkg/init/webhooks/utils_test.go new file mode 100644 index 0000000..19012c5 --- /dev/null +++ b/pkg/init/webhooks/utils_test.go @@ -0,0 +1,51 @@ +package webhooks + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + testGVK = schema.GroupVersionKind{ + Group: "example.com", + Version: "v2", + Kind: "Project", + } +) + +func Test_generate_funcs(t *testing.T) { + testCases := []struct { + desc string + funcToTest func(gvk schema.GroupVersionKind) string + expected string + }{ + { + desc: "generateMutatePath should return expected string", + funcToTest: generateMutatePath, + expected: "/mutate-example-com-v2-project", + }, + { + desc: "generateMutateName should return expected string", + funcToTest: generateMutateName, + expected: "mutate-example-com-v2-project", + }, + { + desc: "generateValidatePath should return expected string", + funcToTest: generateValidatePath, + expected: "/validate-example-com-v2-project", + }, + { + desc: "generateValidateName should return expected string", + funcToTest: generateValidateName, + expected: "validate-example-com-v2-project", + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + actual := tC.funcToTest(testGVK) + assert.Equal(t, tC.expected, actual) + }) + } +} diff --git a/pkg/logging/config.go b/pkg/logging/config.go new file mode 100644 index 0000000..3f69927 --- /dev/null +++ b/pkg/logging/config.go @@ -0,0 +1,141 @@ +package logging + +import ( + flag "github.com/spf13/pflag" + "go.uber.org/zap" +) + +type Config struct { + flagset *flag.FlagSet + + Development bool + Cli bool + DisableStacktrace bool + DisableCaller bool + DisableTimestamp bool + Level logLevelValue + Format logFormatValue +} + +func InitFlags(flagset *flag.FlagSet) { + if flagset == nil { + flagset = flag.CommandLine + } + fs := flag.NewFlagSet("log", flag.ExitOnError) + + fs.BoolVar(&configFromFlags.Development, "dev", false, "enable development logging") + fs.BoolVar(&configFromFlags.Cli, "cli", false, "use CLI formatting for logs (color, no timestamps)") + f := fs.VarPF(&configFromFlags.Format, "format", "f", "logging format [text, json]") + f.DefValue = "text if either dev or cli flag is set, json otherwise" + f = fs.VarPF(&configFromFlags.Level, "verbosity", "v", "logging verbosity [error, info, debug]") + f.DefValue = "info, or debug if dev flag is set" + fs.BoolVar(&configFromFlags.DisableStacktrace, "disable-stacktrace", true, "disable the stacktrace of error logs") + fs.BoolVar(&configFromFlags.DisableCaller, "disable-caller", true, "disable the caller of logs") + fs.BoolVar(&configFromFlags.DisableTimestamp, "disable-timestamp", false, "disable timestamp output") + + configFromFlags.flagset = fs + flagset.AddFlagSet(configFromFlags.flagset) +} + +// SetLogLevel sets the logging verbosity according to the provided flag if the flag was provided +func (c *Config) SetLogLevel(zapCfg *zap.Config) { + if !c.Level.IsUnset() { + zapCfg.Level = zap.NewAtomicLevelAt(toZapLevel(c.Level.Value())) + } +} + +// SetLogFormat sets the logging format according to the provided flag if the flag was provided +func (c *Config) SetLogFormat(zapCfg *zap.Config) { + if !c.Format.IsUnset() { + zapCfg.Encoding = toZapFormat(c.Format.Value()) + } +} + +// SetDisableStacktrace dis- or enables the stackstrace according to the provided flag if the flag was provided +func (c *Config) SetDisableStacktrace(zapCfg *zap.Config) { + if c.flagset != nil && c.flagset.Changed("disable-stacktrace") { + zapCfg.DisableStacktrace = c.DisableStacktrace + } +} + +// SetDisableCaller dis- or enables the caller according to the provided flag if the flag was provided +func (c *Config) SetDisableCaller(zapCfg *zap.Config) { + if c.flagset != nil && c.flagset.Changed("disable-caller") { + zapCfg.DisableCaller = c.DisableCaller + } +} + +// SetTimestamp dis- or enables the logging of timestamps according to the provided flag if the flag was provided +func (c *Config) SetTimestamp(zapCfg *zap.Config) { + if c.flagset != nil && c.flagset.Changed("disable-timestamp") { + if c.DisableTimestamp { + zapCfg.EncoderConfig.TimeKey = "" + } else { + zapCfg.EncoderConfig.TimeKey = "ts" + } + } +} + +// logLevelValue implements the Value interface for LogLevel +type logLevelValue struct { + internal LogLevel +} + +func (l *logLevelValue) String() string { + return l.internal.String() +} +func (l *logLevelValue) Set(raw string) error { + lvl, err := ParseLogLevel(raw) + if err != nil { + return err + } + l.internal = lvl + return nil +} +func (l *logLevelValue) Type() string { + return "LogLevel" +} +func (l *logLevelValue) Value() LogLevel { + return l.internal +} +func (l *logLevelValue) IsUnset() bool { + return l.internal == unknown_level +} +func (c *Config) WithLogLevel(l LogLevel) *Config { + c.Level = logLevelValue{ + internal: l, + } + return c +} + +// logFormatValue implements the Value interface for LogFormat +type logFormatValue struct { + internal LogFormat +} + +func (l *logFormatValue) String() string { + return l.internal.String() +} +func (l *logFormatValue) Set(raw string) error { + f, err := ParseLogFormat(raw) + if err != nil { + return err + } + l.internal = f + return nil +} +func (l *logFormatValue) Type() string { + return "LogFormat" +} +func (l *logFormatValue) Value() LogFormat { + return l.internal +} +func (l *logFormatValue) IsUnset() bool { + return l.internal == unknown_format +} +func (c *Config) WithLogFormat(f LogFormat) *Config { + c.Format = logFormatValue{ + internal: f, + } + return c +} diff --git a/pkg/logging/helper.go b/pkg/logging/helper.go new file mode 100644 index 0000000..51044c4 --- /dev/null +++ b/pkg/logging/helper.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2019 SAP SE or an SAP affiliate company and Gardener contributors. +// +// SPDX-License-Identifier: Apache-2.0 + +package logging + +import ( + "fmt" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/util/sets" +) + +var _ logr.LogSink = KeyConflictPreventionLayer{} + +const conflictModifierFormatString = "%s_conflict(%d)" + +// KeyConflictPreventionLayer is a helper struct. It implements logr.LogSink by containing a LogSink internally, +// to which all method calls are forwarded. The only purpose of this struct is to detect duplicate keys for logr.WithValues +// and replace them to avoid conflicts. +type KeyConflictPreventionLayer struct { + logr.LogSink + keys sets.Set[string] +} + +func (kcpl KeyConflictPreventionLayer) Init(info logr.RuntimeInfo) { + if kcpl.LogSink == nil { + return + } + kcpl.LogSink.Init(info) +} + +func (kcpl KeyConflictPreventionLayer) Enabled(level int) bool { + return kcpl.LogSink != nil && kcpl.LogSink.Enabled(level) +} + +// PreventKeyConflicts takes a logr.Logger and wraps a KeyConflictPreventionLayer around its LogSink. +// It is already used by the logging framework's constructors and will likely not have to be called from outside the package. +// Mainly exported for testing purposes. +func PreventKeyConflicts(log logr.Logger) logr.Logger { + return logr.New(KeyConflictPreventionLayer{ + LogSink: log.GetSink(), + keys: sets.New[string](), + }) +} +func (kcpl KeyConflictPreventionLayer) wrapKeyConflictLayer(sink logr.LogSink) logr.LogSink { + return KeyConflictPreventionLayer{ + LogSink: sink, + keys: sets.New[string](kcpl.keys.UnsortedList()...), + } +} + +func (kcpl KeyConflictPreventionLayer) Info(level int, msg string, keysAndValues ...interface{}) { + if kcpl.LogSink == nil { + return + } + kcpl.WithValues(keysAndValues...).(KeyConflictPreventionLayer).LogSink.Info(level, msg) +} + +func (kcpl KeyConflictPreventionLayer) Error(err error, msg string, keysAndValues ...interface{}) { + if kcpl.LogSink == nil { + return + } + kcpl.WithValues(keysAndValues...).(KeyConflictPreventionLayer).LogSink.Error(err, msg) +} + +// WithValues works as usual, but it will replace keys which already exist with a suffixed version indicating the conflict. +func (kcpl KeyConflictPreventionLayer) WithValues(keysAndValues ...interface{}) logr.LogSink { + if kcpl.LogSink == nil { + return nil + } + var newKeysAndValues []interface{} // lazy copying - if the slice needs to be changed, we have to copy it + finalKeysAndValues := keysAndValues + keyset := sets.New[string](kcpl.keys.UnsortedList()...) + for i := 0; i < len(keysAndValues); i += 2 { + key, isString := keysAndValues[i].(string) + if !isString { + // non-string keys cannot be checked + continue + } + suffixCount := 1 + newKey := key + for keyset.Has(newKey) { + newKey = fmt.Sprintf(conflictModifierFormatString, key, suffixCount) + suffixCount++ + } + if newKey != key { + if len(newKeysAndValues) == 0 { + // initialize copy slice + newKeysAndValues = make([]interface{}, len(keysAndValues)) + copy(newKeysAndValues, keysAndValues) + finalKeysAndValues = newKeysAndValues + } + newKeysAndValues[i] = newKey + } + keyset.Insert(newKey) + } + return KeyConflictPreventionLayer{ + LogSink: kcpl.LogSink.WithValues(finalKeysAndValues...), + keys: keyset, + } +} + +func (kcpl KeyConflictPreventionLayer) WithName(name string) logr.LogSink { + if kcpl.LogSink == nil { + return nil + } + return kcpl.wrapKeyConflictLayer(kcpl.LogSink.WithName(name)) +} diff --git a/pkg/logging/implementation.go b/pkg/logging/implementation.go new file mode 100644 index 0000000..f7cf585 --- /dev/null +++ b/pkg/logging/implementation.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2019 SAP SE or an SAP affiliate company and Gardener contributors. +// +// SPDX-License-Identifier: Apache-2.0 + +package logging + +import ( + "github.com/go-logr/zapr" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var ( + Log Logger + configFromFlags = Config{} +) + +func encoderConfig() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + TimeKey: "ts", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } +} + +func applyCLIEncoding(ecfg zapcore.EncoderConfig) zapcore.EncoderConfig { + ecfg.TimeKey = "" + ecfg.EncodeLevel = zapcore.LowercaseColorLevelEncoder + return ecfg +} + +func defaultConfig() zap.Config { + return zap.Config{ + Level: zap.NewAtomicLevelAt(toZapLevel(INFO)), + Development: false, + Encoding: toZapFormat(TEXT), + DisableStacktrace: true, + DisableCaller: true, + EncoderConfig: encoderConfig(), + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, + } +} + +func applyCLIConfig(cfg zap.Config) zap.Config { + cfg.EncoderConfig = applyCLIEncoding(cfg.EncoderConfig) + return cfg +} + +func applyDevConfig(cfg zap.Config) zap.Config { + cfg.DisableCaller = false + cfg.DisableStacktrace = false + cfg.Development = true + cfg.Level = zap.NewAtomicLevelAt(toZapLevel(DEBUG)) + return cfg +} + +func applyProductionConfig(cfg zap.Config) zap.Config { + cfg.Encoding = toZapFormat(JSON) + return cfg +} + +func New(config *Config) (Logger, error) { + if config == nil { + config = &configFromFlags + } + zapCfg := determineZapConfig(config) + + zapLog, err := zapCfg.Build(zap.AddCallerSkip(1)) + if err != nil { + return Logger{}, err + } + return Wrap(PreventKeyConflicts(zapr.NewLogger(zapLog))), nil +} + +// GetLogger returns a singleton logger. +// Will initialize a new logger, if it doesn't exist yet. +func GetLogger() (Logger, error) { + if Log.IsInitialized() { + return Log, nil + } + log, err := New(nil) + if err != nil { + return Logger{}, err + } + SetLogger(log) + return log, nil +} + +func SetLogger(log Logger) { + Log = log +} + +// NewCliLogger creates a new logger for cli usage. +// CLI usage means that by default: +// - encoding is console +// - timestamps are disabled (can be still activated by the cli flag) +// - level are color encoded +func NewCliLogger() (Logger, error) { + config := &configFromFlags + config.Cli = true + return New(config) +} + +func determineZapConfig(loggerConfig *Config) zap.Config { + zapConfig := defaultConfig() + if loggerConfig.Cli || loggerConfig.Development { + if loggerConfig.Cli { + zapConfig = applyCLIConfig(zapConfig) + } + if loggerConfig.Development { + zapConfig = applyDevConfig(zapConfig) + } + } else { + zapConfig = applyProductionConfig(zapConfig) + } + + loggerConfig.SetLogLevel(&zapConfig) + loggerConfig.SetLogFormat(&zapConfig) + loggerConfig.SetDisableCaller(&zapConfig) + loggerConfig.SetDisableStacktrace(&zapConfig) + loggerConfig.SetTimestamp(&zapConfig) + + return zapConfig +} + +func levelToVerbosity(level LogLevel) int { + var res int + switch level { + case DEBUG: + res = int(zap.DebugLevel) + case ERROR: + res = int(zap.ErrorLevel) + default: + res = int(zap.InfoLevel) + } + return res * -1 +} + +// toZapLevel converts our LogLevel into a zap Level. +// Unknown LogLevels are silently treated as INFO. +func toZapLevel(l LogLevel) zapcore.Level { + switch l { + case DEBUG: + return zap.DebugLevel + case ERROR: + return zap.ErrorLevel + default: + return zap.InfoLevel + } +} + +// toZapFormat converts our LogFormat into a zap encoding. +// Unknown LogFormats are silently treated as TEXT. +func toZapFormat(f LogFormat) string { + switch f { + case JSON: + return "json" + default: + return "console" + } +} diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go new file mode 100644 index 0000000..7120116 --- /dev/null +++ b/pkg/logging/logger.go @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Gardener contributors. +// +// SPDX-License-Identifier: Apache-2.0 + +package logging + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" +) + +type Logger struct { + internal logr.Logger + initialized bool +} + +type LogLevel int + +const ( + unknown_level LogLevel = iota // dummy value to detect if not set + ERROR + INFO + DEBUG +) + +func (l LogLevel) String() string { + switch l { + case ERROR: + return "ERROR" + case INFO: + return "INFO" + case DEBUG: + return "DEBUG" + } + return "UNKNOWN" +} + +func ParseLogLevel(raw string) (LogLevel, error) { + upper := strings.ToUpper(raw) + switch upper { + case "ERROR": + return ERROR, nil + case "INFO": + return INFO, nil + case "DEBUG": + return DEBUG, nil + } + return INFO, fmt.Errorf("unknown log level '%s', valid values are: [%s] (case-insensitive)", raw, strings.Join([]string{ERROR.String(), INFO.String(), DEBUG.String()}, ", ")) +} + +type LogFormat int + +const ( + unknown_format LogFormat = iota + TEXT + JSON +) + +func (f LogFormat) String() string { + switch f { + case TEXT: + return "TEXT" + case JSON: + return "JSON" + } + return "UNKNOWN" +} + +func ParseLogFormat(raw string) (LogFormat, error) { + upper := strings.ToUpper(raw) + switch upper { + case "TEXT": + return TEXT, nil + case "JSON": + return JSON, nil + } + return TEXT, fmt.Errorf("unknown log format '%s', valid values are: [%s] (case-insensitive)", raw, strings.Join([]string{TEXT.String(), JSON.String()}, ", ")) +} + +// LOGR WRAPPER FUNCTIONS + +// Enabled tests whether logging at the provided level is enabled. +// This deviates from the logr Enabled() function, which doesn't take an argument. +func (l Logger) Enabled(lvl LogLevel) bool { + return l.internal.GetSink() != nil && l.internal.GetSink().Enabled(levelToVerbosity(lvl)) +} + +// Info logs a non-error message with the given key/value pairs as context. +// +// The msg argument should be used to add some constant description to +// the log line. The key/value pairs can then be used to add additional +// variable information. The key/value pairs should alternate string +// keys and arbitrary values. +func (l Logger) Info(msg string, keysAndValues ...interface{}) { + l.internal.V(levelToVerbosity(INFO)).Info(msg, keysAndValues...) +} + +// Error logs an error, with the given message and key/value pairs as context. +// It functions similarly to calling Info with the "error" named value, but may +// have unique behavior, and should be preferred for logging errors (see the +// package documentations for more information). +// +// The msg field should be used to add context to any underlying error, +// while the err field should be used to attach the actual error that +// triggered this log line, if present. +func (l Logger) Error(err error, msg string, keysAndValues ...interface{}) { + l.internal.Error(err, msg, keysAndValues...) +} + +// WithValues adds some key-value pairs of context to a logger. +// See Info for documentation on how key/value pairs work. +func (l Logger) WithValues(keysAndValues ...interface{}) Logger { + return Wrap(l.internal.WithValues(keysAndValues...)) +} + +// WithName adds a new element to the logger's name. +// Successive calls with WithName continue to append +// suffixes to the logger's name. It's strongly recommended +// that name segments contain only letters, digits, and hyphens +// (see the package documentation for more information). +func (l Logger) WithName(name string) Logger { + return Wrap(l.internal.WithName(name)) +} + +// FromContext wraps the result of logr.FromContext into a logging.Logger. +func FromContext(ctx context.Context) (Logger, error) { + log, err := logr.FromContext(ctx) + return Wrap(log), err +} + +// FromContextOrDiscard works like FromContext, but it will return a discard logger if no logger is found in the context. +func FromContextOrDiscard(ctx context.Context) Logger { + log, err := FromContext(ctx) + if err != nil { + return Discard() + } + return log +} + +// NewContext is a wrapper for logr.NewContext. +func NewContext(ctx context.Context, log Logger) context.Context { + return logr.NewContext(ctx, log.Logr()) +} + +// Discard is a wrapper for logr.Discard. +func Discard() Logger { + return Wrap(logr.Discard()) +} + +// ADDITIONAL FUNCTIONS + +// Wrap constructs a new Logger, using the provided logr.Logger internally. +func Wrap(log logr.Logger) Logger { + return Logger{internal: log, initialized: true} +} + +// FromContextOrNew tries to fetch a logger from the context. +// It is expected that a logger is contained in the context. If retrieving it fails, a new logger will be created and an error is logged. +// keysAndValuesFallback contains keys and values which will only be added if the logger could not be retrieved and a new one had to be created. +// The key-value-pairs from keysAndValues will always be added. +// A new context, containing the created logger, will be returned. +// The function panics if the logger cannot be fetched from the context and creating a new one fails. +func FromContextOrNew(ctx context.Context, keysAndValuesFallback []interface{}, keysAndValues ...interface{}) (Logger, context.Context) { + log, err := FromContext(ctx) + if err != nil { + newLogger, err2 := GetLogger() + if err2 != nil { + panic(err2) + } + + newLogger = newLogger.WithValues(keysAndValuesFallback...).WithValues(keysAndValues...) + newLogger.Info("unable to fetch logger from context", "error", err2) + ctx = NewContext(ctx, newLogger) + return newLogger, ctx + } + if len(keysAndValues) > 0 { + log = log.WithValues(keysAndValues...) + ctx = NewContext(ctx, log) + } + return log, ctx +} + +// FromContextWithFallback tries to fetch a logger from the context. +// If that fails, the provided fallback logger is used instead. +// It returns the fetched logger, enriched with the given key-value-pairs, and a context containing this new logger. +func FromContextWithFallback(ctx context.Context, fallback Logger, keysAndValues ...interface{}) (Logger, context.Context) { + log, err := FromContext(ctx) + if err != nil { + log = fallback + } + log = log.WithValues(keysAndValues...) + ctx = NewContext(ctx, log) + return log, ctx +} + +// FromContextOrPanic tries to fetch a logger from the context. +// If that fails, the function panics. +func FromContextOrPanic(ctx context.Context) Logger { + log, err := FromContext(ctx) + if err != nil { + panic(fmt.Errorf("logger could not be fetched from context: %w", err)) + } + return log +} + +// NewContextWithDiscard adds a discard logger to the given context and returns the new context. +func NewContextWithDiscard(ctx context.Context) context.Context { + return NewContext(ctx, Discard()) +} + +// Debug logs a message at DEBUG level. +func (l Logger) Debug(msg string, keysAndValues ...interface{}) { + l.internal.V(levelToVerbosity(DEBUG)).Info(msg, keysAndValues...) +} + +// Log logs at the given log level. It can be used to log at dynamically determined levels. +func (l Logger) Log(lvl LogLevel, msg string, keysAndValues ...interface{}) { + switch lvl { + case ERROR: + l.Error(nil, msg, keysAndValues...) + case DEBUG: + l.Debug(msg, keysAndValues...) + default: + l.Info(msg, keysAndValues...) + } +} + +// IsInitialized returns true if the logger is ready to be used and +// false if it is an 'empty' logger (e.g. created by Logger{}). +func (l Logger) IsInitialized() bool { + return l.initialized +} + +// Logr returns the internal logr.Logger. +func (l Logger) Logr() logr.Logger { + return l.internal +} + +// WithValuesAndContext works like WithValues, but also adds the logger directly to a context and returns the new context. +func (l Logger) WithValuesAndContext(ctx context.Context, keysAndValues ...interface{}) (Logger, context.Context) { + log := l.WithValues(keysAndValues...) + ctx = NewContext(ctx, log) + return log, ctx +} + +// WithNameAndContext works like WithName, but also adds the logger directly to a context and returns the new context. +func (l Logger) WithNameAndContext(ctx context.Context, name string) (Logger, context.Context) { + log := l.WithName(name) + ctx = NewContext(ctx, log) + return log, ctx +} diff --git a/pkg/logging/logging_suite_test.go b/pkg/logging/logging_suite_test.go new file mode 100644 index 0000000..3d717ce --- /dev/null +++ b/pkg/logging/logging_suite_test.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Gardener contributors. +// +// SPDX-License-Identifier: Apache-2.0 + +package logging_test + +import ( + "reflect" + "testing" + + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/logging" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Installations Test Suite") +} + +var _ = Describe("Logging Framework Tests", func() { + + It("should not modify the logger if any method is called", func() { + compareToLogger := logging.Wrap(logging.PreventKeyConflicts(logr.Discard())) + log := logging.Wrap(logging.PreventKeyConflicts(logr.Discard())) + Expect(reflect.DeepEqual(log, compareToLogger)).To(BeTrue()) + + log.Debug("foo", "bar", "baz", "bar", "baz") + Expect(reflect.DeepEqual(log, compareToLogger)).To(BeTrue(), "calling log.Debug should not modify the logger") + + log.Info("foo", "bar", "baz", "bar", "baz") + Expect(reflect.DeepEqual(log, compareToLogger)).To(BeTrue(), "calling log.Info should not modify the logger") + + log.Error(nil, "foo", "bar", "baz", "bar", "baz") + Expect(reflect.DeepEqual(log, compareToLogger)).To(BeTrue(), "calling log.Error should not modify the logger") + + log.WithName("myname") + Expect(reflect.DeepEqual(log, compareToLogger)).To(BeTrue(), "calling log.WithName should not modify the logger") + + log.WithValues("foo", "bar") + Expect(reflect.DeepEqual(log, compareToLogger)).To(BeTrue(), "calling log.WithValues should not modify the logger") + }) + +}) diff --git a/pkg/testing/complex_environment.go b/pkg/testing/complex_environment.go new file mode 100644 index 0000000..2bc0f5f --- /dev/null +++ b/pkg/testing/complex_environment.go @@ -0,0 +1,398 @@ +package testing + +import ( + "context" + "fmt" + "path" + "reflect" + "time" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/openmcp-project/controller-utils/pkg/logging" +) + +///////////////// +/// CONSTANTS /// +///////////////// + +func DefaultScheme() *runtime.Scheme { + sc := runtime.NewScheme() + err := clientgoscheme.AddToScheme(sc) + if err != nil { + panic(err) + } + return sc +} + +/////////////////////////// +/// COMPLEX ENVIRONMENT /// +/////////////////////////// + +// ComplexEnvironment helps with testing controllers. +// Construct a new ComplexEnvironment via its builder using NewEnvironmentBuilder(). +type ComplexEnvironment struct { + Ctx context.Context + Log logging.Logger + Clusters map[string]client.Client + Reconcilers map[string]reconcile.Reconciler +} + +// Client returns the cluster client for the cluster with the given name. +func (e *ComplexEnvironment) Client(name string) client.Client { + return e.Clusters[name] +} + +// Reconciler returns the reconciler with the given name. +func (e *ComplexEnvironment) Reconciler(name string) reconcile.Reconciler { + return e.Reconcilers[name] +} + +// ShouldReconcile calls the given reconciler with the given request and expects no error. +func (e *ComplexEnvironment) ShouldReconcile(reconciler string, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + return e.shouldReconcile(reconciler, req, optionalDescription...) +} + +func (e *ComplexEnvironment) shouldReconcile(reconciler string, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + res, err := e.Reconcilers[reconciler].Reconcile(e.Ctx, req) + ExpectWithOffset(2, err).ToNot(HaveOccurred(), optionalDescription...) + return res +} + +// ShouldEventuallyReconcile calls the given reconciler with the given request and retries until no error occurred or the timeout is reached. +func (e *ComplexEnvironment) ShouldEventuallyReconcile(reconciler string, req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result { + return e.shouldEventuallyReconcile(reconciler, req, timeout, poll, optionalDescription...) +} + +func (e *ComplexEnvironment) shouldEventuallyReconcile(reconciler string, req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result { + var err error + var res reconcile.Result + EventuallyWithOffset(1, func() error { + res, err = e.Reconcilers[reconciler].Reconcile(e.Ctx, req) + return err + }, timeout, poll).Should(Succeed(), optionalDescription...) + return res +} + +// ShouldNotReconcile calls the given reconciler with the given request and expects an error. +func (e *ComplexEnvironment) ShouldNotReconcile(reconciler string, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + return e.shouldNotReconcile(reconciler, req, optionalDescription...) +} + +func (e *ComplexEnvironment) shouldNotReconcile(reconciler string, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + res, err := e.Reconcilers[reconciler].Reconcile(e.Ctx, req) + ExpectWithOffset(2, err).To(HaveOccurred(), optionalDescription...) + return res +} + +// ShouldEventuallyNotReconcile calls the given reconciler with the given request and retries until an error occurred or the timeout is reached. +func (e *ComplexEnvironment) ShouldEventuallyNotReconcile(reconciler string, req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result { + return e.shouldEventuallyNotReconcile(reconciler, req, timeout, poll, optionalDescription...) +} + +func (e *ComplexEnvironment) shouldEventuallyNotReconcile(reconciler string, req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result { + var err error + var res reconcile.Result + EventuallyWithOffset(1, func() error { + res, err = e.Reconcilers[reconciler].Reconcile(e.Ctx, req) + return err + }, timeout, poll).ShouldNot(Succeed(), optionalDescription...) + return res +} + +/////////////////////////////////// +/// COMPLEX ENVIRONMENT BUILDER /// +/////////////////////////////////// + +type ReconcilerConstructorForMultipleClusters func(...client.Client) reconcile.Reconciler + +type ComplexEnvironmentBuilder struct { + internal *ComplexEnvironment + Clusters map[string]*ClusterEnvironment + Reconcilers map[string]*ReconcilerEnvironment + ClusterInitObjects map[string][]client.Object + ClusterStatusObjects map[string][]client.Object + ClusterInitObjectPaths map[string][]string + ClientCreationCallbacks map[string][]func(client.Client) + loggerIsSet bool +} + +type ClusterEnvironment struct { + // Client is the client for accessing the cluster. + Client client.Client + // Scheme is the scheme used by the client. + Scheme *runtime.Scheme + // FakeClientBuilderMethodCalls are the method calls that should be made on the fake.ClientBuilder during client creation. + FakeClientBuilderMethodCalls []FakeClientBuilderMethodCall +} + +type ReconcilerEnvironment struct { + // Reconciler is the reconciler to be tested. + // Takes precedence over ReconcilerConstructor. + Reconciler reconcile.Reconciler + // ReconcilerConstructor is a function that provides the reconciler to be tested. + // If the Reconciler field is set, this field is ignored. + ReconcilerConstructor ReconcilerConstructorForMultipleClusters + // Targets references the names clusterEnvironments, which represent the clusters that the controller interacts with. + // Has no effect if the Reconciler is set directly. + Targets []string +} + +// FakeClientBuilderMethodCall represents a method call on a fake.ClientBuilder. +type FakeClientBuilderMethodCall struct { + // Method is the name of the method that should be called. + Method string + // Args are the arguments that should be passed to the method. + // They will be passed in in the same order as they are listed here. + Args []any +} + +// NewComplexEnvironmentBuilder creates a new EnvironmentBuilder. +func NewComplexEnvironmentBuilder() *ComplexEnvironmentBuilder { + return &ComplexEnvironmentBuilder{ + internal: &ComplexEnvironment{}, + Clusters: map[string]*ClusterEnvironment{}, + Reconcilers: map[string]*ReconcilerEnvironment{}, + ClusterInitObjects: map[string][]client.Object{}, + ClusterStatusObjects: map[string][]client.Object{}, + ClusterInitObjectPaths: map[string][]string{}, + ClientCreationCallbacks: map[string][]func(client.Client){}, + } +} + +// WithContext sets the context for the environment. +func (eb *ComplexEnvironmentBuilder) WithContext(ctx context.Context) *ComplexEnvironmentBuilder { + eb.internal.Ctx = ctx + return eb +} + +// WithLogger sets the logger for the environment. +// If the context is not set, the logger is injected into a new context. +func (eb *ComplexEnvironmentBuilder) WithLogger(logger logging.Logger) *ComplexEnvironmentBuilder { + eb.internal.Log = logger + eb.loggerIsSet = true + return eb +} + +// WithFakeClient sets a fake client for the cluster with the given name. +// If no specific scheme is required, set it to nil or DefaultScheme(). +// You should use either WithFakeClient or WithClient for each cluster, but not both. +func (eb *ComplexEnvironmentBuilder) WithFakeClient(name string, scheme *runtime.Scheme) *ComplexEnvironmentBuilder { + _, ok := eb.Clusters[name] + if !ok { + eb.Clusters[name] = &ClusterEnvironment{} + } + if scheme == nil { + scheme = DefaultScheme() + } + eb.Clusters[name].Client = nil + eb.Clusters[name].Scheme = scheme + return eb +} + +// WithClient sets the client for the cluster with the given name. +// You should use either WithFakeClient or WithClient for each cluster, but not both. +func (eb *ComplexEnvironmentBuilder) WithClient(name string, client client.Client) *ComplexEnvironmentBuilder { + _, ok := eb.Clusters[name] + if !ok { + eb.Clusters[name] = &ClusterEnvironment{} + } + eb.Clusters[name].Client = client + eb.Clusters[name].Scheme = client.Scheme() + return eb +} + +// WithInitObjects sets the initial objects for the cluster with the given name. +// If the objects should be loaded from files, use WithInitObjectPath instead. +// If both are specified, the resulting object lists are concatenated. +// Has no effect if the client for the respective cluster is passed in directly. +func (eb *ComplexEnvironmentBuilder) WithInitObjects(name string, objects ...client.Object) *ComplexEnvironmentBuilder { + eb.ClusterInitObjects[name] = append(eb.ClusterInitObjects[name], objects...) + return eb +} + +// WithDynamicObjectsWithStatus enables the status subresource for the given objects for the cluster with the given name. +// All objects that are created on a cluster during a test (after Build() has been called) and where interaction with the object's status is desired must be added here, +// otherwise the fake client will return that no status was found for the object. +func (eb *ComplexEnvironmentBuilder) WithDynamicObjectsWithStatus(name string, objects ...client.Object) *ComplexEnvironmentBuilder { + eb.ClusterStatusObjects[name] = append(eb.ClusterStatusObjects[name], objects...) + return eb +} + +// WithInitObjectPath adds a path to the list of paths from which to load initial objects for the cluster with the given name. +// If the objects should be specified directly, use WithInitObjects instead. +// If both are specified, the resulting object lists are concatenated. +// Note that this function concatenates all arguments into a single path, if you want to load files from multiple paths, call this function multiple times. +// Has no effect if the client for the respective cluster is passed in directly. +func (eb *ComplexEnvironmentBuilder) WithInitObjectPath(name string, pathSegments ...string) *ComplexEnvironmentBuilder { + eb.ClusterInitObjectPaths[name] = append(eb.ClusterInitObjectPaths[name], path.Join(pathSegments...)) + return eb +} + +// WithReconciler sets the reconciler for the given name. +func (eb *ComplexEnvironmentBuilder) WithReconciler(name string, reconciler reconcile.Reconciler) *ComplexEnvironmentBuilder { + _, ok := eb.Reconcilers[name] + if !ok { + eb.Reconcilers[name] = &ReconcilerEnvironment{} + } + eb.Reconcilers[name].Reconciler = reconciler + return eb +} + +// WithReconcilerConstructor sets the constructor for the Reconciler for the given name. +// The Reconciler will be constructed during Build() from the given function and the clients retrieved from the passed in targets list. +// The clients are passed in the function in the same order as they are listed in the targets list. +func (eb *ComplexEnvironmentBuilder) WithReconcilerConstructor(name string, constructor ReconcilerConstructorForMultipleClusters, targets ...string) *ComplexEnvironmentBuilder { + _, ok := eb.Reconcilers[name] + if !ok { + eb.Reconcilers[name] = &ReconcilerEnvironment{} + } + eb.Reconcilers[name].ReconcilerConstructor = constructor + eb.Reconcilers[name].Targets = targets + return eb +} + +// WithAfterClientCreationCallback adds a callback function that will be called with the client with the given name as argument after the client has been created (during Build()). +func (eb *ComplexEnvironmentBuilder) WithAfterClientCreationCallback(name string, callback func(client.Client)) *ComplexEnvironmentBuilder { + eb.ClientCreationCallbacks[name] = append(eb.ClientCreationCallbacks[name], callback) + return eb +} + +// WithFakeClientBuilderCall allows to inject method calls to fake.ClientBuilder when the fake clients are created during Build(). +// The fake clients are usually created using WithScheme(...).WithObjects(...).WithStatusSubresource(...).Build(). +// This function allows to inject additional method calls. It is only required for advanced use-cases. +// The method calls are executed using reflection, so take care to not make any mistakes with the spelling of the method name or the order or type of the arguments. +// Has no effect if the client for the respective cluster is passed in directly (and thus no fake client is constructed). +func (eb *ComplexEnvironmentBuilder) WithFakeClientBuilderCall(name string, method string, args ...any) *ComplexEnvironmentBuilder { + _, ok := eb.Clusters[name] + if !ok { + eb.Clusters[name] = &ClusterEnvironment{} + } + eb.Clusters[name].FakeClientBuilderMethodCalls = append(eb.Clusters[name].FakeClientBuilderMethodCalls, FakeClientBuilderMethodCall{ + Method: method, + Args: args, + }) + return eb +} + +// Build constructs the environment from the builder. +// Note that this function panics instead of throwing an error, +// as it is intended to be used in tests, where all information is static anyway. +func (eb *ComplexEnvironmentBuilder) Build() *ComplexEnvironment { + res := eb.internal + + // initialize logger + if !eb.loggerIsSet { + log, err := logging.GetLogger() + if err != nil { + panic(fmt.Errorf("error getting logger: %w", err)) + } + res.Log = log + } + ctrl.SetLogger(res.Log.Logr()) + + // initialize context + if res.Ctx == nil { + res.Ctx = logging.NewContext(context.Background(), res.Log) + } + + // initialize clusters + if res.Clusters == nil { + res.Clusters = map[string]client.Client{} + } + for name, ce := range eb.Clusters { + if ce == nil { + panic(fmt.Errorf("no ClusterEnvironment set for cluster '%s'", name)) + } + if ce.Client != nil { + if ce.Scheme == nil { + // infer scheme from client + ce.Scheme = ce.Client.Scheme() + } + } else { + if ce.Scheme == nil { + ce.Scheme = DefaultScheme() + } + // create fake client + fcb := fake.NewClientBuilder().WithScheme(ce.Scheme) + objs := []client.Object{} + if len(eb.ClusterInitObjectPaths) > 0 { + // load objects from paths + for _, p := range eb.ClusterInitObjectPaths[name] { + objects, err := LoadObjects(p, ce.Scheme) + if err != nil { + panic(fmt.Errorf("error loading objects for cluster '%s' from path '%s': %w", name, p, err)) + } + objs = append(objs, objects...) + } + } + if len(eb.ClusterInitObjects) > 0 { + objs = append(objs, eb.ClusterInitObjects[name]...) + } + statusObjs := []client.Object{} + statusObjs = append(statusObjs, objs...) + statusObjs = append(statusObjs, eb.ClusterStatusObjects[name]...) + fcb.WithObjects(objs...).WithStatusSubresource(statusObjs...) + for _, call := range ce.FakeClientBuilderMethodCalls { + method := reflect.ValueOf(fcb).MethodByName(call.Method) + if !method.IsValid() { + panic(fmt.Errorf("method '%s' not found on fake.ClientBuilder", call.Method)) + } + args := make([]reflect.Value, len(call.Args)) + for i, arg := range call.Args { + args[i] = reflect.ValueOf(arg) + } + method.Call(args) + } + ce.Client = fcb.Build() + } + res.Clusters[name] = ce.Client + } + + // initialize reconcilers + if res.Reconcilers == nil { + res.Reconcilers = map[string]reconcile.Reconciler{} + } + for name, re := range eb.Reconcilers { + if re == nil { + continue + } + if re.Reconciler == nil { + if re.ReconcilerConstructor == nil { + panic(fmt.Errorf("no ReconcilerConstructor set for reconciler '%s'", name)) + } + if len(re.Targets) == 0 { + panic(fmt.Errorf("no target cluster set for reconciler '%s'", name)) + } + targets := make([]client.Client, len(re.Targets)) + for i, target := range re.Targets { + var ok bool + targets[i], ok = res.Clusters[target] + if !ok { + panic(fmt.Errorf("unknown target cluster '%s' specified for reconciler '%s'", target, name)) + } + } + re.Reconciler = re.ReconcilerConstructor(targets...) + } + res.Reconcilers[name] = re.Reconciler + } + + // call client creation callbacks + for name, callbacks := range eb.ClientCreationCallbacks { + client, ok := res.Clusters[name] + if !ok { + panic(fmt.Errorf("no client found for cluster '%s' to call client creation callbacks", name)) + } + for _, callback := range callbacks { + callback(client) + } + } + + return res +} diff --git a/pkg/testing/environment.go b/pkg/testing/environment.go new file mode 100644 index 0000000..ace6cb1 --- /dev/null +++ b/pkg/testing/environment.go @@ -0,0 +1,173 @@ +package testing + +import ( + "context" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/openmcp-project/controller-utils/pkg/logging" +) + +const ( + SimpleEnvironmentDefaultKey = "default" +) + +////////////////////////// +/// SIMPLE ENVIRONMENT /// +////////////////////////// + +// Environment is a wrapper around ComplexEnvironment. +// It is meant to ease the usage for simple use-cases (meaning only one reconciler and only one cluster). +// Use the EnvironmentBuilder to construct a new Environment. +type Environment struct { + *ComplexEnvironment +} + +// Client returns the client for the cluster. +func (e *Environment) Client() client.Client { + return e.ComplexEnvironment.Client(SimpleEnvironmentDefaultKey) +} + +// Reconciler returns the reconciler. +func (e *Environment) Reconciler() reconcile.Reconciler { + return e.ComplexEnvironment.Reconciler(SimpleEnvironmentDefaultKey) +} + +// ShouldReconcile calls the given reconciler with the given request and expects no error. +func (e *Environment) ShouldReconcile(req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + return e.shouldReconcile(SimpleEnvironmentDefaultKey, req, optionalDescription...) +} + +// ShouldEventuallyReconcile calls the given reconciler with the given request and retries until no error occurred or the timeout is reached. +func (e *Environment) ShouldEventuallyReconcile(req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result { + return e.shouldEventuallyReconcile(SimpleEnvironmentDefaultKey, req, timeout, poll, optionalDescription...) +} + +// ShouldNotReconcile calls the given reconciler with the given request and expects an error. +func (e *Environment) ShouldNotReconcile(req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + return e.shouldNotReconcile(SimpleEnvironmentDefaultKey, req, optionalDescription...) +} + +// ShouldEventuallyNotReconcile calls the given reconciler with the given request and retries until an error occurred or the timeout is reached. +func (e *Environment) ShouldEventuallyNotReconcile(req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result { + return e.shouldEventuallyNotReconcile(SimpleEnvironmentDefaultKey, req, timeout, poll, optionalDescription...) +} + +////////////////////////////////// +/// SIMPLE ENVIRONMENT BUILDER /// +////////////////////////////////// + +type ReconcilerConstructor func(client.Client) reconcile.Reconciler + +type EnvironmentBuilder struct { + *ComplexEnvironmentBuilder +} + +// NewEnvironmentBuilder creates a new SimpleEnvironmentBuilder. +// Use this to construct a SimpleEnvironment, if you need only one reconciler and one cluster. +// For more complex test scenarios, use NewEnvironmentBuilder() instead. +func NewEnvironmentBuilder() *EnvironmentBuilder { + res := &EnvironmentBuilder{ + ComplexEnvironmentBuilder: NewComplexEnvironmentBuilder(), + } + res.WithFakeClient(nil) + return res +} + +// WithContext sets the context for the environment. +func (eb *EnvironmentBuilder) WithContext(ctx context.Context) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithContext(ctx) + return eb +} + +// WithLogger sets the logger for the environment. +// If the context is not set, the logger is injected into a new context. +func (eb *EnvironmentBuilder) WithLogger(log logging.Logger) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithLogger(log) + return eb +} + +// WithFakeClient requests a fake client. +// If no specific scheme is required, set it to nil or DefaultScheme(). +// You should use either WithFakeClient or WithClient, not both. +func (eb *EnvironmentBuilder) WithFakeClient(scheme *runtime.Scheme) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithFakeClient(SimpleEnvironmentDefaultKey, scheme) + return eb +} + +// WithClient sets the client for the cluster. +// If not called, a fake client will be constructed. +func (eb *EnvironmentBuilder) WithClient(client client.Client) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithClient(SimpleEnvironmentDefaultKey, client) + return eb +} + +// WithInitObjects sets the initial objects for the cluster. +// If the objects should be loaded from files, use WithInitObjectPath instead. +// If both are specified, the resulting object lists are concatenated. +// Has no effect if the client for the cluster is set directly. +func (eb *EnvironmentBuilder) WithInitObjects(objects ...client.Object) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithInitObjects(SimpleEnvironmentDefaultKey, objects...) + return eb +} + +// WithDynamicObjectsWithStatus enables the status subresource for the given objects. +// All objects that are created on a cluster during a test (after Build() has been called) and where interaction with the object's status is desired must be added here, +// otherwise the fake client will return that no status was found for the object. +func (eb *EnvironmentBuilder) WithDynamicObjectsWithStatus(objects ...client.Object) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithDynamicObjectsWithStatus(SimpleEnvironmentDefaultKey, objects...) + return eb +} + +// WithInitObjectPath adds a path to the list of paths from which to load initial objects for the cluster. +// If the objects should be specified directly, use WithInitObjects instead. +// If both are specified, the resulting object lists are concatenated. +// Note that this function concatenates all arguments into a single path, if you want to load files from multiple paths, call this function multiple times. +// Has no effect if the client for the cluster is set directly. +func (eb *EnvironmentBuilder) WithInitObjectPath(pathSegments ...string) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithInitObjectPath(SimpleEnvironmentDefaultKey, pathSegments...) + return eb +} + +// WithReconciler sets the reconciler. +// Takes precedence over WithReconcilerConstructor, if both are called. +func (eb *EnvironmentBuilder) WithReconciler(reconciler reconcile.Reconciler) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithReconciler(SimpleEnvironmentDefaultKey, reconciler) + return eb +} + +// WithReconcilerConstructor sets the constructor for the Reconciler. +// The Reconciler will be constructed during Build() using the client from this constructor. +// No effect if the reconciler is set directly via WithReconciler. +func (eb *EnvironmentBuilder) WithReconcilerConstructor(constructor ReconcilerConstructor) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithReconcilerConstructor(SimpleEnvironmentDefaultKey, func(c ...client.Client) reconcile.Reconciler { return constructor(c[0]) }, SimpleEnvironmentDefaultKey) + return eb +} + +// WithAfterClientCreationCallback adds a callback function that will be called with the client as argument after it has been created (during Build()). +func (eb *EnvironmentBuilder) WithAfterClientCreationCallback(callback func(client.Client)) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithAfterClientCreationCallback(SimpleEnvironmentDefaultKey, callback) + return eb +} + +// WithFakeClientBuilderCall allows to inject method calls to fake.ClientBuilder when the fake client is created during Build(). +// The fake client is usually created using WithScheme(...).WithObjects(...).WithStatusSubresource(...).Build(). +// This function allows to inject additional method calls. It is only required for advanced use-cases. +// The method calls are executed using reflection, so take care to not make any mistakes with the spelling of the method name or the order or type of the arguments. +// Has no effect if the client is passed in directly (and thus no fake client is constructed). +func (eb *EnvironmentBuilder) WithFakeClientBuilderCall(method string, args ...any) *EnvironmentBuilder { + eb.ComplexEnvironmentBuilder.WithFakeClientBuilderCall(SimpleEnvironmentDefaultKey, method, args...) + return eb +} + +// Build constructs the environment from the builder. +// Note that this function panics instead of throwing an error, +// as it is intended to be used in tests, where all information is static anyway. +func (eb *EnvironmentBuilder) Build() *Environment { + return &Environment{ + ComplexEnvironment: eb.ComplexEnvironmentBuilder.Build(), + } +} diff --git a/pkg/testing/fake_client.go b/pkg/testing/fake_client.go new file mode 100644 index 0000000..7aff3c1 --- /dev/null +++ b/pkg/testing/fake_client.go @@ -0,0 +1,22 @@ +package testing + +import ( + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// GetFakeClient returns a fake Kubernetes client with the given objects initialized. +func GetFakeClient(scheme *runtime.Scheme, initObjects ...client.Object) (client.WithWatch, error) { + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(initObjects...).WithStatusSubresource(initObjects...).Build(), nil +} + +// GetFakeClientWithDynamicObjects returns a fake Kubernetes client with the given objects initialized. +// Dynamic objects are objects that are not known at compile time, but are create while running the controller. +// The dynamic objects are used to initialize the status subresource. +func GetFakeClientWithDynamicObjects(scheme *runtime.Scheme, dynamicObjects []client.Object, initObjects ...client.Object) (client.WithWatch, error) { + statusObjects := make([]client.Object, 0, len(initObjects)+len(dynamicObjects)) + statusObjects = append(statusObjects, dynamicObjects...) + statusObjects = append(statusObjects, initObjects...) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(initObjects...).WithStatusSubresource(statusObjects...).Build(), nil +} diff --git a/pkg/testing/utils.go b/pkg/testing/utils.go new file mode 100644 index 0000000..8c9720d --- /dev/null +++ b/pkg/testing/utils.go @@ -0,0 +1,140 @@ +package testing + +import ( + "context" + "errors" + "io" + "os" + "path" + "path/filepath" + + "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + yaml2 "sigs.k8s.io/yaml" +) + +// ShouldReconcile calls the given reconciler with the given request and expects no error. +func ShouldReconcile(ctx context.Context, reconciler reconcile.Reconciler, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + res, err := reconciler.Reconcile(ctx, req) + gomega.ExpectWithOffset(1, err).ToNot(gomega.HaveOccurred(), optionalDescription...) + return res +} + +// ShouldNotReconcile calls the given reconciler with the given request and expects an error. +func ShouldNotReconcile(ctx context.Context, reconciler reconcile.Reconciler, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result { + res, err := reconciler.Reconcile(ctx, req) + gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred(), optionalDescription...) + return res +} + +// ExpectRequeue expects the given result to indicate a requeue. +func ExpectRequeue(res reconcile.Result) { + requeue := res.Requeue || res.RequeueAfter > 0 + gomega.ExpectWithOffset(1, requeue).To(gomega.BeTrue()) +} + +// ExpectNoRequeue expects the given result to indicate no requeue. +func ExpectNoRequeue(res reconcile.Result) { + requeue := res.Requeue || res.RequeueAfter > 0 + gomega.ExpectWithOffset(1, requeue).To(gomega.BeFalse()) +} + +// RequestFromObject creates a reconcile.Request from the given object. +func RequestFromObject(obj client.Object) reconcile.Request { + return reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + }, + } +} + +// RequestFromStrings creates a reconcile.Request using the specified name and namespace. +// The first argument is the name of the object. +// An optional second argument contains the namespace. All further arguments are ignored. +func RequestFromStrings(name string, maybeNamespace ...string) reconcile.Request { + namespace := "" + if len(maybeNamespace) > 0 { + namespace = maybeNamespace[0] + } + return reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: namespace, + Name: name, + }, + } +} + +// LoadObject reads a file and unmarshals it into the given object. +// obj must be a non-nil pointer. +func LoadObject(obj any, paths ...string) error { + objRaw, err := os.ReadFile(path.Join(paths...)) + if err != nil { + return err + } + return yaml2.Unmarshal(objRaw, obj) // for some reason doesn't work with the other yaml library -.- +} + +// LoadObjects loads all Kubernetes manifests from the given path and returns them as `client.Object`. +func LoadObjects(p string, scheme *runtime.Scheme) ([]client.Object, error) { + var objectList []client.Object + objDecoder := serializer.NewCodecFactory(scheme).UniversalDeserializer() + + err := filepath.Walk(p, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if !info.IsDir() && filepath.Ext(path) == ".yaml" { + reader, err := os.Open(path) + if err != nil { + return err + } + + yamlDecoder := yaml.NewDecoder(reader) + if yamlDecoder == nil { + return errors.New("failed to create YAML decoder") + } + + for { + var raw map[string]interface{} + err = yamlDecoder.Decode(&raw) + + if raw == nil { + break + } + + if err != nil { + if errors.Is(err, io.EOF) { + break + } else { + return err + } + } + + var data []byte + data, err = yaml.Marshal(raw) + if err != nil { + return err + } + + into := &unstructured.Unstructured{} + obj, _, err := objDecoder.Decode(data, nil, into) + if err != nil { + return err + } + + objectList = append(objectList, obj.(client.Object)) + } + } + + return nil + }) + return objectList, err +} diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..ed1f965 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1 @@ +dot_import_whitelist = ["github.com/onsi/gomega", "github.com/onsi/gomega/gstruct", "github.com/onsi/ginkgo/v2"] \ No newline at end of file