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..616b187 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,34 @@ +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/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8e043a5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,87 @@ +name: Versioned Release + +on: + push: + branches: + - main + +permissions: + contents: write # we need this to be able to push tags + +jobs: + release_tag: + name: Release version + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.PUSH_KEY }} + fetch-tags: true + fetch-depth: 0 + + - name: Read and validate VERSION + id: version + run: | + VERSION=$(cat VERSION) + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-dev)?$ ]]; then + echo "Invalid version format in VERSION file: $VERSION" + exit 1 + fi + echo "New version: $VERSION" + echo "version=$VERSION" >> $GITHUB_ENV + + - name: Skip release if version is a dev version + if: contains(env.version, '-dev') + run: | + echo "Skipping development version release: ${{ env.version }}" + echo "SKIP=true" >> $GITHUB_ENV + exit 0 + + - name: Check if VERSION is already tagged + id: check_tag + run: | + if git rev-parse "refs/tags/${{ env.version }}" >/dev/null 2>&1; then + echo "Tag ${{ env.version }} already exists. Skipping release." + echo "SKIP=true" >> $GITHUB_ENV + exit 0 + fi + echo "Tag ${{ env.version }} doesn't exists. Proceeding with release." + + - name: Create Git tag + if: ${{ env.SKIP != 'true' }} + run: | + AUTHOR_NAME=$(git log -1 --pretty=format:'%an') + AUTHOR_EMAIL=$(git log -1 --pretty=format:'%ae') + echo "Tagging as $AUTHOR_NAME <$AUTHOR_EMAIL>" + + echo "AUTHOR_NAME=$AUTHOR_NAME" >> $GITHUB_ENV + echo "AUTHOR_EMAIL=$AUTHOR_EMAIL" >> $GITHUB_ENV + + git config user.name "$AUTHOR_NAME" + git config user.email "$AUTHOR_EMAIL" + + git tag -a "${{ env.version }}" -m "Release ${{ env.version }}" + git push origin "${{ env.version }}" + + - name: Create GitHub release + if: ${{ env.SKIP != 'true' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.version }} + name: Release ${{ env.version }} + body: "Automated release for version ${{ env.version }}" + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Push dev VERSION + if: ${{ env.SKIP != 'true' }} + run: | + echo "${{ env.version }}-dev" > VERSION + git config user.name "${{ env.AUTHOR_NAME }}" + git config user.email "${{ env.AUTHOR_EMAIL }}" + git add VERSION + git commit -m "Update VERSION to ${{ env.version }}-dev" + git push origin main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b23d75a --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +Dockerfile.cross + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +/**/cover.html +/**/cover.*.html + +# Kubernetes Generated files - skip generated files, except for vendored files + +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +.vscode +*.swp +*.swo +*~ + +# charts +charts/*/charts/*.tgz +/charts/*/templates-original/ +*.orig +*.rej + +go.work* +secrets/ + +tmp/ +*.tmp 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e7f5de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# NOTE: This Dockerfile is used by the pipeline, but not for the 'make image' command, which uses the Dockerfile template in hack/common instead. +# Use distroless as minimal base image to package the component binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot +ARG TARGETOS +ARG TARGETARCH +ARG COMPONENT +WORKDIR / +COPY bin/$COMPONENT-$TARGETOS.$TARGETARCH /$COMPONENT +USER nonroot:nonroot + +ENTRYPOINT ["/$COMPONENT"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b244ce9 --- /dev/null +++ b/Makefile @@ -0,0 +1,241 @@ +PROJECT_FULL_NAME := project-workspace-operator +REPO_ROOT := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +EFFECTIVE_VERSION := $(shell $(REPO_ROOT)/hack/common/get-version.sh) + +COMMON_MAKEFILE ?= $(REPO_ROOT)/hack/common/Makefile +ifneq (,$(wildcard $(COMMON_MAKEFILE))) +include $(COMMON_MAKEFILE) +endif + +# Image URL to use all building/pushing image targets +IMG_VERSION ?= dev +IMG_BASE ?= $(PROJECT_FULL_NAME) +IMG ?= $(IMG_BASE):$(IMG_VERSION) + +# 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 + +COMPONENTS ?= project-workspace-operator +API_CODE_DIRS := $(REPO_ROOT)/api/... +ROOT_CODE_DIRS := $(REPO_ROOT)/cmd/... $(REPO_ROOT)/internal/... + +.PHONY: all +all: build + +##@ General + +ifndef HELP_TARGET +.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) +endif + +##@ Development + +.PHONY: manifests +manifests: controller-gen ## Generate CustomResourceDefinition objects. + @echo "> Remove existing CRD manifests" + @rm -rf api/crds/manifests/ + @rm -rf config/crd/bases/ + @echo "> Generating CRD Manifests" + @$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + @$(CONTROLLER_GEN) crd paths="$(REPO_ROOT)/api/..." output:crd:artifacts:config=api/crds/manifests + +.PHONY: generate +generate: generate-code manifests format ## Generates code (DeepCopy stuff, CRDs), documentation index, and runs formatter. + +.PHONY: generate-code +generate-code: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. Also fetches external APIs. + @echo "> Generating DeepCopy Methods" + @$(CONTROLLER_GEN) object paths="$(REPO_ROOT)/api/..." + +.PHONY: format +format: goimports ## Formats the imports. + @FORMATTER=$(FORMATTER) $(REPO_ROOT)/hack/common/format.sh $(API_CODE_DIRS) $(ROOT_CODE_DIRS) + +.PHONY: verify +verify: golangci-lint goimports ## Runs linter, 'go vet', and checks if the formatter has been run. + @( echo "> Verifying api module ..." && \ + pushd $(REPO_ROOT)/api &>/dev/null && \ + go vet $(API_CODE_DIRS) && \ + $(LINTER) run -c $(REPO_ROOT)/.golangci.yaml $(API_CODE_DIRS) && \ + popd &>/dev/null ) + @( 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/common/format.sh --verify $(API_CODE_DIRS) $(ROOT_CODE_DIRS) ) + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: ## Run tests. + @( echo "> Test root module ..." && \ + pushd $(REPO_ROOT) &>/dev/null && \ + go test $(ROOT_CODE_DIRS) -coverprofile cover.root.out && \ + go tool cover --html=cover.root.out -o cover.root.html && \ + go tool cover -func cover.root.out | tail -n 1 && \ + popd &>/dev/null ) + + @( echo "> Test api module ..." && \ + pushd $(REPO_ROOT)/api &>/dev/null && \ + go test $(API_CODE_DIRS) -coverprofile cover.api.out && \ + go tool cover --html=cover.api.out -o cover.api.html && \ + go tool cover -func cover.api.out | tail -n 1 && \ + popd &>/dev/null ) + +##@ Build + +.PHONY: run +run: manifests generate fmt vet ## Run a controller from your host. + go run ./cmd/project-workspace-operator/main.go + +##@ Deployment + +ifndef ignore-not-found + ignore-not-found = false +endif + +.PHONY: install +install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. + $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - + +.PHONY: uninstall +uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + +.PHONY: deploy +deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - + +.PHONY: undeploy +undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + +##@ Build Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(REPO_ROOT)/bin + +# Tool Binaries +KUSTOMIZE ?= $(LOCALBIN)/kustomize +ENVTEST ?= $(LOCALBIN)/setup-envtest +GOTESTSUM ?= $(LOCALBIN)/gotestsum +KIND ?= kind # fix this to use tools + +# Tool Versions +KUSTOMIZE_VERSION ?= v5.1.1 +SETUP_ENVTEST_VERSION ?= release-0.16 + +ifndef LOCALBIN_TARGET +.PHONY: localbin +localbin: + @test -d $(LOCALBIN) || mkdir -p $(LOCALBIN) +endif + +.PHONY: kustomize +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. If wrong version is installed, it will be removed before downloading. +$(KUSTOMIZE): $(LOCALBIN) + @if test -x $(LOCALBIN)/kustomize && ! $(LOCALBIN)/kustomize version | grep -q $(KUSTOMIZE_VERSION); then \ + echo "$(LOCALBIN)/kustomize version is not expected $(KUSTOMIZE_VERSION). Removing it before installing."; \ + rm -rf $(LOCALBIN)/kustomize; \ + fi + test -s $(LOCALBIN)/kustomize || GOBIN=$(LOCALBIN) GO111MODULE=on go install sigs.k8s.io/kustomize/kustomize/v5@$(KUSTOMIZE_VERSION) + +.PHONY: envtest +envtest: localbin ## Download envtest-setup locally if necessary. + @test -s $(LOCALBIN)/setup-envtest && test -s $(LOCALBIN)/setup-envtest_version && cat $(LOCALBIN)/setup-envtest_version | grep -q $(SETUP_ENVTEST_VERSION) || \ + ( echo "Installing setup-envtest $(SETUP_ENVTEST_VERSION) ..."; \ + GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(SETUP_ENVTEST_VERSION) && \ + echo $(SETUP_ENVTEST_VERSION) > $(LOCALBIN)/setup-envtest_version ) + +.PHONY: gotestsum +gotestsum: $(GOTESTSUM) ## Download gotestsum locally if necessary. +$(GOTESTSUM): $(LOCALBIN) + @test -s $(LOCALBIN)/gotestsum || GOBIN=$(LOCALBIN) go install gotest.tools/gotestsum@latest + + +### ------------------------------------ DEVELOPMENT - LOCAL ------------------------------------ ### + +LOCAL_GOARCH ?= $(shell go env GOARCH) + +.PHONY: dev-build +dev-build: build image-build-local + @echo "Finished building docker image" local/$(PROJECT_FULL_NAME):${EFFECTIVE_VERSION}-linux-$(LOCAL_GOARCH) + +.PHONY: dev-base +dev-base: manifests kustomize dev-build dev-clean dev-cluster helm-install-local + +.PHONY: dev-cluster +dev-cluster: + $(KIND) create cluster --name=$(PROJECT_FULL_NAME)-dev + +# the local dev setup will use the local dev kind cluster as a crate cluster. +.PHONY: helm-install-local +helm-install-local: + helm upgrade --install $(PROJECT_FULL_NAME) charts/$(PROJECT_FULL_NAME)/ --set image.repository=local/$(PROJECT_FULL_NAME) --set image.tag=${EFFECTIVE_VERSION}-linux-$(LOCAL_GOARCH) --set image.pullPolicy=Never -f hack/local-values.yaml + +.PHONY: load-image +load-image: ## Loads the image into the local setup kind cluster. + $(KIND) load docker-image local/$(PROJECT_FULL_NAME):${EFFECTIVE_VERSION}-linux-$(LOCAL_GOARCH) --name=$(PROJECT_FULL_NAME)-dev + +.PHONY: dev-local +dev-local: dev-clean build image-build-local dev-cluster load-image install helm-install-local ## All-in-one command for creating a fresh local setup. + +.PHONY: create-crate-secret +create-crate-secret: + $(KUBECTL) apply -f config/samples/crate-secret.yaml + +.PHONY: dev-clean +dev-clean: + $(KIND) delete cluster --name=$(PROJECT_FULL_NAME)-dev + +.PHONY: dev-run +dev-run: + ## todo: add flag --debug + go run ./cmd/project-workspace-operator/main.go + +.PHONY: lint +lint: + golangci-lint run ./... + +.PHONY: lint-fix +lint-fix: + golangci-lint run --fix + +### ------------------------------------ HELM ------------------------------------ ### + +.PHONY: helm-chart +helm-chart: helm-templates + OPERATOR_VERSION=$(shell cat VERSION) envsubst < charts/$(PROJECT_FULL_NAME)/Chart.yaml.tpl > charts/$(PROJECT_FULL_NAME)/Chart.yaml + OPERATOR_VERSION=$(shell cat VERSION) envsubst < charts/$(PROJECT_FULL_NAME)/values.yaml.tpl > charts/$(PROJECT_FULL_NAME)/values.yaml + +### ------------------------------------ E2E ------------------------------------ ### +.PHONY: e2e +e2e: docker-build + E2E_UUT_IMAGE=$(IMG_BASE) E2E_UUT_TAG=$(IMG_VERSION) E2E_UUT_CHART=$(realpath charts/${PROJECT_FULL_NAME}) go test ./test/e2e/... --tags=e2e --count=1 diff --git a/PROJECT b/PROJECT new file mode 100644 index 0000000..f9eeac2 --- /dev/null +++ b/PROJECT @@ -0,0 +1,37 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html +domain: openmcp.cloud +layout: +- go.kubebuilder.io/v4 +multigroup: true +projectName: project-workspace-operator +repo: github.com/openmcp-project/project-workspace-operator +resources: +- api: + crdVersion: v1 + controller: true + domain: openmcp.cloud + group: core + kind: Project + path: github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1 + version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: openmcp.cloud + group: core + kind: Workspace + path: github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1 + version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 +version: "3" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..bf057db --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v0.10.0 diff --git a/api/core/v1alpha1/common_types.go b/api/core/v1alpha1/common_types.go new file mode 100644 index 0000000..267a502 --- /dev/null +++ b/api/core/v1alpha1/common_types.go @@ -0,0 +1,230 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + "strings" + + authv1 "k8s.io/api/authentication/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + CreatedByAnnotation = fmt.Sprintf("%s/created-by", GroupVersion.Group) + DisplayNameAnnotation = fmt.Sprintf("%s/display-name", GroupVersion.Group) +) + +// Subject contains a reference to the object or user identities a role binding applies to. This can either hold a direct API object reference, +// or a value for non-objects such as user and group names. +// +kubebuilder:validation:XValidation:rule="self.kind == 'ServiceAccount' || !has(self.__namespace__)",message="Namespace must not be specified if Kind is User or Group" +// +kubebuilder:validation:XValidation:rule="self.kind != 'ServiceAccount' || has(self.__namespace__)",message="Namespace is required for ServiceAccount" +type Subject struct { + // Kind of object being referenced. Can be "User", "Group", or "ServiceAccount". + // +kubebuilder:validation:Enum=User;Group;ServiceAccount + Kind string `json:"kind"` + + // Name of the object being referenced. + Name string `json:"name"` + + // Namespace of the referenced object. Required if Kind is "ServiceAccount". Must not be specified if Kind is "User" or "Group". + // +optional + Namespace string `json:"namespace,omitempty"` +} + +func (s Subject) RbacV1() rbacv1.Subject { + rs := rbacv1.Subject{ + Kind: s.Kind, + Name: s.Name, + Namespace: s.Namespace, + } + if s.Kind != rbacv1.ServiceAccountKind { + rs.APIGroup = rbacv1.GroupName + } + return rs +} + +// MemberOverrides is a resource used to Manage admin access to the Project/Workspace operator resources. +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +type MemberOverrides struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec MemberOverridesSpec `json:"spec,omitempty"` + Status MemberOverridesStatus `json:"status,omitempty"` +} +type MemberOverridesSpec struct { + MemberOverrides []MemberOverride `json:"memberOverrides"` +} + +type MemberOverridesStatus struct{} + +// +kubebuilder:validation:Enum=admin;view +type OverrideRole string + +const ( + OverrideRoleAdmin OverrideRole = "admin" + OverrideRoleView OverrideRole = "view" +) + +type MemberOverride struct { + Subject `json:",inline"` + // Roles defines a list of roles that this override subject should have. + Roles []OverrideRole `json:"roles"` + // Resources defines an optional list of projects/workspaces that this override applies to. + Resources []OverrideResource `json:"resources,omitempty"` +} + +type OverrideResource struct { + // +kubebuilder:validation:Enum=project;workspace + Kind string `json:"kind"` + // Name of the object being referenced. + Name string `json:"name"` +} + +// +kubebuilder:object:root=true +type MemberOverridesList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []MemberOverrides `json:"items"` +} + +func init() { + SchemeBuilder.Register(&MemberOverrides{}, &MemberOverridesList{}) +} + +func (m *MemberOverrides) HasAdminOverrideForResource(userInfo *authv1.UserInfo, resourceName, resourceKind string) bool { + for _, override := range m.Spec.MemberOverrides { + if !override.hasAdminRole() { + continue + } + // all-resources admin override for a user + if override.hasUserOrSA(userInfo) && len(override.Resources) == 0 { + return true + } + // all-resources admin override for group + if override.hasGroup(userInfo) && len(override.Resources) == 0 { + return true + } + // resource specific admin user/sa/group override + if override.hasResource(resourceName, resourceKind) && + (override.hasUserOrSA(userInfo) || override.hasGroup(userInfo)) { + return true + } + } + return false +} + +func (m *MemberOverride) hasResource(resourceName, resourceKind string) bool { + for _, resource := range m.Resources { + if strings.EqualFold(resource.Kind, resourceKind) && strings.EqualFold(resource.Name, resourceName) { + return true + } + } + return false +} + +func (m *MemberOverride) hasUserOrSA(userInfo *authv1.UserInfo) bool { + name, isUserOrSA := m.Username() + if !isUserOrSA { + return false + } + + return name == userInfo.Username +} + +func (m *MemberOverride) hasGroup(userInfo *authv1.UserInfo) bool { + if m.Kind != rbacv1.GroupKind { + return false + } + for _, groupName := range userInfo.Groups { + if m.Name == groupName { + return true + } + } + return false +} + +func (m *MemberOverride) hasAdminRole() bool { + for _, role := range m.Roles { + if role == OverrideRoleAdmin { + return true + } + } + return false +} + +func (m *MemberOverride) Username() (string, bool) { + switch m.Kind { + case rbacv1.UserKind: + return m.Name, true + case rbacv1.ServiceAccountKind: + return fmt.Sprintf("system:serviceaccount:%s:%s", m.Namespace, m.Name), true + default: + return "", false + } +} + +// RemainingContentResource is a resource used to track remaining content in a workspace. +// It is solely used as an information resource to inform the user about remaining content. +type RemainingContentResource struct { + // APIGroup is the group of the resource. + APIGroup string `json:"apiGroup"` + // Kind is the kind of the resource. + Kind string `json:"kind"` + // Name is the name of the resource. + Name string `json:"name"` + // Namespace is the namespace of the resource. + Namespace string `json:"namespace"` +} + +const ( + // ConditionTypeContentRemaining is a condition type that indicates that there is content in a project/workspace + // that is preventing the deletion. + ConditionTypeContentRemaining ConditionType = "ContentRemaining" + + // ConditionReasonResourcesRemaining is a condition reason that indicates that there are remaining resources in a + // project/workspace that are preventing the deletion. + ConditionReasonResourcesRemaining ConditionReason = "SomeResourcesRemain" + + // ConditionStatusTrue indicates that the condition is currently active. + ConditionStatusTrue ConditionStatus = "True" + // ConditionStatusFalse indicates that the condition is not currently active. + ConditionStatusFalse ConditionStatus = "False" + // ConditionStatusUnknown indicates that the condition status is unknown. + ConditionStatusUnknown ConditionStatus = "Unknown" +) + +// ConditionType is a type of condition. +type ConditionType string + +// ConditionReason is a reason for why a condition is set. +type ConditionReason string + +// ConditionStatus is the status of a condition. +type ConditionStatus string + +// Condition is part of all conditions that a project/ workspace can have. +type Condition struct { + // Type is the type of the condition. + Type ConditionType `json:"type"` + // Status is the status of the condition. + // +kubebuilder:validation:Enum=True;False;Unknown + Status ConditionStatus `json:"status"` + // LastTransitionTime is the time when the condition last transitioned from one status to another. + // +optional + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` + // Reason is the reason for the condition. + // +optional + Reason ConditionReason `json:"reason"` + // Message is a human-readable message indicating details about the condition. + // +optional + Message string `json:"message,omitempty"` + // Details is an object that can contain additional information about the condition. + // The content is specific to the condition type. + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + // +optional + Details json.RawMessage `json:"details,omitempty"` +} diff --git a/api/core/v1alpha1/common_webhook.go b/api/core/v1alpha1/common_webhook.go new file mode 100644 index 0000000..c26bbf2 --- /dev/null +++ b/api/core/v1alpha1/common_webhook.go @@ -0,0 +1,75 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "os" + "strings" + + admissionv1 "k8s.io/api/admission/v1" + authv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ( + // errCreatedByImmutable is the error that is returned when the value of the resource creator annotation has been changed by the user. + errCreatedByImmutable = fmt.Errorf("annotation %s is immutable", CreatedByAnnotation) + + // errRequestingUserNoAccess is the error that is returned when the user who is creating/updating a project or workspace would lock themselves out. + errRequestingUserNoAccess = func(username string) error { + return fmt.Errorf("requesting user %s will not be able to manage the created/updated resource. please check the list of members again or use MemberOverrides", username) + } +) + +// compareStringMapValue compares the value of string values identified by a key in two maps. +// Returns "true" if the value is the same. +func compareStringMapValue(a, b map[string]string, key string) bool { + return a[key] == b[key] +} + +// verifyCreatedByUnchanged checks if the value of the annotation that contains the name of the resource creator has been changed. +// Returns an error if the value has been changed or "nil" if it's the same. +func verifyCreatedByUnchanged(old, new metav1.Object) error { + if compareStringMapValue(old.GetAnnotations(), new.GetAnnotations(), CreatedByAnnotation) { + return nil + } + + return errCreatedByImmutable +} + +// setCreatedBy sets an annotation that contains the name of the user who created the resource. +// The value is only set when the "Operation" is "Create". +func setCreatedBy(obj metav1.Object, req admission.Request) { + if req.Operation != admissionv1.Create { + return + } + + setMetaDataAnnotation(obj, CreatedByAnnotation, req.UserInfo.Username) +} + +// setMetaDataAnnotation sets the annotation on the given object. +// If the given Object did not yet have annotations, they are initialized. +func setMetaDataAnnotation(meta metav1.Object, key, value string) { // TODO move to utils package + labels := meta.GetAnnotations() + if labels == nil { + labels = make(map[string]string) + } + labels[key] = value + meta.SetAnnotations(labels) +} + +func isOwnServiceAccount(userinfo authv1.UserInfo) bool { + svcAccUsername := fmt.Sprintf("system:serviceaccount:%s:%s", os.Getenv("POD_NAMESPACE"), os.Getenv("POD_SERVICE_ACCOUNT")) + return strings.HasSuffix(userinfo.Username, svcAccUsername) +} + +// userInfoFromContext extracts the authv1.UserInfo from the admission.Request available in the context. Returns an error if the request can't be found. +func userInfoFromContext(ctx context.Context) (authv1.UserInfo, error) { + req, err := admission.RequestFromContext(ctx) + if err != nil { + return authv1.UserInfo{}, err + } + + return req.UserInfo, nil +} diff --git a/api/core/v1alpha1/common_webhook_test.go b/api/core/v1alpha1/common_webhook_test.go new file mode 100644 index 0000000..36a009b --- /dev/null +++ b/api/core/v1alpha1/common_webhook_test.go @@ -0,0 +1,192 @@ +package v1alpha1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + admissionv1 "k8s.io/api/admission/v1" + authv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestCompareStringMapValue(t *testing.T) { + tests := []struct { + description string + mapA map[string]string + mapB map[string]string + key string + expectedResult bool + }{ + { + description: "returns 'true' if both maps contain map entries for the given key with the same value", + mapA: map[string]string{ + "test": "test", + }, + mapB: map[string]string{ + "test": "test", + }, + key: "test", + expectedResult: true, + }, + { + description: "returns 'true' if both maps don't contain a map entry for the given key", + key: "test", + expectedResult: true, + }, + { + description: "returns 'false' if both maps contain map entries for the given key but with different values", + mapA: map[string]string{ + "test": "test1", + }, + mapB: map[string]string{ + "test": "test2", + }, + key: "test", + }, + { + description: "returns 'false' if mapA doesn't contain a map entry for the given key", + mapB: map[string]string{ + "test": "test", + }, + key: "test", + }, + { + description: "returns false if mapB doesn't contain a map entry for the given key", + mapA: map[string]string{ + "test": "test", + }, + key: "test", + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + actualResult := compareStringMapValue(test.mapA, test.mapB, test.key) + + assert.Equal(t, test.expectedResult, actualResult) + }) + } + +} + +func TestVerifyCreatedByUnchanged(t *testing.T) { + tests := []struct { + description string + objA metav1.ObjectMeta + objB metav1.ObjectMeta + expectError bool + }{ + { + description: "returns no error if objA and objB contain a CreatedByAnnotation with the same value", + objA: metav1.ObjectMeta{ + Annotations: map[string]string{ + CreatedByAnnotation: "test", + }, + }, + objB: metav1.ObjectMeta{ + Annotations: map[string]string{ + CreatedByAnnotation: "test", + }, + }, + }, + { + description: "returns no error if objA and objB don't contain a CreatedByAnnotation", + }, + { + description: "returns an error if objA and objB contain a CreatedByAnnotation with a different value", + objA: metav1.ObjectMeta{ + Annotations: map[string]string{ + CreatedByAnnotation: "test1", + }, + }, + objB: metav1.ObjectMeta{ + Annotations: map[string]string{ + CreatedByAnnotation: "test2", + }, + }, + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + err := verifyCreatedByUnchanged(&test.objA, &test.objB) + + if test.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSetCreatedBy(t *testing.T) { + tests := []struct { + description string + request admissionv1.AdmissionRequest + expectedAnnotations map[string]string + }{ + { + description: "set's the CreatedBy annotation during create", + request: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + UserInfo: authv1.UserInfo{ + Username: "john.doe@test.com", + }, + }, + expectedAnnotations: map[string]string{ + CreatedByAnnotation: "john.doe@test.com", + }, + }, + { + description: "doesn't set the CreatedBy annotation if operation is NOT create", + request: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + UserInfo: authv1.UserInfo{ + Username: "john.doe@test.com", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + var uut metav1.ObjectMeta + + setCreatedBy(&uut, admission.Request{ + AdmissionRequest: test.request, + }) + + assert.Equal(t, test.expectedAnnotations, uut.GetAnnotations()) + }) + } +} + +func TestUserInfoFromContext(t *testing.T) { + t.Run("returns the userinfo from the admission.Request in the context", func(t *testing.T) { + userInfo := authv1.UserInfo{} + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + UserInfo: userInfo, + }, + } + + ctx := admission.NewContextWithRequest(context.Background(), req) + + uut, err := userInfoFromContext(ctx) + + if assert.NoError(t, err) { + assert.Equal(t, userInfo, uut) + } + }) + t.Run("fails if admission.Request can't be found in the context", func(t *testing.T) { + _, err := userInfoFromContext(context.TODO()) + + assert.Error(t, err) + }) +} diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..5c872f7 --- /dev/null +++ b/api/core/v1alpha1/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1alpha1 contains API Schema definitions for the core v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=core.openmcp.cloud +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "core.openmcp.cloud", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/core/v1alpha1/project_types.go b/api/core/v1alpha1/project_types.go new file mode 100644 index 0000000..ca79720 --- /dev/null +++ b/api/core/v1alpha1/project_types.go @@ -0,0 +1,166 @@ +package v1alpha1 + +import ( + "fmt" + + authv1 "k8s.io/api/authentication/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/openmcp-project/project-workspace-operator/api/entities" +) + +var ( + _ entities.AccessEntity = &Project{} + _ entities.AccessRole = ProjectRoleAdmin + _ entities.AccessRole = ProjectRoleView +) + +// +kubebuilder:validation:Enum=admin;view +type ProjectMemberRole string + +// Identifier implements AccessRole. +func (p ProjectMemberRole) Identifier() string { + return string(p) +} + +// EntityType implements AccessRole. +func (ProjectMemberRole) EntityType() entities.AccessEntity { + return &Project{} +} + +const ( + ProjectRoleAdmin ProjectMemberRole = "admin" + ProjectRoleView ProjectMemberRole = "view" +) + +// ProjectSpec defines the desired state of Project +type ProjectSpec struct { + // Members is a list of project members. + Members []ProjectMember `json:"members,omitempty"` +} + +type ProjectMember struct { + Subject `json:""` + + // Roles defines a list of roles that this project member should have. + Roles []ProjectMemberRole `json:"roles"` +} + +func (pm *ProjectMember) Username() (string, bool) { + switch pm.Kind { + case rbacv1.UserKind: + return pm.Name, true + case rbacv1.ServiceAccountKind: + return fmt.Sprintf("system:serviceaccount:%s:%s", pm.Namespace, pm.Name), true + default: + return "", false + } +} + +// ProjectStatus defines the observed state of Project +type ProjectStatus struct { + Namespace string `json:"namespace"` + // +optional + Conditions []Condition `json:"conditions,omitempty"` +} + +// Project is the Schema for the projects API +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Display Name",type="string",JSONPath=".metadata.annotations.openmcp\\.cloud/display-name" +// +kubebuilder:printcolumn:name="Resulting Namespace",type="string",JSONPath=".status.namespace" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 25",message="Name must not be longer than 25 characters" +type Project struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ProjectSpec `json:"spec,omitempty"` + Status ProjectStatus `json:"status,omitempty"` +} + +// TypeIdentifier implements AccessEntity. +func (p *Project) TypeIdentifier() string { + return "project" +} + +func (p *Project) UserInfoRoles(userInfo authv1.UserInfo) []ProjectMemberRole { + effectiveRoles := sets.Set[ProjectMemberRole]{} + + // check for access through username (including service accounts) + for _, member := range p.Spec.Members { + name, ok := member.Username() + if name == userInfo.Username && ok { + effectiveRoles.Insert(member.Roles...) + } + } + + // check for access through groups + for _, userGroup := range userInfo.Groups { + for _, member := range p.Spec.Members { + if member.Kind == rbacv1.GroupKind && member.Name == userGroup { + effectiveRoles.Insert(member.Roles...) + } + } + } + + return effectiveRoles.UnsortedList() +} + +func (p *Project) UserInfoHasRole(userInfo authv1.UserInfo, role ProjectMemberRole) bool { + effectiveRoles := p.UserInfoRoles(userInfo) + for _, pmr := range effectiveRoles { + if pmr == role { + return true + } + } + return false +} + +func (p *Project) SetOrUpdateCondition(condition Condition) { + var existingCondition *Condition + for i, c := range p.Status.Conditions { + if c.Type == condition.Type { + existingCondition = &p.Status.Conditions[i] + break + } + } + + if existingCondition == nil { + condition.LastTransitionTime = metav1.Now() + p.Status.Conditions = append(p.Status.Conditions, condition) + } else { + if existingCondition.Status != condition.Status { + condition.LastTransitionTime = metav1.Now() + } else { + condition.LastTransitionTime = existingCondition.LastTransitionTime + } + *existingCondition = condition + } +} + +func (p *Project) RemoveCondition(conditionType ConditionType) { + var conditions []Condition + for _, c := range p.Status.Conditions { + if c.Type != conditionType { + conditions = append(conditions, c) + } + } + p.Status.Conditions = conditions +} + +//+kubebuilder:object:root=true + +// ProjectList contains a list of Project +type ProjectList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Project `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Project{}, &ProjectList{}) +} diff --git a/api/core/v1alpha1/project_webhook.go b/api/core/v1alpha1/project_webhook.go new file mode 100644 index 0000000..bc0dc42 --- /dev/null +++ b/api/core/v1alpha1/project_webhook.go @@ -0,0 +1,184 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var projectlog = logf.Log.WithName("project-resource") + +func (p *Project) SetupWebhookWithManager(mgr ctrl.Manager, memberOverridesName string) error { + + return ctrl.NewWebhookManagedBy(mgr). + For(&Project{}). + WithDefaulter(&Project{}). + WithValidator(&projectValidator{ + Client: mgr.GetClient(), + overrideName: memberOverridesName, + }). + Complete() +} + +//+kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-project,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=projects,verbs=create;update,versions=v1alpha1,name=mproject.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomDefaulter = &Project{} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the type +func (p *Project) Default(ctx context.Context, obj runtime.Object) error { + project, err := expectProject(obj) + if err != nil { + return err + } + projectlog.Info("default", "name", project.Name) + + req, err := admission.RequestFromContext(ctx) + if err != nil { + return err + } + + setCreatedBy(project, req) + + return nil +} + +// Project must implement webhook.CustomValidator in order for it's Mutating/Validating Webhook configuration to be generated by "github.com/openmcp-project/controller-utils/pkg/init/webhooks" +var _ webhook.CustomValidator = &Project{} + +func (p *Project) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + return +} +func (p *Project) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { + return +} +func (p *Project) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + return +} + +//+kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-project,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=projects,verbs=create;update;delete,versions=v1alpha1,name=vproject.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomValidator = &projectValidator{} + +type projectValidator struct { + client.Client + overrideName string +} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type +func (v *projectValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + project, err := expectProject(obj) + if err != nil { + return + } + projectlog.Info("validate create", "name", project.Name) + + userInfo, err := userInfoFromContext(ctx) + if err != nil { + return + } + + validRole, err := v.ensureValidRole(ctx, project) + if err != nil { + return warnings, err + } + if !validRole { + return warnings, errRequestingUserNoAccess(userInfo.Username) + } + + return +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type +func (v *projectValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { + oldProject, err := expectProject(oldObj) + if err != nil { + return + } + + newProject, err := expectProject(newObj) + if err != nil { + return + } + projectlog.Info("validate update", "name", oldProject.Name) + + if err = verifyCreatedByUnchanged(oldProject, newProject); err != nil { + return + } + + userInfo, err := userInfoFromContext(ctx) + if err != nil { + return + } + validRole, err := v.ensureValidRole(ctx, oldProject) + if err != nil { + return warnings, err + } + if !validRole { + return warnings, errRequestingUserNoAccess(userInfo.Username) + } + + // ensure the user can't remove themselves from the member list + validNewRole, err := v.ensureValidRole(ctx, newProject) + if err != nil { + return warnings, err + } + if !validNewRole { + return warnings, errRequestingUserNoAccess(userInfo.Username) + } + + return +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type +func (v *projectValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + project, err := expectProject(obj) + if err != nil { + return + } + projectlog.Info("validate delete", "name", project.Name) + + if validRole, err := v.ensureValidRole(ctx, project); !validRole { + return warnings, err + } + return +} + +// expectProject casts the given runtime.Object to *Project. Returns an error in case the object can't be casted. +func expectProject(obj runtime.Object) (*Project, error) { + project, ok := obj.(*Project) + if !ok { + return nil, fmt.Errorf("expected a Project but got a %T", obj) + } + return project, nil +} + +func (v *projectValidator) ensureValidRole(ctx context.Context, project *Project) (bool, error) { + userInfo, err := userInfoFromContext(ctx) + if err != nil { + return false, fmt.Errorf("failed to get userInfo") + } + if project.UserInfoHasRole(userInfo, ProjectRoleAdmin) || isOwnServiceAccount(userInfo) { + return true, nil + } + + if v.overrideName == "" { + return false, nil + } + + overrides := &MemberOverrides{} + if err := v.Get(ctx, types.NamespacedName{Name: v.overrideName}, overrides); err != nil { + return false, err + } + if overrides.HasAdminOverrideForResource(&userInfo, project.Name, project.Kind) { + return true, nil + } + return false, nil +} diff --git a/api/core/v1alpha1/project_webhook_test.go b/api/core/v1alpha1/project_webhook_test.go new file mode 100644 index 0000000..dda0de4 --- /dev/null +++ b/api/core/v1alpha1/project_webhook_test.go @@ -0,0 +1,418 @@ +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Project Webhook", func() { + BeforeEach(func() { + // this must be cleaned with each run because it's name is passed to the webhook at startup. Creating a new one with a different name won't work. + err := k8sClient.Delete(ctx, &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + }) + Expect(err).To(Or(BeNil(), MatchError(apierrors.IsNotFound, "NotFound"))) + }) + + Context("When creating a Project", func() { + It("Should allow to create the project by the admin user", func() { + var err error + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should allow to create the project by a serviceaccount", func() { + var err error + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "ServiceAccount", + Name: "admin", + Namespace: "kube-system", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = saClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should deny to create the project by a non-member user", func() { + var err error + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "unknown", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).To(HaveOccurred()) + }) + + It("Should allow to create the project by a user in MemberOverrides", func() { + var err error + var projectName = uniqueName() + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: projectName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: projectName, + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should allow to create the project by a serviceaccount in MemeberOverrides", func() { + var err error + var projectName = uniqueName() + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "ServiceAccount", + Name: "admin", + Namespace: "kube-system", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: projectName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: projectName, + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "ServiceAccount", + Name: "second-admin", + Namespace: "kube-system", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = saClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should allow to create the project by a group in MemberOverrides", func() { + var err error + var projectName = uniqueName() + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "Group", + Name: "system:admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: projectName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: projectName, + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should deny to create the project when a user is not project member or in MemberOverrides", func() { + var err error + var projectName = uniqueName() + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "another-admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: projectName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: projectName, + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When updating a Project", func() { + It("should deny removing self from the project", func() { + var err error + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + + project.Spec.Members = []ProjectMember{} + + err = realUserClient.Update(ctx, project) + Expect(err).To(HaveOccurred()) + }) + + It("Should allow to update the project by a user in MemberOverrides", func() { + var err error + var projectName = uniqueName() + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: projectName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + project := &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: projectName, + }, + Spec: ProjectSpec{ + Members: []ProjectMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []ProjectMemberRole{ + ProjectRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).ShouldNot(HaveOccurred()) + + project.Labels = map[string]string{"key": "value"} + + err = realUserClient.Update(ctx, project) + Expect(err).ToNot(HaveOccurred()) + + }) + }) +}) diff --git a/api/core/v1alpha1/webhook_suite_test.go b/api/core/v1alpha1/webhook_suite_test.go new file mode 100644 index 0000000..b18eea7 --- /dev/null +++ b/api/core/v1alpha1/webhook_suite_test.go @@ -0,0 +1,228 @@ +package v1alpha1 + +import ( + "context" + "crypto/tls" + "encoding/hex" + "fmt" + "net" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + //+kubebuilder:scaffold:imports + + "github.com/google/uuid" + envtestutil "github.com/openmcp-project/controller-utils/pkg/envtest" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apimachineryruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var saClient client.Client +var realUserClient client.Client +var testEnv *envtest.Environment +var ctx context.Context +var cancel context.CancelFunc +var testScheme *apimachineryruntime.Scheme + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + var err error + err = envtestutil.Install("latest") + Expect(err).NotTo(HaveOccurred()) + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, + }, + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + testScheme = apimachineryruntime.NewScheme() + err = AddToScheme(testScheme) + Expect(err).NotTo(HaveOccurred()) + + err = admissionv1.AddToScheme(testScheme) + Expect(err).NotTo(HaveOccurred()) + + err = rbacv1.AddToScheme(testScheme) + Expect(err).NotTo(HaveOccurred()) + + err = corev1.AddToScheme(testScheme) + Expect(err).NotTo(HaveOccurred()) + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: testScheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: testScheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = (&Project{}).SetupWebhookWithManager(mgr, "test-override") + Expect(err).NotTo(HaveOccurred()) + + err = (&Workspace{}).SetupWebhookWithManager(mgr, "test-override") + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return err + } + conn.Close() + return nil + }).Should(Succeed()) + + saClient = impersonate("system:serviceaccount:kube-system:admin", []string{ + "system:authenticated", + "system:admin", + }) + + realUserClient = impersonate("admin", []string{ + "system:authenticated", + "system:admin", + }) + + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "system:admin", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{GroupVersion.Group}, + Resources: []string{"projects", "workspaces"}, + Verbs: []string{"*"}, + }, + }, + } + + err = k8sClient.Create(ctx, clusterRole) + if err != nil { + if apierrors.IsAlreadyExists(err) { + err = k8sClient.Update(ctx, clusterRole) + Expect(err).ShouldNot(HaveOccurred()) + } else { + Fail("Failed to create/update cluster role") + } + } + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "system:admin", + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + Name: "system:admin", + APIGroup: "rbac.authorization.k8s.io", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "User", + Name: "admin", + APIGroup: "rbac.authorization.k8s.io", + }, + { + Kind: "ServiceAccount", + Name: "admin", + Namespace: "kube-system", + }, + }, + } + + err = k8sClient.Create(ctx, clusterRoleBinding) + if err != nil { + if apierrors.IsAlreadyExists(err) { + err = k8sClient.Update(ctx, clusterRoleBinding) + Expect(err).ShouldNot(HaveOccurred()) + } else { + Fail("Failed to create/update cluster role binding") + } + } +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +func impersonate(userName string, groups []string) client.Client { + cfg.Impersonate = rest.ImpersonationConfig{ + UserName: userName, + Groups: groups, + } + + client, err := client.New(cfg, client.Options{Scheme: testScheme}) + Expect(err).NotTo(HaveOccurred()) + + return client +} + +func uniqueName() string { + uuidBin, err := uuid.New().MarshalBinary() + Expect(err).ToNot(HaveOccurred()) + uuidHex := make([]byte, 32) + hex.Encode(uuidHex, uuidBin) + uuidStr := string(uuidHex[:25]) + return uuidStr +} diff --git a/api/core/v1alpha1/workspace_types.go b/api/core/v1alpha1/workspace_types.go new file mode 100644 index 0000000..70b717b --- /dev/null +++ b/api/core/v1alpha1/workspace_types.go @@ -0,0 +1,166 @@ +package v1alpha1 + +import ( + "fmt" + + authv1 "k8s.io/api/authentication/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/openmcp-project/project-workspace-operator/api/entities" +) + +var ( + _ entities.AccessEntity = &Workspace{} + _ entities.AccessRole = WorkspaceRoleAdmin + _ entities.AccessRole = WorkspaceRoleView +) + +// +kubebuilder:validation:Enum=admin;view +type WorkspaceMemberRole string + +// EntityType implements AccessRole. +func (w WorkspaceMemberRole) EntityType() entities.AccessEntity { + return &Workspace{} +} + +// Identifier implements AccessRole. +func (w WorkspaceMemberRole) Identifier() string { + return string(w) +} + +const ( + WorkspaceRoleAdmin WorkspaceMemberRole = "admin" + WorkspaceRoleView WorkspaceMemberRole = "view" +) + +// WorkspaceSpec defines the desired state of Workspace +type WorkspaceSpec struct { + // Members is a list of workspace members. + Members []WorkspaceMember `json:"members,omitempty"` +} + +type WorkspaceMember struct { + Subject `json:""` + + // Roles defines a list of roles that this workspace member should have. + Roles []WorkspaceMemberRole `json:"roles"` +} + +func (wm *WorkspaceMember) Username() (string, bool) { + switch wm.Kind { + case rbacv1.UserKind: + return wm.Name, true + case rbacv1.ServiceAccountKind: + return fmt.Sprintf("system:serviceaccount:%s:%s", wm.Namespace, wm.Name), true + default: + return "", false + } +} + +// WorkspaceStatus defines the observed state of Workspace +type WorkspaceStatus struct { + Namespace string `json:"namespace"` + // +optional + Conditions []Condition `json:"conditions,omitempty"` +} + +// Workspace is the Schema for the workspaces API +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=ws +// +kubebuilder:printcolumn:name="Display Name",type="string",JSONPath=".metadata.annotations.openmcp\\.cloud/display-name" +// +kubebuilder:printcolumn:name="Resulting Namespace",type="string",JSONPath=".status.namespace" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 25",message="Name must not be longer than 25 characters" +type Workspace struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WorkspaceSpec `json:"spec,omitempty"` + Status WorkspaceStatus `json:"status,omitempty"` +} + +// TypeIdentifier implements AccessEntity. +func (ws *Workspace) TypeIdentifier() string { + return "workspace" +} + +func (ws *Workspace) UserInfoRoles(userInfo authv1.UserInfo) []WorkspaceMemberRole { + effectiveRoles := sets.Set[WorkspaceMemberRole]{} + + // check for access through username (including service accounts) + for _, member := range ws.Spec.Members { + name, ok := member.Username() + if name == userInfo.Username && ok { + effectiveRoles.Insert(member.Roles...) + } + } + + // check for access through groups + for _, userGroup := range userInfo.Groups { + for _, member := range ws.Spec.Members { + if member.Kind == rbacv1.GroupKind && member.Name == userGroup { + effectiveRoles.Insert(member.Roles...) + } + } + } + + return effectiveRoles.UnsortedList() +} + +func (ws *Workspace) UserInfoHasRole(userInfo authv1.UserInfo, role WorkspaceMemberRole) bool { + effectiveRoles := ws.UserInfoRoles(userInfo) + for _, pmr := range effectiveRoles { + if pmr == role { + return true + } + } + return false +} + +func (ws *Workspace) SetOrUpdateCondition(condition Condition) { + var existingCondition *Condition + for i, c := range ws.Status.Conditions { + if c.Type == condition.Type { + existingCondition = &ws.Status.Conditions[i] + break + } + } + + if existingCondition == nil { + condition.LastTransitionTime = metav1.Now() + ws.Status.Conditions = append(ws.Status.Conditions, condition) + } else { + if existingCondition.Status != condition.Status { + condition.LastTransitionTime = metav1.Now() + } else { + condition.LastTransitionTime = existingCondition.LastTransitionTime + } + *existingCondition = condition + } +} + +func (ws *Workspace) RemoveCondition(conditionType ConditionType) { + var conditions []Condition + for _, c := range ws.Status.Conditions { + if c.Type != conditionType { + conditions = append(conditions, c) + } + } + ws.Status.Conditions = conditions +} + +//+kubebuilder:object:root=true + +// WorkspaceList contains a list of Workspace +type WorkspaceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Workspace `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Workspace{}, &WorkspaceList{}) +} diff --git a/api/core/v1alpha1/workspace_webhook.go b/api/core/v1alpha1/workspace_webhook.go new file mode 100644 index 0000000..fb7b2c0 --- /dev/null +++ b/api/core/v1alpha1/workspace_webhook.go @@ -0,0 +1,198 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var workspacelog = logf.Log.WithName("workspace-resource") + +func (r *Workspace) SetupWebhookWithManager(mgr ctrl.Manager, memberOverridesName string) error { + + return ctrl.NewWebhookManagedBy(mgr). + For(r). + WithDefaulter(&Workspace{}). + WithValidator(&workspaceValidator{ + Client: mgr.GetClient(), + overrideName: memberOverridesName, + }). + Complete() +} + +// +kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-workspace,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=workspaces,verbs=create;update,versions=v1alpha1,name=mworkspace.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomDefaulter = &Workspace{} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the type +func (w *Workspace) Default(ctx context.Context, obj runtime.Object) error { + workspace, err := expectWorkspace(obj) + if err != nil { + return err + } + + req, err := admission.RequestFromContext(ctx) + if err != nil { + return err + } + + setCreatedBy(workspace, req) + + return nil +} + +// Workspace must implement webhook.CustomValidator in order for it's Mutating/Validating Webhook configuration to be generated by "github.com/openmcp-project/controller-utils/pkg/init/webhooks" +var _ webhook.CustomValidator = &Workspace{} + +func (w *Workspace) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + return +} +func (w *Workspace) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { + return +} +func (w *Workspace) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + return +} + +// +kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-workspace,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=workspaces,verbs=create;update;delete,versions=v1alpha1,name=vworkspace.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomValidator = &workspaceValidator{} + +type workspaceValidator struct { + client.Client + overrideName string +} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type +func (v *workspaceValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + workspace, err := expectWorkspace(obj) + if err != nil { + return + } + workspacelog.Info("validate create", "name", workspace.Name) + + userInfo, err := userInfoFromContext(ctx) + if err != nil { + return + } + validRole, err := v.ensureValidRole(ctx, workspace) + if err != nil { + return warnings, err + } + if !validRole { + return warnings, errRequestingUserNoAccess(userInfo.Username) + } + + return +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type +func (v *workspaceValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { + oldWorkspace, err := expectWorkspace(oldObj) + if err != nil { + return + } + + newWorkspace, err := expectWorkspace(newObj) + if err != nil { + return + } + + workspacelog.Info("validate update", "name", oldWorkspace.Name) + + if err = verifyCreatedByUnchanged(oldWorkspace, newWorkspace); err != nil { + return + } + + userInfo, err := userInfoFromContext(ctx) + if err != nil { + return + } + validRole, err := v.ensureValidRole(ctx, oldWorkspace) + if err != nil { + return warnings, err + } + if !validRole { + return warnings, errRequestingUserNoAccess(userInfo.Username) + } + // ensure the user can't remove themselves from the member list + validNewRole, err := v.ensureValidRole(ctx, newWorkspace) + if err != nil { + return warnings, err + } + if !validNewRole { + return warnings, errRequestingUserNoAccess(userInfo.Username) + } + + return +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type +func (v *workspaceValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + workspace, err := expectWorkspace(obj) + if err != nil { + return + } + + workspacelog.Info("validate delete", "name", workspace.Name) + + if validRole, err := v.ensureValidRole(ctx, workspace); !validRole { + return warnings, err + } + return +} + +// expectWorkspace casts the given runtime.Object to *Workspace. Returns an error in case the object can't be casted. +func expectWorkspace(obj runtime.Object) (*Workspace, error) { + workspace, ok := obj.(*Workspace) + if !ok { + return nil, fmt.Errorf("expected a Workspace but got a %T", obj) + } + return workspace, nil +} + +func (v *workspaceValidator) ensureValidRole(ctx context.Context, workspace *Workspace) (bool, error) { + userInfo, err := userInfoFromContext(ctx) + if err != nil { + return false, fmt.Errorf("failed to get userInfo") + } + if workspace.UserInfoHasRole(userInfo, WorkspaceRoleAdmin) || isOwnServiceAccount(userInfo) { + return true, nil + } + + if v.overrideName == "" { + return false, nil + } + + overrides := &MemberOverrides{} + if err := v.Get(ctx, types.NamespacedName{Name: v.overrideName}, overrides); err != nil { + return false, err + } + + if !overrides.HasAdminOverrideForResource(&userInfo, workspace.Name, workspace.Kind) { + return false, nil + } + // slightly hacky way to get parent project name + projectName, ok := strings.CutPrefix(workspace.Namespace, "project-") + if !ok || projectName == "" { + return false, fmt.Errorf("failed to get Workspace Project name") + } + + projectGVK := GroupVersion.WithKind("Project") + + // the subject must have admin access for the parent project as well. + if overrides.HasAdminOverrideForResource(&userInfo, projectName, projectGVK.Kind) { + return true, nil + } + + return false, nil +} diff --git a/api/core/v1alpha1/workspace_webhook_test.go b/api/core/v1alpha1/workspace_webhook_test.go new file mode 100644 index 0000000..5ac1f83 --- /dev/null +++ b/api/core/v1alpha1/workspace_webhook_test.go @@ -0,0 +1,512 @@ +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Workspace Webhook", func() { + BeforeEach(func() { + // this must be cleaned with each run because it's name is passed to the webhook at startup. Creating a new one with a different name won't work. + err := k8sClient.Delete(ctx, &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + }) + Expect(err).To(Or(BeNil(), MatchError(apierrors.IsNotFound, "NotFound"))) + }) + + Context("When creating a Workspace", func() { + It("Should allow to create the workspace by the admin user", func() { + var err error + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + Namespace: "default", + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should allow to create the workspace by a serviceaccount", func() { + var err error + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + Namespace: "default", + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "ServiceAccount", + Name: "admin", + Namespace: "kube-system", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = saClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should deny to create the workspace by a non-member user", func() { + var err error + + project := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + Namespace: "default", + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "unknown", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, project) + Expect(err).To(HaveOccurred()) + }) + + It("Should allow to create the workspace by a user in MemberOverrides", func() { + var err error + var workspaceName = uniqueName() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "project-test", + }, + } + + err = k8sClient.Create(ctx, namespace) + Expect(err).ShouldNot(HaveOccurred()) + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: "test", + }, + { + Kind: "workspace", + Name: workspaceName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespace.Name, + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should allow to create the workspace by a serviceaccount in MemeberOverrides", func() { + var err error + var workspaceName = uniqueName() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "project-test-sa", + }, + } + + err = k8sClient.Create(ctx, namespace) + Expect(err).ShouldNot(HaveOccurred()) + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "ServiceAccount", + Name: "admin", + Namespace: "kube-system", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: "test-sa", + }, + { + Kind: "workspace", + Name: workspaceName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespace.Name, + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "ServiceAccount", + Name: "second-admin", + Namespace: "kube-system", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = saClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should allow to create the workspace by a group in MemberOverrides", func() { + var err error + var workspaceName = uniqueName() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "project-test-group", + }, + } + + err = k8sClient.Create(ctx, namespace) + Expect(err).ShouldNot(HaveOccurred()) + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "Group", + Name: "system:admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: "test-group", + }, + { + Kind: "workspace", + Name: workspaceName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespace.Name, + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("Should deny to create the workspace when a user is not a workspace member or in MemberOverrides", func() { + var err error + var workspaceName = uniqueName() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "project-test3", + }, + } + + err = k8sClient.Create(ctx, namespace) + Expect(err).ShouldNot(HaveOccurred()) + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "another-admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: "test3", + }, + { + Kind: "workspace", + Name: workspaceName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespace.Name, + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, workspace) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When updating a Workspace", func() { + It("should deny removing self from the workspace", func() { + var err error + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: uniqueName(), + Namespace: "default", + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + + workspace.Spec.Members = []WorkspaceMember{} + + err = realUserClient.Update(ctx, workspace) + Expect(err).To(HaveOccurred()) + }) + + It("should deny updates workspace with MemberOverrides if there is no admin override for the parent project", func() { + + var err error + var workspaceName = uniqueName() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "project-test-parent", + }, + } + + err = k8sClient.Create(ctx, namespace) + Expect(err).ShouldNot(HaveOccurred()) + + override := &MemberOverrides{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-override", + }, + Spec: MemberOverridesSpec{ + MemberOverrides: []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "project", + Name: "test-parent", + }, + { + Kind: "workspace", + Name: workspaceName, + }, + }, + }, + }, + }, + } + + err = k8sClient.Create(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + workspace := &Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespace.Name, + }, + Spec: WorkspaceSpec{ + Members: []WorkspaceMember{ + { + Subject: Subject{ + Kind: "User", + Name: "second-admin", + }, + Roles: []WorkspaceMemberRole{ + WorkspaceRoleAdmin, + }, + }, + }, + }, + } + + err = realUserClient.Create(ctx, workspace) + Expect(err).ShouldNot(HaveOccurred()) + + override.Spec.MemberOverrides = []MemberOverride{ + { + Subject: Subject{ + Kind: "User", + Name: "admin", + }, + Roles: []OverrideRole{ + OverrideRoleAdmin, + }, + Resources: []OverrideResource{ + { + Kind: "workspace", + Name: workspaceName, + }, + }, + }, + } + err = k8sClient.Update(ctx, override) + Expect(err).ShouldNot(HaveOccurred()) + + workspace.Labels = map[string]string{"key": "value"} + err = realUserClient.Update(ctx, workspace) + GinkgoLogr.Info("%v", err) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..12cfab0 --- /dev/null +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,447 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "encoding/json" + + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Condition) DeepCopyInto(out *Condition) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) + if in.Details != nil { + in, out := &in.Details, &out.Details + *out = make(json.RawMessage, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition. +func (in *Condition) DeepCopy() *Condition { + if in == nil { + return nil + } + out := new(Condition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemberOverride) DeepCopyInto(out *MemberOverride) { + *out = *in + out.Subject = in.Subject + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]OverrideRole, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]OverrideResource, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemberOverride. +func (in *MemberOverride) DeepCopy() *MemberOverride { + if in == nil { + return nil + } + out := new(MemberOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemberOverrides) DeepCopyInto(out *MemberOverrides) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemberOverrides. +func (in *MemberOverrides) DeepCopy() *MemberOverrides { + if in == nil { + return nil + } + out := new(MemberOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MemberOverrides) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemberOverridesList) DeepCopyInto(out *MemberOverridesList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MemberOverrides, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemberOverridesList. +func (in *MemberOverridesList) DeepCopy() *MemberOverridesList { + if in == nil { + return nil + } + out := new(MemberOverridesList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MemberOverridesList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemberOverridesSpec) DeepCopyInto(out *MemberOverridesSpec) { + *out = *in + if in.MemberOverrides != nil { + in, out := &in.MemberOverrides, &out.MemberOverrides + *out = make([]MemberOverride, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemberOverridesSpec. +func (in *MemberOverridesSpec) DeepCopy() *MemberOverridesSpec { + if in == nil { + return nil + } + out := new(MemberOverridesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemberOverridesStatus) DeepCopyInto(out *MemberOverridesStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemberOverridesStatus. +func (in *MemberOverridesStatus) DeepCopy() *MemberOverridesStatus { + if in == nil { + return nil + } + out := new(MemberOverridesStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OverrideResource) DeepCopyInto(out *OverrideResource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OverrideResource. +func (in *OverrideResource) DeepCopy() *OverrideResource { + if in == nil { + return nil + } + out := new(OverrideResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Project) DeepCopyInto(out *Project) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Project. +func (in *Project) DeepCopy() *Project { + if in == nil { + return nil + } + out := new(Project) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Project) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectList) DeepCopyInto(out *ProjectList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Project, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectList. +func (in *ProjectList) DeepCopy() *ProjectList { + if in == nil { + return nil + } + out := new(ProjectList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProjectList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectMember) DeepCopyInto(out *ProjectMember) { + *out = *in + out.Subject = in.Subject + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]ProjectMemberRole, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectMember. +func (in *ProjectMember) DeepCopy() *ProjectMember { + if in == nil { + return nil + } + out := new(ProjectMember) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) { + *out = *in + if in.Members != nil { + in, out := &in.Members, &out.Members + *out = make([]ProjectMember, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec. +func (in *ProjectSpec) DeepCopy() *ProjectSpec { + if in == nil { + return nil + } + out := new(ProjectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectStatus) DeepCopyInto(out *ProjectStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectStatus. +func (in *ProjectStatus) DeepCopy() *ProjectStatus { + if in == nil { + return nil + } + out := new(ProjectStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemainingContentResource) DeepCopyInto(out *RemainingContentResource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemainingContentResource. +func (in *RemainingContentResource) DeepCopy() *RemainingContentResource { + if in == nil { + return nil + } + out := new(RemainingContentResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Subject) DeepCopyInto(out *Subject) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Subject. +func (in *Subject) DeepCopy() *Subject { + if in == nil { + return nil + } + out := new(Subject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Workspace) DeepCopyInto(out *Workspace) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Workspace. +func (in *Workspace) DeepCopy() *Workspace { + if in == nil { + return nil + } + out := new(Workspace) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Workspace) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceList) DeepCopyInto(out *WorkspaceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Workspace, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceList. +func (in *WorkspaceList) DeepCopy() *WorkspaceList { + if in == nil { + return nil + } + out := new(WorkspaceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkspaceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceMember) DeepCopyInto(out *WorkspaceMember) { + *out = *in + out.Subject = in.Subject + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]WorkspaceMemberRole, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceMember. +func (in *WorkspaceMember) DeepCopy() *WorkspaceMember { + if in == nil { + return nil + } + out := new(WorkspaceMember) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { + *out = *in + if in.Members != nil { + in, out := &in.Members, &out.Members + *out = make([]WorkspaceMember, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceSpec. +func (in *WorkspaceSpec) DeepCopy() *WorkspaceSpec { + if in == nil { + return nil + } + out := new(WorkspaceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceStatus) DeepCopyInto(out *WorkspaceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceStatus. +func (in *WorkspaceStatus) DeepCopy() *WorkspaceStatus { + if in == nil { + return nil + } + out := new(WorkspaceStatus) + in.DeepCopyInto(out) + return out +} diff --git a/api/crds/crds.go b/api/crds/crds.go new file mode 100644 index 0000000..65cc101 --- /dev/null +++ b/api/crds/crds.go @@ -0,0 +1,35 @@ +package crds + +import ( + "embed" + "fmt" + "path/filepath" + + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" +) + +//go:embed manifests +var CRDFS embed.FS + +// CRDs returns the generated CustomResourceDefinitions as go structs. +// Panics if anything goes wrong trying to read the CRD files, as an error here is likely related to a wrong build or invalid generated CRDs. +func CRDs() []*apiextv1.CustomResourceDefinition { + files, err := CRDFS.ReadDir("manifests") + if err != nil { + panic(err) + } + res := []*apiextv1.CustomResourceDefinition{} + for _, f := range files { + data, err := CRDFS.ReadFile(filepath.Join("manifests", f.Name())) + if err != nil { + panic(fmt.Errorf("error reading CRD file '%s': %w", f.Name(), err)) + } + crd := &apiextv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(data, crd); err != nil { + panic(fmt.Errorf("error parsing file '%s' into CRD: %w", f.Name(), err)) + } + res = append(res, crd) + } + return res +} diff --git a/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml b/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml new file mode 100644 index 0000000..16f7b05 --- /dev/null +++ b/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml @@ -0,0 +1,106 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: memberoverrides.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: MemberOverrides + listKind: MemberOverridesList + plural: memberoverrides + singular: memberoverrides + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: MemberOverrides is a resource used to Manage admin access to + the Project/Workspace operator resources. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + memberOverrides: + items: + properties: + kind: + description: Kind of object being referenced. Can be "User", + "Group", or "ServiceAccount". + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the object being referenced. + type: string + namespace: + description: Namespace of the referenced object. Required if + Kind is "ServiceAccount". Must not be specified if Kind is + "User" or "Group". + type: string + resources: + description: Resources defines an optional list of projects/workspaces + that this override applies to. + items: + properties: + kind: + enum: + - project + - workspace + type: string + name: + description: Name of the object being referenced. + type: string + required: + - kind + - name + type: object + type: array + roles: + description: Roles defines a list of roles that this override + subject should have. + items: + enum: + - admin + - view + type: string + type: array + required: + - kind + - name + - roles + type: object + x-kubernetes-validations: + - message: Namespace must not be specified if Kind is User or Group + rule: self.kind == 'ServiceAccount' || !has(self.__namespace__) + - message: Namespace is required for ServiceAccount + rule: self.kind != 'ServiceAccount' || has(self.__namespace__) + type: array + required: + - memberOverrides + type: object + status: + type: object + type: object + served: true + storage: true diff --git a/api/crds/manifests/core.openmcp.cloud_projects.yaml b/api/crds/manifests/core.openmcp.cloud_projects.yaml new file mode 100644 index 0000000..b0d7199 --- /dev/null +++ b/api/crds/manifests/core.openmcp.cloud_projects.yaml @@ -0,0 +1,145 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: projects.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: Project + listKind: ProjectList + plural: projects + singular: project + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.annotations.openmcp\.cloud/display-name + name: Display Name + type: string + - jsonPath: .status.namespace + name: Resulting Namespace + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Project is the Schema for the projects API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ProjectSpec defines the desired state of Project + properties: + members: + description: Members is a list of project members. + items: + properties: + kind: + description: Kind of object being referenced. Can be "User", + "Group", or "ServiceAccount". + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the object being referenced. + type: string + namespace: + description: Namespace of the referenced object. Required if + Kind is "ServiceAccount". Must not be specified if Kind is + "User" or "Group". + type: string + roles: + description: Roles defines a list of roles that this project + member should have. + items: + enum: + - admin + - view + type: string + type: array + required: + - kind + - name + - roles + type: object + x-kubernetes-validations: + - message: Namespace must not be specified if Kind is User or Group + rule: self.kind == 'ServiceAccount' || !has(self.__namespace__) + - message: Namespace is required for ServiceAccount + rule: self.kind != 'ServiceAccount' || has(self.__namespace__) + type: array + type: object + status: + description: ProjectStatus defines the observed state of Project + properties: + conditions: + items: + description: Condition is part of all conditions that a project/ + workspace can have. + properties: + details: + description: |- + Details is an object that can contain additional information about the condition. + The content is specific to the condition type. + x-kubernetes-preserve-unknown-fields: true + lastTransitionTime: + description: LastTransitionTime is the time when the condition + last transitioned from one status to another. + format: date-time + type: string + message: + description: Message is a human-readable message indicating + details about the condition. + type: string + reason: + description: Reason is the reason for the condition. + type: string + status: + description: Status is the status of the condition. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: Type is the type of the condition. + type: string + required: + - status + - type + type: object + type: array + namespace: + type: string + required: + - namespace + type: object + type: object + x-kubernetes-validations: + - message: Name must not be longer than 25 characters + rule: size(self.metadata.name) <= 25 + served: true + storage: true + subresources: + status: {} diff --git a/api/crds/manifests/core.openmcp.cloud_workspaces.yaml b/api/crds/manifests/core.openmcp.cloud_workspaces.yaml new file mode 100644 index 0000000..72cd8e0 --- /dev/null +++ b/api/crds/manifests/core.openmcp.cloud_workspaces.yaml @@ -0,0 +1,147 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: workspaces.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: Workspace + listKind: WorkspaceList + plural: workspaces + shortNames: + - ws + singular: workspace + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.annotations.openmcp\.cloud/display-name + name: Display Name + type: string + - jsonPath: .status.namespace + name: Resulting Namespace + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Workspace is the Schema for the workspaces API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WorkspaceSpec defines the desired state of Workspace + properties: + members: + description: Members is a list of workspace members. + items: + properties: + kind: + description: Kind of object being referenced. Can be "User", + "Group", or "ServiceAccount". + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the object being referenced. + type: string + namespace: + description: Namespace of the referenced object. Required if + Kind is "ServiceAccount". Must not be specified if Kind is + "User" or "Group". + type: string + roles: + description: Roles defines a list of roles that this workspace + member should have. + items: + enum: + - admin + - view + type: string + type: array + required: + - kind + - name + - roles + type: object + x-kubernetes-validations: + - message: Namespace must not be specified if Kind is User or Group + rule: self.kind == 'ServiceAccount' || !has(self.__namespace__) + - message: Namespace is required for ServiceAccount + rule: self.kind != 'ServiceAccount' || has(self.__namespace__) + type: array + type: object + status: + description: WorkspaceStatus defines the observed state of Workspace + properties: + conditions: + items: + description: Condition is part of all conditions that a project/ + workspace can have. + properties: + details: + description: |- + Details is an object that can contain additional information about the condition. + The content is specific to the condition type. + x-kubernetes-preserve-unknown-fields: true + lastTransitionTime: + description: LastTransitionTime is the time when the condition + last transitioned from one status to another. + format: date-time + type: string + message: + description: Message is a human-readable message indicating + details about the condition. + type: string + reason: + description: Reason is the reason for the condition. + type: string + status: + description: Status is the status of the condition. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: Type is the type of the condition. + type: string + required: + - status + - type + type: object + type: array + namespace: + type: string + required: + - namespace + type: object + type: object + x-kubernetes-validations: + - message: Name must not be longer than 25 characters + rule: size(self.metadata.name) <= 25 + served: true + storage: true + subresources: + status: {} diff --git a/api/entities/access.go b/api/entities/access.go new file mode 100644 index 0000000..8ce1ce3 --- /dev/null +++ b/api/entities/access.go @@ -0,0 +1,17 @@ +package entities + +type AccessEntity interface { + // TypeIdentifier is a lower-cased type identifier for the AccessEntity. It is used as a prefix for namespace-scoped resources such as role bindings. + TypeIdentifier() string + + // GetName returns the name of the AccessEntity instance. + GetName() string +} + +type AccessRole interface { + // EntityType references the AccessEntity that this role belongs to. + EntityType() AccessEntity + + // Identifier is a lower-cased type identifier for this role, scoped to the AccessEntity returned by EntityType(). + Identifier() string +} diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..c71382e --- /dev/null +++ b/api/go.mod @@ -0,0 +1,77 @@ +module github.com/openmcp-project/project-workspace-operator/api + +go 1.23.0 + +toolchain go1.23.6 + +require ( + github.com/google/uuid v1.6.0 + github.com/onsi/ginkgo/v2 v2.22.2 + github.com/onsi/gomega v1.36.2 + github.com/openmcp-project/controller-utils v0.4.1 + github.com/stretchr/testify v1.10.0 + 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 + 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/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-logr/logr v1.4.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/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-20241210010833-40e02aabc2ad // 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/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/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.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.28.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/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 0000000..1ce50f9 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,184 @@ +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.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/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.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.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-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/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/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/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/openmcp-project/controller-utils v0.4.1 h1:V+NqE3LK+AkbsCn1o75a+zGB+629T/HPEYx+0plpck0= +github.com/openmcp-project/controller-utils v0.4.1/go.mod h1:KSLlm8xeSXzIXX9JzkG0gNFoAjmS/JBdfU+OxrskrPQ= +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.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/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.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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.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/api/v1alpha1/common_types.go b/api/v1alpha1/common_types.go new file mode 100644 index 0000000..6474ca3 --- /dev/null +++ b/api/v1alpha1/common_types.go @@ -0,0 +1,41 @@ +package v1alpha1 + +import ( + "fmt" + + rbacv1 "k8s.io/api/rbac/v1" +) + +var ( + CreatedByAnnotation = fmt.Sprintf("%s/created-by", GroupVersion.Group) + DisplayNameAnnotation = fmt.Sprintf("%s/display-name", GroupVersion.Group) +) + +// Subject contains a reference to the object or user identities a role binding applies to. This can either hold a direct API object reference, +// or a value for non-objects such as user and group names. +// +kubebuilder:validation:XValidation:rule="self.kind == 'ServiceAccount' || !has(self.__namespace__)",message="Namespace must not be specified if Kind is User or Group" +// +kubebuilder:validation:XValidation:rule="self.kind != 'ServiceAccount' || has(self.__namespace__)",message="Namespace is required for ServiceAccount" +type Subject struct { + // Kind of object being referenced. Can be "User", "Group", or "ServiceAccount". + // +kubebuilder:validation:Enum=User;Group;ServiceAccount + Kind string `json:"kind"` + + // Name of the object being referenced. + Name string `json:"name"` + + // Namespace of the referenced object. Required if Kind is "ServiceAccount". Must not be specified if Kind is "User" or "Group". + // +optional + Namespace string `json:"namespace,omitempty"` +} + +func (s Subject) RbacV1() rbacv1.Subject { + rs := rbacv1.Subject{ + Kind: s.Kind, + Name: s.Name, + Namespace: s.Namespace, + } + if s.Kind != rbacv1.ServiceAccountKind { + rs.APIGroup = rbacv1.GroupName + } + return rs +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..45d9545 --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1alpha1 contains API Schema definitions for the v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=openmcp.cloud +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "openmcp.cloud", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..f99468e --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,20 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Subject) DeepCopyInto(out *Subject) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Subject. +func (in *Subject) DeepCopy() *Subject { + if in == nil { + return nil + } + out := new(Subject) + in.DeepCopyInto(out) + return out +} diff --git a/charts/project-workspace-operator/.helmignore b/charts/project-workspace-operator/.helmignore new file mode 100644 index 0000000..684b32b --- /dev/null +++ b/charts/project-workspace-operator/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +README.md diff --git a/charts/project-workspace-operator/Chart.yaml b/charts/project-workspace-operator/Chart.yaml new file mode 100644 index 0000000..a555f2f --- /dev/null +++ b/charts/project-workspace-operator/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: btpflm-project-workspace-operator +description: A Helm chart for the project-workspace-operator +type: application +version: v0.9.0 +appVersion: v0.9.0 diff --git a/charts/project-workspace-operator/templates/.gitignore b/charts/project-workspace-operator/templates/.gitignore new file mode 100644 index 0000000..184f2b4 --- /dev/null +++ b/charts/project-workspace-operator/templates/.gitignore @@ -0,0 +1,2 @@ +.idea/* +README.md diff --git a/charts/project-workspace-operator/templates/_helpers.tpl b/charts/project-workspace-operator/templates/_helpers.tpl new file mode 100644 index 0000000..c57b744 --- /dev/null +++ b/charts/project-workspace-operator/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "operator.labels" -}} +helm.sh/chart: {{ include "operator.chart" . }} +{{ include "operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/project-workspace-operator/templates/deployment.yaml b/charts/project-workspace-operator/templates/deployment.yaml new file mode 100644 index 0000000..e265046 --- /dev/null +++ b/charts/project-workspace-operator/templates/deployment.yaml @@ -0,0 +1,225 @@ +{{ $clustersConfigMountPath := "/var/run/secrets/clusters" }} +{{ $operatorConfigMountPath := "/var/run/configs/operator" }} + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.deployment.replicaCount }} + minReadySeconds: {{ .Values.deployment.minReadySeconds }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: {{ .Values.deployment.maxSurge }} + maxUnavailable: {{ .Values.deployment.maxUnavailable }} + selector: + matchLabels: + {{- include "operator.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + openmcp.cloud/topology: project-workspace-operator + openmcp.cloud/topology-ns: {{ .Release.Namespace }} + {{- include "operator.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "operator.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /project-workspace-operator + args: + - start + - "--metrics-bind-address={{ .Values.metrics.listen.host }}:{{ .Values.metrics.listen.port }}" + {{- if .Values.webhooks.listen }} + - "--webhooks-bind-address={{ .Values.webhooks.listen.host }}:{{ .Values.webhooks.listen.port }}" + {{- end }} + {{- if .Values.webhooks.memberOverrides }} + - "--use-member-overrides={{ .Values.webhooks.memberOverrides.memberOverridesName }}" + {{- end }} + {{- if .Values.deployment.leaderElection.enabled }} + - --leader-elect + - --lease-namespace={{ .Values.deployment.leaderElection.leaseNamespace }} + {{- end }} + {{- if and .Values.clusters .Values.clusters.crate }} + - --crate-cluster={{ $clustersConfigMountPath }}/crate + {{- end }} + {{- if .Values.config }} + - --config={{ $operatorConfigMountPath }}/config.yaml + {{- end }} + {{- with .Values.manager.args }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.manager.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + {{- if .Values.webhooks.listen }} + - name: webhooks-https + containerPort: {{ .Values.webhooks.listen.port }} + protocol: TCP + {{- end }} + - name: metrics-http + containerPort: {{ .Values.metrics.listen.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- if .Values.clusters }} + - name: clusters + mountPath: {{ $clustersConfigMountPath }} + readOnly: true + {{- end }} + {{- if .Values.config }} + - name: config + mountPath: {{ $operatorConfigMountPath }} + readOnly: true + {{- end }} + {{- if .Values.webhooks.listen }} + - name: webhooks-tls + mountPath: /tmp/k8s-webhook-server/serving-certs + readOnly: true + {{- end }} + {{- with .Values.manager.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.manager.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_SERVICE_ACCOUNT + valueFrom: + fieldRef: + fieldPath: spec.serviceAccountName + {{- with .Values.manager.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.manager.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- if .Values.webhooks.listen }} + - name: webhooks-tls + secret: + defaultMode: 420 + secretName: {{ include "operator.fullname" . }}-webhooks-tls + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + + {{- if .Values.config }} + - name: config + secret: + defaultMode: 420 + secretName: {{ include "operator.fullname" . }}-config + {{- end }} + + {{- if .Values.clusters }} + {{- $clusterOperatorName := include "operator.fullname" . }} + - name: clusters + projected: + sources: + {{- range $cname, $cvalues := .Values.clusters }} + {{- if $cvalues.kubeconfig }} + - secret: + name: {{ $clusterOperatorName }}-{{ $cname }}-cluster + items: + - key: kubeconfig + path: {{ $cname }}/kubeconfig + {{- else }} + - secret: + name: {{ $clusterOperatorName }}-{{ $cname }}-cluster + items: + - key: host + path: {{ $cname }}/host + {{- if $cvalues.caData }} + - key: caData + path: {{ $cname }}/ca.crt + {{- end }} + - serviceAccountToken: + path: {{ $cname }}/token + expirationSeconds: 7200 + audience: {{ $cvalues.audience }} + {{- if $cvalues.caConfigMapName }} + - configMap: + name: {{ $cvalues.caConfigMapName }} + items: + - key: ca.crt + path: {{ $cname }}/ca.crt + {{- end }} + {{- end }} + {{- end }} + {{- end }} + + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.deployment.topologySpreadConstraints.enabled }} + topologySpreadConstraints: + - maxSkew: {{ .Values.deployment.topologySpreadConstraints.maxSkew }} + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + openmcp.cloud/topology: project-workspace-operator + openmcp.cloud/topology-ns: {{ .Release.Namespace }} + - maxSkew: {{ .Values.deployment.topologySpreadConstraints.maxSkew }} + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + openmcp.cloud/topology: project-workspace-operator + openmcp.cloud/topology-ns: {{ .Release.Namespace }} + {{- end }} diff --git a/charts/project-workspace-operator/templates/init-job.yaml b/charts/project-workspace-operator/templates/init-job.yaml new file mode 100644 index 0000000..1d492c8 --- /dev/null +++ b/charts/project-workspace-operator/templates/init-job.yaml @@ -0,0 +1,156 @@ +{{- if .Values.init.enabled }} + +{{ $clustersConfigMountPath := "/var/run/secrets/clusters" }} + +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "operator.fullname" . }}-init + labels: + labels: + {{- include "operator.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation +spec: + template: + metadata: + name: {{ include "operator.fullname" . }}-init + labels: + {{- include "operator.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "operator.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + restartPolicy: Never + containers: + - name: {{ .Chart.Name }}-init + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /project-workspace-operator + args: + - init + {{- if .Values.crds.manage }} + - "--install-crds" + {{- end }} + {{- if .Values.webhooks.manage }} + - "--install-webhooks" + {{- if .Values.webhooks.url }} + - "--webhooks-base-url={{ .Values.webhooks.url }}" + - "--webhooks-without-ca" + {{- end }} + {{- end }} + {{- if and .Values.clusters .Values.clusters.crate }} + - --crate-cluster={{ $clustersConfigMountPath }}/crate + {{- end }} + {{- with .Values.init.args }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.init.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_SERVICE_ACCOUNT + valueFrom: + fieldRef: + fieldPath: spec.serviceAccountName + {{- if .Values.webhooks.manage }} + - name: WEBHOOK_SECRET_NAME + value: {{ include "operator.fullname" . }}-webhooks-tls + - name: WEBHOOK_SECRET_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: WEBHOOK_SERVICE_NAME + value: {{ include "operator.fullname" . }}-webhooks + - name: WEBHOOK_SERVICE_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- end }} + {{- with .Values.init.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.init.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.clusters }} + volumeMounts: + - name: clusters + mountPath: {{ $clustersConfigMountPath }} + readOnly: true + {{- end }} + {{- with .Values.init.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.init.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + + volumes: + {{- if .Values.webhooks.listen }} + - name: webhooks-tls + secret: + defaultMode: 420 + secretName: {{ include "operator.fullname" . }}-webhooks-tls + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + + {{- if .Values.clusters }} + {{- $clusterOperatorName := include "operator.fullname" . }} + - name: clusters + projected: + sources: + {{- range $cname, $cvalues := .Values.clusters }} + {{- if $cvalues.kubeconfig }} + - secret: + name: {{ $clusterOperatorName }}-{{ $cname }}-cluster + items: + - key: kubeconfig + path: {{ $cname }}/kubeconfig + {{- else }} + - secret: + name: {{ $clusterOperatorName }}-{{ $cname }}-cluster + items: + - key: host + path: {{ $cname }}/host + {{- if $cvalues.caData }} + - key: caData + path: {{ $cname }}/ca.crt + {{- end }} + - serviceAccountToken: + path: {{ $cname }}/token + expirationSeconds: 7200 + audience: {{ $cvalues.audience }} + {{- if $cvalues.caConfigMapName }} + - configMap: + name: {{ $cvalues.caConfigMapName }} + items: + - key: ca.crt + path: {{ $cname }}/ca.crt + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/project-workspace-operator/templates/rbac.yaml b/charts/project-workspace-operator/templates/rbac.yaml new file mode 100644 index 0000000..b928574 --- /dev/null +++ b/charts/project-workspace-operator/templates/rbac.yaml @@ -0,0 +1,66 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +rules: + - apiGroups: ["admissionregistration.k8s.io"] + resources: + - validatingwebhookconfigurations + - mutatingwebhookconfigurations + verbs: ["*"] + - apiGroups: ["apiextensions.k8s.io"] + resources: + - customresourcedefinitions + verbs: ["*"] + {{- with .Values.rbac.clusterRole.rules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "operator.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ include "operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["*"] + resourceNames: + - {{ include "operator.fullname" . }}-webhooks-tls + {{- with .Values.rbac.role.rules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "operator.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ include "operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +--- diff --git a/charts/project-workspace-operator/templates/secret-config.yaml b/charts/project-workspace-operator/templates/secret-config.yaml new file mode 100644 index 0000000..6622192 --- /dev/null +++ b/charts/project-workspace-operator/templates/secret-config.yaml @@ -0,0 +1,11 @@ +{{- if .Values.config }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "operator.fullname" . }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "operator.labels" . | nindent 4 }} +data: + config.yaml: {{ .Values.config | toYaml | b64enc }} +{{- end }} diff --git a/charts/project-workspace-operator/templates/secret-webhooks.yaml b/charts/project-workspace-operator/templates/secret-webhooks.yaml new file mode 100644 index 0000000..7148049 --- /dev/null +++ b/charts/project-workspace-operator/templates/secret-webhooks.yaml @@ -0,0 +1,9 @@ +{{- if .Values.webhooks.listen }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "operator.fullname" . }}-webhooks-tls + labels: + {{- include "operator.labels" . | nindent 4 }} +type: Opaque +{{- end }} diff --git a/charts/project-workspace-operator/templates/secrets-common-clusters.yaml b/charts/project-workspace-operator/templates/secrets-common-clusters.yaml new file mode 100644 index 0000000..0ca697b --- /dev/null +++ b/charts/project-workspace-operator/templates/secrets-common-clusters.yaml @@ -0,0 +1,17 @@ +{{- if .Values.clusters }} +{{- $operatorName := include "operator.fullname" . }} +{{- $operatorLabels := include "operator.labels" . }} +{{- range $cname, $cvalues := .Values.clusters }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $operatorName }}-{{ $cname }}-cluster + labels: + {{- $operatorLabels | nindent 4 }} +data: + {{- range $k, $v := $cvalues }} + {{ $k }}: {{ $v | b64enc }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/project-workspace-operator/templates/service-metrics.yaml b/charts/project-workspace-operator/templates/service-metrics.yaml new file mode 100644 index 0000000..6d7360a --- /dev/null +++ b/charts/project-workspace-operator/templates/service-metrics.yaml @@ -0,0 +1,17 @@ +{{- if .Values.metrics.service.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "operator.fullname" . }}-metrics + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + type: {{ .Values.metrics.service.type }} + ports: + - port: {{ .Values.metrics.service.port }} + targetPort: metrics-http + protocol: TCP + name: http + selector: + {{- include "operator.selectorLabels" . | nindent 4 }} +{{- end -}} diff --git a/charts/project-workspace-operator/templates/service-webhooks.yaml b/charts/project-workspace-operator/templates/service-webhooks.yaml new file mode 100644 index 0000000..4a79d50 --- /dev/null +++ b/charts/project-workspace-operator/templates/service-webhooks.yaml @@ -0,0 +1,17 @@ +{{- if .Values.webhooks.service.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "operator.fullname" . }}-webhooks + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + type: {{ .Values.webhooks.service.type }} + ports: + - port: {{ .Values.webhooks.service.port }} + targetPort: webhooks-https + protocol: TCP + name: https + selector: + {{- include "operator.selectorLabels" . | nindent 4 }} +{{- end -}} diff --git a/charts/project-workspace-operator/templates/serviceaccount.yaml b/charts/project-workspace-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..a0d3118 --- /dev/null +++ b/charts/project-workspace-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "operator.serviceAccountName" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/project-workspace-operator/values.yaml b/charts/project-workspace-operator/values.yaml new file mode 100644 index 0000000..53fbba3 --- /dev/null +++ b/charts/project-workspace-operator/values.yaml @@ -0,0 +1,156 @@ +# Default values for project-workspace-operator. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +deployment: + replicaCount: 1 + minReadySeconds: 5 + maxSurge: 1 + maxUnavailable: 0 + + topologySpreadConstraints: + enabled: false + maxSkew: 1 + + leaderElection: + enabled: false + leaseNamespace: default + +image: + repository: deploy-releases-hyperspace-docker.common.repositories.cloud.sap/openmcp/project-workspace-operator + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: v0.9.0 + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +config: + # project: + # resourcesBlockingDeletion: + # - group: "" + # version: "v1" + # kind: "Secret" + # workspace: + # resourcesBlockingDeletion: + # - group: "" + # version: "v1" + # kind: "Secret" + +clusters: + # crate: + # # specify either kubeconfig or host, audience, and one of caData or caConfigMapName. + # kubeconfig: | + # apiVersion: v1 + # clusters: + # - cluster: ... + # host: https://api.mycluster.com + # audience: ... + # caData: ... + # caConfigMapName: ... + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +init: + enabled: true + + args: [] + extraArgs: [] + + env: [] + # Extra environment variables to add to the init container. + extraEnv: [] + + # Volumes to mount to the init container. + volumeMounts: [] + # Extra volumes to mount to the init container. + extraVolumeMounts: [] + +manager: + args: [] + extraArgs: [] + + env: [] + # Extra environment variables to add to the manager container. + extraEnv: [] + + # Volumes to mount to the manager container. + volumeMounts: [] + # Extra volumes to mount to the manager container. + extraVolumeMounts: [] + +# Volumes to pass to pod. +volumes: [] + +# Extra volumes to pass to pod. +extraVolumes: [] + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: + runAsNonRoot: true + runAsUser: 65532 # nonroot user id for gcr.io/distroless/static:nonroot + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsUser: 1000 + +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +crds: + manage: true + +metrics: + listen: + port: 8080 + service: + enabled: false + port: 8080 + type: ClusterIP + annotations: {} + +webhooks: + manage: true + url: "" + listen: + port: 9443 + service: + enabled: true + port: 443 + type: ClusterIP + annotations: {} + # memberOverrides: + # memberOverridesName: override-1 + +rbac: + clusterRole: + rules: [] + role: + rules: [] + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/cmd/project-workspace-operator/main.go b/cmd/project-workspace-operator/main.go new file mode 100644 index 0000000..7859334 --- /dev/null +++ b/cmd/project-workspace-operator/main.go @@ -0,0 +1,348 @@ +package main + +import ( + "context" + goflag "flag" + "fmt" + "os" + + "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" + + "github.com/openmcp-project/project-workspace-operator/internal/controller/core" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + // to ensure that exec-entrypoint and run can make use of them. + _ "k8s.io/client-go/plugin/pkg/client/auth" + + openmcpctrlutil "github.com/openmcp-project/controller-utils/pkg/controller" + "github.com/openmcp-project/controller-utils/pkg/logging" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + + "github.com/openmcp-project/controller-utils/pkg/init/crds" + "github.com/openmcp-project/controller-utils/pkg/init/webhooks" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwocrds "github.com/openmcp-project/project-workspace-operator/api/crds" + //+kubebuilder:scaffold:imports +) + +const ( + controllerName = "project-workspace-operator" +) + +var ( + scheme = core.Scheme +) + +func main() { + cmd := NewProjectWorkspaceOperatorCommand() + + if err := cmd.Execute(); err != nil { + fmt.Print(err) + os.Exit(1) + } +} + +func NewProjectWorkspaceOperatorCommand() *cobra.Command { + options := NewOptions() + + cmd := &cobra.Command{ + Use: "project-workspace-operator", + Short: "Runs the Project/Workspace Operator", + RunE: func(cmd *cobra.Command, args []string) error { + return fmt.Errorf("no command specified") + }, + } + cmd.PersistentFlags().AddGoFlagSet(goflag.CommandLine) + + cmd.AddCommand(newProjectWorkspaceOperatorInitCommand(options)) + cmd.AddCommand(newProjectWorkspaceOperatorStartCommand(options)) + + return cmd +} + +func (o *Options) run() { + runContext := context.Background() + setupLog := o.Log.WithName("setup") + + crateClient, err := client.New(o.CrateClusterConfig, client.Options{Scheme: scheme}) + if err != nil { + setupLog.Error(err, "unable to create crate client") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(o.CrateClusterConfig, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: o.MetricsAddr}, + HealthProbeBindAddress: o.ProbeAddr, + LeaderElection: o.EnableLeaderElection, + LeaderElectionID: "pwo.openmcp.cloud", + LeaderElectionNamespace: o.LeaseNamespace, + // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily + // when the Manager ends. This requires the binary to immediately end when the + // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + // speeds up voluntary leader transitions as the new leader don't have to wait + // LeaseDuration time first. + // + // In the default scaffold provided, the program ends immediately after + // the manager stops, so would be fine to enable this option. However, + // if you are doing or is intended to do any operation such as perform cleanups + // after the manager stops then its usage might be unsafe. + // LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + rbacSetup := core.NewRBACSetup(setupLog.Logr(), crateClient, controllerName) + if err := rbacSetup.EnsureResources(runContext); err != nil { + setupLog.Error(err, "unable to create or update RBAC resources") + os.Exit(1) + } + + commonReconciler := core.CommonReconciler{ + Client: mgr.GetClient(), + ControllerName: controllerName, + ProjectWorkspaceConfig: o.ProjectWorkspaceConfig, + } + + if err = (&core.ProjectReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + CommonReconciler: commonReconciler, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Project") + os.Exit(1) + } + + if err = (&core.WorkspaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + CommonReconciler: commonReconciler, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Workspace") + os.Exit(1) + } + + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err = (&openmcpv1alpha1.Project{}).SetupWebhookWithManager(mgr, *o.MemberOverridesName); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Project") + os.Exit(1) + } + + if err = (&openmcpv1alpha1.Workspace{}).SetupWebhookWithManager(mgr, *o.MemberOverridesName); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Workspace") + os.Exit(1) + } + } + //+kubebuilder:scaffold:builder + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} + +func newProjectWorkspaceOperatorInitCommand(options *Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Installs Webhooks and CRDs for the Project/Workspace Operator", + Run: func(cmd *cobra.Command, args []string) { + options.runInit() + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return options.Complete() + }, + } + + options.AddInitFlags(cmd.Flags(), cmd.PersistentFlags()) + + return cmd +} + +func newProjectWorkspaceOperatorStartCommand(options *Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Starts the Project/Workspace Operator", + Run: func(cmd *cobra.Command, args []string) { + options.run() + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return options.Complete() + }, + } + + options.AddStartFlags(cmd.Flags(), cmd.PersistentFlags()) + + return cmd +} + +func (o *Options) runInit() { + initContext := context.Background() + setupLog := o.Log.WithName("setup") + + setupClient, err := client.New(o.HostClusterConfig, client.Options{Scheme: scheme}) + if err != nil { + setupLog.Error(err, "unable to create setup client") + os.Exit(1) + } + crateClient, err := client.New(o.CrateClusterConfig, client.Options{Scheme: scheme}) + if err != nil { + setupLog.Error(err, "unable to create crate client") + os.Exit(1) + } + + if o.WebhooksFlags.Install { + // Generate webhook certificate + if err := webhooks.GenerateCertificate(initContext, setupClient, o.WebhooksFlags.CertOptions...); err != nil { + setupLog.Error(err, "unable to generate webhook certificates") + os.Exit(1) + } + + installOptions := o.WebhooksFlags.InstallOptions + installOptions = append(installOptions, webhooks.WithRemoteClient{Client: crateClient}) + + // Install webhooks + err := webhooks.Install( + initContext, + setupClient, + scheme, + []client.Object{ + &openmcpv1alpha1.Project{}, + &openmcpv1alpha1.Workspace{}, + }, + installOptions..., + ) + if err != nil { + setupLog.Error(err, "unable to configure webhooks") + os.Exit(1) + } + } + + if o.CRDFlags.Install { + installOptions := o.CRDFlags.InstallOptions + installOptions = append(installOptions, crds.WithRemoteClient{Client: crateClient}) + + // Install CRDs + if err := crds.Install(initContext, setupClient, pwocrds.CRDFS, installOptions...); err != nil { + setupLog.Error(err, "unable to install Custom Resource Definitions") + os.Exit(1) + } + } +} + +// rawOptions contains the options specified directly via the command line. +// The Options struct then contains these as embedded struct and additionally some options that were derived from the raw options (e.g. by loading files or interpreting raw options). +type rawOptions struct { + // controller-runtime stuff + MetricsAddr string + EnableLeaderElection bool + LeaseNamespace string + ProbeAddr string + + // raw options that need to be evaluated + CrateClusterPath string + ProjectWorkspaceConfigPath string + + // raw options that are final +} + +type Options struct { + rawOptions + + // completed options from raw options + Log logging.Logger + HostClusterConfig *rest.Config + CrateClusterConfig *rest.Config + CRDFlags *crds.Flags + WebhooksFlags *webhooks.Flags + MemberOverridesName *string + ProjectWorkspaceConfig *config.ProjectWorkspaceConfig +} + +func NewOptions() *Options { + return &Options{ + CRDFlags: crds.BindFlags(goflag.CommandLine), + WebhooksFlags: webhooks.BindFlags(goflag.CommandLine), + // MemberOverridesFlags: &MemberOverridesOptions{}, + } +} + +func (o *Options) addCommonFlags(fs *flag.FlagSet) { + fs.StringVar(&o.CrateClusterPath, "crate-cluster", "", "Path to the crate cluster kubeconfig file or directory containing either a kubeconfig or host, token, and ca file. Leave empty to use in-cluster config.") +} + +func (o *Options) AddStartFlags(fs *flag.FlagSet, ps *flag.FlagSet) { + // standard stuff + fs.StringVar(&o.MetricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + fs.StringVar(&o.ProbeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + fs.BoolVar(&o.EnableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.") + fs.StringVar(&o.LeaseNamespace, "lease-namespace", "default", "Namespace in which the controller manager's leader election lease will be created.") + fs.StringVar(&o.ProjectWorkspaceConfigPath, "config", "", "Path to the project workspace config file.") + + o.MemberOverridesName = fs.String("use-member-overrides", "", "Specify a MemberOverrides resources name.") + // add common flags + o.addCommonFlags(fs) + + // custom stuff + logging.InitFlags(fs) + + // fs.StringVar(o.MemberOverridesFlags.MemberOverridesName, "use-member-overrides-name", "", "Specify a MemberOverrides resources name.") +} + +func (o *Options) AddInitFlags(fs *flag.FlagSet, ps *flag.FlagSet) { + // add common flags + o.addCommonFlags(fs) +} + +func (o *Options) Complete() error { + // build logger + log, err := logging.GetLogger() + if err != nil { + return err + } + o.Log = log + ctrl.SetLogger(o.Log.Logr()) + + // load kubeconfigs + o.HostClusterConfig = ctrl.GetConfigOrDie() + if o.CrateClusterConfig, err = openmcpctrlutil.LoadKubeconfig(o.CrateClusterPath); err != nil { + return err + } + + if o.ProjectWorkspaceConfigPath != "" { + o.ProjectWorkspaceConfig, err = config.LoadConfig(o.ProjectWorkspaceConfigPath) + if err != nil { + return err + } + } + + if o.ProjectWorkspaceConfig == nil { + o.ProjectWorkspaceConfig = &config.ProjectWorkspaceConfig{} + } + + o.ProjectWorkspaceConfig.SetDefaults() + + if err = o.ProjectWorkspaceConfig.Validate(); err != nil { + return err + } + + return nil +} diff --git a/cmd/project-workspace-operator/main_test.go b/cmd/project-workspace-operator/main_test.go new file mode 100644 index 0000000..a94bf0f --- /dev/null +++ b/cmd/project-workspace-operator/main_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_NewProjectWorkspaceOperatorCommand(t *testing.T) { + cmd := NewProjectWorkspaceOperatorCommand() + assert.NotEmpty(t, cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotNil(t, cmd.RunE) + assert.True(t, cmd.HasSubCommands()) + + cmds := cmd.Commands() + assert.Len(t, cmds, 2) + + cmdNames := []string{} + for _, c := range cmds { + cmdNames = append(cmdNames, c.Name()) + } + + assert.Contains(t, cmdNames, "start") + assert.Contains(t, cmdNames, "init") +} + +func Test_Options_Complete(t *testing.T) { + testCases := []struct { + desc string + kubeconfigPath string + crateClusterPath string + expected error + }{ + { + desc: "should load kubeconfig when specified directly", + kubeconfigPath: "testdata/kubeconfig", + crateClusterPath: "testdata/kubeconfig", + }, + { + desc: "should load kubeconfig when present in directory", + kubeconfigPath: "testdata/kubeconfig", + crateClusterPath: "testdata", + }, + { + desc: "should build rest.Config when using OIDC trust", + kubeconfigPath: "testdata/kubeconfig", + crateClusterPath: "testdata/oidc", + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + o := &Options{ + rawOptions: rawOptions{ + CrateClusterPath: tC.crateClusterPath, + }, + } + os.Setenv("KUBECONFIG", tC.kubeconfigPath) + err := o.Complete() + assert.Equal(t, tC.expected, err) + }) + } +} diff --git a/cmd/project-workspace-operator/testdata/kubeconfig b/cmd/project-workspace-operator/testdata/kubeconfig new file mode 100644 index 0000000..7fd11dd --- /dev/null +++ b/cmd/project-workspace-operator/testdata/kubeconfig @@ -0,0 +1,16 @@ +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 +users: + - name: shoot--unit-test + user: + token: >- + test-token-invalid diff --git a/cmd/project-workspace-operator/testdata/oidc/ca.crt b/cmd/project-workspace-operator/testdata/oidc/ca.crt new file mode 100644 index 0000000..7b2ad0a --- /dev/null +++ b/cmd/project-workspace-operator/testdata/oidc/ca.crt @@ -0,0 +1 @@ +definitely a valid CA cert file :) diff --git a/cmd/project-workspace-operator/testdata/oidc/host b/cmd/project-workspace-operator/testdata/oidc/host new file mode 100644 index 0000000..aa1d127 --- /dev/null +++ b/cmd/project-workspace-operator/testdata/oidc/host @@ -0,0 +1 @@ +https://api.example.com diff --git a/cmd/project-workspace-operator/testdata/oidc/token b/cmd/project-workspace-operator/testdata/oidc/token new file mode 100644 index 0000000..0ded04d --- /dev/null +++ b/cmd/project-workspace-operator/testdata/oidc/token @@ -0,0 +1 @@ +test-token-invalid diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 0000000..37373cf --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -0,0 +1,39 @@ +# The following manifests contain a self-signed issuer CR and a certificate CR. +# More document can be found at https://docs.cert-manager.io +# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + labels: + app.kubernetes.io/name: certificate + app.kubernetes.io/instance: serving-cert + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + app.kubernetes.io/name: certificate + app.kubernetes.io/instance: serving-cert + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml + namespace: system +spec: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + dnsNames: + - SERVICE_NAME.SERVICE_NAMESPACE.svc + - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 0000000..bebea5a --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- certificate.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 0000000..cf6f89e --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -0,0 +1,8 @@ +# This configuration is for teaching kustomize how to update name ref substitution +nameReference: +- kind: Issuer + group: cert-manager.io + fieldSpecs: + - kind: Certificate + group: cert-manager.io + path: spec/issuerRef/name diff --git a/config/crd/bases/core.openmcp.cloud_memberoverrides.yaml b/config/crd/bases/core.openmcp.cloud_memberoverrides.yaml new file mode 100644 index 0000000..16f7b05 --- /dev/null +++ b/config/crd/bases/core.openmcp.cloud_memberoverrides.yaml @@ -0,0 +1,106 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: memberoverrides.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: MemberOverrides + listKind: MemberOverridesList + plural: memberoverrides + singular: memberoverrides + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: MemberOverrides is a resource used to Manage admin access to + the Project/Workspace operator resources. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + memberOverrides: + items: + properties: + kind: + description: Kind of object being referenced. Can be "User", + "Group", or "ServiceAccount". + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the object being referenced. + type: string + namespace: + description: Namespace of the referenced object. Required if + Kind is "ServiceAccount". Must not be specified if Kind is + "User" or "Group". + type: string + resources: + description: Resources defines an optional list of projects/workspaces + that this override applies to. + items: + properties: + kind: + enum: + - project + - workspace + type: string + name: + description: Name of the object being referenced. + type: string + required: + - kind + - name + type: object + type: array + roles: + description: Roles defines a list of roles that this override + subject should have. + items: + enum: + - admin + - view + type: string + type: array + required: + - kind + - name + - roles + type: object + x-kubernetes-validations: + - message: Namespace must not be specified if Kind is User or Group + rule: self.kind == 'ServiceAccount' || !has(self.__namespace__) + - message: Namespace is required for ServiceAccount + rule: self.kind != 'ServiceAccount' || has(self.__namespace__) + type: array + required: + - memberOverrides + type: object + status: + type: object + type: object + served: true + storage: true diff --git a/config/crd/bases/core.openmcp.cloud_projects.yaml b/config/crd/bases/core.openmcp.cloud_projects.yaml new file mode 100644 index 0000000..b0d7199 --- /dev/null +++ b/config/crd/bases/core.openmcp.cloud_projects.yaml @@ -0,0 +1,145 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: projects.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: Project + listKind: ProjectList + plural: projects + singular: project + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.annotations.openmcp\.cloud/display-name + name: Display Name + type: string + - jsonPath: .status.namespace + name: Resulting Namespace + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Project is the Schema for the projects API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ProjectSpec defines the desired state of Project + properties: + members: + description: Members is a list of project members. + items: + properties: + kind: + description: Kind of object being referenced. Can be "User", + "Group", or "ServiceAccount". + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the object being referenced. + type: string + namespace: + description: Namespace of the referenced object. Required if + Kind is "ServiceAccount". Must not be specified if Kind is + "User" or "Group". + type: string + roles: + description: Roles defines a list of roles that this project + member should have. + items: + enum: + - admin + - view + type: string + type: array + required: + - kind + - name + - roles + type: object + x-kubernetes-validations: + - message: Namespace must not be specified if Kind is User or Group + rule: self.kind == 'ServiceAccount' || !has(self.__namespace__) + - message: Namespace is required for ServiceAccount + rule: self.kind != 'ServiceAccount' || has(self.__namespace__) + type: array + type: object + status: + description: ProjectStatus defines the observed state of Project + properties: + conditions: + items: + description: Condition is part of all conditions that a project/ + workspace can have. + properties: + details: + description: |- + Details is an object that can contain additional information about the condition. + The content is specific to the condition type. + x-kubernetes-preserve-unknown-fields: true + lastTransitionTime: + description: LastTransitionTime is the time when the condition + last transitioned from one status to another. + format: date-time + type: string + message: + description: Message is a human-readable message indicating + details about the condition. + type: string + reason: + description: Reason is the reason for the condition. + type: string + status: + description: Status is the status of the condition. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: Type is the type of the condition. + type: string + required: + - status + - type + type: object + type: array + namespace: + type: string + required: + - namespace + type: object + type: object + x-kubernetes-validations: + - message: Name must not be longer than 25 characters + rule: size(self.metadata.name) <= 25 + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.openmcp.cloud_workspaces.yaml b/config/crd/bases/core.openmcp.cloud_workspaces.yaml new file mode 100644 index 0000000..72cd8e0 --- /dev/null +++ b/config/crd/bases/core.openmcp.cloud_workspaces.yaml @@ -0,0 +1,147 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: workspaces.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: Workspace + listKind: WorkspaceList + plural: workspaces + shortNames: + - ws + singular: workspace + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.annotations.openmcp\.cloud/display-name + name: Display Name + type: string + - jsonPath: .status.namespace + name: Resulting Namespace + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Workspace is the Schema for the workspaces API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WorkspaceSpec defines the desired state of Workspace + properties: + members: + description: Members is a list of workspace members. + items: + properties: + kind: + description: Kind of object being referenced. Can be "User", + "Group", or "ServiceAccount". + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the object being referenced. + type: string + namespace: + description: Namespace of the referenced object. Required if + Kind is "ServiceAccount". Must not be specified if Kind is + "User" or "Group". + type: string + roles: + description: Roles defines a list of roles that this workspace + member should have. + items: + enum: + - admin + - view + type: string + type: array + required: + - kind + - name + - roles + type: object + x-kubernetes-validations: + - message: Namespace must not be specified if Kind is User or Group + rule: self.kind == 'ServiceAccount' || !has(self.__namespace__) + - message: Namespace is required for ServiceAccount + rule: self.kind != 'ServiceAccount' || has(self.__namespace__) + type: array + type: object + status: + description: WorkspaceStatus defines the observed state of Workspace + properties: + conditions: + items: + description: Condition is part of all conditions that a project/ + workspace can have. + properties: + details: + description: |- + Details is an object that can contain additional information about the condition. + The content is specific to the condition type. + x-kubernetes-preserve-unknown-fields: true + lastTransitionTime: + description: LastTransitionTime is the time when the condition + last transitioned from one status to another. + format: date-time + type: string + message: + description: Message is a human-readable message indicating + details about the condition. + type: string + reason: + description: Reason is the reason for the condition. + type: string + status: + description: Status is the status of the condition. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: Type is the type of the condition. + type: string + required: + - status + - type + type: object + type: array + namespace: + type: string + required: + - namespace + type: object + type: object + x-kubernetes-validations: + - message: Name must not be longer than 25 characters + rule: size(self.metadata.name) <= 25 + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml new file mode 100644 index 0000000..acf2892 --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,25 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- bases/core.openmcp.cloud_projects.yaml +- bases/core.openmcp.cloud_workspaces.yaml +- bases/core.openmcp.cloud_memberoverrides.yaml +#+kubebuilder:scaffold:crdkustomizeresource + +patches: +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. +# patches here are for enabling the conversion webhook for each CRD +- path: patches/webhook_in_projects.yaml +- path: patches/webhook_in_workspaces.yaml +#+kubebuilder:scaffold:crdkustomizewebhookpatch + +# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. +# patches here are for enabling the CA injection for each CRD +- path: patches/cainjection_in_projects.yaml +- path: patches/cainjection_in_workspaces.yaml +#+kubebuilder:scaffold:crdkustomizecainjectionpatch + +# the following config is for teaching kustomize how to do kustomization for CRDs. +configurations: +- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 0000000..ec5c150 --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,19 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/crd/patches/cainjection_in_projects.yaml b/config/crd/patches/cainjection_in_projects.yaml new file mode 100644 index 0000000..cd5080b --- /dev/null +++ b/config/crd/patches/cainjection_in_projects.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: projects.core.openmcp.cloud diff --git a/config/crd/patches/cainjection_in_workspaces.yaml b/config/crd/patches/cainjection_in_workspaces.yaml new file mode 100644 index 0000000..53f1c95 --- /dev/null +++ b/config/crd/patches/cainjection_in_workspaces.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: workspaces.core.openmcp.cloud diff --git a/config/crd/patches/webhook_in_projects.yaml b/config/crd/patches/webhook_in_projects.yaml new file mode 100644 index 0000000..b18e072 --- /dev/null +++ b/config/crd/patches/webhook_in_projects.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: projects.core.openmcp.cloud +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/crd/patches/webhook_in_workspaces.yaml b/config/crd/patches/webhook_in_workspaces.yaml new file mode 100644 index 0000000..e6ae274 --- /dev/null +++ b/config/crd/patches/webhook_in_workspaces.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: workspaces.core.openmcp.cloud +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml new file mode 100644 index 0000000..2ac23eb --- /dev/null +++ b/config/default/kustomization.yaml @@ -0,0 +1,144 @@ +# Adds namespace to all resources. +namespace: project-workspace-operator-system + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: project-workspace-operator- + +# Labels to add to all resources and selectors. +#labels: +#- includeSelectors: true +# pairs: +# someName: someValue + +resources: +- ../crd +- ../rbac +- ../manager +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +- ../webhook +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. +- ../certmanager +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus + +patchesStrategicMerge: +# Protect the /metrics endpoint by putting it behind auth. +# If you want your controller-manager to expose the /metrics +# endpoint w/o any authn/z, please comment the following line. +- manager_auth_proxy_patch.yaml + + + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +- manager_webhook_patch.yaml + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. +# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. +# 'CERTMANAGER' needs to be enabled to use ca injection +- webhookcainjection_patch.yaml + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. +# Uncomment the following replacements to add the cert-manager CA injection annotations +replacements: + - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldPath: .metadata.namespace # namespace of the certificate CR + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - select: + kind: CustomResourceDefinition + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldPath: .metadata.name + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - select: + kind: CustomResourceDefinition + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - source: # Add cert-manager annotation to the webhook Service + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.name # namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 0 + create: true + - source: + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.namespace # namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 1 + create: true diff --git a/config/default/manager_auth_proxy_patch.yaml b/config/default/manager_auth_proxy_patch.yaml new file mode 100644 index 0000000..73fad2a --- /dev/null +++ b/config/default/manager_auth_proxy_patch.yaml @@ -0,0 +1,39 @@ +# This patch inject a sidecar container which is a HTTP proxy for the +# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: kube-rbac-proxy + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + image: gcr.io/kubebuilder/kube-rbac-proxy:v0.14.1 + args: + - "--secure-listen-address=0.0.0.0:8443" + - "--upstream=http://127.0.0.1:8080/" + - "--logtostderr=true" + - "--v=0" + ports: + - containerPort: 8443 + protocol: TCP + name: https + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 5m + memory: 64Mi + - name: manager + args: + - "--health-probe-bind-address=:8081" + - "--metrics-bind-address=127.0.0.1:8080" + - "--leader-elect" diff --git a/config/default/manager_config_patch.yaml b/config/default/manager_config_patch.yaml new file mode 100644 index 0000000..f6f5891 --- /dev/null +++ b/config/default/manager_config_patch.yaml @@ -0,0 +1,10 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 0000000..738de35 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml new file mode 100644 index 0000000..6e0cca3 --- /dev/null +++ b/config/default/webhookcainjection_patch.yaml @@ -0,0 +1,29 @@ +# This patch add annotation to admission webhook config and +# CERTIFICATE_NAMESPACE and CERTIFICATE_NAME will be substituted by kustomize +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: mutatingwebhookconfiguration + app.kubernetes.io/instance: mutating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: mutating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: validatingwebhookconfiguration + app.kubernetes.io/instance: validating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml new file mode 100644 index 0000000..c8c0ed0 --- /dev/null +++ b/config/manager/kustomization.yaml @@ -0,0 +1,8 @@ +resources: +- manager.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: controller + newName: project-workspace-operator + newTag: dev diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml new file mode 100644 index 0000000..5e75cdd --- /dev/null +++ b/config/manager/manager.yaml @@ -0,0 +1,102 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: namespace + app.kubernetes.io/instance: system + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system + labels: + control-plane: controller-manager + app.kubernetes.io/name: deployment + app.kubernetes.io/instance: controller-manager + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: controller-manager + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + spec: + # TODO(user): Uncomment the following code to configure the nodeAffinity expression + # according to the platforms which are supported by your solution. + # It is considered best practice to support multiple architectures. You can + # build your manager image using the makefile target docker-buildx. + # affinity: + # nodeAffinity: + # requiredDuringSchedulingIgnoredDuringExecution: + # nodeSelectorTerms: + # - matchExpressions: + # - key: kubernetes.io/arch + # operator: In + # values: + # - amd64 + # - arm64 + # - ppc64le + # - s390x + # - key: kubernetes.io/os + # operator: In + # values: + # - linux + securityContext: + runAsNonRoot: true + # TODO(user): For common cases that do not require escalating privileges + # it is recommended to ensure that all your Pods/Containers are restrictive. + # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted + # Please uncomment the following code if your project does NOT have to work on old Kubernetes + # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). + # seccompProfile: + # type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + image: controller:latest + name: manager + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + # TODO(user): Configure the resources accordingly based on the project requirements. + # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + serviceAccountName: controller-manager + terminationGracePeriodSeconds: 10 diff --git a/config/prometheus/kustomization.yaml b/config/prometheus/kustomization.yaml new file mode 100644 index 0000000..ed13716 --- /dev/null +++ b/config/prometheus/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- monitor.yaml diff --git a/config/prometheus/monitor.yaml b/config/prometheus/monitor.yaml new file mode 100644 index 0000000..fd4c0f8 --- /dev/null +++ b/config/prometheus/monitor.yaml @@ -0,0 +1,26 @@ + +# Prometheus Monitor Service (Metrics) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: servicemonitor + app.kubernetes.io/instance: controller-manager-metrics-monitor + app.kubernetes.io/component: metrics + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-monitor + namespace: system +spec: + endpoints: + - path: /metrics + port: https + scheme: https + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + tlsConfig: + insecureSkipVerify: true + selector: + matchLabels: + control-plane: controller-manager diff --git a/config/rbac/auth_proxy_client_clusterrole.yaml b/config/rbac/auth_proxy_client_clusterrole.yaml new file mode 100644 index 0000000..243977c --- /dev/null +++ b/config/rbac/auth_proxy_client_clusterrole.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: metrics-reader + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/config/rbac/auth_proxy_role.yaml b/config/rbac/auth_proxy_role.yaml new file mode 100644 index 0000000..ee5a99b --- /dev/null +++ b/config/rbac/auth_proxy_role.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: proxy-role + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: proxy-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/config/rbac/auth_proxy_role_binding.yaml b/config/rbac/auth_proxy_role_binding.yaml new file mode 100644 index 0000000..0fa8502 --- /dev/null +++ b/config/rbac/auth_proxy_role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/instance: proxy-rolebinding + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: proxy-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/auth_proxy_service.yaml b/config/rbac/auth_proxy_service.yaml new file mode 100644 index 0000000..6a4d364 --- /dev/null +++ b/config/rbac/auth_proxy_service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: service + app.kubernetes.io/instance: controller-manager-metrics-service + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + selector: + control-plane: controller-manager diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml new file mode 100644 index 0000000..731832a --- /dev/null +++ b/config/rbac/kustomization.yaml @@ -0,0 +1,18 @@ +resources: +# All RBAC will be applied under this service account in +# the deployment namespace. You may comment out this resource +# if your manager will use a service account that exists at +# runtime. Be sure to update RoleBinding and ClusterRoleBinding +# subjects if changing service account names. +- service_account.yaml +- role.yaml +- role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml +# Comment the following 4 lines if you want to disable +# the auth proxy (https://github.com/brancz/kube-rbac-proxy) +# which protects your /metrics endpoint. +- auth_proxy_service.yaml +- auth_proxy_role.yaml +- auth_proxy_role_binding.yaml +- auth_proxy_client_clusterrole.yaml diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml new file mode 100644 index 0000000..fa9c803 --- /dev/null +++ b/config/rbac/leader_election_role.yaml @@ -0,0 +1,44 @@ +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: role + app.kubernetes.io/instance: leader-election-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 0000000..f0a36eb --- /dev/null +++ b/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: rolebinding + app.kubernetes.io/instance: leader-election-rolebinding + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/project_editor_role.yaml b/config/rbac/project_editor_role.yaml new file mode 100644 index 0000000..7d48676 --- /dev/null +++ b/config/rbac/project_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit projects. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: project-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: project-editor-role +rules: +- apiGroups: + - core.openmcp.cloud + resources: + - projects + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.openmcp.cloud + resources: + - projects/status + verbs: + - get diff --git a/config/rbac/project_viewer_role.yaml b/config/rbac/project_viewer_role.yaml new file mode 100644 index 0000000..a416168 --- /dev/null +++ b/config/rbac/project_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view projects. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: project-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: project-viewer-role +rules: +- apiGroups: + - core.openmcp.cloud + resources: + - projects + verbs: + - get + - list + - watch +- apiGroups: + - core.openmcp.cloud + resources: + - projects/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 0000000..e17dd18 --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - "" + resources: + - namespaces + - secrets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.openmcp.cloud + resources: + - projects + - workspaces + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.openmcp.cloud + resources: + - projects/finalizers + - workspaces/finalizers + verbs: + - update +- apiGroups: + - core.openmcp.cloud + resources: + - projects/status + - workspaces/status + verbs: + - get + - patch + - update +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings + - clusterroles + - rolebindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml new file mode 100644 index 0000000..2d27c29 --- /dev/null +++ b/config/rbac/role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/instance: manager-rolebinding + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/service_account.yaml b/config/rbac/service_account.yaml new file mode 100644 index 0000000..add6f1f --- /dev/null +++ b/config/rbac/service_account.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: serviceaccount + app.kubernetes.io/instance: controller-manager-sa + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/config/rbac/workspace_editor_role.yaml b/config/rbac/workspace_editor_role.yaml new file mode 100644 index 0000000..c17c708 --- /dev/null +++ b/config/rbac/workspace_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit workspaces. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: workspace-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: workspace-editor-role +rules: +- apiGroups: + - core.openmcp.cloud + resources: + - workspaces + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.openmcp.cloud + resources: + - workspaces/status + verbs: + - get diff --git a/config/rbac/workspace_viewer_role.yaml b/config/rbac/workspace_viewer_role.yaml new file mode 100644 index 0000000..fbeaa0d --- /dev/null +++ b/config/rbac/workspace_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view workspaces. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: workspace-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: workspace-viewer-role +rules: +- apiGroups: + - core.openmcp.cloud + resources: + - workspaces + verbs: + - get + - list + - watch +- apiGroups: + - core.openmcp.cloud + resources: + - workspaces/status + verbs: + - get diff --git a/config/samples/_v1alpha1_memberoverride.yaml b/config/samples/_v1alpha1_memberoverride.yaml new file mode 100644 index 0000000..1fe7599 --- /dev/null +++ b/config/samples/_v1alpha1_memberoverride.yaml @@ -0,0 +1,15 @@ +apiVersion: core.openmcp.cloud/v1alpha1 +kind: MemberOverrides +metadata: + name: override1 +spec: + memberOverrides: + # user needed for local development + - kind: User + name: kubernetes-admin + resources: + - kind: project + name: two + roles: + - admin + diff --git a/config/samples/_v1alpha1_project.yaml b/config/samples/_v1alpha1_project.yaml new file mode 100644 index 0000000..a5370f6 --- /dev/null +++ b/config/samples/_v1alpha1_project.yaml @@ -0,0 +1,60 @@ +apiVersion: core.openmcp.cloud/v1alpha1 +kind: Project +metadata: + name: one + annotations: + openmcp.cloud/display-name: First Project +spec: + members: + # user needed for local development + - kind: User + name: kubernetes-admin + roles: + - admin + - kind: User + name: user-1@example.com + roles: + - admin + - kind: User + name: user-2@example.com + roles: + - view + - kind: User + name: user-3@example.com + roles: + - view +--- +apiVersion: core.openmcp.cloud/v1alpha1 +kind: Project +metadata: + name: two + annotations: + openmcp.cloud/display-name: Second Project +spec: + members: + # user needed for local development + # - kind: User + # name: kubernetes-admin + # roles: + # - admin + - kind: User + name: user-1@example.com + roles: + - view + - kind: User + name: user-2@example.com + roles: + - view + - kind: User + name: user-3@example.com + roles: + - admin +--- +apiVersion: core.openmcp.cloud/v1alpha1 +kind: Project +metadata: + name: three + annotations: + openmcp.cloud/display-name: Third Project +spec: + members: [] diff --git a/config/samples/_v1alpha1_workspace.yaml b/config/samples/_v1alpha1_workspace.yaml new file mode 100644 index 0000000..d6a7356 --- /dev/null +++ b/config/samples/_v1alpha1_workspace.yaml @@ -0,0 +1,68 @@ +apiVersion: core.openmcp.cloud/v1alpha1 +kind: Workspace +metadata: + name: dev + namespace: project-one + annotations: + openmcp.cloud/display-name: Development +spec: + members: + # user needed for local development + # - kind: User + # name: kubernetes-admin + # roles: + # - admin + - kind: User + name: user-1@example.com + roles: + - admin + - kind: User + name: user-2@example.com + roles: + - view +--- +apiVersion: core.openmcp.cloud/v1alpha1 +kind: Workspace +metadata: + name: prod + namespace: default + annotations: + openmcp.cloud/display-name: Production +spec: + members: + # user needed for local development + - kind: User + name: kubernetes-admin + roles: + - admin + - kind: User + name: user-2@example.com + roles: + - admin +--- +apiVersion: core.openmcp.cloud/v1alpha1 +kind: Workspace +metadata: + name: dev + namespace: project-two + annotations: + openmcp.cloud/display-name: Development +spec: + members: + # user needed for local development + # - kind: User + # name: kubernetes-admin + # roles: + # - admin + - kind: User + name: user-1@example.com + roles: + - view + - kind: User + name: user-2@example.com + roles: + - view + - kind: User + name: user-3@example.com + roles: + - admin diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml new file mode 100644 index 0000000..ec4fe66 --- /dev/null +++ b/config/samples/kustomization.yaml @@ -0,0 +1,5 @@ +## Append samples of your project ## +resources: +- _v1alpha1_project.yaml +- _v1alpha1_workspace.yaml +#+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/rbac_crate.yaml b/config/samples/rbac_crate.yaml new file mode 100644 index 0000000..63d8a21 --- /dev/null +++ b/config/samples/rbac_crate.yaml @@ -0,0 +1,100 @@ +apiVersion: authentication.gardener.cloud/v1alpha1 +kind: OpenIDConnect +metadata: + name: openmcp-prev-system +spec: + clientID: openmcp-prev-crate + issuerURL: https://oidc.system.cola-prev.shoot.canary.k8s-hana.ondemand.com + supportedSigningAlgs: + - RS256 + usernameClaim: sub + usernamePrefix: 'openmcp-prev-system:' +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: project-workspace-operator +rules: + - apiGroups: + - openmcp.cloud + resources: + - projects + - projects/finalizers + - projects/status + - workspaces + - workspaces/finalizers + - workspaces/status + verbs: + - "*" + - apiGroups: + - "" + resources: + - namespaces + verbs: + - "*" + - apiGroups: + - "rbac.authorization.k8s.io" + resources: + - clusterrolebindings + - clusterroles + - rolebindings + verbs: + - "*" + - apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + - mutatingwebhookconfigurations + verbs: + - "*" + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - "*" + # permissions to do leader election. + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: project-workspace-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: project-workspace-operator +subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: openmcp-prev-system:system:serviceaccount:openmcp-system:project-workspace-operator diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 0000000..9cf2613 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 0000000..206316e --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,22 @@ +# the following config is for teaching kustomize where to look at when substituting nameReference. +# It requires kustomize v2.1.0 or newer to work properly. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 0000000..6117f99 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,94 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: mutating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-core-openmcp-cloud-v1alpha1-project + failurePolicy: Fail + name: mproject.kb.io + rules: + - apiGroups: + - core.openmcp.cloud + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - projects + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-core-openmcp-cloud-v1alpha1-workspace + failurePolicy: Fail + name: mworkspace.kb.io + rules: + - apiGroups: + - core.openmcp.cloud + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - workspaces + sideEffects: None +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-core-openmcp-cloud-v1alpha1-project + failurePolicy: Fail + name: vproject.kb.io + rules: + - apiGroups: + - core.openmcp.cloud + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - projects + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-core-openmcp-cloud-v1alpha1-workspace + failurePolicy: Fail + name: vworkspace.kb.io + rules: + - apiGroups: + - core.openmcp.cloud + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - workspaces + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 0000000..bde6127 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,20 @@ + +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: service + app.kubernetes.io/instance: webhook-service + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: project-workspace-operator + app.kubernetes.io/part-of: project-workspace-operator + app.kubernetes.io/managed-by: kustomize + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager diff --git a/dev.env b/dev.env new file mode 100644 index 0000000..2804cee --- /dev/null +++ b/dev.env @@ -0,0 +1,4 @@ +ENABLE_WEBHOOKS=false + +CRATE_CLUSTER_HOST=https://api.crate.cola-prev.shoot.canary.k8s-hana.ondemand.com +CRATE_CLUSTER_CONFIG_DIR=../secrets/crate-cluster diff --git a/docs/member_overrides.md b/docs/member_overrides.md new file mode 100644 index 0000000..23a5a33 --- /dev/null +++ b/docs/member_overrides.md @@ -0,0 +1,109 @@ +# Member Overrides + +### Introduction + +The Project-Workspace Operator webhook is designed to prevent cluster user from modifying or deleting `Project` and `Workspace` resources if they are not specifically with the `admin` role in the resources `members` spec. + +Unfortunately, this blocks platform or landscape administrators from helping users when there are issues with these resources. The `MemberOverrides` resource is added to the operator webhook to address this limitation by providing a configurable escape-hatch that allows the landscape administrators to manage resources they are not a member of. + +### Usage +A single `MembersOverrides` resource is created per cluster. To actually use it, you need to explicitly pass the resource name to the operator: + +```yaml +--use-member-overrides= +``` + +This can be configured using the following helm-values-file entries: +```yaml +webhooks: + ... + memberOverrides: + memberOverridesName: landscape-admins +``` + +The `MemberOverrides` spec is modeled based on the `Project/Workspace` members spec. A full example looks like this: + + +```yaml +apiVersion: core.openmcp.cloud/v1alpha1 +kind: MemberOverrides +metadata: + name: landscape-admins +spec: + memberOverrides: + - kind: User + name: kubernetes-admin + roles: + - admin + - kind: User + name: project-two-support + resources: + - kind: project + name: two + roles: + - admin + - kind: Group + name: system:project-one-admins + resources: + - kind: project + name: one + roles: + - admin + - kind: User + name: project-three-ws-1-manager + resources: + - kind: project + name: project-three + - kind: workspace + name: workspace-1 + roles: + - admin +``` + +#### Use Cases + +##### General Admin +This is useful in cases where you have a specific user that you want to allow admin access over all Projects/Workspaces on the cluster: +```yaml + memberOverrides: + - kind: User + name: kubernetes-admin + roles: + - admin +``` + +It's also possible to use a group here. If the landscape admins are grouped in a specific group, it's possible to use that group: +```yaml + memberOverrides: + - kind: Group + name: system:masters + roles: + - admin +``` + +#### Project/Workspace Admin +It's possible to specify a resource type and name to limit access to a specific project or Workspace resources. This is useful to allow granular permissions or for granting temporary permissions to a specific user during debugging: + + +```yaml + memberOverrides: + - kind: User + name: project-two-support + resources: + - kind: project + name: two + roles: + - admin + + - kind: User + name: project-three-ws-1-manager + resources: + - kind: project + name: project-three + - kind: workspace + name: workspace-1 + roles: + - admin +``` + +**Note:** Since the `Workspace` doesn't have an explicit reference to the parent `Project`, the override must specify the parent `Project` in the same override configuration for the override to work. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c77bb94 --- /dev/null +++ b/go.mod @@ -0,0 +1,94 @@ +module github.com/openmcp-project/project-workspace-operator + +go 1.23.0 + +toolchain go1.23.6 + +require ( + github.com/go-logr/logr v1.4.2 + github.com/onsi/ginkgo/v2 v2.22.2 + github.com/onsi/gomega v1.36.2 + github.com/openmcp-project/controller-utils v0.4.2 + github.com/openmcp-project/project-workspace-operator/api v0.9.0 + github.com/stretchr/testify v1.10.0 + k8s.io/api v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 + sigs.k8s.io/controller-runtime v0.20.2 + sigs.k8s.io/e2e-framework v0.6.0 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect + golang.org/x/sync v0.11.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + k8s.io/component-base v0.32.2 // indirect +) + +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/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // 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-20241210010833-40e02aabc2ad // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/moby/spdystream v0.5.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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // 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/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.6 + github.com/vladimirvivien/gexe v0.4.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/oauth2 v0.26.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.28.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.32.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect + sigs.k8s.io/yaml v1.4.0 +) + +replace github.com/openmcp-project/project-workspace-operator/api => ./api/ diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e84504c --- /dev/null +++ b/go.sum @@ -0,0 +1,217 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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.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/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.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.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-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/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/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/openmcp-project/controller-utils v0.4.2 h1:8viXmZZdBLanN1ummLr05ceCd7XaEBToflm1hU17ecQ= +github.com/openmcp-project/controller-utils v0.4.2/go.mod h1:6BeRpSZK/FkJbelqGA4pv10FEtNerT2RvWb3Eg1Z9j0= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vladimirvivien/gexe v0.4.1 h1:W9gWkp8vSPjDoXDu04Yp4KljpVMaSt8IQuHswLDd5LY= +github.com/vladimirvivien/gexe v0.4.1/go.mod h1:3gjgTqE2c0VyHnU5UOIwk7gyNzZDGulPb/DJPgcw64E= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.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/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.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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.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/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.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/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU= +k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0= +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/e2e-framework v0.6.0 h1:p7hFzHnLKO7eNsWGI2AbC1Mo2IYxidg49BiT4njxkrM= +sigs.k8s.io/e2e-framework v0.6.0/go.mod h1:IREnCHnKgRCioLRmNi0hxSJ1kJ+aAdjEKK/gokcZu4k= +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/common/Dockerfile b/hack/common/Dockerfile new file mode 100644 index 0000000..c6783d8 --- /dev/null +++ b/hack/common/Dockerfile @@ -0,0 +1,12 @@ +# Use distroless as minimal base image to package the component binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot +ARG TARGETOS +ARG TARGETARCH +ARG COMPONENT +WORKDIR / +COPY bin/$COMPONENT-$TARGETOS.$TARGETARCH / +USER 65532:65532 + +# docker doesn't substitue args in ENTRYPOINT, so we replace this during the build script +ENTRYPOINT ["/"] diff --git a/hack/common/Makefile b/hack/common/Makefile new file mode 100644 index 0000000..ca12e03 --- /dev/null +++ b/hack/common/Makefile @@ -0,0 +1,273 @@ +# 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 + +############################################################################################################ +# This Makefile is meant to be included in the Makefile of the repository including this one as submodule. # +# Currently, this repository has to be included at 'hack/common' path in the parent repository. # +# Also, the parent Makefile has to set the following variables: # +# - REPO_ROOT: The root directory of the repository. # +# - COMPONENTS: The list of components to build (usually just one, e.g. 'mcp-operator'). # +############################################################################################################ + +##@ Common - 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 + +ifndef HELP_TARGET +HELP_TARGET := true +.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) +endif + +##@ Common - Development + +ifndef TIDY_TARGET +TIDY_TARGET := true +.PHONY: tidy +tidy: ## Runs 'go mod tidy' for all modules in this repo. + @$(REPO_ROOT)/hack/common/tidy.sh +endif + +ifndef GENERATE_DOCS_TARGET +GENERATE_DOCS_TARGET := true +.PHONY: generate-docs +generate-docs: jq ## Generates the documentation index. + @JQ=$(JQ) $(REPO_ROOT)/hack/common/generate-docs-index.sh +endif + +ifndef VERIFY_DOCS_TARGET +VERIFY_DOCS_TARGET := true +.PHONY: verify-docs +verify-docs: jq ## Verifies that the documentation index is up-to-date. + @test "$(SKIP_DOCS_INDEX_CHECK)" = "true" || \ + ( echo "> Verify documentation index ..." && \ + JQ=$(JQ) $(REPO_ROOT)/hack/common/verify-docs-index.sh ) +endif + +##@ Common - Release + +ifndef PREPARE_RELEASE_TARGET +PREPARE_RELEASE_TARGET := true +.PHONY: prepare-release +prepare-release: tidy generate verify test +endif + +ifndef RELEASE_MAJOR_TARGET +RELEASE_MAJOR_TARGET := true +.PHONY: release-major +release-major: prepare-release ## Creates a major release commit. + @COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/release.sh major +endif + +ifndef RELEASE_MINOR_TARGET +RELEASE_MINOR_TARGET := true +.PHONY: release-minor +release-minor: prepare-release ## Creates a minor release commit. + @COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/release.sh minor +endif + +ifndef RELEASE_PATCH_TARGET +RELEASE_PATCH_TARGET := true +.PHONY: release-patch +release-patch: prepare-release ## Creates a patch release commit. + @COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/release.sh patch +endif + +##@ Common - Build + +PLATFORMS ?= linux/arm64,linux/amd64 + +ifndef ALL_TARGET +ALL_TARGET := true +.PHONY: all +all: build image chart component ## Complete build and push for all components/platforms specified in COMPONENTS/PLATFORMS. Alias for 'make build image chart component'. +endif + +ifndef BUILD_TARGET +BUILD_TARGET := true +.PHONY: build +build: generate ## Builds binaries for all components specified in COMPONENTS and all platforms specified in PLATFORMS. + @PLATFORMS=$(PLATFORMS) COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/build-binary.sh +endif + +ifndef IMAGE_TARGET +IMAGE_TARGET := true +.PHONY: image +image: build image-build image-push ## Builds and pushes docker images for all components specified in COMPONENTS and all platforms specified in PLATFORMS. +endif + +ifndef IMAGE_BUILD_TARGET +IMAGE_BUILD_TARGET := true +.PHONY: image-build +image-build: ## Builds the docker images for all components specified in COMPONENTS and all platforms specified in PLATFORMS. Requires 'make build' to have run before. + @PLATFORMS=$(PLATFORMS) COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/build-image.sh +endif + +ifndef IMAGE_BUILD_LOCAL_TARGET +IMAGE_BUILD_LOCAL_TARGET := true +.PHONY: image-build-local +image-build-local: ## Builds the docker images for all components specified in COMPONENTS and all platforms specified in PLATFORMS. Requires 'make build' to have run before. + @PLATFORMS=$(PLATFORMS) COMPONENTS=$(COMPONENTS) BASE_REGISTRY=local $(REPO_ROOT)/hack/common/build-image.sh +endif + +ifndef IMAGE_PUSH_TARGET +IMAGE_PUSH_TARGET := true +.PHONY: image-push +image-push: ## Pushes the docker images for all components specified in COMPONENTS and all platforms specified in PLATFORMS. Requires 'make image-build' to have run before. + @PLATFORMS=$(PLATFORMS) COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/push-image.sh +endif + +ifndef CHART_TARGET +CHART_TARGET := true +.PHONY: chart +chart: chart-build chart-push ## Packs and pushes the helm charts for all components specified in COMPONENTS into the OCI registry. +endif + +ifndef CHART_BUILD_TARGET +CHART_BUILD_TARGET := true +.PHONY: chart-build +chart-build: helm ## Packs the helm charts for all components specified in COMPONENTS to prepare them for upload into the OCI registry. + @HELM=$(HELM) COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/build-chart.sh +endif + +ifndef CHART_PUSH_TARGET +CHART_PUSH_TARGET := true +.PHONY: chart-push +chart-push: helm jq yaml2json ## Pushes helm charts for all components specified in COMPONENTS into the OCI registry. Requires 'make chart-build' to have run before. + @HELM=$(HELM) COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/push-chart.sh +endif + +ifndef COMPONENT_TARGET +COMPONENT_TARGET := true +.PHONY: component +component: component-build component-push ## Builds and pushes the component descriptor into the registry. Requires charts and images to be already pushed into the registry. +endif + +ifndef COMPONENT_BUILD_TARGET +COMPONENT_BUILD_TARGET := true +.PHONY: component-build +component-build: ocm ## Builds the component descriptor for the mcp-operator. Several env variables can be used to control the result, see components/components.yaml for details. Requires charts and images to be already pushed into the registry. + @OCM=$(OCM) COMPONENTS=$(COMPONENTS) $(REPO_ROOT)/hack/common/build-component.sh +endif + +ifndef COMPONENT_PUSH_TARGET +COMPONENT_PUSH_TARGET := true +.PHONY: component-push +component-push: ocm ## Pushes the component descriptor into the registry. Requires 'make component-build' to have run before. + @OCM=$(OCM) $(REPO_ROOT)/hack/common/push-component.sh +endif + +##@ Common - Build Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(REPO_ROOT)/bin + +## Tool Binaries +KUBECTL ?= kubectl +KIND ?= kind +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +FORMATTER ?= $(LOCALBIN)/goimports +LINTER ?= $(LOCALBIN)/golangci-lint +OCM ?= $(LOCALBIN)/ocm +HELM ?= $(LOCALBIN)/helm +JQ ?= $(LOCALBIN)/jq +YAML2JSON ?= $(LOCALBIN)/yaml2json + +## Tool Versions +# renovate: datasource=github-releases depName=kubernetes-sigs/controller-tools +CONTROLLER_TOOLS_VERSION ?= v0.16.4 +# renovate: datasource=github-tags depName=golang/tools +FORMATTER_VERSION ?= v0.26.0 +# renovate: datasource=github-releases depName=golangci/golangci-lint +LINTER_VERSION ?= v1.61.0 +# renovate: datasource=github-releases depName=jqlang/jq +JQ_VERSION ?= 1.7.1 +# renovate: datasource=github-releases depName=open-component-model/ocm +OCM_VERSION ?= 0.16.2 +HELM_VERSION ?= v3.13.2 +# renovate: datasource=github-releases depName=bronze1man/yaml2json +YAML2JSON_VERSION ?= v1.3.3 + +ifndef LOCALBIN_TARGET +LOCALBIN_TARGET := true +.PHONY: localbin +localbin: + @test -d $(LOCALBIN) || mkdir -p $(LOCALBIN) +endif + +ifndef CONTROLLER_GEN_TARGET +CONTROLLER_GEN_TARGET := true +.PHONY: controller-gen +controller-gen: localbin ## Download controller-gen locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(CONTROLLER_GEN) && $(CONTROLLER_GEN) --version | grep -q $(CONTROLLER_TOOLS_VERSION) || \ + ( echo "Installing controller-gen $(CONTROLLER_TOOLS_VERSION) ..."; \ + GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) ) +endif + +ifndef GOIMPORTS_TARGET +GOIMPORTS_TARGET := true +.PHONY: goimports +goimports: localbin ## Download goimports locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(FORMATTER) && test -s $(LOCALBIN)/goimports_version && cat $(LOCALBIN)/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) > $(LOCALBIN)/goimports_version ) +endif + +ifndef GOLANGCI_LINT_TARGET +GOLANGCI_LINT_TARGET := true +.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 $(subst v,,$(LINTER_VERSION)) || \ + ( echo "Installing golangci-lint $(LINTER_VERSION) ..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LOCALBIN) $(LINTER_VERSION) ) +endif + +ifndef OCM_TARGET +OCM_TARGET := true +.PHONY: ocm +ocm: localbin ## Install OCM CLI if necessary. + @test -s $(OCM) && $(OCM) --version | grep -q $(OCM_VERSION) || \ + ( echo "Installing ocm $(OCM_VERSION) ..."; \ + curl -sSfL https://ocm.software/install.sh | OCM_VERSION=$(OCM_VERSION) bash -s $(LOCALBIN) ) +endif + +ifndef HELM_TARGET +HELM_TARGET := true +.PHONY: helm +helm: localbin ## Download helm locally if necessary. + @test -s $(HELM) && $(HELM) version --short | grep -q $(HELM_VERSION) || \ + ( echo "Installing helm $(HELM_VERSION) ..."; \ + HELM=$(HELM) LOCALBIN=$(LOCALBIN) $(REPO_ROOT)/hack/common/install/helm.sh $(HELM_VERSION) ) +endif + +ifndef JQ_TARGET +JQ_TARGET := true +.PHONY: jq +jq: localbin ## Download jq locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(JQ) && $(JQ) --version | grep -q $(subst v,,$(JQ_VERSION)) || \ + ( echo "Installing jq $(JQ_VERSION) ..."; \ + JQ=$(JQ) LOCALBIN=$(LOCALBIN) $(REPO_ROOT)/hack/common/install/jq.sh $(JQ_VERSION) ) +endif + +ifndef YAML2JSON_TARGET +YAML2JSON_TARGET := true +.PHONY: yaml2json +# yaml2json 1.3.3 incorrectly reports its version as 1.3.2, thus the workaround below +yaml2json: localbin ## Download yaml2json locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(YAML2JSON) && ($(YAML2JSON) --version || true) | grep -q $(subst 1.3.3,1.3.2,$(subst v,,$(YAML2JSON_VERSION))) || \ + ( echo "Installing yaml2json $(YAML2JSON_VERSION) ..."; \ + YAML2JSON=$(YAML2JSON) LOCALBIN=$(LOCALBIN) $(REPO_ROOT)/hack/common/install/yaml2json.sh $(YAML2JSON_VERSION) ) +endif diff --git a/hack/common/build-binary.sh b/hack/common/build-binary.sh new file mode 100755 index 0000000..e0e1249 --- /dev/null +++ b/hack/common/build-binary.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +echo +echo "> Building binaries ..." +( + cd "$PROJECT_ROOT" + for comp in ${COMPONENTS//,/ }; do + for pf in ${PLATFORMS//,/ }; do + echo "> Building binary for component '$comp' ($pf) ..." | indent 1 + os=${pf%/*} + arch=${pf#*/} + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -a -o bin/${comp}-${os}.${arch} cmd/${comp}/main.go | indent 2 + done + done +) diff --git a/hack/common/build-chart.sh b/hack/common/build-chart.sh new file mode 100755 index 0000000..b136bb2 --- /dev/null +++ b/hack/common/build-chart.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") + +echo +echo "> Packaging helm charts to prepare for upload ..." +tmpdir="$("$COMMON_SCRIPT_DIR/get-tmp-dir.sh")" +for comp in ${COMPONENTS//,/ }; do + echo "> Packaging helm chart for component '$comp' ..." | indent 1 + "$HELM" package "$PROJECT_ROOT/charts/$comp" -d "$tmpdir" --version "$VERSION" | indent 2 # file name is -.tgz (derived from Chart.yaml) +done diff --git a/hack/common/build-component.sh b/hack/common/build-component.sh new file mode 100755 index 0000000..e0234e2 --- /dev/null +++ b/hack/common/build-component.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") +CHART_REGISTRY=$("$COMMON_SCRIPT_DIR/get-registry.sh" --helm) +IMG_REGISTRY=$("$COMMON_SCRIPT_DIR/get-registry.sh" --image) + +pushd ${PROJECT_ROOT} > /dev/null 2>&1 +COMMIT="$(git rev-parse HEAD)" +popd > /dev/null 2>&1 + +echo +echo "> Building component in version ${CD_VERSION:-$VERSION} ..." +compdir="$("$COMMON_SCRIPT_DIR/get-tmp-dir.sh")/component" +$OCM add componentversions --file "$compdir" --version "$VERSION" --create --force --templater spiff "$COMPONENT_DEFINITION_FILE" -- \ + VERSION="$VERSION" \ + CHART_REGISTRY="$CHART_REGISTRY" \ + IMG_REGISTRY="$IMG_REGISTRY" \ + COMMIT="$COMMIT" \ + COMPONENTS="$COMPONENTS" \ + ${CD_VERSION:+CD_VERSION=}${CD_VERSION:-} \ + ${CHART_VERSION:+CHART_VERSION=}${CHART_VERSION:-} \ + ${IMG_VERSION:+IMG_VERSION=}${IMG_VERSION:-} \ + ${BP_COMPONENTS:+BP_COMPONENTS=}${BP_COMPONENTS:-} \ + ${CHART_COMPONENTS:+CHART_COMPONENTS=}${CHART_COMPONENTS:-} \ + ${IMG_COMPONENTS:+IMG_COMPONENTS=}${IMG_COMPONENTS:-} \ + | indent 1 + +echo "Use '$(realpath --relative-base=$(pwd) $OCM) get cv $compdir -o yaml' to view the generated component descriptor." \ No newline at end of file diff --git a/hack/common/build-image.sh b/hack/common/build-image.sh new file mode 100755 index 0000000..c1595c5 --- /dev/null +++ b/hack/common/build-image.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +if [[ -z ${IMAGE_REGISTRY:-} ]]; then + IMAGE_REGISTRY=$("$COMMON_SCRIPT_DIR/get-registry.sh" -i) +fi + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") + +DOCKER_BUILDER_NAME="mcp-multiarch-builder" +if ! docker buildx ls | grep "$DOCKER_BUILDER_NAME" >/dev/null; then + docker buildx create --name "$DOCKER_BUILDER_NAME" +fi + +# remove temporary Dockerfile on exit +trap "rm -f \"${PROJECT_ROOT}/Dockerfile.tmp\"" EXIT + +echo +echo "> Building images ..." +for comp in ${COMPONENTS//,/ }; do + for pf in ${PLATFORMS//,/ }; do + os=${pf%/*} + arch=${pf#*/} + img="${IMAGE_REGISTRY}/${comp}:${VERSION}-${os}-${arch}" + echo "> Building image for component '$comp' ($pf): $img ..." | indent 1 + cat "${COMMON_SCRIPT_DIR}/Dockerfile" | sed "s//$comp/g" > "${PROJECT_ROOT}/Dockerfile.tmp" + docker buildx build --builder ${DOCKER_BUILDER_NAME} --load --build-arg COMPONENT=${comp} --platform ${pf} -t $img -f Dockerfile.tmp "${PROJECT_ROOT}" | indent 2 + done +done + +docker buildx rm "$DOCKER_BUILDER_NAME" \ No newline at end of file diff --git a/hack/common/environment.sh b/hack/common/environment.sh new file mode 100755 index 0000000..2d03251 --- /dev/null +++ b/hack/common/environment.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +export COMMON_SCRIPT_DIR="$(realpath "$(dirname ${BASH_SOURCE[0]})")" +source "$COMMON_SCRIPT_DIR/lib.sh" +export PROJECT_ROOT="${PROJECT_ROOT:-$(realpath "$COMMON_SCRIPT_DIR/../..")}" +export COMPONENT_DEFINITION_FILE="${COMPONENT_DEFINITION_FILE:-"$PROJECT_ROOT/components/components.yaml"}" + +export LOCALBIN="${LOCALBIN:-"$PROJECT_ROOT/bin"}" +export HELM="${HELM:-"$LOCALBIN/helm"}" +export JQ="${JQ:-"$LOCALBIN/jq"}" +export FORMATTER=${FORMATTER:-"$LOCALBIN/goimports"} +export OCM="${OCM:-"$LOCALBIN/ocm"}" +export YAML2JSON="${YAML2JSON:-"$LOCALBIN/yaml2json"}" + +if [[ -f "$COMMON_SCRIPT_DIR/../environment.sh" ]]; then + source "$COMMON_SCRIPT_DIR/../environment.sh" +fi diff --git a/hack/common/format.sh b/hack/common/format.sh new file mode 100755 index 0000000..06c6b33 --- /dev/null +++ b/hack/common/format.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +write_mode="-w" +if [[ ${1:-} == "--verify" ]]; then + write_mode="" + shift +fi + +# MODULE_NAME must be set to the name of the local go module. +tmp=$("${FORMATTER}" -l $write_mode -local=$MODULE_NAME $("$COMMON_SCRIPT_DIR/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/common/generate-docs-index.sh b/hack/common/generate-docs-index.sh new file mode 100755 index 0000000..c0f03a3 --- /dev/null +++ b/hack/common/generate-docs-index.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +DOCS_FOLDER="${DOCS_FOLDER:-${PROJECT_ROOT}/docs}" +METAFILE_NAME=".docnames" + +doc_index_file=${1:-"$DOCS_FOLDER/README.md"} + +# prints to the new doc index +function println() { + echo "$@" >> "$newindex" +} + +# expects a path to a folder as argument +# returns how this folder should be named in an index +function getDocFolderName() { + local metafile="$1/$METAFILE_NAME" + if [[ -f "$metafile" ]]; then + cat "$metafile" | $JQ -r '.header' + fi +} + +# expects two arguments: +# - path to a doc folder +# - name of the file in there +# the file is expected to contain its header in the first line +# or there should be an overwrite present in /$METAFILE_NAME +function getDocName() { + local metafile="$1/$METAFILE_NAME" + local filename="$2" + if [[ -f "$metafile" ]]; then + local overwrite="$(cat "$metafile" | $JQ -r '.overwrites[$name]' --arg name "$2")" + if [[ "$overwrite" != "null" ]]; then + echo "$overwrite" + return + fi + fi + if [[ -f "$filename" ]]; then + local firstheader=$(grep -m1 "#" "$filename") + echo "${firstheader#'# '}" + fi +} + +echo "> Generating Documentation Index" + +newindex=$(mktemp) + +println '' +println "# Documentation Index" +println + +( + cd "$DOCS_FOLDER" + for f in *; do + if [[ -d "$f" ]]; then + foldername="$(getDocFolderName "$f")" + if [[ -z "$foldername" ]]; then + echo "Ignoring folder '$f' due to missing '$METAFILE_NAME' file." + continue + fi + + println "## $foldername" + println + + ( + cd "$f" + for f2 in *.md; do + docname="$(getDocName "../$f" "$f2")" + if [[ -z "$docname" ]]; then + echo "Ignoring file '$f/$f2' because the header could not be determined." + # There are two possible reasons for this: + # 1. The file doesn't start with a '# ' in the first line and no overwrite is defined in the folder's metafile. + # 2. The overwrite in the folder's metafile explicitly sets the name to an empty string, meaning this file should be ignored. + continue + fi + println "- [$docname]($f/$f2)" + done + ) + + println + fi + done +) + +cp "$newindex" "$doc_index_file" \ No newline at end of file diff --git a/hack/common/get-registry.sh b/hack/common/get-registry.sh new file mode 100755 index 0000000..4da3f9d --- /dev/null +++ b/hack/common/get-registry.sh @@ -0,0 +1,40 @@ +#!/bin/bash -eu + +set -euo pipefail + +if [[ -z ${BASE_REGISTRY:-} ]]; then + BASE_REGISTRY=europe-docker.pkg.dev/sap-gcp-cp-k8s-stable-hub/openmcp +fi + +if [[ -z ${IMAGE_REGISTRY:-} ]]; then + IMAGE_REGISTRY=$BASE_REGISTRY +fi +if [[ -z ${CHART_REGISTRY:-} ]]; then + CHART_REGISTRY=$BASE_REGISTRY/charts +fi +if [[ -z ${COMPONENT_REGISTRY:-} ]]; then + COMPONENT_REGISTRY=$BASE_REGISTRY/components +fi + +mode="BASE_" + +while [[ "$#" -gt 0 ]]; do + case ${1:-} in + "-i"|"--image") + mode="IMAGE_" + ;; + "-h"|"--helm") + mode="CHART_" + ;; + "-c"|"--component") + mode="COMPONENT_" + ;; + *) + echo "invalid argument: $1" 1>&2 + exit 1 + ;; + esac + shift +done + +eval echo "\$${mode}REGISTRY" diff --git a/hack/common/get-tmp-dir.sh b/hack/common/get-tmp-dir.sh new file mode 100755 index 0000000..4f844eb --- /dev/null +++ b/hack/common/get-tmp-dir.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +# Creates a directory within the temporary folder (either TMPDIR or /tmp) and returns its path. + +tmpdir="${TMPDIR:-"/tmp"}" +tmpdir="${tmpdir%/}/mcp/$(basename "$PROJECT_ROOT")" +mkdir -p "$tmpdir" +echo "$tmpdir" \ No newline at end of file diff --git a/hack/common/get-version.sh b/hack/common/get-version.sh new file mode 100755 index 0000000..afc4cc9 --- /dev/null +++ b/hack/common/get-version.sh @@ -0,0 +1,22 @@ +#!/bin/bash -eu + +if [[ -n ${EFFECTIVE_VERSION:-} ]] ; then + # running in the pipeline use the provided EFFECTIVE_VERSION + echo "$EFFECTIVE_VERSION" + exit 0 +fi + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +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/common/install/helm.sh b/hack/common/install/helm.sh new file mode 100755 index 0000000..5ac6fda --- /dev/null +++ b/hack/common/install/helm.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/../environment.sh")" + +HELM_VERSION="$1" + +arch=$(uname -m) +if [[ "$arch" == "x86_64" ]]; then + arch="amd64" +fi +os=$(uname | tr '[:upper:]' '[:lower:]') +curl -sfL "https://get.helm.sh/helm-${HELM_VERSION}-${os}-${arch}.tar.gz" --output "$LOCALBIN/helm.tar.gz" +mkdir -p "$LOCALBIN/helm-unpacked" +tar -xzf "$LOCALBIN/helm.tar.gz" --directory "$LOCALBIN/helm-unpacked" +mv "$LOCALBIN/helm-unpacked/${os}-${arch}/helm" "$LOCALBIN/helm" +chmod +x "$LOCALBIN/helm" +rm -rf "$LOCALBIN/helm.tar.gz" "$LOCALBIN/helm-unpacked" diff --git a/hack/common/install/jq.sh b/hack/common/install/jq.sh new file mode 100755 index 0000000..b1baeac --- /dev/null +++ b/hack/common/install/jq.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/../environment.sh")" + +JQ_VERSION="$1" +os="linux64" +if [[ $(uname -o) == "Darwin" ]]; then + os="osx-amd64" +fi +curl -sfL "https://github.com/stedolan/jq/releases/download/jq-${JQ_VERSION}/jq-${os}" --output "${LOCALBIN}/jq" +chmod +x "${LOCALBIN}/jq" \ No newline at end of file diff --git a/hack/common/install/yaml2json.sh b/hack/common/install/yaml2json.sh new file mode 100755 index 0000000..52f3cf9 --- /dev/null +++ b/hack/common/install/yaml2json.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/../environment.sh")" + +YAML2JSON_VERSION="$1" + +arch=$(uname -m) +if [[ "$arch" == "x86_64" ]]; then + arch="amd64" +fi +os=$(uname | tr '[:upper:]' '[:lower:]') +curl -sfL "https://github.com/bronze1man/yaml2json/releases/download/${YAML2JSON_VERSION}/yaml2json_${os}_${arch}" --output "${LOCALBIN}/yaml2json" +chmod +x "${LOCALBIN}/yaml2json" \ No newline at end of file diff --git a/hack/common/lib.sh b/hack/common/lib.sh new file mode 100755 index 0000000..fbbf560 --- /dev/null +++ b/hack/common/lib.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# pipe some text into 'indent X' to indent each line by X levels (one 'level' being two spaces) +function indent() { + local level=${1:-""} + if [[ -z "$level" ]]; then + level=1 + fi + local spaces=$(($level * 2)) + local iv=$(printf %${spaces}s) + sed "s/^/$iv/" +} \ No newline at end of file diff --git a/hack/common/push-chart.sh b/hack/common/push-chart.sh new file mode 100755 index 0000000..b9be000 --- /dev/null +++ b/hack/common/push-chart.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") +HELM_REGISTRY=$("$COMMON_SCRIPT_DIR/get-registry.sh" --helm) + +echo +echo "> Uploading helm charts to $HELM_REGISTRY ..." +tmpdir="$("$COMMON_SCRIPT_DIR/get-tmp-dir.sh")" +for comp in ${COMPONENTS//,/ }; do + chname="$(cat "$PROJECT_ROOT/charts/$comp/Chart.yaml" | $YAML2JSON | $JQ -r .name)" + echo "> Pushing helm chart for component '$comp' ..." | indent 1 + "$HELM" push "$tmpdir/$chname-$VERSION.tgz" "oci://$HELM_REGISTRY" | indent 2 +done diff --git a/hack/common/push-component.sh b/hack/common/push-component.sh new file mode 100755 index 0000000..2012650 --- /dev/null +++ b/hack/common/push-component.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") +COMPONENT_REGISTRY="$($COMMON_SCRIPT_DIR/get-registry.sh --component)" + +overwrite="" +if [[ -n ${OVERWRITE_COMPONENTS:-} ]] && [[ ${OVERWRITE_COMPONENTS} != "false" ]]; then + overwrite="--overwrite" +fi + +echo +echo "> Uploading Component Descriptors to $COMPONENT_REGISTRY ..." +compdir="$("$COMMON_SCRIPT_DIR/get-tmp-dir.sh")/component" +$OCM transfer componentversions "$compdir" "$COMPONENT_REGISTRY" $overwrite | indent 1 diff --git a/hack/common/push-image.sh b/hack/common/push-image.sh new file mode 100755 index 0000000..35814b3 --- /dev/null +++ b/hack/common/push-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +if [[ -z ${IMAGE_REGISTRY:-} ]]; then + IMAGE_REGISTRY=$("$COMMON_SCRIPT_DIR/get-registry.sh" -i) +fi + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") + +( + cd "$PROJECT_ROOT" + + echo + echo "> Pushing images to registry $IMAGE_REGISTRY ..." + for comp in ${COMPONENTS//,/ }; do + echo "Pushing image for component '$comp' ..." | indent 1 + img="${IMAGE_REGISTRY}/${comp}:${VERSION}" + for pf in ${PLATFORMS//,/ }; do + os=${pf%/*} + arch=${pf#*/} + pfimg="${img}-${os}-${arch}" + + echo "> Pushing platform-specific image for $pf ..." | indent 2 + docker push "$pfimg" | indent 3 + + echo "> Adding image to multi-platform manifest ..." | indent 2 + docker manifest create $img --amend $pfimg | indent 3 + done + + echo "> Pushing multi-platform manifest ..." + docker manifest push "$img" | indent 2 + + if [[ ${ADDITIONAL_TAG:-} ]]; then + echo "> Adding additional tag '$ADDITIONAL_TAG' ..." + docker buildx imagetools create "$img" --tag "${img%:*}:$ADDITIONAL_TAG" | indent 2 + fi + done +) \ No newline at end of file diff --git a/hack/common/release.sh b/hack/common/release.sh new file mode 100755 index 0000000..35e7e7d --- /dev/null +++ b/hack/common/release.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +VERSION=$("$COMMON_SCRIPT_DIR/get-version.sh") + +( + cd "$PROJECT_ROOT" + + echo "> Checking for uncommitted changes" + if [[ -n "$(git status --porcelain=v1)" ]]; then + echo "There are uncommitted changes in the working directory." + git status --short + echo + echo "These changes will be included in the release commit, unless you stash, commit, or remove them otherwise before." + echo "Do you want to continue, including these changes in the release commit? Please confirm with 'yes' or 'y':" + read confirm + if [[ "$confirm" != "yes" ]] && [[ "$confirm" != "y" ]]; then + echo "Release aborted." + exit 0 + fi + else + echo "No uncommitted changes found." + fi + echo + + 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" + "$COMMON_SCRIPT_DIR/set-version.sh" $release_version + echo + + git add --all + git commit -m "release $release_version" + echo + + echo "> Successfully finished" +) \ No newline at end of file diff --git a/hack/common/renovate.json b/hack/common/renovate.json new file mode 100644 index 0000000..2ce1e84 --- /dev/null +++ b/hack/common/renovate.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:best-practices", + "security:openssf-scorecard", + ":dependencyDashboard" + ], + "customManagers": [ + { + "description": "Match in Makefile", + "customType": "regex", + "fileMatch": [ + "(^|/|\\.)([Dd]ocker|[Cc]ontainer)file$", + "(^|/)([Dd]ocker|[Cc]ontainer)file[^/]*$", + "(^|/)Makefile$" + ], + "matchStrings": [ + "# renovate: datasource=(?[a-z-.]+?) depName=(?[^\\s]+?)(?: (lookupName|packageName)=(?[^\\s]+?))?(?: versioning=(?[^\\s]+?))?(?: extractVersion=(?[^\\s]+?))?(?: registryUrl=(?[^\\s]+?))?\\s(?:ENV |ARG )?.+?_VERSION ?(?:\\?=|=)\"? ?(?.+?)\"?\\s" + ] + } + ] +} diff --git a/hack/common/set-version.sh b/hack/common/set-version.sh new file mode 100755 index 0000000..5a3a752 --- /dev/null +++ b/hack/common/set-version.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +VERSION=$1 + +GO_MOD_FILE="${PROJECT_ROOT}/go.mod" + +# update VERSION file +echo "$VERSION" > "$PROJECT_ROOT/VERSION" + +for comp in ${COMPONENTS//,/ }; do + CHART_FILE="${PROJECT_ROOT}/charts/${comp}/Chart.yaml" + CHART_VALUES_FILE="${PROJECT_ROOT}/charts/${comp}/values.yaml" + + # update version and image tag in helm charts + sed -E -i -e "s@version: v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+.*@version: $VERSION@1" "${CHART_FILE}" + sed -E -i -e "s@appVersion: v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+.*@appVersion: $VERSION@1" "${CHART_FILE}" + sed -i -e "s@ tag: .*@ tag: ${VERSION}@" "${CHART_VALUES_FILE}" + + # remove backup files (created by sed on MacOS) + for file in "${CHART_FILE}" "${CHART_VALUES_FILE}"; do + rm -f "${file}-e" + done +done + +# MODULE_NAME must be set to the local go module name. +# NESTED_MODULES must be set to the list of nested go modules, e.g. 'api,nested2,nested3' +for nm in ${NESTED_MODULES//,/ }; do + # update go.mod + sed -i -e "s@ $MODULE_NAME/$nm .*@ $MODULE_NAME/$nm ${VERSION}@" "${GO_MOD_FILE}" + # remove backup file (created by sed on MacOS) + rm -f "${GO_MOD_FILE}-e" +done diff --git a/hack/common/tidy.sh b/hack/common/tidy.sh new file mode 100755 index 0000000..8a6d46d --- /dev/null +++ b/hack/common/tidy.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +function tidy() { + go mod tidy -e +} + +# NESTED_MODULES must be set to the list of nested go modules, e.g. 'api,nested2,nested3' +for nm in ${NESTED_MODULES//,/ }; do + echo "Tidy $nm module ..." + ( + cd "$PROJECT_ROOT/$nm" + tidy + ) +done + +echo "Tidy root module ..." +( + cd "$PROJECT_ROOT" + tidy +) diff --git a/hack/common/unfold.sh b/hack/common/unfold.sh new file mode 100755 index 0000000..c2620c2 --- /dev/null +++ b/hack/common/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/hack/common/verify-docs-index.sh b/hack/common/verify-docs-index.sh new file mode 100755 index 0000000..4edc398 --- /dev/null +++ b/hack/common/verify-docs-index.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -euo pipefail +source "$(realpath "$(dirname $0)/environment.sh")" + +echo "> Checking if documentation index needs changes" +doc_index_file="$PROJECT_ROOT/docs/README.md" +tmp_compare_file=$(mktemp) +"$COMMON_SCRIPT_DIR/generate-docs-index.sh" "$tmp_compare_file" >/dev/null +if ! cmp -s "$doc_index_file" "$tmp_compare_file"; then + echo "The documentation index requires changes." + echo "Please run 'make generate-docs' to update it." + exit 1 +fi +echo "Documentation index is up-to-date." \ No newline at end of file diff --git a/hack/deploy-in-kind.sh b/hack/deploy-in-kind.sh new file mode 100755 index 0000000..eeb10e4 --- /dev/null +++ b/hack/deploy-in-kind.sh @@ -0,0 +1,18 @@ +set -e + +IMG="project-workspace-operator:dev" + +if [ ! -e "Makefile" ]; then + echo "Please run this script from the project root." + echo "$ ./hack/deploy-in-kind.sh" + exit 1 +fi + +# kind delete cluster || true +# kind create cluster + +make docker-build IMG=$IMG +kind load docker-image $IMG +make install +kubectl delete deployment -n project-workspace-operator-system project-workspace-operator-controller-manager || true +make deploy IMG=$IMG diff --git a/hack/environment.sh b/hack/environment.sh new file mode 100755 index 0000000..06333cf --- /dev/null +++ b/hack/environment.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# this file is sourced automatically by the environment.sh script in the 'common' scripts submodule + +export MODULE_NAME="github.com/openmcp-project/project-workspace-operator" +export NESTED_MODULES="api" diff --git a/hack/local-values.yaml b/hack/local-values.yaml new file mode 100644 index 0000000..a0ad0b8 --- /dev/null +++ b/hack/local-values.yaml @@ -0,0 +1,48 @@ +fullnameOverride: project-workspace-operator + +manager: + extraArgs: [] + +rbac: + clusterRole: + rules: + - apiGroups: + - core.openmcp.cloud + resources: + - projects + - projects/finalizers + - projects/status + - workspaces + - workspaces/finalizers + - workspaces/status + - memberoverrides + verbs: + - "*" + - apiGroups: + - "" + resources: + - namespaces + verbs: + - "*" + - apiGroups: + - "rbac.authorization.k8s.io" + resources: + - clusterrolebindings + - clusterroles + - rolebindings + verbs: + - "*" +webhooks: + memberOverrides: + memberOverridesName: project-workspace-operator-overrides + overrides: + - kind: User + name: kubernetes-admin + resources: + - kind: project + name: two + - kind: workspace + name: dev + roles: + - admin + diff --git a/internal/controller/core/common.go b/internal/controller/core/common.go new file mode 100644 index 0000000..38fd82e --- /dev/null +++ b/internal/controller/core/common.go @@ -0,0 +1,226 @@ +package core + +import ( + "context" + "fmt" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/util/json" + + "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/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/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + "github.com/openmcp-project/project-workspace-operator/api/entities" +) + +var ( + Scheme = runtime.NewScheme() + + deleteFinalizer = v1alpha1.GroupVersion.Group +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(Scheme)) + utilruntime.Must(v1alpha1.AddToScheme(Scheme)) +} + +type CommonReconciler struct { + client.Client + *config.ProjectWorkspaceConfig + ControllerName string +} + +func (r *CommonReconciler) ensureFinalizer(ctx context.Context, o client.Object) error { + if !controllerutil.ContainsFinalizer(o, deleteFinalizer) { + controllerutil.AddFinalizer(o, deleteFinalizer) + if err := r.Update(ctx, o); err != nil { + return fmt.Errorf("failed to add finalizer: %w", err) + } + } + + return nil +} + +func (r *CommonReconciler) handleRemainingContentBeforeDelete(ctx context.Context, o client.Object) (bool, error) { + if !wasDeleted(o) { + return false, nil + } + + project, isProject := o.(*v1alpha1.Project) + workspace, isWorkspace := o.(*v1alpha1.Workspace) + + if !isProject && !isWorkspace { + return false, fmt.Errorf("object is not a Project or Workspace") + } + + var namespace string + var resourcesBlockingDeletion []config.GroupVersionKind + + if isProject { + namespace = project.Status.Namespace + + if len(r.ProjectWorkspaceConfig.Project.ResourcesBlockingDeletion) == 0 { + return false, nil + } + + resourcesBlockingDeletion = r.ProjectWorkspaceConfig.Project.ResourcesBlockingDeletion + } else { + namespace = workspace.Status.Namespace + + if len(r.ProjectWorkspaceConfig.Workspace.ResourcesBlockingDeletion) == 0 { + return false, nil + } + + resourcesBlockingDeletion = r.ProjectWorkspaceConfig.Workspace.ResourcesBlockingDeletion + } + + remainingResources := make([]unstructured.Unstructured, 0) + var remainingResourcesCondition v1alpha1.Condition + + log := log.FromContext(ctx) + + for _, gvk := range resourcesBlockingDeletion { + resList := &unstructured.UnstructuredList{} + resList.SetGroupVersionKind(gvk.ToSchemaGVK()) + + if err := r.Client.List(ctx, resList, client.InNamespace(namespace)); err != nil { + log.Error(err, "failed to list resources") + return false, err + } + + if len(resList.Items) > 0 { + remainingResources = append(remainingResources, resList.Items...) + } + } + + if len(remainingResources) > 0 { + resources := make([]v1alpha1.RemainingContentResource, 0, len(remainingResources)) + + remainingResourcesCondition = v1alpha1.Condition{ + Type: v1alpha1.ConditionTypeContentRemaining, + Status: v1alpha1.ConditionStatusTrue, + Reason: v1alpha1.ConditionReasonResourcesRemaining, + Message: fmt.Sprintf("There are %d remaining resources in namespace %s that are preventing deletion", len(remainingResources), namespace), + } + + for _, res := range remainingResources { + resources = append(resources, v1alpha1.RemainingContentResource{ + APIGroup: res.GetAPIVersion(), + Kind: res.GetKind(), + Name: res.GetName(), + Namespace: res.GetNamespace(), + }) + } + + resourcesMarshalled, err := json.Marshal(resources) + if err != nil { + log.Error(err, "failed to marshal resources") + return false, err + } + + remainingResourcesCondition.Details = resourcesMarshalled + + if isProject { + project.SetOrUpdateCondition(remainingResourcesCondition) + } else { + workspace.SetOrUpdateCondition(remainingResourcesCondition) + } + + return true, nil + } else { + if isProject { + project.RemoveCondition(v1alpha1.ConditionTypeContentRemaining) + } else { + workspace.RemoveCondition(v1alpha1.ConditionTypeContentRemaining) + } + } + + return false, nil +} +func (r *CommonReconciler) handleDelete(ctx context.Context, o client.Object, deleteFunc func() error) (bool, ctrl.Result, error) { + if !wasDeleted(o) { + return false, reconcile.Result{}, nil + } + + log := log.FromContext(ctx) + + if controllerutil.ContainsFinalizer(o, deleteFinalizer) { + if err := deleteFunc(); err != nil { + if rrErr, ok := err.(ResourcesRemainingError); ok { + log.Info(rrErr.Error()) + return true, reconcile.Result(rrErr), nil + } + + return false, reconcile.Result{}, fmt.Errorf("failed to perform cleanup operation: %w", err) + } + + controllerutil.RemoveFinalizer(o, deleteFinalizer) + if err := r.Update(ctx, o); err != nil { + return false, reconcile.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) + } + } + + return true, reconcile.Result{}, nil +} + +func (r *CommonReconciler) applyManagementLabel(obj metav1.Object) { + setManagementLabel(obj, r.ControllerName) +} + +var _ error = ResourcesRemainingError{} + +type ResourcesRemainingError ctrl.Result + +func (err ResourcesRemainingError) Error() string { + return fmt.Sprintf("cleanup is not finished yet because there are remaining resources. should check again in %s", err.RequeueAfter) +} + +func (err ResourcesRemainingError) Is(target error) bool { + return reflect.TypeOf(target) == reflect.TypeOf(err) +} + +func clusterRoleForEntityAndRole(entity entities.AccessEntity, role entities.AccessRole) string { + if reflect.TypeOf(entity) != reflect.TypeOf(role.EntityType()) { + panic("AccessEntity/AccessRole mismatch") + } + return strings.Join([]string{ + entity.TypeIdentifier(), + entity.GetName(), + role.Identifier(), + }, ":") +} + +func clusterRoleForRole(role entities.AccessRole) string { + return strings.Join([]string{ + role.EntityType().TypeIdentifier(), + role.Identifier(), + }, "-") +} + +func roleBindingForRole(role entities.AccessRole) string { + // Name of RoleBinding (namespaced) should be the same as ClusterRole. + return clusterRoleForRole(role) +} + +func clusterRoleForEntityAndRoleWithParent(entity entities.AccessEntity, role entities.AccessRole, parent entities.AccessEntity) string { + if reflect.TypeOf(entity) == reflect.TypeOf(parent) { + panic("AccessEntity/Parent must not be of same type") + } + return strings.Join([]string{ + parent.TypeIdentifier(), + parent.GetName(), + clusterRoleForEntityAndRole(entity, role), + }, ":") +} diff --git a/internal/controller/core/common_test.go b/internal/controller/core/common_test.go new file mode 100644 index 0000000..7eb5310 --- /dev/null +++ b/internal/controller/core/common_test.go @@ -0,0 +1,266 @@ +package core + +import ( + "context" + "errors" + "fmt" + + "io/fs" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + 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/client/interceptor" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + + "github.com/stretchr/testify/assert" +) + +func Test_ResourcesRemainingError_Is(t *testing.T) { + testCases := []struct { + desc string + a error + b error + expected bool + }{ + { + desc: "should return true for same error", + a: ResourcesRemainingError{RequeueAfter: 10 * time.Second}, + b: ResourcesRemainingError{RequeueAfter: 10 * time.Second}, + expected: true, + }, + { + desc: "should return true for similar error", + a: ResourcesRemainingError{RequeueAfter: 10 * time.Second}, + b: ResourcesRemainingError{RequeueAfter: 20 * time.Second}, + expected: true, + }, + { + desc: "should return false for different error", + a: ResourcesRemainingError{RequeueAfter: 10 * time.Second}, + b: fs.ErrNotExist, + expected: false, + }, + { + desc: "should return false for different error (swapped)", + a: fs.ErrNotExist, + b: ResourcesRemainingError{RequeueAfter: 10 * time.Second}, + expected: false, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + assert.Equal(t, tC.expected, errors.Is(tC.a, tC.b)) + }) + } +} + +func Test_CommonReconciler_handleDelete(t *testing.T) { + fakeTime := time.Now() + testProject := &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + DeletionTimestamp: &metav1.Time{Time: fakeTime}, + Finalizers: []string{deleteFinalizer}, + }, + } + + type exp struct { + b bool + result ctrl.Result + err error + } + + test := []struct { + name string + obj client.Object + interceptorFuncs interceptor.Funcs + deleteFunc func() error + expected exp + validateFunc func(ctx context.Context, c client.Client) error + }{ + { + name: "should return false if object was not deleted", + obj: &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{Name: "test-project"}, + }, + deleteFunc: func() error { + return nil + }, + expected: exp{ + b: false, + result: ctrl.Result{}, + err: nil, + }, + }, + { + name: "Resources are still remaining in the cluster", + obj: testProject.DeepCopy(), + deleteFunc: func() error { + return ResourcesRemainingError{RequeueAfter: 3 * time.Second} + }, + expected: exp{ + b: true, + result: ctrl.Result{RequeueAfter: 3 * time.Second}, + err: nil, + }, + }, + { + name: "Failed to perform clean up operation", + obj: testProject.DeepCopy(), + deleteFunc: func() error { + return errors.New("some error") + }, + expected: exp{ + b: false, + result: ctrl.Result{}, + err: fmt.Errorf("failed to perform cleanup operation: %w", errors.New("some error")), + }, + }, + { + name: "Failed to remove finalizer", + obj: testProject.DeepCopy(), + interceptorFuncs: interceptor.Funcs{ + Update: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + if _, ok := obj.(*openmcpv1alpha1.Project); ok { + return errors.New("some update error") + } + return client.Update(ctx, obj, opts...) + }, + }, + deleteFunc: func() error { + return nil + }, + expected: exp{ + b: false, + result: ctrl.Result{}, + err: fmt.Errorf("failed to remove finalizer: %w", errors.New("some update error")), + }, + }, + { + name: "Finalizer removed successfully", + obj: testProject.DeepCopy(), + deleteFunc: func() error { + return nil + }, + expected: exp{ + b: true, + result: ctrl.Result{}, + err: nil, + }, + validateFunc: func(ctx context.Context, c client.Client) error { + project := &openmcpv1alpha1.Project{} + err := c.Get(ctx, client.ObjectKeyFromObject(testProject), project) + assert.True(t, apierrors.IsNotFound(err)) + return nil + }, + }, + } + + for _, tt := range test { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + fakeClient := fake.NewClientBuilder().WithScheme(Scheme).WithObjects(tt.obj).WithInterceptorFuncs(tt.interceptorFuncs).Build() + r := &CommonReconciler{ + Client: fakeClient, + ControllerName: "test-controller", + } + assert.NoError(t, fakeClient.Get(ctx, client.ObjectKeyFromObject(tt.obj), tt.obj)) + + b, result, err := r.handleDelete(ctx, tt.obj, tt.deleteFunc) + assert.Equal(t, tt.expected.b, b) + assert.Equal(t, tt.expected.result, result) + assert.Equal(t, tt.expected.err, err) + + if tt.validateFunc != nil { + if err := tt.validateFunc(ctx, fakeClient); err != nil { + t.Errorf("validation failed unexpectedly: %v", err) + } + } + }) + } +} + +func Test_CommonReconciler_ensureFinalizer(t *testing.T) { + test := []struct { + name string + obj client.Object + interceptorFuncs interceptor.Funcs + expectedErr error + validateFunc func(ctx context.Context, c client.Client) error + }{ + { + name: "Finalizer already exists", + obj: &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + Finalizers: []string{deleteFinalizer}, + }, + }, + expectedErr: nil, + }, + { + name: "Failed to add finalizer", + obj: &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + }, + }, + interceptorFuncs: interceptor.Funcs{ + Update: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + if _, ok := obj.(*openmcpv1alpha1.Project); ok { + return errors.New("some error") + } + return client.Update(ctx, obj, opts...) + }, + }, + expectedErr: fmt.Errorf("failed to add finalizer: %w", errors.New("some error")), + }, + { + name: "Finalizer added successfully", + obj: &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + }, + }, + expectedErr: nil, + validateFunc: func(ctx context.Context, c client.Client) error { + project := &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + }, + } + if err := c.Get(ctx, client.ObjectKeyFromObject(project), project); err != nil { + return err + } + assert.Contains(t, project.Finalizers, deleteFinalizer) + return nil + }, + }, + } + + for _, tt := range test { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + fakeClient := fake.NewClientBuilder().WithScheme(Scheme).WithObjects(tt.obj).WithInterceptorFuncs(tt.interceptorFuncs).Build() + r := &CommonReconciler{ + Client: fakeClient, + ControllerName: "test-controller", + } + assert.NoError(t, fakeClient.Get(ctx, client.ObjectKeyFromObject(tt.obj), tt.obj)) + + err := r.ensureFinalizer(ctx, tt.obj) + assert.Equal(t, tt.expectedErr, err) + + if tt.validateFunc != nil { + err := tt.validateFunc(ctx, fakeClient) + assert.NoErrorf(t, err, "validation failed unexpectedly") + } + }) + } +} diff --git a/internal/controller/core/config/config.go b/internal/controller/core/config/config.go new file mode 100644 index 0000000..aa06c3a --- /dev/null +++ b/internal/controller/core/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "fmt" + "os" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" +) + +// GroupVersionKind represents a Kubernetes GroupVersionKind +type GroupVersionKind struct { + Group string `json:"group"` + Version string `json:"version"` + Kind string `json:"kind"` +} + +// ToSchemaGVK converts a GroupVersionKind to a schema.GroupVersionKind +func (g *GroupVersionKind) ToSchemaGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: g.Group, + Version: g.Version, + Kind: g.Kind, + } +} + +// ProjectConfig contains the configuration for projects. +type ProjectConfig struct { + // +optional + ResourcesBlockingDeletion []GroupVersionKind `json:"resourcesBlockingDeletion,omitempty"` +} + +// WorkspaceConfig contains the configuration for workspaces. +type WorkspaceConfig struct { + // +optional + ResourcesBlockingDeletion []GroupVersionKind `json:"resourcesBlockingDeletion,omitempty"` +} + +// ProjectWorkspaceConfig contains the configuration for projects and workspaces. +type ProjectWorkspaceConfig struct { + // +optional + Project ProjectConfig `json:"project,omitempty"` + // +optional + Workspace WorkspaceConfig `json:"workspace,omitempty"` +} + +// SetDefaults sets the default values for the project workspace configuration when not set. +func (pwc *ProjectWorkspaceConfig) SetDefaults() { +} + +// Validate validates the project workspace configuration. +func (pwc *ProjectWorkspaceConfig) Validate() error { + errs := field.ErrorList{} + return errs.ToAggregate() +} + +// LoadConfig loads a project workspace configuration from a file. +func LoadConfig(path string) (*ProjectWorkspaceConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + cfg := &ProjectWorkspaceConfig{} + err = yaml.Unmarshal(data, cfg) + if err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + return cfg, nil +} diff --git a/internal/controller/core/config/config_test.go b/internal/controller/core/config/config_test.go new file mode 100644 index 0000000..18724ed --- /dev/null +++ b/internal/controller/core/config/config_test.go @@ -0,0 +1,57 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" +) + +func TestLoadConfig(t *testing.T) { + pwConfig, err := config.LoadConfig("./testdata/config_valid.yaml") + + if assert.NoError(t, err) { + assert.NotNil(t, pwConfig) + } + + pwConfig, err = config.LoadConfig("./testdata/config_invalid.yaml") + + if assert.Error(t, err) { + assert.Nil(t, pwConfig) + } + + pwConfig, err = config.LoadConfig("./testdata/config_not_found.yaml") + + if assert.Error(t, err) { + assert.Nil(t, pwConfig) + } +} + +func TestDefaults(t *testing.T) { + pwConfig := &config.ProjectWorkspaceConfig{ + Project: config.ProjectConfig{}, + Workspace: config.WorkspaceConfig{}, + } + + pwConfig.SetDefaults() + + // No defaults yet +} + +func TestValidate(t *testing.T) { + pwConfig := &config.ProjectWorkspaceConfig{ + Project: config.ProjectConfig{ + ResourcesBlockingDeletion: []config.GroupVersionKind{ + { + Group: "", + Version: "v1", + Kind: "Secret", + }, + }, + }, + Workspace: config.WorkspaceConfig{}, + } + + assert.NoError(t, pwConfig.Validate()) +} diff --git a/internal/controller/core/config/testdata/config_invalid.yaml b/internal/controller/core/config/testdata/config_invalid.yaml new file mode 100644 index 0000000..d77f6aa --- /dev/null +++ b/internal/controller/core/config/testdata/config_invalid.yaml @@ -0,0 +1 @@ +foo: bar: \ No newline at end of file diff --git a/internal/controller/core/config/testdata/config_valid.yaml b/internal/controller/core/config/testdata/config_valid.yaml new file mode 100644 index 0000000..0125698 --- /dev/null +++ b/internal/controller/core/config/testdata/config_valid.yaml @@ -0,0 +1,11 @@ +project: + resourcesBlockingDeletion: + - group: "" + version: "v1" + kind: "Secret" + +workspace: + resourcesBlockingDeletion: + - group: "" + version: "v1" + kind: "Secret" diff --git a/internal/controller/core/labels.go b/internal/controller/core/labels.go new file mode 100644 index 0000000..d63f802 --- /dev/null +++ b/internal/controller/core/labels.go @@ -0,0 +1,30 @@ +package core + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +const ( + labelManagedBy string = "app.kubernetes.io/managed-by" +) + +var ( // TODO make those constant + labelProject = fmt.Sprintf("%s/project", openmcpv1alpha1.GroupVersion.Group) + labelWorkspace = fmt.Sprintf("%s/workspace", openmcpv1alpha1.GroupVersion.Group) +) + +func setManagementLabel(obj metav1.Object, controllerName string) { + setMetaDataLabel(obj, labelManagedBy, controllerName) +} + +func setProjectLabel(obj metav1.Object, project string) { + setMetaDataLabel(obj, labelProject, project) +} + +func setWorkspaceLabel(obj metav1.Object, workspace string) { + setMetaDataLabel(obj, labelWorkspace, workspace) +} diff --git a/internal/controller/core/labels_test.go b/internal/controller/core/labels_test.go new file mode 100644 index 0000000..533f876 --- /dev/null +++ b/internal/controller/core/labels_test.go @@ -0,0 +1,112 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSetManagementLabels(t *testing.T) { + t.Run("set's the 'app.kubernetes.io/managed-by' label", func(t *testing.T) { + var obj metav1.ObjectMeta + + setManagementLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelManagedBy: "test"}, obj.Labels) + }) + t.Run("set's the 'app.kubernetes.io/managed-by' label which embeds metav1.ObjectMeta", func(t *testing.T) { + var obj rbacv1.ClusterRole + + setManagementLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelManagedBy: "test"}, obj.Labels) + }) + t.Run("overwrites label if already set", func(t *testing.T) { + var obj metav1.ObjectMeta + setManagementLabel(&obj, "first") + setManagementLabel(&obj, "second") + + assert.Equal(t, map[string]string{labelManagedBy: "second"}, obj.Labels) + }) + t.Run("doesn't overwrite other labels", func(t *testing.T) { + var obj metav1.ObjectMeta + obj.Labels = map[string]string{ + "existing": "shouldn't be touched", + } + + setManagementLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelManagedBy: "test", "existing": "shouldn't be touched"}, obj.Labels) + }) +} + +func TestSetProjectLabel(t *testing.T) { + t.Run("set's the 'openmcp.cloud/project' label", func(t *testing.T) { + var obj metav1.ObjectMeta + + setProjectLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelProject: "test"}, obj.Labels) + }) + t.Run("set's the 'openmcp.cloud/project' label which embeds metav1.ObjectMeta", func(t *testing.T) { + var obj rbacv1.ClusterRole + + setProjectLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelProject: "test"}, obj.Labels) + }) + t.Run("overwrites label if already set", func(t *testing.T) { + var obj metav1.ObjectMeta + setProjectLabel(&obj, "first") + setProjectLabel(&obj, "second") + + assert.Equal(t, map[string]string{labelProject: "second"}, obj.Labels) + }) + t.Run("doesn't overwrite other labels", func(t *testing.T) { + var obj metav1.ObjectMeta + obj.Labels = map[string]string{ + "existing": "shouldn't be touched", + } + + setProjectLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelProject: "test", "existing": "shouldn't be touched"}, obj.Labels) + }) +} + +func TestSetWorkspaceLabel(t *testing.T) { + t.Run("set's the 'openmcp.cloud/workspace' label", func(t *testing.T) { + var obj metav1.ObjectMeta + + setWorkspaceLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelWorkspace: "test"}, obj.Labels) + }) + t.Run("set's the 'openmcp.cloud/workspace' label which embeds metav1.ObjectMeta", func(t *testing.T) { + var obj rbacv1.ClusterRole + + setWorkspaceLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelWorkspace: "test"}, obj.Labels) + }) + t.Run("overwrites label if already set", func(t *testing.T) { + var obj metav1.ObjectMeta + setWorkspaceLabel(&obj, "first") + setWorkspaceLabel(&obj, "second") + + assert.Equal(t, map[string]string{labelWorkspace: "second"}, obj.Labels) + }) + t.Run("doesn't overwrite other labels", func(t *testing.T) { + var obj metav1.ObjectMeta + obj.Labels = map[string]string{ + "existing": "shouldn't be touched", + } + + setWorkspaceLabel(&obj, "test") + + assert.Equal(t, map[string]string{labelWorkspace: "test", "existing": "shouldn't be touched"}, obj.Labels) + }) +} diff --git a/internal/controller/core/project_controller.go b/internal/controller/core/project_controller.go new file mode 100644 index 0000000..d33b14c --- /dev/null +++ b/internal/controller/core/project_controller.go @@ -0,0 +1,246 @@ +package core + +import ( + "context" + "time" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +// ProjectReconciler reconciles a Project object +type ProjectReconciler struct { + client.Client + Scheme *runtime.Scheme + CommonReconciler +} + +//+kubebuilder:rbac:groups=core.openmcp.cloud,resources=projects,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core.openmcp.cloud,resources=projects/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=core.openmcp.cloud,resources=projects/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=namespaces;secrets,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings;rolebindings,verbs=get;list;watch;create;update;patch;delete + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + project := &v1alpha1.Project{} + if err := r.Client.Get(ctx, req.NamespacedName, project); err != nil { + if apierrors.IsNotFound(err) { + log.Info("Project not found") + return ctrl.Result{}, nil + } + log.Error(err, "unable to fetch Project") + return ctrl.Result{}, err + } + + projectNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceForProject(project), + }, + } + + // Check if there are remaining resources in the namespace that are blocking the deletion of the project + // If the project is not it deletion, this will return false + hasRemainingContent, err := r.handleRemainingContentBeforeDelete(ctx, project) + if err != nil { + return ctrl.Result{}, err + } + if hasRemainingContent { + if err := r.Status().Update(ctx, project); err != nil { + log.Error(err, "failed to update status") + } + + return ctrl.Result{ + RequeueAfter: 3 * time.Second, + }, nil + } + + deleted, dresult, err := r.handleDelete(ctx, project, func() error { + if err := r.Delete(ctx, projectNamespace); err != nil { + return client.IgnoreNotFound(err) + } + + return ResourcesRemainingError{RequeueAfter: 3 * time.Second} + }) + if deleted || err != nil { + return dresult, err + } + + if err := r.ensureFinalizer(ctx, project); err != nil { + return ctrl.Result{}, err + } + + // Always update status + defer func() { + if err := r.Status().Update(ctx, project); err != nil { + log.Error(err, "failed to update status") + } + }() + + // + // Namespace Creation + // + + result, err := controllerutil.CreateOrUpdate(ctx, r.Client, projectNamespace, func() error { + setProjectLabel(projectNamespace, project.Name) + r.applyManagementLabel(projectNamespace) + return nil + }) + if err != nil { + return ctrl.Result{}, err + } + logOperationResult(log, projectNamespace, result) + + project.Status.Namespace = projectNamespace.Name + + // + // Role bindings + // + + if err := r.createOrUpdateClusterRole(ctx, project); err != nil { + return ctrl.Result{}, err + } + if err := r.createOrUpdateRoleBinding(ctx, project, v1alpha1.ProjectRoleAdmin); err != nil { + return ctrl.Result{}, err + } + if err := r.createOrUpdateRoleBinding(ctx, project, v1alpha1.ProjectRoleView); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.Project{}). + Complete(r) +} + +func (r *ProjectReconciler) createOrUpdateRoleBinding(ctx context.Context, project *v1alpha1.Project, role v1alpha1.ProjectMemberRole) error { + log := log.FromContext(ctx) + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: roleBindingForRole(role), + Namespace: project.Status.Namespace, + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, r.Client, roleBinding, func() error { + r.applyManagementLabel(roleBinding) + + roleBinding.Subjects = getSubjectsForProjectRole(project, role) + roleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: clusterRoleForRole(role), + } + + return controllerutil.SetOwnerReference(project, roleBinding, r.Scheme) + }) + logOperationResult(log, roleBinding, result) + return err +} + +func getSubjectsForProjectRole(project *v1alpha1.Project, role v1alpha1.ProjectMemberRole) []rbacv1.Subject { + subjects := []rbacv1.Subject{} + + for _, member := range project.Spec.Members { + if hasProjectRole(member, role) { + subjects = append(subjects, member.Subject.RbacV1()) + } + } + + return subjects +} + +func hasProjectRole(member v1alpha1.ProjectMember, role v1alpha1.ProjectMemberRole) bool { + for _, memberRole := range member.Roles { + if memberRole == role { + return true + } + } + + return false +} + +func (r *ProjectReconciler) createOrUpdateClusterRole(ctx context.Context, project *v1alpha1.Project) error { + log := log.FromContext(ctx) + + projectRoles := map[v1alpha1.ProjectMemberRole][]string{ + v1alpha1.ProjectRoleAdmin: AllVerbs, + v1alpha1.ProjectRoleView: ReadOnlyVerbs, + } + + for role, verbs := range projectRoles { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForEntityAndRole(project, role), + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, r.Client, clusterRole, func() error { + r.applyManagementLabel(clusterRole) + + clusterRole.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{v1alpha1.GroupVersion.Group}, + Resources: []string{"projects"}, + ResourceNames: []string{project.Name}, + Verbs: verbs, + }, + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + ResourceNames: []string{project.Status.Namespace}, + Verbs: []string{"get"}, + }, + } + + // Delete ClusterRole automatically when Project is deleted. + return controllerutil.SetOwnerReference(project, clusterRole, r.Scheme) + }) + if err != nil { + return err + } + logOperationResult(log, clusterRole, result) + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForEntityAndRole(project, role), + }, + } + + result, err = controllerutil.CreateOrUpdate(ctx, r.Client, clusterRoleBinding, func() error { + r.applyManagementLabel(clusterRoleBinding) + + clusterRoleBinding.Subjects = getSubjectsForProjectRole(project, role) + clusterRoleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: clusterRole.Name, + } + + // Delete ClusterRoleBinding automatically when Project is deleted. + return controllerutil.SetOwnerReference(project, clusterRoleBinding, r.Scheme) + }) + if err != nil { + return err + } + logOperationResult(log, clusterRoleBinding, result) + } + + return nil +} diff --git a/internal/controller/core/project_controller_test.go b/internal/controller/core/project_controller_test.go new file mode 100644 index 0000000..252a001 --- /dev/null +++ b/internal/controller/core/project_controller_test.go @@ -0,0 +1,345 @@ +package core + +import ( + "context" + "errors" + "testing" + "time" + + "k8s.io/apimachinery/pkg/util/json" + + "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + 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/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +const ( + maxReconcileCycles = 10 +) + +var ( + sampleProject = &v1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample", + }, + Spec: v1alpha1.ProjectSpec{ + Members: []v1alpha1.ProjectMember{ + { + Subject: v1alpha1.Subject{ + Kind: rbacv1.UserKind, + Name: "user@example.com", + }, + Roles: []v1alpha1.ProjectMemberRole{v1alpha1.ProjectRoleAdmin}, + }, + { + Subject: v1alpha1.Subject{ + Kind: rbacv1.GroupKind, + Name: "some-group", + }, + Roles: []v1alpha1.ProjectMemberRole{v1alpha1.ProjectRoleAdmin}, + }, + { + Subject: v1alpha1.Subject{ + Kind: "ServiceAccount", + Name: "default", + Namespace: "default", + }, + Roles: []v1alpha1.ProjectMemberRole{v1alpha1.ProjectRoleView}, + }, + }, + }, + } + sampleProjectDeleted = &v1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample", + DeletionTimestamp: ptr.To(metav1.Now()), + Finalizers: []string{ + deleteFinalizer, + }, + }, + Status: v1alpha1.ProjectStatus{ + Namespace: "project-sample", + }, + } + + errFake = errors.New("fake") +) + +func Test_ProjectReconciler_Reconcile(t *testing.T) { + testCases := []struct { + desc string + initObjs []client.Object + interceptorFuncs interceptor.Funcs + expectedResult ctrl.Result + expectedErr error + validate func(t *testing.T, ctx context.Context, c client.Client) error + }{ + { + desc: "CO-1154 should not return error when not found", + initObjs: []client.Object{ + sampleProject, + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return apierrors.NewNotFound(v1alpha1.GroupVersion.WithResource("projects").GroupResource(), sampleProject.Name) + }, + }, + expectedResult: reconcile.Result{}, + expectedErr: nil, + }, + { + desc: "CO-1154 should return error when unknown error occurs", + initObjs: []client.Object{ + sampleProject, + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return errFake + }, + }, + expectedResult: reconcile.Result{}, + expectedErr: errFake, + }, + { + desc: "CO-1154 should create namespace and RBAC resources", + initObjs: []client.Object{ + sampleProject, + }, + expectedResult: reconcile.Result{}, + expectedErr: nil, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + // check project status + p := &v1alpha1.Project{} + assert.NoErrorf(t, c.Get(ctx, client.ObjectKeyFromObject(sampleProject), p), "GET failed unexpectedly") + assert.Equal(t, "project-sample", p.Status.Namespace) + assert.Contains(t, p.Finalizers, deleteFinalizer) + + namespaceCreatedForProject(t, ctx, c, p, true) + + expectedAdmins := []rbacv1.Subject{ + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.UserKind, + Name: "user@example.com", + }, + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.GroupKind, + Name: "some-group", + }, + } + + clusterRoleCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleAdmin, true, 2) + clusterRoleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleAdmin, true, expectedAdmins) + roleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleAdmin, true, expectedAdmins) + + expectedViewers := []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: "default", + Namespace: "default", + }, + } + + clusterRoleCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleView, true, 2) + clusterRoleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleView, true, expectedViewers) + roleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleView, true, expectedViewers) + + return nil + }, + }, + { + desc: "CO-1154 should delete namespace", + initObjs: []client.Object{ + sampleProjectDeleted, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: sampleProjectDeleted.Status.Namespace, + }, + }, + }, + expectedResult: reconcile.Result{}, + expectedErr: nil, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + // check project status + p := &v1alpha1.Project{} + err := c.Get(ctx, client.ObjectKeyFromObject(sampleProjectDeleted), p) + assert.True(t, apierrors.IsNotFound(err)) + + namespaceCreatedForProject(t, ctx, c, sampleProjectDeleted, false) + + return nil + }, + }, + { + desc: "CO-1154 should not delete namespace when deletion is blocked by resources", + initObjs: []client.Object{ + sampleProjectDeleted, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: sampleProjectDeleted.Status.Namespace, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "blocking", + Namespace: sampleProjectDeleted.Status.Namespace, + }, + }, + }, + expectedResult: reconcile.Result{RequeueAfter: 3 * time.Second}, + expectedErr: nil, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + // check workspace status + p := &v1alpha1.Project{} + err := c.Get(ctx, client.ObjectKeyFromObject(sampleProjectDeleted), p) + assert.NoError(t, err) + + assert.Len(t, p.Status.Conditions, 1) + assert.Equal(t, v1alpha1.ConditionTypeContentRemaining, p.Status.Conditions[0].Type) + assert.Equal(t, v1alpha1.ConditionStatusTrue, p.Status.Conditions[0].Status) + assert.Equal(t, v1alpha1.ConditionReasonResourcesRemaining, p.Status.Conditions[0].Reason) + assert.NotEmpty(t, p.Status.Conditions[0].Message) + assert.NotNil(t, p.Status.Conditions[0].Details) + + var remainingResources []v1alpha1.RemainingContentResource + assert.NoError(t, json.Unmarshal(p.Status.Conditions[0].Details, &remainingResources)) + assert.Len(t, remainingResources, 1) + assert.Equal(t, "v1", remainingResources[0].APIGroup) + assert.Equal(t, "Secret", remainingResources[0].Kind) + assert.Equal(t, "blocking", remainingResources[0].Name) + + ns := &corev1.Namespace{} + err = c.Get(ctx, types.NamespacedName{Name: p.Status.Namespace}, ns) + assert.NoError(t, err) + assert.Nil(t, ns.GetDeletionTimestamp()) + + return nil + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithObjects(tC.initObjs...). + WithInterceptorFuncs(tC.interceptorFuncs). + WithStatusSubresource(tC.initObjs[0]). + WithScheme(Scheme). + Build() + ctx := newContext() + req := newRequest(tC.initObjs[0]) + + sr := &ProjectReconciler{ + Client: c, + Scheme: c.Scheme(), + CommonReconciler: CommonReconciler{ + Client: c, + ControllerName: "test", + ProjectWorkspaceConfig: &config.ProjectWorkspaceConfig{ + Project: config.ProjectConfig{ + ResourcesBlockingDeletion: []config.GroupVersionKind{ + { + Group: "", + Version: "v1", + Kind: "Secret", + }, + }, + }, + }, + }, + } + + result, err := ctrl.Result{}, error(nil) + for i := 0; i < maxReconcileCycles; i++ { + result, err = sr.Reconcile(ctx, req) + if result == tC.expectedResult || result.RequeueAfter == 0 || err != nil { + break + } + } + + assert.Equal(t, tC.expectedResult, result) + assert.Equal(t, tC.expectedErr, err) + + if tC.validate != nil { + assert.NoError(t, tC.validate(t, ctx, c)) + } + }) + } +} + +func newContext() context.Context { + ctx := context.Background() + ctx = log.IntoContext(ctx, log.Log) + return ctx +} + +func newRequest(obj client.Object) ctrl.Request { + return ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(obj), + } +} + +func namespaceCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, expectation bool) *corev1.Namespace { + ns := &corev1.Namespace{} + err := c.Get(ctx, types.NamespacedName{Name: p.Status.Namespace}, ns) + if expectation { + assert.NoError(t, err) + assert.Equal(t, p.Name, ns.Labels[labelProject]) + } else { + assert.True(t, apierrors.IsNotFound(err)) + } + return ns +} + +func clusterRoleCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, role v1alpha1.ProjectMemberRole, expectation bool, expectedRules int) { + cr := &rbacv1.ClusterRole{} + err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRole(p, role)}, cr) + if expectation { + assert.NoError(t, err) + assert.Len(t, cr.Rules, expectedRules) + if assert.Len(t, cr.OwnerReferences, 1) { + assert.Equal(t, p.UID, cr.OwnerReferences[0].UID) + } + } else { + assert.True(t, apierrors.IsNotFound(err)) + } +} + +func clusterRoleBindingCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, role v1alpha1.ProjectMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { + crb := &rbacv1.ClusterRoleBinding{} + err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRole(p, role)}, crb) + if expectation { + assert.NoError(t, err) + assert.Equal(t, expectedSubjects, crb.Subjects) + if assert.Len(t, crb.OwnerReferences, 1) { + assert.Equal(t, p.UID, crb.OwnerReferences[0].UID) + } + } else { + assert.True(t, apierrors.IsNotFound(err)) + } +} + +func roleBindingCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, role v1alpha1.ProjectMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { + rb := &rbacv1.RoleBinding{} + err := c.Get(ctx, types.NamespacedName{Name: roleBindingForRole(role), Namespace: p.Status.Namespace}, rb) + if expectation { + assert.NoError(t, err) + assert.Equal(t, expectedSubjects, rb.Subjects) + } else { + assert.True(t, apierrors.IsNotFound(err)) + } +} diff --git a/internal/controller/core/rbac.go b/internal/controller/core/rbac.go new file mode 100644 index 0000000..9700def --- /dev/null +++ b/internal/controller/core/rbac.go @@ -0,0 +1,178 @@ +package core + +import ( + "context" + + "github.com/go-logr/logr" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +var ( + AllVerbs = []string{ + "get", + "list", + "watch", + "create", + "update", + "patch", + "delete", + } + ReadOnlyVerbs = []string{ + "get", + "list", + "watch", + } +) + +func NewRBACSetup(setupLog logr.Logger, c client.Client, controllerName string) *RBACSetup { + return &RBACSetup{ + log: setupLog, + client: c, + controllerName: controllerName, + } +} + +type RBACSetup struct { + log logr.Logger + client client.Client + controllerName string +} + +func (setup *RBACSetup) EnsureResources(ctx context.Context) error { + if err := setup.createOrUpdateProjectClusterRoles(ctx); err != nil { + return err + } + + if err := setup.createOrUpdateWorkspaceClusterRoles(ctx); err != nil { + return err + } + + return nil +} + +func (setup *RBACSetup) createOrUpdateProjectClusterRoles(ctx context.Context) error { + projectRoles := map[openmcpv1alpha1.ProjectMemberRole][]string{ + openmcpv1alpha1.ProjectRoleAdmin: AllVerbs, + openmcpv1alpha1.ProjectRoleView: ReadOnlyVerbs, + } + + for role, verbs := range projectRoles { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForRole(role), + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, setup.client, clusterRole, func() error { + setManagementLabel(clusterRole, setup.controllerName) + + clusterRole.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{openmcpv1alpha1.GroupVersion.Group}, + Resources: []string{"workspaces"}, + Verbs: verbs, + }, + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"serviceaccounts"}, + Verbs: verbs, + }, + { + APIGroups: []string{corev1.GroupName}, // this rule prevents k9s from crashing + Resources: []string{"pods"}, + Verbs: []string{"list"}, + }, + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"resourcequotas"}, + Verbs: ReadOnlyVerbs, + }, + } + + if role == openmcpv1alpha1.ProjectRoleAdmin { + clusterRole.Rules = append(clusterRole.Rules, rbacv1.PolicyRule{ + APIGroups: []string{corev1.GroupName}, + Resources: []string{"serviceaccounts/token"}, + Verbs: []string{"create"}, + }) + } + + return nil + }) + if err != nil { + return err + } + logOperationResult(setup.log, clusterRole, result) + } + + return nil +} + +func (setup *RBACSetup) createOrUpdateWorkspaceClusterRoles(ctx context.Context) error { + workspaceRoles := map[openmcpv1alpha1.WorkspaceMemberRole][]string{ + openmcpv1alpha1.WorkspaceRoleAdmin: AllVerbs, + openmcpv1alpha1.WorkspaceRoleView: ReadOnlyVerbs, + } + + for role, verbs := range workspaceRoles { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForRole(role), + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, setup.client, clusterRole, func() error { + setManagementLabel(clusterRole, setup.controllerName) + + clusterRole.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{openmcpv1alpha1.GroupVersion.Group}, + Resources: []string{"managedcontrolplanes", "clusteradmins"}, + Verbs: verbs, + }, + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{ + "secrets", + "configmaps", + "serviceaccounts", + }, + Verbs: verbs, + }, + { + APIGroups: []string{corev1.GroupName}, // this rule prevents k9s from crashing + Resources: []string{"pods"}, + Verbs: []string{"list"}, + }, + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"resourcequotas"}, + Verbs: ReadOnlyVerbs, + }, + } + + if role == openmcpv1alpha1.WorkspaceRoleAdmin { + clusterRole.Rules = append(clusterRole.Rules, rbacv1.PolicyRule{ + APIGroups: []string{corev1.GroupName}, + Resources: []string{"serviceaccounts/token"}, + Verbs: []string{"create"}, + }) + } + + return nil + }) + if err != nil { + return err + } + logOperationResult(setup.log, clusterRole, result) + } + + return nil +} diff --git a/internal/controller/core/rbac_test.go b/internal/controller/core/rbac_test.go new file mode 100644 index 0000000..979e90d --- /dev/null +++ b/internal/controller/core/rbac_test.go @@ -0,0 +1,109 @@ +package core + +import ( + "context" + "errors" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/assert" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/types" + "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/client/interceptor" + + "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +func TestRBACSetup_EnsureResources(t *testing.T) { + tests := []struct { + name string + interceptorFuncs interceptor.Funcs + expectedError *string + validateFunc func(ctx context.Context, client client.Client) error + }{ + { + name: "Failed to Create/Update Project Cluster Roles", + interceptorFuncs: interceptor.Funcs{ + Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if _, ok := obj.(*rbacv1.ClusterRole); ok { + return errors.New("some create error") + } + return nil + }, + }, + expectedError: ptr.To("some create error"), + }, + { + name: "Failed to Create/Update Workspace Cluster Roles", + interceptorFuncs: interceptor.Funcs{ + Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if role, ok := obj.(*rbacv1.ClusterRole); ok && role.Name == clusterRoleForRole(v1alpha1.WorkspaceRoleView) { + return errors.New("some create error") + } + return client.Create(ctx, obj) + }, + }, + expectedError: ptr.To("some create error"), + }, + { + name: "Successfully Create/Update Project and Workspace Cluster Roles", + expectedError: nil, + validateFunc: func(ctx context.Context, client client.Client) error { + clusterRoleProjectAdmin := &rbacv1.ClusterRole{} + err := client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.ProjectRoleAdmin)}, clusterRoleProjectAdmin) + if err != nil { + return err + } + + assert.NotEmpty(t, clusterRoleProjectAdmin.Rules) + + clusterRoleProjectView := &rbacv1.ClusterRole{} + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.ProjectRoleView)}, clusterRoleProjectView) + if err != nil { + return err + } + + assert.NotEmpty(t, clusterRoleProjectView.Rules) + + clusterRoleWorkspaceAdmin := &rbacv1.ClusterRole{} + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.WorkspaceRoleAdmin)}, clusterRoleWorkspaceAdmin) + if err != nil { + return err + } + + assert.NotEmpty(t, clusterRoleWorkspaceAdmin.Rules) + + clusterRoleWorkspaceView := &rbacv1.ClusterRole{} + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.WorkspaceRoleView)}, clusterRoleWorkspaceView) + if err != nil { + return err + } + + assert.NotEmpty(t, clusterRoleWorkspaceView.Rules) + + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + c := fake.NewClientBuilder().WithInterceptorFuncs(tt.interceptorFuncs).Build() + s := NewRBACSetup(testr.New(t), c, "test-rbac-controller") + + actualError := s.EnsureResources(ctx) + + if tt.expectedError != nil { + assert.EqualError(t, actualError, *tt.expectedError) + } + if tt.validateFunc != nil { + err := tt.validateFunc(ctx, c) + assert.NoErrorf(t, err, "validation failed unexpected") + } + }) + } +} diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go new file mode 100644 index 0000000..ddf38a3 --- /dev/null +++ b/internal/controller/core/suite_test.go @@ -0,0 +1,64 @@ +package core + +import ( + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = openmcpv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/internal/controller/core/utils.go b/internal/controller/core/utils.go new file mode 100644 index 0000000..72924e8 --- /dev/null +++ b/internal/controller/core/utils.go @@ -0,0 +1,47 @@ +package core + +import ( + "fmt" + "reflect" + + "github.com/go-logr/logr" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +func namespaceForProject(project *openmcpv1alpha1.Project) string { + return fmt.Sprintf("project-%s", project.Name) +} + +func namespaceForWorkspace(workspace *openmcpv1alpha1.Workspace) string { + return fmt.Sprintf("%s--ws-%s", workspace.Namespace, workspace.Name) +} + +// wasDeleted returns true if the supplied object was deleted from the API server. +func wasDeleted(o metav1.Object) bool { + return !o.GetDeletionTimestamp().IsZero() +} + +// setMetaDataLabel sets the key value pair in the labels section of the given Object. +// If the given Object did not yet have labels, they are initialized. +func setMetaDataLabel(meta metav1.Object, key, value string) { // TODO move to utils package + labels := meta.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + labels[key] = value + meta.SetLabels(labels) +} + +func logOperationResult(log logr.Logger, obj client.Object, result controllerutil.OperationResult) { + objType := reflect.ValueOf(obj).Elem().Type() + if obj.GetNamespace() == "" { + log.Info(fmt.Sprintf("%s %s %s", objType.Name(), obj.GetName(), result)) + } else { + log.Info(fmt.Sprintf("%s %s/%s %s", objType.Name(), obj.GetNamespace(), obj.GetName(), result)) + } +} diff --git a/internal/controller/core/utils_test.go b/internal/controller/core/utils_test.go new file mode 100644 index 0000000..a2cde40 --- /dev/null +++ b/internal/controller/core/utils_test.go @@ -0,0 +1,127 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +func newTestProject(name string) *openmcpv1alpha1.Project { + return &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } +} + +func newTestWorkspace(namespace string, name string) *openmcpv1alpha1.Workspace { + return &openmcpv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } +} + +func TestNamespaceForProject(t *testing.T) { + tests := []struct { + description string + project *openmcpv1alpha1.Project + expected string + }{ + { + description: "happy path", + project: newTestProject("test"), + expected: "project-test", + }, + // FIXME the current implementation panics if the project is nil + /*{ + description: "doesn't fail if nil", + expected: "project-default", + },*/ + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + uut := namespaceForProject(test.project) + + assert.Equal(t, test.expected, uut) + }) + } +} + +func TestNamespaceForWorkspace(t *testing.T) { + tests := []struct { + description string + workspace *openmcpv1alpha1.Workspace + expected string + }{ + { + description: "happy path", + workspace: newTestWorkspace("my-namespace", "test"), + expected: "my-namespace--ws-test", + }, + // FIXME the current implementation panics if the workspace is nil + /*{ + description: "doesn't fail if nil", + expected: "tbd", + },*/ + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + uut := namespaceForWorkspace(test.workspace) + + assert.Equal(t, test.expected, uut) + }) + } +} + +func TestWasDeleted(t *testing.T) { + t.Run("returns 'true' if the object has been deleted", func(t *testing.T) { + timestamp := metav1.Now() + + uut := &openmcpv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + DeletionTimestamp: ×tamp, + }, + } + + assert.True(t, wasDeleted(uut)) + }) + t.Run("returns 'false' if the object is not deleted", func(t *testing.T) { + uut := &openmcpv1alpha1.Project{} + + assert.False(t, wasDeleted(uut)) + }) +} + +func TestSetMetaDataLabel(t *testing.T) { + t.Run("set's the label on an object which has no other labels set", func(t *testing.T) { + var obj metav1.ObjectMeta + + setMetaDataLabel(&obj, "test", "abc") + + assert.Equal(t, map[string]string{"test": "abc"}, obj.GetLabels()) + }) + t.Run("overwrites the label on an object if it was set before", func(t *testing.T) { + var obj metav1.ObjectMeta + + setMetaDataLabel(&obj, "test", "abc") + setMetaDataLabel(&obj, "test", "def") + + assert.Equal(t, map[string]string{"test": "def"}, obj.GetLabels()) + }) + t.Run("doesn't modify other labels", func(t *testing.T) { + var obj metav1.ObjectMeta + + setMetaDataLabel(&obj, "a", "abc") + setMetaDataLabel(&obj, "b", "def") + + assert.Equal(t, map[string]string{"a": "abc", "b": "def"}, obj.GetLabels()) + }) +} diff --git a/internal/controller/core/workspace_controller.go b/internal/controller/core/workspace_controller.go new file mode 100644 index 0000000..7ff3a14 --- /dev/null +++ b/internal/controller/core/workspace_controller.go @@ -0,0 +1,318 @@ +package core + +import ( + "context" + "errors" + "time" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +var ( + ErrNamespaceHasNoLabels = errors.New("namespace has no labels. Map is nil") + ErrNamespaceHasNoProjectLabel = errors.New("namespace has no project label") +) + +// WorkspaceReconciler reconciles a Workspace object +type WorkspaceReconciler struct { + client.Client + Scheme *runtime.Scheme + CommonReconciler +} + +//+kubebuilder:rbac:groups=core.openmcp.cloud,resources=workspaces,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core.openmcp.cloud,resources=workspaces/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=core.openmcp.cloud,resources=workspaces/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + workspace := &v1alpha1.Workspace{} + if err := r.Client.Get(ctx, req.NamespacedName, workspace); err != nil { + if apierrors.IsNotFound(err) { + log.Info("Workspace not found") + return ctrl.Result{}, nil + } + log.Error(err, "unable to fetch Workspace") + return ctrl.Result{}, err + } + + project, err := r.getProjectByNamespace(ctx, workspace.Namespace) + if err != nil { + log.Error(err, "unable to fetch Project of Workspace") + return ctrl.Result{}, err + } + + workspaceNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceForWorkspace(workspace), + }, + } + + // Check if there are remaining resources in the namespace that are blocking the deletion of the Workspace + // If the workspace is not it deletion, this will return false + hasRemainingContent, err := r.handleRemainingContentBeforeDelete(ctx, workspace) + if err != nil { + return ctrl.Result{}, err + } + if hasRemainingContent { + if err := r.Status().Update(ctx, workspace); err != nil { + log.Error(err, "failed to update status") + } + + return ctrl.Result{ + RequeueAfter: 3 * time.Second, + }, nil + } + + deleted, dresult, err := r.handleDelete(ctx, workspace, func() error { + if err := r.Delete(ctx, workspaceNamespace); err != nil { + return client.IgnoreNotFound(err) + } + if err := r.deleteClusterRole(ctx, project, workspace); err != nil { + return err + } + + return ResourcesRemainingError{RequeueAfter: 3 * time.Second} + }) + if deleted || err != nil { + return dresult, err + } + + if err := r.ensureFinalizer(ctx, workspace); err != nil { + return ctrl.Result{}, err + } + + // Always update status + defer func() { + if err := r.Status().Update(ctx, workspace); err != nil { + log.Error(err, "failed to update status") + } + }() + + // + // Namespace Creation + // + + result, err := controllerutil.CreateOrUpdate(ctx, r.Client, workspaceNamespace, func() error { + setWorkspaceLabel(workspaceNamespace, workspace.Name) + setProjectLabel(workspaceNamespace, project.Name) + r.applyManagementLabel(workspaceNamespace) + return nil + }) + if err != nil { + return ctrl.Result{}, err + } + logOperationResult(log, workspaceNamespace, result) + + workspace.Status.Namespace = workspaceNamespace.Name + + // + // Role bindings + // + + if err := r.createOrUpdateClusterRole(ctx, project, workspace); err != nil { + return ctrl.Result{}, err + } + if err := r.createOrUpdateRoleBinding(ctx, workspace, v1alpha1.WorkspaceRoleAdmin); err != nil { + return ctrl.Result{}, err + } + if err := r.createOrUpdateRoleBinding(ctx, workspace, v1alpha1.WorkspaceRoleView); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *WorkspaceReconciler) getProjectByNamespace(ctx context.Context, namespaceName string) (*v1alpha1.Project, error) { + namespace := &corev1.Namespace{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: namespaceName}, namespace); err != nil { + return nil, err + } + + if namespace.Labels == nil { + return nil, ErrNamespaceHasNoLabels + } + + projectName := namespace.Labels[labelProject] + if projectName == "" { + return nil, ErrNamespaceHasNoProjectLabel + } + + project := &v1alpha1.Project{} + if err := r.Client.Get(ctx, types.NamespacedName{Name: projectName}, project); err != nil { + return nil, err + } + + return project, nil +} + +func (r *WorkspaceReconciler) createOrUpdateRoleBinding(ctx context.Context, workspace *v1alpha1.Workspace, workspaceRole v1alpha1.WorkspaceMemberRole) error { + log := log.FromContext(ctx) + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: roleBindingForRole(workspaceRole), + Namespace: workspace.Status.Namespace, + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, r.Client, roleBinding, func() error { + r.applyManagementLabel(roleBinding) + + roleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: clusterRoleForRole(workspaceRole), + } + + roleBinding.Subjects = getSubjectsForWorkspaceRole(workspace, workspaceRole) + return nil + }) + logOperationResult(log, roleBinding, result) + return err +} + +// createOrUpdateClusterRole manages the ClusterRole and ClusterRoleBinding granting GET permissions to the namespace belonging to the workspace. +func (r *WorkspaceReconciler) createOrUpdateClusterRole(ctx context.Context, project *v1alpha1.Project, ws *v1alpha1.Workspace) error { + log := log.FromContext(ctx) + + workspaceRoles := []v1alpha1.WorkspaceMemberRole{ + v1alpha1.WorkspaceRoleAdmin, + v1alpha1.WorkspaceRoleView, + } + + for _, role := range workspaceRoles { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForEntityAndRoleWithParent(ws, role, project), + }, + } + + result, err := controllerutil.CreateOrUpdate(ctx, r.Client, clusterRole, func() error { + r.applyManagementLabel(clusterRole) + + clusterRole.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + ResourceNames: []string{ws.Status.Namespace}, + Verbs: []string{"get"}, + }, + } + + return nil + }) + if err != nil { + return err + } + logOperationResult(log, clusterRole, result) + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForEntityAndRoleWithParent(ws, role, project), + }, + } + + result, err = controllerutil.CreateOrUpdate(ctx, r.Client, clusterRoleBinding, func() error { + r.applyManagementLabel(clusterRoleBinding) + + clusterRoleBinding.Subjects = getSubjectsForWorkspaceRole(ws, role) + clusterRoleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: clusterRole.Name, + } + + return nil + }) + if err != nil { + return err + } + logOperationResult(log, clusterRoleBinding, result) + } + + return nil +} + +// deleteClusterRole deletes the ClusterRole and ClusterRoleBinding that were created for the Workspace. +// It has to be done explicitly because cross-namespace OwnerReferences are not allowed. +func (r *WorkspaceReconciler) deleteClusterRole(ctx context.Context, project *v1alpha1.Project, ws *v1alpha1.Workspace) error { + log := log.FromContext(ctx) + + workspaceRoles := []v1alpha1.WorkspaceMemberRole{ + v1alpha1.WorkspaceRoleAdmin, + v1alpha1.WorkspaceRoleView, + } + + for _, role := range workspaceRoles { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForEntityAndRoleWithParent(ws, role, project), + }, + } + + if err := r.Delete(ctx, clusterRole); err != nil { + if !apierrors.IsNotFound(err) { + return err + } + log.Info("Deleted ClusterRole", "name", clusterRole.Name) + } + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleForEntityAndRoleWithParent(ws, role, project), + }, + } + + if err := r.Delete(ctx, clusterRoleBinding); err != nil { + if !apierrors.IsNotFound(err) { + return err + } + log.Info("Deleted ClusterRoleBinding", "name", clusterRoleBinding.Name) + } + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *WorkspaceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.Workspace{}). + Complete(r) +} + +func getSubjectsForWorkspaceRole(workspace *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole) []rbacv1.Subject { + subjects := []rbacv1.Subject{} + + for _, member := range workspace.Spec.Members { + if hasWorkspaceRole(member, role) { + subjects = append(subjects, member.Subject.RbacV1()) + } + } + + return subjects +} + +func hasWorkspaceRole(member v1alpha1.WorkspaceMember, role v1alpha1.WorkspaceMemberRole) bool { + for _, memberRole := range member.Roles { + if memberRole == role { + return true + } + } + + return false +} diff --git a/internal/controller/core/workspace_controller_test.go b/internal/controller/core/workspace_controller_test.go new file mode 100644 index 0000000..d3ddae5 --- /dev/null +++ b/internal/controller/core/workspace_controller_test.go @@ -0,0 +1,371 @@ +package core + +import ( + "context" + "testing" + "time" + + "k8s.io/apimachinery/pkg/util/json" + + "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + 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/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +var ( + projectNamespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceForProject(sampleProject), + Labels: map[string]string{ + labelProject: sampleProject.Name, + }, + }, + } + sampleWorkspace = &v1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample", + Namespace: projectNamespace.Name, + }, + Spec: v1alpha1.WorkspaceSpec{ + Members: []v1alpha1.WorkspaceMember{ + { + Subject: v1alpha1.Subject{ + Kind: rbacv1.UserKind, + Name: "user@example.com", + }, + Roles: []v1alpha1.WorkspaceMemberRole{v1alpha1.WorkspaceRoleAdmin}, + }, + { + Subject: v1alpha1.Subject{ + Kind: rbacv1.GroupKind, + Name: "some-group", + }, + Roles: []v1alpha1.WorkspaceMemberRole{v1alpha1.WorkspaceRoleAdmin}, + }, + { + Subject: v1alpha1.Subject{ + Kind: "ServiceAccount", + Name: "default", + Namespace: "default", + }, + Roles: []v1alpha1.WorkspaceMemberRole{v1alpha1.WorkspaceRoleView}, + }, + }, + }, + } + sampleWorkspaceDeleted = &v1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample", + Namespace: projectNamespace.Name, + DeletionTimestamp: ptr.To(metav1.Now()), + Finalizers: []string{ + deleteFinalizer, + }, + }, + Status: v1alpha1.WorkspaceStatus{ + Namespace: "project-sample--ws-sample", + }, + } +) + +func Test_WorkspaceReconciler_Reconcile(t *testing.T) { + testCases := []struct { + desc string + initObjs []client.Object + interceptorFuncs interceptor.Funcs + expectedResult ctrl.Result + expectedErr error + validate func(t *testing.T, ctx context.Context, c client.Client) error + }{ + { + desc: "CO-1154 should not return error when not found", + initObjs: []client.Object{ + sampleWorkspace, + projectNamespace, + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return apierrors.NewNotFound(v1alpha1.GroupVersion.WithResource("workspaces").GroupResource(), sampleWorkspace.Name) + }, + }, + expectedResult: reconcile.Result{}, + expectedErr: nil, + }, + { + desc: "CO-1154 should return error when unknown error occurs", + initObjs: []client.Object{ + sampleWorkspace, + projectNamespace, + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return errFake + }, + }, + expectedResult: reconcile.Result{}, + expectedErr: errFake, + }, + { + desc: "CO-1154 should return error when project namespace label map is nil", + initObjs: []client.Object{ + sampleWorkspace, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: sampleWorkspace.Namespace, + }, + }, + }, + expectedErr: ErrNamespaceHasNoLabels, + }, + { + desc: "CO-1154 should return error when project namespace has no project label", + initObjs: []client.Object{ + sampleWorkspace, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: sampleWorkspace.Namespace, + Labels: map[string]string{ + "some-unrelated-label": "true", + }, + }, + }, + }, + expectedErr: ErrNamespaceHasNoProjectLabel, + }, + { + desc: "CO-1154 should create namespace and RBAC resources", + initObjs: []client.Object{ + sampleWorkspace, + projectNamespace, + sampleProject, + }, + expectedResult: reconcile.Result{}, + expectedErr: nil, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + // check workspace status + ws := &v1alpha1.Workspace{} + assert.NoErrorf(t, c.Get(ctx, client.ObjectKeyFromObject(sampleWorkspace), ws), "GET failed unexpectedly") + assert.Equal(t, "project-sample--ws-sample", ws.Status.Namespace) + assert.Contains(t, ws.Finalizers, deleteFinalizer) + + namespaceCreatedForWorkspace(t, ctx, c, ws, true) + + expectedAdmins := []rbacv1.Subject{ + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.UserKind, + Name: "user@example.com", + }, + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.GroupKind, + Name: "some-group", + }, + } + + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, true, 1) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, true, expectedAdmins) + roleBindingCreatedForWorkspace(t, ctx, c, ws, v1alpha1.WorkspaceRoleAdmin, true, expectedAdmins) + + expectedViewers := []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: "default", + Namespace: "default", + }, + } + + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, true, 1) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, true, expectedViewers) + roleBindingCreatedForWorkspace(t, ctx, c, ws, v1alpha1.WorkspaceRoleView, true, expectedViewers) + + return nil + }, + }, + { + desc: "CO-1154 should delete namespace", + initObjs: []client.Object{ + sampleWorkspaceDeleted, + projectNamespace, + sampleProject, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: sampleWorkspaceDeleted.Status.Namespace, + }, + }, + }, + expectedResult: reconcile.Result{}, + expectedErr: nil, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + // check workspace status + ws := &v1alpha1.Workspace{} + err := c.Get(ctx, client.ObjectKeyFromObject(sampleWorkspaceDeleted), ws) + assert.True(t, apierrors.IsNotFound(err)) + + namespaceCreatedForWorkspace(t, ctx, c, sampleWorkspaceDeleted, false) + + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, false, 0) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, false, nil) + + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, false, 0) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, false, nil) + + return nil + }, + }, + { + desc: "CO-1154 should not delete namespace when deletion is blocked by resources", + initObjs: []client.Object{ + sampleWorkspaceDeleted, + projectNamespace, + sampleProject, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: sampleWorkspaceDeleted.Status.Namespace, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "blocking", + Namespace: sampleWorkspaceDeleted.Status.Namespace, + }, + }, + }, + expectedResult: reconcile.Result{RequeueAfter: 3 * time.Second}, + expectedErr: nil, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + // check workspace status + ws := &v1alpha1.Workspace{} + err := c.Get(ctx, client.ObjectKeyFromObject(sampleWorkspaceDeleted), ws) + assert.NoError(t, err) + assert.NotNil(t, ws.GetDeletionTimestamp()) + + assert.Len(t, ws.Status.Conditions, 1) + assert.Equal(t, v1alpha1.ConditionTypeContentRemaining, ws.Status.Conditions[0].Type) + assert.Equal(t, v1alpha1.ConditionStatusTrue, ws.Status.Conditions[0].Status) + assert.Equal(t, v1alpha1.ConditionReasonResourcesRemaining, ws.Status.Conditions[0].Reason) + assert.NotEmpty(t, ws.Status.Conditions[0].Message) + assert.NotNil(t, ws.Status.Conditions[0].Details) + + var remainingResources []v1alpha1.RemainingContentResource + assert.NoError(t, json.Unmarshal(ws.Status.Conditions[0].Details, &remainingResources)) + assert.Len(t, remainingResources, 1) + assert.Equal(t, "v1", remainingResources[0].APIGroup) + assert.Equal(t, "Secret", remainingResources[0].Kind) + assert.Equal(t, "blocking", remainingResources[0].Name) + + ns := &corev1.Namespace{} + err = c.Get(ctx, types.NamespacedName{Name: ws.Status.Namespace}, ns) + assert.NoError(t, err) + assert.Nil(t, ns.GetDeletionTimestamp()) + + return nil + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithObjects(tC.initObjs...). + WithInterceptorFuncs(tC.interceptorFuncs). + WithStatusSubresource(tC.initObjs[0]). + WithScheme(Scheme). + Build() + ctx := newContext() + req := newRequest(tC.initObjs[0]) + + sr := &WorkspaceReconciler{ + Client: c, + Scheme: c.Scheme(), + CommonReconciler: CommonReconciler{ + Client: c, + ControllerName: "test", + ProjectWorkspaceConfig: &config.ProjectWorkspaceConfig{ + Workspace: config.WorkspaceConfig{ + ResourcesBlockingDeletion: []config.GroupVersionKind{ + { + Group: "", + Version: "v1", + Kind: "Secret", + }, + }, + }, + }, + }, + } + + result, err := ctrl.Result{}, error(nil) + for i := 0; i < maxReconcileCycles; i++ { + result, err = sr.Reconcile(ctx, req) + if result == tC.expectedResult || result.RequeueAfter == 0 || err != nil { + break + } + } + + assert.Equal(t, tC.expectedResult, result) + assert.Equal(t, tC.expectedErr, err) + + if tC.validate != nil { + assert.NoError(t, tC.validate(t, ctx, c)) + } + }) + } +} + +func namespaceCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, ws *v1alpha1.Workspace, expectation bool) *corev1.Namespace { + ns := &corev1.Namespace{} + err := c.Get(ctx, types.NamespacedName{Name: ws.Status.Namespace}, ns) + if expectation { + assert.NoError(t, err) + assert.Equal(t, ws.Name, ns.Labels[labelWorkspace]) + } else { + assert.True(t, apierrors.IsNotFound(err)) + } + return ns +} + +func clusterRoleCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, ws *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole, expectation bool, expectedRules int) { + cr := &rbacv1.ClusterRole{} + err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRoleWithParent(ws, role, p)}, cr) + if expectation { + assert.NoError(t, err) + assert.Len(t, cr.Rules, expectedRules) + } else { + assert.True(t, apierrors.IsNotFound(err)) + } +} + +func clusterRoleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, ws *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { + crb := &rbacv1.ClusterRoleBinding{} + err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRoleWithParent(ws, role, p)}, crb) + if expectation { + assert.NoError(t, err) + assert.Equal(t, expectedSubjects, crb.Subjects) + } else { + assert.True(t, apierrors.IsNotFound(err)) + } +} + +func roleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, ws *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { + rb := &rbacv1.RoleBinding{} + err := c.Get(ctx, types.NamespacedName{Name: roleBindingForRole(role), Namespace: ws.Status.Namespace}, rb) + if expectation { + assert.NoError(t, err) + assert.Equal(t, expectedSubjects, rb.Subjects) + } else { + assert.True(t, apierrors.IsNotFound(err)) + } +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 0000000..0cbfcb0 --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,67 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "testing" + + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/envfuncs" + "sigs.k8s.io/e2e-framework/support/kind" + "sigs.k8s.io/e2e-framework/third_party/helm" +) + +var ( + e2eClusterName = getEnv("E2E_CLUSTER_NAME", envconf.RandomName("openmcp-pwo-e2e", 16)) + e2eUUTImage = getEnv("E2E_UUT_IMAGE", "project-workspace-operator") + e2eUUTTag = getEnv("E2E_UUT_VERSION", "dev") + e2eUUTImageTag = fmt.Sprintf("%s:%s", e2eUUTImage, e2eUUTTag) + e2eUUTChart = getEnv("E2E_UUT_CHART", "../../charts/project-workspace-operator") +) + +func TestMain(m *testing.M) { + testenv := env.New(). + Setup( + // create a kind cluster + envfuncs.CreateCluster(kind.NewProvider(), e2eClusterName), + + // load the operator image to be tested into the cluster + envfuncs.LoadImageToCluster(e2eClusterName, e2eUUTImageTag), + + // install the operator using the helmchart + func(ctx context.Context, config *envconf.Config) (context.Context, error) { + manager := helm.New(config.KubeconfigFile()) + err := manager.RunInstall( + helm.WithName("project-workspace-operator"), + helm.WithChart(e2eUUTChart), + helm.WithArgs( + "--set", fmt.Sprintf("image.repository=%s", e2eUUTImage), + "--set", fmt.Sprintf("image.tag=%s", e2eUUTTag), + "--set", "image.pullPolicy=Never", + "-f", "../../hack/local-values.yaml", // TODO e2e tests should have its own values file + ), + helm.WithWait(), + helm.WithTimeout("10m"), + ) + return ctx, err + }, + ). + Finish( + envfuncs.DestroyCluster(e2eClusterName), + ) + + os.Exit(testenv.Run(m)) +} + +// getEnv returns the value of the given environment variable or the specified defaultValue if none is set +func getEnv(name string, defaultValue string) string { + if value, exists := os.LookupEnv(name); exists { + return value + } + + return defaultValue +}