diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9075ea99..5b9650c7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -199,3 +199,21 @@ jobs: module: notification run_check_generated_files: true ko_build_path: "cmd/main.go" + + event: + name: Event + uses: ./.github/workflows/reusable-go-ci.yaml + with: + name: event + module: event + run_check_generated_files: true + ko_build_path: "cmd/main.go" + + pubsub: + name: PubSub + uses: ./.github/workflows/reusable-go-ci.yaml + with: + name: pubsub + module: pubsub + run_check_generated_files: true + ko_build_path: "cmd/main.go" \ No newline at end of file diff --git a/.gitignore b/.gitignore index d052594c..ec0df283 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ **/bin/ charts snapshotter-config.yaml +testdata* # editor and IDE paraphernalia .idea @@ -17,6 +18,10 @@ snapshots allure-report allure-results debug-results +tmp -# ignore Claude AI files -CLAUDE.md \ No newline at end of file +# ignore AI files +CLAUDE.md +.grepai/ +.opencode/ +docs/IMPLEMENTATION_PLAN* \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 24a76bde..051b7e45 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -136,6 +136,24 @@ builds: - windows # only for rover-ctl goarch: - amd64 + - id: event + dir: event + main: cmd/main.go + binary: event + goos: + - linux + goarch: + - amd64 + - arm64 + - id: pubsub + dir: pubsub + main: cmd/main.go + binary: pubsub + goos: + - linux + goarch: + - amd64 + - arm64 archives: - id: rover-ctl ids: [rover-ctl] diff --git a/api/internal/handler/apisubscription/util.go b/api/internal/handler/apisubscription/util.go index bf7c8c18..a60d18f7 100644 --- a/api/internal/handler/apisubscription/util.go +++ b/api/internal/handler/apisubscription/util.go @@ -96,8 +96,8 @@ func ApiVisibilityMustBeValid(ctx context.Context, apiExposure *apiapi.ApiExposu // only same zone if exposureVisibility == apiapi.VisibilityZone { - if apiExposure.Spec.Zone.GetName() != subZone.GetName() { - log.Info(fmt.Sprintf("Exposure visibility is ZONE and it doesnt match the subscription zone '%s'", subZone.GetName())) + if apiExposure.Spec.Zone.Equals(subZone) { + log.Info(fmt.Sprintf("Exposure visibility is ZONE and it does not match the subscription zone '%s'", subZone.GetName())) return false, nil } } diff --git a/common-server/internal/config/config.go b/common-server/internal/config/config.go index 4bcb9d3e..11393ec4 100644 --- a/common-server/internal/config/config.go +++ b/common-server/internal/config/config.go @@ -160,7 +160,7 @@ func (c *ServerConfig) BuildServer(ctx context.Context, dynamicClient dynamic.In } appCfg := server.NewAppConfig() - appCfg.CtxLog = &log + appCfg.CtxLog = log s := server.NewServerWithApp(server.NewAppWithConfig(appCfg)) openapiBuilder := openapi.NewDocumentBuilder() openapiBuilder.NewInfo(c.Openapi.Title, c.Openapi.Description, c.Openapi.Version) diff --git a/common-server/pkg/client/errors.go b/common-server/pkg/client/errors.go index f8d55241..766585e2 100644 --- a/common-server/pkg/client/errors.go +++ b/common-server/pkg/client/errors.go @@ -80,7 +80,7 @@ func HandleError(httpStatus int, msg string, okStatusCodes ...int) error { } var httpErr *HttpError switch httpStatus { - case 400, 403: + case 400, 403, 405: return BlockedErrorf("bad request error (%d): %s", httpStatus, msg) case 409, 500, 502, 504: httpErr = RetryableErrorf("server error (%d): %s", httpStatus, msg) diff --git a/common-server/pkg/server/middleware/logging.go b/common-server/pkg/server/middleware/logging.go index 288d5d56..896f49c9 100644 --- a/common-server/pkg/server/middleware/logging.go +++ b/common-server/pkg/server/middleware/logging.go @@ -34,7 +34,7 @@ func WithOutput(w io.Writer) LoggerOption { } } -const jsonFormat = `{"time":"${time}","ip":"${ip}","host":"${host}","method":"${method}","path":"${path}","status":${status},"latency":"${latency}","queryParams":"${queryParams}", "cid": "${cid}"}` + "\n" +const jsonFormat = `{"time":"${time}","ip":"${ip}","host":"${host}","method":"${method}","path":"${path}","status":${status},"latency":"${latency}","ua":"${ua}","queryParams":"${queryParams}","cid":"${cid}"}` + "\n" var formats = map[LogFormat]string{ LogFormatJSON: jsonFormat, @@ -72,7 +72,7 @@ func NewLogger(opts ...LoggerOption) fiber.Handler { }) } -func NewContextLogger(log *logr.Logger) fiber.Handler { +func NewContextLogger(log logr.Logger) fiber.Handler { return func(c *fiber.Ctx) error { ctx := c.UserContext() cid := uuid.NewString() diff --git a/common-server/pkg/server/middleware/suite_test.go b/common-server/pkg/server/middleware/suite_test.go index 314f7876..b4ec1e6c 100644 --- a/common-server/pkg/server/middleware/suite_test.go +++ b/common-server/pkg/server/middleware/suite_test.go @@ -28,7 +28,7 @@ var _ = Describe("Middleware", func() { var buffer = bytes.NewBuffer(nil) app := fiber.New() - app.Use(middleware.NewContextLogger(&GinkgoLogr)) + app.Use(middleware.NewContextLogger(GinkgoLogr)) app.Use(middleware.NewLogger(middleware.WithOutput(buffer))) req := httptest.NewRequest("GET", "/", nil) res, err := app.Test(req) diff --git a/common-server/pkg/server/server.go b/common-server/pkg/server/server.go index 2013210f..785d96a4 100644 --- a/common-server/pkg/server/server.go +++ b/common-server/pkg/server/server.go @@ -67,7 +67,7 @@ func (s *Server) RegisterController(controller Controller, opts ControllerOpts) type AppConfig struct { fiber.Config - CtxLog *logr.Logger + CtxLog logr.Logger EnableLogging bool EnableMetrics bool EnableCors bool @@ -100,7 +100,8 @@ func NewAppWithConfig(cfg AppConfig) *fiber.App { EnableStackTrace: true, })) if cfg.EnableLogging { - if cfg.CtxLog != nil { + if cfg.CtxLog.Enabled() { + // TODO: in the future use the otel integration here app.Use(middleware.NewContextLogger(cfg.CtxLog)) } app.Use(middleware.NewLogger()) diff --git a/common-server/pkg/server/server_test.go b/common-server/pkg/server/server_test.go index 4e43b7cf..414ec68c 100644 --- a/common-server/pkg/server/server_test.go +++ b/common-server/pkg/server/server_test.go @@ -92,7 +92,7 @@ var _ = Describe("Server", func() { It("should create a new app with custom config", func() { appCfg := server.NewAppConfig() - appCfg.CtxLog = &GinkgoLogr + appCfg.CtxLog = GinkgoLogr app := server.NewAppWithConfig(appCfg) Expect(app).NotTo(BeNil()) }) diff --git a/common-server/pkg/store/inmemory/inmemory_store.go b/common-server/pkg/store/inmemory/inmemory_store.go index bd72e9d4..632853da 100644 --- a/common-server/pkg/store/inmemory/inmemory_store.go +++ b/common-server/pkg/store/inmemory/inmemory_store.go @@ -321,20 +321,22 @@ func (s *InmemoryObjectStore[T]) CreateOrReplace(ctx context.Context, in T) erro return err } + // Initial creation if object does not exist if problems.IsNotFound(err) { obj.GetObjectKind().SetGroupVersionKind(s.gvk) - s.log.Info("creating object", "namespace", obj.GetNamespace(), "name", obj.GetName(), "gvk", s.gvk) + s.log.Info("creating object", "namespace", obj.GetNamespace(), "name", obj.GetName(), "gvk", obj.GetObjectKind().GroupVersionKind()) - // check if not found obj, err = s.k8sClient.Namespace(obj.GetNamespace()).Create(ctx, obj, metav1.CreateOptions{ FieldValidation: "Strict", }) if err != nil { - return errors.Wrap(mapErrorToProblem(err), "failed to create object") + return errors.Wrapf(mapErrorToProblem(err), "failed to create object %v/%v", in.GetNamespace(), in.GetName()) } return s.OnCreate(ctx, obj) } + // Object exists, update it + obj.SetResourceVersion(currentObj.GetResourceVersion()) obj, err = s.k8sClient.Namespace(obj.GetNamespace()).Update(ctx, obj, metav1.UpdateOptions{ FieldValidation: "Strict", diff --git a/common-server/pkg/store/noop/noop.go b/common-server/pkg/store/noop/noop.go new file mode 100644 index 00000000..72a740bc --- /dev/null +++ b/common-server/pkg/store/noop/noop.go @@ -0,0 +1,63 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package noop + +import ( + "context" + "fmt" + + "github.com/telekom/controlplane/common-server/pkg/problems" + "github.com/telekom/controlplane/common-server/pkg/store" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// noopStore is a no-operation implementation of ObjectStore that returns +// empty results for read operations and errors for write operations. +// It is intended to be used when a feature is disabled, allowing callers +// to interact with the store without special-casing feature-flag checks. +type noopStore[T store.Object] struct { + gvr schema.GroupVersionResource + gvk schema.GroupVersionKind +} + +// NewStore creates a new ObjectStore that performs no operations. +// Read operations return not-found or empty results, and write operations +// return an error indicating the feature is disabled. +func NewStore[T store.Object](gvr schema.GroupVersionResource, gvk schema.GroupVersionKind) store.ObjectStore[T] { + return &noopStore[T]{ + gvr: gvr, + gvk: gvk, + } +} + +func (s *noopStore[T]) Info() (schema.GroupVersionResource, schema.GroupVersionKind) { + return s.gvr, s.gvk +} + +func (s *noopStore[T]) Ready() bool { + return true +} + +func (s *noopStore[T]) Get(_ context.Context, _, name string) (T, error) { + var zero T + return zero, problems.NotFound(name) +} + +func (s *noopStore[T]) List(_ context.Context, _ store.ListOpts) (*store.ListResponse[T], error) { + return &store.ListResponse[T]{Items: []T{}}, nil +} + +func (s *noopStore[T]) Delete(_ context.Context, _, name string) error { + return problems.NotFound(name) +} + +func (s *noopStore[T]) CreateOrReplace(_ context.Context, _ T) error { + return problems.BadRequest(fmt.Sprintf("feature for %s is disabled", s.gvk.Kind)) +} + +func (s *noopStore[T]) Patch(_ context.Context, _, _ string, _ ...store.Patch) (T, error) { + var zero T + return zero, problems.BadRequest(fmt.Sprintf("feature for %s is disabled", s.gvk.Kind)) +} diff --git a/common-server/pkg/store/noop/noop_test.go b/common-server/pkg/store/noop/noop_test.go new file mode 100644 index 00000000..ed346506 --- /dev/null +++ b/common-server/pkg/store/noop/noop_test.go @@ -0,0 +1,101 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package noop_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/telekom/controlplane/common-server/pkg/problems" + "github.com/telekom/controlplane/common-server/pkg/store" + "github.com/telekom/controlplane/common-server/pkg/store/noop" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var _ = Describe("NoopStore", func() { + var ( + s store.ObjectStore[*unstructured.Unstructured] + ctx context.Context + gvr schema.GroupVersionResource + gvk schema.GroupVersionKind + ) + + BeforeEach(func() { + ctx = context.Background() + gvr = schema.GroupVersionResource{ + Group: "test.io", + Version: "v1", + Resource: "things", + } + gvk = schema.GroupVersionKind{ + Group: "test.io", + Version: "v1", + Kind: "Thing", + } + s = noop.NewStore[*unstructured.Unstructured](gvr, gvk) + }) + + Context("Info", func() { + It("should return the configured GVR and GVK", func() { + gotGVR, gotGVK := s.Info() + Expect(gotGVR).To(Equal(gvr)) + Expect(gotGVK).To(Equal(gvk)) + }) + }) + + Context("Ready", func() { + It("should always return true", func() { + Expect(s.Ready()).To(BeTrue()) + }) + }) + + Context("Get", func() { + It("should return a NotFound error", func() { + _, err := s.Get(ctx, "default", "my-thing") + Expect(err).To(HaveOccurred()) + Expect(problems.IsNotFound(err)).To(BeTrue()) + }) + }) + + Context("List", func() { + It("should return an empty list", func() { + resp, err := s.List(ctx, store.NewListOpts()) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Items).To(BeEmpty()) + }) + }) + + Context("Delete", func() { + It("should return a NotFound error", func() { + err := s.Delete(ctx, "default", "my-thing") + Expect(err).To(HaveOccurred()) + Expect(problems.IsNotFound(err)).To(BeTrue()) + }) + }) + + Context("CreateOrReplace", func() { + It("should return a BadRequest error indicating the feature is disabled", func() { + obj := &unstructured.Unstructured{} + err := s.CreateOrReplace(ctx, obj) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("disabled")) + }) + }) + + Context("Patch", func() { + It("should return a BadRequest error indicating the feature is disabled", func() { + _, err := s.Patch(ctx, "default", "my-thing", store.Patch{ + Path: "/spec/foo", + Op: store.OpReplace, + Value: "bar", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("disabled")) + }) + }) +}) diff --git a/common/pkg/client/clientutil.go b/common/pkg/client/clientutil.go index aa951ae6..713b3e10 100644 --- a/common/pkg/client/clientutil.go +++ b/common/pkg/client/clientutil.go @@ -11,6 +11,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) +// OwnedBy filters for resources owned by the given owner based on controller references. +// It requires that the index ".metadata.controller" is registered func OwnedBy(owner client.Object) []client.ListOption { ownerUID := string(owner.GetUID()) diff --git a/common/pkg/config/config.go b/common/pkg/config/config.go index 83a27c5f..0e52a33a 100644 --- a/common/pkg/config/config.go +++ b/common/pkg/config/config.go @@ -27,6 +27,27 @@ const ( FinalizerSuffix = "finalizer" ) +type Feature string + +func (f Feature) String() string { + return string(f) +} + +func (f Feature) IsEnabled() bool { + return Features[f] +} + +func (f Feature) Path() string { + return "feature-" + f.String() + "-enabled" +} + +var ( + Features map[Feature]bool + FeaturePubSub Feature = "pubsub" + FeatureSecretManager Feature = "secret_manager" + FeatureFileManager Feature = "file_manager" +) + // exposed configuration variables var ( // RequeueAfterOnError is the time to wait before retrying a failed operation. @@ -62,6 +83,8 @@ func registerDefaults() { viper.SetDefault(configKeyJitterFactor, JitterFactor) viper.SetDefault(configKeyMaxBackoff, MaxBackoff) viper.SetDefault(configKeyMaxConcurrentRec, MaxConcurrentReconciles) + viper.SetDefault(FeatureSecretManager.Path(), true) // Secret Manager feature enabled by default + viper.SetDefault(FeatureFileManager.Path(), true) // File Manager feature enabled by default } func registerEnvs() { @@ -81,4 +104,9 @@ func Parse() { LabelKeyPrefix = viper.GetString(configKeyLabelKeyPrefix) FinalizerName = LabelKeyPrefix + "/" + FinalizerSuffix + Features = map[Feature]bool{ + FeaturePubSub: viper.GetBool(FeaturePubSub.Path()), // FEATURE_PUBSUB_ENABLED + FeatureSecretManager: viper.GetBool(FeatureSecretManager.Path()), // FEATURE_SECRET_MANAGER_ENABLED + FeatureFileManager: viper.GetBool(FeatureFileManager.Path()), // FEATURE_FILE_MANAGER_ENABLED + } } diff --git a/common/pkg/config/labels.go b/common/pkg/config/labels.go index bfe57aea..5f6ed45f 100644 --- a/common/pkg/config/labels.go +++ b/common/pkg/config/labels.go @@ -7,6 +7,7 @@ package config var ( EnvironmentLabelKey = BuildLabelKey("environment") OwnerUidLabelKey = BuildLabelKey("owner.uid") + DomainLabelKey = BuildLabelKey("domain") ) func BuildLabelKey(key string) string { diff --git a/common/pkg/errors/ctrlerrors/errors.go b/common/pkg/errors/ctrlerrors/errors.go index c532c1cc..13d99fea 100644 --- a/common/pkg/errors/ctrlerrors/errors.go +++ b/common/pkg/errors/ctrlerrors/errors.go @@ -64,7 +64,7 @@ func HandleError(ctx context.Context, obj types.Object, err error, recorder reco } if re, ok := rootCauseErr.(RetryableError); ok { - recordError(ctx, obj, rootCauseErr, "Retryable", recorder) + recordError(ctx, obj, err, "Retryable", recorder) if re.IsRetryable() { return false, reconcile.Result{RequeueAfter: config.RetryWithJitterOnError()} } else { @@ -72,7 +72,7 @@ func HandleError(ctx context.Context, obj types.Object, err error, recorder reco } } - recordError(ctx, obj, rootCauseErr, "Unknown", recorder) + recordError(ctx, obj, err, "Unknown", recorder) return false, reconcile.Result{RequeueAfter: config.RetryWithJitterOnError()} } diff --git a/common/pkg/types/objectref.go b/common/pkg/types/objectref.go index b676fe58..f70cf245 100644 --- a/common/pkg/types/objectref.go +++ b/common/pkg/types/objectref.go @@ -50,6 +50,7 @@ func (o *ObjectRef) DeepCopy() *ObjectRef { return &ObjectRef{ Name: o.Name, Namespace: o.Namespace, + UID: o.UID, } } diff --git a/event/.gitignore b/event/.gitignore new file mode 100644 index 00000000..01b364e5 --- /dev/null +++ b/event/.gitignore @@ -0,0 +1,34 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +Dockerfile.cross + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Kubernetes Generated files - skip generated files, except for vendored files +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +.vscode +*.swp +*.swo +*~ + +# Kubeconfig might contain secrets +*.kubeconfig diff --git a/event/.golangci.yml b/event/.golangci.yml new file mode 100644 index 00000000..75739b19 --- /dev/null +++ b/event/.golangci.yml @@ -0,0 +1,56 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +version: "2" +run: + allow-parallel-runners: true +linters: + default: none + enable: + - copyloopvar + - dupl + - errcheck + - ginkgolinter + - goconst + - gocyclo + - govet + - ineffassign + - lll + - misspell + - nakedret + - prealloc + - revive + - staticcheck + - unconvert + - unparam + - unused + settings: + revive: + rules: + - name: comment-spacings + - name: import-shadowing + exclusions: + generated: lax + rules: + - linters: + - lll + path: api/* + - linters: + - dupl + - lll + path: internal/* + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/event/Makefile b/event/Makefile new file mode 100644 index 00000000..0bad7121 --- /dev/null +++ b/event/Makefile @@ -0,0 +1,254 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Image URL to use all building/pushing image targets +IMG ?= controller:latest + +# 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 + +.PHONY: all +all: build + +##@ 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 command is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Development + +.PHONY: manifests +manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + $(CONTROLLER_GEN) rbac:roleName=manager-role,headerFile="../hack/boilerplate.yaml.txt" crd:headerFile="../hack/boilerplate.yaml.txt" paths="./..." output:crd:artifacts:config=config/crd/bases + +.PHONY: generate +generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + $(CONTROLLER_GEN) object:headerFile="../hack/boilerplate.go.txt" paths="./..." + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: manifests generate fmt vet setup-envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /test) -coverprofile cover.out -json 2>&1 | tee gotest.log | gotestfmt + +# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. +# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. +# CertManager is installed by default; skip with: +# - CERT_MANAGER_INSTALL_SKIP=true +KIND_CLUSTER ?= event-test-e2e + +.PHONY: setup-test-e2e +setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist + @command -v $(KIND) >/dev/null 2>&1 || { \ + echo "Kind is not installed. Please install Kind manually."; \ + exit 1; \ + } + @case "$$($(KIND) get clusters)" in \ + *"$(KIND_CLUSTER)"*) \ + echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \ + *) \ + echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ + $(KIND) create cluster --name $(KIND_CLUSTER) ;; \ + esac + +.PHONY: test-e2e +test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. + KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v + $(MAKE) cleanup-test-e2e + +.PHONY: cleanup-test-e2e +cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests + @$(KIND) delete cluster --name $(KIND_CLUSTER) + +.PHONY: lint +lint: golangci-lint ## Run golangci-lint linter + "$(GOLANGCI_LINT)" run + +.PHONY: lint-fix +lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes + "$(GOLANGCI_LINT)" run --fix + +.PHONY: lint-config +lint-config: golangci-lint ## Verify golangci-lint linter configuration + "$(GOLANGCI_LINT)" config verify + +##@ Build + +.PHONY: build +build: manifests generate fmt vet ## Build manager binary. + go build -o bin/manager cmd/main.go + +.PHONY: run +run: manifests generate fmt vet ## Run a controller from your host. + go run ./cmd/main.go + +# If you wish to build the manager image targeting other platforms you can use the --platform flag. +# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. +# More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +.PHONY: docker-build +docker-build: ## Build docker image with the manager. + $(CONTAINER_TOOL) build -t ${IMG} . + +.PHONY: docker-push +docker-push: ## Push docker image with the manager. + $(CONTAINER_TOOL) push ${IMG} + +# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple +# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: +# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ +# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) +# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. +PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +.PHONY: docker-buildx +docker-buildx: ## Build and push docker image for the manager for cross-platform support + # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile + sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross + - $(CONTAINER_TOOL) buildx create --name event-builder + $(CONTAINER_TOOL) buildx use event-builder + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx rm event-builder + rm Dockerfile.cross + +.PHONY: build-installer +build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. + mkdir -p dist + cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} + "$(KUSTOMIZE)" build config/default > dist/install.yaml + +##@ 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. + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi + +.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. + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi + +.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: kustomize ## 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 - + +##@ Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p "$(LOCALBIN)" + +## Tool Binaries +KUBECTL ?= kubectl +KIND ?= kind +KUSTOMIZE ?= $(LOCALBIN)/kustomize +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +ENVTEST ?= $(LOCALBIN)/setup-envtest +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint + +## Tool Versions +KUSTOMIZE_VERSION ?= v5.7.1 +CONTROLLER_TOOLS_VERSION ?= v0.19.0 + +#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) +ENVTEST_VERSION ?= $(shell v='$(call gomodver,sigs.k8s.io/controller-runtime)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_VERSION manually (controller-runtime replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/release-\1.\2/') + +#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) +ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/') + +GOLANGCI_LINT_VERSION ?= v2.5.0 +.PHONY: kustomize +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. +$(KUSTOMIZE): $(LOCALBIN) + $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) + +.PHONY: controller-gen +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. +$(CONTROLLER_GEN): $(LOCALBIN) + $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) + +.PHONY: setup-envtest +setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. + @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." + @"$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path || { \ + echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ + exit 1; \ + } + +.PHONY: envtest +envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. +$(ENVTEST): $(LOCALBIN) + $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) + +.PHONY: golangci-lint +golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. +$(GOLANGCI_LINT): $(LOCALBIN) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + +# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist +# $1 - target path with name of binary +# $2 - package url which can be installed +# $3 - specific version of package +define go-install-tool +@[ -f "$(1)-$(3)" ] && [ "$$(readlink -- "$(1)" 2>/dev/null)" = "$(1)-$(3)" ] || { \ +set -e; \ +package=$(2)@$(3) ;\ +echo "Downloading $${package}" ;\ +rm -f "$(1)" ;\ +GOBIN="$(LOCALBIN)" go install $${package} ;\ +mv "$(LOCALBIN)/$$(basename "$(1)")" "$(1)-$(3)" ;\ +} ;\ +ln -sf "$$(realpath "$(1)-$(3)")" "$(1)" +endef + +define gomodver +$(shell go list -m -f '{{if .Replace}}{{.Replace.Version}}{{else}}{{.Version}}{{end}}' $(1) 2>/dev/null) +endef diff --git a/event/PROJECT b/event/PROJECT new file mode 100644 index 00000000..27873b9e --- /dev/null +++ b/event/PROJECT @@ -0,0 +1,48 @@ +# 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 +cliVersion: 4.10.1 +domain: cp.ei.telekom.de +layout: +- go.kubebuilder.io/v4 +projectName: event +repo: github.com/telekom/controlplane/event +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: event + kind: EventConfig + path: github.com/telekom/controlplane/event/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: event + kind: EventType + path: github.com/telekom/controlplane/event/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: event + kind: EventExposure + path: github.com/telekom/controlplane/event/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: event + kind: EventSubscription + path: github.com/telekom/controlplane/event/api/v1 + version: v1 +version: "3" diff --git a/event/PROJECT.license b/event/PROJECT.license new file mode 100644 index 00000000..be863cd5 --- /dev/null +++ b/event/PROJECT.license @@ -0,0 +1,3 @@ +Copyright 2026 Deutsche Telekom IT GmbH + +SPDX-License-Identifier: Apache-2.0 diff --git a/event/README.md b/event/README.md new file mode 100644 index 00000000..acbbe590 --- /dev/null +++ b/event/README.md @@ -0,0 +1,87 @@ + + + +

+

Event Domain

+

+ +

+ Event Domain is an optional Feature of the Controlplane that allows users to publish and subscribe to events. + It is a domain with focus on business-logic between the Rover (User-Config-Layer) and PubSub (Runtime-Config-Layer) Domain. +

+ +

+ About • + Usage • + References +

+ +## About + +The Event Domain is responsible for handling the business logic of event publishing and subscribing. It acts as an intermediary between the Rover Domain, which is responsible for user configuration, and the PubSub Domain, which handles runtime configuration. The Event Domain provides a structured way to manage events, ensuring that they are published and subscribed to correctly based on the configurations set by users. + +The core functions of the Event Domain include: +- **Approval Management**: Handling the approval process between event publishers and subscribers, ensuring that only authorized entities can subscribe to events. +- **Event Routing (Meshing)**: Managing the routing of events from publishers to subscribers based on the configurations set in the Rover Domain. + +> [!NOTE] +> For a detailed architecture diagram, see [docs](./docs/event-domain-architecture.md). + +## Usage + +As this is an optional feature to the Controlplane, it is not configured by default via the admin domain. +Instead, the administrator needs to explicitly enable the Event Domain and deploy its components (Event and PubSub Operators, CRDs, etc.) to the cluster. +Currently, the only supported runtime implementation is [Horizon](https://github.com/telekom/pubsub-horizon). + +The core CR to enable the Event Domain is the `EventConfig` CR, which is used to provide the necessary configuration for the Event Domain to function: + +```yaml +apiVersion: event.cp.ei.telekom.de/v1 +kind: EventConfig +metadata: + name: test-env # Name must match the environment name for which this EventConfig is created + namespace: test-env--aws # Namespace must match the namespace of the Zone CR for this environment + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + cp.ei.telekom.de/environment: test-env +spec: + # Zone for which this EventConfig is created. Must match the Zone CR for this environment. + zone: + name: aws + namespace: test-env + + # Mesh configuration for event routing between zones. If fullMesh is true, all zones will be meshed together. + mesh: + fullMesh: false + zoneNames: + - aws + - azure + - gcp + + # Admin backend (quasar configuration API). + admin: + url: https://my-horizon-instance-aws.test.dhei.telekom.de/api/v1/resources + + # Identity Realm for OAuth2 authentication with the admin backend. + realm: + name: test-env + namespace: test-env--aws + + # Internal URL of the SSE backend service (e.g. horizon-tasse). + # Used as the upstream for the SSE gateway Route created by EventExposure. + serverSendEventUrl: "https://my-horizon-instance-aws.test.dhei.telekom.de/api/v1/sse" + + # Internal URL of the publish backend service (e.g. horizon-producer). + # Used as the upstream for the publish gateway Route + publishEventUrl: "https://my-horizon-instance-aws.test.dhei.telekom.de/api/v1/events" +``` + +## References + +- PubSub Domain: [PubSub Documentation](../pubsub/README.md) +- Rover Domain: [Rover Documentation](../rover/README.md) diff --git a/event/api/go.mod b/event/api/go.mod new file mode 100644 index 00000000..c853e2e0 --- /dev/null +++ b/event/api/go.mod @@ -0,0 +1,76 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +module github.com/telekom/controlplane/event/api + +go 1.24.9 + +require ( + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/telekom/controlplane/common v0.0.0 + k8s.io/apiextensions-apiserver v0.34.2 + k8s.io/apimachinery v0.34.2 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.2 // indirect + k8s.io/client-go v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace github.com/telekom/controlplane/common => ../../common diff --git a/event/api/go.sum b/event/api/go.sum new file mode 100644 index 00000000..98e0e842 --- /dev/null +++ b/event/api/go.sum @@ -0,0 +1,230 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +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.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.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.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/event/api/go.sum.license b/event/api/go.sum.license new file mode 100644 index 00000000..be863cd5 --- /dev/null +++ b/event/api/go.sum.license @@ -0,0 +1,3 @@ +Copyright 2026 Deutsche Telekom IT GmbH + +SPDX-License-Identifier: Apache-2.0 diff --git a/event/api/v1/event_shared_types.go b/event/api/v1/event_shared_types.go new file mode 100644 index 00000000..bfd33fc6 --- /dev/null +++ b/event/api/v1/event_shared_types.go @@ -0,0 +1,100 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// DeliveryType defines how events are delivered to subscribers. +// +kubebuilder:validation:Enum=Callback;ServerSentEvent +type DeliveryType string + +const ( + DeliveryTypeCallback DeliveryType = "Callback" + DeliveryTypeServerSentEvent DeliveryType = "ServerSentEvent" +) + +func (d DeliveryType) String() string { + return string(d) +} + +// PayloadType defines the event payload format. +// +kubebuilder:validation:Enum=Data;DataRef +type PayloadType string + +const ( + PayloadTypeData PayloadType = "Data" + PayloadTypeDataRef PayloadType = "DataRef" +) + +func (p PayloadType) String() string { + return string(p) +} + +// ResponseFilterMode controls whether the response filter includes or excludes the specified fields. +// +kubebuilder:validation:Enum=Include;Exclude +type ResponseFilterMode string + +const ( + ResponseFilterModeInclude ResponseFilterMode = "Include" + ResponseFilterModeExclude ResponseFilterMode = "Exclude" +) + +func (r ResponseFilterMode) String() string { + return string(r) +} + +// ResponseFilter controls which fields are included or excluded from the event payload. +type ResponseFilter struct { + // Paths lists the JSON paths to include or exclude from the event payload. + // +optional + Paths []string `json:"paths,omitempty"` + + // Mode controls whether the listed paths are included or excluded. + // +optional + // +kubebuilder:default=Include + Mode ResponseFilterMode `json:"mode,omitempty"` +} + +// SelectionFilter defines criteria for selecting which events are delivered. +type SelectionFilter struct { + // Attributes defines simple key-value equality matches on CloudEvents attributes. + // All entries are AND-ed together. + // +optional + Attributes map[string]string `json:"attributes,omitempty"` + + // Expression contains an arbitrary JSON filter expression tree + // using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + // that is passed through to the configuration backend without structural validation. + // +optional + Expression *apiextensionsv1.JSON `json:"expression,omitempty"` +} + +// EventTrigger defines filtering criteria for event delivery. +type EventTrigger struct { + // ResponseFilter controls payload shaping (which fields to return). + // +optional + ResponseFilter *ResponseFilter `json:"responseFilter,omitempty"` + + // SelectionFilter controls event matching (which events to deliver). + // +optional + SelectionFilter *SelectionFilter `json:"selectionFilter,omitempty"` +} + +// EventScope defines a named scope with required trigger-based filtering for event exposure. +// Scopes allow publishers to partition their events and apply publisher-side filters. +// Each scope must define a trigger that specifies which events belong to it. +type EventScope struct { + // Name is the unique identifier for this scope. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Trigger defines publisher-side filtering criteria for this scope. + // Every scope must define a trigger. + // +kubebuilder:validation:Required + Trigger EventTrigger `json:"trigger"` +} diff --git a/event/api/v1/eventconfig_types.go b/event/api/v1/eventconfig_types.go new file mode 100644 index 00000000..e4dd3bae --- /dev/null +++ b/event/api/v1/eventconfig_types.go @@ -0,0 +1,205 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "slices" + + ctypes "github.com/telekom/controlplane/common/pkg/types" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AdminConfig configures the connection to the configuration backend. +type AdminConfig struct { + // Url is the base URL of the configuration backend API. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Format=uri + Url string `json:"url"` + + // Realm references the identity Realm CR used for OAuth2 authentication + // with the configuration backend. + Realm ctypes.ObjectRef `json:"realm"` +} + +// MeshConfig configures the mesh topology for event distribution. +// Either FullMesh can be enabled for a full mesh topology, or specific ZoneNames can be listed for a partial mesh. +type MeshConfig struct { + // FullMesh enables a full mesh topology where events are distributed to all zones. + // +kubebuilder:default=true + FullMesh bool `json:"fullMesh"` + // ZoneNames lists specific zones for event distribution in a partial mesh topology. + // Must be set if FullMesh is false. + // +optional + ZoneNames []string `json:"zoneNames,omitempty"` +} + +// EventConfigSpec defines the desired state of EventConfig. +type EventConfigSpec struct { + // Zone references the Zone for which this EventConfig applies. + Zone ctypes.ObjectRef `json:"zone"` + + // Admin configures the connection to the configuration backend. + Admin AdminConfig `json:"admin"` + + // ServerSendEventUrl is the internal URL of the SSE backend service + // Used as the upstream for the SSE gateway Route. + // +kubebuilder:validation:Format=uri + ServerSendEventUrl string `json:"serverSendEventUrl"` + + // PublishEventUrl is the internal URL of the publish backend service + // Used as the upstream for the publish gateway Route. + // +kubebuilder:validation:Format=uri + PublishEventUrl string `json:"publishEventUrl"` + + // VoyagerApiUrl is the internal URL of the Voyager backend service. + // Used as the upstream for the Voyager gateway Route which exposes + // event listing and redelivery APIs. + // +kubebuilder:validation:Format=uri + VoyagerApiUrl string `json:"voyagerApiUrl,omitempty"` + + // Mesh configures the mesh topology for event distribution. + Mesh MeshConfig `json:"mesh"` +} + +// EventConfigStatus defines the observed state of EventConfig. +type EventConfigStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // EventStore references the EventStore CR in the pubsub domain. + // +optional + EventStore *ctypes.ObjectRef `json:"eventStore,omitempty"` + + // AdminClient references the identity Client CR created for admin access to the configuration backend. + // +optional + AdminClient *ObservedObjectRef `json:"adminClient,omitempty"` + + // MeshClient references the identity Client CR created for mesh access between zones. + // +optional + MeshClient *ObservedObjectRef `json:"meshClient,omitempty"` + + // PublishRoute references the Route CR created for the publish gateway. + // +optional + PublishRoute *ctypes.ObjectRef `json:"publishRoute,omitempty"` + + // PublishURL is the external URL of the publish gateway, used by event producers to publish events. + // +optional + PublishURL string `json:"publishUrl,omitempty"` + + // CallbackRoute references the Route CR created for the callback gateway. + // +optional + CallbackRoute *ctypes.ObjectRef `json:"callbackRoute,omitempty"` + + // CallbackURL is the external URL of the callback gateway, used to send events to event consumers. + // +optional + CallbackURL string `json:"callbackUrl,omitempty"` + + // ProxyCallbackRoutes references the Route CRs created for the proxy callback gateway. + // +optional + ProxyCallbackRoutes []ctypes.ObjectRef `json:"proxyCallbackRoutes,omitempty"` + + // ProxyCallbackURLs maps zone names to the external URLs of the proxy callback gateway Routes for those zones. + // Used to send events to event consumers in other zones. + // +optional + ProxyCallbackURLs map[string]string `json:"proxyCallbackUrls,omitempty"` + + // VoyagerRoute references the primary Route CR created for the Voyager gateway. + // +optional + VoyagerRoute *ctypes.ObjectRef `json:"voyagerRoute,omitempty"` + + // VoyagerURL is the external gateway URL for the Voyager API, + // used for event listing and redelivery. + // +optional + VoyagerURL string `json:"voyagerUrl,omitempty"` + + // ProxyVoyagerRoutes references the proxy Route CRs created for cross-zone Voyager access. + // +optional + ProxyVoyagerRoutes []ctypes.ObjectRef `json:"proxyVoyagerRoutes,omitempty"` + + // ProxyVoyagerURLs maps zone names to the external URLs of the proxy Voyager gateway Routes for those zones. + // +optional + ProxyVoyagerURLs map[string]string `json:"proxyVoyagerUrls,omitempty"` +} + +type ObservedObjectRef struct { + ctypes.ObjectRef `json:",inline"` + + // ObservedGeneration is the generation of the referenced object that has been observed by the controller. + ObservedGeneration int64 `json:"observedGeneration"` +} + +func NewObservedObjectRef(obj ctypes.Object) *ObservedObjectRef { + if obj == nil { + return nil + } + return &ObservedObjectRef{ + ObjectRef: *ctypes.ObjectRefFromObject(obj), + ObservedGeneration: obj.GetGeneration(), + } +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Zone",type="string",JSONPath=".spec.zone.name",description="Zone" + +// EventConfig is the Schema for the eventconfigs API. +// It provides configuration for the event operator, including the configuration backend +// connection and OAuth2 authentication settings. +type EventConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventConfigSpec `json:"spec,omitempty"` + Status EventConfigStatus `json:"status,omitempty"` +} + +var _ ctypes.Object = &EventConfig{} + +func (r *EventConfig) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *EventConfig) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +func (r *EventConfig) SupportsZone(zoneName string) bool { + if r.Spec.Zone.Name == zoneName { + return true + } + if r.Spec.Mesh.FullMesh { + return true + } + return slices.Contains(r.Spec.Mesh.ZoneNames, zoneName) +} + +// +kubebuilder:object:root=true + +// EventConfigList contains a list of EventConfig +type EventConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventConfig `json:"items"` +} + +var _ ctypes.ObjectList = &EventConfigList{} + +func (r *EventConfigList) GetItems() []ctypes.Object { + items := make([]ctypes.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&EventConfig{}, &EventConfigList{}) +} diff --git a/event/api/v1/eventexposure_types.go b/event/api/v1/eventexposure_types.go new file mode 100644 index 00000000..50e839fb --- /dev/null +++ b/event/api/v1/eventexposure_types.go @@ -0,0 +1,175 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + ctypes "github.com/telekom/controlplane/common/pkg/types" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Visibility defines who can see and subscribe to an exposed event. +// +kubebuilder:validation:Enum=World;Zone;Enterprise +type Visibility string + +const ( + VisibilityWorld Visibility = "World" + VisibilityZone Visibility = "Zone" + VisibilityEnterprise Visibility = "Enterprise" +) + +func (v Visibility) String() string { + return string(v) +} + +// ApprovalStrategy defines the approval mode for subscriptions. +// +kubebuilder:validation:Enum=Auto;Simple;FourEyes +type ApprovalStrategy string + +const ( + ApprovalStrategyAuto ApprovalStrategy = "Auto" + ApprovalStrategySimple ApprovalStrategy = "Simple" + ApprovalStrategyFourEyes ApprovalStrategy = "FourEyes" +) + +// Approval configures how subscriptions to this event are approved. +type Approval struct { + // Strategy defines the approval mode. + // +kubebuilder:default=Auto + Strategy ApprovalStrategy `json:"strategy"` + + // TrustedTeams identifies teams that are trusted for approving subscriptions. + // By default your own team is trusted. + // +optional + // +kubebuilder:validation:MinItems=0 + // +kubebuilder:validation:MaxItems=10 + TrustedTeams []string `json:"trustedTeams,omitempty"` +} + +// EventExposureSpec defines the desired state of EventExposure. +type EventExposureSpec struct { + // EventType is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + // References the EventType CR via MakeEventTypeName() conversion. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + EventType string `json:"eventType"` + + // Visibility defines who can see and subscribe to this event. + // +kubebuilder:default=Enterprise + Visibility Visibility `json:"visibility"` + + // Approval configures how subscriptions to this event are approved. + Approval Approval `json:"approval"` + + // Zone references the Zone CR where this event is exposed. + Zone ctypes.ObjectRef `json:"zone"` + + // Provider identifies the providing application. + Provider ctypes.TypedObjectRef `json:"provider"` + + // Scopes defines named scopes with optional publisher-side trigger filtering. + // +optional + Scopes []EventScope `json:"scopes,omitempty"` + + // AdditionalPublisherIds allows multiple application IDs to publish to the same event type. + // Todo: rethink this approach and consider a decoupling + // +optional + AdditionalPublisherIds []string `json:"additionalPublisherIds,omitempty"` +} + +// EventExposureStatus defines the observed state of EventExposure. +type EventExposureStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // Active indicates whether this EventExposure is the active one for its event type. + Active bool `json:"active"` + + // Route references the primary gateway Route CR created for this exposure. + // +optional + Route *ctypes.ObjectRef `json:"route,omitempty"` + + // ProxyRoutes references proxy gateway Route CRs for cross-zone SSE delivery. + // +optional + ProxyRoutes []ctypes.ObjectRef `json:"proxyRoutes,omitempty"` + + SseURLs map[string]string `json:"sseUrls,omitempty"` + + // Publisher references the Publisher CR in the pubsub domain. + // +optional + Publisher *ctypes.ObjectRef `json:"publisher,omitempty"` + + // CallbackURL is the URL of callback gateway in the provider zone. + // +optional + CallbackURL string `json:"callbackURL,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="EventType",type="string",JSONPath=".spec.eventType",description="The event type identifier" +// +kubebuilder:printcolumn:name="Active",type="boolean",JSONPath=".status.active",description="Whether this exposure is active" +// +kubebuilder:printcolumn:name="CreatedAt",type="date",JSONPath=".metadata.creationTimestamp",description="Creation timestamp" + +// EventExposure is the Schema for the eventexposures API. +// It represents a declaration that an application publishes events of a specific type, +// making them available for subscription by other applications. +type EventExposure struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventExposureSpec `json:"spec,omitempty"` + Status EventExposureStatus `json:"status,omitempty"` +} + +var _ ctypes.Object = &EventExposure{} + +func (r *EventExposure) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *EventExposure) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +func (r *EventExposure) FindSseUrlForZone(zoneName string) (string, bool) { + url, found := r.Status.SseURLs[zoneName] + return url, found +} + +func (r *EventExposure) FindTriggerForScope(scopeName string) (EventTrigger, bool) { + for _, scope := range r.Spec.Scopes { + if scope.Name == scopeName { + return scope.Trigger, true + } + } + return EventTrigger{}, false +} + +// +kubebuilder:object:root=true + +// EventExposureList contains a list of EventExposure +type EventExposureList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventExposure `json:"items"` +} + +var _ ctypes.ObjectList = &EventExposureList{} + +func (r *EventExposureList) GetItems() []ctypes.Object { + items := make([]ctypes.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&EventExposure{}, &EventExposureList{}) +} diff --git a/event/api/v1/eventsubscription_types.go b/event/api/v1/eventsubscription_types.go new file mode 100644 index 00000000..c5461a4c --- /dev/null +++ b/event/api/v1/eventsubscription_types.go @@ -0,0 +1,154 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + ctypes "github.com/telekom/controlplane/common/pkg/types" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Delivery configures how events are delivered to the subscriber. +// +kubebuilder:validation:XValidation:rule="self.type == 'Callback' ? self.callback != \"\" : !has(self.callback)",message="callback is required for deliveryType 'Callback' and must not be set for 'ServerSentEvent'" +type Delivery struct { + // Type defines the delivery mechanism. + // +kubebuilder:default=Callback + Type DeliveryType `json:"type"` + + // Payload defines the event payload format. + // +kubebuilder:default=Data + Payload PayloadType `json:"payload"` + + // Callback is the URL where events are delivered. + // Required when type is "callback", must not be set for "ServerSentEvent". + // +kubebuilder:validation:Format=uri + // +optional + Callback string `json:"callback,omitempty"` + + // EventRetentionTime defines how long events are retained for this subscriber. + // +optional + EventRetentionTime string `json:"eventRetentionTime,omitempty"` + + // CircuitBreakerOptOut disables the circuit breaker for this subscription. + // +optional + CircuitBreakerOptOut bool `json:"circuitBreakerOptOut,omitempty"` + + // RetryableStatusCodes defines HTTP status codes that should trigger a retry. + // +optional + RetryableStatusCodes []int `json:"retryableStatusCodes,omitempty"` + + // RedeliveriesPerSecond limits the rate of event redeliveries. + // +optional + RedeliveriesPerSecond *int `json:"redeliveriesPerSecond,omitempty"` + + // EnforceGetHttpRequestMethodForHealthCheck forces GET for health check probes instead of HEAD. + // +optional + EnforceGetHttpRequestMethodForHealthCheck bool `json:"enforceGetHttpRequestMethodForHealthCheck,omitempty"` +} + +// EventSubscriptionSpec defines the desired state of EventSubscription. +type EventSubscriptionSpec struct { + // EventType is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + // References the EventType CR via MakeEventTypeName() conversion. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + EventType string `json:"eventType"` + + // Zone references the Zone CR where this subscription is placed. + Zone ctypes.ObjectRef `json:"zone"` + + // Requestor identifies the consuming application. + Requestor ctypes.TypedObjectRef `json:"requestor"` + + // Delivery configures how events are delivered to the subscriber. + Delivery Delivery `json:"delivery"` + + // Trigger defines subscriber-side filtering criteria for event delivery. + // +optional + Trigger *EventTrigger `json:"trigger,omitempty"` + + // Scopes selects which publisher-defined scopes to subscribe to. + // Must match scope names defined on the corresponding EventExposure. + // +optional + Scopes []string `json:"scopes,omitempty"` + + // TODO: Add Security field — currently derived from Zone/Gateway config in the handler +} + +// EventSubscriptionStatus defines the observed state of EventSubscription. +type EventSubscriptionStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // Subscriber references the Subscriber CR in the pubsub domain. + // +optional + Subscriber *ctypes.ObjectRef `json:"subscriber,omitempty"` + + // Approval references the Approval CR managing subscription approval. + // +optional + Approval *ctypes.ObjectRef `json:"approval,omitempty"` + + // ApprovalRequest references the ApprovalRequest CR for this subscription. + // +optional + ApprovalRequest *ctypes.ObjectRef `json:"approvalRequest,omitempty"` + + // URL is the SSE endpoint URL for this subscription, set when Delivery.Type is ServerSentEvent. + // +kubebuilder:validation:Format=uri + // +optional + URL string `json:"url,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="EventType",type="string",JSONPath=".spec.eventType",description="The event type identifier" +// +kubebuilder:printcolumn:name="CreatedAt",type="date",JSONPath=".metadata.creationTimestamp",description="Creation timestamp" + +// EventSubscription is the Schema for the eventsubscriptions API. +// It represents a declaration that an application subscribes to events of a specific type, +// configuring delivery, filtering, and scope selection. +type EventSubscription struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventSubscriptionSpec `json:"spec,omitempty"` + Status EventSubscriptionStatus `json:"status,omitempty"` +} + +var _ ctypes.Object = &EventSubscription{} + +func (r *EventSubscription) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *EventSubscription) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +// EventSubscriptionList contains a list of EventSubscription +type EventSubscriptionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventSubscription `json:"items"` +} + +var _ ctypes.ObjectList = &EventSubscriptionList{} + +func (r *EventSubscriptionList) GetItems() []ctypes.Object { + items := make([]ctypes.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&EventSubscription{}, &EventSubscriptionList{}) +} diff --git a/event/api/v1/eventtype_types.go b/event/api/v1/eventtype_types.go new file mode 100644 index 00000000..cdb7df55 --- /dev/null +++ b/event/api/v1/eventtype_types.go @@ -0,0 +1,116 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "strings" + + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/types" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var EventTypeLabelKey = config.BuildLabelKey("eventtype") + +// MakeEventTypeName generates a Kubernetes resource name from a dot-separated event type identifier. +// It replaces dots with hyphens and lowercases the result (e.g. "de.telekom.eni.quickstart.v1" -> "de-telekom-eni-quickstart-v1"). +func MakeEventTypeName(eventType string) string { + return strings.ToLower(strings.ReplaceAll(eventType, ".", "-")) +} + +// EventTypeSpec defines the desired state of EventType. +// +kubebuilder:validation:XValidation:rule="self.type.endsWith('.v' + self.version.split('.')[0])",message="major version in \"version\" must match the version suffix (e.g. \"vN\") in \"type\"" +type EventTypeSpec struct { + // Type is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + // The last segment must be a version prefix matching the major version. + // Used to generate the resource name via dots-to-hyphens conversion. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]+(\.[a-z0-9]+)*$` + Type string `json:"type"` + + // Version of the event type specification (e.g. "1.0.0"). + // The major version must match the version suffix in Type. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^\d+.*$` + Version string `json:"version"` + + // Description provides a human-readable summary of this event type. + // +optional + Description string `json:"description,omitempty"` + + // Specification contains the file ID reference from the file manager for + // the optional JSON schema that describes the event payload. + // +optional + Specification string `json:"specification,omitempty"` + + // TODO: Add Category field (typed enum) - see backlog and ApiCategory implementation +} + +// EventTypeStatus defines the observed state of EventType. +type EventTypeStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // Active indicates whether this EventType is the active singleton for its type string. + // When multiple EventTypes exist for the same type, only the oldest non-deleted one is active. + Active bool `json:"active"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type",description="The event type identifier" +// +kubebuilder:printcolumn:name="Active",type="boolean",JSONPath=".status.active",description="Indicates if this EventType is the active singleton" +// +kubebuilder:printcolumn:name="CreatedAt",type="date",JSONPath=".metadata.creationTimestamp",description="Creation timestamp" + +// EventType is the Schema for the eventtypes API. +// It represents a singleton registry entry for a known event type, serving as the +// canonical reference that both EventExposure and EventSubscription point to. +type EventType struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventTypeSpec `json:"spec,omitempty"` + Status EventTypeStatus `json:"status,omitempty"` +} + +var _ types.Object = &EventType{} + +func (r *EventType) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *EventType) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +// EventTypeList contains a list of EventType +type EventTypeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventType `json:"items"` +} + +var _ types.ObjectList = &EventTypeList{} + +func (r *EventTypeList) GetItems() []types.Object { + items := make([]types.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&EventType{}, &EventTypeList{}) +} diff --git a/event/api/v1/groupversion_info.go b/event/api/v1/groupversion_info.go new file mode 100644 index 00000000..fa8ddf23 --- /dev/null +++ b/event/api/v1/groupversion_info.go @@ -0,0 +1,24 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +// Package v1 contains API Schema definitions for the event v1 API group. +// +kubebuilder:object:generate=true +// +groupName=event.cp.ei.telekom.de +package v1 + +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: "event.cp.ei.telekom.de", Version: "v1"} + + // 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/event/api/v1/suite_test.go b/event/api/v1/suite_test.go new file mode 100644 index 00000000..c33fd575 --- /dev/null +++ b/event/api/v1/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEventApiV1(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Event API V1 Suite") +} diff --git a/event/api/v1/types_test.go b/event/api/v1/types_test.go new file mode 100644 index 00000000..ff69c891 --- /dev/null +++ b/event/api/v1/types_test.go @@ -0,0 +1,103 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + ctypes "github.com/telekom/controlplane/common/pkg/types" + v1 "github.com/telekom/controlplane/event/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("MakeEventTypeName", func() { + DescribeTable("converts event type strings to Kubernetes resource names", + func(input, expected string) { + Expect(v1.MakeEventTypeName(input)).To(Equal(expected)) + }, + Entry("normal event type with dots", + "de.telekom.eni.quickstart.v1", + "de-telekom-eni-quickstart-v1", + ), + Entry("already lowercase with no dots", + "simple", + "simple", + ), + Entry("empty string", + "", + "", + ), + Entry("mixed case with dots", + "De.Telekom.V1", + "de-telekom-v1", + ), + ) +}) + +var _ = Describe("EventConfig", func() { + Describe("SupportsZone", func() { + var config *v1.EventConfig + + BeforeEach(func() { + config = &v1.EventConfig{ + Spec: v1.EventConfigSpec{ + Zone: ctypes.ObjectRef{ + Name: "zone-a", + Namespace: "default", + }, + Mesh: v1.MeshConfig{ + FullMesh: false, + ZoneNames: []string{"zone-b", "zone-c"}, + }, + }, + } + }) + + It("returns true for an exact zone match", func() { + Expect(config.SupportsZone("zone-a")).To(BeTrue()) + }) + + It("returns true when FullMesh is enabled even for a different zone", func() { + config.Spec.Mesh.FullMesh = true + Expect(config.SupportsZone("zone-x")).To(BeTrue()) + }) + + It("returns true when the zone is in the ZoneNames list", func() { + Expect(config.SupportsZone("zone-b")).To(BeTrue()) + }) + + It("returns false when the zone is not in the list and FullMesh is false", func() { + Expect(config.SupportsZone("zone-x")).To(BeFalse()) + }) + + It("returns false with empty ZoneNames, FullMesh false, and a different zone", func() { + config.Spec.Mesh.ZoneNames = nil + Expect(config.SupportsZone("zone-x")).To(BeFalse()) + }) + }) +}) + +var _ = Describe("NewObservedObjectRef", func() { + It("returns nil for nil input", func() { + // Pass an explicitly nil ctypes.Object interface value + Expect(v1.NewObservedObjectRef(nil)).To(BeNil()) + }) + + It("returns a correct ObservedObjectRef for a valid object", func() { + obj := &v1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Generation: 3, + }, + } + ref := v1.NewObservedObjectRef(obj) + Expect(ref).ToNot(BeNil()) + Expect(ref.Name).To(Equal("test")) + Expect(ref.Namespace).To(Equal("default")) + Expect(ref.ObservedGeneration).To(Equal(int64(3))) + }) +}) diff --git a/event/api/v1/zz_generated.deepcopy.go b/event/api/v1/zz_generated.deepcopy.go new file mode 100644 index 00000000..81120b08 --- /dev/null +++ b/event/api/v1/zz_generated.deepcopy.go @@ -0,0 +1,704 @@ +//go:build !ignore_autogenerated + +// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import ( + "github.com/telekom/controlplane/common/pkg/types" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AdminConfig) DeepCopyInto(out *AdminConfig) { + *out = *in + in.Realm.DeepCopyInto(&out.Realm) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdminConfig. +func (in *AdminConfig) DeepCopy() *AdminConfig { + if in == nil { + return nil + } + out := new(AdminConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Approval) DeepCopyInto(out *Approval) { + *out = *in + if in.TrustedTeams != nil { + in, out := &in.TrustedTeams, &out.TrustedTeams + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Approval. +func (in *Approval) DeepCopy() *Approval { + if in == nil { + return nil + } + out := new(Approval) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Delivery) DeepCopyInto(out *Delivery) { + *out = *in + if in.RetryableStatusCodes != nil { + in, out := &in.RetryableStatusCodes, &out.RetryableStatusCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } + if in.RedeliveriesPerSecond != nil { + in, out := &in.RedeliveriesPerSecond, &out.RedeliveriesPerSecond + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Delivery. +func (in *Delivery) DeepCopy() *Delivery { + if in == nil { + return nil + } + out := new(Delivery) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventConfig) DeepCopyInto(out *EventConfig) { + *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 EventConfig. +func (in *EventConfig) DeepCopy() *EventConfig { + if in == nil { + return nil + } + out := new(EventConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventConfig) 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 *EventConfigList) DeepCopyInto(out *EventConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventConfigList. +func (in *EventConfigList) DeepCopy() *EventConfigList { + if in == nil { + return nil + } + out := new(EventConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventConfigList) 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 *EventConfigSpec) DeepCopyInto(out *EventConfigSpec) { + *out = *in + in.Zone.DeepCopyInto(&out.Zone) + out.Admin = in.Admin + in.Mesh.DeepCopyInto(&out.Mesh) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventConfigSpec. +func (in *EventConfigSpec) DeepCopy() *EventConfigSpec { + if in == nil { + return nil + } + out := new(EventConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventConfigStatus) DeepCopyInto(out *EventConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.EventStore != nil { + in, out := &in.EventStore, &out.EventStore + *out = (*in).DeepCopy() + } + if in.AdminClient != nil { + in, out := &in.AdminClient, &out.AdminClient + *out = new(ObservedObjectRef) + **out = **in + } + if in.MeshClient != nil { + in, out := &in.MeshClient, &out.MeshClient + *out = new(ObservedObjectRef) + **out = **in + } + if in.PublishRoute != nil { + in, out := &in.PublishRoute, &out.PublishRoute + *out = (*in).DeepCopy() + } + if in.CallbackRoute != nil { + in, out := &in.CallbackRoute, &out.CallbackRoute + *out = (*in).DeepCopy() + } + if in.ProxyCallbackRoutes != nil { + in, out := &in.ProxyCallbackRoutes, &out.ProxyCallbackRoutes + *out = make([]types.ObjectRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ProxyCallbackURLs != nil { + in, out := &in.ProxyCallbackURLs, &out.ProxyCallbackURLs + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.VoyagerRoute != nil { + in, out := &in.VoyagerRoute, &out.VoyagerRoute + *out = (*in).DeepCopy() + } + if in.ProxyVoyagerRoutes != nil { + in, out := &in.ProxyVoyagerRoutes, &out.ProxyVoyagerRoutes + *out = make([]types.ObjectRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ProxyVoyagerURLs != nil { + in, out := &in.ProxyVoyagerURLs, &out.ProxyVoyagerURLs + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventConfigStatus. +func (in *EventConfigStatus) DeepCopy() *EventConfigStatus { + if in == nil { + return nil + } + out := new(EventConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventExposure) DeepCopyInto(out *EventExposure) { + *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 EventExposure. +func (in *EventExposure) DeepCopy() *EventExposure { + if in == nil { + return nil + } + out := new(EventExposure) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventExposure) 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 *EventExposureList) DeepCopyInto(out *EventExposureList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventExposure, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventExposureList. +func (in *EventExposureList) DeepCopy() *EventExposureList { + if in == nil { + return nil + } + out := new(EventExposureList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventExposureList) 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 *EventExposureSpec) DeepCopyInto(out *EventExposureSpec) { + *out = *in + in.Approval.DeepCopyInto(&out.Approval) + in.Zone.DeepCopyInto(&out.Zone) + in.Provider.DeepCopyInto(&out.Provider) + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]EventScope, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AdditionalPublisherIds != nil { + in, out := &in.AdditionalPublisherIds, &out.AdditionalPublisherIds + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventExposureSpec. +func (in *EventExposureSpec) DeepCopy() *EventExposureSpec { + if in == nil { + return nil + } + out := new(EventExposureSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventExposureStatus) DeepCopyInto(out *EventExposureStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Route != nil { + in, out := &in.Route, &out.Route + *out = (*in).DeepCopy() + } + if in.ProxyRoutes != nil { + in, out := &in.ProxyRoutes, &out.ProxyRoutes + *out = make([]types.ObjectRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SseURLs != nil { + in, out := &in.SseURLs, &out.SseURLs + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Publisher != nil { + in, out := &in.Publisher, &out.Publisher + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventExposureStatus. +func (in *EventExposureStatus) DeepCopy() *EventExposureStatus { + if in == nil { + return nil + } + out := new(EventExposureStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventScope) DeepCopyInto(out *EventScope) { + *out = *in + in.Trigger.DeepCopyInto(&out.Trigger) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventScope. +func (in *EventScope) DeepCopy() *EventScope { + if in == nil { + return nil + } + out := new(EventScope) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventSubscription) DeepCopyInto(out *EventSubscription) { + *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 EventSubscription. +func (in *EventSubscription) DeepCopy() *EventSubscription { + if in == nil { + return nil + } + out := new(EventSubscription) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventSubscription) 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 *EventSubscriptionList) DeepCopyInto(out *EventSubscriptionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventSubscription, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSubscriptionList. +func (in *EventSubscriptionList) DeepCopy() *EventSubscriptionList { + if in == nil { + return nil + } + out := new(EventSubscriptionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventSubscriptionList) 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 *EventSubscriptionSpec) DeepCopyInto(out *EventSubscriptionSpec) { + *out = *in + in.Zone.DeepCopyInto(&out.Zone) + in.Requestor.DeepCopyInto(&out.Requestor) + in.Delivery.DeepCopyInto(&out.Delivery) + if in.Trigger != nil { + in, out := &in.Trigger, &out.Trigger + *out = new(EventTrigger) + (*in).DeepCopyInto(*out) + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSubscriptionSpec. +func (in *EventSubscriptionSpec) DeepCopy() *EventSubscriptionSpec { + if in == nil { + return nil + } + out := new(EventSubscriptionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventSubscriptionStatus) DeepCopyInto(out *EventSubscriptionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Subscriber != nil { + in, out := &in.Subscriber, &out.Subscriber + *out = (*in).DeepCopy() + } + if in.Approval != nil { + in, out := &in.Approval, &out.Approval + *out = (*in).DeepCopy() + } + if in.ApprovalRequest != nil { + in, out := &in.ApprovalRequest, &out.ApprovalRequest + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSubscriptionStatus. +func (in *EventSubscriptionStatus) DeepCopy() *EventSubscriptionStatus { + if in == nil { + return nil + } + out := new(EventSubscriptionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventTrigger) DeepCopyInto(out *EventTrigger) { + *out = *in + if in.ResponseFilter != nil { + in, out := &in.ResponseFilter, &out.ResponseFilter + *out = new(ResponseFilter) + (*in).DeepCopyInto(*out) + } + if in.SelectionFilter != nil { + in, out := &in.SelectionFilter, &out.SelectionFilter + *out = new(SelectionFilter) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventTrigger. +func (in *EventTrigger) DeepCopy() *EventTrigger { + if in == nil { + return nil + } + out := new(EventTrigger) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventType) DeepCopyInto(out *EventType) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventType. +func (in *EventType) DeepCopy() *EventType { + if in == nil { + return nil + } + out := new(EventType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventType) 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 *EventTypeList) DeepCopyInto(out *EventTypeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventType, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventTypeList. +func (in *EventTypeList) DeepCopy() *EventTypeList { + if in == nil { + return nil + } + out := new(EventTypeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventTypeList) 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 *EventTypeSpec) DeepCopyInto(out *EventTypeSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventTypeSpec. +func (in *EventTypeSpec) DeepCopy() *EventTypeSpec { + if in == nil { + return nil + } + out := new(EventTypeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventTypeStatus) DeepCopyInto(out *EventTypeStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventTypeStatus. +func (in *EventTypeStatus) DeepCopy() *EventTypeStatus { + if in == nil { + return nil + } + out := new(EventTypeStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MeshConfig) DeepCopyInto(out *MeshConfig) { + *out = *in + if in.ZoneNames != nil { + in, out := &in.ZoneNames, &out.ZoneNames + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MeshConfig. +func (in *MeshConfig) DeepCopy() *MeshConfig { + if in == nil { + return nil + } + out := new(MeshConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservedObjectRef) DeepCopyInto(out *ObservedObjectRef) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectRef. +func (in *ObservedObjectRef) DeepCopy() *ObservedObjectRef { + if in == nil { + return nil + } + out := new(ObservedObjectRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseFilter) DeepCopyInto(out *ResponseFilter) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseFilter. +func (in *ResponseFilter) DeepCopy() *ResponseFilter { + if in == nil { + return nil + } + out := new(ResponseFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SelectionFilter) DeepCopyInto(out *SelectionFilter) { + *out = *in + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Expression != nil { + in, out := &in.Expression, &out.Expression + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SelectionFilter. +func (in *SelectionFilter) DeepCopy() *SelectionFilter { + if in == nil { + return nil + } + out := new(SelectionFilter) + in.DeepCopyInto(out) + return out +} diff --git a/event/cmd/main.go b/event/cmd/main.go new file mode 100644 index 00000000..ebc624e7 --- /dev/null +++ b/event/cmd/main.go @@ -0,0 +1,225 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/tls" + "flag" + "os" + + // 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" + + "github.com/telekom/controlplane/common/pkg/config" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/telekom/controlplane/event/internal/controller" + "github.com/telekom/controlplane/event/internal/index" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + controller.RegisterSchemesOrDie(scheme) +} + +// nolint:gocyclo +func main() { + var metricsAddr string + var metricsCertPath, metricsCertName, metricsCertKey string + var webhookCertPath, webhookCertName, webhookCertKey string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + flag.StringVar(&metricsCertPath, "metrics-cert-path", "", + "The directory that contains the metrics server certificate.") + flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, + "If set, HTTP/2 will be enabled for the metrics and webhook servers") + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + // if the enable-http2 flag is false (the default), http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + + // Initial webhook TLS options + webhookTLSOpts := tlsOpts + webhookServerOptions := webhook.Options{ + TLSOpts: webhookTLSOpts, + } + + if len(webhookCertPath) > 0 { + setupLog.Info("Initializing webhook certificate watcher using provided certificates", + "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) + + webhookServerOptions.CertDir = webhookCertPath + webhookServerOptions.CertName = webhookCertName + webhookServerOptions.KeyName = webhookCertKey + } + + webhookServer := webhook.NewServer(webhookServerOptions) + + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.4/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.4/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + + // If the certificate is not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + // + // TODO(user): If you enable certManager, uncomment the following lines: + // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates + // managed by cert-manager for the metrics server. + // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. + if len(metricsCertPath) > 0 { + setupLog.Info("Initializing metrics certificate watcher using provided certificates", + "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) + + metricsServerOptions.CertDir = metricsCertPath + metricsServerOptions.CertName = metricsCertName + metricsServerOptions.KeyName = metricsCertKey + } + + selector := labels.NewSelector() + requirement, err := labels.NewRequirement(config.DomainLabelKey, selection.Equals, []string{"event"}) + if err != nil { + setupLog.Error(err, "unable to create label requirement") + os.Exit(1) + } + selector = selector.Add(*requirement) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "5a9bf056.cp.ei.telekom.de", + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &gatewayv1.Route{}: { + Label: selector, + }, + &identityv1.Client{}: { + Label: selector, + }, + }, + }, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + rootCtx := ctrl.SetupSignalHandler() + index.RegisterIndicesOrDie(rootCtx, mgr) + + if err := (&controller.EventConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EventConfig") + os.Exit(1) + } + if err := (&controller.EventTypeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EventType") + os.Exit(1) + } + if err := (&controller.EventExposureReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EventExposure") + os.Exit(1) + } + if err := (&controller.EventSubscriptionReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EventSubscription") + 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(rootCtx); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/event/config/crd/bases/event.cp.ei.telekom.de_eventconfigs.yaml b/event/config/crd/bases/event.cp.ei.telekom.de_eventconfigs.yaml new file mode 100644 index 00000000..e5d8c414 --- /dev/null +++ b/event/config/crd/bases/event.cp.ei.telekom.de_eventconfigs.yaml @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: eventconfigs.event.cp.ei.telekom.de +spec: + group: event.cp.ei.telekom.de + names: + kind: EventConfig + listKind: EventConfigList + plural: eventconfigs + singular: eventconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Zone + jsonPath: .spec.zone.name + name: Zone + type: string + name: v1 + schema: + openAPIV3Schema: + description: |- + EventConfig is the Schema for the eventconfigs API. + It provides configuration for the event operator, including the configuration backend + connection and OAuth2 authentication settings. + 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: EventConfigSpec defines the desired state of EventConfig. + properties: + admin: + description: Admin configures the connection to the configuration + backend. + properties: + realm: + description: |- + Realm references the identity Realm CR used for OAuth2 authentication + with the configuration backend. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + url: + description: Url is the base URL of the configuration backend + API. + format: uri + minLength: 1 + type: string + required: + - realm + - url + type: object + mesh: + description: Mesh configures the mesh topology for event distribution. + properties: + fullMesh: + default: true + description: FullMesh enables a full mesh topology where events + are distributed to all zones. + type: boolean + zoneNames: + description: |- + ZoneNames lists specific zones for event distribution in a partial mesh topology. + Must be set if FullMesh is false. + items: + type: string + type: array + required: + - fullMesh + type: object + publishEventUrl: + description: |- + PublishEventUrl is the internal URL of the publish backend service + Used as the upstream for the publish gateway Route. + format: uri + type: string + serverSendEventUrl: + description: |- + ServerSendEventUrl is the internal URL of the SSE backend service + Used as the upstream for the SSE gateway Route. + format: uri + type: string + voyagerApiUrl: + description: |- + VoyagerApiUrl is the internal URL of the Voyager backend service. + Used as the upstream for the Voyager gateway Route which exposes + event listing and redelivery APIs. + format: uri + type: string + zone: + description: Zone references the Zone for which this EventConfig applies. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + required: + - admin + - mesh + - publishEventUrl + - serverSendEventUrl + - zone + type: object + status: + description: EventConfigStatus defines the observed state of EventConfig. + properties: + adminClient: + description: AdminClient references the identity Client CR created + for admin access to the configuration backend. + properties: + name: + type: string + namespace: + type: string + observedGeneration: + description: ObservedGeneration is the generation of the referenced + object that has been observed by the controller. + format: int64 + type: integer + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + - observedGeneration + type: object + callbackRoute: + description: CallbackRoute references the Route CR created for the + callback gateway. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + callbackUrl: + description: CallbackURL is the external URL of the callback gateway, + used to send events to event consumers. + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + eventStore: + description: EventStore references the EventStore CR in the pubsub + domain. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + meshClient: + description: MeshClient references the identity Client CR created + for mesh access between zones. + properties: + name: + type: string + namespace: + type: string + observedGeneration: + description: ObservedGeneration is the generation of the referenced + object that has been observed by the controller. + format: int64 + type: integer + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + - observedGeneration + type: object + proxyCallbackRoutes: + description: ProxyCallbackRoutes references the Route CRs created + for the proxy callback gateway. + items: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: array + proxyCallbackUrls: + additionalProperties: + type: string + description: |- + ProxyCallbackURLs maps zone names to the external URLs of the proxy callback gateway Routes for those zones. + Used to send events to event consumers in other zones. + type: object + proxyVoyagerRoutes: + description: ProxyVoyagerRoutes references the proxy Route CRs created + for cross-zone Voyager access. + items: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: array + proxyVoyagerUrls: + additionalProperties: + type: string + description: ProxyVoyagerURLs maps zone names to the external URLs + of the proxy Voyager gateway Routes for those zones. + type: object + publishRoute: + description: PublishRoute references the Route CR created for the + publish gateway. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + publishUrl: + description: PublishURL is the external URL of the publish gateway, + used by event producers to publish events. + type: string + voyagerRoute: + description: VoyagerRoute references the primary Route CR created + for the Voyager gateway. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + voyagerUrl: + description: |- + VoyagerURL is the external gateway URL for the Voyager API, + used for event listing and redelivery. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/event/config/crd/bases/event.cp.ei.telekom.de_eventexposures.yaml b/event/config/crd/bases/event.cp.ei.telekom.de_eventexposures.yaml new file mode 100644 index 00000000..f549f1c8 --- /dev/null +++ b/event/config/crd/bases/event.cp.ei.telekom.de_eventexposures.yaml @@ -0,0 +1,363 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: eventexposures.event.cp.ei.telekom.de +spec: + group: event.cp.ei.telekom.de + names: + kind: EventExposure + listKind: EventExposureList + plural: eventexposures + singular: eventexposure + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The event type identifier + jsonPath: .spec.eventType + name: EventType + type: string + - description: Whether this exposure is active + jsonPath: .status.active + name: Active + type: boolean + - description: Creation timestamp + jsonPath: .metadata.creationTimestamp + name: CreatedAt + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + EventExposure is the Schema for the eventexposures API. + It represents a declaration that an application publishes events of a specific type, + making them available for subscription by other applications. + 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: EventExposureSpec defines the desired state of EventExposure. + properties: + additionalPublisherIds: + description: |- + AdditionalPublisherIds allows multiple application IDs to publish to the same event type. + Todo: rethink this approach and consider a decoupling + items: + type: string + type: array + approval: + description: Approval configures how subscriptions to this event are + approved. + properties: + strategy: + default: Auto + description: Strategy defines the approval mode. + enum: + - Auto + - Simple + - FourEyes + type: string + trustedTeams: + description: |- + TrustedTeams identifies teams that are trusted for approving subscriptions. + By default your own team is trusted. + items: + type: string + maxItems: 10 + minItems: 0 + type: array + required: + - strategy + type: object + eventType: + description: |- + EventType is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + References the EventType CR via MakeEventTypeName() conversion. + minLength: 1 + type: string + provider: + description: Provider identifies the providing application. + 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 + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + scopes: + description: Scopes defines named scopes with optional publisher-side + trigger filtering. + items: + description: |- + EventScope defines a named scope with required trigger-based filtering for event exposure. + Scopes allow publishers to partition their events and apply publisher-side filters. + Each scope must define a trigger that specifies which events belong to it. + properties: + name: + description: Name is the unique identifier for this scope. + minLength: 1 + type: string + trigger: + description: |- + Trigger defines publisher-side filtering criteria for this scope. + Every scope must define a trigger. + properties: + responseFilter: + description: ResponseFilter controls payload shaping (which + fields to return). + properties: + mode: + default: Include + description: Mode controls whether the listed paths + are included or excluded. + enum: + - Include + - Exclude + type: string + paths: + description: Paths lists the JSON paths to include or + exclude from the event payload. + items: + type: string + type: array + type: object + selectionFilter: + description: SelectionFilter controls event matching (which + events to deliver). + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes defines simple key-value equality matches on CloudEvents attributes. + All entries are AND-ed together. + type: object + expression: + description: |- + Expression contains an arbitrary JSON filter expression tree + using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + that is passed through to the configuration backend without structural validation. + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + required: + - name + - trigger + type: object + type: array + visibility: + default: Enterprise + description: Visibility defines who can see and subscribe to this + event. + enum: + - World + - Zone + - Enterprise + type: string + zone: + description: Zone references the Zone CR where this event is exposed. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + required: + - approval + - eventType + - provider + - visibility + - zone + type: object + status: + description: EventExposureStatus defines the observed state of EventExposure. + properties: + active: + description: Active indicates whether this EventExposure is the active + one for its event type. + type: boolean + callbackURL: + description: CallbackURL is the URL of callback gateway in the provider + zone. + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + proxyRoutes: + description: ProxyRoutes references proxy gateway Route CRs for cross-zone + SSE delivery. + items: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: array + publisher: + description: Publisher references the Publisher CR in the pubsub domain. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + route: + description: Route references the primary gateway Route CR created + for this exposure. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + sseUrls: + additionalProperties: + type: string + type: object + required: + - active + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/event/config/crd/bases/event.cp.ei.telekom.de_eventsubscriptions.yaml b/event/config/crd/bases/event.cp.ei.telekom.de_eventsubscriptions.yaml new file mode 100644 index 00000000..257046a0 --- /dev/null +++ b/event/config/crd/bases/event.cp.ei.telekom.de_eventsubscriptions.yaml @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: eventsubscriptions.event.cp.ei.telekom.de +spec: + group: event.cp.ei.telekom.de + names: + kind: EventSubscription + listKind: EventSubscriptionList + plural: eventsubscriptions + singular: eventsubscription + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The event type identifier + jsonPath: .spec.eventType + name: EventType + type: string + - description: Creation timestamp + jsonPath: .metadata.creationTimestamp + name: CreatedAt + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + EventSubscription is the Schema for the eventsubscriptions API. + It represents a declaration that an application subscribes to events of a specific type, + configuring delivery, filtering, and scope selection. + 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: EventSubscriptionSpec defines the desired state of EventSubscription. + properties: + delivery: + description: Delivery configures how events are delivered to the subscriber. + properties: + callback: + description: |- + Callback is the URL where events are delivered. + Required when type is "callback", must not be set for "ServerSentEvent". + format: uri + type: string + circuitBreakerOptOut: + description: CircuitBreakerOptOut disables the circuit breaker + for this subscription. + type: boolean + enforceGetHttpRequestMethodForHealthCheck: + description: EnforceGetHttpRequestMethodForHealthCheck forces + GET for health check probes instead of HEAD. + type: boolean + eventRetentionTime: + description: EventRetentionTime defines how long events are retained + for this subscriber. + type: string + payload: + default: Data + description: Payload defines the event payload format. + enum: + - Data + - DataRef + type: string + redeliveriesPerSecond: + description: RedeliveriesPerSecond limits the rate of event redeliveries. + type: integer + retryableStatusCodes: + description: RetryableStatusCodes defines HTTP status codes that + should trigger a retry. + items: + type: integer + type: array + type: + default: Callback + description: Type defines the delivery mechanism. + enum: + - Callback + - ServerSentEvent + type: string + required: + - payload + - type + type: object + x-kubernetes-validations: + - message: callback is required for deliveryType 'Callback' and must + not be set for 'ServerSentEvent' + rule: 'self.type == ''Callback'' ? self.callback != "" : !has(self.callback)' + eventType: + description: |- + EventType is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + References the EventType CR via MakeEventTypeName() conversion. + minLength: 1 + type: string + requestor: + description: Requestor identifies the consuming application. + 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 + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + scopes: + description: |- + Scopes selects which publisher-defined scopes to subscribe to. + Must match scope names defined on the corresponding EventExposure. + items: + type: string + type: array + trigger: + description: Trigger defines subscriber-side filtering criteria for + event delivery. + properties: + responseFilter: + description: ResponseFilter controls payload shaping (which fields + to return). + properties: + mode: + default: Include + description: Mode controls whether the listed paths are included + or excluded. + enum: + - Include + - Exclude + type: string + paths: + description: Paths lists the JSON paths to include or exclude + from the event payload. + items: + type: string + type: array + type: object + selectionFilter: + description: SelectionFilter controls event matching (which events + to deliver). + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes defines simple key-value equality matches on CloudEvents attributes. + All entries are AND-ed together. + type: object + expression: + description: |- + Expression contains an arbitrary JSON filter expression tree + using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + that is passed through to the configuration backend without structural validation. + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + zone: + description: Zone references the Zone CR where this subscription is + placed. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + required: + - delivery + - eventType + - requestor + - zone + type: object + status: + description: EventSubscriptionStatus defines the observed state of EventSubscription. + properties: + approval: + description: Approval references the Approval CR managing subscription + approval. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + approvalRequest: + description: ApprovalRequest references the ApprovalRequest CR for + this subscription. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + subscriber: + description: Subscriber references the Subscriber CR in the pubsub + domain. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + url: + description: URL is the SSE endpoint URL for this subscription, set + when Delivery.Type is ServerSentEvent. + format: uri + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/event/config/crd/bases/event.cp.ei.telekom.de_eventtypes.yaml b/event/config/crd/bases/event.cp.ei.telekom.de_eventtypes.yaml new file mode 100644 index 00000000..4a2a1407 --- /dev/null +++ b/event/config/crd/bases/event.cp.ei.telekom.de_eventtypes.yaml @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: eventtypes.event.cp.ei.telekom.de +spec: + group: event.cp.ei.telekom.de + names: + kind: EventType + listKind: EventTypeList + plural: eventtypes + singular: eventtype + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The event type identifier + jsonPath: .spec.type + name: Type + type: string + - description: Indicates if this EventType is the active singleton + jsonPath: .status.active + name: Active + type: boolean + - description: Creation timestamp + jsonPath: .metadata.creationTimestamp + name: CreatedAt + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + EventType is the Schema for the eventtypes API. + It represents a singleton registry entry for a known event type, serving as the + canonical reference that both EventExposure and EventSubscription point to. + 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: EventTypeSpec defines the desired state of EventType. + properties: + description: + description: Description provides a human-readable summary of this + event type. + type: string + specification: + description: |- + Specification contains the file ID reference from the file manager for + the optional JSON schema that describes the event payload. + type: string + type: + description: |- + Type is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + The last segment must be a version prefix matching the major version. + Used to generate the resource name via dots-to-hyphens conversion. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]+(\.[a-z0-9]+)*$ + type: string + version: + description: |- + Version of the event type specification (e.g. "1.0.0"). + The major version must match the version suffix in Type. + pattern: ^\d+.*$ + type: string + required: + - type + - version + type: object + x-kubernetes-validations: + - message: major version in "version" must match the version suffix (e.g. + "vN") in "type" + rule: self.type.endsWith('.v' + self.version.split('.')[0]) + status: + description: EventTypeStatus defines the observed state of EventType. + properties: + active: + description: |- + Active indicates whether this EventType is the active singleton for its type string. + When multiple EventTypes exist for the same type, only the oldest non-deleted one is active. + type: boolean + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + required: + - active + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/event/config/crd/kustomization.yaml b/event/config/crd/kustomization.yaml new file mode 100644 index 00000000..b143a8ff --- /dev/null +++ b/event/config/crd/kustomization.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# 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/event.cp.ei.telekom.de_eventconfigs.yaml +- bases/event.cp.ei.telekom.de_eventtypes.yaml +- bases/event.cp.ei.telekom.de_eventexposures.yaml +- bases/event.cp.ei.telekom.de_eventsubscriptions.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 +# +kubebuilder:scaffold:crdkustomizewebhookpatch + +# [WEBHOOK] To enable webhook, uncomment the following section +# the following config is for teaching kustomize how to do kustomization for CRDs. +#configurations: +#- kustomizeconfig.yaml diff --git a/event/config/crd/kustomizeconfig.yaml b/event/config/crd/kustomizeconfig.yaml new file mode 100644 index 00000000..756c9f39 --- /dev/null +++ b/event/config/crd/kustomizeconfig.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# 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/event/config/default/cert_metrics_manager_patch.yaml b/event/config/default/cert_metrics_manager_patch.yaml new file mode 100644 index 00000000..c904c2a3 --- /dev/null +++ b/event/config/default/cert_metrics_manager_patch.yaml @@ -0,0 +1,34 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs. + +# Add the volumeMount for the metrics-server certs +- op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + mountPath: /tmp/k8s-metrics-server/metrics-certs + name: metrics-certs + readOnly: true + +# Add the --metrics-cert-path argument for the metrics server +- op: add + path: /spec/template/spec/containers/0/args/- + value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs + +# Add the metrics-server certs volume configuration +- op: add + path: /spec/template/spec/volumes/- + value: + name: metrics-certs + secret: + secretName: metrics-server-cert + optional: false + items: + - key: ca.crt + path: ca.crt + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key diff --git a/event/config/default/kustomization.yaml b/event/config/default/kustomization.yaml new file mode 100644 index 00000000..dac9b709 --- /dev/null +++ b/event/config/default/kustomization.yaml @@ -0,0 +1,238 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Adds namespace to all resources. +namespace: event-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: event- + +# 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 +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml +# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. +# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. +# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will +# be able to communicate with the Webhook Server. +#- ../network-policy + +# Uncomment the patches line if you enable Metrics +patches: +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml + target: + kind: Deployment + +# Uncomment the patches line if you enable Metrics and CertManager +# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line. +# This patch will protect the metrics with certManager self-signed certs. +#- path: cert_metrics_manager_patch.yaml +# target: +# kind: Deployment + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +#- path: manager_webhook_patch.yaml +# target: +# kind: Deployment + +# [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: # Uncomment the following block to enable certificates for metrics +# kind: Service +# version: v1 +# name: controller-manager-metrics-service +# fieldPath: metadata.name +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: metrics-certs +# fieldPaths: +# - spec.dnsNames.0 +# - spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 0 +# create: true +# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor +# kind: ServiceMonitor +# group: monitoring.coreos.com +# version: v1 +# name: controller-manager-metrics-monitor +# fieldPaths: +# - spec.endpoints.0.tlsConfig.serverName +# options: +# delimiter: '.' +# index: 0 +# create: true + +# - source: +# kind: Service +# version: v1 +# name: controller-manager-metrics-service +# fieldPath: metadata.namespace +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: metrics-certs +# fieldPaths: +# - spec.dnsNames.0 +# - spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true +# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor +# kind: ServiceMonitor +# group: monitoring.coreos.com +# version: v1 +# name: controller-manager-metrics-monitor +# fieldPaths: +# - spec.endpoints.0.tlsConfig.serverName +# options: +# delimiter: '.' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have any webhook +# kind: Service +# version: v1 +# name: webhook-service +# fieldPath: .metadata.name # Name of the service +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# 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 +# name: serving-cert +# fieldPaths: +# - .spec.dnsNames.0 +# - .spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) +# 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 +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: +# - select: +# kind: ValidatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: +# - select: +# kind: MutatingWebhookConfiguration +# 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 +# fieldPath: .metadata.name +# targets: +# - select: +# kind: MutatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. +# +kubebuilder:scaffold:crdkustomizecainjectionns +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. +# +kubebuilder:scaffold:crdkustomizecainjectionname diff --git a/event/config/default/manager_metrics_patch.yaml b/event/config/default/manager_metrics_patch.yaml new file mode 100644 index 00000000..c0899178 --- /dev/null +++ b/event/config/default/manager_metrics_patch.yaml @@ -0,0 +1,8 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/event/config/default/metrics_service.yaml b/event/config/default/metrics_service.yaml new file mode 100644 index 00000000..daf4c8d2 --- /dev/null +++ b/event/config/default/metrics_service.yaml @@ -0,0 +1,22 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: controller-manager + app.kubernetes.io/name: event diff --git a/event/config/manager/kustomization.yaml b/event/config/manager/kustomization.yaml new file mode 100644 index 00000000..c821ec62 --- /dev/null +++ b/event/config/manager/kustomization.yaml @@ -0,0 +1,6 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +resources: +- manager.yaml diff --git a/event/config/manager/manager.yaml b/event/config/manager/manager.yaml new file mode 100644 index 00000000..99ae1547 --- /dev/null +++ b/event/config/manager/manager.yaml @@ -0,0 +1,103 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: event + 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: event + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: event + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + app.kubernetes.io/name: event + 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: + # Projects are configured by default to adhere to the "restricted" Pod Security Standards. + # This ensures that deployments meet the highest security requirements for Kubernetes. + # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: controller:latest + name: manager + ports: [] + securityContext: + readOnlyRootFilesystem: true + 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 + volumeMounts: [] + volumes: [] + serviceAccountName: controller-manager + terminationGracePeriodSeconds: 10 diff --git a/event/config/network-policy/allow-metrics-traffic.yaml b/event/config/network-policy/allow-metrics-traffic.yaml new file mode 100644 index 00000000..c1137aa7 --- /dev/null +++ b/event/config/network-policy/allow-metrics-traffic.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This NetworkPolicy allows ingress traffic +# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those +# namespaces are able to gather data from the metrics endpoint. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: allow-metrics-traffic + namespace: system +spec: + podSelector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: event + policyTypes: + - Ingress + ingress: + # This allows ingress traffic from any namespace with the label metrics: enabled + - from: + - namespaceSelector: + matchLabels: + metrics: enabled # Only from namespaces with this label + ports: + - port: 8443 + protocol: TCP diff --git a/event/config/network-policy/kustomization.yaml b/event/config/network-policy/kustomization.yaml new file mode 100644 index 00000000..d3c1807d --- /dev/null +++ b/event/config/network-policy/kustomization.yaml @@ -0,0 +1,6 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +resources: +- allow-metrics-traffic.yaml diff --git a/event/config/prometheus/kustomization.yaml b/event/config/prometheus/kustomization.yaml new file mode 100644 index 00000000..94fd0a04 --- /dev/null +++ b/event/config/prometheus/kustomization.yaml @@ -0,0 +1,15 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +resources: +- monitor.yaml + +# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus +# to securely reference certificates created and managed by cert-manager. +# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml +# to mount the "metrics-server-cert" secret in the Manager Deployment. +#patches: +# - path: monitor_tls_patch.yaml +# target: +# kind: ServiceMonitor diff --git a/event/config/prometheus/monitor.yaml b/event/config/prometheus/monitor.yaml new file mode 100644 index 00000000..0410f0d1 --- /dev/null +++ b/event/config/prometheus/monitor.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Prometheus Monitor Service (Metrics) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-monitor + namespace: system +spec: + endpoints: + - path: /metrics + port: https # Ensure this is the name of the port that exposes HTTPS metrics + scheme: https + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + tlsConfig: + # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables + # certificate verification, exposing the system to potential man-in-the-middle attacks. + # For production environments, it is recommended to use cert-manager for automatic TLS certificate management. + # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml, + # which securely references the certificate from the 'metrics-server-cert' secret. + insecureSkipVerify: true + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: event diff --git a/event/config/prometheus/monitor_tls_patch.yaml b/event/config/prometheus/monitor_tls_patch.yaml new file mode 100644 index 00000000..5bc0d408 --- /dev/null +++ b/event/config/prometheus/monitor_tls_patch.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Patch for Prometheus ServiceMonitor to enable secure TLS configuration +# using certificates managed by cert-manager +- op: replace + path: /spec/endpoints/0/tlsConfig + value: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc + insecureSkipVerify: false + ca: + secret: + name: metrics-server-cert + key: ca.crt + cert: + secret: + name: metrics-server-cert + key: tls.crt + keySecret: + name: metrics-server-cert + key: tls.key diff --git a/event/config/rbac/eventconfig_admin_role.yaml b/event/config/rbac/eventconfig_admin_role.yaml new file mode 100644 index 00000000..b5f6701a --- /dev/null +++ b/event/config/rbac/eventconfig_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over event.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventconfig-admin-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs + verbs: + - '*' +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs/status + verbs: + - get diff --git a/event/config/rbac/eventconfig_editor_role.yaml b/event/config/rbac/eventconfig_editor_role.yaml new file mode 100644 index 00000000..2d4dd54c --- /dev/null +++ b/event/config/rbac/eventconfig_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the event.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventconfig-editor-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs/status + verbs: + - get diff --git a/event/config/rbac/eventconfig_viewer_role.yaml b/event/config/rbac/eventconfig_viewer_role.yaml new file mode 100644 index 00000000..5843c663 --- /dev/null +++ b/event/config/rbac/eventconfig_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to event.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventconfig-viewer-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs + verbs: + - get + - list + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs/status + verbs: + - get diff --git a/event/config/rbac/eventexposure_admin_role.yaml b/event/config/rbac/eventexposure_admin_role.yaml new file mode 100644 index 00000000..117cd814 --- /dev/null +++ b/event/config/rbac/eventexposure_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over event.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventexposure-admin-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures + verbs: + - '*' +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures/status + verbs: + - get diff --git a/event/config/rbac/eventexposure_editor_role.yaml b/event/config/rbac/eventexposure_editor_role.yaml new file mode 100644 index 00000000..72672119 --- /dev/null +++ b/event/config/rbac/eventexposure_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the event.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventexposure-editor-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures/status + verbs: + - get diff --git a/event/config/rbac/eventexposure_viewer_role.yaml b/event/config/rbac/eventexposure_viewer_role.yaml new file mode 100644 index 00000000..62d6df35 --- /dev/null +++ b/event/config/rbac/eventexposure_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to event.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventexposure-viewer-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures + verbs: + - get + - list + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures/status + verbs: + - get diff --git a/event/config/rbac/eventsubscription_admin_role.yaml b/event/config/rbac/eventsubscription_admin_role.yaml new file mode 100644 index 00000000..f1264e19 --- /dev/null +++ b/event/config/rbac/eventsubscription_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over event.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventsubscription-admin-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventsubscriptions + verbs: + - '*' +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventsubscriptions/status + verbs: + - get diff --git a/event/config/rbac/eventsubscription_editor_role.yaml b/event/config/rbac/eventsubscription_editor_role.yaml new file mode 100644 index 00000000..c24c4ad9 --- /dev/null +++ b/event/config/rbac/eventsubscription_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the event.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventsubscription-editor-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventsubscriptions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventsubscriptions/status + verbs: + - get diff --git a/event/config/rbac/eventsubscription_viewer_role.yaml b/event/config/rbac/eventsubscription_viewer_role.yaml new file mode 100644 index 00000000..ac66007a --- /dev/null +++ b/event/config/rbac/eventsubscription_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to event.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventsubscription-viewer-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventsubscriptions + verbs: + - get + - list + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventsubscriptions/status + verbs: + - get diff --git a/event/config/rbac/eventtype_admin_role.yaml b/event/config/rbac/eventtype_admin_role.yaml new file mode 100644 index 00000000..b9551463 --- /dev/null +++ b/event/config/rbac/eventtype_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over event.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventtype-admin-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventtypes + verbs: + - '*' +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventtypes/status + verbs: + - get diff --git a/event/config/rbac/eventtype_editor_role.yaml b/event/config/rbac/eventtype_editor_role.yaml new file mode 100644 index 00000000..d05d9172 --- /dev/null +++ b/event/config/rbac/eventtype_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the event.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventtype-editor-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventtypes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventtypes/status + verbs: + - get diff --git a/event/config/rbac/eventtype_viewer_role.yaml b/event/config/rbac/eventtype_viewer_role.yaml new file mode 100644 index 00000000..103867e8 --- /dev/null +++ b/event/config/rbac/eventtype_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project event itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to event.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: eventtype-viewer-role +rules: +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventtypes + verbs: + - get + - list + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventtypes/status + verbs: + - get diff --git a/event/config/rbac/kustomization.yaml b/event/config/rbac/kustomization.yaml new file mode 100644 index 00000000..e0355958 --- /dev/null +++ b/event/config/rbac/kustomization.yaml @@ -0,0 +1,41 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +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 +# The following RBAC configurations are used to protect +# the metrics endpoint with authn/authz. These configurations +# ensure that only authorized users and service accounts +# can access the metrics endpoint. Comment the following +# permissions if you want to disable this protection. +# More info: https://book.kubebuilder.io/reference/metrics.html +- metrics_auth_role.yaml +- metrics_auth_role_binding.yaml +- metrics_reader_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the event itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- eventsubscription_admin_role.yaml +- eventsubscription_editor_role.yaml +- eventsubscription_viewer_role.yaml +- eventexposure_admin_role.yaml +- eventexposure_editor_role.yaml +- eventexposure_viewer_role.yaml +- eventtype_admin_role.yaml +- eventtype_editor_role.yaml +- eventtype_viewer_role.yaml +- eventconfig_admin_role.yaml +- eventconfig_editor_role.yaml +- eventconfig_viewer_role.yaml + diff --git a/event/config/rbac/leader_election_role.yaml b/event/config/rbac/leader_election_role.yaml new file mode 100644 index 00000000..33e47f28 --- /dev/null +++ b/event/config/rbac/leader_election_role.yaml @@ -0,0 +1,44 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: event + 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/event/config/rbac/leader_election_role_binding.yaml b/event/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 00000000..eceb07a1 --- /dev/null +++ b/event/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: event + 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/event/config/rbac/metrics_auth_role.yaml b/event/config/rbac/metrics_auth_role.yaml new file mode 100644 index 00000000..67bd0ffc --- /dev/null +++ b/event/config/rbac/metrics_auth_role.yaml @@ -0,0 +1,21 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-auth-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/event/config/rbac/metrics_auth_role_binding.yaml b/event/config/rbac/metrics_auth_role_binding.yaml new file mode 100644 index 00000000..a2f150c9 --- /dev/null +++ b/event/config/rbac/metrics_auth_role_binding.yaml @@ -0,0 +1,16 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-auth-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics-auth-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/event/config/rbac/metrics_reader_role.yaml b/event/config/rbac/metrics_reader_role.yaml new file mode 100644 index 00000000..d6c33933 --- /dev/null +++ b/event/config/rbac/metrics_reader_role.yaml @@ -0,0 +1,13 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/event/config/rbac/role.yaml b/event/config/rbac/role.yaml new file mode 100644 index 00000000..54505e12 --- /dev/null +++ b/event/config/rbac/role.yaml @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - admin.cp.ei.telekom.de + resources: + - zones + verbs: + - get + - list + - watch +- apiGroups: + - admin.cp.ei.telekom.de + resources: + - zones/status + verbs: + - get +- apiGroups: + - application.cp.ei.telekom.de + resources: + - applications + verbs: + - get + - list + - watch +- apiGroups: + - approval.cp.ei.telekom.de + resources: + - approvalrequests + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - approval.cp.ei.telekom.de + resources: + - approvals + verbs: + - get + - list + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs + - eventexposures + - eventsubscriptions + - eventtypes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs/finalizers + - eventexposures/finalizers + - eventsubscriptions/finalizers + - eventtypes/finalizers + verbs: + - update +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventconfigs/status + - eventexposures/status + - eventsubscriptions/status + - eventtypes/status + verbs: + - get + - patch + - update +- apiGroups: + - gateway.cp.ei.telekom.de + resources: + - realms + verbs: + - get + - list + - watch +- apiGroups: + - gateway.cp.ei.telekom.de + resources: + - routes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - identity.cp.ei.telekom.de + resources: + - clients + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - identity.cp.ei.telekom.de + resources: + - realms + verbs: + - get +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores + - publishers + - subscribers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/event/config/rbac/role_binding.yaml b/event/config/rbac/role_binding.yaml new file mode 100644 index 00000000..d5b52e4b --- /dev/null +++ b/event/config/rbac/role_binding.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: event + 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/event/config/rbac/service_account.yaml b/event/config/rbac/service_account.yaml new file mode 100644 index 00000000..78341394 --- /dev/null +++ b/event/config/rbac/service_account.yaml @@ -0,0 +1,12 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: event + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/event/docs/event-domain-architecture.md b/event/docs/event-domain-architecture.md new file mode 100644 index 00000000..6eca9be0 --- /dev/null +++ b/event/docs/event-domain-architecture.md @@ -0,0 +1,171 @@ + + +# Event Domain -- Architecture Overview + +This document describes how the **Event domain** (`event.cp.ei.telekom.de/v1`) interacts with its surrounding domains in the Control Plane. + +## Domain Interaction Diagram + +```mermaid +flowchart TB + %% ── Styling ────────────────────────────────────────────── + classDef eventCls fill:#4a90d9,color:#fff,stroke:#2c5f8a,stroke-width:2px + classDef adminCls fill:#e8a838,color:#fff,stroke:#b07d1e,stroke-width:2px + classDef gatewayCls fill:#50b86c,color:#fff,stroke:#2e7d42,stroke-width:2px + classDef identityCls fill:#ab47bc,color:#fff,stroke:#7b1fa2,stroke-width:2px + classDef pubsubCls fill:#ef5350,color:#fff,stroke:#c62828,stroke-width:2px + classDef approvalCls fill:#ff7043,color:#fff,stroke:#bf360c,stroke-width:2px + classDef appCls fill:#26a69a,color:#fff,stroke:#00796b,stroke-width:2px + + %% ── Admin Domain ──────────────────────────────────────── + subgraph admin["Admin Domain"] + direction TB + AdminZone["Zone"]:::adminCls + end + + %% ── Application Domain ────────────────────────────────── + subgraph application["Application Domain"] + direction TB + App["Application"]:::appCls + end + + %% ── Identity Domain ───────────────────────────────────── + subgraph identity["Identity Domain"] + direction TB + IdRealm["Realm"]:::identityCls + IdClient["Client"]:::identityCls + end + + %% ── Gateway Domain ────────────────────────────────────── + subgraph gateway["Gateway Domain"] + direction TB + GwRealm["Realm"]:::gatewayCls + GwRoute["Route"]:::gatewayCls + end + + %% ── PubSub Domain ─────────────────────────────────────── + subgraph pubsub["PubSub Domain"] + direction TB + EventStore["EventStore"]:::pubsubCls + Publisher["Publisher"]:::pubsubCls + Subscriber["Subscriber"]:::pubsubCls + end + + %% ── Approval Domain ───────────────────────────────────── + subgraph approval["Approval Domain"] + direction TB + ApprovalReq["ApprovalRequest"]:::approvalCls + ApprovalRes["Approval"]:::approvalCls + end + + %% ── Event Domain (center) ─────────────────────────────── + subgraph event["Event Domain"] + direction TB + EventConfig["EventConfig"]:::eventCls + EventType["EventType"]:::eventCls + EventExposure["EventExposure"]:::eventCls + EventSubscription["EventSubscription"]:::eventCls + + EventType -. "referenced by" .-> EventExposure + EventType -. "referenced by" .-> EventSubscription + EventConfig -. "configures zone for" .-> EventExposure + EventConfig -. "configures zone for" .-> EventSubscription + EventExposure -. "triggers reconcile" .-> EventSubscription + end + + %% ── EventConfig interactions ──────────────────────────── + EventConfig -- "creates" --> IdClient + EventConfig -- "creates" --> EventStore + EventConfig -- "creates" --> GwRoute + EventConfig -. "reads" .-> IdRealm + EventConfig -. "watches" .-> AdminZone + + %% ── EventExposure interactions ────────────────────────── + EventExposure -- "creates" --> Publisher + EventExposure -- "creates" --> GwRoute + EventExposure -. "reads" .-> App + EventExposure -. "reads" .-> EventStore + EventExposure -. "reads" .-> GwRealm + EventExposure -. "watches" .-> AdminZone + + %% ── EventSubscription interactions ────────────────────── + EventSubscription -- "creates" --> Subscriber + EventSubscription -- "creates" --> ApprovalReq + EventSubscription -- "creates" --> ApprovalRes + EventSubscription -. "reads" .-> App + EventSubscription -. "watches" .-> App +``` + +### Legend + +| Arrow style | Meaning | +|---|---| +| **Solid line** (`--creates-->`) | The event controller **creates and owns** this resource (full CRUD lifecycle) | +| **Dashed line** (`-.watches.->`) | The event controller **watches** this resource for changes that trigger reconciliation | +| **Dashed line** (`-.reads.->`) | The event controller **reads** this resource during reconciliation (GET/LIST) | + +## Interaction Details + +### EventConfig Controller + +The zone-level configuration controller. One `EventConfig` exists per zone and bootstraps the infrastructure resources needed by the event system in that zone. + +| Target Domain | Resource | Relationship | Purpose | +|---|---|---|---| +| **Identity** | `Client` | creates/owns (x2) | Creates a **mesh client** for cross-zone OAuth2 communication and an **admin client** for the configuration backend | +| **Identity** | `Realm` | reads | Resolves token URL for OAuth2 authentication | +| **PubSub** | `EventStore` | creates/owns | Provisions the event store (connection to the pubsub backend) | +| **Gateway** | `Route` | creates/owns | Creates **publish**, **callback**, and **proxy callback** routes for event ingestion and delivery | +| **Admin** | `Zone` | watches | Reacts to zone changes; reads zone details and namespace references | + +Internal: watches other `EventConfig` resources in the same environment (for mesh topology updates). + +### EventType Controller + +Self-contained. Manages singleton semantics (oldest non-deleted `EventType` for a given type string becomes "active"). No cross-domain interactions. + +### EventExposure Controller + +The publisher-side controller. Declares that an application exposes events of a specific type. + +| Target Domain | Resource | Relationship | Purpose | +|---|---|---|---| +| **PubSub** | `Publisher` | creates/owns | Registers the application as a publisher on the event store | +| **PubSub** | `EventStore` | reads | Retrieves the event store reference for the zone | +| **Gateway** | `Route` | creates/owns | Creates **SSE routes** and **cross-zone SSE proxy routes** for event streaming | +| **Gateway** | `Realm` | reads | Resolves gateway realm for route upstream/downstream configuration | +| **Application** | `Application` | reads | Resolves provider application info (client ID for publisher registration) | +| **Admin** | `Zone` | watches/reads | Reacts to zone changes; resolves namespace and realm references | + +Internal: watches `EventType`, `EventConfig`, `EventSubscription` (for SSE proxy routes), and other `EventExposure` resources (active/standby failover). + +### EventSubscription Controller + +The consumer-side controller. Declares that an application subscribes to events of a specific type. + +| Target Domain | Resource | Relationship | Purpose | +|---|---|---|---| +| **PubSub** | `Subscriber` | creates/owns | Registers the application as a subscriber with delivery and trigger configuration | +| **Approval** | `ApprovalRequest` | creates/owns | Initiates the approval workflow for the subscription | +| **Approval** | `Approval` | creates/owns | Manages the approval decision (auto/simple/four-eyes strategy) | +| **Application** | `Application` | watches/reads | Watches for application changes; reads requestor and provider application info | + +Internal: watches `EventExposure` (to react when the provider appears/disappears), `EventConfig` (for zone configuration), reads `EventType` (for type validation). + +## Registered Schemes + +The event operator registers API types from **7 domains** (including itself): + +| Domain | API Group | Resources Used | +|---|---|---| +| **Event** | `event.cp.ei.telekom.de` | EventConfig, EventType, EventExposure, EventSubscription | +| **Admin** | `admin.cp.ei.telekom.de` | Zone | +| **Application** | `application.cp.ei.telekom.de` | Application | +| **Approval** | `approval.cp.ei.telekom.de` | ApprovalRequest, Approval | +| **Gateway** | `gateway.cp.ei.telekom.de` | Route, Realm | +| **Identity** | `identity.cp.ei.telekom.de` | Client, Realm | +| **PubSub** | `pubsub.cp.ei.telekom.de` | EventStore, Publisher, Subscriber | diff --git a/event/go.mod b/event/go.mod new file mode 100644 index 00000000..11b1ca6e --- /dev/null +++ b/event/go.mod @@ -0,0 +1,138 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +module github.com/telekom/controlplane/event + +go 1.24.9 + +require ( + github.com/telekom/controlplane/common v0.0.0 + github.com/telekom/controlplane/event/api v0.0.0 + github.com/telekom/controlplane/admin/api v0.0.0 + github.com/telekom/controlplane/application/api v0.0.0 + github.com/telekom/controlplane/approval/api v0.0.0 + github.com/telekom/controlplane/gateway/api v0.0.0 + github.com/telekom/controlplane/identity/api v0.0.0 + github.com/telekom/controlplane/pubsub/api v0.0.0 +) + +require ( + github.com/google/uuid v1.6.0 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.11.1 + k8s.io/apimachinery v0.34.2 + k8s.io/client-go v0.34.2 + sigs.k8s.io/controller-runtime v0.22.4 +) + +replace ( + github.com/telekom/controlplane/admin/api => ../admin/api + github.com/telekom/controlplane/application/api => ../application/api + github.com/telekom/controlplane/approval/api => ../approval/api + github.com/telekom/controlplane/common => ../common + github.com/telekom/controlplane/event/api => ./api + github.com/telekom/controlplane/gateway/api => ../gateway/api + github.com/telekom/controlplane/identity/api => ../identity/api + github.com/telekom/controlplane/pubsub/api => ../pubsub/api +) + +require ( + cel.dev/expr v0.24.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // 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.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/grpc v1.72.1 // indirect + google.golang.org/protobuf v1.36.10 // 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/api v0.34.2 // indirect + k8s.io/apiextensions-apiserver v0.34.2 // indirect + k8s.io/apiserver v0.34.2 // indirect + k8s.io/component-base v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/event/go.sum b/event/go.sum new file mode 100644 index 00000000..c6cfb723 --- /dev/null +++ b/event/go.sum @@ -0,0 +1,306 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +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.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/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/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +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.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE= +k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/component-base v0.34.2 h1:HQRqK9x2sSAsd8+R4xxRirlTjowsg6fWCPwWYeSvogQ= +k8s.io/component-base v0.34.2/go.mod h1:9xw2FHJavUHBFpiGkZoKuYZ5pdtLKe97DEByaA+hHbM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/event/go.sum.license b/event/go.sum.license new file mode 100644 index 00000000..be863cd5 --- /dev/null +++ b/event/go.sum.license @@ -0,0 +1,3 @@ +Copyright 2026 Deutsche Telekom IT GmbH + +SPDX-License-Identifier: Apache-2.0 diff --git a/event/internal/controller/eventconfig_controller.go b/event/internal/controller/eventconfig_controller.go new file mode 100644 index 00000000..6f08cd6a --- /dev/null +++ b/event/internal/controller/eventconfig_controller.go @@ -0,0 +1,132 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventconfig" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// EventConfigReconciler reconciles a EventConfig object +type EventConfigReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*eventv1.EventConfig] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventconfigs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventconfigs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventconfigs/finalizers,verbs=update +// +kubebuilder:rbac:groups=identity.cp.ei.telekom.de,resources=realms,verbs=get +// +kubebuilder:rbac:groups=identity.cp.ei.telekom.de,resources=clients,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch +// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones/status,verbs=get +// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=routes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=realms,verbs=get;list;watch + +func (r *EventConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &eventv1.EventConfig{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *EventConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("eventconfig-controller") + r.Controller = cc.NewController(&eventconfig.EventConfigHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&eventv1.EventConfig{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Owns(&pubsubv1.EventStore{}). + Owns(&gatewayv1.Route{}, builder.WithPredicates(LabelPredicate)). + Owns(&identityv1.Client{}, builder.WithPredicates(LabelPredicate)). + Watches(&adminv1.Zone{}, + handler.EnqueueRequestsFromMapFunc(r.MapZoneToEventConfig), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + Watches(&eventv1.EventConfig{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventConfigToEventConfig), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} + +// MapZoneToEventConfig enqueues EventConfig referencing the changed Zone. +func (r *EventConfigReconciler) MapZoneToEventConfig(ctx context.Context, obj client.Object) []reconcile.Request { + zone, ok := obj.(*adminv1.Zone) + if !ok { + return nil + } + + list := &eventv1.EventConfigList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: zone.Labels[cconfig.EnvironmentLabelKey], + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if !item.Spec.Zone.Equals(zone) { + continue + } + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + return reqs +} + +// MapEventConfigToEventConfig enqueues other EventConfig referencing the changed EventConfig. +// This is required to trigger updates for meshing +func (r *EventConfigReconciler) MapEventConfigToEventConfig(ctx context.Context, obj client.Object) []reconcile.Request { + eventConfig, ok := obj.(*eventv1.EventConfig) + if !ok { + return nil + } + + list := &eventv1.EventConfigList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: eventConfig.Labels[cconfig.EnvironmentLabelKey], + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if ctypes.Equals(&item, eventConfig) { + continue + } + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + + } + return reqs +} diff --git a/event/internal/controller/eventconfig_controller_test.go b/event/internal/controller/eventconfig_controller_test.go new file mode 100644 index 00000000..26d04abf --- /dev/null +++ b/event/internal/controller/eventconfig_controller_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventconfig" +) + +var _ = Describe("EventConfig Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + eventconfigObj := &eventv1.EventConfig{} + + BeforeEach(func() { + By("creating the custom resource for the Kind EventConfig") + err := k8sClient.Get(ctx, typeNamespacedName, eventconfigObj) + if err != nil && errors.IsNotFound(err) { + resource := &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: eventv1.EventConfigSpec{ + Zone: ctypes.ObjectRef{Name: "test-zone", Namespace: "default"}, + Admin: eventv1.AdminConfig{ + Url: "https://admin.example.com", + Realm: ctypes.ObjectRef{Name: "test-realm", Namespace: "default"}, + }, + ServerSendEventUrl: "https://sse.example.com", + PublishEventUrl: "https://publish.example.com", + Mesh: eventv1.MeshConfig{FullMesh: true}, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &eventv1.EventConfig{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance EventConfig") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &EventConfigReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&eventconfig.EventConfigHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/event/internal/controller/eventexposure_controller.go b/event/internal/controller/eventexposure_controller.go new file mode 100644 index 00000000..721b404c --- /dev/null +++ b/event/internal/controller/eventexposure_controller.go @@ -0,0 +1,275 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventexposure" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// EventExposureReconciler reconciles a EventExposure object +type EventExposureReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*eventv1.EventExposure] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventexposures,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventexposures/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventexposures/finalizers,verbs=update +// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch +// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones/status,verbs=get +// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=routes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=gateway.cp.ei.telekom.de,resources=realms,verbs=get;list;watch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventconfigs,verbs=get;list;watch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventsubscriptions,verbs=get;list;watch +// +kubebuilder:rbac:groups=identity.cp.ei.telekom.de,resources=clients,verbs=get;list;watch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=publishers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventtypes,verbs=get;list;watch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores,verbs=get;list;watch +// +kubebuilder:rbac:groups=application.cp.ei.telekom.de,resources=applications,verbs=get;list;watch + +func (r *EventExposureReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &eventv1.EventExposure{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *EventExposureReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("eventexposure-controller") + r.Controller = cc.NewController(&eventexposure.EventExposureHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&eventv1.EventExposure{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Owns(&pubsubv1.Publisher{}). + Watches(&gatewayv1.Route{}, + handler.EnqueueRequestsFromMapFunc(r.MapRouteToEventExposure), + builder.WithPredicates(predicate.GenerationChangedPredicate{}, LabelPredicate), + ). + Watches(&eventv1.EventType{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventTypeToEventExposure), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches(&eventv1.EventExposure{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventExposureToEventExposure), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + Watches(&adminv1.Zone{}, + handler.EnqueueRequestsFromMapFunc(r.MapZoneToEventExposure), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + Watches(&eventv1.EventConfig{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventConfigToEventExposure), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches(&eventv1.EventSubscription{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventSubscriptionToEventExposure), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} + +// MapEventTypeToEventExposure enqueues EventExposures referencing the changed EventType. +// This ensures exposures react to changes in the EventType (e.g., schema changes). +func (r *EventExposureReconciler) MapEventTypeToEventExposure(ctx context.Context, obj client.Object) []reconcile.Request { + eventType, ok := obj.(*eventv1.EventType) + if !ok { + return nil + } + + list := &eventv1.EventExposureList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: eventType.Labels[cconfig.EnvironmentLabelKey], + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType.Spec.Type), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if item.Spec.EventType == eventType.Spec.Type { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} + +// MapEventExposureToEventExposure enqueues other EventExposures with the same event type +// when any EventExposure changes or is deleted. This triggers standby exposures to detect +// the active one is gone and become active themselves. +func (r *EventExposureReconciler) MapEventExposureToEventExposure(ctx context.Context, obj client.Object) []reconcile.Request { + exposure, ok := obj.(*eventv1.EventExposure) + if !ok { + return nil + } + + list := &eventv1.EventExposureList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: exposure.Labels[cconfig.EnvironmentLabelKey], + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(exposure.Spec.EventType), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if item.UID == exposure.UID { + continue + } + if item.Spec.EventType == exposure.Spec.EventType { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} + +// MapRouteToEventExposure enqueues EventExposures whose event type matches +// the Route's EventTypeLabelKey label. This allows EventExposure to react +// to Route status changes (e.g., Route becoming ready). +func (r *EventExposureReconciler) MapRouteToEventExposure(ctx context.Context, obj client.Object) []reconcile.Request { + route, ok := obj.(*gatewayv1.Route) + if !ok { + return nil + } + + // Only care about SSE Routes (identified by EventTypeLabelKey) + eventTypeLabel := route.GetLabels()[eventv1.EventTypeLabelKey] + if eventTypeLabel == "" { + return nil + } + + list := &eventv1.EventExposureList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: route.Labels[cconfig.EnvironmentLabelKey], + eventv1.EventTypeLabelKey: eventTypeLabel, + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + normalized := labelutil.NormalizeLabelValue(item.Spec.EventType) + if normalized == eventTypeLabel { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} + +// MapZoneToEventExposure enqueues EventExposures referencing the changed Zone. +// This ensures EventExposures react to zone status changes (e.g., GatewayRealm becoming available). +func (r *EventExposureReconciler) MapZoneToEventExposure(ctx context.Context, obj client.Object) []reconcile.Request { + zone, ok := obj.(*adminv1.Zone) + if !ok { + return nil + } + + list := &eventv1.EventExposureList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: zone.Labels[cconfig.EnvironmentLabelKey], + cconfig.BuildLabelKey("zone"): labelutil.NormalizeLabelValue(zone.Name), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if item.Spec.Zone.Name == zone.Name { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} + +// MapEventConfigToEventExposure enqueues EventExposures referencing the same zone as the changed EventConfig. +// This ensures EventExposures react to EventConfig changes (e.g., SSE URL updates, config becoming ready). +func (r *EventExposureReconciler) MapEventConfigToEventExposure(ctx context.Context, obj client.Object) []reconcile.Request { + eventConfig, ok := obj.(*eventv1.EventConfig) + if !ok { + return nil + } + + // TODO: full environment list --> investigate if we can optimize it + list := &eventv1.EventExposureList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: eventConfig.Labels[cconfig.EnvironmentLabelKey], + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if item.Spec.Zone.Equals(&eventConfig.Spec.Zone) { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} + +// MapEventSubscriptionToEventExposure enqueues EventExposures whose event type matches +// the subscription's event type. This triggers proxy route creation/cleanup when SSE +// subscriptions are created, updated, or deleted. +func (r *EventExposureReconciler) MapEventSubscriptionToEventExposure(ctx context.Context, obj client.Object) []reconcile.Request { + sub, ok := obj.(*eventv1.EventSubscription) + if !ok { + return nil + } + + // Only care about SSE subscriptions — callback subscriptions don't need proxy routes + if sub.Spec.Delivery.Type != eventv1.DeliveryTypeServerSentEvent { + return nil + } + + list := &eventv1.EventExposureList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: sub.Labels[cconfig.EnvironmentLabelKey], + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(sub.Spec.EventType), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if item.Spec.EventType == sub.Spec.EventType { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} diff --git a/event/internal/controller/eventexposure_controller_test.go b/event/internal/controller/eventexposure_controller_test.go new file mode 100644 index 00000000..4670eb13 --- /dev/null +++ b/event/internal/controller/eventexposure_controller_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventexposure" +) + +var _ = Describe("EventExposure Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + eventexposureObj := &eventv1.EventExposure{} + + BeforeEach(func() { + By("creating the custom resource for the Kind EventExposure") + err := k8sClient.Get(ctx, typeNamespacedName, eventexposureObj) + if err != nil && errors.IsNotFound(err) { + resource := &eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: eventv1.EventExposureSpec{ + EventType: "de.telekom.test.v1", + Visibility: eventv1.VisibilityEnterprise, + Approval: eventv1.Approval{Strategy: eventv1.ApprovalStrategyAuto}, + Zone: ctypes.ObjectRef{Name: "test-zone", Namespace: "default"}, + Provider: ctypes.TypedObjectRef{ + TypeMeta: metav1.TypeMeta{Kind: "Application", APIVersion: "application.cp.ei.telekom.de/v1"}, + ObjectRef: ctypes.ObjectRef{Name: "test-app", Namespace: "default"}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &eventv1.EventExposure{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance EventExposure") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &EventExposureReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&eventexposure.EventExposureHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/event/internal/controller/eventsubscription_controller.go b/event/internal/controller/eventsubscription_controller.go new file mode 100644 index 00000000..a452c947 --- /dev/null +++ b/event/internal/controller/eventsubscription_controller.go @@ -0,0 +1,169 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + approvalv1 "github.com/telekom/controlplane/approval/api/v1" + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventsubscription" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + ctypes "github.com/telekom/controlplane/common/pkg/types" + + applicationv1 "github.com/telekom/controlplane/application/api/v1" +) + +// EventSubscriptionReconciler reconciles a EventSubscription object +type EventSubscriptionReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*eventv1.EventSubscription] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventsubscriptions,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventsubscriptions/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventsubscriptions/finalizers,verbs=update +// +kubebuilder:rbac:groups=application.cp.ei.telekom.de,resources=applications,verbs=get;list;watch +// +kubebuilder:rbac:groups=approval.cp.ei.telekom.de,resources=approvalrequests,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=approval.cp.ei.telekom.de,resources=approvals,verbs=get;list;watch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventtypes,verbs=get;list;watch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventconfigs,verbs=get;list;watch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=subscribers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventexposures,verbs=get;list;watch + +func (r *EventSubscriptionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &eventv1.EventSubscription{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *EventSubscriptionReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("eventsubscription-controller") + r.Controller = cc.NewController(&eventsubscription.EventSubscriptionHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&eventv1.EventSubscription{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Owns(&approvalv1.ApprovalRequest{}). + Owns(&approvalv1.Approval{}). + Owns(&pubsubv1.Subscriber{}). + Watches(&eventv1.EventExposure{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventExposureToEventSubscription), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches(&eventv1.EventConfig{}, + handler.EnqueueRequestsFromMapFunc(r.MapEventConfigToEventSubscription), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches(&applicationv1.Application{}, + handler.EnqueueRequestsFromMapFunc(r.MapApplicationToEventSubscription), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} + +// MapEventExposureToEventSubscription enqueues EventSubscriptions that are affected by changes to EventExposures. +// This is necessary to update the status of EventSubscriptions when the corresponding EventExposure changes. +func (r *EventSubscriptionReconciler) MapEventExposureToEventSubscription(ctx context.Context, obj client.Object) []reconcile.Request { + eventExposure, ok := obj.(*eventv1.EventExposure) + if !ok { + return nil + } + + list := &eventv1.EventSubscriptionList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: eventExposure.Labels[cconfig.EnvironmentLabelKey], + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventExposure.Spec.EventType), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if item.Spec.EventType == eventExposure.Spec.EventType { + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + } + return reqs +} + +// MapEventConfigToEventSubscription enqueues EventSubscriptions that are affected by changes to EventConfigs. +// This is necessary to update the status of EventSubscriptions when the corresponding EventConfig changes. +func (r *EventSubscriptionReconciler) MapEventConfigToEventSubscription(ctx context.Context, obj client.Object) []reconcile.Request { + eventConfig, ok := obj.(*eventv1.EventConfig) + if !ok { + return nil + } + + list := &eventv1.EventSubscriptionList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: eventConfig.Labels[cconfig.EnvironmentLabelKey], + cconfig.BuildLabelKey("zone"): labelutil.NormalizeLabelValue(eventConfig.Spec.Zone.Name), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if !item.Spec.Zone.Equals(&eventConfig.Spec.Zone) { + continue + } + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + return reqs +} + +// MapApplicationToEventSubscription enqueues EventSubscriptions that are affected by changes to Applications. +// This is necessary to update the status of EventSubscriptions when the corresponding Application is updated, e.g. becoming ready +func (r *EventSubscriptionReconciler) MapApplicationToEventSubscription(ctx context.Context, obj client.Object) []reconcile.Request { + application, ok := obj.(*applicationv1.Application) + if !ok { + return nil + } + + list := &eventv1.EventSubscriptionList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: application.Labels[cconfig.EnvironmentLabelKey], + cconfig.BuildLabelKey("application"): labelutil.NormalizeLabelValue(application.Name), + }); err != nil { + return nil + } + + var reqs []reconcile.Request + for _, item := range list.Items { + if !ctypes.ObjectRefFromObject(application).Equals(&item.Spec.Requestor) { + continue + } + + reqs = append(reqs, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&item), + }) + } + return reqs + +} diff --git a/event/internal/controller/eventsubscription_controller_test.go b/event/internal/controller/eventsubscription_controller_test.go new file mode 100644 index 00000000..198e1e25 --- /dev/null +++ b/event/internal/controller/eventsubscription_controller_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventsubscription" +) + +var _ = Describe("EventSubscription Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + eventsubscriptionObj := &eventv1.EventSubscription{} + + BeforeEach(func() { + By("creating the custom resource for the Kind EventSubscription") + err := k8sClient.Get(ctx, typeNamespacedName, eventsubscriptionObj) + if err != nil && errors.IsNotFound(err) { + resource := &eventv1.EventSubscription{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Zone: ctypes.ObjectRef{Name: "test-zone", Namespace: "default"}, + Requestor: ctypes.TypedObjectRef{ + TypeMeta: metav1.TypeMeta{Kind: "Application", APIVersion: "application.cp.ei.telekom.de/v1"}, + ObjectRef: ctypes.ObjectRef{Name: "test-app", Namespace: "default"}, + }, + Delivery: eventv1.Delivery{ + Type: eventv1.DeliveryTypeCallback, + Payload: eventv1.PayloadTypeData, + Callback: "https://callback.example.com", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &eventv1.EventSubscription{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance EventSubscription") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &EventSubscriptionReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&eventsubscription.EventSubscriptionHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/event/internal/controller/eventtype_controller.go b/event/internal/controller/eventtype_controller.go new file mode 100644 index 00000000..a5c28faf --- /dev/null +++ b/event/internal/controller/eventtype_controller.go @@ -0,0 +1,51 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventtype" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" +) + +// EventTypeReconciler reconciles a EventType object +type EventTypeReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*eventv1.EventType] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventtypes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventtypes/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventtypes/finalizers,verbs=update + +func (r *EventTypeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &eventv1.EventType{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *EventTypeReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("eventtype-controller") + r.Controller = cc.NewController(&eventtype.EventTypeHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&eventv1.EventType{}). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} diff --git a/event/internal/controller/eventtype_controller_test.go b/event/internal/controller/eventtype_controller_test.go new file mode 100644 index 00000000..2ed7f26a --- /dev/null +++ b/event/internal/controller/eventtype_controller_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventtype" +) + +var _ = Describe("EventType Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + eventtypeObj := &eventv1.EventType{} + + BeforeEach(func() { + By("creating the custom resource for the Kind EventType") + err := k8sClient.Get(ctx, typeNamespacedName, eventtypeObj) + if err != nil && errors.IsNotFound(err) { + resource := &eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: eventv1.EventTypeSpec{ + Type: "de.telekom.test.v1", + Version: "1.0.0", + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &eventv1.EventType{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance EventType") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &EventTypeReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&eventtype.EventTypeHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/event/internal/controller/predicates.go b/event/internal/controller/predicates.go new file mode 100644 index 00000000..b91e397d --- /dev/null +++ b/event/internal/controller/predicates.go @@ -0,0 +1,21 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// LabelPredicate is a predicate that filters objects based on the presence of the label. +// It can be used to ensure that only objects relevant to the event controller are processed. +var LabelPredicate = predicate.NewPredicateFuncs(func(object client.Object) bool { + labels := object.GetLabels() + if labels == nil { + return false + } + _, ok := labels["event"] + return ok +}) diff --git a/event/internal/controller/schema.go b/event/internal/controller/schema.go new file mode 100644 index 00000000..7414e4f3 --- /dev/null +++ b/event/internal/controller/schema.go @@ -0,0 +1,30 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + adminv1 "github.com/telekom/controlplane/admin/api/v1" + applicationv1 "github.com/telekom/controlplane/application/api/v1" + approvalv1 "github.com/telekom/controlplane/approval/api/v1" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +func RegisterSchemesOrDie(scheme *runtime.Scheme) { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(eventv1.AddToScheme(scheme)) + utilruntime.Must(pubsubv1.AddToScheme(scheme)) + utilruntime.Must(identityv1.AddToScheme(scheme)) + utilruntime.Must(gatewayv1.AddToScheme(scheme)) + utilruntime.Must(adminv1.AddToScheme(scheme)) + utilruntime.Must(approvalv1.AddToScheme(scheme)) + utilruntime.Must(applicationv1.AddToScheme(scheme)) +} diff --git a/event/internal/controller/suite_test.go b/event/internal/controller/suite_test.go new file mode 100644 index 00000000..8d5f458f --- /dev/null +++ b/event/internal/controller/suite_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "os" + "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" + + eventv1 "github.com/telekom/controlplane/event/api/v1" + // +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 ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = eventv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + 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") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/event/internal/handler/eventconfig/handler.go b/event/internal/handler/eventconfig/handler.go new file mode 100644 index 00000000..37965430 --- /dev/null +++ b/event/internal/handler/eventconfig/handler.go @@ -0,0 +1,334 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventconfig + +import ( + "context" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/client" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + "github.com/telekom/controlplane/common/pkg/handler" + "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + adminv1 "github.com/telekom/controlplane/admin/api/v1" +) + +var _ handler.Handler[*eventv1.EventConfig] = &EventConfigHandler{} + +type EventConfigHandler struct{} + +func (h *EventConfigHandler) CreateOrUpdate(ctx context.Context, obj *eventv1.EventConfig) error { + logger := log.FromContext(ctx) + c := cclient.ClientFromContextOrDie(ctx) + + // --- Identity Client --- + + realm := &identityv1.Realm{} + err := c.Get(ctx, obj.Spec.Admin.Realm.K8s(), realm) + if err != nil { + if apierrors.IsNotFound(errors.Cause(err)) { + return ctrlerrors.BlockedErrorf("referenced identity Realm %q not found", obj.Spec.Admin.Realm.String()) + } + return errors.Wrapf(err, "failed to get identity Realm %q", obj.Spec.Admin.Realm.String()) + } + + // Derive the token URL from the realm's issuer URL + tokenUrl := realm.Status.IssuerUrl + "/protocol/openid-connect/token" + if tokenUrl == "" { + return ctrlerrors.BlockedErrorf("identity Realm %s has no issuerUrl yet", realm.Name) + } + + adminClient, err := h.createIdentityClient(ctx, obj, realm, util.AdminClientName) + if err != nil { + return errors.Wrap(err, "failed to create identity Client") + } + obj.Status.AdminClient = eventv1.NewObservedObjectRef(adminClient) + logger.V(1).Info("identity AdminClient created/updated", "client", adminClient.Name) + + meshClient, err := h.createIdentityClient(ctx, obj, realm, util.MeshClientName) + if err != nil { + return errors.Wrap(err, "failed to create identity Client") + } + obj.Status.MeshClient = eventv1.NewObservedObjectRef(meshClient) + logger.V(1).Info("identity MeshClient created/updated", "client", meshClient.Name) + + // --- EventStore --- + + eventStore, err := h.createEventStore(ctx, obj, adminClient, tokenUrl) + if err != nil { + return errors.Wrap(err, "failed to create EventStore") + } + obj.Status.EventStore = types.ObjectRefFromObject(eventStore) + logger.V(1).Info("EventStore created/updated", "eventStore", eventStore.Name) + + // --- Routes --- + + if err := h.createCallbackRoutes(ctx, obj); err != nil { + return errors.Wrap(err, "failed to create callback Routes") + } + logger.V(1).Info("Callback Routes created/updated", "count", len(obj.Status.ProxyCallbackRoutes)) + + if obj.Spec.VoyagerApiUrl != "" { + if err := h.createVoyagerRoutes(ctx, obj); err != nil { + return errors.Wrap(err, "failed to create voyager Routes") + } + logger.V(1).Info("Voyager Routes created/updated", "count", len(obj.Status.ProxyVoyagerRoutes)) + } + + if err := h.createPublishRoute(ctx, obj); err != nil { + return errors.Wrap(err, "failed to create publish Route") + } + logger.V(1).Info("Publish Route created/updated") + + // --- Finalize status conditions --- + + if !c.AllReady() { + obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", + "One or more child resources are not yet ready")) + obj.SetCondition(condition.NewDoneProcessingCondition("Waiting for child resources")) + return nil + } + + // --- Cleanup old child resources that are no longer referenced + + deleted, err := c.CleanupAll(ctx, client.OwnedBy(obj)) + if err != nil { + return errors.Wrap(err, "failed to cleanup old child resources") + } + if deleted > 0 { + logger.V(1).Info("Cleaned up old child resources", "count", deleted) + } + + // --- Done --- + + obj.SetCondition(condition.NewReadyCondition("EventConfigProvisioned", "EventConfig has been provisioned")) + obj.SetCondition(condition.NewDoneProcessingCondition("EventConfig has been provisioned")) + + return nil +} + +func (h *EventConfigHandler) Delete(ctx context.Context, obj *eventv1.EventConfig) error { + // Child resources are cleaned up by the janitor client (ownership tracking). + // No additional manual cleanup needed. + return nil +} + +// createIdentityClient creates an identity.Client for the event operator to authenticate with configuration backend. +func (h *EventConfigHandler) createIdentityClient(ctx context.Context, obj *eventv1.EventConfig, realm *identityv1.Realm, clientName string) (*identityv1.Client, error) { + c := cclient.ClientFromContextOrDie(ctx) + + identityClient := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{ + Name: clientName, + Namespace: obj.Namespace, + }, + } + + mutator := func() error { + if err := controllerutil.SetControllerReference(obj, identityClient, c.Scheme()); err != nil { + return errors.Wrap(err, "failed to set controller reference") + } + + identityClient.Labels = map[string]string{ + config.DomainLabelKey: "event", + } + + // Preserve existing client secret to avoid rotation on every reconciliation + var clientSecret string + if identityClient.Spec.ClientSecret != "" { + clientSecret = identityClient.Spec.ClientSecret + } else { + clientSecret = uuid.NewString() // todo: this needs to be set using secret-manager + } + + identityClient.Spec = identityv1.ClientSpec{ + Realm: types.ObjectRefFromObject(realm), + ClientId: clientName, + ClientSecret: clientSecret, + } + return nil + } + + _, err := c.CreateOrUpdate(ctx, identityClient, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update identity Client %s", clientName) + } + + return identityClient, nil +} + +func (h *EventConfigHandler) createCallbackRoutes(ctx context.Context, obj *eventv1.EventConfig) error { + c := cclient.ClientFromContextOrDie(ctx) + logger := log.FromContext(ctx) + + myZone, err := util.GetZone(ctx, obj.Spec.Zone.K8s()) + if err != nil { + return errors.Wrapf(err, "failed to get zone for EventConfig's zone reference %q", obj.Spec.Zone.String()) + } + + if myZone.Status.Namespace != obj.Namespace { + return ctrlerrors.BlockedErrorf("EventConfig must be located in the correlated zone-namespace %q", myZone.Status.Namespace) + } + + otherEventConfigs := &eventv1.EventConfigList{} + err = c.List(ctx, otherEventConfigs) + if err != nil { + return errors.Wrap(err, "failed to list other EventConfigs") + } + logger.V(1).Info("Fetched other EventConfigs", "count", len(otherEventConfigs.Items)) + otherZones := make([]*adminv1.Zone, 0, len(otherEventConfigs.Items)) + + for _, other := range otherEventConfigs.Items { + if types.Equals(&other, obj) { + continue + } + zone, err := util.GetZone(ctx, other.Spec.Zone.K8s()) + if err != nil { + return errors.Wrapf(err, "failed to get zone for other EventConfig %q", other.Name) + } + otherZones = append(otherZones, zone) + } + + logger.V(1).Info("Creating proxy callback Routes for other zones", "count", len(otherZones)) + routes, err := util.CreateCallbackProxyRoutes(ctx, obj.Spec.Mesh, myZone, otherZones, util.WithOwner(obj)) + if err != nil { + return errors.Wrap(err, "failed to create callback proxy Routes") + } + logger.V(1).Info("Created proxy callback Routes", "count", len(routes)) + obj.Status.ProxyCallbackRoutes = make([]types.ObjectRef, 0, len(routes)) + obj.Status.ProxyCallbackURLs = make(map[string]string, len(routes)) + + for zoneName, route := range routes { + obj.Status.ProxyCallbackRoutes = append(obj.Status.ProxyCallbackRoutes, *types.ObjectRefFromObject(route)) + obj.Status.ProxyCallbackURLs[zoneName] = route.Spec.Downstreams[0].Url() + } + + isProxyTarget := len(obj.Status.ProxyCallbackRoutes) > 0 + myCallbackRoute, err := util.CreateCallbackRoute(ctx, myZone, util.WithOwner(obj), util.WithProxyTarget(isProxyTarget)) + if err != nil { + return errors.Wrap(err, "failed to create callback Route for own zone") + } + obj.Status.CallbackRoute = types.ObjectRefFromObject(myCallbackRoute) + obj.Status.CallbackURL = myCallbackRoute.Spec.Downstreams[0].Url() + + return nil +} + +func (h *EventConfigHandler) createPublishRoute(ctx context.Context, obj *eventv1.EventConfig) error { + + myZone, err := util.GetZone(ctx, obj.Spec.Zone.K8s()) + if err != nil { + return errors.Wrapf(err, "failed to get zone for EventConfig's zone reference %q", obj.Spec.Zone.String()) + } + + route, err := util.CreatePublishRoute(ctx, myZone, obj) + if err != nil { + return errors.Wrap(err, "failed to create publish Route") + } + obj.Status.PublishRoute = types.ObjectRefFromObject(route) + obj.Status.PublishURL = route.Spec.Downstreams[0].Url() + + return nil +} + +func (h *EventConfigHandler) createVoyagerRoutes(ctx context.Context, obj *eventv1.EventConfig) error { + c := cclient.ClientFromContextOrDie(ctx) + logger := log.FromContext(ctx) + + myZone, err := util.GetZone(ctx, obj.Spec.Zone.K8s()) + if err != nil { + return errors.Wrapf(err, "failed to get zone for EventConfig's zone reference %q", obj.Spec.Zone.String()) + } + + otherEventConfigs := &eventv1.EventConfigList{} + err = c.List(ctx, otherEventConfigs) + if err != nil { + return errors.Wrap(err, "failed to list other EventConfigs") + } + logger.V(1).Info("Fetched other EventConfigs for voyager Routes", "count", len(otherEventConfigs.Items)) + otherZones := make([]*adminv1.Zone, 0, len(otherEventConfigs.Items)) + + for _, other := range otherEventConfigs.Items { + if types.Equals(&other, obj) { + continue + } + zone, err := util.GetZone(ctx, other.Spec.Zone.K8s()) + if err != nil { + return errors.Wrapf(err, "failed to get zone for other EventConfig %q", other.Name) + } + otherZones = append(otherZones, zone) + } + + logger.V(1).Info("Creating proxy voyager Routes for other zones", "count", len(otherZones)) + routes, err := util.CreateVoyagerProxyRoutes(ctx, obj.Spec.Mesh, myZone, otherZones, util.WithOwner(obj)) + if err != nil { + return errors.Wrap(err, "failed to create voyager proxy Routes") + } + logger.V(1).Info("Created proxy voyager Routes", "count", len(routes)) + obj.Status.ProxyVoyagerRoutes = make([]types.ObjectRef, 0, len(routes)) + obj.Status.ProxyVoyagerURLs = make(map[string]string, len(routes)) + + for zoneName, route := range routes { + obj.Status.ProxyVoyagerRoutes = append(obj.Status.ProxyVoyagerRoutes, *types.ObjectRefFromObject(route)) + obj.Status.ProxyVoyagerURLs[zoneName] = route.Spec.Downstreams[0].Url() + } + + isProxyTarget := len(obj.Status.ProxyVoyagerRoutes) > 0 + myVoyagerRoute, err := util.CreateVoyagerRoute(ctx, myZone, obj, util.WithOwner(obj), util.WithProxyTarget(isProxyTarget)) + if err != nil { + return errors.Wrap(err, "failed to create voyager Route for own zone") + } + obj.Status.VoyagerRoute = types.ObjectRefFromObject(myVoyagerRoute) + obj.Status.VoyagerURL = myVoyagerRoute.Spec.Downstreams[0].Url() + + return nil +} + +// createEventStore creates a pubsub.EventStore with resolved configuration backend connection details. +func (h *EventConfigHandler) createEventStore(ctx context.Context, obj *eventv1.EventConfig, identityClient *identityv1.Client, tokenUrl string) (*pubsubv1.EventStore, error) { + c := cclient.ClientFromContextOrDie(ctx) + + eventStoreName := obj.Name + eventStore := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: eventStoreName, + Namespace: obj.Namespace, + }, + } + + mutator := func() error { + if err := controllerutil.SetControllerReference(obj, eventStore, c.Scheme()); err != nil { + return errors.Wrap(err, "failed to set controller reference") + } + + eventStore.Spec = pubsubv1.EventStoreSpec{ + Url: obj.Spec.Admin.Url, + TokenUrl: tokenUrl, + ClientId: identityClient.Spec.ClientId, + ClientSecret: identityClient.Spec.ClientSecret, + } + return nil + } + + _, err := c.CreateOrUpdate(ctx, eventStore, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update EventStore %s", eventStoreName) + } + + return eventStore, nil +} diff --git a/event/internal/handler/eventconfig/handler_test.go b/event/internal/handler/eventconfig/handler_test.go new file mode 100644 index 00000000..f9de05b8 --- /dev/null +++ b/event/internal/handler/eventconfig/handler_test.go @@ -0,0 +1,531 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventconfig_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + pkgerrors "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventconfig" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func isBlockedError(err error) bool { + for e := err; e != nil; e = pkgerrors.Unwrap(e) { + if be, ok := e.(ctrlerrors.BlockedError); ok && be.IsBlocked() { + return true + } + } + cause := pkgerrors.Cause(err) + if be, ok := cause.(ctrlerrors.BlockedError); ok && be.IsBlocked() { + return true + } + return false +} + +func newEventConfig() *eventv1.EventConfig { + return &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventconfig", + Namespace: "default", + UID: "test-uid-1234", + }, + Spec: eventv1.EventConfigSpec{ + Zone: ctypes.ObjectRef{ + Name: "test-zone", + Namespace: "default", + }, + Admin: eventv1.AdminConfig{ + Url: "https://admin.example.com", + Realm: ctypes.ObjectRef{ + Name: "test-realm", + Namespace: "default", + }, + }, + ServerSendEventUrl: "https://sse.example.com", + PublishEventUrl: "http://publish.internal:8080/publish", + VoyagerApiUrl: "http://voyager.internal:8080/voyager", + Mesh: eventv1.MeshConfig{ + FullMesh: false, + }, + }, + } +} + +var ( + realmKey = k8stypes.NamespacedName{Name: "test-realm", Namespace: "default"} + zoneKey = k8stypes.NamespacedName{Name: "test-zone", Namespace: "default"} + gwRealmKey = k8stypes.NamespacedName{Name: "gw-realm", Namespace: "default"} +) + +func makeReadyRealm() *identityv1.Realm { + r := &identityv1.Realm{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-realm", + Namespace: "default", + }, + Status: identityv1.RealmStatus{ + IssuerUrl: "https://issuer.example.com", + }, + } + return r +} + +func makeReadyZone() *adminv1.Zone { + z := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-zone", + Namespace: "default", + }, + Status: adminv1.ZoneStatus{ + Namespace: "default", + GatewayRealm: &ctypes.ObjectRef{ + Name: "gw-realm", + Namespace: "default", + }, + }, + } + meta.SetStatusCondition(&z.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return z +} + +func makeReadyGatewayRealm() *gatewayv1.Realm { + r := &gatewayv1.Realm{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw-realm", + Namespace: "default", + }, + Spec: gatewayv1.RealmSpec{ + Url: "https://gateway.example.com:443", + IssuerUrl: "https://issuer.example.com", + }, + } + meta.SetStatusCondition(&r.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return r +} + +// buildScheme creates a runtime.Scheme with all types needed by the handler. +func buildScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = eventv1.AddToScheme(s) + _ = gatewayv1.AddToScheme(s) + return s +} + +var _ = Describe("EventConfigHandler", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + h *eventconfig.EventConfigHandler + obj *eventv1.EventConfig + testScheme *runtime.Scheme + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + h = &eventconfig.EventConfigHandler{} + obj = newEventConfig() + testScheme = buildScheme() + }) + + // mockGetRealm sets up a mock for c.Get on the identity realm key. + mockGetRealm := func(realm *identityv1.Realm) { + fakeClient.EXPECT(). + Get(ctx, realmKey, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Realm) = *realm + }). + Return(nil).Once() + } + + // mockGetRealmError sets up a mock for c.Get on the identity realm key that returns an error. + mockGetRealmError := func(err error) { + fakeClient.EXPECT(). + Get(ctx, realmKey, mock.AnythingOfType("*v1.Realm")). + Return(err).Once() + } + + // mockCreateOrUpdateClient sets up a mock for c.CreateOrUpdate on an identity Client. + mockCreateOrUpdateClient := func(result controllerutil.OperationResult, err error, times int) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Client"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Times(times) + } + + // mockCreateOrUpdateEventStore sets up a mock for c.CreateOrUpdate on an EventStore. + mockCreateOrUpdateEventStore := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.EventStore"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // mockGetZone sets up a mock for c.Get on the zone key (adminv1.Zone). + mockGetZone := func(zone *adminv1.Zone, times int) { + fakeClient.EXPECT(). + Get(ctx, zoneKey, mock.AnythingOfType("*v1.Zone")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*adminv1.Zone) = *zone + }). + Return(nil).Times(times) + } + + // mockGetZoneError sets up a mock for c.Get on the zone key that returns an error. + mockGetZoneError := func(err error) { + fakeClient.EXPECT(). + Get(ctx, zoneKey, mock.AnythingOfType("*v1.Zone")). + Return(err).Once() + } + + // mockListEventConfigs sets up a mock for c.List on EventConfigList. + mockListEventConfigs := func(items []eventv1.EventConfig, times int) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList")). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{ + Items: items, + } + }). + Return(nil).Times(times) + } + + // mockListEventConfigsError sets up a mock for c.List that returns an error. + mockListEventConfigsError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList")). + Return(err).Once() + } + + // mockGetGatewayRealm sets up a mock for c.Get on the gateway realm key. + mockGetGatewayRealm := func(realm *gatewayv1.Realm, times int) { + fakeClient.EXPECT(). + Get(ctx, gwRealmKey, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayv1.Realm) = *realm + }). + Return(nil).Times(times) + } + + // mockScheme sets up a mock for c.Scheme() used by SetControllerReference in route mutators. + mockScheme := func() { + fakeClient.EXPECT().Scheme().Return(testScheme).Maybe() + } + + // mockCreateOrUpdateCallbackRoute sets up a mock for the callback Route CreateOrUpdate. + // It populates Spec.Downstreams so the handler can read the URL. + mockCreateOrUpdateCallbackRoute := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // mockCreateOrUpdateVoyagerRoute sets up a mock for the voyager Route CreateOrUpdate. + mockCreateOrUpdateVoyagerRoute := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // mockCreateOrUpdatePublishRoute sets up a mock for the publish Route CreateOrUpdate. + mockCreateOrUpdatePublishRoute := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // setupFullHappyPath sets up all mocks needed for a full successful CreateOrUpdate run + // (up to the AllReady / CleanupAll point). + setupFullHappyPath := func() { + realm := makeReadyRealm() + zone := makeReadyZone() + gwRealm := makeReadyGatewayRealm() + + mockScheme() + mockGetRealm(realm) + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultCreated, nil) + mockGetZone(zone, 3) // callback + voyager + publish + mockListEventConfigs([]eventv1.EventConfig{}, 2) // callback + voyager + mockGetGatewayRealm(gwRealm, 3) // callback + voyager + publish + mockCreateOrUpdateCallbackRoute(controllerutil.OperationResultCreated, nil) + mockCreateOrUpdateVoyagerRoute(controllerutil.OperationResultCreated, nil) + mockCreateOrUpdatePublishRoute(controllerutil.OperationResultCreated, nil) + } + + Describe("CreateOrUpdate", func() { + + It("should return BlockedError when Realm is not found", func() { + notFoundErr := apierrors.NewNotFound( + schema.GroupResource{Group: "identity.cp.ei.telekom.de", Resource: "realms"}, + "test-realm", + ) + mockGetRealmError(notFoundErr) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("referenced identity Realm")) + Expect(isBlockedError(err)).To(BeTrue()) + }) + + It("should return error when Realm Get fails", func() { + mockGetRealmError(fmt.Errorf("connection refused")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get identity Realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return error when admin identity Client creation fails", func() { + realm := makeReadyRealm() + mockGetRealm(realm) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Client"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create identity Client")) + }) + + It("should return error when mesh identity Client creation fails", func() { + realm := makeReadyRealm() + mockGetRealm(realm) + + // First call (admin client) succeeds + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Client"), mock.Anything). + Return(controllerutil.OperationResultCreated, nil).Once() + + // Second call (mesh client) fails + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Client"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("mesh error")).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create identity Client")) + }) + + It("should return error when EventStore creation fails", func() { + realm := makeReadyRealm() + mockGetRealm(realm) + mockScheme() + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultNone, fmt.Errorf("eventstore error")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create EventStore")) + }) + + It("should return error when GetZone fails in createCallbackRoutes", func() { + realm := makeReadyRealm() + mockGetRealm(realm) + mockScheme() + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultCreated, nil) + mockGetZoneError(fmt.Errorf("zone fetch failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create callback Routes")) + }) + + It("should return BlockedError when zone namespace does not match", func() { + realm := makeReadyRealm() + zone := makeReadyZone() + zone.Status.Namespace = "wrong-ns" + + mockGetRealm(realm) + mockScheme() + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultCreated, nil) + mockGetZone(zone, 1) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("must be located in the correlated zone-namespace")) + Expect(isBlockedError(err)).To(BeTrue()) + }) + + It("should return error when List EventConfigs fails in createCallbackRoutes", func() { + realm := makeReadyRealm() + zone := makeReadyZone() + + mockGetRealm(realm) + mockScheme() + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultCreated, nil) + mockGetZone(zone, 1) + mockListEventConfigsError(fmt.Errorf("list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create callback Routes")) + }) + + It("should return error when createVoyagerRoutes GetZone fails", func() { + realm := makeReadyRealm() + zone := makeReadyZone() + gwRealm := makeReadyGatewayRealm() + + mockGetRealm(realm) + mockScheme() + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultCreated, nil) + + // Callback routes succeed: GetZone(1) + List(1) + GetGatewayRealm(1) + CreateOrUpdate Route(1) + mockGetZone(zone, 1) + mockListEventConfigs([]eventv1.EventConfig{}, 1) + mockGetGatewayRealm(gwRealm, 1) + mockCreateOrUpdateCallbackRoute(controllerutil.OperationResultCreated, nil) + + // Voyager routes: GetZone fails + mockGetZoneError(fmt.Errorf("voyager zone fetch failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create voyager Routes")) + }) + + It("should return error when List EventConfigs fails in createVoyagerRoutes", func() { + realm := makeReadyRealm() + zone := makeReadyZone() + gwRealm := makeReadyGatewayRealm() + + mockGetRealm(realm) + mockScheme() + mockCreateOrUpdateClient(controllerutil.OperationResultCreated, nil, 2) + mockCreateOrUpdateEventStore(controllerutil.OperationResultCreated, nil) + + // Callback routes succeed: GetZone(1) + List(1) + GetGatewayRealm(1) + CreateOrUpdate Route(1) + mockGetZone(zone, 2) // callback + voyager + mockListEventConfigs([]eventv1.EventConfig{}, 1) + mockGetGatewayRealm(gwRealm, 1) + mockCreateOrUpdateCallbackRoute(controllerutil.OperationResultCreated, nil) + + // Voyager routes: List fails + mockListEventConfigsError(fmt.Errorf("voyager list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create voyager Routes")) + }) + + It("should set NotReady condition when not all children are ready", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(false).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ChildResourcesNotReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should return error when CleanupAll fails", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(true).Once() + fakeClient.EXPECT().CleanupAll(ctx, mock.Anything).Return(0, fmt.Errorf("cleanup error")).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to cleanup old child resources")) + }) + + It("should set Ready condition when all children are ready and cleanup succeeds", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(true).Once() + fakeClient.EXPECT().CleanupAll(ctx, mock.Anything).Return(0, nil).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(readyCond.Reason).To(Equal("EventConfigProvisioned")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + }) + + Describe("Delete", func() { + It("should always return nil", func() { + err := h.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/event/internal/handler/eventconfig/suite_test.go b/event/internal/handler/eventconfig/suite_test.go new file mode 100644 index 00000000..74532f02 --- /dev/null +++ b/event/internal/handler/eventconfig/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventconfig_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEventConfigHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventConfig Handler Suite") +} diff --git a/event/internal/handler/eventexposure/handler.go b/event/internal/handler/eventexposure/handler.go new file mode 100644 index 00000000..f98f6cbc --- /dev/null +++ b/event/internal/handler/eventexposure/handler.go @@ -0,0 +1,246 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventexposure + +import ( + "context" + + "github.com/pkg/errors" + applicationv1 "github.com/telekom/controlplane/application/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/handler" + "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var _ handler.Handler[*eventv1.EventExposure] = &EventExposureHandler{} + +type EventExposureHandler struct{} + +func (h *EventExposureHandler) CreateOrUpdate(ctx context.Context, obj *eventv1.EventExposure) error { + logger := log.FromContext(ctx) + + found, eventType, err := util.FindActiveEventType(ctx, obj.Spec.EventType) + if err != nil { + return err + } + if !found { + obj.Status.Active = false + obj.SetCondition(condition.NewNotReadyCondition("EventTypeNotFound", + "No active EventType found for type "+obj.Spec.EventType)) + obj.SetCondition(condition.NewBlockedCondition( + "EventType " + obj.Spec.EventType + " does not exist or is not active. " + + "EventExposure will be automatically processed when the EventType is registered")) + return nil + } + + existingExposures, err := util.FindEventExposures(ctx, obj.Spec.EventType) + if err != nil { + return errors.Wrapf(err, "failed to list EventExposures for event type %q", obj.Spec.EventType) + } + existingFound, existingExposure, err := util.FindActiveEventExposure(existingExposures) + if err != nil { + return errors.Wrapf(err, "failed to find active EventExposure for event type %q", obj.Spec.EventType) + } + + if existingFound && existingExposure.UID != obj.UID { + // Another exposure already owns this event type + obj.Status.Active = false + obj.SetCondition(condition.NewNotReadyCondition("EventExposureAlreadyExists", + "Event type "+obj.Spec.EventType+" is already exposed by "+existingExposure.Name)) + obj.SetCondition(condition.NewBlockedCondition( + "Event already exposed by " + existingExposure.Name + ". " + + "Only one active EventExposure per event type is allowed")) + return nil + } + + // This exposure is the active one (either no existing, or we are the existing one) + obj.Status.Active = true + + // TODO: Validate category — check if the provider's team category allows exposure of this event category + + zone, err := util.GetZone(ctx, obj.Spec.Zone.K8s()) + if err != nil { + return err + } + + eventConfig, err := util.GetEventConfigForZone(ctx, obj.Spec.Zone.Name) + if err != nil { + return err + } + obj.Status.CallbackURL = eventConfig.Status.CallbackURL + + eventStore, err := util.GetEventStoreForZone(ctx, obj.Spec.Zone.Name) + if err != nil { + return err + } + + application, err := util.GetApplication(ctx, obj.Spec.Provider.ObjectRef) + if err != nil { + return errors.Wrap(err, "failed to get application") + } + + publisher, err := h.createPublisher(ctx, obj, eventType, eventStore, application) + if err != nil { + return errors.Wrap(err, "failed to create Publisher") + } + obj.Status.Publisher = types.ObjectRefFromObject(publisher) + logger.V(1).Info("Publisher created/updated", "publisher", publisher.Name) + + // --- SSE Route management --- + + crossZones, err := util.FindCrossZoneSSESubscriptionZones(ctx, obj.Spec.EventType, obj.Spec.Zone.Name) + if err != nil { + return errors.Wrap(err, "failed to find cross-zone SSE subscriptions") + } + + obj.Status.ProxyRoutes = nil + obj.Status.SseURLs = make(map[string]string) + for _, subscriberZoneRef := range crossZones { + subscriberZone, err := util.GetZone(ctx, subscriberZoneRef.K8s()) + if err != nil { + return errors.Wrapf(err, "failed to get subscriber zone %q", subscriberZoneRef.Name) + } + + proxyRoute, err := util.CreateSSEProxyRoute(ctx, obj.Spec.EventType, eventConfig, subscriberZone, zone) + if err != nil { + return errors.Wrapf(err, "failed to create SSE proxy Route for zone %q", subscriberZoneRef.Name) + } + obj.Status.ProxyRoutes = append(obj.Status.ProxyRoutes, *types.ObjectRefFromObject(proxyRoute)) + obj.Status.SseURLs[subscriberZoneRef.Name] = proxyRoute.Spec.Downstreams[0].Url() + logger.V(1).Info("SSE proxy Route created/updated", "zone", subscriberZoneRef.Name, "route", proxyRoute.Name) + } + + isProxyTarget := len(obj.Status.ProxyRoutes) > 0 + route, err := util.CreateSSERoute(ctx, obj.Spec.EventType, zone, eventConfig, isProxyTarget) + if err != nil { + return errors.Wrap(err, "failed to create SSE Route") + } + obj.Status.Route = types.ObjectRefFromObject(route) + obj.Status.SseURLs[zone.Name] = route.Spec.Downstreams[0].Url() + + deleted, err := util.CleanupOldSSERoutes(ctx, obj.Spec.EventType) + if err != nil { + return errors.Wrap(err, "failed to cleanup old SSE Routes") + } + if deleted > 0 { + logger.V(1).Info("Cleaned up stale SSE Routes", "deleted", deleted) + } + + // 9. Set final conditions + c := cclient.ClientFromContextOrDie(ctx) + if !c.AllReady() { + obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", + "One or more child resources are not yet ready")) + obj.SetCondition(condition.NewDoneProcessingCondition("Waiting for child resources")) + return nil + } + + obj.SetCondition(condition.NewReadyCondition("EventExposureProvisioned", + "EventExposure has been provisioned")) + obj.SetCondition(condition.NewDoneProcessingCondition( + "EventExposure has been provisioned")) + + return nil +} + +func (h *EventExposureHandler) Delete(ctx context.Context, obj *eventv1.EventExposure) error { + logger := log.FromContext(ctx) + + // Publisher is cleaned up automatically via ownerRef (SetControllerReference). + + // Check if another EventExposure exists for the same event type. + // If so, skip Route deletion — the other exposure will take over. + otherExists, err := util.AnyOtherEventExposureExists(ctx, obj.Spec.EventType, obj.UID) + if err != nil { + return errors.Wrap(err, "failed to check for other EventExposures") + } + + if otherExists { + // Another exposure exists — it will take over the Route via + // MapEventExposureToEventExposure watch + re-reconciliation. + logger.Info("Skipping Route deletion — another EventExposure exists for this event type", + "eventType", obj.Spec.EventType) + return nil + } + + if obj.Status.Publisher != nil { + c := cclient.ClientFromContextOrDie(ctx) + publisher := &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: obj.Status.Publisher.Name, + Namespace: obj.Status.Publisher.Namespace, + }, + } + err = c.Delete(ctx, publisher) + if err != nil && !apierrors.IsNotFound(err) { + return errors.Wrapf(err, "failed to delete Publisher %q", obj.Status.Publisher.String()) + } + logger.Info("Deleted Publisher", "publisher", obj.Status.Publisher.String()) + } + + // Last exposure for this event type — clean up the Route and proxy Routes. + if obj.Status.Route != nil { + if err := util.DeleteRouteIfExists(ctx, obj.Status.Route); err != nil { + return errors.Wrap(err, "failed to delete SSE Route") + } + logger.Info("Deleted SSE Route", "route", obj.Status.Route.String()) + } + + for i := range obj.Status.ProxyRoutes { + ref := &obj.Status.ProxyRoutes[i] + if err := util.DeleteRouteIfExists(ctx, ref); err != nil { + return errors.Wrapf(err, "failed to delete SSE proxy Route %q", ref.String()) + } + logger.Info("Deleted SSE proxy Route", "route", ref.String()) + } + + return nil +} + +// createPublisher creates a pubsub.Publisher child resource for this EventExposure. +func (h *EventExposureHandler) createPublisher(ctx context.Context, obj *eventv1.EventExposure, eventType *eventv1.EventType, eventStore *pubsubv1.EventStore, application *applicationv1.Application) (*pubsubv1.Publisher, error) { + c := cclient.ClientFromContextOrDie(ctx) + + publisher := &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: labelutil.NormalizeNameValue(obj.Spec.EventType), + Namespace: eventStore.Namespace, // zone namespace + }, + } + publisherId := application.Status.ClientId + + mutator := func() error { + publisher.Labels = map[string]string{ + config.BuildLabelKey("application"): labelutil.NormalizeLabelValue(application.Name), + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(obj.Spec.EventType), + config.BuildLabelKey("zone"): obj.Spec.Zone.Name, + } + + publisher.Spec = pubsubv1.PublisherSpec{ + EventStore: *types.ObjectRefFromObject(eventStore), + EventType: obj.Spec.EventType, + PublisherId: publisherId, + AdditionalPublisherIds: obj.Spec.AdditionalPublisherIds, + JsonSchema: eventType.Spec.Specification, + } + return nil + } + + _, err := c.CreateOrUpdate(ctx, publisher, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update Publisher %q", obj.Name) + } + + return publisher, nil +} diff --git a/event/internal/handler/eventexposure/handler_test.go b/event/internal/handler/eventexposure/handler_test.go new file mode 100644 index 00000000..d941bf36 --- /dev/null +++ b/event/internal/handler/eventexposure/handler_test.go @@ -0,0 +1,758 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventexposure_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + pkgerrors "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + applicationv1 "github.com/telekom/controlplane/application/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventexposure" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func isBlockedError(err error) bool { + for e := err; e != nil; e = pkgerrors.Unwrap(e) { + if be, ok := e.(ctrlerrors.BlockedError); ok && be.IsBlocked() { + return true + } + } + cause := pkgerrors.Cause(err) + if be, ok := cause.(ctrlerrors.BlockedError); ok && be.IsBlocked() { + return true + } + return false +} + +func newEventExposure(name, eventType string) *eventv1.EventExposure { + return &eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + UID: "test-uid", + }, + Spec: eventv1.EventExposureSpec{ + EventType: eventType, + Visibility: eventv1.VisibilityEnterprise, + Zone: ctypes.ObjectRef{Name: "test-zone", Namespace: "default"}, + Provider: ctypes.TypedObjectRef{ObjectRef: ctypes.ObjectRef{Name: "test-app", Namespace: "default"}}, + }, + } +} + +func makeReadyEventType(eventType string) eventv1.EventType { + et := eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{ + Name: eventv1.MakeEventTypeName(eventType), + Namespace: "default", + }, + Spec: eventv1.EventTypeSpec{ + Type: eventType, + Version: "1.0.0", + }, + Status: eventv1.EventTypeStatus{ + Active: true, + }, + } + meta.SetStatusCondition(&et.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return et +} + +func makeReadyZone() *adminv1.Zone { + z := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-zone", + Namespace: "default", + }, + Status: adminv1.ZoneStatus{ + Namespace: "default", + GatewayRealm: &ctypes.ObjectRef{ + Name: "gw-realm", + Namespace: "default", + }, + }, + } + meta.SetStatusCondition(&z.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return z +} + +func makeReadyEventConfig() eventv1.EventConfig { + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventconfig", + Namespace: "default", + }, + Spec: eventv1.EventConfigSpec{ + Zone: ctypes.ObjectRef{Name: "test-zone", Namespace: "default"}, + Admin: eventv1.AdminConfig{ + Url: "https://admin.example.com", + Realm: ctypes.ObjectRef{Name: "test-realm", Namespace: "default"}, + }, + ServerSendEventUrl: "https://sse.example.com", + PublishEventUrl: "http://publish.internal:8080/publish", + }, + Status: eventv1.EventConfigStatus{ + EventStore: &ctypes.ObjectRef{ + Name: "test-eventstore", + Namespace: "default", + }, + CallbackURL: "https://callback.example.com/test-zone/callback/v1", + }, + } + meta.SetStatusCondition(&ec.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return ec +} + +func makeReadyEventStore() *pubsubv1.EventStore { + es := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + Spec: pubsubv1.EventStoreSpec{ + Url: "https://eventstore.example.com", + TokenUrl: "https://token.example.com/token", + ClientId: "es-client-id", + ClientSecret: "es-client-secret", + }, + } + meta.SetStatusCondition(&es.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return es +} + +func makeReadyApplication() *applicationv1.Application { + app := &applicationv1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "default", + }, + Status: applicationv1.ApplicationStatus{ + ClientId: "app-client-id", + ClientSecret: "app-client-secret", + }, + } + meta.SetStatusCondition(&app.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return app +} + +func makeReadyGatewayRealm() *gatewayv1.Realm { + r := &gatewayv1.Realm{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw-realm", + Namespace: "default", + }, + Spec: gatewayv1.RealmSpec{ + Url: "https://gateway.example.com:443", + IssuerUrl: "https://issuer.example.com", + }, + } + meta.SetStatusCondition(&r.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return r +} + +var ( + zoneKey = k8stypes.NamespacedName{Name: "test-zone", Namespace: "default"} + eventStoreKey = k8stypes.NamespacedName{Name: "test-eventstore", Namespace: "default"} + appKey = k8stypes.NamespacedName{Name: "test-app", Namespace: "default"} + gwRealmKey = k8stypes.NamespacedName{Name: "gw-realm", Namespace: "default"} +) + +var _ = Describe("EventExposureHandler", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + h *eventexposure.EventExposureHandler + obj *eventv1.EventExposure + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + h = &eventexposure.EventExposureHandler{} + obj = newEventExposure("test-exposure", "de.telekom.eni.quickstart.v1") + }) + + // --- mock helpers --- + + // mockListEventTypes sets up a mock for c.List on EventTypeList (no ListOption args). + mockListEventTypes := func(items []eventv1.EventType) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventTypeList")). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{Items: items} + }). + Return(nil).Once() + } + + mockListEventTypesError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventTypeList")). + Return(err).Once() + } + + // mockListEventExposures sets up a mock for c.List on EventExposureList (with MatchingLabels). + mockListEventExposures := func(items []eventv1.EventExposure) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventExposureList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventExposureList) = eventv1.EventExposureList{Items: items} + }). + Return(nil).Once() + } + + mockListEventExposuresError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventExposureList"), mock.Anything). + Return(err).Once() + } + + // mockGetZone sets up a mock for c.Get on the zone key (adminv1.Zone). + mockGetZone := func(zone *adminv1.Zone) { + fakeClient.EXPECT(). + Get(ctx, zoneKey, mock.AnythingOfType("*v1.Zone")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*adminv1.Zone) = *zone + }). + Return(nil).Once() + } + + mockGetZoneError := func(err error) { + fakeClient.EXPECT(). + Get(ctx, zoneKey, mock.AnythingOfType("*v1.Zone")). + Return(err).Once() + } + + // mockListEventConfigs sets up a mock for c.List on EventConfigList (with MatchingFields). + // times controls how many times this mock is expected (GetEventConfigForZone + GetEventStoreForZone both call it). + mockListEventConfigs := func(items []eventv1.EventConfig, times int) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: items} + }). + Return(nil).Times(times) + } + + mockListEventConfigsError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Return(err).Once() + } + + // mockGetEventStore sets up a mock for c.Get on the EventStore. + mockGetEventStore := func(es *pubsubv1.EventStore) { + fakeClient.EXPECT(). + Get(ctx, eventStoreKey, mock.AnythingOfType("*v1.EventStore")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *es + }). + Return(nil).Once() + } + + mockGetEventStoreError := func(err error) { + fakeClient.EXPECT(). + Get(ctx, eventStoreKey, mock.AnythingOfType("*v1.EventStore")). + Return(err).Once() + } + + // mockGetApplication sets up a mock for c.Get on the Application. + mockGetApplication := func(app *applicationv1.Application) { + fakeClient.EXPECT(). + Get(ctx, appKey, mock.AnythingOfType("*v1.Application")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*applicationv1.Application) = *app + }). + Return(nil).Once() + } + + mockGetApplicationError := func(err error) { + fakeClient.EXPECT(). + Get(ctx, appKey, mock.AnythingOfType("*v1.Application")). + Return(err).Once() + } + + // mockCreateOrUpdatePublisher sets up a mock for c.CreateOrUpdate on a Publisher. + mockCreateOrUpdatePublisher := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Publisher"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // mockListEventSubscriptions sets up a mock for c.List on EventSubscriptionList. + mockListEventSubscriptions := func(items []eventv1.EventSubscription) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventSubscriptionList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: items} + }). + Return(nil).Once() + } + + mockListEventSubscriptionsError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventSubscriptionList"), mock.Anything). + Return(err).Once() + } + + // mockGetGatewayRealm sets up a mock for c.Get on the gateway Realm. + mockGetGatewayRealm := func(realm *gatewayv1.Realm) { + fakeClient.EXPECT(). + Get(ctx, gwRealmKey, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayv1.Realm) = *realm + }). + Return(nil).Once() + } + + mockGetGatewayRealmError := func(err error) { + fakeClient.EXPECT(). + Get(ctx, gwRealmKey, mock.AnythingOfType("*v1.Realm")). + Return(err).Once() + } + + // mockCreateOrUpdateRoute sets up a mock for c.CreateOrUpdate on a Route. + mockCreateOrUpdateRoute := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // mockCleanup sets up a mock for c.Cleanup on a RouteList. + mockCleanup := func(deleted int, err error) { + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.RouteList"), mock.Anything). + Return(deleted, err).Once() + } + + // setupFullHappyPath sets up all mocks needed for a full successful CreateOrUpdate + // run with no cross-zone SSE subscriptions. + setupFullHappyPath := func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + ec := makeReadyEventConfig() + es := makeReadyEventStore() + app := makeReadyApplication() + gwRealm := makeReadyGatewayRealm() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + mockListEventConfigs([]eventv1.EventConfig{ec}, 2) // GetEventConfigForZone + GetEventStoreForZone + mockGetEventStore(es) + mockGetApplication(app) + mockCreateOrUpdatePublisher(controllerutil.OperationResultCreated, nil) + mockListEventSubscriptions([]eventv1.EventSubscription{}) // no cross-zone SSE + mockGetGatewayRealm(gwRealm) // for CreateSSERoute + mockCreateOrUpdateRoute(controllerutil.OperationResultCreated, nil) + mockCleanup(0, nil) + } + + Describe("CreateOrUpdate", func() { + + It("should return error when FindActiveEventType fails", func() { + mockListEventTypesError(fmt.Errorf("connection refused")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list EventTypes")) + }) + + It("should set NotReady when no active EventType found", func() { + mockListEventTypes([]eventv1.EventType{}) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeFalse()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventTypeNotFound")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should return error when FindEventExposures fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposuresError(fmt.Errorf("list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list EventExposures")) + }) + + It("should set NotReady when another active EventExposure already exists", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + mockListEventTypes([]eventv1.EventType{et}) + + existingExposure := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-exposure", + Namespace: "default", + UID: "other-uid", + }, + Spec: eventv1.EventExposureSpec{ + EventType: "de.telekom.eni.quickstart.v1", + }, + Status: eventv1.EventExposureStatus{ + Active: true, + }, + } + meta.SetStatusCondition(&existingExposure.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + mockListEventExposures([]eventv1.EventExposure{existingExposure}) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeFalse()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventExposureAlreadyExists")) + }) + + It("should return error when GetZone fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZoneError(fmt.Errorf("zone fetch failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("zone")) + }) + + It("should return error when GetEventConfigForZone fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + mockListEventConfigsError(fmt.Errorf("list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list EventConfigs")) + }) + + It("should return error when GetEventStoreForZone fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + ec := makeReadyEventConfig() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + // First call succeeds (GetEventConfigForZone in handler line 78) + // Second call also succeeds (GetEventConfigForZone inside GetEventStoreForZone) + mockListEventConfigs([]eventv1.EventConfig{ec}, 2) + mockGetEventStoreError(fmt.Errorf("eventstore fetch failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("eventstore fetch failed")) + }) + + It("should return error when GetApplication fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + ec := makeReadyEventConfig() + es := makeReadyEventStore() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + mockListEventConfigs([]eventv1.EventConfig{ec}, 2) + mockGetEventStore(es) + mockGetApplicationError(fmt.Errorf("app fetch failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get application")) + }) + + It("should return error when createPublisher fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + ec := makeReadyEventConfig() + es := makeReadyEventStore() + app := makeReadyApplication() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + mockListEventConfigs([]eventv1.EventConfig{ec}, 2) + mockGetEventStore(es) + mockGetApplication(app) + mockCreateOrUpdatePublisher(controllerutil.OperationResultNone, fmt.Errorf("publisher create failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create Publisher")) + }) + + It("should return error when FindCrossZoneSSESubscriptionZones fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + ec := makeReadyEventConfig() + es := makeReadyEventStore() + app := makeReadyApplication() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + mockListEventConfigs([]eventv1.EventConfig{ec}, 2) + mockGetEventStore(es) + mockGetApplication(app) + mockCreateOrUpdatePublisher(controllerutil.OperationResultCreated, nil) + mockListEventSubscriptionsError(fmt.Errorf("subscription list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to find cross-zone SSE subscriptions")) + }) + + It("should return error when CreateSSERoute fails", func() { + et := makeReadyEventType("de.telekom.eni.quickstart.v1") + zone := makeReadyZone() + ec := makeReadyEventConfig() + es := makeReadyEventStore() + app := makeReadyApplication() + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockGetZone(zone) + mockListEventConfigs([]eventv1.EventConfig{ec}, 2) + mockGetEventStore(es) + mockGetApplication(app) + mockCreateOrUpdatePublisher(controllerutil.OperationResultCreated, nil) + mockListEventSubscriptions([]eventv1.EventSubscription{}) // no cross-zone + mockGetGatewayRealmError(fmt.Errorf("realm fetch failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create SSE Route")) + }) + + It("should set NotReady when AllReady is false", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(false).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ChildResourcesNotReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should set Ready condition when all children ready", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(true).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(readyCond.Reason).To(Equal("EventExposureProvisioned")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + }) + + Describe("Delete", func() { + + It("should return error when AnyOtherEventExposureExists fails", func() { + mockListEventExposuresError(fmt.Errorf("list failed")) + + err := h.Delete(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to check for other EventExposures")) + }) + + It("should skip cleanup when another EventExposure exists", func() { + otherExposure := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-exposure", + Namespace: "default", + UID: "other-uid", + }, + Spec: eventv1.EventExposureSpec{ + EventType: "de.telekom.eni.quickstart.v1", + }, + } + mockListEventExposures([]eventv1.EventExposure{otherExposure}) + + err := h.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + // No further mock calls expected (no Delete calls) + }) + + It("should delete Publisher and Routes when no other exposure exists", func() { + obj.Status.Publisher = &ctypes.ObjectRef{Name: "test-publisher", Namespace: "default"} + obj.Status.Route = &ctypes.ObjectRef{Name: "test-route", Namespace: "default"} + obj.Status.ProxyRoutes = []ctypes.ObjectRef{ + {Name: "proxy-route-1", Namespace: "default"}, + } + + mockListEventExposures([]eventv1.EventExposure{}) + + // Delete Publisher + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Publisher")). + Return(nil).Once() + + // DeleteRouteIfExists for Route: Get then Delete + routeKey := k8stypes.NamespacedName{Name: "test-route", Namespace: "default"} + fakeClient.EXPECT(). + Get(ctx, routeKey, mock.AnythingOfType("*v1.Route")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + route := out.(*gatewayv1.Route) + route.Name = "test-route" + route.Namespace = "default" + }). + Return(nil).Once() + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Route")). + Return(nil).Once() + + // DeleteRouteIfExists for ProxyRoute: Get then Delete + proxyRouteKey := k8stypes.NamespacedName{Name: "proxy-route-1", Namespace: "default"} + fakeClient.EXPECT(). + Get(ctx, proxyRouteKey, mock.AnythingOfType("*v1.Route")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + route := out.(*gatewayv1.Route) + route.Name = "proxy-route-1" + route.Namespace = "default" + }). + Return(nil).Once() + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Route")). + Return(nil).Once() + + err := h.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should tolerate NotFound on Publisher delete", func() { + obj.Status.Publisher = &ctypes.ObjectRef{Name: "test-publisher", Namespace: "default"} + + mockListEventExposures([]eventv1.EventExposure{}) + + notFoundErr := apierrors.NewNotFound( + schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "publishers"}, + "test-publisher", + ) + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Publisher")). + Return(notFoundErr).Once() + + err := h.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return error when Publisher delete fails", func() { + obj.Status.Publisher = &ctypes.ObjectRef{Name: "test-publisher", Namespace: "default"} + + mockListEventExposures([]eventv1.EventExposure{}) + + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Publisher")). + Return(fmt.Errorf("delete failed")).Once() + + err := h.Delete(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to delete Publisher")) + }) + }) +}) diff --git a/event/internal/handler/eventexposure/suite_test.go b/event/internal/handler/eventexposure/suite_test.go new file mode 100644 index 00000000..38f954cc --- /dev/null +++ b/event/internal/handler/eventexposure/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventexposure_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEventExposureHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventExposure Handler Suite") +} diff --git a/event/internal/handler/eventsubscription/handler.go b/event/internal/handler/eventsubscription/handler.go new file mode 100644 index 00000000..1115e1c5 --- /dev/null +++ b/event/internal/handler/eventsubscription/handler.go @@ -0,0 +1,377 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventsubscription + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + applicationv1 "github.com/telekom/controlplane/application/api/v1" + approvalapi "github.com/telekom/controlplane/approval/api/v1" + "github.com/telekom/controlplane/approval/api/v1/builder" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + "github.com/telekom/controlplane/common/pkg/handler" + "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var _ handler.Handler[*eventv1.EventSubscription] = &EventSubscriptionHandler{} + +type EventSubscriptionHandler struct{} + +func (h *EventSubscriptionHandler) CreateOrUpdate(ctx context.Context, obj *eventv1.EventSubscription) error { + logger := log.FromContext(ctx) + c := cclient.ClientFromContextOrDie(ctx) + + found, _, err := util.FindActiveEventType(ctx, obj.Spec.EventType) + if err != nil { + return err + } + if !found { + obj.SetCondition(condition.NewNotReadyCondition("EventTypeNotFound", + "No active EventType found for type "+obj.Spec.EventType)) + obj.SetCondition(condition.NewBlockedCondition( + "EventType " + obj.Spec.EventType + " does not exist or is not active. " + + "EventSubscription will be automatically processed when the EventType is registered")) + return nil + } + + exposures, err := util.FindEventExposures(ctx, obj.Spec.EventType) + if err != nil { + return err + } + + if len(exposures) == 0 { + // no exposure found, cleanup + deleted, err := c.Cleanup(ctx, &pubsubv1.SubscriberList{}, cclient.OwnedBy(obj)) + if err != nil { + return errors.Wrapf(err, "unable to cleanup Subscriber for EventSubscription %q in namespace %q", + obj.Name, obj.Namespace) + } + logger.Info("No EventExposure found for event type — cleaned up Subscriber resources", "deleted", deleted) + } + + exposureFound, exposure, err := util.FindActiveEventExposure(exposures) + if err != nil { + return errors.Wrapf(err, "failed to find active EventExposure for event type %q", obj.Spec.EventType) + } + + if !exposureFound { + obj.SetCondition(condition.NewNotReadyCondition("EventExposureNotFound", + "No active EventExposure found for type "+obj.Spec.EventType)) + obj.SetCondition(condition.NewBlockedCondition( + "EventExposure for " + obj.Spec.EventType + " does not exist or is not active. " + + "EventSubscription will be automatically processed when the EventExposure is registered")) + return nil + } + + // TODO: Validate category — check if the subscriber's team category allows subscription of this event category + + // Validate visibility — check if subscription zone is compatible with exposure visibility + valid, err := EventVisibilityMustBeValid(ctx, exposure, obj) + if err != nil { + return errors.Wrap(err, "failed to validate event visibility for EventSubscription") + } + if !valid { + obj.SetCondition(condition.NewNotReadyCondition("VisibilityConstraintViolation", "EventExposure and EventSubscription visibility combination is not allowed")) + return ctrlerrors.BlockedErrorf("EventSubscription is blocked. Subscriptions from zone %q are not allowed due to exposure visibility constraints", obj.Spec.Zone.GetName()) + } + + // Validate scopes — check if requested scopes are a subset of exposure scopes + valid, err = EventScopesMustBeValid(ctx, exposure, obj) + if err != nil { + return errors.Wrap(err, "failed to validate event scopes for EventSubscription") + } + if !valid { + obj.SetCondition(condition.NewNotReadyCondition("ScopeConstraintViolation", "Requested scopes are not allowed by the EventExposure")) + return ctrlerrors.BlockedErrorf("EventSubscription is blocked. Requested scopes %q are not allowed by the EventExposure", obj.Spec.Scopes) + } + + exposureEventConfig, err := util.GetEventConfigForZone(ctx, exposure.Spec.Zone.Name) + if err != nil { + return errors.Wrapf(err, "failed to get EventConfig for exposure zone %q", exposure.Spec.Zone.Name) + } + + if !exposureEventConfig.SupportsZone(obj.Spec.Zone.Name) { + obj.SetCondition(condition.NewNotReadyCondition("ZoneNotSupported", + fmt.Sprintf("EventConfig for zone %q does not support this subscription zone", exposure.Spec.Zone.Name))) + obj.SetCondition(condition.NewBlockedCondition( + fmt.Sprintf("EventConfig for zone %q does not support this subscription zone. "+ + "EventSubscription will be automatically processed when an EventConfig that supports the subscription zone is registered", + exposure.Spec.Zone.Name))) + + return nil + } + + subscriberEventConfig, err := util.GetEventConfigForZone(ctx, obj.Spec.Zone.Name) + if err != nil { + return errors.Wrapf(err, "failed to get EventConfig for subscription zone %q", obj.Spec.Zone.Name) + } + + if err := updateCallbackURL(ctx, exposure, obj, subscriberEventConfig); err != nil { + return errors.Wrap(err, "failed to update callback URL for EventSubscription") + } + + if obj.Spec.Requestor.Kind != "Application" { + obj.SetCondition(condition.NewNotReadyCondition("InvalidRequestor", + "Only requestors of kind 'Application' are supported")) + obj.SetCondition(condition.NewBlockedCondition( + "EventSubscription with requestor kind " + obj.Spec.Requestor.Kind + " is not supported")) + return nil + } + requestorApp, err := util.GetApplication(ctx, obj.Spec.Requestor.ObjectRef) + if err != nil { + return err + } + + providerApp, err := util.GetApplication(ctx, exposure.Spec.Provider.ObjectRef) + if err != nil { + return errors.Wrapf(err, "unable to get application from EventExposure provider %q while handling EventSubscription %q", + exposure.Spec.Provider.Name, obj.Name) + } + + requester := &approvalapi.Requester{ + TeamName: requestorApp.Spec.Team, + TeamEmail: requestorApp.Spec.TeamEmail, + ApplicationRef: types.TypedObjectRefFromObject(requestorApp, c.Scheme()), + Reason: fmt.Sprintf("Team %s requested subscription to event %s from zone %s", + requestorApp.Spec.Team, obj.Spec.EventType, obj.Spec.Zone.Name), + } + + properties := map[string]any{ + "eventType": obj.Spec.EventType, + "scopes": obj.Spec.Scopes, + } + if err := requester.SetProperties(properties); err != nil { + return errors.Wrapf(err, "unable to set approvalRequest properties for EventSubscription %q in namespace %q", + obj.Name, obj.Namespace) + } + + decider := &approvalapi.Decider{ + TeamName: providerApp.Spec.Team, + TeamEmail: providerApp.Spec.TeamEmail, + ApplicationRef: types.TypedObjectRefFromObject(providerApp, c.Scheme()), + } + + approvalBuilder := builder.NewApprovalBuilder(c, obj) + approvalBuilder.WithAction("subscribe") + approvalBuilder.WithHashValue(requester.Properties) + approvalBuilder.WithRequester(requester) + approvalBuilder.WithDecider(decider) + approvalBuilder.WithStrategy(approvalapi.ApprovalStrategy(exposure.Spec.Approval.Strategy)) + + if len(exposure.Spec.Approval.TrustedTeams) > 0 { + approvalBuilder.WithTrustedRequesters(exposure.Spec.Approval.TrustedTeams) + } + + res, err := approvalBuilder.Build(ctx) + if err != nil { + return err + } + obj.Status.ApprovalRequest = types.ObjectRefFromObject(approvalBuilder.GetApprovalRequest()) + obj.Status.Approval = types.ObjectRefFromObject(approvalBuilder.GetApproval()) + + switch res { + case builder.ApprovalResultRequestDenied: + logger.Info("ApprovalRequest was denied — not touching child resources") + obj.SetCondition(condition.NewNotReadyCondition("ApprovalRequestDenied", "ApprovalRequest has been denied")) + obj.SetCondition(condition.NewDoneProcessingCondition("ApprovalRequest has been denied")) + return nil + + case builder.ApprovalResultPending: + logger.Info("Approval is pending — waiting for approval") + obj.SetCondition(condition.NewNotReadyCondition("ApprovalPending", "Approval has not been approved")) + obj.SetCondition(condition.NewBlockedCondition("Approval has not been approved")) + return nil + + case builder.ApprovalResultDenied: + logger.Info("Approval was denied — cleaning up Subscriber") + obj.SetCondition(condition.NewNotReadyCondition("ApprovalDenied", "Approval has been denied")) + obj.SetCondition(condition.NewDoneProcessingCondition("Approval has been denied")) + + deleted, err := c.Cleanup(ctx, &pubsubv1.SubscriberList{}, cclient.OwnedBy(obj)) + if err != nil { + return errors.Wrapf(err, "unable to cleanup Subscriber for EventSubscription %q in namespace %q", + obj.Name, obj.Namespace) + } + logger.Info("Cleaned up Subscriber resources", "deleted", deleted) + return nil + + case builder.ApprovalResultGranted: + logger.Info("Approval is granted — continuing with provisioning") + + default: + return errors.Errorf("unknown approval-builder result %q", res) + } + + if exposure.Status.Publisher == nil { + obj.SetCondition(condition.NewNotReadyCondition("PublisherNotReady", + "EventExposure does not have a Publisher reference yet")) + obj.SetCondition(condition.NewBlockedCondition( + "EventExposure " + exposure.Name + " has no Publisher reference. " + + "EventSubscription will be automatically processed when the Publisher is created")) + return nil + } + + subscriber, err := h.createSubscriber(ctx, obj, requestorApp, exposure) + if err != nil { + return errors.Wrap(err, "failed to create Subscriber") + } + obj.Status.Subscriber = types.ObjectRefFromObject(subscriber) + + if obj.Spec.Delivery.Type == eventv1.DeliveryTypeServerSentEvent { + obj.Status.URL = exposure.Status.SseURLs[obj.Spec.Zone.Name] + } + + logger.V(1).Info("Subscriber created/updated", "subscriber", subscriber.Name) + + if !c.AllReady() { + obj.SetCondition(condition.NewNotReadyCondition("ChildResourcesNotReady", + "One or more child resources are not yet ready")) + obj.SetCondition(condition.NewDoneProcessingCondition("Waiting for child resources")) + return nil + } + + obj.SetCondition(condition.NewReadyCondition("EventSubscriptionProvisioned", + "EventSubscription has been provisioned")) + obj.SetCondition(condition.NewDoneProcessingCondition( + "EventSubscription has been provisioned")) + + return nil +} + +func (h *EventSubscriptionHandler) Delete(ctx context.Context, obj *eventv1.EventSubscription) error { + // Child resources (Subscriber, ApprovalRequest) are cleaned up via owner references. + // No additional manual cleanup needed. + return nil +} + +// createSubscriber creates a pubsub.Subscriber child resource for this EventSubscription. +func (h *EventSubscriptionHandler) createSubscriber( + ctx context.Context, + obj *eventv1.EventSubscription, + application *applicationv1.Application, + exposure *eventv1.EventExposure, +) (*pubsubv1.Subscriber, error) { + c := cclient.ClientFromContextOrDie(ctx) + + subscriber := &pubsubv1.Subscriber{ + ObjectMeta: metav1.ObjectMeta{ + Name: labelutil.NormalizeNameValue(obj.Name), + Namespace: obj.Namespace, + }, + } + + mutator := func() error { + if err := controllerutil.SetControllerReference(obj, subscriber, c.Scheme()); err != nil { + return errors.Wrap(err, "failed to set controller reference") + } + + subscriber.Labels = map[string]string{ + config.BuildLabelKey("application"): labelutil.NormalizeLabelValue(application.Name), + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(obj.Spec.EventType), + config.BuildLabelKey("zone"): obj.Spec.Zone.Name, + } + + subscriber.Spec = pubsubv1.SubscriberSpec{ + Publisher: *exposure.Status.Publisher, + SubscriberId: application.Status.ClientId, + Delivery: mapDelivery(obj.Spec.Delivery), + Trigger: mapTrigger(obj.Spec.Trigger), + PublisherTrigger: mapTrigger(createPublisherTrigger(exposure, obj.Spec.Scopes)), + AppliedScopes: obj.Spec.Scopes, + } + return nil + } + + _, err := c.CreateOrUpdate(ctx, subscriber, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update Subscriber %s", obj.Name) + } + + return subscriber, nil +} + +// mapDelivery maps event domain Delivery to pubsub domain SubscriptionDelivery. +func mapDelivery(d eventv1.Delivery) pubsubv1.SubscriptionDelivery { + return pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryType(d.Type), + Payload: pubsubv1.PayloadType(d.Payload), + Callback: d.Callback, + EventRetentionTime: d.EventRetentionTime, + CircuitBreakerOptOut: d.CircuitBreakerOptOut, + RetryableStatusCodes: d.RetryableStatusCodes, + RedeliveriesPerSecond: d.RedeliveriesPerSecond, + EnforceGetHttpRequestMethodForHealthCheck: d.EnforceGetHttpRequestMethodForHealthCheck, + } +} + +// mapTrigger maps event domain EventTrigger to pubsub domain Trigger. +func mapTrigger(t *eventv1.EventTrigger) *pubsubv1.Trigger { + if t == nil { + return nil + } + + result := &pubsubv1.Trigger{} + + if t.ResponseFilter != nil { + result.ResponseFilter = &pubsubv1.ResponseFilter{ + Paths: t.ResponseFilter.Paths, + Mode: pubsubv1.ResponseFilterMode(t.ResponseFilter.Mode), + } + } + + if t.SelectionFilter != nil { + result.SelectionFilter = &pubsubv1.SelectionFilter{ + Attributes: t.SelectionFilter.Attributes, + Expression: t.SelectionFilter.Expression, + } + } + + return result +} + +// updateCallbackURL updates the callback URL in the EventSubscription spec. +// The callback request needs to be sent via the Gateway, so we always set the Gateway as direct upstream +// In the Gateway will use the Feature "DynamicUpstream" to then dynamically set the actual callback URL as upstream. +func updateCallbackURL(ctx context.Context, exposure *eventv1.EventExposure, sub *eventv1.EventSubscription, subEventCfg *eventv1.EventConfig) error { + logger := log.FromContext(ctx) + isCallback := sub.Spec.Delivery.Type == eventv1.DeliveryTypeCallback + if !isCallback { + // we only do this for callback subscriptions, so if it's not a callback subscription, we can skip this + return nil + } + isProxy := !exposure.Spec.Zone.Equals(&sub.Spec.Zone) + var rawCallbackUrl string + + if isProxy { + // If this is a proxy subscription, we set the callbackURL to the sub-zone callback in the provider-zone. + // E. g. aws --> aws-gcp-callback --> gcp-callback --> provider-callback (determined using DynamicUpstream) + var ok bool + rawCallbackUrl, ok = subEventCfg.Status.ProxyCallbackURLs[exposure.Spec.Zone.Name] + if !ok { + return ctrlerrors.BlockedErrorf("no proxy callback URL found in subscription zone's EventConfig for exposure zone %q", exposure.Spec.Zone.Name) + } + } else { + // If this is not a proxy subscription, we directly use the provider-zone callback URL as callback URL. + rawCallbackUrl = subEventCfg.Status.CallbackURL + } + + // Use rawCallbackUrl as new callback URL and add actual callback URL as query parameter so that provider can use it for callbacks. + sub.Spec.Delivery.Callback = rawCallbackUrl + "?" + util.CallbackUrlQUeryParam + "=" + sub.Spec.Delivery.Callback + logger.V(1).Info("Updated callback URL for proxy scenario", "callback", sub.Spec.Delivery.Callback) + + return nil +} diff --git a/event/internal/handler/eventsubscription/handler_test.go b/event/internal/handler/eventsubscription/handler_test.go new file mode 100644 index 00000000..6adf96b8 --- /dev/null +++ b/event/internal/handler/eventsubscription/handler_test.go @@ -0,0 +1,996 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventsubscription_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + pkgerrors "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + applicationv1 "github.com/telekom/controlplane/application/api/v1" + approvalv1 "github.com/telekom/controlplane/approval/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventsubscription" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func isBlockedError(err error) bool { + for e := err; e != nil; e = pkgerrors.Unwrap(e) { + if be, ok := e.(ctrlerrors.BlockedError); ok && be.IsBlocked() { + return true + } + } + cause := pkgerrors.Cause(err) + if be, ok := cause.(ctrlerrors.BlockedError); ok && be.IsBlocked() { + return true + } + return false +} + +// buildScheme creates a runtime.Scheme with all types needed by the handler. +func buildScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = adminv1.AddToScheme(s) + _ = eventv1.AddToScheme(s) + _ = applicationv1.AddToScheme(s) + _ = approvalv1.AddToScheme(s) + _ = pubsubv1.AddToScheme(s) + return s +} + +// --- Factory helpers --- + +func newEventSubscription(name, eventType, zoneName string) *eventv1.EventSubscription { + return &eventv1.EventSubscription{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + UID: "sub-uid-1234", + }, + Spec: eventv1.EventSubscriptionSpec{ + EventType: eventType, + Zone: ctypes.ObjectRef{Name: zoneName, Namespace: "default"}, + Requestor: ctypes.TypedObjectRef{ + TypeMeta: metav1.TypeMeta{Kind: "Application"}, + ObjectRef: ctypes.ObjectRef{Name: "requestor-app", Namespace: "default"}, + }, + Delivery: eventv1.Delivery{ + Type: eventv1.DeliveryTypeCallback, + Payload: eventv1.PayloadTypeData, + // Callback is set per-test for callback scenarios + }, + }, + } +} + +func makeReadyEventType(eventType string) eventv1.EventType { + et := eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{ + Name: eventv1.MakeEventTypeName(eventType), + Namespace: "default", + }, + Spec: eventv1.EventTypeSpec{ + Type: eventType, + Version: "1.0.0", + }, + Status: eventv1.EventTypeStatus{ + Active: true, + }, + } + meta.SetStatusCondition(&et.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return et +} + +func makeReadyEventExposure(eventType, zoneName string) eventv1.EventExposure { + exp := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-exposure", + Namespace: "default", + UID: "exposure-uid", + }, + Spec: eventv1.EventExposureSpec{ + EventType: eventType, + Visibility: eventv1.VisibilityEnterprise, + Zone: ctypes.ObjectRef{Name: zoneName, Namespace: "default"}, + Provider: ctypes.TypedObjectRef{ObjectRef: ctypes.ObjectRef{Name: "provider-app", Namespace: "default"}}, + Approval: eventv1.Approval{ + Strategy: eventv1.ApprovalStrategyAuto, + }, + }, + Status: eventv1.EventExposureStatus{ + Active: true, + Publisher: &ctypes.ObjectRef{ + Name: "test-publisher", + Namespace: "default", + }, + }, + } + meta.SetStatusCondition(&exp.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return exp +} + +func makeReadyEventConfig(zoneName string, fullMesh bool, meshZones []string) eventv1.EventConfig { + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: zoneName + "-eventconfig", + Namespace: "default", + }, + Spec: eventv1.EventConfigSpec{ + Zone: ctypes.ObjectRef{Name: zoneName, Namespace: "default"}, + Mesh: eventv1.MeshConfig{ + FullMesh: fullMesh, + ZoneNames: meshZones, + }, + }, + Status: eventv1.EventConfigStatus{ + ProxyCallbackURLs: map[string]string{}, + }, + } + meta.SetStatusCondition(&ec.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return ec +} + +func makeReadyApplication(name, team, teamEmail, clientId string) *applicationv1.Application { + app := &applicationv1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + UID: k8stypes.UID(name + "-uid"), + }, + Spec: applicationv1.ApplicationSpec{ + Team: team, + TeamEmail: teamEmail, + }, + Status: applicationv1.ApplicationStatus{ + ClientId: clientId, + }, + } + meta.SetStatusCondition(&app.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return app +} + +func makeReadyZone(name, namespace string, visibility adminv1.ZoneVisibility) *adminv1.Zone { + zone := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: adminv1.ZoneSpec{ + Visibility: visibility, + }, + } + meta.SetStatusCondition(&zone.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return zone +} + +var ( + testEventType = "de.telekom.eni.quickstart.v1" + requestorAppKey = k8stypes.NamespacedName{Name: "requestor-app", Namespace: "default"} + providerAppKey = k8stypes.NamespacedName{Name: "provider-app", Namespace: "default"} +) + +var _ = Describe("EventSubscriptionHandler", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + testScheme *runtime.Scheme + h *eventsubscription.EventSubscriptionHandler + obj *eventv1.EventSubscription + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + testScheme = buildScheme() + h = &eventsubscription.EventSubscriptionHandler{} + // Default: same-zone callback subscription + obj = newEventSubscription("test-sub", testEventType, "expo-zone") + obj.Spec.Delivery.Callback = "https://my-callback.example.com" + }) + + // --- mock helpers --- + + mockListEventTypes := func(items []eventv1.EventType) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventTypeList")). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{Items: items} + }). + Return(nil).Once() + } + + mockListEventTypesError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventTypeList")). + Return(err).Once() + } + + mockListEventExposures := func(items []eventv1.EventExposure) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventExposureList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventExposureList) = eventv1.EventExposureList{Items: items} + }). + Return(nil).Once() + } + + mockListEventExposuresError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventExposureList"), mock.Anything). + Return(err).Once() + } + + // mockListEventConfigs stubs c.List for EventConfigList the given number of times. + mockListEventConfigs := func(items []eventv1.EventConfig, times int) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: items} + }). + Return(nil).Times(times) + } + + mockListEventConfigsError := func(err error) { + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Return(err).Once() + } + + mockGetApplication := func(key k8stypes.NamespacedName, app *applicationv1.Application) { + fakeClient.EXPECT(). + Get(ctx, key, mock.AnythingOfType("*v1.Application")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*applicationv1.Application) = *app + }). + Return(nil).Once() + } + + mockGetApplicationError := func(key k8stypes.NamespacedName, err error) { + fakeClient.EXPECT(). + Get(ctx, key, mock.AnythingOfType("*v1.Application")). + Return(err).Once() + } + + mockGetZone := func(key k8stypes.NamespacedName, zone *adminv1.Zone) { + fakeClient.EXPECT(). + Get(ctx, key, mock.AnythingOfType("*v1.Zone")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*adminv1.Zone) = *zone + }). + Return(nil).Once() + } + + mockScheme := func() { + fakeClient.EXPECT().Scheme().Return(testScheme).Maybe() + } + + // mockApprovalBuilderGranted stubs the three client calls the approval builder makes, + // resulting in an "Granted" result. The Approval object returned by Get has state Granted. + mockApprovalBuilderGranted := func() { + // 1. CreateOrUpdate ApprovalRequest + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything). + Return(controllerutil.OperationResultCreated, nil).Once() + + // 2. Cleanup old ApprovalRequests + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything). + Return(0, nil).Once() + + // 3. Get Approval — return a Granted Approval + fakeClient.EXPECT(). + Get(ctx, mock.Anything, mock.AnythingOfType("*v1.Approval")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + approval := out.(*approvalv1.Approval) + approval.Spec.State = approvalv1.ApprovalStateGranted + }). + Return(nil).Once() + } + + mockApprovalBuilderPending := func() { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything). + Return(controllerutil.OperationResultCreated, nil).Once() + + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything). + Return(0, nil).Once() + + // Approval not found — results in Pending + fakeClient.EXPECT(). + Get(ctx, mock.Anything, mock.AnythingOfType("*v1.Approval")). + Return(apierrors.NewNotFound(schema.GroupResource{}, "")).Once() + } + + mockApprovalBuilderDenied := func() { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything). + Return(controllerutil.OperationResultCreated, nil).Once() + + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything). + Return(0, nil).Once() + + // Approval found with Rejected state + fakeClient.EXPECT(). + Get(ctx, mock.Anything, mock.AnythingOfType("*v1.Approval")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + approval := out.(*approvalv1.Approval) + approval.Spec.State = approvalv1.ApprovalStateRejected + }). + Return(nil).Once() + } + + mockApprovalBuilderRequestDenied := func() { + // CreateOrUpdate returns an ApprovalRequest with State=Rejected + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.ApprovalRequest"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ controllerutil.MutateFn) { + req := obj.(*approvalv1.ApprovalRequest) + req.Spec.State = approvalv1.ApprovalStateRejected + }). + Return(controllerutil.OperationResultNone, nil).Once() + + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.ApprovalRequestList"), mock.Anything). + Return(0, nil).Once() + + // Approval found with Granted state (but RequestDenied takes priority from request) + fakeClient.EXPECT(). + Get(ctx, mock.Anything, mock.AnythingOfType("*v1.Approval")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + approval := out.(*approvalv1.Approval) + approval.Spec.State = approvalv1.ApprovalStateGranted + }). + Return(nil).Once() + } + + mockCreateOrUpdateSubscriber := func(result controllerutil.OperationResult, err error) { + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Subscriber"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(result, err).Once() + } + + // mockCleanupSubscribers stubs the Cleanup call for SubscriberList. + mockCleanupSubscribers := func(deleted int, err error) { + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.SubscriberList"), mock.Anything). + Return(deleted, err).Once() + } + + // setupUpToApproval sets up mocks through the approval step (for same-zone scenarios). + setupUpToApproval := func(exposure eventv1.EventExposure) { + et := makeReadyEventType(testEventType) + expoConfig := makeReadyEventConfig("expo-zone", true, nil) // FullMesh=true supports any zone + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + // Visibility validation requires Zone lookup for the subscription's zone + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + mockListEventConfigs([]eventv1.EventConfig{expoConfig}, 2) // exposure zone + subscription zone (same zone) + + requestorApp := makeReadyApplication("requestor-app", "requester-team", "req@example.com", "req-client-id") + providerApp := makeReadyApplication("provider-app", "provider-team", "prov@example.com", "prov-client-id") + mockGetApplication(requestorAppKey, requestorApp) + mockGetApplication(providerAppKey, providerApp) + mockScheme() + } + + // setupFullHappyPath sets up all mocks for a complete successful CreateOrUpdate (same-zone). + setupFullHappyPath := func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + setupUpToApproval(exposure) + mockApprovalBuilderGranted() + mockCreateOrUpdateSubscriber(controllerutil.OperationResultCreated, nil) + } + + // ===================================================================== + // CreateOrUpdate tests + // ===================================================================== + + Describe("CreateOrUpdate", func() { + + It("should return error when FindActiveEventType fails", func() { + mockListEventTypesError(fmt.Errorf("connection refused")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list EventTypes")) + }) + + It("should set NotReady when no active EventType found", func() { + mockListEventTypes([]eventv1.EventType{}) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventTypeNotFound")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should return error when FindEventExposures fails", func() { + et := makeReadyEventType(testEventType) + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposuresError(fmt.Errorf("list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list EventExposures")) + }) + + It("should set NotReady when no EventExposure found (empty list) and cleanup succeeds", func() { + et := makeReadyEventType(testEventType) + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockCleanupSubscribers(0, nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventExposureNotFound")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should return error when Subscriber cleanup fails (no exposures path)", func() { + et := makeReadyEventType(testEventType) + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{}) + mockCleanupSubscribers(0, fmt.Errorf("cleanup failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unable to cleanup Subscriber")) + }) + + It("should set NotReady when exposure exists but is not active", func() { + et := makeReadyEventType(testEventType) + mockListEventTypes([]eventv1.EventType{et}) + + // Inactive exposure + exp := makeReadyEventExposure(testEventType, "expo-zone") + exp.Status.Active = false + mockListEventExposures([]eventv1.EventExposure{exp}) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventExposureNotFound")) + }) + + It("should return error when GetEventConfigForZone fails for exposure zone", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + mockListEventConfigsError(fmt.Errorf("config list failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get EventConfig for exposure zone")) + }) + + It("should set NotReady when subscription zone is not supported", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + // Subscription is in a different zone + obj.Spec.Zone.Name = "other-zone" + + // EventConfig with FullMesh=false and no mesh zones → doesn't support "other-zone" + expoConfig := makeReadyEventConfig("expo-zone", false, nil) + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + mockListEventConfigs([]eventv1.EventConfig{expoConfig}, 1) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ZoneNotSupported")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should return error when GetEventConfigForZone fails for subscription zone", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + obj.Spec.Zone.Name = "sub-zone" + + // Exposure zone config supports sub-zone via mesh + expoConfig := makeReadyEventConfig("expo-zone", false, []string{"sub-zone"}) + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + + // First EventConfigList call (exposure zone) succeeds + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{expoConfig}} + }). + Return(nil).Once() + + // Second EventConfigList call (subscription zone) fails + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Return(fmt.Errorf("sub-zone config list failed")).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get EventConfig for subscription zone")) + }) + + It("should return blocked error when cross-zone callback proxy URL is missing", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + obj.Spec.Zone.Name = "sub-zone" + obj.Spec.Delivery.Callback = "https://my-callback.example.com" + + expoConfig := makeReadyEventConfig("expo-zone", true, nil) + subConfig := makeReadyEventConfig("sub-zone", true, nil) + // No ProxyCallbackURLs → missing proxy URL for "expo-zone" + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{expoConfig}} + }). + Return(nil).Once() + + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{subConfig}} + }). + Return(nil).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(isBlockedError(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("no proxy callback URL")) + }) + + It("should update callback URL in cross-zone callback proxy scenario", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + obj.Spec.Zone.Name = "sub-zone" + obj.Spec.Delivery.Callback = "https://my-callback.example.com" + + expoConfig := makeReadyEventConfig("expo-zone", true, nil) + subConfig := makeReadyEventConfig("sub-zone", true, nil) + subConfig.Status.ProxyCallbackURLs = map[string]string{ + "expo-zone": "https://proxy-callback.example.com/expo-zone", + } + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{expoConfig}} + }). + Return(nil).Once() + + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{subConfig}} + }). + Return(nil).Once() + + requestorApp := makeReadyApplication("requestor-app", "requester-team", "req@example.com", "req-client-id") + providerApp := makeReadyApplication("provider-app", "provider-team", "prov@example.com", "prov-client-id") + mockGetApplication(requestorAppKey, requestorApp) + mockGetApplication(providerAppKey, providerApp) + mockScheme() + + mockApprovalBuilderGranted() + mockCreateOrUpdateSubscriber(controllerutil.OperationResultCreated, nil) + fakeClient.EXPECT().AllReady().Return(true).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + // Verify callback URL was updated + Expect(obj.Spec.Delivery.Callback).To(ContainSubstring("https://proxy-callback.example.com/expo-zone")) + Expect(obj.Spec.Delivery.Callback).To(ContainSubstring("callback=https://my-callback.example.com")) + }) + + It("should not modify callback URL for SSE delivery in cross-zone scenario", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + obj.Spec.Zone.Name = "sub-zone" + obj.Spec.Delivery.Type = eventv1.DeliveryTypeServerSentEvent + obj.Spec.Delivery.Callback = "" // SSE has no callback + + expoConfig := makeReadyEventConfig("expo-zone", true, nil) + subConfig := makeReadyEventConfig("sub-zone", true, nil) + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{expoConfig}} + }). + Return(nil).Once() + + fakeClient.EXPECT(). + List(ctx, mock.AnythingOfType("*v1.EventConfigList"), mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{subConfig}} + }). + Return(nil).Once() + + requestorApp := makeReadyApplication("requestor-app", "requester-team", "req@example.com", "req-client-id") + providerApp := makeReadyApplication("provider-app", "provider-team", "prov@example.com", "prov-client-id") + mockGetApplication(requestorAppKey, requestorApp) + mockGetApplication(providerAppKey, providerApp) + mockScheme() + + mockApprovalBuilderGranted() + mockCreateOrUpdateSubscriber(controllerutil.OperationResultCreated, nil) + fakeClient.EXPECT().AllReady().Return(true).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Spec.Delivery.Callback).To(BeEmpty()) + }) + + It("should set NotReady when requestor kind is not Application", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + obj.Spec.Requestor.Kind = "ServiceAccount" // unsupported + + expoConfig := makeReadyEventConfig("expo-zone", true, nil) + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + mockListEventConfigs([]eventv1.EventConfig{expoConfig}, 2) // exposure zone + subscription zone (same) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("InvalidRequestor")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should return error when GetApplication for requestor fails", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + expoConfig := makeReadyEventConfig("expo-zone", true, nil) + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + mockListEventConfigs([]eventv1.EventConfig{expoConfig}, 2) + mockGetApplicationError(requestorAppKey, fmt.Errorf("not found")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when GetApplication for provider fails", func() { + et := makeReadyEventType(testEventType) + exposure := makeReadyEventExposure(testEventType, "expo-zone") + expoConfig := makeReadyEventConfig("expo-zone", true, nil) + + requestorApp := makeReadyApplication("requestor-app", "requester-team", "req@example.com", "req-client-id") + + mockListEventTypes([]eventv1.EventType{et}) + mockListEventExposures([]eventv1.EventExposure{exposure}) + mockGetZone(obj.Spec.Zone.K8s(), makeReadyZone(obj.Spec.Zone.Name, obj.Spec.Zone.Namespace, adminv1.ZoneVisibilityEnterprise)) + mockListEventConfigs([]eventv1.EventConfig{expoConfig}, 2) + mockGetApplication(requestorAppKey, requestorApp) + mockGetApplicationError(providerAppKey, fmt.Errorf("provider not found")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unable to get application from EventExposure provider")) + }) + + It("should set NotReady when approval is pending", func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + setupUpToApproval(exposure) + mockApprovalBuilderPending() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ApprovalPending")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should set NotReady and cleanup when approval is denied", func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + setupUpToApproval(exposure) + mockApprovalBuilderDenied() + + // Cleanup Subscriber on denial + mockCleanupSubscribers(1, nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ApprovalDenied")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should set NotReady when approval request is denied", func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + setupUpToApproval(exposure) + mockApprovalBuilderRequestDenied() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ApprovalRequestDenied")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should set NotReady when exposure has no Publisher reference yet", func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + exposure.Status.Publisher = nil // Publisher not yet created + setupUpToApproval(exposure) + mockApprovalBuilderGranted() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("PublisherNotReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should return error when createSubscriber fails", func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + setupUpToApproval(exposure) + mockApprovalBuilderGranted() + mockCreateOrUpdateSubscriber(controllerutil.OperationResultNone, fmt.Errorf("subscriber create failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create Subscriber")) + }) + + It("should set NotReady when child resources are not ready", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(false).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("ChildResourcesNotReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should set Ready when all provisioning succeeds", func() { + setupFullHappyPath() + fakeClient.EXPECT().AllReady().Return(true).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(readyCond.Reason).To(Equal("EventSubscriptionProvisioned")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + + // Verify status refs are populated + Expect(obj.Status.Subscriber).ToNot(BeNil()) + Expect(obj.Status.ApprovalRequest).ToNot(BeNil()) + Expect(obj.Status.Approval).ToNot(BeNil()) + }) + + It("should return error when approval cleanup of subscribers fails on denial", func() { + exposure := makeReadyEventExposure(testEventType, "expo-zone") + setupUpToApproval(exposure) + mockApprovalBuilderDenied() + mockCleanupSubscribers(0, fmt.Errorf("cleanup failed")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unable to cleanup Subscriber")) + }) + }) + + // ===================================================================== + // Delete tests + // ===================================================================== + + Describe("Delete", func() { + It("should return nil (no-op)", func() { + err := h.Delete(ctx, obj) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + // ===================================================================== + // mapDelivery / mapTrigger (tested indirectly via CreateOrUpdate) + // ===================================================================== + + Describe("CreateOrUpdate with trigger and delivery options", func() { + It("should successfully provision with SSE delivery and triggers", func() { + obj.Spec.Delivery = eventv1.Delivery{ + Type: eventv1.DeliveryTypeServerSentEvent, + Payload: eventv1.PayloadTypeDataRef, + } + obj.Spec.Trigger = &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.name"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"source": "test"}, + }, + } + obj.Spec.Scopes = []string{"scope-a", "scope-b"} + + // Build exposure with scopes that match the subscription's requested scopes + exposure := makeReadyEventExposure(testEventType, "expo-zone") + exposure.Spec.Scopes = []eventv1.EventScope{ + {Name: "scope-a", Trigger: eventv1.EventTrigger{}}, + {Name: "scope-b", Trigger: eventv1.EventTrigger{}}, + } + setupUpToApproval(exposure) + mockApprovalBuilderGranted() + mockCreateOrUpdateSubscriber(controllerutil.OperationResultCreated, nil) + fakeClient.EXPECT().AllReady().Return(true).Once() + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + }) + }) +}) diff --git a/event/internal/handler/eventsubscription/suite_test.go b/event/internal/handler/eventsubscription/suite_test.go new file mode 100644 index 00000000..104650dd --- /dev/null +++ b/event/internal/handler/eventsubscription/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventsubscription_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEventSubscriptionHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventSubscription Handler Suite") +} diff --git a/event/internal/handler/eventsubscription/trigger.go b/event/internal/handler/eventsubscription/trigger.go new file mode 100644 index 00000000..13ad3667 --- /dev/null +++ b/event/internal/handler/eventsubscription/trigger.go @@ -0,0 +1,165 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventsubscription + +import ( + "encoding/json" + "slices" + "sort" + + eventv1 "github.com/telekom/controlplane/event/api/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// createPublisherTrigger merges publisher-defined triggers from all matching +// scopes into a single EventTrigger for the pubsub Subscriber. +// +// Merge strategy: +// - SelectionFilter: Each scope's filter is converted to an expression and +// combined with OR semantics: {"or": [scope1_expr, scope2_expr, ...]} +// Single scope: expression used directly (no OR wrapper). +// - ResponseFilter.Paths: Union of all paths, deduplicated. +// - ResponseFilter.Mode: Last-write-wins (consistent with Java Horizon). +func createPublisherTrigger(exposure *eventv1.EventExposure, subscribedScopes []string) *eventv1.EventTrigger { + if len(exposure.Spec.Scopes) == 0 || len(subscribedScopes) == 0 { + return nil + } + + result := &eventv1.EventTrigger{} + var selectionExprs []map[string]any + + for _, eeScope := range exposure.Spec.Scopes { + if slices.Contains(subscribedScopes, eeScope.Name) { + applyPublisherTrigger(&eeScope.Trigger, &selectionExprs, result) + } + } + + finalizeSelectionFilter(result, selectionExprs) + deduplicateResponseFilterPaths(result) + + if result.ResponseFilter == nil && result.SelectionFilter == nil { + return nil + } + return result +} + +// applyPublisherTrigger accumulates a single scope's trigger into the result. +// Selection filters are collected as expression maps for later OR-wrapping. +// Response filter paths are accumulated. Mode uses last-write-wins. +func applyPublisherTrigger( + scopeTrigger *eventv1.EventTrigger, + selectionExprs *[]map[string]any, + result *eventv1.EventTrigger, +) { + // Accumulate selection filters for OR-wrapping + if scopeTrigger.SelectionFilter != nil { + if scopeTrigger.SelectionFilter.Expression != nil { + // Advanced selection filter: add the expression as-is + var expr map[string]any + if err := json.Unmarshal(scopeTrigger.SelectionFilter.Expression.Raw, &expr); err == nil { + *selectionExprs = append(*selectionExprs, expr) + } + } else if len(scopeTrigger.SelectionFilter.Attributes) > 0 { + // Simple selection filter: convert attributes to expression tree + *selectionExprs = append(*selectionExprs, + attributesToExpression(scopeTrigger.SelectionFilter.Attributes)) + } + } + + // Accumulate response filter paths, last-write-wins for mode + if scopeTrigger.ResponseFilter != nil { + if result.ResponseFilter == nil { + result.ResponseFilter = &eventv1.ResponseFilter{} + } + result.ResponseFilter.Paths = append(result.ResponseFilter.Paths, scopeTrigger.ResponseFilter.Paths...) + if scopeTrigger.ResponseFilter.Mode != "" { + result.ResponseFilter.Mode = scopeTrigger.ResponseFilter.Mode + } + } +} + +// attributesToExpression converts a simple key-value attribute map to an expression tree. +// +// Single attribute: +// +// {"color": "red"} → {"eq": {"field": "color", "value": "red"}} +// +// Multiple attributes (AND-ed): +// +// {"color": "red", "size": "large"} → {"and": [{"eq": {"field": "color", "value": "red"}}, {"eq": {"field": "size", "value": "large"}}]} +func attributesToExpression(attrs map[string]string) map[string]any { + eqExprs := make([]any, 0, len(attrs)) + + // Sort keys for deterministic output + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + eqExprs = append(eqExprs, map[string]any{ + "eq": map[string]any{ + "field": k, + "value": attrs[k], + }, + }) + } + + if len(eqExprs) == 1 { + return eqExprs[0].(map[string]any) + } + return map[string]any{"and": eqExprs} +} + +// finalizeSelectionFilter wraps collected expression filters and sets them +// on the result's SelectionFilter.Expression. +// +// - 0 expressions: no-op (no selection filter set) +// - 1 expression: used directly (no OR wrapper) +// - N expressions: wrapped in {"or": [expr1, expr2, ...]} +func finalizeSelectionFilter(result *eventv1.EventTrigger, exprs []map[string]any) { + if len(exprs) == 0 { + return + } + + var exprMap map[string]any + if len(exprs) == 1 { + exprMap = exprs[0] + } else { + orList := make([]any, len(exprs)) + for i, e := range exprs { + orList[i] = e + } + exprMap = map[string]any{"or": orList} + } + + raw, err := json.Marshal(exprMap) + if err != nil { + return + } + + result.SelectionFilter = &eventv1.SelectionFilter{ + Expression: &apiextensionsv1.JSON{Raw: raw}, + } +} + +// deduplicateResponseFilterPaths removes duplicate paths from the response filter +// while preserving order. +func deduplicateResponseFilterPaths(result *eventv1.EventTrigger) { + if result.ResponseFilter == nil || len(result.ResponseFilter.Paths) == 0 { + return + } + + seen := make(map[string]struct{}) + unique := make([]string, 0, len(result.ResponseFilter.Paths)) + for _, p := range result.ResponseFilter.Paths { + if _, exists := seen[p]; !exists { + seen[p] = struct{}{} + unique = append(unique, p) + } + } + result.ResponseFilter.Paths = unique +} diff --git a/event/internal/handler/eventsubscription/trigger_test.go b/event/internal/handler/eventsubscription/trigger_test.go new file mode 100644 index 00000000..6d28eba0 --- /dev/null +++ b/event/internal/handler/eventsubscription/trigger_test.go @@ -0,0 +1,905 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventsubscription + +import ( + "encoding/json" + "testing" + + eventv1 "github.com/telekom/controlplane/event/api/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// helper: build a JSON expression from a Go map. +func mustJSON(t *testing.T, v any) *apiextensionsv1.JSON { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("mustJSON: %v", err) + } + return &apiextensionsv1.JSON{Raw: raw} +} + +// helper: unmarshal a *apiextensionsv1.JSON into a generic map. +func jsonToMap(t *testing.T, j *apiextensionsv1.JSON) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(j.Raw, &m); err != nil { + t.Fatalf("jsonToMap: %v", err) + } + return m +} + +// helper: build an EventExposure with given scopes. +func makeExposure(scopes []eventv1.EventScope) *eventv1.EventExposure { + return &eventv1.EventExposure{ + Spec: eventv1.EventExposureSpec{ + Scopes: scopes, + }, + } +} + +// ========================================================================= +// attributesToExpression tests +// ========================================================================= + +func TestAttributesToExpression_SingleAttribute(t *testing.T) { + result := attributesToExpression(map[string]string{"color": "red"}) + + // Expect {"eq": {"field": "color", "value": "red"}} + eq, ok := result["eq"].(map[string]any) + if !ok { + t.Fatalf("expected 'eq' key, got: %v", result) + } + if eq["field"] != "color" || eq["value"] != "red" { + t.Errorf("expected field=color, value=red, got field=%v, value=%v", eq["field"], eq["value"]) + } +} + +func TestAttributesToExpression_MultipleAttributes(t *testing.T) { + result := attributesToExpression(map[string]string{"color": "red", "size": "large"}) + + // Expect {"and": [{"eq": {"field":"color","value":"red"}}, {"eq": {"field":"size","value":"large"}}]} + andList, ok := result["and"].([]any) + if !ok { + t.Fatalf("expected 'and' key, got: %v", result) + } + if len(andList) != 2 { + t.Fatalf("expected 2 items in 'and', got %d", len(andList)) + } + + // Keys are sorted, so "color" comes before "size" + first := andList[0].(map[string]any)["eq"].(map[string]any) + second := andList[1].(map[string]any)["eq"].(map[string]any) + + if first["field"] != "color" || first["value"] != "red" { + t.Errorf("first eq: expected color=red, got %v=%v", first["field"], first["value"]) + } + if second["field"] != "size" || second["value"] != "large" { + t.Errorf("second eq: expected size=large, got %v=%v", second["field"], second["value"]) + } +} + +func TestAttributesToExpression_DeterministicOrdering(t *testing.T) { + attrs := map[string]string{"z": "1", "a": "2", "m": "3"} + + // Run multiple times to verify determinism + for i := 0; i < 10; i++ { + result := attributesToExpression(attrs) + andList := result["and"].([]any) + + fields := make([]string, len(andList)) + for j, item := range andList { + eq := item.(map[string]any)["eq"].(map[string]any) + fields[j] = eq["field"].(string) + } + + if fields[0] != "a" || fields[1] != "m" || fields[2] != "z" { + t.Errorf("iteration %d: expected sorted [a,m,z], got %v", i, fields) + } + } +} + +// ========================================================================= +// finalizeSelectionFilter tests +// ========================================================================= + +func TestFinalizeSelectionFilter_NoExpressions(t *testing.T) { + result := &eventv1.EventTrigger{} + finalizeSelectionFilter(result, nil) + + if result.SelectionFilter != nil { + t.Errorf("expected nil SelectionFilter for empty expressions") + } +} + +func TestFinalizeSelectionFilter_SingleExpression(t *testing.T) { + result := &eventv1.EventTrigger{} + expr := map[string]any{"eq": map[string]any{"field": "type", "value": "A"}} + finalizeSelectionFilter(result, []map[string]any{expr}) + + if result.SelectionFilter == nil || result.SelectionFilter.Expression == nil { + t.Fatal("expected SelectionFilter.Expression to be set") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + // Should be the expression directly, not wrapped in OR + if _, hasOr := got["or"]; hasOr { + t.Error("single expression should not be wrapped in 'or'") + } + eq, ok := got["eq"].(map[string]any) + if !ok { + t.Fatalf("expected 'eq' key, got: %v", got) + } + if eq["field"] != "type" || eq["value"] != "A" { + t.Errorf("expected field=type, value=A, got %v", eq) + } +} + +func TestFinalizeSelectionFilter_MultipleExpressions(t *testing.T) { + result := &eventv1.EventTrigger{} + expr1 := map[string]any{"eq": map[string]any{"field": "type", "value": "A"}} + expr2 := map[string]any{"eq": map[string]any{"field": "type", "value": "B"}} + finalizeSelectionFilter(result, []map[string]any{expr1, expr2}) + + if result.SelectionFilter == nil || result.SelectionFilter.Expression == nil { + t.Fatal("expected SelectionFilter.Expression to be set") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + orList, ok := got["or"].([]any) + if !ok { + t.Fatalf("expected 'or' key for multiple expressions, got: %v", got) + } + if len(orList) != 2 { + t.Fatalf("expected 2 items in 'or', got %d", len(orList)) + } +} + +// ========================================================================= +// deduplicateResponseFilterPaths tests +// ========================================================================= + +func TestDeduplicateResponseFilterPaths_NilResponseFilter(t *testing.T) { + result := &eventv1.EventTrigger{} + deduplicateResponseFilterPaths(result) + // no-op, no panic + if result.ResponseFilter != nil { + t.Error("expected nil ResponseFilter") + } +} + +func TestDeduplicateResponseFilterPaths_EmptyPaths(t *testing.T) { + result := &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{Paths: []string{}}, + } + deduplicateResponseFilterPaths(result) + if len(result.ResponseFilter.Paths) != 0 { + t.Errorf("expected 0 paths, got %d", len(result.ResponseFilter.Paths)) + } +} + +func TestDeduplicateResponseFilterPaths_NoDuplicates(t *testing.T) { + result := &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{Paths: []string{"a", "b", "c"}}, + } + deduplicateResponseFilterPaths(result) + if len(result.ResponseFilter.Paths) != 3 { + t.Errorf("expected 3 paths, got %d", len(result.ResponseFilter.Paths)) + } +} + +func TestDeduplicateResponseFilterPaths_WithDuplicates(t *testing.T) { + result := &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{Paths: []string{"a", "b", "a", "c", "b"}}, + } + deduplicateResponseFilterPaths(result) + + expected := []string{"a", "b", "c"} + if len(result.ResponseFilter.Paths) != len(expected) { + t.Fatalf("expected %d paths, got %d: %v", len(expected), len(result.ResponseFilter.Paths), result.ResponseFilter.Paths) + } + for i, p := range result.ResponseFilter.Paths { + if p != expected[i] { + t.Errorf("path[%d] = %q, want %q", i, p, expected[i]) + } + } +} + +func TestDeduplicateResponseFilterPaths_PreservesOrder(t *testing.T) { + result := &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{Paths: []string{"c", "a", "c", "b", "a"}}, + } + deduplicateResponseFilterPaths(result) + + // First occurrence order: c, a, b + expected := []string{"c", "a", "b"} + if len(result.ResponseFilter.Paths) != len(expected) { + t.Fatalf("expected %d paths, got %d", len(expected), len(result.ResponseFilter.Paths)) + } + for i, p := range result.ResponseFilter.Paths { + if p != expected[i] { + t.Errorf("path[%d] = %q, want %q", i, p, expected[i]) + } + } +} + +// ========================================================================= +// createPublisherTrigger tests +// ========================================================================= + +func TestCreatePublisherTrigger_NoSubscribedScopes(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{Attributes: map[string]string{"type": "A"}}, + }}, + }) + result := createPublisherTrigger(exposure, []string{}) + if result != nil { + t.Errorf("expected nil for empty subscribed scopes, got: %v", result) + } +} + +func TestCreatePublisherTrigger_NoExposureScopes(t *testing.T) { + exposure := makeExposure(nil) + result := createPublisherTrigger(exposure, []string{"gold"}) + if result != nil { + t.Errorf("expected nil for empty exposure scopes, got: %v", result) + } +} + +func TestCreatePublisherTrigger_ScopeNotFound(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{Attributes: map[string]string{"type": "A"}}, + }}, + }) + result := createPublisherTrigger(exposure, []string{"missing"}) + if result != nil { + t.Errorf("expected nil when no scope names match, got: %v", result) + } +} + +func TestCreatePublisherTrigger_SingleScope_AttributesOnly(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"color": "red"}, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.SelectionFilter == nil || result.SelectionFilter.Expression == nil { + t.Fatal("expected SelectionFilter.Expression to be set") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + // Single attribute → {"eq": {"field": "color", "value": "red"}} + eq, ok := got["eq"].(map[string]any) + if !ok { + t.Fatalf("expected 'eq', got: %v", got) + } + if eq["field"] != "color" || eq["value"] != "red" { + t.Errorf("unexpected eq: %v", eq) + } + if result.ResponseFilter != nil { + t.Error("expected nil ResponseFilter") + } +} + +func TestCreatePublisherTrigger_SingleScope_ExpressionOnly(t *testing.T) { + exprMap := map[string]any{"gt": map[string]any{"field": "age", "value": float64(18)}} + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Expression: mustJSON(t, exprMap), + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + gt, ok := got["gt"].(map[string]any) + if !ok { + t.Fatalf("expected 'gt', got: %v", got) + } + if gt["field"] != "age" { + t.Errorf("expected field=age, got %v", gt["field"]) + } +} + +func TestCreatePublisherTrigger_SingleScope_MultiAttributes(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"color": "red", "size": "big"}, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + // Multiple attributes → {"and": [{"eq":...color}, {"eq":...size}]} + andList, ok := got["and"].([]any) + if !ok { + t.Fatalf("expected 'and' for multi-attrs, got: %v", got) + } + if len(andList) != 2 { + t.Fatalf("expected 2 items in 'and', got %d", len(andList)) + } +} + +func TestCreatePublisherTrigger_TwoScopes_BothAttributes(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "A"}, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "B"}, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + // Two scopes → {"or": [scope1_expr, scope2_expr]} + orList, ok := got["or"].([]any) + if !ok { + t.Fatalf("expected 'or' for two scopes, got: %v", got) + } + if len(orList) != 2 { + t.Fatalf("expected 2 items in 'or', got %d", len(orList)) + } + + // First: {"eq": {"field":"type","value":"A"}} + first := orList[0].(map[string]any) + eq1 := first["eq"].(map[string]any) + if eq1["value"] != "A" { + t.Errorf("first scope: expected value=A, got %v", eq1["value"]) + } + + // Second: {"eq": {"field":"type","value":"B"}} + second := orList[1].(map[string]any) + eq2 := second["eq"].(map[string]any) + if eq2["value"] != "B" { + t.Errorf("second scope: expected value=B, got %v", eq2["value"]) + } +} + +func TestCreatePublisherTrigger_TwoScopes_MixedAttrsAndExpression(t *testing.T) { + customExpr := map[string]any{"gt": map[string]any{"field": "priority", "value": float64(5)}} + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "A"}, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Expression: mustJSON(t, customExpr), + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + orList, ok := got["or"].([]any) + if !ok { + t.Fatalf("expected 'or', got: %v", got) + } + if len(orList) != 2 { + t.Fatalf("expected 2 items in 'or', got %d", len(orList)) + } + + // First: converted attributes → {"eq":...} + first := orList[0].(map[string]any) + if _, ok := first["eq"]; !ok { + t.Errorf("first scope should be eq expression from attributes, got: %v", first) + } + + // Second: pass-through expression → {"gt":...} + second := orList[1].(map[string]any) + if _, ok := second["gt"]; !ok { + t.Errorf("second scope should be gt expression, got: %v", second) + } +} + +func TestCreatePublisherTrigger_ResponseFilterPathsUnion(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.a"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.b"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.ResponseFilter == nil { + t.Fatal("expected ResponseFilter to be set") + } + + expected := []string{"$.data.a", "$.data.b"} + if len(result.ResponseFilter.Paths) != len(expected) { + t.Fatalf("expected %d paths, got %d: %v", len(expected), len(result.ResponseFilter.Paths), result.ResponseFilter.Paths) + } + for i, p := range result.ResponseFilter.Paths { + if p != expected[i] { + t.Errorf("path[%d] = %q, want %q", i, p, expected[i]) + } + } +} + +func TestCreatePublisherTrigger_ResponseFilterPathsDedup(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.a"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.a"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + if len(result.ResponseFilter.Paths) != 1 { + t.Errorf("expected 1 deduplicated path, got %d: %v", len(result.ResponseFilter.Paths), result.ResponseFilter.Paths) + } + if result.ResponseFilter.Paths[0] != "$.data.a" { + t.Errorf("expected $.data.a, got %s", result.ResponseFilter.Paths[0]) + } +} + +func TestCreatePublisherTrigger_ResponseFilterModeLastWriteWins(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.a"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.b"}, + Mode: eventv1.ResponseFilterModeExclude, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + // Last scope is "silver" with Exclude, so mode should be Exclude + if result.ResponseFilter.Mode != eventv1.ResponseFilterModeExclude { + t.Errorf("expected mode Exclude (last-write-wins), got %q", result.ResponseFilter.Mode) + } +} + +func TestCreatePublisherTrigger_SelectionAndResponseCombined(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "premium"}, + }, + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.name", "$.data.id"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.SelectionFilter == nil || result.SelectionFilter.Expression == nil { + t.Error("expected SelectionFilter to be set") + } + if result.ResponseFilter == nil { + t.Error("expected ResponseFilter to be set") + } + if len(result.ResponseFilter.Paths) != 2 { + t.Errorf("expected 2 paths, got %d", len(result.ResponseFilter.Paths)) + } +} + +func TestCreatePublisherTrigger_ScopeWithoutSelectionFilter(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.name"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + // No SelectionFilter + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + if result.SelectionFilter != nil { + t.Error("expected nil SelectionFilter when scope has no selection filter") + } + if result.ResponseFilter == nil { + t.Fatal("expected ResponseFilter to be set") + } + if len(result.ResponseFilter.Paths) != 1 || result.ResponseFilter.Paths[0] != "$.data.name" { + t.Errorf("unexpected paths: %v", result.ResponseFilter.Paths) + } +} + +func TestCreatePublisherTrigger_ScopeWithEmptyTrigger(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + // Both nil — nothing to contribute + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + // No selection filter, no response filter → should be nil + if result != nil { + t.Errorf("expected nil for scope with empty trigger, got: %+v", result) + } +} + +func TestCreatePublisherTrigger_OnlyMatchingScopes(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "A"}, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "B"}, + }, + }}, + {Name: "bronze", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "C"}, + }, + }}, + }) + + // Only subscribe to "gold" — should NOT get OR wrapper since only 1 matching scope + result := createPublisherTrigger(exposure, []string{"gold"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + // Should be direct eq, no "or" wrapper + if _, hasOr := got["or"]; hasOr { + t.Error("expected no 'or' wrapper for single matching scope") + } + eq := got["eq"].(map[string]any) + if eq["value"] != "A" { + t.Errorf("expected type=A from gold scope, got %v", eq["value"]) + } +} + +func TestCreatePublisherTrigger_SubsetOfScopes(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "A"}, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "B"}, + }, + }}, + {Name: "bronze", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "C"}, + }, + }}, + }) + + // Subscribe to gold and bronze (skip silver) + result := createPublisherTrigger(exposure, []string{"gold", "bronze"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + got := jsonToMap(t, result.SelectionFilter.Expression) + + orList, ok := got["or"].([]any) + if !ok { + t.Fatalf("expected 'or' for two matching scopes, got: %v", got) + } + if len(orList) != 2 { + t.Fatalf("expected 2 items in 'or', got %d", len(orList)) + } + + // Verify values are A and C (gold and bronze), not B (silver) + first := orList[0].(map[string]any)["eq"].(map[string]any) + second := orList[1].(map[string]any)["eq"].(map[string]any) + + if first["value"] != "A" { + t.Errorf("first scope: expected A (gold), got %v", first["value"]) + } + if second["value"] != "C" { + t.Errorf("second scope: expected C (bronze), got %v", second["value"]) + } +} + +func TestCreatePublisherTrigger_SelectionFilterWithEmptyAttributes(t *testing.T) { + // SelectionFilter is non-nil but has empty attributes and no expression + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{}, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold"}) + + // Empty attributes → no expression generated → nil result + if result != nil { + t.Errorf("expected nil for scope with empty attributes, got: %+v", result) + } +} + +func TestCreatePublisherTrigger_ResponseFilterModeWithEmptyStringNotOverwritten(t *testing.T) { + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.a"}, + Mode: eventv1.ResponseFilterModeExclude, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.b"}, + Mode: "", // Empty mode should not overwrite previous + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + // Gold set Exclude, silver has empty mode → should stay Exclude + if result.ResponseFilter.Mode != eventv1.ResponseFilterModeExclude { + t.Errorf("expected mode Exclude (empty should not overwrite), got %q", result.ResponseFilter.Mode) + } +} + +// ========================================================================= +// applyPublisherTrigger tests +// ========================================================================= + +func TestApplyPublisherTrigger_AccumulatesSelectionExpressions(t *testing.T) { + result := &eventv1.EventTrigger{} + var exprs []map[string]any + + trigger1 := &eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"type": "A"}, + }, + } + trigger2 := &eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Expression: mustJSON(t, map[string]any{"gt": map[string]any{"field": "x", "value": float64(1)}}), + }, + } + + applyPublisherTrigger(trigger1, &exprs, result) + applyPublisherTrigger(trigger2, &exprs, result) + + if len(exprs) != 2 { + t.Fatalf("expected 2 accumulated expressions, got %d", len(exprs)) + } +} + +func TestApplyPublisherTrigger_AccumulatesResponseFilterPaths(t *testing.T) { + result := &eventv1.EventTrigger{} + var exprs []map[string]any + + trigger1 := &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"a", "b"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + } + trigger2 := &eventv1.EventTrigger{ + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"c"}, + Mode: eventv1.ResponseFilterModeExclude, + }, + } + + applyPublisherTrigger(trigger1, &exprs, result) + applyPublisherTrigger(trigger2, &exprs, result) + + if result.ResponseFilter == nil { + t.Fatal("expected ResponseFilter to be set") + } + if len(result.ResponseFilter.Paths) != 3 { + t.Errorf("expected 3 accumulated paths, got %d: %v", len(result.ResponseFilter.Paths), result.ResponseFilter.Paths) + } + // Mode should be Exclude (last write) + if result.ResponseFilter.Mode != eventv1.ResponseFilterModeExclude { + t.Errorf("expected mode Exclude, got %q", result.ResponseFilter.Mode) + } +} + +func TestApplyPublisherTrigger_SkipsNilFilters(t *testing.T) { + result := &eventv1.EventTrigger{} + var exprs []map[string]any + + trigger := &eventv1.EventTrigger{ + // Both nil + } + + applyPublisherTrigger(trigger, &exprs, result) + + if len(exprs) != 0 { + t.Errorf("expected 0 expressions for nil filter, got %d", len(exprs)) + } + if result.ResponseFilter != nil { + t.Errorf("expected nil ResponseFilter, got %+v", result.ResponseFilter) + } +} + +// ========================================================================= +// Integration-style tests: three scopes with mixed filters +// ========================================================================= + +func TestCreatePublisherTrigger_ThreeScopes_FullMerge(t *testing.T) { + customExpr := map[string]any{"ne": map[string]any{"field": "status", "value": "draft"}} + exposure := makeExposure([]eventv1.EventScope{ + {Name: "gold", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"tier": "premium"}, + }, + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.name", "$.data.id"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + {Name: "silver", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Expression: mustJSON(t, customExpr), + }, + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.id", "$.data.email"}, + Mode: eventv1.ResponseFilterModeExclude, + }, + }}, + {Name: "bronze", Trigger: eventv1.EventTrigger{ + SelectionFilter: &eventv1.SelectionFilter{ + Attributes: map[string]string{"region": "eu", "priority": "low"}, + }, + ResponseFilter: &eventv1.ResponseFilter{ + Paths: []string{"$.data.name"}, + Mode: eventv1.ResponseFilterModeInclude, + }, + }}, + }) + + result := createPublisherTrigger(exposure, []string{"gold", "silver", "bronze"}) + + if result == nil { + t.Fatal("expected non-nil result") + } + + // Check selection filter: 3 scopes → {"or": [gold_expr, silver_expr, bronze_expr]} + got := jsonToMap(t, result.SelectionFilter.Expression) + orList, ok := got["or"].([]any) + if !ok { + t.Fatalf("expected 'or' for 3 scopes, got: %v", got) + } + if len(orList) != 3 { + t.Fatalf("expected 3 items in 'or', got %d", len(orList)) + } + + // Gold: {"eq": {"field":"tier","value":"premium"}} (single attribute) + goldExpr := orList[0].(map[string]any) + if _, hasEq := goldExpr["eq"]; !hasEq { + t.Errorf("gold should have 'eq', got: %v", goldExpr) + } + + // Silver: {"ne": ...} (pass-through expression) + silverExpr := orList[1].(map[string]any) + if _, hasNe := silverExpr["ne"]; !hasNe { + t.Errorf("silver should have 'ne', got: %v", silverExpr) + } + + // Bronze: {"and": [...]} (two attributes) + bronzeExpr := orList[2].(map[string]any) + if _, hasAnd := bronzeExpr["and"]; !hasAnd { + t.Errorf("bronze should have 'and' for 2 attributes, got: %v", bronzeExpr) + } + + // Check response filter: union of paths deduplicated + if result.ResponseFilter == nil { + t.Fatal("expected ResponseFilter to be set") + } + // Paths: [$.data.name, $.data.id] ∪ [$.data.id, $.data.email] ∪ [$.data.name] → [$.data.name, $.data.id, $.data.email] + expectedPaths := []string{"$.data.name", "$.data.id", "$.data.email"} + if len(result.ResponseFilter.Paths) != len(expectedPaths) { + t.Fatalf("expected %d paths, got %d: %v", len(expectedPaths), len(result.ResponseFilter.Paths), result.ResponseFilter.Paths) + } + for i, p := range result.ResponseFilter.Paths { + if p != expectedPaths[i] { + t.Errorf("path[%d] = %q, want %q", i, p, expectedPaths[i]) + } + } + + // Mode: last-write-wins → "bronze" is last → Include + if result.ResponseFilter.Mode != eventv1.ResponseFilterModeInclude { + t.Errorf("expected mode Include (last-write-wins from bronze), got %q", result.ResponseFilter.Mode) + } +} diff --git a/event/internal/handler/eventsubscription/util.go b/event/internal/handler/eventsubscription/util.go new file mode 100644 index 00000000..5e65cea4 --- /dev/null +++ b/event/internal/handler/eventsubscription/util.go @@ -0,0 +1,58 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventsubscription + +import ( + "context" + "slices" + "strings" + + adminv1 "github.com/telekom/controlplane/admin/api/v1" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" +) + +// EventVisibilityMustBeValid checks if the visibility of the EventExposure is compatible with the subscription's zone. +func EventVisibilityMustBeValid(ctx context.Context, exposure *eventv1.EventExposure, sub *eventv1.EventSubscription) (bool, error) { + subZone, err := util.GetZone(ctx, sub.Spec.Zone.K8s()) + if err != nil { + return false, err + } + + switch exposure.Spec.Visibility { + case eventv1.VisibilityWorld: + // Any subscription is valid for a WORLD exposure + return true, nil + + case eventv1.VisibilityEnterprise: + // For an ENTERPRISE exposure, only subscriptions from an enterprise zone are valid + return strings.EqualFold(string(subZone.Spec.Visibility), string(adminv1.ZoneVisibilityEnterprise)), nil + + case eventv1.VisibilityZone: + // For a ZONE exposure, only subscriptions from the same zone are valid + return exposure.Spec.Zone.Equals(&sub.Spec.Zone), nil + + default: + // If the visibility is unknown, consider it invalid + return false, nil + } +} + +// EventScopesMustBeValid checks if all scopes configured by the subscribers are actually supported by the exposure. +func EventScopesMustBeValid(ctx context.Context, apiExposure *eventv1.EventExposure, apiSubscription *eventv1.EventSubscription) (bool, error) { + requestedScopes := apiSubscription.Spec.Scopes + var supportedScopes []string + for _, scope := range apiExposure.Spec.Scopes { + supportedScopes = append(supportedScopes, scope.Name) + } + + for _, scope := range requestedScopes { + if !slices.Contains(supportedScopes, scope) { + return false, nil + } + } + + return true, nil +} diff --git a/event/internal/handler/eventtype/handler.go b/event/internal/handler/eventtype/handler.go new file mode 100644 index 00000000..09c70ab4 --- /dev/null +++ b/event/internal/handler/eventtype/handler.go @@ -0,0 +1,79 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventtype + +import ( + "context" + "sort" + + "github.com/pkg/errors" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/handler" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var _ handler.Handler[*eventv1.EventType] = &EventTypeHandler{} + +type EventTypeHandler struct{} + +func (h *EventTypeHandler) CreateOrUpdate(ctx context.Context, obj *eventv1.EventType) error { + logger := log.FromContext(ctx) + c := cclient.ClientFromContextOrDie(ctx) + + // List all EventTypes in the environment + eventTypeList := &eventv1.EventTypeList{} + if err := c.List(ctx, eventTypeList); err != nil { + return errors.Wrap(err, "failed to list EventTypes") + } + + // Filter to only those matching our spec.type + var candidates []eventv1.EventType + for _, et := range eventTypeList.Items { + if et.Spec.Type == obj.Spec.Type { + candidates = append(candidates, et) + } + } + + // Sort by CreationTimestamp ascending (oldest first) + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].CreationTimestamp.Before(&candidates[j].CreationTimestamp) + }) + + // Find the first non-deleted candidate — that one is the active singleton + var activeCandidate *eventv1.EventType + for i := range candidates { + if candidates[i].DeletionTimestamp == nil || candidates[i].DeletionTimestamp.IsZero() { + activeCandidate = &candidates[i] + break + } + } + + // Determine if this EventType is the active one + if activeCandidate != nil && activeCandidate.UID == obj.UID { + obj.Status.Active = true + obj.SetCondition(condition.NewReadyCondition("EventTypeActive", + "EventType is the active singleton for its type")) + obj.SetCondition(condition.NewDoneProcessingCondition("EventType is active")) + logger.V(1).Info("EventType is active", "type", obj.Spec.Type) + } else { + obj.Status.Active = false + msg := "Another EventType with the same type identifier is already active" + obj.SetCondition(condition.NewNotReadyCondition("EventTypeNotActive", msg)) + obj.SetCondition(condition.NewBlockedCondition(msg)) + logger.V(1).Info("EventType is not active (blocked by older resource)", "type", obj.Spec.Type) + } + + return nil +} + +func (h *EventTypeHandler) Delete(ctx context.Context, obj *eventv1.EventType) error { + // When the active EventType is deleted, the controller-runtime will re-reconcile + // the remaining EventTypes (via watches in the controller). The next-oldest will + // naturally become the active one during its next reconciliation. + // No manual cleanup is needed. + return nil +} diff --git a/event/internal/handler/eventtype/handler_test.go b/event/internal/handler/eventtype/handler_test.go new file mode 100644 index 00000000..a846a0c3 --- /dev/null +++ b/event/internal/handler/eventtype/handler_test.go @@ -0,0 +1,262 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventtype_test + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/eventtype" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func newEventType(name, specType string, uid types.UID, creationTime time.Time) *eventv1.EventType { + return &eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + UID: uid, + CreationTimestamp: metav1.NewTime(creationTime), + }, + Spec: eventv1.EventTypeSpec{ + Type: specType, + Version: "1.0.0", + }, + } +} + +var _ = Describe("EventTypeHandler", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + h *eventtype.EventTypeHandler + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + h = &eventtype.EventTypeHandler{} + }) + + Describe("CreateOrUpdate", func() { + It("should return an error wrapping 'failed to list EventTypes' when List fails", func() { + obj := newEventType("et-1", "de.telekom.test.v1", "uid-1", time.Now()) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Return(fmt.Errorf("connection refused")) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list EventTypes")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should set Active=false with NotReady and Blocked conditions when no candidates match the type", func() { + obj := newEventType("et-1", "de.telekom.test.v1", "uid-1", time.Now()) + + // List returns items that do NOT match obj's type + otherET := newEventType("et-other", "de.telekom.other.v1", "uid-other", time.Now()) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{ + Items: []eventv1.EventType{*otherET}, + } + }). + Return(nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeFalse()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventTypeNotActive")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should set Active=true with Ready and DoneProcessing when this obj is the only candidate", func() { + now := time.Now() + obj := newEventType("et-1", "de.telekom.test.v1", "uid-1", now) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{ + Items: []eventv1.EventType{*obj}, + } + }). + Return(nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeTrue()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(readyCond.Reason).To(Equal("EventTypeActive")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should set Active=true when this obj is the oldest among multiple candidates", func() { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + t3 := time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC) + + obj := newEventType("et-oldest", "de.telekom.test.v1", "uid-oldest", t1) + et2 := newEventType("et-middle", "de.telekom.test.v1", "uid-middle", t2) + et3 := newEventType("et-newest", "de.telekom.test.v1", "uid-newest", t3) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + // Return in arbitrary order; handler sorts by creation time + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{ + Items: []eventv1.EventType{*et3, *obj, *et2}, + } + }). + Return(nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeTrue()) + Expect(meta.IsStatusConditionTrue(obj.GetConditions(), condition.ConditionTypeReady)).To(BeTrue()) + }) + + It("should set Active=false when this obj is NOT the oldest among multiple candidates", func() { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + + older := newEventType("et-older", "de.telekom.test.v1", "uid-older", t1) + obj := newEventType("et-newer", "de.telekom.test.v1", "uid-newer", t2) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{ + Items: []eventv1.EventType{*obj, *older}, + } + }). + Return(nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeFalse()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal("EventTypeNotActive")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + + It("should skip deletion-marked candidates and activate the next non-deleted one", func() { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + deletionTime := metav1.NewTime(time.Now()) + + deletedET := newEventType("et-deleted", "de.telekom.test.v1", "uid-deleted", t1) + deletedET.DeletionTimestamp = &deletionTime + + obj := newEventType("et-active", "de.telekom.test.v1", "uid-active", t2) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{ + Items: []eventv1.EventType{*deletedET, *obj}, + } + }). + Return(nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeTrue()) + Expect(meta.IsStatusConditionTrue(obj.GetConditions(), condition.ConditionTypeReady)).To(BeTrue()) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should set Active=false when all candidates are deletion-marked", func() { + t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + deletionTime := metav1.NewTime(time.Now()) + + deleted1 := newEventType("et-deleted-1", "de.telekom.test.v1", "uid-deleted-1", t1) + deleted1.DeletionTimestamp = &deletionTime + + obj := newEventType("et-deleted-2", "de.telekom.test.v1", "uid-deleted-2", t2) + obj.DeletionTimestamp = &deletionTime + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{ + Items: []eventv1.EventType{*deleted1, *obj}, + } + }). + Return(nil) + + err := h.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.Active).To(BeFalse()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond).ToNot(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Reason).To(Equal("Blocked")) + }) + }) + + Describe("Delete", func() { + It("should always return nil", func() { + obj := newEventType("et-1", "de.telekom.test.v1", "uid-1", time.Now()) + + err := h.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/event/internal/handler/eventtype/suite_test.go b/event/internal/handler/eventtype/suite_test.go new file mode 100644 index 00000000..a484f85a --- /dev/null +++ b/event/internal/handler/eventtype/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventtype_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEventTypeHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventType Handler Suite") +} diff --git a/event/internal/handler/util/callback_route.go b/event/internal/handler/util/callback_route.go new file mode 100644 index 00000000..0d7870be --- /dev/null +++ b/event/internal/handler/util/callback_route.go @@ -0,0 +1,286 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + + "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +func CreateProxyCallbackRoute( + ctx context.Context, + sourceZone *adminv1.Zone, + targetZone *adminv1.Zone, + meshClient *identityv1.Client, + opts ...Option, +) (*gatewayapi.Route, error) { + + options := &Options{} + for _, opt := range opts { + opt(options) + } + + c := cclient.ClientFromContextOrDie(ctx) + + downstreamRealm := &gatewayapi.Realm{} + err := c.Get(ctx, sourceZone.Status.GatewayRealm.K8s(), downstreamRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", sourceZone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", sourceZone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(downstreamRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", downstreamRealm.Name) + } + + upstreamRealm := &gatewayapi.Realm{} + err = c.Get(ctx, targetZone.Status.GatewayRealm.K8s(), upstreamRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", targetZone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", targetZone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(upstreamRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", upstreamRealm.Name) + } + + route := &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: makeCallbackRouteName(targetZone.Name), + Namespace: sourceZone.Status.Namespace, + }, + } + + downstream, err := downstreamRealm.AsDownstream(makeCallbackRoutePath(targetZone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create downstream for proxy callback Route") + } + + upstream, err := upstreamRealm.AsUpstream(makeCallbackRoutePath(targetZone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create upstream for proxy callback Route") + } + upstream.ClientId = meshClient.Spec.ClientId + upstream.ClientSecret = meshClient.Spec.ClientSecret + upstream.IssuerUrl = meshClient.Status.IssuerUrl + + mutator := func() error { + + err := options.apply(ctx, route) + if err != nil { + return errors.Wrap(err, "failed to apply options to proxy callback Route") + } + + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + config.BuildLabelKey("zone"): sourceZone.Name, + config.BuildLabelKey("realm"): downstreamRealm.Name, + config.BuildLabelKey("type"): "callback-proxy", + } + route.Spec = gatewayapi.RouteSpec{ + Realm: *ctypes.ObjectRefFromObject(downstreamRealm), + Upstreams: []gatewayapi.Upstream{ + upstream, + }, + Downstreams: []gatewayapi.Downstream{ + downstream, + }, + Security: &gatewayapi.Security{ + // The mesh-client is used to access this Route + DisableAccessControl: false, + }, + } + return nil + } + + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update proxy callback Route %q", ctypes.ObjectRefFromObject(route).String()) + } + + return route, nil +} + +// CreateCallbackRoute creates a Route for sending callback events to subscribers +// The Route is created once per zone where the event-feature is configured +// and points to an internal service +func CreateCallbackRoute( + ctx context.Context, + zone *adminv1.Zone, + opts ...Option, +) (*gatewayapi.Route, error) { + + options := &Options{} + for _, opt := range opts { + opt(options) + } + + c := cclient.ClientFromContextOrDie(ctx) + name := makeCallbackRouteName(zone.Name) + + gatewayRealm := &gatewayapi.Realm{} + err := c.Get(ctx, zone.Status.GatewayRealm.K8s(), gatewayRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", zone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", zone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(gatewayRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", gatewayRealm.Name) + } + + route := &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: zone.Status.Namespace, + }, + } + + upstream := gatewayapi.Upstream{ + Scheme: "http", + Host: "localhost", + Path: "/proxy", + Port: 8080, + } + + downstream, err := gatewayRealm.AsDownstream(makeCallbackRoutePath(zone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create downstream for callback Route") + } + mutator := func() error { + + err := options.apply(ctx, route) + if err != nil { + return errors.Wrap(err, "failed to apply options to callback Route") + } + + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + config.BuildLabelKey("zone"): zone.Name, + config.BuildLabelKey("realm"): gatewayRealm.Name, + config.BuildLabelKey("type"): "callback", + } + route.Spec = gatewayapi.RouteSpec{ + Realm: *ctypes.ObjectRefFromObject(gatewayRealm), + Upstreams: []gatewayapi.Upstream{ + upstream, + }, + Downstreams: []gatewayapi.Downstream{ + downstream, + }, + Security: &gatewayapi.Security{ + // The mesh-client is used to access this Route + DisableAccessControl: false, + }, + Traffic: gatewayapi.Traffic{ + DynamicUpstream: &gatewayapi.DynamicUpstream{ + // Use DynamicUpstream to extract the actual callback URL from a query parameter at runtime + QueryParameter: CallbackUrlQUeryParam, + }, + }, + } + if options.IsProxyTarget { + // If this Route is used as target of a proxy Route, + // the proxy-route will is the mesh-client. We need to allow access to this Route. + route.Spec.Security.DefaultConsumers = append(route.Spec.Security.DefaultConsumers, MeshClientName) + } + + return nil + } + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update callback Route %q", ctypes.ObjectRefFromObject(route).String()) + } + + return route, nil +} + +// CreateCallbackProxyRoutes creates cross-zone proxy Routes for callback delivery to remote subscribers. +// For each target zone, a Route is created in the source zone thats points to the target callback Route +// It is secured using OAuth2 credentials from the target zone's event service account. +func CreateCallbackProxyRoutes( + ctx context.Context, + meshConfig eventv1.MeshConfig, + sourceZone *adminv1.Zone, + targetZones []*adminv1.Zone, + opts ...Option, +) (map[string]*gatewayapi.Route, error) { + + logger := log.FromContext(ctx) + c := cclient.ClientFromContextOrDie(ctx) + + routes := map[string]*gatewayapi.Route{} + zones := collectZones(targetZones, meshConfig.FullMesh, meshConfig.ZoneNames) + logger.V(1).Info("Collected target zones for proxy callback Routes", "before", len(targetZones), "after", len(zones)) + + for _, targetZone := range zones { + if ctypes.Equals(sourceZone, targetZone) { + // ignore the source zone itself if it's included in the target zones (in case of full mesh) + continue + } + + // Get the mesh-client credentials for the target zone. + + meshClient := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{ + Name: MeshClientName, + Namespace: targetZone.Status.Namespace, + }, + } + + err := c.Get(ctx, client.ObjectKeyFromObject(meshClient), meshClient) + if err != nil { + return nil, errors.Wrapf(err, "failed to get mesh client credentials for target zone %q", targetZone.Name) + } + + route, err := CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient, opts...) + if err != nil { + return nil, errors.Wrapf(err, "failed to create proxy callback Route for target zone %q", targetZone.Name) + } + routes[targetZone.Name] = route + logger.V(1).Info("Created proxy callback Route for target zone", "targetZone", targetZone.Name, "route", ctypes.ObjectRefFromObject(route).String()) + } + + return routes, nil +} + +// collectZones filters the given candidate zones based on the mesh configuration. +// If fullMesh is true, all candidates are returned. +func collectZones(candidates []*adminv1.Zone, fullMesh bool, wanted []string) []*adminv1.Zone { + if fullMesh { + return candidates + } + + wantedSet := make(map[string]struct{}) + for _, name := range wanted { + wantedSet[name] = struct{}{} + } + + var collected []*adminv1.Zone + for _, zone := range candidates { + if _, ok := wantedSet[zone.Name]; ok { + collected = append(collected, zone) + } + } + + return collected +} diff --git a/event/internal/handler/util/callback_route_test.go b/event/internal/handler/util/callback_route_test.go new file mode 100644 index 00000000..cfcc9cb5 --- /dev/null +++ b/event/internal/handler/util/callback_route_test.go @@ -0,0 +1,643 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + mock "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func makeReadyGatewayRealm(name, ns string) *gatewayapi.Realm { + r := &gatewayapi.Realm{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: gatewayapi.RealmSpec{ + Url: "https://gateway.example.com:443", + IssuerUrl: "https://issuer.example.com", + }, + } + meta.SetStatusCondition(&r.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return r +} + +func makeNotReadyGatewayRealm(name, ns string) *gatewayapi.Realm { + return &gatewayapi.Realm{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: gatewayapi.RealmSpec{ + Url: "https://gateway.example.com:443", + IssuerUrl: "https://issuer.example.com", + }, + } +} + +func makeZone(name, ns, statusNs string, gwRealmName, gwRealmNs string) *adminv1.Zone { + return &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Status: adminv1.ZoneStatus{ + Namespace: statusNs, + GatewayRealm: &ctypes.ObjectRef{Name: gwRealmName, Namespace: gwRealmNs}, + }, + } +} + +// ---------- CreateCallbackRoute ---------- + +var _ = Describe("CreateCallbackRoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + zone *adminv1.Zone + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + zone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + }) + + It("should return BlockedError when realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-a")) + + route, err := util.CreateCallbackRoute(ctx, zone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreateCallbackRoute(ctx, zone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreateCallbackRoute(ctx, zone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should create callback route successfully", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateCallbackRoute(ctx, zone) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata + Expect(route.Name).To(Equal("callback--zone-a")) + Expect(route.Namespace).To(Equal("zone-a-ns")) + + // Verify labels + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "callback")) + + // Verify upstream: localhost:8080/proxy + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("http")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("localhost")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(8080)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/proxy")) + + // Verify downstream: from realm + Expect(route.Spec.Downstreams).To(HaveLen(1)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[0].Path).To(Equal("/zone-a/callback/v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify DynamicUpstream + Expect(route.Spec.Traffic.DynamicUpstream).ToNot(BeNil()) + Expect(route.Spec.Traffic.DynamicUpstream.QueryParameter).To(Equal("callback")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeFalse()) + Expect(route.Spec.Security.DefaultConsumers).To(BeEmpty()) + + // Verify realm ref + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-a")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + }) + + It("should add util.MeshClientName to DefaultConsumers when IsProxyTarget", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateCallbackRoute(ctx, zone, util.WithProxyTarget(true)) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DefaultConsumers).To(ContainElement("eventstore")) + }) + + It("should return error when CreateOrUpdate fails", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreateCallbackRoute(ctx, zone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update callback Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) +}) + +// ---------- CreateProxyCallbackRoute ---------- + +var _ = Describe("CreateProxyCallbackRoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + sourceZone *adminv1.Zone + targetZone *adminv1.Zone + meshClient *identityv1.Client + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + sourceZone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + targetZone = makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + meshClient = &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-b-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-id", + ClientSecret: "mesh-client-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.target.example.com", + }, + } + }) + + It("should return BlockedError when downstream realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-a")) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when downstream realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when downstream realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return BlockedError when upstream realm is not found", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-b")) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return BlockedError when upstream realm is not ready", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + notReadyTargetRealm := makeNotReadyGatewayRealm("gw-realm-b", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyTargetRealm + }). + Return(nil) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should create proxy callback route successfully", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + readyTargetRealm := makeReadyGatewayRealm("gw-realm-b", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyTargetRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata + Expect(route.Name).To(Equal("callback--zone-b")) + Expect(route.Namespace).To(Equal("zone-a-ns")) + + // Verify labels reference source zone + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "callback-proxy")) + + // Verify upstream: from target realm with mesh client credentials + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("https")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/zone-b/callback/v1")) + Expect(route.Spec.Upstreams[0].ClientId).To(Equal("mesh-client-id")) + Expect(route.Spec.Upstreams[0].ClientSecret).To(Equal("mesh-client-secret")) + Expect(route.Spec.Upstreams[0].IssuerUrl).To(Equal("https://issuer.target.example.com")) + + // Verify downstream: from source realm + Expect(route.Spec.Downstreams).To(HaveLen(1)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[0].Path).To(Equal("/zone-b/callback/v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeFalse()) + + // Verify realm ref points to downstream (source) realm + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-a")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + }) + + It("should return error when CreateOrUpdate fails", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + readyTargetRealm := makeReadyGatewayRealm("gw-realm-b", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyTargetRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreateProxyCallbackRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update proxy callback Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) +}) + +// ---------- CreateCallbackProxyRoutes ---------- + +var _ = Describe("CreateCallbackProxyRoutes", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + sourceZone *adminv1.Zone + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + sourceZone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + }) + + It("should return empty map when no target zones after filtering", func() { + meshConfig := eventv1.MeshConfig{ + FullMesh: false, + ZoneNames: []string{}, + } + targetZones := []*adminv1.Zone{ + makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default"), + } + + routes, err := util.CreateCallbackProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).ToNot(HaveOccurred()) + Expect(routes).To(BeEmpty()) + }) + + It("should skip source zone in full mesh", func() { + meshConfig := eventv1.MeshConfig{FullMesh: true} + targetZoneB := makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + // Include source zone in targets to test skipping + targetZones := []*adminv1.Zone{sourceZone, targetZoneB} + + meshClientObj := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-b-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-id", + ClientSecret: "mesh-client-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.target.example.com", + }, + } + + // Get mesh client for zone-b + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-b-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClientObj + }). + Return(nil) + + // Get source realm (downstream) for proxy route creation + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + // Get target realm (upstream) + readyTargetRealm := makeReadyGatewayRealm("gw-realm-b", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyTargetRealm + }). + Return(nil) + + // CreateOrUpdate for proxy route + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(controllerutil.OperationResultCreated, nil).Once() + + routes, err := util.CreateCallbackProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).ToNot(HaveOccurred()) + Expect(routes).To(HaveLen(1)) + Expect(routes).To(HaveKey("zone-b")) + }) + + It("should return error when mesh client Get fails", func() { + meshConfig := eventv1.MeshConfig{FullMesh: true} + targetZoneB := makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + targetZones := []*adminv1.Zone{targetZoneB} + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-b-ns"}, mock.AnythingOfType("*v1.Client")). + Return(fmt.Errorf("client not found")) + + routes, err := util.CreateCallbackProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).To(HaveOccurred()) + Expect(routes).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get mesh client credentials")) + Expect(err.Error()).To(ContainSubstring("client not found")) + }) + + It("should create routes for multiple target zones", func() { + meshConfig := eventv1.MeshConfig{FullMesh: true} + targetZoneB := makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + targetZoneC := makeZone("zone-c", "default", "zone-c-ns", "gw-realm-c", "default") + targetZones := []*adminv1.Zone{targetZoneB, targetZoneC} + + meshClientB := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-b-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-b-id", + ClientSecret: "mesh-client-b-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.b.example.com", + }, + } + meshClientC := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-c-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-c-id", + ClientSecret: "mesh-client-c-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.c.example.com", + }, + } + + // Get mesh client for zone-b + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-b-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClientB + }). + Return(nil).Once() + + // Get source realm for zone-b proxy route + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil).Times(2) // called for both zone-b and zone-c + + // Get target realm for zone-b proxy route + readyRealmB := makeReadyGatewayRealm("gw-realm-b", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealmB + }). + Return(nil).Once() + + // CreateOrUpdate for zone-b proxy route + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(controllerutil.OperationResultCreated, nil).Once() + + // Get mesh client for zone-c + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-c-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClientC + }). + Return(nil).Once() + + // Get target realm for zone-c proxy route + readyRealmC := makeReadyGatewayRealm("gw-realm-c", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-c", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealmC + }). + Return(nil).Once() + + // CreateOrUpdate for zone-c proxy route + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(controllerutil.OperationResultCreated, nil).Once() + + routes, err := util.CreateCallbackProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).ToNot(HaveOccurred()) + Expect(routes).To(HaveLen(2)) + Expect(routes).To(HaveKey("zone-b")) + Expect(routes).To(HaveKey("zone-c")) + }) +}) diff --git a/event/internal/handler/util/getters.go b/event/internal/handler/util/getters.go new file mode 100644 index 00000000..72da9a32 --- /dev/null +++ b/event/internal/handler/util/getters.go @@ -0,0 +1,316 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + "slices" + "sort" + + "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + applicationapi "github.com/telekom/controlplane/application/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/index" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// GetZone retrieves a Zone object by ObjectRef and ensures it is ready. +func GetZone(ctx context.Context, ref client.ObjectKey) (*adminv1.Zone, error) { + c := cclient.ClientFromContextOrDie(ctx) + + zone := &adminv1.Zone{} + err := c.Get(ctx, ref, zone) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("zone %q not found", ref.String()) + } + return nil, errors.Wrapf(err, "failed to get zone %q", ref.String()) + } + if err := condition.EnsureReady(zone); err != nil { + return nil, ctrlerrors.BlockedErrorf("zone %q is not ready", ref.String()) + } + + return zone, nil +} + +// GetEventConfigForZone finds the EventConfig for a given zone name using the field index. +// Returns BlockedError if no EventConfig is found or if it is not ready. +func GetEventConfigForZone(ctx context.Context, zoneName string) (*eventv1.EventConfig, error) { + c := cclient.ClientFromContextOrDie(ctx) + + eventConfigList := &eventv1.EventConfigList{} + err := c.List(ctx, eventConfigList, + client.MatchingFields{index.EventConfigZoneIndex: zoneName}) + if err != nil { + return nil, errors.Wrapf(err, "failed to list EventConfigs for zone %q", zoneName) + } + + if len(eventConfigList.Items) == 0 { + return nil, ctrlerrors.BlockedErrorf("no EventConfig found for zone %q", zoneName) + } + + // Should be exactly 1 (1:1 zone-to-eventconfig), but be defensive + if len(eventConfigList.Items) > 1 { + slices.SortStableFunc(eventConfigList.Items, func(a, b eventv1.EventConfig) int { + return a.CreationTimestamp.Compare(b.CreationTimestamp.Time) + }) + log.FromContext(ctx).Info("multiple EventConfigs found for zone, using first", "zone", zoneName, "count", len(eventConfigList.Items)) + } + eventConfig := &eventConfigList.Items[0] + + if err := condition.EnsureReady(eventConfig); err != nil { + return nil, ctrlerrors.BlockedErrorf("EventConfig %q for zone %q is not ready", eventConfig.Name, zoneName) + } + + return eventConfig, nil +} + +// GetEventStoreForZone resolves the Zone -> EventConfig -> EventStore chain. +// Returns the ready EventStore for a given zone name. +func GetEventStoreForZone(ctx context.Context, zoneName string) (*pubsubv1.EventStore, error) { + c := cclient.ClientFromContextOrDie(ctx) + + eventConfig, err := GetEventConfigForZone(ctx, zoneName) + if err != nil { + return nil, err + } + + if eventConfig.Status.EventStore == nil { + return nil, ctrlerrors.BlockedErrorf("EventConfig %q has no EventStore reference yet", eventConfig.Name) + } + + eventStore := &pubsubv1.EventStore{} + err = c.Get(ctx, eventConfig.Status.EventStore.K8s(), eventStore) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("EventStore %q not found", eventConfig.Status.EventStore.String()) + } + return nil, errors.Wrapf(err, "failed to get EventStore %q", eventConfig.Status.EventStore.String()) + } + + if err := condition.EnsureReady(eventStore); err != nil { + return nil, ctrlerrors.BlockedErrorf("EventStore %q is not ready", eventStore.Name) + } + + return eventStore, nil +} + +// FindActiveEventType finds the active EventType for a given event type string. +// Returns (found, eventType, error). If found is false, there is no active EventType. +func FindActiveEventType(ctx context.Context, eventType string) (bool, *eventv1.EventType, error) { + c := cclient.ClientFromContextOrDie(ctx) + + eventTypeList := &eventv1.EventTypeList{} + if err := c.List(ctx, eventTypeList); err != nil { + return false, nil, errors.Wrapf(err, "failed to list EventTypes for type %q", eventType) + } + + // Filter to matching type and sort by creation timestamp (oldest first) + var candidates []eventv1.EventType + for _, et := range eventTypeList.Items { + if et.Spec.Type == eventType && et.Status.Active { + candidates = append(candidates, et) + } + } + + if len(candidates) == 0 { + return false, nil, nil + } + + // Sort by CreationTimestamp ascending and return the oldest active one + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].CreationTimestamp.Before(&candidates[j].CreationTimestamp) + }) + + activeET := &candidates[0] + if err := condition.EnsureReady(activeET); err != nil { + return false, activeET, ctrlerrors.BlockedErrorf("EventType %q is not ready", eventType) + } + + return true, activeET, nil +} + +// GetApplication retrieves an Application object by ObjectRef and ensures it is ready. +func GetApplication(ctx context.Context, ref types.ObjectRef) (*applicationapi.Application, error) { + c := cclient.ClientFromContextOrDie(ctx) + + application := &applicationapi.Application{} + err := c.Get(ctx, ref.K8s(), application) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("application %q not found", ref.String()) + } + return nil, errors.Wrapf(err, "failed to get application %q", ref.String()) + } + if err := condition.EnsureReady(application); err != nil { + return nil, ctrlerrors.BlockedErrorf("application %q is not ready", ref.String()) + } + + return application, nil +} + +// FindCrossZoneSSESubscriptionZones lists all EventSubscriptions for a given event type +// and returns the unique zone ObjectRefs where cross-zone SSE subscriptions exist. +// A subscription is cross-zone if its zone differs from the exposure's zone, +// and is SSE if its delivery type is "ServerSentEvent". +func FindCrossZoneSSESubscriptionZones(ctx context.Context, eventType string, exposureZoneName string) ([]types.ObjectRef, error) { + c := cclient.ClientFromContextOrDie(ctx) + logger := log.FromContext(ctx) + + subList := &eventv1.EventSubscriptionList{} + if err := c.List(ctx, subList, client.MatchingLabels{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType), + }); err != nil { + return nil, errors.Wrapf(err, "failed to list EventSubscriptions for event type %q", eventType) + } + + seen := make(map[string]bool) + var zones []types.ObjectRef + for _, sub := range subList.Items { + if sub.Spec.EventType != eventType { + continue + } + if sub.Spec.Delivery.Type != eventv1.DeliveryTypeServerSentEvent { + continue + } + if sub.Spec.Zone.Name == exposureZoneName { + continue + } + + approvalCond := meta.FindStatusCondition(sub.GetConditions(), "ApprovalGranted") + if approvalCond == nil || approvalCond.Status != metav1.ConditionTrue { + logger.Info("Skipping subscription with missing approval", "subscription", sub.Name, "zone", sub.Spec.Zone.Name, "reason", approvalCond.Reason) + continue + } + + zoneName := sub.Spec.Zone.Name + if !seen[zoneName] { + seen[zoneName] = true + zones = append(zones, sub.Spec.Zone) + } + } + + return zones, nil +} + +// AnyOtherEventExposureExists checks if any other EventExposure (active or inactive) +// exists for the given event type, excluding the one with the given UID. +// This is used in the Delete handler to determine whether the Route should be preserved +// for a standby exposure to take over. +func AnyOtherEventExposureExists(ctx context.Context, eventType string, excludeUID k8stypes.UID) (bool, error) { + candidates, err := FindEventExposures(ctx, eventType) + if err != nil { + return false, err + } + + for _, exp := range candidates { + if exp.UID == excludeUID { + continue + } + if exp.Spec.EventType == eventType { + return true, nil + } + } + + return false, nil +} + +// FindEventExposures lists all EventExposures for a given event type, regardless of status. +func FindEventExposures(ctx context.Context, eventType string) ([]eventv1.EventExposure, error) { + c := cclient.ClientFromContextOrDie(ctx) + + exposureList := &eventv1.EventExposureList{} + if err := c.List(ctx, exposureList, client.MatchingLabels{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType), + }); err != nil { + return nil, errors.Wrapf(err, "failed to list EventExposures for type %q", eventType) + } + + var exposures []eventv1.EventExposure + for _, exp := range exposureList.Items { + if exp.Spec.EventType == eventType { + exposures = append(exposures, exp) + } + } + + return exposures, nil +} + +// FindActiveEventExposure finds the active EventExposure for a given event type. +// It should be used in combination with FindEventExposures to avoid multiple list calls. +func FindActiveEventExposure(exposures []eventv1.EventExposure) (bool, *eventv1.EventExposure, error) { + if len(exposures) == 0 { + return false, nil, nil + } + + var candidates []eventv1.EventExposure + for _, exp := range exposures { + if exp.Status.Active { + candidates = append(candidates, exp) + } + } + + if len(candidates) == 0 { + return false, nil, nil + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].CreationTimestamp.Before(&candidates[j].CreationTimestamp) + }) + + activeExp := &candidates[0] + if err := condition.EnsureReady(activeExp); err != nil { + return false, activeExp, ctrlerrors.BlockedErrorf("EventExposure %q is not ready", activeExp.Name) + } + + return true, activeExp, nil +} + +func FindCrossZoneCallbackSubscriptions(ctx context.Context, eventType string, exposureZoneName string) ([]eventv1.EventSubscription, error) { + c := cclient.ClientFromContextOrDie(ctx) + logger := log.FromContext(ctx) + + subList := &eventv1.EventSubscriptionList{} + if err := c.List(ctx, subList, client.MatchingLabels{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType), + }); err != nil { + return nil, errors.Wrapf(err, "failed to list EventSubscriptions for event type %q", eventType) + } + + var subs []eventv1.EventSubscription + for _, sub := range subList.Items { + if sub.Spec.EventType != eventType { + continue + } + if sub.Spec.Delivery.Type != eventv1.DeliveryTypeCallback { + continue + } + if sub.Spec.Zone.Name == exposureZoneName { + continue + } + + approvalCond := meta.FindStatusCondition(sub.GetConditions(), "ApprovalGranted") + if approvalCond == nil || approvalCond.Status != metav1.ConditionTrue { + logger.Info("Skipping subscription with missing approval", "subscription", sub.Name, "zone", sub.Spec.Zone.Name, "reason", approvalCond.Reason) + continue + } + + subs = append(subs, sub) + } + + return subs, nil +} diff --git a/event/internal/handler/util/getters_test.go b/event/internal/handler/util/getters_test.go new file mode 100644 index 00000000..d76ae486 --- /dev/null +++ b/event/internal/handler/util/getters_test.go @@ -0,0 +1,1172 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + mock "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + applicationapi "github.com/telekom/controlplane/application/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// unwrapAll follows the pkg/errors Cause chain to the root error. +func unwrapAll(err error) error { + for { + cause, ok := err.(interface{ Cause() error }) + if !ok { + return err + } + err = cause.Cause() + } +} + +// isBlockedError checks if the error implements the BlockedError interface. +func isBlockedError(err error) bool { + be, ok := err.(ctrlerrors.BlockedError) + return ok && be.IsBlocked() +} + +// setReady sets the Ready condition to True on a resource. +func setReady(obj ctypes.Object) { + meta.SetStatusCondition(conditionsPtr(obj), metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) +} + +// conditionsPtr returns a pointer to the conditions slice for the given object. +func conditionsPtr(obj ctypes.Object) *[]metav1.Condition { + switch o := obj.(type) { + case *adminv1.Zone: + return &o.Status.Conditions + case *eventv1.EventConfig: + return &o.Status.Conditions + case *eventv1.EventType: + return &o.Status.Conditions + case *eventv1.EventExposure: + return &o.Status.Conditions + case *applicationapi.Application: + return &o.Status.Conditions + case *pubsubv1.EventStore: + return &o.Status.Conditions + default: + panic(fmt.Sprintf("unsupported type %T", obj)) + } +} + +// ---------- GetZone ---------- + +var _ = Describe("GetZone", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ref client.ObjectKey + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + ref = client.ObjectKey{Name: "zone-a", Namespace: "default"} + }) + + It("should return zone when found and ready", func() { + expected := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{Name: "zone-a", Namespace: "default"}, + } + setReady(expected) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "zone-a", Namespace: "default"}, &adminv1.Zone{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*adminv1.Zone) = *expected + }). + Return(nil) + + result, err := util.GetZone(ctx, ref) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("zone-a")) + }) + + It("should return BlockedError when zone is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "zone-a", Namespace: "default"}, &adminv1.Zone{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "admin.cp.ei.telekom.de", Resource: "zones"}, "zone-a")) + + result, err := util.GetZone(ctx, ref) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return wrapped error on unexpected Get failure", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "zone-a", Namespace: "default"}, &adminv1.Zone{}). + Return(fmt.Errorf("connection refused")) + + result, err := util.GetZone(ctx, ref) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get zone")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when zone is not ready", func() { + notReady := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{Name: "zone-a", Namespace: "default"}, + } + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "zone-a", Namespace: "default"}, &adminv1.Zone{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*adminv1.Zone) = *notReady + }). + Return(nil) + + result, err := util.GetZone(ctx, ref) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) +}) + +// ---------- GetEventConfigForZone ---------- + +var _ = Describe("GetEventConfigForZone", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return EventConfig when single match found and ready", func() { + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + Spec: eventv1.EventConfigSpec{Zone: ctypes.ObjectRef{Name: "zone-a", Namespace: "default"}}, + } + setReady(&ec) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec}} + }). + Return(nil) + + result, err := util.GetEventConfigForZone(ctx, "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("ec-zone-a")) + }) + + It("should return wrapped error on List failure", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Return(fmt.Errorf("api server unavailable")) + + result, err := util.GetEventConfigForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to list EventConfigs")) + Expect(err.Error()).To(ContainSubstring("api server unavailable")) + }) + + It("should return BlockedError when no EventConfig found", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{}} + }). + Return(nil) + + result, err := util.GetEventConfigForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("no EventConfig found")) + }) + + It("should pick the first (oldest) when multiple EventConfigs found", func() { + now := metav1.Now() + later := metav1.NewTime(now.Add(time.Minute)) + + ec1 := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ec-old", + Namespace: "default", + CreationTimestamp: now, + }, + } + setReady(&ec1) + + ec2 := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ec-new", + Namespace: "default", + CreationTimestamp: later, + }, + } + setReady(&ec2) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec2, ec1}} + }). + Return(nil) + + result, err := util.GetEventConfigForZone(ctx, "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("ec-old")) + }) + + It("should return BlockedError when EventConfig is not ready", func() { + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + } + // Not ready — no condition set + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec}} + }). + Return(nil) + + result, err := util.GetEventConfigForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) +}) + +// ---------- GetEventStoreForZone ---------- + +var _ = Describe("GetEventStoreForZone", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return EventStore when EventConfig and EventStore both found and ready", func() { + esRef := ctypes.ObjectRef{Name: "es-1", Namespace: "default"} + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + Status: eventv1.EventConfigStatus{EventStore: &esRef}, + } + setReady(&ec) + + es := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{Name: "es-1", Namespace: "default"}, + } + setReady(es) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec}} + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "es-1", Namespace: "default"}, &pubsubv1.EventStore{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *es + }). + Return(nil) + + result, err := util.GetEventStoreForZone(ctx, "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("es-1")) + }) + + It("should propagate error when EventConfig lookup fails", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Return(fmt.Errorf("list failed")) + + result, err := util.GetEventStoreForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("list failed")) + }) + + It("should return BlockedError when EventStore ref is nil", func() { + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + Status: eventv1.EventConfigStatus{EventStore: nil}, + } + setReady(&ec) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec}} + }). + Return(nil) + + result, err := util.GetEventStoreForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("no EventStore reference")) + }) + + It("should return BlockedError when EventStore is not found", func() { + esRef := ctypes.ObjectRef{Name: "es-missing", Namespace: "default"} + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + Status: eventv1.EventConfigStatus{EventStore: &esRef}, + } + setReady(&ec) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec}} + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "es-missing", Namespace: "default"}, &pubsubv1.EventStore{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "eventstores"}, "es-missing")) + + result, err := util.GetEventStoreForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return BlockedError when EventStore is not ready", func() { + esRef := ctypes.ObjectRef{Name: "es-1", Namespace: "default"} + ec := eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + Status: eventv1.EventConfigStatus{EventStore: &esRef}, + } + setReady(&ec) + + es := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{Name: "es-1", Namespace: "default"}, + } + // Not ready — no condition set + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventConfigList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventConfigList) = eventv1.EventConfigList{Items: []eventv1.EventConfig{ec}} + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "es-1", Namespace: "default"}, &pubsubv1.EventStore{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *es + }). + Return(nil) + + result, err := util.GetEventStoreForZone(ctx, "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) +}) + +// ---------- FindActiveEventType ---------- + +var _ = Describe("FindActiveEventType", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return error on List failure", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Return(fmt.Errorf("timeout")) + + found, result, err := util.FindActiveEventType(ctx, "de.telekom.test.v1") + Expect(err).To(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to list EventTypes")) + }) + + It("should return false when no matching type found", func() { + items := []eventv1.EventType{ + { + ObjectMeta: metav1.ObjectMeta{Name: "other-type"}, + Spec: eventv1.EventTypeSpec{Type: "de.telekom.other.v1"}, + Status: eventv1.EventTypeStatus{Active: true}, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{Items: items} + }). + Return(nil) + + found, result, err := util.FindActiveEventType(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).To(BeNil()) + }) + + It("should return false when matching type exists but none are active", func() { + items := []eventv1.EventType{ + { + ObjectMeta: metav1.ObjectMeta{Name: "test-type"}, + Spec: eventv1.EventTypeSpec{Type: "de.telekom.test.v1"}, + Status: eventv1.EventTypeStatus{Active: false}, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{Items: items} + }). + Return(nil) + + found, result, err := util.FindActiveEventType(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).To(BeNil()) + }) + + It("should return active EventType when found and ready", func() { + et := eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{Name: "test-type", CreationTimestamp: metav1.Now()}, + Spec: eventv1.EventTypeSpec{Type: "de.telekom.test.v1"}, + Status: eventv1.EventTypeStatus{Active: true}, + } + setReady(&et) + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{Items: []eventv1.EventType{et}} + }). + Return(nil) + + found, result, err := util.FindActiveEventType(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("test-type")) + }) + + It("should return BlockedError when active EventType is not ready", func() { + et := eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{Name: "test-type", CreationTimestamp: metav1.Now()}, + Spec: eventv1.EventTypeSpec{Type: "de.telekom.test.v1"}, + Status: eventv1.EventTypeStatus{Active: true}, + } + // Not ready + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventTypeList{}). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventTypeList) = eventv1.EventTypeList{Items: []eventv1.EventType{et}} + }). + Return(nil) + + found, result, err := util.FindActiveEventType(ctx, "de.telekom.test.v1") + Expect(err).To(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).ToNot(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) +}) + +// ---------- GetApplication ---------- + +var _ = Describe("GetApplication", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + objRef ctypes.ObjectRef + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + objRef = ctypes.ObjectRef{Name: "my-app", Namespace: "default"} + }) + + It("should return application when found and ready", func() { + expected := &applicationapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "default"}, + } + setReady(expected) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "my-app", Namespace: "default"}, &applicationapi.Application{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*applicationapi.Application) = *expected + }). + Return(nil) + + result, err := util.GetApplication(ctx, objRef) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("my-app")) + }) + + It("should return BlockedError when application is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "my-app", Namespace: "default"}, &applicationapi.Application{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "application.cp.ei.telekom.de", Resource: "applications"}, "my-app")) + + result, err := util.GetApplication(ctx, objRef) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return wrapped error on unexpected Get failure", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "my-app", Namespace: "default"}, &applicationapi.Application{}). + Return(fmt.Errorf("connection refused")) + + result, err := util.GetApplication(ctx, objRef) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get application")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when application is not ready", func() { + notReady := &applicationapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "default"}, + } + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "my-app", Namespace: "default"}, &applicationapi.Application{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*applicationapi.Application) = *notReady + }). + Return(nil) + + result, err := util.GetApplication(ctx, objRef) + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) +}) + +// ---------- FindCrossZoneSSESubscriptionZones ---------- + +var _ = Describe("FindCrossZoneSSESubscriptionZones", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return error on List failure", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Return(fmt.Errorf("list error")) + + result, err := util.FindCrossZoneSSESubscriptionZones(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to list EventSubscriptions")) + }) + + It("should return empty when no matching subscriptions", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: []eventv1.EventSubscription{}} + }). + Return(nil) + + result, err := util.FindCrossZoneSSESubscriptionZones(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("should filter correctly: skip wrong type, non-SSE, same-zone, unapproved", func() { + // Build subscriptions covering each skip condition + subs := []eventv1.EventSubscription{ + // Wrong event type → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-wrong-type"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.other.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + }, + // Non-SSE delivery → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-callback"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + }, + // Same zone → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-same-zone"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-a", Namespace: "default"}, + }, + }, + // Unapproved (non-nil condition with Status=False) → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-unapproved"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-c", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + { + Type: "ApprovalGranted", + Status: metav1.ConditionFalse, + Reason: "Denied", + }, + }, + }, + }, + // Valid cross-zone SSE approved → should be included + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-valid"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + { + Type: "ApprovalGranted", + Status: metav1.ConditionTrue, + Reason: "Approved", + }, + }, + }, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: subs} + }). + Return(nil) + + result, err := util.FindCrossZoneSSESubscriptionZones(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("zone-b")) + }) + + It("should deduplicate zones", func() { + subs := []eventv1.EventSubscription{ + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-1"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + {Type: "ApprovalGranted", Status: metav1.ConditionTrue, Reason: "Approved"}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-2"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + {Type: "ApprovalGranted", Status: metav1.ConditionTrue, Reason: "Approved"}, + }, + }, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: subs} + }). + Return(nil) + + result, err := util.FindCrossZoneSSESubscriptionZones(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("zone-b")) + }) +}) + +// ---------- AnyOtherEventExposureExists ---------- + +var _ = Describe("AnyOtherEventExposureExists", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return error when FindEventExposures fails", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventExposureList{}, mock.Anything). + Return(fmt.Errorf("list error")) + + found, err := util.AnyOtherEventExposureExists(ctx, "de.telekom.test.v1", "uid-1") + Expect(err).To(HaveOccurred()) + Expect(found).To(BeFalse()) + }) + + It("should return false when no other exposure exists", func() { + items := []eventv1.EventExposure{ + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-self", Namespace: "default", UID: "uid-1"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.test.v1"}, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventExposureList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventExposureList) = eventv1.EventExposureList{Items: items} + }). + Return(nil) + + found, err := util.AnyOtherEventExposureExists(ctx, "de.telekom.test.v1", "uid-1") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + }) + + It("should return true when another exposure exists", func() { + items := []eventv1.EventExposure{ + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-self", Namespace: "default", UID: "uid-1"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.test.v1"}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-other", Namespace: "default", UID: "uid-2"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.test.v1"}, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventExposureList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventExposureList) = eventv1.EventExposureList{Items: items} + }). + Return(nil) + + found, err := util.AnyOtherEventExposureExists(ctx, "de.telekom.test.v1", "uid-1") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + }) +}) + +// ---------- FindEventExposures ---------- + +var _ = Describe("FindEventExposures", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return error on List failure", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventExposureList{}, mock.Anything). + Return(fmt.Errorf("api error")) + + result, err := util.FindEventExposures(ctx, "de.telekom.test.v1") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to list EventExposures")) + }) + + It("should return empty slice when no matches", func() { + items := []eventv1.EventExposure{ + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-other"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.other.v1"}, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventExposureList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventExposureList) = eventv1.EventExposureList{Items: items} + }). + Return(nil) + + result, err := util.FindEventExposures(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("should return matching exposures filtered by eventType", func() { + items := []eventv1.EventExposure{ + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-match"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.test.v1"}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-other"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.other.v1"}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-match-2"}, + Spec: eventv1.EventExposureSpec{EventType: "de.telekom.test.v1"}, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventExposureList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventExposureList) = eventv1.EventExposureList{Items: items} + }). + Return(nil) + + result, err := util.FindEventExposures(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("exp-match")) + Expect(result[1].Name).To(Equal("exp-match-2")) + }) +}) + +// ---------- FindActiveEventExposure (pure function) ---------- + +var _ = Describe("FindActiveEventExposure", func() { + It("should return false when exposure list is empty", func() { + found, result, err := util.FindActiveEventExposure([]eventv1.EventExposure{}) + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).To(BeNil()) + }) + + It("should return false when none are active", func() { + exposures := []eventv1.EventExposure{ + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-1"}, + Status: eventv1.EventExposureStatus{Active: false}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "exp-2"}, + Status: eventv1.EventExposureStatus{Active: false}, + }, + } + + found, result, err := util.FindActiveEventExposure(exposures) + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).To(BeNil()) + }) + + It("should return active exposure when found and ready", func() { + exp := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{Name: "exp-active", CreationTimestamp: metav1.Now()}, + Status: eventv1.EventExposureStatus{Active: true}, + } + setReady(&exp) + + found, result, err := util.FindActiveEventExposure([]eventv1.EventExposure{exp}) + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("exp-active")) + }) + + It("should pick the oldest active exposure by creation time", func() { + now := metav1.Now() + later := metav1.NewTime(now.Add(time.Minute)) + + expOld := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{Name: "exp-old", CreationTimestamp: now}, + Status: eventv1.EventExposureStatus{Active: true}, + } + setReady(&expOld) + + expNew := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{Name: "exp-new", CreationTimestamp: later}, + Status: eventv1.EventExposureStatus{Active: true}, + } + setReady(&expNew) + + // Pass new first to ensure sorting works + found, result, err := util.FindActiveEventExposure([]eventv1.EventExposure{expNew, expOld}) + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("exp-old")) + }) + + It("should return BlockedError when active exposure is not ready", func() { + exp := eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{Name: "exp-active", CreationTimestamp: metav1.Now()}, + Status: eventv1.EventExposureStatus{Active: true}, + } + // Not ready — no condition set + + found, result, err := util.FindActiveEventExposure([]eventv1.EventExposure{exp}) + Expect(err).To(HaveOccurred()) + Expect(found).To(BeFalse()) + Expect(result).ToNot(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) +}) + +// ---------- FindCrossZoneCallbackSubscriptions ---------- + +var _ = Describe("FindCrossZoneCallbackSubscriptions", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return error on List failure", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Return(fmt.Errorf("list error")) + + result, err := util.FindCrossZoneCallbackSubscriptions(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to list EventSubscriptions")) + }) + + It("should return empty when no matching subscriptions", func() { + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: []eventv1.EventSubscription{}} + }). + Return(nil) + + result, err := util.FindCrossZoneCallbackSubscriptions(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("should filter correctly: skip wrong type, non-callback, same-zone, unapproved", func() { + subs := []eventv1.EventSubscription{ + // Wrong event type → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-wrong-type"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.other.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + }, + // Non-callback delivery (SSE) → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-sse"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeServerSentEvent}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + }, + // Same zone → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-same-zone"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-a", Namespace: "default"}, + }, + }, + // Unapproved (non-nil condition with Status=False) → skip + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-unapproved"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-c", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + { + Type: "ApprovalGranted", + Status: metav1.ConditionFalse, + Reason: "Denied", + }, + }, + }, + }, + // Valid cross-zone callback approved → should be included + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-valid"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + { + Type: "ApprovalGranted", + Status: metav1.ConditionTrue, + Reason: "Approved", + }, + }, + }, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: subs} + }). + Return(nil) + + result, err := util.FindCrossZoneCallbackSubscriptions(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("sub-valid")) + }) + + It("should return multiple matching subscriptions", func() { + subs := []eventv1.EventSubscription{ + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-1"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-b", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + {Type: "ApprovalGranted", Status: metav1.ConditionTrue, Reason: "Approved"}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "sub-2"}, + Spec: eventv1.EventSubscriptionSpec{ + EventType: "de.telekom.test.v1", + Delivery: eventv1.Delivery{Type: eventv1.DeliveryTypeCallback}, + Zone: ctypes.ObjectRef{Name: "zone-c", Namespace: "default"}, + }, + Status: eventv1.EventSubscriptionStatus{ + Conditions: []metav1.Condition{ + {Type: "ApprovalGranted", Status: metav1.ConditionTrue, Reason: "Approved"}, + }, + }, + }, + } + + fakeClient.EXPECT(). + List(ctx, &eventv1.EventSubscriptionList{}, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + *list.(*eventv1.EventSubscriptionList) = eventv1.EventSubscriptionList{Items: subs} + }). + Return(nil) + + result, err := util.FindCrossZoneCallbackSubscriptions(ctx, "de.telekom.test.v1", "zone-a") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("sub-1")) + Expect(result[1].Name).To(Equal("sub-2")) + }) +}) diff --git a/event/internal/handler/util/helpers_test.go b/event/internal/handler/util/helpers_test.go new file mode 100644 index 00000000..deea025b --- /dev/null +++ b/event/internal/handler/util/helpers_test.go @@ -0,0 +1,202 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + "testing" + + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func TestCollectZones(t *testing.T) { + zoneA := &adminv1.Zone{ObjectMeta: metav1.ObjectMeta{Name: "zone-a"}} + zoneB := &adminv1.Zone{ObjectMeta: metav1.ObjectMeta{Name: "zone-b"}} + zoneC := &adminv1.Zone{ObjectMeta: metav1.ObjectMeta{Name: "zone-c"}} + + candidates := []*adminv1.Zone{zoneA, zoneB, zoneC} + + tests := []struct { + name string + candidates []*adminv1.Zone + fullMesh bool + wanted []string + wantNames []string + }{ + { + name: "fullMesh=true returns all candidates", + candidates: candidates, + fullMesh: true, + wanted: nil, + wantNames: []string{"zone-a", "zone-b", "zone-c"}, + }, + { + name: "fullMesh=false with matching wanted names filters correctly", + candidates: candidates, + fullMesh: false, + wanted: []string{"zone-a", "zone-c"}, + wantNames: []string{"zone-a", "zone-c"}, + }, + { + name: "fullMesh=false with empty wanted list returns empty", + candidates: candidates, + fullMesh: false, + wanted: []string{}, + wantNames: nil, + }, + { + name: "fullMesh=false with no matching candidates returns empty", + candidates: candidates, + fullMesh: false, + wanted: []string{"zone-x", "zone-y"}, + wantNames: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := collectZones(tc.candidates, tc.fullMesh, tc.wanted) + + if len(got) != len(tc.wantNames) { + t.Fatalf("collectZones() returned %d zones, want %d", len(got), len(tc.wantNames)) + } + + for i, zone := range got { + if zone.Name != tc.wantNames[i] { + t.Errorf("collectZones()[%d].Name = %q, want %q", i, zone.Name, tc.wantNames[i]) + } + } + }) + } +} + +func TestOptionsApply(t *testing.T) { + tests := []struct { + name string + setupMock func(t *testing.T) (*fakeclient.MockJanitorClient, context.Context) + options Options + route *gatewayapi.Route + wantErr bool + wantOwner bool + }{ + { + name: "Owner is nil returns nil without calling Scheme", + setupMock: func(t *testing.T) (*fakeclient.MockJanitorClient, context.Context) { + mockClient := fakeclient.NewMockJanitorClient(t) + mockClient.EXPECT().Scheme().Return(nil).Maybe() + ctx := cclient.WithClient(context.Background(), mockClient) + return mockClient, ctx + }, + options: Options{Owner: nil}, + route: &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + }, + wantErr: false, + wantOwner: false, + }, + { + name: "Owner set with proper scheme sets owner reference", + setupMock: func(t *testing.T) (*fakeclient.MockJanitorClient, context.Context) { + s := runtime.NewScheme() + if err := eventv1.AddToScheme(s); err != nil { + t.Fatalf("failed to add eventv1 to scheme: %v", err) + } + if err := gatewayapi.AddToScheme(s); err != nil { + t.Fatalf("failed to add gatewayapi to scheme: %v", err) + } + mockClient := fakeclient.NewMockJanitorClient(t) + mockClient.EXPECT().Scheme().Return(s).Maybe() + ctx := cclient.WithClient(context.Background(), mockClient) + return mockClient, ctx + }, + options: Options{ + Owner: &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventconfig", + Namespace: "default", + UID: types.UID("test-uid-1234"), + }, + }, + }, + route: &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + }, + wantErr: false, + wantOwner: true, + }, + { + name: "Owner set but scheme missing owner type returns error", + setupMock: func(t *testing.T) (*fakeclient.MockJanitorClient, context.Context) { + emptyScheme := runtime.NewScheme() + mockClient := fakeclient.NewMockJanitorClient(t) + mockClient.EXPECT().Scheme().Return(emptyScheme).Maybe() + ctx := cclient.WithClient(context.Background(), mockClient) + return mockClient, ctx + }, + options: Options{ + Owner: &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventconfig", + Namespace: "default", + UID: types.UID("test-uid-1234"), + }, + }, + }, + route: &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + }, + wantErr: true, + wantOwner: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, ctx := tc.setupMock(t) + + err := tc.options.apply(ctx, tc.route) + + if tc.wantErr && err == nil { + t.Fatal("apply() expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("apply() unexpected error: %v", err) + } + + ownerRefs := tc.route.GetOwnerReferences() + if tc.wantOwner { + if len(ownerRefs) == 0 { + t.Fatal("apply() expected owner reference to be set, but none found") + } + if ownerRefs[0].Name != tc.options.Owner.GetName() { + t.Errorf("owner reference name = %q, want %q", ownerRefs[0].Name, tc.options.Owner.GetName()) + } + if ownerRefs[0].UID != tc.options.Owner.GetUID() { + t.Errorf("owner reference UID = %q, want %q", ownerRefs[0].UID, tc.options.Owner.GetUID()) + } + } else { + if len(ownerRefs) != 0 { + t.Errorf("apply() expected no owner references, got %d", len(ownerRefs)) + } + } + }) + } +} diff --git a/event/internal/handler/util/naming.go b/event/internal/handler/util/naming.go new file mode 100644 index 00000000..af299d83 --- /dev/null +++ b/event/internal/handler/util/naming.go @@ -0,0 +1,59 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + + eventv1 "github.com/telekom/controlplane/event/api/v1" +) + +const ( + // MeshClientName is the name of the client used for mesh communication. + // It is used for both SSE and Callback proxy-routes to access the real-route. + MeshClientName = "eventstore" + // AdminClientName is the name of the client used for administrative operations. + // This client must be configured in the configuration backend. + AdminClientName = "admin--controlplane-client" + + // CallbackUrlQUeryParam is the name of the query parameter used to pass the original callback URL in proxy scenarios. + CallbackUrlQUeryParam = "callback" +) + +func makePublishRouteName(eventConfig *eventv1.EventConfig) string { + return "publish" +} + +func makePublishRoutePath(zoneName string) string { + return fmt.Sprintf("/%s/publish/v1", zoneName) +} + +// makeSSERouteName returns the deterministic Route name for an SSE event type. +func makeSSERouteName(eventType string) string { + return "sse--" + eventv1.MakeEventTypeName(eventType) +} + +func makeSSERoutePath(eventType string) string { + return fmt.Sprintf("/sse/v1/%s", eventv1.MakeEventTypeName(eventType)) +} + +func makeCallbackRouteName(zoneName string) string { + return "callback--" + zoneName +} + +func makeCallbackRoutePath(zoneName string) string { + return fmt.Sprintf("/%s/callback/v1", zoneName) +} + +func makeVoyagerRouteName(zoneName string) string { + return "voyager--" + zoneName +} + +func makeVoyagerRoutePath(zoneName string) string { + if zoneName == "" { + return "/voyager/v1" + } + return fmt.Sprintf("/%s/voyager/v1", zoneName) +} diff --git a/event/internal/handler/util/naming_test.go b/event/internal/handler/util/naming_test.go new file mode 100644 index 00000000..df9134d9 --- /dev/null +++ b/event/internal/handler/util/naming_test.go @@ -0,0 +1,244 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "testing" + + eventv1 "github.com/telekom/controlplane/event/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNamingConstants(t *testing.T) { + tests := []struct { + name string + got string + expected string + }{ + { + name: "MeshClientName", + got: MeshClientName, + expected: "eventstore", + }, + { + name: "AdminClientName", + got: AdminClientName, + expected: "admin--controlplane-client", + }, + { + name: "CallbackUrlQUeryParam", + got: CallbackUrlQUeryParam, + expected: "callback", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, tc.got) + } + }) + } +} + +func TestNamingMakePublishRouteName(t *testing.T) { + tests := []struct { + name string + eventConfig *eventv1.EventConfig + expected string + }{ + { + name: "with nil EventConfig", + eventConfig: nil, + expected: "publish", + }, + { + name: "with valid EventConfig", + eventConfig: &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + }, + expected: "publish", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := makePublishRouteName(tc.eventConfig) + if got != tc.expected { + t.Errorf("makePublishRouteName() = %q, want %q", got, tc.expected) + } + }) + } +} + +func TestNamingMakePublishRoutePath(t *testing.T) { + tests := []struct { + name string + zoneName string + expected string + }{ + { + name: "standard zone name", + zoneName: "zone-a", + expected: "/zone-a/publish/v1", + }, + { + name: "zone name with multiple segments", + zoneName: "eu-west-1", + expected: "/eu-west-1/publish/v1", + }, + { + name: "empty zone name", + zoneName: "", + expected: "//publish/v1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := makePublishRoutePath(tc.zoneName) + if got != tc.expected { + t.Errorf("makePublishRoutePath(%q) = %q, want %q", tc.zoneName, got, tc.expected) + } + }) + } +} + +func TestNamingMakeSSERouteName(t *testing.T) { + tests := []struct { + name string + eventType string + expected string + }{ + { + name: "dotted event type", + eventType: "de.telekom.test.v1", + expected: "sse--de-telekom-test-v1", + }, + { + name: "already hyphenated", + eventType: "simple-event", + expected: "sse--simple-event", + }, + { + name: "single segment", + eventType: "events", + expected: "sse--events", + }, + { + name: "uppercase letters get lowered", + eventType: "DE.Telekom.Test.V1", + expected: "sse--de-telekom-test-v1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := makeSSERouteName(tc.eventType) + if got != tc.expected { + t.Errorf("makeSSERouteName(%q) = %q, want %q", tc.eventType, got, tc.expected) + } + }) + } +} + +func TestNamingMakeSSERoutePath(t *testing.T) { + tests := []struct { + name string + eventType string + expected string + }{ + { + name: "dotted event type", + eventType: "de.telekom.test.v1", + expected: "/sse/v1/de-telekom-test-v1", + }, + { + name: "already hyphenated", + eventType: "simple-event", + expected: "/sse/v1/simple-event", + }, + { + name: "uppercase letters get lowered", + eventType: "DE.Telekom.Test.V1", + expected: "/sse/v1/de-telekom-test-v1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := makeSSERoutePath(tc.eventType) + if got != tc.expected { + t.Errorf("makeSSERoutePath(%q) = %q, want %q", tc.eventType, got, tc.expected) + } + }) + } +} + +func TestNamingMakeCallbackRouteName(t *testing.T) { + tests := []struct { + name string + zoneName string + expected string + }{ + { + name: "standard zone name", + zoneName: "zone-a", + expected: "callback--zone-a", + }, + { + name: "zone name with multiple segments", + zoneName: "eu-west-1", + expected: "callback--eu-west-1", + }, + { + name: "empty zone name", + zoneName: "", + expected: "callback--", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := makeCallbackRouteName(tc.zoneName) + if got != tc.expected { + t.Errorf("makeCallbackRouteName(%q) = %q, want %q", tc.zoneName, got, tc.expected) + } + }) + } +} + +func TestNamingMakeCallbackRoutePath(t *testing.T) { + tests := []struct { + name string + zoneName string + expected string + }{ + { + name: "standard zone name", + zoneName: "zone-a", + expected: "/zone-a/callback/v1", + }, + { + name: "zone name with multiple segments", + zoneName: "eu-west-1", + expected: "/eu-west-1/callback/v1", + }, + { + name: "empty zone name", + zoneName: "", + expected: "//callback/v1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := makeCallbackRoutePath(tc.zoneName) + if got != tc.expected { + t.Errorf("makeCallbackRoutePath(%q) = %q, want %q", tc.zoneName, got, tc.expected) + } + }) + } +} diff --git a/event/internal/handler/util/publish_route.go b/event/internal/handler/util/publish_route.go new file mode 100644 index 00000000..bc92bf9b --- /dev/null +++ b/event/internal/handler/util/publish_route.go @@ -0,0 +1,107 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + "net/url" + + "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + "github.com/telekom/controlplane/common/pkg/types" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// CreatePublishRoute creates a Route for the publishing events +// The Route is created once per zone where the event-feature is configured +// and points to an internal service +func CreatePublishRoute( + ctx context.Context, + zone *adminv1.Zone, + eventConfig *eventv1.EventConfig, +) (*gatewayv1.Route, error) { + + c := cclient.ClientFromContextOrDie(ctx) + name := makePublishRouteName(eventConfig) + + gatewayRealm := &gatewayv1.Realm{} + err := c.Get(ctx, zone.Status.GatewayRealm.K8s(), gatewayRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", zone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", zone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(gatewayRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", gatewayRealm.Name) + } + + route := &gatewayv1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: zone.Status.Namespace, + }, + } + + publishUrl, err := url.Parse(eventConfig.Spec.PublishEventUrl) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse publishEventUrl %q", eventConfig.Spec.PublishEventUrl) + } + + upstream := gatewayv1.Upstream{ + Scheme: publishUrl.Scheme, + Host: publishUrl.Host, + Path: publishUrl.Path, + Port: gatewayv1.GetPortOrDefaultFromScheme(publishUrl), + } + + downstream, err := gatewayRealm.AsDownstream(makePublishRoutePath(zone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create downstream for publish Route") + } + mutator := func() error { + if err := controllerutil.SetControllerReference(eventConfig, route, c.Scheme()); err != nil { + return errors.Wrap(err, "failed to set controller reference to EventConfig") + } + + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + config.BuildLabelKey("zone"): zone.Name, + config.BuildLabelKey("realm"): gatewayRealm.Name, + config.BuildLabelKey("type"): "publish", + } + + route.Spec = gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + upstream, + }, + Downstreams: []gatewayv1.Downstream{ + downstream, + }, + Realm: *types.ObjectRefFromObject(gatewayRealm), + Security: &gatewayv1.Security{ + DisableAccessControl: true, + }, + } + + return nil + } + + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update publish Route %q", ctypes.ObjectRefFromObject(route).String()) + } + + return route, nil +} diff --git a/event/internal/handler/util/publish_route_test.go b/event/internal/handler/util/publish_route_test.go new file mode 100644 index 00000000..6f6c3e81 --- /dev/null +++ b/event/internal/handler/util/publish_route_test.go @@ -0,0 +1,286 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + mock "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/config" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// ---------- CreatePublishRoute ---------- + +var _ = Describe("CreatePublishRoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + zone *adminv1.Zone + eventConfig *eventv1.EventConfig + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + + zone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + eventConfig = &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ec-zone-a", + Namespace: "zone-a-ns", // must match zone.Status.Namespace for SetControllerReference + UID: k8stypes.UID("ec-uid-1234"), + }, + Spec: eventv1.EventConfigSpec{ + PublishEventUrl: "http://publish-service:8080/events", + }, + } + }) + + It("should return BlockedError when realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-a")) + + route, err := util.CreatePublishRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreatePublishRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreatePublishRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return error when publishEventUrl is invalid", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + badConfig := &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-bad", Namespace: "default"}, + Spec: eventv1.EventConfigSpec{ + PublishEventUrl: "://bad-url", + }, + } + + route, err := util.CreatePublishRoute(ctx, zone, badConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to parse publishEventUrl")) + }) + + It("should create publish route successfully", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + // SetControllerReference requires a scheme that knows both EventConfig and Route + s := runtime.NewScheme() + Expect(eventv1.AddToScheme(s)).To(Succeed()) + Expect(gatewayapi.AddToScheme(s)).To(Succeed()) + fakeClient.EXPECT().Scheme().Return(s).Maybe() + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreatePublishRoute(ctx, zone, eventConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata + Expect(route.Name).To(Equal("publish")) + Expect(route.Namespace).To(Equal("zone-a-ns")) + + // Verify labels + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "publish")) + + // Verify upstream: from publishEventUrl + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("http")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("publish-service:8080")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(8080)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/events")) + + // Verify downstream: from realm URL + Expect(route.Spec.Downstreams).To(HaveLen(1)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[0].Path).To(Equal("/zone-a/publish/v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeTrue()) + + // Verify realm ref + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-a")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + + // Verify owner reference was set (via SetControllerReference) + Expect(route.GetOwnerReferences()).To(HaveLen(1)) + Expect(route.GetOwnerReferences()[0].Name).To(Equal("ec-zone-a")) + Expect(route.GetOwnerReferences()[0].UID).To(Equal(k8stypes.UID("ec-uid-1234"))) + }) + + It("should create publish route with HTTPS upstream URL", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + s := runtime.NewScheme() + Expect(eventv1.AddToScheme(s)).To(Succeed()) + Expect(gatewayapi.AddToScheme(s)).To(Succeed()) + fakeClient.EXPECT().Scheme().Return(s).Maybe() + + httpsConfig := &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ec-zone-a", + Namespace: "zone-a-ns", // must match zone.Status.Namespace for SetControllerReference + UID: k8stypes.UID("ec-uid-1234"), + }, + Spec: eventv1.EventConfigSpec{ + PublishEventUrl: "https://publish-service.internal:9443/api/publish", + }, + } + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreatePublishRoute(ctx, zone, httpsConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify upstream with explicit HTTPS port + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("https")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("publish-service.internal:9443")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(9443)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/api/publish")) + }) + + It("should return error when CreateOrUpdate fails", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreatePublishRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update publish Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) + + It("should use correct ObjectRef for realm in route spec", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + s := runtime.NewScheme() + Expect(eventv1.AddToScheme(s)).To(Succeed()) + Expect(gatewayapi.AddToScheme(s)).To(Succeed()) + fakeClient.EXPECT().Scheme().Return(s).Maybe() + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreatePublishRoute(ctx, zone, eventConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify realm ObjectRef + expectedRealmRef := *ctypes.ObjectRefFromObject(readyRealm) + Expect(route.Spec.Realm).To(Equal(expectedRealmRef)) + }) +}) diff --git a/event/internal/handler/util/shared_route.go b/event/internal/handler/util/shared_route.go new file mode 100644 index 00000000..0a567679 --- /dev/null +++ b/event/internal/handler/util/shared_route.go @@ -0,0 +1,69 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + + "github.com/pkg/errors" + cclient "github.com/telekom/controlplane/common/pkg/client" + ctypes "github.com/telekom/controlplane/common/pkg/types" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// DeleteRouteIfExists fetches a Route by ObjectRef and deletes it if found. +// Returns nil if the Route is already gone (NotFound). +func DeleteRouteIfExists(ctx context.Context, ref *ctypes.ObjectRef) error { + if ref == nil { + return nil + } + + c := cclient.ClientFromContextOrDie(ctx) + + route := &gatewayapi.Route{} + err := c.Get(ctx, ref.K8s(), route) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return errors.Wrapf(err, "failed to get Route %q", ref.String()) + } + + if err := c.Delete(ctx, route); err != nil { + return errors.Wrapf(err, "failed to delete Route %q", ref.String()) + } + + return nil +} + +type Options struct { + Owner metav1.Object + IsProxyTarget bool +} + +type Option func(*Options) + +func WithOwner(owner metav1.Object) Option { + return func(o *Options) { + o.Owner = owner + } +} + +func WithProxyTarget(isProxyTarget bool) Option { + return func(o *Options) { + o.IsProxyTarget = isProxyTarget + } +} + +func (o *Options) apply(ctx context.Context, route *gatewayapi.Route) error { + c := cclient.ClientFromContextOrDie(ctx) + if o.Owner != nil { + return controllerutil.SetControllerReference(o.Owner, route, c.Scheme()) + } + return nil +} diff --git a/event/internal/handler/util/shared_route_test.go b/event/internal/handler/util/shared_route_test.go new file mode 100644 index 00000000..1b0bc6b0 --- /dev/null +++ b/event/internal/handler/util/shared_route_test.go @@ -0,0 +1,137 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + mock "github.com/stretchr/testify/mock" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + ctypes "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/event/internal/handler/util" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ---------- DeleteRouteIfExists ---------- + +var _ = Describe("DeleteRouteIfExists", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return nil when ref is nil", func() { + err := util.DeleteRouteIfExists(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should delete route when found", func() { + ref := &ctypes.ObjectRef{Name: "test-route", Namespace: "default"} + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "test-route", Namespace: "default"}, &gatewayapi.Route{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Route) = gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{Name: "test-route", Namespace: "default"}, + } + }). + Return(nil) + + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Route")). + Return(nil) + + err := util.DeleteRouteIfExists(ctx, ref) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return nil when route is not found", func() { + ref := &ctypes.ObjectRef{Name: "missing-route", Namespace: "default"} + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "missing-route", Namespace: "default"}, &gatewayapi.Route{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "routes"}, "missing-route")) + + err := util.DeleteRouteIfExists(ctx, ref) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return wrapped error when Get fails with unexpected error", func() { + ref := &ctypes.ObjectRef{Name: "test-route", Namespace: "default"} + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "test-route", Namespace: "default"}, &gatewayapi.Route{}). + Return(fmt.Errorf("connection refused")) + + err := util.DeleteRouteIfExists(ctx, ref) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get Route")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return wrapped error when Delete fails", func() { + ref := &ctypes.ObjectRef{Name: "test-route", Namespace: "default"} + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "test-route", Namespace: "default"}, &gatewayapi.Route{}). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Route) = gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{Name: "test-route", Namespace: "default"}, + } + }). + Return(nil) + + fakeClient.EXPECT(). + Delete(ctx, mock.AnythingOfType("*v1.Route")). + Return(fmt.Errorf("forbidden")) + + err := util.DeleteRouteIfExists(ctx, ref) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to delete Route")) + Expect(err.Error()).To(ContainSubstring("forbidden")) + }) +}) + +// ---------- WithOwner ---------- + +var _ = Describe("WithOwner", func() { + It("should set Owner on Options", func() { + owner := &metav1.ObjectMeta{Name: "my-owner", Namespace: "default"} + opts := &util.Options{} + util.WithOwner(owner)(opts) + Expect(opts.Owner).To(Equal(owner)) + }) +}) + +// ---------- WithProxyTarget ---------- + +var _ = Describe("WithProxyTarget", func() { + It("should set IsProxyTarget to true", func() { + opts := &util.Options{} + util.WithProxyTarget(true)(opts) + Expect(opts.IsProxyTarget).To(BeTrue()) + }) + + It("should set IsProxyTarget to false", func() { + opts := &util.Options{} + util.WithProxyTarget(false)(opts) + Expect(opts.IsProxyTarget).To(BeFalse()) + }) +}) diff --git a/event/internal/handler/util/sse_route.go b/event/internal/handler/util/sse_route.go new file mode 100644 index 00000000..2db04804 --- /dev/null +++ b/event/internal/handler/util/sse_route.go @@ -0,0 +1,252 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + "net/url" + + "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + identityapi "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// CreateSSERoute creates a gateway Route for the SSE endpoint of an event type. +// The Route is created in the zone's namespace (cross-namespace from EventExposure), +// so NO owner reference is set. Uses c.CreateOrUpdate() which automatically tracks +// the Route in the JanitorClient state for later cleanup. +func CreateSSERoute( + ctx context.Context, + eventType string, + zone *adminv1.Zone, + eventConfig *eventv1.EventConfig, + isTargetOfProxy bool, +) (*gatewayapi.Route, error) { + c := cclient.ClientFromContextOrDie(ctx) + + // 1. Nil-check zone.Status.GatewayRealm + if zone.Status.GatewayRealm == nil { + return nil, ctrlerrors.BlockedErrorf("zone %q has no GatewayRealm configured", zone.Name) + } + + // 2. Get Realm CR + realm := &gatewayapi.Realm{} + if err := c.Get(ctx, zone.Status.GatewayRealm.K8s(), realm); err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", zone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", zone.Status.GatewayRealm.String()) + } + + // 3. Ensure realm is ready + if err := condition.EnsureReady(realm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", realm.Name) + } + + // 4. Build downstream + downstream, err := realm.AsDownstream(makeSSERoutePath(eventType)) + if err != nil { + return nil, errors.Wrap(err, "failed to create downstream") + } + + // 5. Build upstream from eventConfig.Spec.ServerSendEventUrl + parsedUrl, err := url.Parse(eventConfig.Spec.ServerSendEventUrl) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse ServerSendEventUrl %q", eventConfig.Spec.ServerSendEventUrl) + } + upstream := gatewayapi.Upstream{ + Scheme: parsedUrl.Scheme, + Host: parsedUrl.Hostname(), + Port: gatewayapi.GetPortOrDefaultFromScheme(parsedUrl), + Path: parsedUrl.Path, + } + + // 6. Create or update the Route + route := &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: makeSSERouteName(eventType), + Namespace: zone.Status.Namespace, + }, + } + + mutator := func() error { + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType), + config.BuildLabelKey("zone"): zone.Name, + config.BuildLabelKey("realm"): realm.Name, + config.BuildLabelKey("type"): "sse", + } + + route.Spec = gatewayapi.RouteSpec{ + Realm: *ctypes.ObjectRefFromObject(realm), + Upstreams: []gatewayapi.Upstream{ + upstream, + }, + Downstreams: []gatewayapi.Downstream{ + downstream, + }, + Security: &gatewayapi.Security{ + DisableAccessControl: true, + }, + Buffering: gatewayapi.Buffering{ + DisableResponseBuffering: true, + }, + } + // If this Route is used as target of a proxy Route, + // the proxy-route will is the mesh-client. We need to allow access to this Route. + if isTargetOfProxy { + route.Spec.Security.DefaultConsumers = append(route.Spec.Security.DefaultConsumers, MeshClientName) + } + + return nil + } + + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update SSE Route %s/%s", route.Namespace, route.Name) + } + + return route, nil +} + +// CleanupOldSSERoutes uses the JanitorClient's Cleanup() to delete any stale SSE Routes +// for the given event type that were NOT created/updated in this reconciliation cycle. +// This handles cross-zone transfers: when an EventExposure moves from zone-1 to zone-2, +// the new Route is created in zone-2 (tracked in janitor state), and this function +// deletes the old Route in zone-1 (not in janitor state). +func CleanupOldSSERoutes(ctx context.Context, eventType string) (int, error) { + c := cclient.ClientFromContextOrDie(ctx) + + deleted, err := c.Cleanup(ctx, &gatewayapi.RouteList{}, []client.ListOption{ + client.MatchingLabels{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType), + }, + }) + if err != nil { + return deleted, errors.Wrapf(err, "failed to cleanup old SSE Routes for event type %q", eventType) + } + + return deleted, nil +} + +// CreateSSEProxyRoute creates a cross-zone proxy Route for SSE delivery. +// The Route is created in the subscriber zone's namespace and points upstream +// to the provider zone's gateway with OAuth2 credentials. +// This allows subscribers in a remote zone to consume SSE events without +// direct access to the provider zone's internal SSE endpoint. +func CreateSSEProxyRoute( + ctx context.Context, + eventType string, + evenConfig *eventv1.EventConfig, + subscriberZone *adminv1.Zone, + providerZone *adminv1.Zone, +) (*gatewayapi.Route, error) { + c := cclient.ClientFromContextOrDie(ctx) + + // 1. Resolve subscriber zone's realm (for downstream) + if subscriberZone.Status.GatewayRealm == nil { + return nil, ctrlerrors.BlockedErrorf("subscriber zone %q has no GatewayRealm configured", subscriberZone.Name) + } + subscriberRealm := &gatewayapi.Realm{} + if err := c.Get(ctx, subscriberZone.Status.GatewayRealm.K8s(), subscriberRealm); err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("subscriber realm %q not found", subscriberZone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get subscriber realm %q", subscriberZone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(subscriberRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("subscriber realm %q is not ready", subscriberRealm.Name) + } + + // 2. Resolve provider zone's realm (for upstream) + if providerZone.Status.GatewayRealm == nil { + return nil, ctrlerrors.BlockedErrorf("provider zone %q has no GatewayRealm configured", providerZone.Name) + } + providerRealm := &gatewayapi.Realm{} + if err := c.Get(ctx, providerZone.Status.GatewayRealm.K8s(), providerRealm); err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("provider realm %q not found", providerZone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get provider realm %q", providerZone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(providerRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("provider realm %q is not ready", providerRealm.Name) + } + + // 3. Build downstream from subscriber realm + downstream, err := subscriberRealm.AsDownstream(makeSSERoutePath(eventType)) + if err != nil { + return nil, errors.Wrap(err, "failed to create downstream for proxy route") + } + + // 4. Build upstream from provider realm with OAuth2 gateway credentials + identityClient := &identityapi.Client{} + if err := c.Get(ctx, evenConfig.Status.MeshClient.K8s(), identityClient); err != nil { + return nil, errors.Wrapf(err, "failed to get gateway identity client for provider realm %s/%s", + providerRealm.Name, providerRealm.Namespace) + } + + upstream, err := providerRealm.AsUpstream(makeSSERoutePath(eventType)) + if err != nil { + return nil, errors.Wrap(err, "failed to create upstream for proxy route") + } + upstream.ClientId = identityClient.Spec.ClientId + upstream.ClientSecret = identityClient.Spec.ClientSecret + upstream.IssuerUrl = identityClient.Status.IssuerUrl + + // 5. Create or update the proxy Route in the subscriber zone's namespace + route := &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: makeSSERouteName(eventType), + Namespace: subscriberZone.Status.Namespace, + }, + } + + mutator := func() error { + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventType), + config.BuildLabelKey("zone"): subscriberZone.Name, + config.BuildLabelKey("realm"): subscriberRealm.Name, + config.BuildLabelKey("type"): "sse-proxy", + } + + route.Spec = gatewayapi.RouteSpec{ + Realm: *ctypes.ObjectRefFromObject(subscriberRealm), + Upstreams: []gatewayapi.Upstream{ + upstream, + }, + Downstreams: []gatewayapi.Downstream{ + downstream, + }, + Security: &gatewayapi.Security{ + DisableAccessControl: true, + }, + Buffering: gatewayapi.Buffering{ + DisableResponseBuffering: true, + }, + } + return nil + } + + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update SSE proxy Route %s/%s", route.Namespace, route.Name) + } + + return route, nil +} diff --git a/event/internal/handler/util/sse_route_test.go b/event/internal/handler/util/sse_route_test.go new file mode 100644 index 00000000..71d279f6 --- /dev/null +++ b/event/internal/handler/util/sse_route_test.go @@ -0,0 +1,659 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + mock "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/config" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// ---------- CreateSSERoute ---------- + +var _ = Describe("CreateSSERoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + zone *adminv1.Zone + eventConfig *eventv1.EventConfig + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + + zone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + eventConfig = &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-a", Namespace: "default"}, + Spec: eventv1.EventConfigSpec{ + ServerSendEventUrl: "http://sse-service:8080/sse", + }, + } + }) + + It("should return BlockedError when zone has no GatewayRealm", func() { + zoneNoRealm := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{Name: "zone-no-realm", Namespace: "default"}, + Status: adminv1.ZoneStatus{ + Namespace: "zone-ns", + GatewayRealm: nil, + }, + } + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zoneNoRealm, eventConfig, false) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("has no GatewayRealm configured")) + }) + + It("should return BlockedError when realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-a")) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, false) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, false) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, false) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return error when ServerSendEventUrl is invalid", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + badConfig := &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-bad", Namespace: "default"}, + Spec: eventv1.EventConfigSpec{ + ServerSendEventUrl: "://bad-url", + }, + } + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, badConfig, false) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to parse ServerSendEventUrl")) + }) + + It("should create SSE route successfully", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, false) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata + // makeSSERouteName("de.telekom.test.v1") = "sse--de-telekom-test-v1" + Expect(route.Name).To(Equal("sse--de-telekom-test-v1")) + Expect(route.Namespace).To(Equal("zone-a-ns")) + + // Verify labels + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "sse")) + Expect(route.Labels).To(HaveKey(eventv1.EventTypeLabelKey)) + + // Verify upstream: from ServerSendEventUrl + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("http")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("sse-service")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(8080)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/sse")) + + // Verify downstream: from realm URL + Expect(route.Spec.Downstreams).To(HaveLen(1)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + // makeSSERoutePath("de.telekom.test.v1") = "/sse/v1/de-telekom-test-v1" + Expect(route.Spec.Downstreams[0].Path).To(Equal("/sse/v1/de-telekom-test-v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeTrue()) + Expect(route.Spec.Security.DefaultConsumers).To(BeEmpty()) + + // Verify Buffering: response buffering must be disabled for SSE streaming + Expect(route.Spec.Buffering.DisableResponseBuffering).To(BeTrue()) + Expect(route.Spec.Buffering.DisableRequestBuffering).To(BeFalse()) + + // Verify realm ref + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-a")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + }) + + It("should add MeshClientName to DefaultConsumers when isTargetOfProxy is true", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, true) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DefaultConsumers).To(ContainElement(util.MeshClientName)) + }) + + It("should NOT add MeshClientName to DefaultConsumers when isTargetOfProxy is false", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, false) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DefaultConsumers).To(BeEmpty()) + }) + + It("should return error when CreateOrUpdate fails", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreateSSERoute(ctx, "de.telekom.test.v1", zone, eventConfig, false) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update SSE Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) +}) + +// ---------- CleanupOldSSERoutes ---------- + +var _ = Describe("CleanupOldSSERoutes", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + }) + + It("should return 0 deleted when Cleanup removes nothing", func() { + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.RouteList"), mock.Anything). + Return(0, nil) + + deleted, err := util.CleanupOldSSERoutes(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(deleted).To(Equal(0)) + }) + + It("should return count of deleted routes on success", func() { + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.RouteList"), mock.Anything). + Return(3, nil) + + deleted, err := util.CleanupOldSSERoutes(ctx, "de.telekom.test.v1") + Expect(err).ToNot(HaveOccurred()) + Expect(deleted).To(Equal(3)) + }) + + It("should return error when Cleanup fails", func() { + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.RouteList"), mock.Anything). + Return(0, fmt.Errorf("cleanup failed")) + + deleted, err := util.CleanupOldSSERoutes(ctx, "de.telekom.test.v1") + Expect(err).To(HaveOccurred()) + Expect(deleted).To(Equal(0)) + Expect(err.Error()).To(ContainSubstring("failed to cleanup old SSE Routes")) + Expect(err.Error()).To(ContainSubstring("de.telekom.test.v1")) + }) + + It("should return partial count when Cleanup fails after some deletions", func() { + fakeClient.EXPECT(). + Cleanup(ctx, mock.AnythingOfType("*v1.RouteList"), mock.Anything). + Return(2, fmt.Errorf("partial cleanup failure")) + + deleted, err := util.CleanupOldSSERoutes(ctx, "de.telekom.test.v1") + Expect(err).To(HaveOccurred()) + Expect(deleted).To(Equal(2)) + Expect(err.Error()).To(ContainSubstring("failed to cleanup old SSE Routes")) + }) +}) + +// ---------- CreateSSEProxyRoute ---------- + +var _ = Describe("CreateSSEProxyRoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + subscriberZone *adminv1.Zone + providerZone *adminv1.Zone + eventConfig *eventv1.EventConfig + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + + subscriberZone = makeZone("zone-sub", "default", "zone-sub-ns", "gw-realm-sub", "default") + providerZone = makeZone("zone-prov", "default", "zone-prov-ns", "gw-realm-prov", "default") + eventConfig = &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-zone-prov", Namespace: "default"}, + Status: eventv1.EventConfigStatus{ + MeshClient: &eventv1.ObservedObjectRef{ + ObjectRef: ctypes.ObjectRef{Name: "mesh-client", Namespace: "zone-prov-ns"}, + }, + }, + } + }) + + It("should return BlockedError when subscriber zone has no GatewayRealm", func() { + subscriberZoneNoRealm := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{Name: "zone-no-realm", Namespace: "default"}, + Status: adminv1.ZoneStatus{ + Namespace: "zone-ns", + GatewayRealm: nil, + }, + } + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZoneNoRealm, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("subscriber zone")) + Expect(err.Error()).To(ContainSubstring("has no GatewayRealm configured")) + }) + + It("should return BlockedError when subscriber realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-sub")) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("subscriber realm")) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when subscriber realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get subscriber realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when subscriber realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-sub", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("subscriber realm")) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return BlockedError when provider zone has no GatewayRealm", func() { + readySubRealm := makeReadyGatewayRealm("gw-realm-sub", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySubRealm + }). + Return(nil) + + providerZoneNoRealm := &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{Name: "zone-prov-no-realm", Namespace: "default"}, + Status: adminv1.ZoneStatus{ + Namespace: "zone-prov-ns", + GatewayRealm: nil, + }, + } + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZoneNoRealm) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("provider zone")) + Expect(err.Error()).To(ContainSubstring("has no GatewayRealm configured")) + }) + + It("should return BlockedError when provider realm is not found", func() { + readySubRealm := makeReadyGatewayRealm("gw-realm-sub", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySubRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-prov", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-prov")) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("provider realm")) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return BlockedError when provider realm is not ready", func() { + readySubRealm := makeReadyGatewayRealm("gw-realm-sub", "default") + notReadyProvRealm := makeNotReadyGatewayRealm("gw-realm-prov", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySubRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-prov", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyProvRealm + }). + Return(nil) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("provider realm")) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return error when mesh client Get fails", func() { + readySubRealm := makeReadyGatewayRealm("gw-realm-sub", "default") + readyProvRealm := makeReadyGatewayRealm("gw-realm-prov", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySubRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-prov", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyProvRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "mesh-client", Namespace: "zone-prov-ns"}, mock.AnythingOfType("*v1.Client")). + Return(fmt.Errorf("client not found")) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get gateway identity client")) + Expect(err.Error()).To(ContainSubstring("client not found")) + }) + + It("should create SSE proxy route successfully", func() { + readySubRealm := makeReadyGatewayRealm("gw-realm-sub", "default") + readyProvRealm := makeReadyGatewayRealm("gw-realm-prov", "default") + + meshClient := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: "mesh-client", Namespace: "zone-prov-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-id", + ClientSecret: "mesh-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.provider.example.com", + }, + } + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySubRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-prov", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyProvRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "mesh-client", Namespace: "zone-prov-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClient + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata: created in subscriber zone's namespace + Expect(route.Name).To(Equal("sse--de-telekom-test-v1")) + Expect(route.Namespace).To(Equal("zone-sub-ns")) + + // Verify labels reference subscriber zone + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-sub")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-sub")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "sse-proxy")) + Expect(route.Labels).To(HaveKey(eventv1.EventTypeLabelKey)) + + // Verify upstream: from provider realm with mesh client credentials + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("https")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/sse/v1/de-telekom-test-v1")) + Expect(route.Spec.Upstreams[0].ClientId).To(Equal("mesh-id")) + Expect(route.Spec.Upstreams[0].ClientSecret).To(Equal("mesh-secret")) + Expect(route.Spec.Upstreams[0].IssuerUrl).To(Equal("https://issuer.provider.example.com")) + + // Verify downstream: from subscriber realm + Expect(route.Spec.Downstreams).To(HaveLen(1)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[0].Path).To(Equal("/sse/v1/de-telekom-test-v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeTrue()) + + // Verify Buffering: response buffering must be disabled for SSE streaming + Expect(route.Spec.Buffering.DisableResponseBuffering).To(BeTrue()) + Expect(route.Spec.Buffering.DisableRequestBuffering).To(BeFalse()) + + // Verify realm ref points to subscriber realm + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-sub")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + }) + + It("should return error when CreateOrUpdate fails", func() { + readySubRealm := makeReadyGatewayRealm("gw-realm-sub", "default") + readyProvRealm := makeReadyGatewayRealm("gw-realm-prov", "default") + + meshClient := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: "mesh-client", Namespace: "zone-prov-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-id", + ClientSecret: "mesh-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.provider.example.com", + }, + } + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-sub", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySubRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-prov", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyProvRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "mesh-client", Namespace: "zone-prov-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClient + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreateSSEProxyRoute(ctx, "de.telekom.test.v1", eventConfig, subscriberZone, providerZone) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update SSE proxy Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) +}) diff --git a/event/internal/handler/util/suite_test.go b/event/internal/handler/util/suite_test.go new file mode 100644 index 00000000..5abd2527 --- /dev/null +++ b/event/internal/handler/util/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtil(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Event Handler Util Suite") +} diff --git a/event/internal/handler/util/voyager_route.go b/event/internal/handler/util/voyager_route.go new file mode 100644 index 00000000..1d31f392 --- /dev/null +++ b/event/internal/handler/util/voyager_route.go @@ -0,0 +1,271 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + "net/url" + + "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// CreateVoyagerRoute creates a gateway Route for the Voyager API endpoint. +// The Route is created once per zone where the event feature is configured +// and points to the internal Voyager backend service. +func CreateVoyagerRoute( + ctx context.Context, + zone *adminv1.Zone, + eventConfig *eventv1.EventConfig, + opts ...Option, +) (*gatewayapi.Route, error) { + + options := &Options{} + for _, opt := range opts { + opt(options) + } + + c := cclient.ClientFromContextOrDie(ctx) + name := makeVoyagerRouteName(zone.Name) + + gatewayRealm := &gatewayapi.Realm{} + err := c.Get(ctx, zone.Status.GatewayRealm.K8s(), gatewayRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", zone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", zone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(gatewayRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", gatewayRealm.Name) + } + + voyagerUrl, err := url.Parse(eventConfig.Spec.VoyagerApiUrl) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse voyagerApiUrl %q", eventConfig.Spec.VoyagerApiUrl) + } + + upstream := gatewayapi.Upstream{ + Scheme: voyagerUrl.Scheme, + Host: voyagerUrl.Hostname(), + Port: gatewayapi.GetPortOrDefaultFromScheme(voyagerUrl), + Path: voyagerUrl.Path, + } + + meshDownstream, err := gatewayRealm.AsDownstream(makeVoyagerRoutePath(zone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create mesh downstream for voyager Route") + } + + localDownstream, err := gatewayRealm.AsDownstream(makeVoyagerRoutePath("")) + if err != nil { + return nil, errors.Wrap(err, "failed to create local downstream for voyager Route") + } + + route := &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: zone.Status.Namespace, + }, + } + + mutator := func() error { + err := options.apply(ctx, route) + if err != nil { + return errors.Wrap(err, "failed to apply options to voyager Route") + } + + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + config.BuildLabelKey("zone"): zone.Name, + config.BuildLabelKey("realm"): gatewayRealm.Name, + config.BuildLabelKey("type"): "voyager", + } + route.Spec = gatewayapi.RouteSpec{ + Realm: *ctypes.ObjectRefFromObject(gatewayRealm), + Upstreams: []gatewayapi.Upstream{ + upstream, + }, + Downstreams: []gatewayapi.Downstream{ + meshDownstream, + localDownstream, + }, + Security: &gatewayapi.Security{ + DisableAccessControl: true, + }, + } + if options.IsProxyTarget { + // If this Route is the target of a proxy Route, + // the proxy-route uses the mesh-client. We need to allow access to this Route. + route.Spec.Security.DefaultConsumers = append(route.Spec.Security.DefaultConsumers, MeshClientName) + } + + return nil + } + + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update voyager Route %q", ctypes.ObjectRefFromObject(route).String()) + } + + return route, nil +} + +// CreateProxyVoyagerRoute creates a single cross-zone proxy Route for the Voyager API. +// The Route is created in the source zone's namespace and points upstream +// to the target zone's gateway with OAuth2 credentials from the mesh client. +func CreateProxyVoyagerRoute( + ctx context.Context, + sourceZone *adminv1.Zone, + targetZone *adminv1.Zone, + meshClient *identityv1.Client, + opts ...Option, +) (*gatewayapi.Route, error) { + + options := &Options{} + for _, opt := range opts { + opt(options) + } + + c := cclient.ClientFromContextOrDie(ctx) + + downstreamRealm := &gatewayapi.Realm{} + err := c.Get(ctx, sourceZone.Status.GatewayRealm.K8s(), downstreamRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", sourceZone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", sourceZone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(downstreamRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", downstreamRealm.Name) + } + + upstreamRealm := &gatewayapi.Realm{} + err = c.Get(ctx, targetZone.Status.GatewayRealm.K8s(), upstreamRealm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("realm %q not found", targetZone.Status.GatewayRealm.String()) + } + return nil, errors.Wrapf(err, "failed to get realm %q", targetZone.Status.GatewayRealm.String()) + } + if err := condition.EnsureReady(upstreamRealm); err != nil { + return nil, ctrlerrors.BlockedErrorf("realm %q is not ready", upstreamRealm.Name) + } + + route := &gatewayapi.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: makeVoyagerRouteName(targetZone.Name), + Namespace: sourceZone.Status.Namespace, + }, + } + + downstream, err := downstreamRealm.AsDownstream(makeVoyagerRoutePath(targetZone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create downstream for proxy voyager Route") + } + + upstream, err := upstreamRealm.AsUpstream(makeVoyagerRoutePath(targetZone.Name)) + if err != nil { + return nil, errors.Wrap(err, "failed to create upstream for proxy voyager Route") + } + upstream.ClientId = meshClient.Spec.ClientId + upstream.ClientSecret = meshClient.Spec.ClientSecret + upstream.IssuerUrl = meshClient.Status.IssuerUrl + + mutator := func() error { + err := options.apply(ctx, route) + if err != nil { + return errors.Wrap(err, "failed to apply options to proxy voyager Route") + } + + route.Labels = map[string]string{ + config.DomainLabelKey: "event", + config.BuildLabelKey("zone"): sourceZone.Name, + config.BuildLabelKey("realm"): downstreamRealm.Name, + config.BuildLabelKey("type"): "voyager-proxy", + } + route.Spec = gatewayapi.RouteSpec{ + Realm: *ctypes.ObjectRefFromObject(downstreamRealm), + Upstreams: []gatewayapi.Upstream{ + upstream, + }, + Downstreams: []gatewayapi.Downstream{ + downstream, + }, + Security: &gatewayapi.Security{ + DisableAccessControl: true, + }, + } + return nil + } + + _, err = c.CreateOrUpdate(ctx, route, mutator) + if err != nil { + return nil, errors.Wrapf(err, "failed to create or update proxy voyager Route %q", ctypes.ObjectRefFromObject(route).String()) + } + + return route, nil +} + +// CreateVoyagerProxyRoutes creates cross-zone proxy Routes for the Voyager API. +// For each target zone, a Route is created in the source zone that points to +// the target zone's Voyager Route via the target zone's gateway with OAuth2 credentials. +func CreateVoyagerProxyRoutes( + ctx context.Context, + meshConfig eventv1.MeshConfig, + sourceZone *adminv1.Zone, + targetZones []*adminv1.Zone, + opts ...Option, +) (map[string]*gatewayapi.Route, error) { + + logger := log.FromContext(ctx) + c := cclient.ClientFromContextOrDie(ctx) + + routes := map[string]*gatewayapi.Route{} + zones := collectZones(targetZones, meshConfig.FullMesh, meshConfig.ZoneNames) + logger.V(1).Info("Collected target zones for proxy voyager Routes", "before", len(targetZones), "after", len(zones)) + + for _, targetZone := range zones { + if ctypes.Equals(sourceZone, targetZone) { + // ignore the source zone itself if it's included in the target zones (in case of full mesh) + continue + } + + // Get the mesh-client credentials for the target zone. + meshClient := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{ + Name: MeshClientName, + Namespace: targetZone.Status.Namespace, + }, + } + + err := c.Get(ctx, client.ObjectKeyFromObject(meshClient), meshClient) + if err != nil { + return nil, errors.Wrapf(err, "failed to get mesh client credentials for target zone %q", targetZone.Name) + } + + route, err := CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient, opts...) + if err != nil { + return nil, errors.Wrapf(err, "failed to create proxy voyager Route for target zone %q", targetZone.Name) + } + routes[targetZone.Name] = route + logger.V(1).Info("Created proxy voyager Route for target zone", "targetZone", targetZone.Name, "route", ctypes.ObjectRefFromObject(route).String()) + } + + return routes, nil +} diff --git a/event/internal/handler/util/voyager_route_test.go b/event/internal/handler/util/voyager_route_test.go new file mode 100644 index 00000000..d7b6405a --- /dev/null +++ b/event/internal/handler/util/voyager_route_test.go @@ -0,0 +1,673 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + mock "github.com/stretchr/testify/mock" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/config" + eventv1 "github.com/telekom/controlplane/event/api/v1" + "github.com/telekom/controlplane/event/internal/handler/util" + gatewayapi "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// ---------- CreateVoyagerRoute ---------- + +var _ = Describe("CreateVoyagerRoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + zone *adminv1.Zone + eventConfig *eventv1.EventConfig + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + + zone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + eventConfig = &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ec-zone-a", + Namespace: "zone-a-ns", + UID: k8stypes.UID("ec-uid-1234"), + }, + Spec: eventv1.EventConfigSpec{ + VoyagerApiUrl: "http://voyager-service:8080/voyager", + }, + } + }) + + It("should return BlockedError when realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-a")) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return error when voyagerApiUrl is invalid", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + badConfig := &eventv1.EventConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "ec-bad", Namespace: "default"}, + Spec: eventv1.EventConfigSpec{ + VoyagerApiUrl: "://bad-url", + }, + } + + route, err := util.CreateVoyagerRoute(ctx, zone, badConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to parse voyagerApiUrl")) + }) + + It("should create voyager route successfully", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata + Expect(route.Name).To(Equal("voyager--zone-a")) + Expect(route.Namespace).To(Equal("zone-a-ns")) + + // Verify labels + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "voyager")) + + // Verify upstream: from voyagerApiUrl + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("http")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("voyager-service")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(8080)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/voyager")) + + // Verify downstream: from realm + Expect(route.Spec.Downstreams).To(HaveLen(2)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[0].Path).To(Equal("/zone-a/voyager/v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + Expect(route.Spec.Downstreams[1].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[1].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[1].Path).To(Equal("/voyager/v1")) + Expect(route.Spec.Downstreams[1].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeTrue()) + Expect(route.Spec.Security.DefaultConsumers).To(BeEmpty()) + + // Verify realm ref + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-a")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + }) + + It("should add MeshClientName to DefaultConsumers when IsProxyTarget", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig, util.WithProxyTarget(true)) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DefaultConsumers).To(ContainElement("eventstore")) + }) + + It("should set owner reference when WithOwner is provided", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + s := runtime.NewScheme() + Expect(eventv1.AddToScheme(s)).To(Succeed()) + Expect(gatewayapi.AddToScheme(s)).To(Succeed()) + fakeClient.EXPECT().Scheme().Return(s).Maybe() + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig, util.WithOwner(eventConfig)) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify owner reference was set + Expect(route.GetOwnerReferences()).To(HaveLen(1)) + Expect(route.GetOwnerReferences()[0].Name).To(Equal("ec-zone-a")) + Expect(route.GetOwnerReferences()[0].UID).To(Equal(k8stypes.UID("ec-uid-1234"))) + }) + + It("should return error when CreateOrUpdate fails", func() { + readyRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreateVoyagerRoute(ctx, zone, eventConfig) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update voyager Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) +}) + +// ---------- CreateProxyVoyagerRoute ---------- + +var _ = Describe("CreateProxyVoyagerRoute", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + sourceZone *adminv1.Zone + targetZone *adminv1.Zone + meshClient *identityv1.Client + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + sourceZone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + targetZone = makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + meshClient = &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-b-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-id", + ClientSecret: "mesh-client-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.target.example.com", + }, + } + }) + + It("should return BlockedError when downstream realm is not found", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-a")) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when downstream realm Get fails", func() { + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(fmt.Errorf("connection refused")) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get realm")) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + + It("should return BlockedError when downstream realm is not ready", func() { + notReadyRealm := makeNotReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyRealm + }). + Return(nil) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return BlockedError when upstream realm is not found", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "gateway.cp.ei.telekom.de", Resource: "realms"}, "gw-realm-b")) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return BlockedError when upstream realm is not ready", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + notReadyTargetRealm := makeNotReadyGatewayRealm("gw-realm-b", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *notReadyTargetRealm + }). + Return(nil) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should create proxy voyager route successfully", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + readyTargetRealm := makeReadyGatewayRealm("gw-realm-b", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyTargetRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + RunAndReturn(func(_ context.Context, obj client.Object, mutate controllerutil.MutateFn) (controllerutil.OperationResult, error) { + err := mutate() + return controllerutil.OperationResultCreated, err + }) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).ToNot(HaveOccurred()) + Expect(route).ToNot(BeNil()) + + // Verify route metadata + Expect(route.Name).To(Equal("voyager--zone-b")) + Expect(route.Namespace).To(Equal("zone-a-ns")) + + // Verify labels reference source zone + Expect(route.Labels).To(HaveKeyWithValue(config.DomainLabelKey, "event")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("zone"), "zone-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("realm"), "gw-realm-a")) + Expect(route.Labels).To(HaveKeyWithValue(config.BuildLabelKey("type"), "voyager-proxy")) + + // Verify upstream: from target realm with mesh client credentials + Expect(route.Spec.Upstreams).To(HaveLen(1)) + Expect(route.Spec.Upstreams[0].Scheme).To(Equal("https")) + Expect(route.Spec.Upstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Upstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Upstreams[0].Path).To(Equal("/zone-b/voyager/v1")) + Expect(route.Spec.Upstreams[0].ClientId).To(Equal("mesh-client-id")) + Expect(route.Spec.Upstreams[0].ClientSecret).To(Equal("mesh-client-secret")) + Expect(route.Spec.Upstreams[0].IssuerUrl).To(Equal("https://issuer.target.example.com")) + + // Verify downstream: from source realm + Expect(route.Spec.Downstreams).To(HaveLen(1)) + Expect(route.Spec.Downstreams[0].Host).To(Equal("gateway.example.com")) + Expect(route.Spec.Downstreams[0].Port).To(Equal(443)) + Expect(route.Spec.Downstreams[0].Path).To(Equal("/zone-b/voyager/v1")) + Expect(route.Spec.Downstreams[0].IssuerUrl).To(Equal("https://issuer.example.com")) + + // Verify Security + Expect(route.Spec.Security).ToNot(BeNil()) + Expect(route.Spec.Security.DisableAccessControl).To(BeTrue()) + + // Verify realm ref points to downstream (source) realm + Expect(route.Spec.Realm.Name).To(Equal("gw-realm-a")) + Expect(route.Spec.Realm.Namespace).To(Equal("default")) + }) + + It("should return error when CreateOrUpdate fails", func() { + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + readyTargetRealm := makeReadyGatewayRealm("gw-realm-b", "default") + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyTargetRealm + }). + Return(nil) + + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Return(controllerutil.OperationResultNone, fmt.Errorf("create failed")) + + route, err := util.CreateProxyVoyagerRoute(ctx, sourceZone, targetZone, meshClient) + Expect(err).To(HaveOccurred()) + Expect(route).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to create or update proxy voyager Route")) + Expect(err.Error()).To(ContainSubstring("create failed")) + }) +}) + +// ---------- CreateVoyagerProxyRoutes ---------- + +var _ = Describe("CreateVoyagerProxyRoutes", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + sourceZone *adminv1.Zone + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + sourceZone = makeZone("zone-a", "default", "zone-a-ns", "gw-realm-a", "default") + }) + + It("should return empty map when no target zones after filtering", func() { + meshConfig := eventv1.MeshConfig{ + FullMesh: false, + ZoneNames: []string{}, + } + targetZones := []*adminv1.Zone{ + makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default"), + } + + routes, err := util.CreateVoyagerProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).ToNot(HaveOccurred()) + Expect(routes).To(BeEmpty()) + }) + + It("should skip source zone in full mesh", func() { + meshConfig := eventv1.MeshConfig{FullMesh: true} + targetZoneB := makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + // Include source zone in targets to test skipping + targetZones := []*adminv1.Zone{sourceZone, targetZoneB} + + meshClientObj := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-b-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-id", + ClientSecret: "mesh-client-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.target.example.com", + }, + } + + // Get mesh client for zone-b + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-b-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClientObj + }). + Return(nil) + + // Get source realm (downstream) for proxy route creation + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil) + + // Get target realm (upstream) + readyTargetRealm := makeReadyGatewayRealm("gw-realm-b", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyTargetRealm + }). + Return(nil) + + // CreateOrUpdate for proxy route + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(controllerutil.OperationResultCreated, nil).Once() + + routes, err := util.CreateVoyagerProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).ToNot(HaveOccurred()) + Expect(routes).To(HaveLen(1)) + Expect(routes).To(HaveKey("zone-b")) + }) + + It("should return error when mesh client Get fails", func() { + meshConfig := eventv1.MeshConfig{FullMesh: true} + targetZoneB := makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + targetZones := []*adminv1.Zone{targetZoneB} + + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-b-ns"}, mock.AnythingOfType("*v1.Client")). + Return(fmt.Errorf("client not found")) + + routes, err := util.CreateVoyagerProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).To(HaveOccurred()) + Expect(routes).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to get mesh client credentials")) + Expect(err.Error()).To(ContainSubstring("client not found")) + }) + + It("should create routes for multiple target zones", func() { + meshConfig := eventv1.MeshConfig{FullMesh: true} + targetZoneB := makeZone("zone-b", "default", "zone-b-ns", "gw-realm-b", "default") + targetZoneC := makeZone("zone-c", "default", "zone-c-ns", "gw-realm-c", "default") + targetZones := []*adminv1.Zone{targetZoneB, targetZoneC} + + meshClientB := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-b-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-b-id", + ClientSecret: "mesh-client-b-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.b.example.com", + }, + } + meshClientC := &identityv1.Client{ + ObjectMeta: metav1.ObjectMeta{Name: util.MeshClientName, Namespace: "zone-c-ns"}, + Spec: identityv1.ClientSpec{ + ClientId: "mesh-client-c-id", + ClientSecret: "mesh-client-c-secret", + }, + Status: identityv1.ClientStatus{ + IssuerUrl: "https://issuer.c.example.com", + }, + } + + // Get mesh client for zone-b + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-b-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClientB + }). + Return(nil).Once() + + // Get source realm for zone-b proxy route + readySourceRealm := makeReadyGatewayRealm("gw-realm-a", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-a", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readySourceRealm + }). + Return(nil).Times(2) // called for both zone-b and zone-c + + // Get target realm for zone-b proxy route + readyRealmB := makeReadyGatewayRealm("gw-realm-b", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-b", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealmB + }). + Return(nil).Once() + + // CreateOrUpdate for zone-b proxy route + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(controllerutil.OperationResultCreated, nil).Once() + + // Get mesh client for zone-c + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: util.MeshClientName, Namespace: "zone-c-ns"}, mock.AnythingOfType("*v1.Client")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*identityv1.Client) = *meshClientC + }). + Return(nil).Once() + + // Get target realm for zone-c proxy route + readyRealmC := makeReadyGatewayRealm("gw-realm-c", "default") + fakeClient.EXPECT(). + Get(ctx, k8stypes.NamespacedName{Name: "gw-realm-c", Namespace: "default"}, mock.AnythingOfType("*v1.Realm")). + Run(func(_ context.Context, _ k8stypes.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*gatewayapi.Realm) = *readyRealmC + }). + Return(nil).Once() + + // CreateOrUpdate for zone-c proxy route + fakeClient.EXPECT(). + CreateOrUpdate(ctx, mock.AnythingOfType("*v1.Route"), mock.Anything). + Run(func(_ context.Context, _ client.Object, mutate controllerutil.MutateFn) { + _ = mutate() + }). + Return(controllerutil.OperationResultCreated, nil).Once() + + routes, err := util.CreateVoyagerProxyRoutes(ctx, meshConfig, sourceZone, targetZones) + Expect(err).ToNot(HaveOccurred()) + Expect(routes).To(HaveLen(2)) + Expect(routes).To(HaveKey("zone-b")) + Expect(routes).To(HaveKey("zone-c")) + }) +}) diff --git a/event/internal/index/index.go b/event/internal/index/index.go new file mode 100644 index 00000000..51d1d37f --- /dev/null +++ b/event/internal/index/index.go @@ -0,0 +1,73 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "context" + "os" + + approvalapi "github.com/telekom/controlplane/approval/api/v1" + "github.com/telekom/controlplane/common/pkg/controller/index" + eventv1 "github.com/telekom/controlplane/event/api/v1" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + identityv1 "github.com/telekom/controlplane/identity/api/v1" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // EventConfigZoneIndex indexes EventConfig by spec.zone.name for efficient lookups. + EventConfigZoneIndex = ".spec.zone.name" +) + +func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { + indexEventConfigByZone := func(obj client.Object) []string { + ec, ok := obj.(*eventv1.EventConfig) + if !ok { + return nil + } + if ec.Spec.Zone.Name == "" { + return nil + } + return []string{ec.Spec.Zone.Name} + } + err := mgr.GetFieldIndexer().IndexField(ctx, &eventv1.EventConfig{}, EventConfigZoneIndex, indexEventConfigByZone) + if err != nil { + ctrl.Log.Error(err, "unable to create fieldIndex for EventConfig", "FieldIndex", EventConfigZoneIndex) + os.Exit(1) + } + + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &pubsubv1.Subscriber{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } + + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &pubsubv1.EventStore{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } + + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &approvalapi.ApprovalRequest{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } + + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &identityv1.Client{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } + + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &gatewayv1.Route{}) + if err != nil { + ctrl.Log.Error(err, "unable to create field-indexer") + os.Exit(1) + } + +} diff --git a/event/test/e2e/e2e_suite_test.go b/event/test/e2e/e2e_suite_test.go new file mode 100644 index 00000000..deeb8caf --- /dev/null +++ b/event/test/e2e/e2e_suite_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e +// +build e2e + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/telekom/controlplane/event/test/utils" +) + +var ( + // Optional Environment Variables: + // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup. + // These variables are useful if CertManager is already installed, avoiding + // re-installation and conflicts. + skipCertManagerInstall = os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" + // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster + isCertManagerAlreadyInstalled = false + + // projectImage is the name of the image which will be build and loaded + // with the code source changes to be tested. + projectImage = "example.com/event:v0.0.1" +) + +// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated, +// temporary environment to validate project changes with the purpose of being used in CI jobs. +// The default setup requires Kind, builds/loads the Manager Docker image locally, and installs +// CertManager. +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting event integration test suite\n") + RunSpecs(t, "e2e suite") +} + +var _ = BeforeSuite(func() { + By("building the manager(Operator) image") + cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectImage)) + _, err := utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") + + // TODO(user): If you want to change the e2e test vendor from Kind, ensure the image is + // built and available before running the tests. Also, remove the following block. + By("loading the manager(Operator) image on Kind") + err = utils.LoadImageToKindClusterWithName(projectImage) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") + + // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing. + // To prevent errors when tests run in environments with CertManager already installed, + // we check for its presence before execution. + // Setup CertManager before the suite if not skipped and if not already installed + if !skipCertManagerInstall { + By("checking if cert manager is installed already") + isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled() + if !isCertManagerAlreadyInstalled { + _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n") + Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n") + } + } +}) + +var _ = AfterSuite(func() { + // Teardown CertManager after the suite if not skipped and if it was not already installed + if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { + _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n") + utils.UninstallCertManager() + } +}) diff --git a/event/test/e2e/e2e_test.go b/event/test/e2e/e2e_test.go new file mode 100644 index 00000000..57bdbb6a --- /dev/null +++ b/event/test/e2e/e2e_test.go @@ -0,0 +1,325 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e +// +build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/telekom/controlplane/event/test/utils" +) + +// namespace where the project is deployed in +const namespace = "event-system" + +// serviceAccountName created for the project +const serviceAccountName = "event-controller-manager" + +// metricsServiceName is the name of the metrics service of the project +const metricsServiceName = "event-controller-manager-metrics-service" + +// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data +const metricsRoleBindingName = "event-metrics-binding" + +var _ = Describe("Manager", Ordered, func() { + var controllerPodName string + + // Before running the tests, set up the environment by creating the namespace, + // enforce the restricted security policy to the namespace, installing CRDs, + // and deploying the controller. + BeforeAll(func() { + By("creating manager namespace") + cmd := exec.Command("kubectl", "create", "ns", namespace) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + + By("labeling the namespace to enforce the restricted security policy") + cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace, + "pod-security.kubernetes.io/enforce=restricted") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + + By("installing CRDs") + cmd = exec.Command("make", "install") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") + + By("deploying the controller-manager") + cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectImage)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + }) + + // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, + // and deleting the namespace. + AfterAll(func() { + By("cleaning up the curl pod for metrics") + cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace) + _, _ = utils.Run(cmd) + + By("undeploying the controller-manager") + cmd = exec.Command("make", "undeploy") + _, _ = utils.Run(cmd) + + By("uninstalling CRDs") + cmd = exec.Command("make", "uninstall") + _, _ = utils.Run(cmd) + + By("removing manager namespace") + cmd = exec.Command("kubectl", "delete", "ns", namespace) + _, _ = utils.Run(cmd) + }) + + // After each test, check for failures and collect logs, events, + // and pod descriptions for debugging. + AfterEach(func() { + specReport := CurrentSpecReport() + if specReport.Failed() { + By("Fetching controller manager pod logs") + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + controllerLogs, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + } + + By("Fetching Kubernetes events") + cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") + eventsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) + } + + By("Fetching curl-metrics logs") + cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) + } + + By("Fetching controller manager pod description") + cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) + podDescription, err := utils.Run(cmd) + if err == nil { + fmt.Println("Pod description:\n", podDescription) + } else { + fmt.Println("Failed to describe controller pod") + } + } + }) + + SetDefaultEventuallyTimeout(2 * time.Minute) + SetDefaultEventuallyPollingInterval(time.Second) + + Context("Manager", func() { + It("should run successfully", func() { + By("validating that the controller-manager pod is running as expected") + verifyControllerUp := func(g Gomega) { + // Get the name of the controller-manager pod + cmd := exec.Command("kubectl", "get", + "pods", "-l", "control-plane=controller-manager", + "-o", "go-template={{ range .items }}"+ + "{{ if not .metadata.deletionTimestamp }}"+ + "{{ .metadata.name }}"+ + "{{ \"\\n\" }}{{ end }}{{ end }}", + "-n", namespace, + ) + + podOutput, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") + podNames := utils.GetNonEmptyLines(podOutput) + g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") + controllerPodName = podNames[0] + g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) + + // Validate the pod's status + cmd = exec.Command("kubectl", "get", + "pods", controllerPodName, "-o", "jsonpath={.status.phase}", + "-n", namespace, + ) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") + } + Eventually(verifyControllerUp).Should(Succeed()) + }) + + It("should ensure the metrics endpoint is serving metrics", func() { + By("creating a ClusterRoleBinding for the service account to allow access to metrics") + cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName, + "--clusterrole=event-metrics-reader", + fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName), + ) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") + + By("validating that the metrics service is available") + cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") + + By("getting the service account token") + token, err := serviceAccountToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token).NotTo(BeEmpty()) + + By("ensuring the controller pod is ready") + verifyControllerPodReady := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pod", controllerPodName, "-n", namespace, + "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("True"), "Controller pod not ready") + } + Eventually(verifyControllerPodReady, 3*time.Minute, time.Second).Should(Succeed()) + + By("verifying that the controller manager is serving the metrics server") + verifyMetricsServerStarted := func(g Gomega) { + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("Serving metrics server"), + "Metrics server not yet started") + } + Eventually(verifyMetricsServerStarted, 3*time.Minute, time.Second).Should(Succeed()) + + // +kubebuilder:scaffold:e2e-metrics-webhooks-readiness + + By("creating the curl-metrics pod to access the metrics endpoint") + cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never", + "--namespace", namespace, + "--image=curlimages/curl:latest", + "--overrides", + fmt.Sprintf(`{ + "spec": { + "containers": [{ + "name": "curl", + "image": "curlimages/curl:latest", + "command": ["/bin/sh", "-c"], + "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"], + "securityContext": { + "readOnlyRootFilesystem": true, + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + }, + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + }], + "serviceAccountName": "%s" + } + }`, token, metricsServiceName, namespace, serviceAccountName)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") + + By("waiting for the curl-metrics pod to complete.") + verifyCurlUp := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pods", "curl-metrics", + "-o", "jsonpath={.status.phase}", + "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") + } + Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) + + By("getting the metrics by checking curl-metrics logs") + verifyMetricsAvailable := func(g Gomega) { + metricsOutput, err := getMetricsOutput() + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + g.Expect(metricsOutput).NotTo(BeEmpty()) + g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) + } + Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed()) + }) + + // +kubebuilder:scaffold:e2e-webhooks-checks + + // TODO: Customize the e2e test suite with scenarios specific to your project. + // Consider applying sample/CR(s) and check their status and/or verifying + // the reconciliation by using the metrics, i.e.: + // metricsOutput, err := getMetricsOutput() + // Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + // Expect(metricsOutput).To(ContainSubstring( + // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, + // strings.ToLower(), + // )) + }) +}) + +// serviceAccountToken returns a token for the specified service account in the given namespace. +// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request +// and parsing the resulting token from the API response. +func serviceAccountToken() (string, error) { + const tokenRequestRawString = `{ + "apiVersion": "authentication.k8s.io/v1", + "kind": "TokenRequest" + }` + + // Temporary file to store the token request + secretName := fmt.Sprintf("%s-token-request", serviceAccountName) + tokenRequestFile := filepath.Join("/tmp", secretName) + err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)) + if err != nil { + return "", err + } + + var out string + verifyTokenCreation := func(g Gomega) { + // Execute kubectl command to create the token + cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf( + "/api/v1/namespaces/%s/serviceaccounts/%s/token", + namespace, + serviceAccountName, + ), "-f", tokenRequestFile) + + output, err := cmd.CombinedOutput() + g.Expect(err).NotTo(HaveOccurred()) + + // Parse the JSON output to extract the token + var token tokenRequest + err = json.Unmarshal(output, &token) + g.Expect(err).NotTo(HaveOccurred()) + + out = token.Status.Token + } + Eventually(verifyTokenCreation).Should(Succeed()) + + return out, err +} + +// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. +func getMetricsOutput() (string, error) { + By("getting the curl-metrics logs") + cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + return utils.Run(cmd) +} + +// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, +// containing only the token field that we need to extract. +type tokenRequest struct { + Status struct { + Token string `json:"token"` + } `json:"status"` +} diff --git a/event/test/utils/utils.go b/event/test/utils/utils.go new file mode 100644 index 00000000..414ec159 --- /dev/null +++ b/event/test/utils/utils.go @@ -0,0 +1,214 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "strings" + + . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck +) + +const ( + certmanagerVersion = "v1.19.1" + certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" + + defaultKindBinary = "kind" + defaultKindCluster = "kind" +) + +func warnError(err error) { + _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) +} + +// Run executes the provided command within this context +func Run(cmd *exec.Cmd) (string, error) { + dir, _ := GetProjectDir() + cmd.Dir = dir + + if err := os.Chdir(cmd.Dir); err != nil { + _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err) + } + + cmd.Env = append(os.Environ(), "GO111MODULE=on") + command := strings.Join(cmd.Args, " ") + _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err) + } + + return string(output), nil +} + +// UninstallCertManager uninstalls the cert manager +func UninstallCertManager() { + url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) + cmd := exec.Command("kubectl", "delete", "-f", url) + if _, err := Run(cmd); err != nil { + warnError(err) + } + + // Delete leftover leases in kube-system (not cleaned by default) + kubeSystemLeases := []string{ + "cert-manager-cainjector-leader-election", + "cert-manager-controller", + } + for _, lease := range kubeSystemLeases { + cmd = exec.Command("kubectl", "delete", "lease", lease, + "-n", "kube-system", "--ignore-not-found", "--force", "--grace-period=0") + if _, err := Run(cmd); err != nil { + warnError(err) + } + } +} + +// InstallCertManager installs the cert manager bundle. +func InstallCertManager() error { + url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) + cmd := exec.Command("kubectl", "apply", "-f", url) + if _, err := Run(cmd); err != nil { + return err + } + // Wait for cert-manager-webhook to be ready, which can take time if cert-manager + // was re-installed after uninstalling on a cluster. + cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "5m", + ) + + _, err := Run(cmd) + return err +} + +// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed +// by verifying the existence of key CRDs related to Cert Manager. +func IsCertManagerCRDsInstalled() bool { + // List of common Cert Manager CRDs + certManagerCRDs := []string{ + "certificates.cert-manager.io", + "issuers.cert-manager.io", + "clusterissuers.cert-manager.io", + "certificaterequests.cert-manager.io", + "orders.acme.cert-manager.io", + "challenges.acme.cert-manager.io", + } + + // Execute the kubectl command to get all CRDs + cmd := exec.Command("kubectl", "get", "crds") + output, err := Run(cmd) + if err != nil { + return false + } + + // Check if any of the Cert Manager CRDs are present + crdList := GetNonEmptyLines(output) + for _, crd := range certManagerCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// LoadImageToKindClusterWithName loads a local docker image to the kind cluster +func LoadImageToKindClusterWithName(name string) error { + cluster := defaultKindCluster + if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { + cluster = v + } + kindOptions := []string{"load", "docker-image", name, "--name", cluster} + kindBinary := defaultKindBinary + if v, ok := os.LookupEnv("KIND"); ok { + kindBinary = v + } + cmd := exec.Command(kindBinary, kindOptions...) + _, err := Run(cmd) + return err +} + +// GetNonEmptyLines converts given command output string into individual objects +// according to line breakers, and ignores the empty elements in it. +func GetNonEmptyLines(output string) []string { + var res []string + elements := strings.Split(output, "\n") + for _, element := range elements { + if element != "" { + res = append(res, element) + } + } + + return res +} + +// GetProjectDir will return the directory where the project is +func GetProjectDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return wd, fmt.Errorf("failed to get current working directory: %w", err) + } + wd = strings.ReplaceAll(wd, "/test/e2e", "") + return wd, nil +} + +// UncommentCode searches for target in the file and remove the comment prefix +// of the target content. The target content may span multiple lines. +func UncommentCode(filename, target, prefix string) error { + // false positive + // nolint:gosec + content, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filename, err) + } + strContent := string(content) + + idx := strings.Index(strContent, target) + if idx < 0 { + return fmt.Errorf("unable to find the code %q to be uncomment", target) + } + + out := new(bytes.Buffer) + _, err = out.Write(content[:idx]) + if err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + scanner := bufio.NewScanner(bytes.NewBufferString(target)) + if !scanner.Scan() { + return nil + } + for { + if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + // Avoid writing a newline in case the previous line was the last in target. + if !scanner.Scan() { + break + } + if _, err = out.WriteString("\n"); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + } + + if _, err = out.Write(content[idx+len(target):]); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + // false positive + // nolint:gosec + if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write file %q: %w", filename, err) + } + + return nil +} diff --git a/gateway/api/v1/buffering_types.go b/gateway/api/v1/buffering_types.go new file mode 100644 index 00000000..56b867df --- /dev/null +++ b/gateway/api/v1/buffering_types.go @@ -0,0 +1,23 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +// Buffering configures Kong request/response body buffering for a route. +// By default, Kong buffers both request and response bodies before proxying. +type Buffering struct { + // DisableRequestBuffering disables Kong request body buffering. + // When true, the request body is streamed directly to the upstream + // without being buffered first. Useful for large uploads or chunked transfers. + // +kubebuilder:validation:Optional + // +kubebuilder:default:=false + DisableRequestBuffering bool `json:"disableRequestBuffering,omitempty"` + + // DisableResponseBuffering disables Kong response body buffering. + // When true, the response body is streamed directly to the client + // without being buffered first. Useful for SSE or large downloads. + // +kubebuilder:validation:Optional + // +kubebuilder:default:=false + DisableResponseBuffering bool `json:"disableResponseBuffering,omitempty"` +} diff --git a/gateway/api/v1/features.go b/gateway/api/v1/features.go index 053777ee..22af3844 100644 --- a/gateway/api/v1/features.go +++ b/gateway/api/v1/features.go @@ -15,6 +15,7 @@ const ( FeatureTypeBasicAuth FeatureType = "BasicAuth" FeatureTypeIpRestriction FeatureType = "IpRestriction" FeatureTypeCircuitBreaker FeatureType = "CircuitBreaker" + FeatureTypeDynamicUpstream FeatureType = "DynamicUpstream" ) // Dependent Features diff --git a/gateway/api/v1/route_types.go b/gateway/api/v1/route_types.go index 34100617..88784ee5 100644 --- a/gateway/api/v1/route_types.go +++ b/gateway/api/v1/route_types.go @@ -68,8 +68,10 @@ type RouteSpec struct { Realm types.ObjectRef `json:"realm"` // PassThrough is a flag to pass through the request to the upstream without authentication // +kubebuilder:default=false - PassThrough bool `json:"passThrough"` - Upstreams []Upstream `json:"upstreams"` + PassThrough bool `json:"passThrough"` + // +kubebuilder:validation:MinItems=1 + Upstreams []Upstream `json:"upstreams"` + // +kubebuilder:validation:MinItems=1 Downstreams []Downstream `json:"downstreams"` Traffic Traffic `json:"traffic"` @@ -81,6 +83,9 @@ type RouteSpec struct { // Security is the security configuration for the route // +kubebuilder:validation:Optional Security *Security `json:"security,omitempty"` + + // Buffering configures Kong request/response body buffering for this route + Buffering Buffering `json:"buffering,omitempty"` } func (route *Route) HasM2M() bool { @@ -117,6 +122,10 @@ func (route *Route) HasM2MExternalIdpBasic() bool { return route.Spec.Security.M2M.ExternalIDP.Basic != nil } +func (g *Route) HasDynamicUpstream() bool { + return g.Spec.Traffic.DynamicUpstream != nil +} + // RouteStatus defines the observed state of Route type RouteStatus struct { // +listType=map @@ -163,6 +172,14 @@ func (g *Route) GetPath() string { return g.Spec.Downstreams[0].Path } +func (g *Route) GetRequestBuffering() bool { + return !g.Spec.Buffering.DisableRequestBuffering +} + +func (g *Route) GetResponseBuffering() bool { + return !g.Spec.Buffering.DisableResponseBuffering +} + func (g *Route) SetRouteId(id string) { g.SetProperty("routeId", id) } diff --git a/gateway/api/v1/security_types.go b/gateway/api/v1/security_types.go index 282c6d89..ad24fd01 100644 --- a/gateway/api/v1/security_types.go +++ b/gateway/api/v1/security_types.go @@ -10,6 +10,10 @@ type Security struct { // +kubebuilder:default:=false DisableAccessControl bool `json:"disableAccessControl,omitempty"` + // DefaultConsumers defines a list of default consumers that are allowed to access this route without being explicitly added as a consumer + // +kubebuilder:validation:Optional + DefaultConsumers []string `json:"defaultConsumers,omitempty"` + // M2M defines machine-to-machine authentication configuration // +kubebuilder:validation:Optional M2M *Machine2MachineAuthentication `json:"m2m,omitempty"` diff --git a/gateway/api/v1/traffic_types.go b/gateway/api/v1/traffic_types.go index 0102c072..4c525bb5 100644 --- a/gateway/api/v1/traffic_types.go +++ b/gateway/api/v1/traffic_types.go @@ -9,6 +9,8 @@ type Traffic struct { RateLimit *RateLimit `json:"rateLimit,omitempty"` CircuitBreaker *CircuitBreaker `json:"circuitBreaker,omitempty"` + + DynamicUpstream *DynamicUpstream `json:"dynamicUpstream,omitempty"` } type ConsumeRouteTraffic struct { @@ -65,3 +67,12 @@ type RateLimitOptions struct { // +kubebuilder:default=true FaultTolerant bool `json:"faultTolerant,omitempty"` } + +// DynamicUpstream configures runtime upstream URL resolution. +// When set, the gateway resolves the actual upstream target from a +// request query parameter instead of using the static upstream. +type DynamicUpstream struct { + // QueryParameter is the name of the query parameter containing the target URL. + // The parameter will be removed from the forwarded request. + QueryParameter string `json:"queryParameter,omitempty"` +} diff --git a/gateway/api/v1/zz_generated.deepcopy.go b/gateway/api/v1/zz_generated.deepcopy.go index dc4d55a0..41d56e12 100644 --- a/gateway/api/v1/zz_generated.deepcopy.go +++ b/gateway/api/v1/zz_generated.deepcopy.go @@ -43,6 +43,21 @@ func (in *BasicAuthCredentials) DeepCopy() *BasicAuthCredentials { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Buffering) DeepCopyInto(out *Buffering) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Buffering. +func (in *Buffering) DeepCopy() *Buffering { + if in == nil { + return nil + } + out := new(Buffering) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CircuitBreaker) DeepCopyInto(out *CircuitBreaker) { *out = *in @@ -402,6 +417,21 @@ func (in *Downstream) DeepCopy() *Downstream { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DynamicUpstream) DeepCopyInto(out *DynamicUpstream) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicUpstream. +func (in *DynamicUpstream) DeepCopy() *DynamicUpstream { + if in == nil { + return nil + } + out := new(DynamicUpstream) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalIdentityProvider) DeepCopyInto(out *ExternalIdentityProvider) { *out = *in @@ -929,6 +959,7 @@ func (in *RouteSpec) DeepCopyInto(out *RouteSpec) { *out = new(Security) (*in).DeepCopyInto(*out) } + out.Buffering = in.Buffering } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteSpec. @@ -978,6 +1009,11 @@ func (in *RouteStatus) DeepCopy() *RouteStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Security) DeepCopyInto(out *Security) { *out = *in + if in.DefaultConsumers != nil { + in, out := &in.DefaultConsumers, &out.DefaultConsumers + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.M2M != nil { in, out := &in.M2M, &out.M2M *out = new(Machine2MachineAuthentication) @@ -1013,6 +1049,11 @@ func (in *Traffic) DeepCopyInto(out *Traffic) { *out = new(CircuitBreaker) **out = **in } + if in.DynamicUpstream != nil { + in, out := &in.DynamicUpstream, &out.DynamicUpstream + *out = new(DynamicUpstream) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Traffic. diff --git a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml index f2468eb1..41db2309 100644 --- a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml +++ b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml @@ -42,6 +42,25 @@ spec: spec: description: RouteSpec defines the desired state of Route properties: + buffering: + description: Buffering configures Kong request/response body buffering + for this route + properties: + disableRequestBuffering: + default: false + description: |- + DisableRequestBuffering disables Kong request body buffering. + When true, the request body is streamed directly to the upstream + without being buffered first. Useful for large uploads or chunked transfers. + type: boolean + disableResponseBuffering: + default: false + description: |- + DisableResponseBuffering disables Kong response body buffering. + When true, the response body is streamed directly to the client + without being buffered first. Useful for SSE or large downloads. + type: boolean + type: object downstreams: items: properties: @@ -58,6 +77,7 @@ spec: - path - port type: object + minItems: 1 type: array passThrough: default: false @@ -86,6 +106,13 @@ spec: security: description: Security is the security configuration for the route properties: + defaultConsumers: + description: DefaultConsumers defines a list of default consumers + that are allowed to access this route without being explicitly + added as a consumer + items: + type: string + type: array disableAccessControl: default: false description: DisableAccessControl disable the ACL mechanism for @@ -213,10 +240,29 @@ spec: feature should be used type: boolean type: object + dynamicUpstream: + description: |- + DynamicUpstream configures runtime upstream URL resolution. + When set, the gateway resolves the actual upstream target from a + request query parameter instead of using the static upstream. + properties: + queryParameter: + description: |- + QueryParameter is the name of the query parameter containing the target URL. + The parameter will be removed from the forwarded request. + type: string + type: object failover: properties: security: properties: + defaultConsumers: + description: DefaultConsumers defines a list of default + consumers that are allowed to access this route without + being explicitly added as a consumer + items: + type: string + type: array disableAccessControl: default: false description: DisableAccessControl disable the ACL mechanism @@ -473,6 +519,7 @@ spec: - port - scheme type: object + minItems: 1 type: array required: - downstreams diff --git a/gateway/internal/features/feature/access_control.go b/gateway/internal/features/feature/access_control.go index 6f65e97f..bae55bcf 100644 --- a/gateway/internal/features/feature/access_control.go +++ b/gateway/internal/features/feature/access_control.go @@ -58,10 +58,12 @@ func (f *AccessControlFeature) Apply(ctx context.Context, builder features.Featu } aclPlugin := builder.AclPlugin() - aclPlugin.Config.Allow.Add("gateway") for _, defaultConsumer := range builder.GetRealm().Spec.DefaultConsumers { aclPlugin.Config.Allow.Add(defaultConsumer) } + for _, defaultConsumer := range route.Spec.Security.DefaultConsumers { + aclPlugin.Config.Allow.Add(defaultConsumer) + } for _, consumer := range builder.GetAllowedConsumers() { if consumer.Spec.Route.Equals(route) { diff --git a/gateway/internal/features/feature/dynamic_upstream.go b/gateway/internal/features/feature/dynamic_upstream.go new file mode 100644 index 00000000..158b3a80 --- /dev/null +++ b/gateway/internal/features/feature/dynamic_upstream.go @@ -0,0 +1,80 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package feature + +import ( + "context" + "fmt" + + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + "github.com/telekom/controlplane/gateway/internal/features" +) + +var _ features.Feature = &DynamicUpstreamFeature{} + +var InstanceDynamicUpstreamFeature = &DynamicUpstreamFeature{ + // Priority is set to be higher than InstanceLastMileSecurityFeature to ensure + // that the value of "remote_api_url" is set by this feature + priority: InstanceLastMileSecurityFeature.Priority() + 1, +} + +type DynamicUpstreamFeature struct { + priority int +} + +// Name implements features.Feature. +func (d *DynamicUpstreamFeature) Name() gatewayv1.FeatureType { + return gatewayv1.FeatureTypeDynamicUpstream +} + +// Priority implements features.Feature. +func (d *DynamicUpstreamFeature) Priority() int { + return d.priority +} + +// IsUsed implements features.Feature. +func (d *DynamicUpstreamFeature) IsUsed(ctx context.Context, builder features.FeaturesBuilder) bool { + route, ok := builder.GetRoute() + if !ok { + return false + } + + if len(route.Spec.Upstreams) != 1 { + // Must have exactly 1 upstream to be considered for dynamic upstream feature + return false + } + if route.Spec.Upstreams[0].IsProxy() { + // Dynamic Upstream is only relevant for non-proxy routes (last-hop) + return false + } + if route.Spec.Upstreams[0].Host != "localhost" { + // Dynamic Upstream is only relevant if the upstream host is set to "localhost" + // (indicating that it should be replaced with the actual target URL at runtime) + return false + } + + return route.HasDynamicUpstream() + +} + +// Apply implements features.Feature. +func (d *DynamicUpstreamFeature) Apply(ctx context.Context, builder features.FeaturesBuilder) error { + route, ok := builder.GetRoute() + if !ok { + return features.ErrNoRoute + } + urlParameter := route.Spec.Traffic.DynamicUpstream.QueryParameter + + rtpPlugin := builder.RequestTransformerPlugin() + + // Override the static remote_api_url (set by LastMileSecurity) with the dynamic value + rtpPlugin.Config.Append.Headers.Remove("remote_api_url") + rtpPlugin.Config.Append. + AddHeader("remote_api_url", fmt.Sprintf("$(query_params.%s)", urlParameter)) + // Remove the URL parameter from the forwarded request + rtpPlugin.Config.Remove.AddQuerystring(urlParameter) + + return nil +} diff --git a/gateway/internal/features/feature/dynamic_upstream_test.go b/gateway/internal/features/feature/dynamic_upstream_test.go new file mode 100644 index 00000000..2ae3920b --- /dev/null +++ b/gateway/internal/features/feature/dynamic_upstream_test.go @@ -0,0 +1,209 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package feature + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gatewayv1 "github.com/telekom/controlplane/gateway/api/v1" + "github.com/telekom/controlplane/gateway/internal/features" + featuresmock "github.com/telekom/controlplane/gateway/internal/features/mock" + "github.com/telekom/controlplane/gateway/pkg/kong/client/plugin" + "go.uber.org/mock/gomock" +) + +var _ = Describe("DynamicUpstreamFeature", func() { + + It("should return the correct feature type", func() { + Expect(InstanceDynamicUpstreamFeature.Name()).To(Equal(gatewayv1.FeatureTypeDynamicUpstream)) + }) + + It("should have priority higher than LastMileSecurityFeature", func() { + Expect(InstanceDynamicUpstreamFeature.Priority()).To(Equal(InstanceLastMileSecurityFeature.Priority() + 1)) + }) + + Context("with mocked feature builder", func() { + + var ctrl *gomock.Controller + var mockFeatureBuilder *featuresmock.MockFeaturesBuilder + var feature DynamicUpstreamFeature + + BeforeEach(func() { + feature = *InstanceDynamicUpstreamFeature + + ctrl = gomock.NewController(GinkgoT()) + mockFeatureBuilder = featuresmock.NewMockFeaturesBuilder(ctrl) + }) + + Context("check IsUsed", func() { + It("should return false when no route in builder", func() { + mockFeatureBuilder.EXPECT().GetRoute().Return(nil, false) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeFalse()) + }) + + It("should return false when route has no upstreams", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{}, + }, + } + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeFalse()) + }) + + It("should return false when route has multiple upstreams", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + {Host: "localhost"}, + {Host: "localhost"}, + }, + }, + } + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeFalse()) + }) + + It("should return false when upstream is a proxy", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + { + Host: "localhost", + IssuerUrl: "https://issuer.example.com", + }, + }, + Traffic: gatewayv1.Traffic{ + DynamicUpstream: &gatewayv1.DynamicUpstream{ + QueryParameter: "target_url", + }, + }, + }, + } + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeFalse()) + }) + + It("should return false when upstream host is not localhost", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + {Host: "example.com"}, + }, + Traffic: gatewayv1.Traffic{ + DynamicUpstream: &gatewayv1.DynamicUpstream{ + QueryParameter: "target_url", + }, + }, + }, + } + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeFalse()) + }) + + It("should return false when no DynamicUpstream config", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + {Host: "localhost"}, + }, + }, + } + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeFalse()) + }) + + It("should return true when all conditions are met", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + {Host: "localhost"}, + }, + Traffic: gatewayv1.Traffic{ + DynamicUpstream: &gatewayv1.DynamicUpstream{ + QueryParameter: "target_url", + }, + }, + }, + } + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + Expect(feature.IsUsed(context.Background(), mockFeatureBuilder)).To(BeTrue()) + }) + }) + + Context("Apply", func() { + It("should return ErrNoRoute when no route in builder", func() { + mockFeatureBuilder.EXPECT().GetRoute().Return(nil, false) + err := feature.Apply(context.Background(), mockFeatureBuilder) + Expect(err).To(MatchError(features.ErrNoRoute)) + }) + + It("should replace static remote_api_url with dynamic value", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + {Host: "localhost"}, + }, + Traffic: gatewayv1.Traffic{ + DynamicUpstream: &gatewayv1.DynamicUpstream{ + QueryParameter: "target_url", + }, + }, + }, + } + + rtpPlugin := &plugin.RequestTransformerPlugin{ + Config: plugin.RequestTransformerPluginConfig{}, + } + // Simulate LastMileSecurity having set the static header + rtpPlugin.Config.Append.AddHeader("remote_api_url", "https://static.example.com") + + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + mockFeatureBuilder.EXPECT().RequestTransformerPlugin().Return(rtpPlugin) + + err := feature.Apply(context.Background(), mockFeatureBuilder) + Expect(err).ToNot(HaveOccurred()) + + // Verify static header was replaced with dynamic value + Expect(rtpPlugin.Config.Append.Headers.Get("remote_api_url")).To(Equal("$(query_params.target_url)")) + // Verify query parameter is removed from forwarded request + Expect(rtpPlugin.Config.Remove.Querystring).ToNot(BeNil()) + Expect(rtpPlugin.Config.Remove.Querystring.Contains("target_url")).To(BeTrue()) + }) + + It("should use the configured query parameter name", func() { + route := &gatewayv1.Route{ + Spec: gatewayv1.RouteSpec{ + Upstreams: []gatewayv1.Upstream{ + {Host: "localhost"}, + }, + Traffic: gatewayv1.Traffic{ + DynamicUpstream: &gatewayv1.DynamicUpstream{ + QueryParameter: "api_endpoint", + }, + }, + }, + } + + rtpPlugin := &plugin.RequestTransformerPlugin{ + Config: plugin.RequestTransformerPluginConfig{}, + } + rtpPlugin.Config.Append.AddHeader("remote_api_url", "https://original.example.com") + + mockFeatureBuilder.EXPECT().GetRoute().Return(route, true) + mockFeatureBuilder.EXPECT().RequestTransformerPlugin().Return(rtpPlugin) + + err := feature.Apply(context.Background(), mockFeatureBuilder) + Expect(err).ToNot(HaveOccurred()) + + Expect(rtpPlugin.Config.Append.Headers.Get("remote_api_url")).To(Equal("$(query_params.api_endpoint)")) + Expect(rtpPlugin.Config.Remove.Querystring).ToNot(BeNil()) + Expect(rtpPlugin.Config.Remove.Querystring.Contains("api_endpoint")).To(BeTrue()) + }) + }) + }) +}) diff --git a/gateway/internal/handler/route/handler.go b/gateway/internal/handler/route/handler.go index 0dc54a55..32e82136 100644 --- a/gateway/internal/handler/route/handler.go +++ b/gateway/internal/handler/route/handler.go @@ -166,6 +166,7 @@ func NewFeatureBuilder(ctx context.Context, route *gatewayv1.Route) (features.Fe builder.EnableFeature(feature.InstanceHeaderTransformationFeature) builder.EnableFeature(feature.InstanceBasicAuthFeature) builder.EnableFeature(feature.InstanceCircuitBreakerFeature) + builder.EnableFeature(feature.InstanceDynamicUpstreamFeature) return builder, nil } diff --git a/gateway/pkg/kong/client/client.go b/gateway/pkg/kong/client/client.go index 97761277..eb04fe83 100644 --- a/gateway/pkg/kong/client/client.go +++ b/gateway/pkg/kong/client/client.go @@ -468,8 +468,8 @@ func (c *kongClient) CreateOrReplaceRoute(ctx context.Context, route CustomRoute Service: &kong.CreateRouteRequestService{ Id: service.Id, }, - RequestBuffering: true, - ResponseBuffering: true, + RequestBuffering: route.GetRequestBuffering(), + ResponseBuffering: route.GetResponseBuffering(), HttpsRedirectStatusCode: 426, Tags: &[]string{ diff --git a/gateway/pkg/kong/client/types.go b/gateway/pkg/kong/client/types.go index d02f91af..27560f61 100644 --- a/gateway/pkg/kong/client/types.go +++ b/gateway/pkg/kong/client/types.go @@ -31,6 +31,8 @@ type CustomRoute interface { GetName() string GetHost() string GetPath() string + GetRequestBuffering() bool + GetResponseBuffering() bool } type CustomConsumer interface { diff --git a/pubsub/.gitignore b/pubsub/.gitignore new file mode 100644 index 00000000..01b364e5 --- /dev/null +++ b/pubsub/.gitignore @@ -0,0 +1,34 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +Dockerfile.cross + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Kubernetes Generated files - skip generated files, except for vendored files +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +.vscode +*.swp +*.swo +*~ + +# Kubeconfig might contain secrets +*.kubeconfig diff --git a/pubsub/.golangci.yml b/pubsub/.golangci.yml new file mode 100644 index 00000000..75739b19 --- /dev/null +++ b/pubsub/.golangci.yml @@ -0,0 +1,56 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +version: "2" +run: + allow-parallel-runners: true +linters: + default: none + enable: + - copyloopvar + - dupl + - errcheck + - ginkgolinter + - goconst + - gocyclo + - govet + - ineffassign + - lll + - misspell + - nakedret + - prealloc + - revive + - staticcheck + - unconvert + - unparam + - unused + settings: + revive: + rules: + - name: comment-spacings + - name: import-shadowing + exclusions: + generated: lax + rules: + - linters: + - lll + path: api/* + - linters: + - dupl + - lll + path: internal/* + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/pubsub/Makefile b/pubsub/Makefile new file mode 100644 index 00000000..0dbc945c --- /dev/null +++ b/pubsub/Makefile @@ -0,0 +1,254 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Image URL to use all building/pushing image targets +IMG ?= controller:latest + +# 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 + +.PHONY: all +all: build + +##@ 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 command is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Development + +.PHONY: manifests +manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + $(CONTROLLER_GEN) rbac:roleName=manager-role,headerFile="../hack/boilerplate.yaml.txt" crd:headerFile="../hack/boilerplate.yaml.txt" paths="./..." output:crd:artifacts:config=config/crd/bases + +.PHONY: generate +generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + $(CONTROLLER_GEN) object:headerFile="../hack/boilerplate.go.txt" paths="./..." + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: manifests generate fmt vet setup-envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /test) -coverprofile cover.out -json 2>&1 | tee gotest.log | gotestfmt + +# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. +# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. +# CertManager is installed by default; skip with: +# - CERT_MANAGER_INSTALL_SKIP=true +KIND_CLUSTER ?= pubsub-test-e2e + +.PHONY: setup-test-e2e +setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist + @command -v $(KIND) >/dev/null 2>&1 || { \ + echo "Kind is not installed. Please install Kind manually."; \ + exit 1; \ + } + @case "$$($(KIND) get clusters)" in \ + *"$(KIND_CLUSTER)"*) \ + echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \ + *) \ + echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ + $(KIND) create cluster --name $(KIND_CLUSTER) ;; \ + esac + +.PHONY: test-e2e +test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. + KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v + $(MAKE) cleanup-test-e2e + +.PHONY: cleanup-test-e2e +cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests + @$(KIND) delete cluster --name $(KIND_CLUSTER) + +.PHONY: lint +lint: golangci-lint ## Run golangci-lint linter + "$(GOLANGCI_LINT)" run + +.PHONY: lint-fix +lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes + "$(GOLANGCI_LINT)" run --fix + +.PHONY: lint-config +lint-config: golangci-lint ## Verify golangci-lint linter configuration + "$(GOLANGCI_LINT)" config verify + +##@ Build + +.PHONY: build +build: manifests generate fmt vet ## Build manager binary. + go build -o bin/manager cmd/main.go + +.PHONY: run +run: manifests generate fmt vet ## Run a controller from your host. + go run ./cmd/main.go + +# If you wish to build the manager image targeting other platforms you can use the --platform flag. +# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. +# More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +.PHONY: docker-build +docker-build: ## Build docker image with the manager. + $(CONTAINER_TOOL) build -t ${IMG} . + +.PHONY: docker-push +docker-push: ## Push docker image with the manager. + $(CONTAINER_TOOL) push ${IMG} + +# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple +# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: +# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ +# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) +# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. +PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +.PHONY: docker-buildx +docker-buildx: ## Build and push docker image for the manager for cross-platform support + # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile + sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross + - $(CONTAINER_TOOL) buildx create --name pubsub-builder + $(CONTAINER_TOOL) buildx use pubsub-builder + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx rm pubsub-builder + rm Dockerfile.cross + +.PHONY: build-installer +build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. + mkdir -p dist + cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} + "$(KUSTOMIZE)" build config/default > dist/install.yaml + +##@ 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. + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi + +.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. + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi + +.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: kustomize ## 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 - + +##@ Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p "$(LOCALBIN)" + +## Tool Binaries +KUBECTL ?= kubectl +KIND ?= kind +KUSTOMIZE ?= $(LOCALBIN)/kustomize +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +ENVTEST ?= $(LOCALBIN)/setup-envtest +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint + +## Tool Versions +KUSTOMIZE_VERSION ?= v5.7.1 +CONTROLLER_TOOLS_VERSION ?= v0.19.0 + +#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) +ENVTEST_VERSION ?= $(shell v='$(call gomodver,sigs.k8s.io/controller-runtime)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_VERSION manually (controller-runtime replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/release-\1.\2/') + +#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) +ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/') + +GOLANGCI_LINT_VERSION ?= v2.5.0 +.PHONY: kustomize +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. +$(KUSTOMIZE): $(LOCALBIN) + $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) + +.PHONY: controller-gen +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. +$(CONTROLLER_GEN): $(LOCALBIN) + $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) + +.PHONY: setup-envtest +setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. + @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." + @"$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path || { \ + echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ + exit 1; \ + } + +.PHONY: envtest +envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. +$(ENVTEST): $(LOCALBIN) + $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) + +.PHONY: golangci-lint +golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. +$(GOLANGCI_LINT): $(LOCALBIN) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + +# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist +# $1 - target path with name of binary +# $2 - package url which can be installed +# $3 - specific version of package +define go-install-tool +@[ -f "$(1)-$(3)" ] && [ "$$(readlink -- "$(1)" 2>/dev/null)" = "$(1)-$(3)" ] || { \ +set -e; \ +package=$(2)@$(3) ;\ +echo "Downloading $${package}" ;\ +rm -f "$(1)" ;\ +GOBIN="$(LOCALBIN)" go install $${package} ;\ +mv "$(LOCALBIN)/$$(basename "$(1)")" "$(1)-$(3)" ;\ +} ;\ +ln -sf "$$(realpath "$(1)-$(3)")" "$(1)" +endef + +define gomodver +$(shell go list -m -f '{{if .Replace}}{{.Replace.Version}}{{else}}{{.Version}}{{end}}' $(1) 2>/dev/null) +endef diff --git a/pubsub/PROJECT b/pubsub/PROJECT new file mode 100644 index 00000000..81f69ede --- /dev/null +++ b/pubsub/PROJECT @@ -0,0 +1,39 @@ +# 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 +cliVersion: 4.10.1 +domain: cp.ei.telekom.de +layout: +- go.kubebuilder.io/v4 +projectName: pubsub +repo: github.com/telekom/controlplane/pubsub +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: pubsub + kind: EventStore + path: github.com/telekom/controlplane/pubsub/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: pubsub + kind: Publisher + path: github.com/telekom/controlplane/pubsub/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cp.ei.telekom.de + group: pubsub + kind: Subscriber + path: github.com/telekom/controlplane/pubsub/api/v1 + version: v1 +version: "3" diff --git a/pubsub/PROJECT.license b/pubsub/PROJECT.license new file mode 100644 index 00000000..be863cd5 --- /dev/null +++ b/pubsub/PROJECT.license @@ -0,0 +1,3 @@ +Copyright 2026 Deutsche Telekom IT GmbH + +SPDX-License-Identifier: Apache-2.0 diff --git a/pubsub/README.md b/pubsub/README.md new file mode 100644 index 00000000..d034235b --- /dev/null +++ b/pubsub/README.md @@ -0,0 +1,46 @@ + + +

+

PubSub Domain

+

+ +

+ Publish & Subscribe (PubSub) is a messaging pattern where senders (publishers) send messages to a topic, and receivers (subscribers) receive messages from that topic (event-types) + It allows for decoupling of components and asynchronous communication. This implementation relies on [Horizon](https://github.com/telekom/pubsub-horizon) as the underlying messaging system. +

+ +

+ About • + Usage • + References +

+ +## About + +The PubSub Domain is an extension (Feature enabled via Flag) to the Controlplane that provides a Publish & Subscribe (PubSub) messaging system. +It allows for decoupling of components and asynchronous communication. +It integrates with the Controlplane cores features such as Gateway and Approval to provide a seamless experience for users. + +This domain is used as a `Runtime-Config-Layer` for the Controlplane, which means that it is the last layer before the configuration is written to the runtime component (Horizon) + +For a more detailed view of the internal workings of the Horizon components, please refer to the [Horizon Documentation](https://github.com/telekom/pubsub-horizon). The pubsub Domain is designed as a thin bridge between the Controlplane and Horizon. + +> [!NOTE] +> For a detailed architecture diagram, see [docs](./docs/pubsub-domain-architecture.md). + + +## Usage + +This domain is fully configured through the Event Domain, which means that users do not interact with the PubSub Domain directly. +Instead, they create `EventExposure` and `EventSubscription` resources in the Event Domain, which then creates the necessary resources in the PubSub Domain. +The PubSub Domain then interacts with the Horizon runtime component to create the necessary topics and subscriptions based on the configurations set in the Event Domain. + +## References + +- Runtime Component: [Horizon Documentation](https://github.com/telekom/pubsub-horizon) +- Parent Domain which does most of the heavy lifting: [Event Domain](../event/README.md) + diff --git a/pubsub/api/go.mod b/pubsub/api/go.mod new file mode 100644 index 00000000..9b77031c --- /dev/null +++ b/pubsub/api/go.mod @@ -0,0 +1,57 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +module github.com/telekom/controlplane/pubsub/api + +go 1.24.9 + +require ( + github.com/telekom/controlplane/common v0.0.0 + k8s.io/apiextensions-apiserver v0.34.2 + k8s.io/apimachinery v0.34.2 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.2 // indirect + k8s.io/client-go v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace github.com/telekom/controlplane/common => ../../common diff --git a/pubsub/api/go.sum b/pubsub/api/go.sum new file mode 100644 index 00000000..21cc1e3e --- /dev/null +++ b/pubsub/api/go.sum @@ -0,0 +1,188 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +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.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.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/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/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.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pubsub/api/go.sum.license b/pubsub/api/go.sum.license new file mode 100644 index 00000000..be863cd5 --- /dev/null +++ b/pubsub/api/go.sum.license @@ -0,0 +1,3 @@ +Copyright 2026 Deutsche Telekom IT GmbH + +SPDX-License-Identifier: Apache-2.0 diff --git a/pubsub/api/v1/eventstore_types.go b/pubsub/api/v1/eventstore_types.go new file mode 100644 index 00000000..f6a41957 --- /dev/null +++ b/pubsub/api/v1/eventstore_types.go @@ -0,0 +1,96 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ctypes "github.com/telekom/controlplane/common/pkg/types" +) + +// EventStoreSpec defines the desired state of EventStore. +// EventStore holds resolved operational values for connecting to the configuration backend, +// including OAuth2 credentials. It is created by the EventConfig handler. +type EventStoreSpec struct { + // Url is the base URL of the configuration backend API. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Format=uri + Url string `json:"url"` + + // TokenUrl is the OAuth2 token endpoint for authenticating with the configuration backend. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Format=uri + TokenUrl string `json:"tokenUrl"` + + // ClientId is the OAuth2 client ID for authenticating with the configuration backend. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ClientId string `json:"clientId"` + + // ClientSecret is the OAuth2 client secret for authenticating with the configuration backend. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ClientSecret string `json:"clientSecret"` +} + +// EventStoreStatus defines the observed state of EventStore. +type EventStoreStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// EventStore is the Schema for the eventstores API. +// It stores the resolved connection and authentication details for the configuration backend. +// EventStore resources are created and managed by the EventConfig handler in the event domain. +type EventStore struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventStoreSpec `json:"spec,omitempty"` + Status EventStoreStatus `json:"status,omitempty"` +} + +var _ ctypes.Object = &EventStore{} + +func (r *EventStore) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *EventStore) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +// EventStoreList contains a list of EventStore +type EventStoreList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventStore `json:"items"` +} + +var _ ctypes.ObjectList = &EventStoreList{} + +func (r *EventStoreList) GetItems() []ctypes.Object { + items := make([]ctypes.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&EventStore{}, &EventStoreList{}) +} diff --git a/pubsub/api/v1/groupversion_info.go b/pubsub/api/v1/groupversion_info.go new file mode 100644 index 00000000..e04471d4 --- /dev/null +++ b/pubsub/api/v1/groupversion_info.go @@ -0,0 +1,24 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +// Package v1 contains API Schema definitions for the pubsub v1 API group. +// +kubebuilder:object:generate=true +// +groupName=pubsub.cp.ei.telekom.de +package v1 + +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: "pubsub.cp.ei.telekom.de", Version: "v1"} + + // 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/pubsub/api/v1/publisher_types.go b/pubsub/api/v1/publisher_types.go new file mode 100644 index 00000000..98878fcd --- /dev/null +++ b/pubsub/api/v1/publisher_types.go @@ -0,0 +1,97 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ctypes "github.com/telekom/controlplane/common/pkg/types" +) + +// PublisherSpec defines the desired state of Publisher. +// Publisher represents a registered event publisher in the configuration backend. +// It is created by the EventExposure handler in the event domain. +type PublisherSpec struct { + // EventStore references the EventStore CR that provides configuration connection details. + EventStore ctypes.ObjectRef `json:"eventStore"` + + // EventType is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + EventType string `json:"eventType"` + + // JsonSchema is an optional JSON schema defining the structure of events published by this publisher. + // It can be used for validation and documentation purposes. + // +optional + JsonSchema string `json:"jsonSchema,omitempty"` + + // PublisherId is the unique identifier for this publisher in the configuration backend. + // Typically derived from the providing application's identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + PublisherId string `json:"publisherId"` + + // AdditionalPublisherIds allows multiple application IDs to publish to the same event type. + // +optional + AdditionalPublisherIds []string `json:"additionalPublisherIds,omitempty"` +} + +// PublisherStatus defines the observed state of Publisher. +type PublisherStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// Publisher is the Schema for the publishers API. +// It represents an event publisher registration in the configuration backend. +// Publisher resources are created and managed by the EventExposure handler in the event domain. +type Publisher struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PublisherSpec `json:"spec,omitempty"` + Status PublisherStatus `json:"status,omitempty"` +} + +var _ ctypes.Object = &Publisher{} + +func (r *Publisher) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *Publisher) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +// PublisherList contains a list of Publisher +type PublisherList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Publisher `json:"items"` +} + +var _ ctypes.ObjectList = &PublisherList{} + +func (r *PublisherList) GetItems() []ctypes.Object { + items := make([]ctypes.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&Publisher{}, &PublisherList{}) +} diff --git a/pubsub/api/v1/shared_types.go b/pubsub/api/v1/shared_types.go new file mode 100644 index 00000000..b45cc8e7 --- /dev/null +++ b/pubsub/api/v1/shared_types.go @@ -0,0 +1,122 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// DeliveryType defines how events are delivered to subscribers. +// +kubebuilder:validation:Enum=Callback;ServerSentEvent +type DeliveryType string + +const ( + DeliveryTypeCallback DeliveryType = "Callback" + DeliveryTypeServerSentEvent DeliveryType = "ServerSentEvent" +) + +func (d DeliveryType) String() string { + return string(d) +} + +// PayloadType defines the event payload format. +// +kubebuilder:validation:Enum=Data;DataRef +type PayloadType string + +const ( + PayloadTypeData PayloadType = "Data" + PayloadTypeDataRef PayloadType = "DataRef" +) + +func (p PayloadType) String() string { + return string(p) +} + +// ResponseFilterMode controls whether the response filter includes or excludes the specified fields. +// +kubebuilder:validation:Enum=Include;Exclude +type ResponseFilterMode string + +const ( + ResponseFilterModeInclude ResponseFilterMode = "Include" + ResponseFilterModeExclude ResponseFilterMode = "Exclude" +) + +func (r ResponseFilterMode) String() string { + return string(r) +} + +// ResponseFilter controls which fields are included or excluded from the event payload. +type ResponseFilter struct { + // Paths lists the JSON paths to include or exclude from the event payload. + // +optional + Paths []string `json:"paths,omitempty"` + + // Mode controls whether the listed paths are included or excluded. + // +optional + // +kubebuilder:default=Include + Mode ResponseFilterMode `json:"mode,omitempty"` +} + +// SelectionFilter defines criteria for selecting which events are delivered. +type SelectionFilter struct { + // Attributes defines simple key-value equality matches on CloudEvents attributes. + // All entries are AND-ed together. + // +optional + Attributes map[string]string `json:"attributes,omitempty"` + + // Expression contains an arbitrary JSON filter expression tree + // using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + // that is passed through to the configuration backend without structural validation. + // +optional + Expression *apiextensionsv1.JSON `json:"expression,omitempty"` +} + +// Trigger defines filtering criteria for event delivery in the pubsub domain. +type Trigger struct { + // ResponseFilter controls payload shaping (which fields to return). + // +optional + ResponseFilter *ResponseFilter `json:"responseFilter,omitempty"` + + // SelectionFilter controls event matching (which events to deliver). + // +optional + SelectionFilter *SelectionFilter `json:"selectionFilter,omitempty"` +} + +// SubscriptionDelivery configures how events are delivered to the subscriber in the pubsub domain. +// +kubebuilder:validation:XValidation:rule="self.type == 'Callback' ? self.callback != \"\" : !has(self.callback)",message="callback is required for deliveryType 'Callback' and must not be set for 'ServerSentEvent'" +type SubscriptionDelivery struct { + // Type defines the delivery mechanism. + // +kubebuilder:default=Callback + Type DeliveryType `json:"type"` + + // Payload defines the event payload format. + // +kubebuilder:default=Data + Payload PayloadType `json:"payload"` + + // Callback is the URL where events are delivered. + // Required when type is "callback", must not be set for "ServerSentEvent". + // +optional + Callback string `json:"callback,omitempty"` + + // EventRetentionTime defines how long events are retained for this subscriber. + // +optional + EventRetentionTime string `json:"eventRetentionTime,omitempty"` + + // CircuitBreakerOptOut disables the circuit breaker for this subscription. + // +optional + CircuitBreakerOptOut bool `json:"circuitBreakerOptOut,omitempty"` + + // RetryableStatusCodes defines HTTP status codes that should trigger a retry. + // +optional + RetryableStatusCodes []int `json:"retryableStatusCodes,omitempty"` + + // RedeliveriesPerSecond limits the rate of event redeliveries. + // +optional + RedeliveriesPerSecond *int `json:"redeliveriesPerSecond,omitempty"` + + // EnforceGetHttpRequestMethodForHealthCheck forces GET for health check probes instead of HEAD. + // +optional + EnforceGetHttpRequestMethodForHealthCheck bool `json:"enforceGetHttpRequestMethodForHealthCheck,omitempty"` +} diff --git a/pubsub/api/v1/subscriber_types.go b/pubsub/api/v1/subscriber_types.go new file mode 100644 index 00000000..505f23ef --- /dev/null +++ b/pubsub/api/v1/subscriber_types.go @@ -0,0 +1,105 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ctypes "github.com/telekom/controlplane/common/pkg/types" +) + +// SubscriberSpec defines the desired state of Subscriber. +// Subscriber represents an event subscription registration in the configuration backend. +// It is created by the EventSubscription handler in the event domain. +type SubscriberSpec struct { + // Publisher references the Publisher CR that this subscriber subscribes to. + // The Subscriber controller resolves the Publisher at runtime to obtain + // EventType, PublisherId, AdditionalPublisherIds, and EventStore details. + Publisher ctypes.ObjectRef `json:"publisher"` + + // SubscriberId is the unique identifier for this subscriber in the configuration backend. + // Typically derived from the consuming application's identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + SubscriberId string `json:"subscriberId"` + + // Delivery configures how events are delivered to the subscriber. + Delivery SubscriptionDelivery `json:"delivery"` + + // Trigger defines subscriber-side filtering criteria for event delivery. + // +optional + Trigger *Trigger `json:"trigger,omitempty"` + + // PublisherTrigger defines publisher-side filtering criteria for event delivery. + // +optional + PublisherTrigger *Trigger `json:"publisherTrigger,omitempty"` + + // AppliedScopes lists the scope names that are applied to this subscription. + // +optional + AppliedScopes []string `json:"appliedScopes,omitempty"` +} + +// SubscriberStatus defines the observed state of Subscriber. +type SubscriberStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // SubscriptionId is the self-assigned subscription identifier. + // Populated after the subscription is successfully registered with the configuration backend. + // +optional + SubscriptionId string `json:"subscriptionId,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// Subscriber is the Schema for the subscribers API. +// It represents an event subscription registration in the configuration backend. +// Subscriber resources are created and managed by the EventSubscription handler in the event domain. +type Subscriber struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec SubscriberSpec `json:"spec,omitempty"` + Status SubscriberStatus `json:"status,omitempty"` +} + +var _ ctypes.Object = &Subscriber{} + +func (r *Subscriber) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *Subscriber) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +// SubscriberList contains a list of Subscriber +type SubscriberList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Subscriber `json:"items"` +} + +var _ ctypes.ObjectList = &SubscriberList{} + +func (r *SubscriberList) GetItems() []ctypes.Object { + items := make([]ctypes.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&Subscriber{}, &SubscriberList{}) +} diff --git a/pubsub/api/v1/zz_generated.deepcopy.go b/pubsub/api/v1/zz_generated.deepcopy.go new file mode 100644 index 00000000..2b8bdddd --- /dev/null +++ b/pubsub/api/v1/zz_generated.deepcopy.go @@ -0,0 +1,423 @@ +//go:build !ignore_autogenerated + +// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventStore) DeepCopyInto(out *EventStore) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventStore. +func (in *EventStore) DeepCopy() *EventStore { + if in == nil { + return nil + } + out := new(EventStore) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventStore) 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 *EventStoreList) DeepCopyInto(out *EventStoreList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventStore, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventStoreList. +func (in *EventStoreList) DeepCopy() *EventStoreList { + if in == nil { + return nil + } + out := new(EventStoreList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventStoreList) 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 *EventStoreSpec) DeepCopyInto(out *EventStoreSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventStoreSpec. +func (in *EventStoreSpec) DeepCopy() *EventStoreSpec { + if in == nil { + return nil + } + out := new(EventStoreSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventStoreStatus) DeepCopyInto(out *EventStoreStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventStoreStatus. +func (in *EventStoreStatus) DeepCopy() *EventStoreStatus { + if in == nil { + return nil + } + out := new(EventStoreStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Publisher) DeepCopyInto(out *Publisher) { + *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 Publisher. +func (in *Publisher) DeepCopy() *Publisher { + if in == nil { + return nil + } + out := new(Publisher) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Publisher) 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 *PublisherList) DeepCopyInto(out *PublisherList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Publisher, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublisherList. +func (in *PublisherList) DeepCopy() *PublisherList { + if in == nil { + return nil + } + out := new(PublisherList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PublisherList) 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 *PublisherSpec) DeepCopyInto(out *PublisherSpec) { + *out = *in + in.EventStore.DeepCopyInto(&out.EventStore) + if in.AdditionalPublisherIds != nil { + in, out := &in.AdditionalPublisherIds, &out.AdditionalPublisherIds + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublisherSpec. +func (in *PublisherSpec) DeepCopy() *PublisherSpec { + if in == nil { + return nil + } + out := new(PublisherSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublisherStatus) DeepCopyInto(out *PublisherStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublisherStatus. +func (in *PublisherStatus) DeepCopy() *PublisherStatus { + if in == nil { + return nil + } + out := new(PublisherStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseFilter) DeepCopyInto(out *ResponseFilter) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseFilter. +func (in *ResponseFilter) DeepCopy() *ResponseFilter { + if in == nil { + return nil + } + out := new(ResponseFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SelectionFilter) DeepCopyInto(out *SelectionFilter) { + *out = *in + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Expression != nil { + in, out := &in.Expression, &out.Expression + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SelectionFilter. +func (in *SelectionFilter) DeepCopy() *SelectionFilter { + if in == nil { + return nil + } + out := new(SelectionFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Subscriber) DeepCopyInto(out *Subscriber) { + *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 Subscriber. +func (in *Subscriber) DeepCopy() *Subscriber { + if in == nil { + return nil + } + out := new(Subscriber) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Subscriber) 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 *SubscriberList) DeepCopyInto(out *SubscriberList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Subscriber, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriberList. +func (in *SubscriberList) DeepCopy() *SubscriberList { + if in == nil { + return nil + } + out := new(SubscriberList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SubscriberList) 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 *SubscriberSpec) DeepCopyInto(out *SubscriberSpec) { + *out = *in + in.Publisher.DeepCopyInto(&out.Publisher) + in.Delivery.DeepCopyInto(&out.Delivery) + if in.Trigger != nil { + in, out := &in.Trigger, &out.Trigger + *out = new(Trigger) + (*in).DeepCopyInto(*out) + } + if in.PublisherTrigger != nil { + in, out := &in.PublisherTrigger, &out.PublisherTrigger + *out = new(Trigger) + (*in).DeepCopyInto(*out) + } + if in.AppliedScopes != nil { + in, out := &in.AppliedScopes, &out.AppliedScopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriberSpec. +func (in *SubscriberSpec) DeepCopy() *SubscriberSpec { + if in == nil { + return nil + } + out := new(SubscriberSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SubscriberStatus) DeepCopyInto(out *SubscriberStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriberStatus. +func (in *SubscriberStatus) DeepCopy() *SubscriberStatus { + if in == nil { + return nil + } + out := new(SubscriberStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SubscriptionDelivery) DeepCopyInto(out *SubscriptionDelivery) { + *out = *in + if in.RetryableStatusCodes != nil { + in, out := &in.RetryableStatusCodes, &out.RetryableStatusCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } + if in.RedeliveriesPerSecond != nil { + in, out := &in.RedeliveriesPerSecond, &out.RedeliveriesPerSecond + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubscriptionDelivery. +func (in *SubscriptionDelivery) DeepCopy() *SubscriptionDelivery { + if in == nil { + return nil + } + out := new(SubscriptionDelivery) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Trigger) DeepCopyInto(out *Trigger) { + *out = *in + if in.ResponseFilter != nil { + in, out := &in.ResponseFilter, &out.ResponseFilter + *out = new(ResponseFilter) + (*in).DeepCopyInto(*out) + } + if in.SelectionFilter != nil { + in, out := &in.SelectionFilter, &out.SelectionFilter + *out = new(SelectionFilter) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Trigger. +func (in *Trigger) DeepCopy() *Trigger { + if in == nil { + return nil + } + out := new(Trigger) + in.DeepCopyInto(out) + return out +} diff --git a/pubsub/cmd/main.go b/pubsub/cmd/main.go new file mode 100644 index 00000000..a90193a5 --- /dev/null +++ b/pubsub/cmd/main.go @@ -0,0 +1,200 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/tls" + "flag" + "os" + + // 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" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/telekom/controlplane/pubsub/internal/controller" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + controller.RegisterSchemesOrDie(scheme) +} + +// nolint:gocyclo +func main() { + var metricsAddr string + var metricsCertPath, metricsCertName, metricsCertKey string + var webhookCertPath, webhookCertName, webhookCertKey string + var enableLeaderElection bool + var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + flag.StringVar(&metricsCertPath, "metrics-cert-path", "", + "The directory that contains the metrics server certificate.") + flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, + "If set, HTTP/2 will be enabled for the metrics and webhook servers") + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + // if the enable-http2 flag is false (the default), http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + + // Initial webhook TLS options + webhookTLSOpts := tlsOpts + webhookServerOptions := webhook.Options{ + TLSOpts: webhookTLSOpts, + } + + if len(webhookCertPath) > 0 { + setupLog.Info("Initializing webhook certificate watcher using provided certificates", + "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) + + webhookServerOptions.CertDir = webhookCertPath + webhookServerOptions.CertName = webhookCertName + webhookServerOptions.KeyName = webhookCertKey + } + + webhookServer := webhook.NewServer(webhookServerOptions) + + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.4/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.4/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + + // If the certificate is not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + // + // TODO(user): If you enable certManager, uncomment the following lines: + // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates + // managed by cert-manager for the metrics server. + // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. + if len(metricsCertPath) > 0 { + setupLog.Info("Initializing metrics certificate watcher using provided certificates", + "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) + + metricsServerOptions.CertDir = metricsCertPath + metricsServerOptions.CertName = metricsCertName + metricsServerOptions.KeyName = metricsCertKey + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "30720559.cp.ei.telekom.de", + // 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) + } + + if err := (&controller.EventStoreReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EventStore") + os.Exit(1) + } + if err := (&controller.PublisherReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Publisher") + os.Exit(1) + } + if err := (&controller.SubscriberReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Subscriber") + 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) + } +} diff --git a/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_eventstores.yaml b/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_eventstores.yaml new file mode 100644 index 00000000..e99d29ec --- /dev/null +++ b/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_eventstores.yaml @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: eventstores.pubsub.cp.ei.telekom.de +spec: + group: pubsub.cp.ei.telekom.de + names: + kind: EventStore + listKind: EventStoreList + plural: eventstores + singular: eventstore + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + EventStore is the Schema for the eventstores API. + It stores the resolved connection and authentication details for the configuration backend. + EventStore resources are created and managed by the EventConfig handler in the event domain. + 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: |- + EventStoreSpec defines the desired state of EventStore. + EventStore holds resolved operational values for connecting to the configuration backend, + including OAuth2 credentials. It is created by the EventConfig handler. + properties: + clientId: + description: ClientId is the OAuth2 client ID for authenticating with + the configuration backend. + minLength: 1 + type: string + clientSecret: + description: ClientSecret is the OAuth2 client secret for authenticating + with the configuration backend. + minLength: 1 + type: string + tokenUrl: + description: TokenUrl is the OAuth2 token endpoint for authenticating + with the configuration backend. + format: uri + minLength: 1 + type: string + url: + description: Url is the base URL of the configuration backend API. + format: uri + minLength: 1 + type: string + required: + - clientId + - clientSecret + - tokenUrl + - url + type: object + status: + description: EventStoreStatus defines the observed state of EventStore. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_publishers.yaml b/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_publishers.yaml new file mode 100644 index 00000000..429ed969 --- /dev/null +++ b/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_publishers.yaml @@ -0,0 +1,163 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: publishers.pubsub.cp.ei.telekom.de +spec: + group: pubsub.cp.ei.telekom.de + names: + kind: Publisher + listKind: PublisherList + plural: publishers + singular: publisher + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + Publisher is the Schema for the publishers API. + It represents an event publisher registration in the configuration backend. + Publisher resources are created and managed by the EventExposure handler in the event domain. + 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: |- + PublisherSpec defines the desired state of Publisher. + Publisher represents a registered event publisher in the configuration backend. + It is created by the EventExposure handler in the event domain. + properties: + additionalPublisherIds: + description: AdditionalPublisherIds allows multiple application IDs + to publish to the same event type. + items: + type: string + type: array + eventStore: + description: EventStore references the EventStore CR that provides + configuration connection details. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + eventType: + description: EventType is the dot-separated event type identifier + (e.g. "de.telekom.eni.quickstart.v1"). + minLength: 1 + type: string + jsonSchema: + description: |- + JsonSchema is an optional JSON schema defining the structure of events published by this publisher. + It can be used for validation and documentation purposes. + type: string + publisherId: + description: |- + PublisherId is the unique identifier for this publisher in the configuration backend. + Typically derived from the providing application's identifier. + minLength: 1 + type: string + required: + - eventStore + - eventType + - publisherId + type: object + status: + description: PublisherStatus defines the observed state of Publisher. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_subscribers.yaml b/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_subscribers.yaml new file mode 100644 index 00000000..8304b591 --- /dev/null +++ b/pubsub/config/crd/bases/pubsub.cp.ei.telekom.de_subscribers.yaml @@ -0,0 +1,295 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: subscribers.pubsub.cp.ei.telekom.de +spec: + group: pubsub.cp.ei.telekom.de + names: + kind: Subscriber + listKind: SubscriberList + plural: subscribers + singular: subscriber + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + Subscriber is the Schema for the subscribers API. + It represents an event subscription registration in the configuration backend. + Subscriber resources are created and managed by the EventSubscription handler in the event domain. + 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: |- + SubscriberSpec defines the desired state of Subscriber. + Subscriber represents an event subscription registration in the configuration backend. + It is created by the EventSubscription handler in the event domain. + properties: + appliedScopes: + description: AppliedScopes lists the scope names that are applied + to this subscription. + items: + type: string + type: array + delivery: + description: Delivery configures how events are delivered to the subscriber. + properties: + callback: + description: |- + Callback is the URL where events are delivered. + Required when type is "callback", must not be set for "ServerSentEvent". + type: string + circuitBreakerOptOut: + description: CircuitBreakerOptOut disables the circuit breaker + for this subscription. + type: boolean + enforceGetHttpRequestMethodForHealthCheck: + description: EnforceGetHttpRequestMethodForHealthCheck forces + GET for health check probes instead of HEAD. + type: boolean + eventRetentionTime: + description: EventRetentionTime defines how long events are retained + for this subscriber. + type: string + payload: + default: Data + description: Payload defines the event payload format. + enum: + - Data + - DataRef + type: string + redeliveriesPerSecond: + description: RedeliveriesPerSecond limits the rate of event redeliveries. + type: integer + retryableStatusCodes: + description: RetryableStatusCodes defines HTTP status codes that + should trigger a retry. + items: + type: integer + type: array + type: + default: Callback + description: Type defines the delivery mechanism. + enum: + - Callback + - ServerSentEvent + type: string + required: + - payload + - type + type: object + x-kubernetes-validations: + - message: callback is required for deliveryType 'Callback' and must + not be set for 'ServerSentEvent' + rule: 'self.type == ''Callback'' ? self.callback != "" : !has(self.callback)' + publisher: + description: |- + Publisher references the Publisher CR that this subscriber subscribes to. + The Subscriber controller resolves the Publisher at runtime to obtain + EventType, PublisherId, AdditionalPublisherIds, and EventStore details. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + publisherTrigger: + description: PublisherTrigger defines publisher-side filtering criteria + for event delivery. + properties: + responseFilter: + description: ResponseFilter controls payload shaping (which fields + to return). + properties: + mode: + default: Include + description: Mode controls whether the listed paths are included + or excluded. + enum: + - Include + - Exclude + type: string + paths: + description: Paths lists the JSON paths to include or exclude + from the event payload. + items: + type: string + type: array + type: object + selectionFilter: + description: SelectionFilter controls event matching (which events + to deliver). + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes defines simple key-value equality matches on CloudEvents attributes. + All entries are AND-ed together. + type: object + expression: + description: |- + Expression contains an arbitrary JSON filter expression tree + using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + that is passed through to the configuration backend without structural validation. + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + subscriberId: + description: |- + SubscriberId is the unique identifier for this subscriber in the configuration backend. + Typically derived from the consuming application's identifier. + minLength: 1 + type: string + trigger: + description: Trigger defines subscriber-side filtering criteria for + event delivery. + properties: + responseFilter: + description: ResponseFilter controls payload shaping (which fields + to return). + properties: + mode: + default: Include + description: Mode controls whether the listed paths are included + or excluded. + enum: + - Include + - Exclude + type: string + paths: + description: Paths lists the JSON paths to include or exclude + from the event payload. + items: + type: string + type: array + type: object + selectionFilter: + description: SelectionFilter controls event matching (which events + to deliver). + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes defines simple key-value equality matches on CloudEvents attributes. + All entries are AND-ed together. + type: object + expression: + description: |- + Expression contains an arbitrary JSON filter expression tree + using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + that is passed through to the configuration backend without structural validation. + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + required: + - delivery + - publisher + - subscriberId + type: object + status: + description: SubscriberStatus defines the observed state of Subscriber. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + subscriptionId: + description: |- + SubscriptionId is the self-assigned subscription identifier. + Populated after the subscription is successfully registered with the configuration backend. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pubsub/config/crd/kustomization.yaml b/pubsub/config/crd/kustomization.yaml new file mode 100644 index 00000000..29da6e35 --- /dev/null +++ b/pubsub/config/crd/kustomization.yaml @@ -0,0 +1,22 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# 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/pubsub.cp.ei.telekom.de_eventstores.yaml +- bases/pubsub.cp.ei.telekom.de_publishers.yaml +- bases/pubsub.cp.ei.telekom.de_subscribers.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 +# +kubebuilder:scaffold:crdkustomizewebhookpatch + +# [WEBHOOK] To enable webhook, uncomment the following section +# the following config is for teaching kustomize how to do kustomization for CRDs. +#configurations: +#- kustomizeconfig.yaml diff --git a/pubsub/config/crd/kustomizeconfig.yaml b/pubsub/config/crd/kustomizeconfig.yaml new file mode 100644 index 00000000..756c9f39 --- /dev/null +++ b/pubsub/config/crd/kustomizeconfig.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# 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/pubsub/config/default/cert_metrics_manager_patch.yaml b/pubsub/config/default/cert_metrics_manager_patch.yaml new file mode 100644 index 00000000..c904c2a3 --- /dev/null +++ b/pubsub/config/default/cert_metrics_manager_patch.yaml @@ -0,0 +1,34 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs. + +# Add the volumeMount for the metrics-server certs +- op: add + path: /spec/template/spec/containers/0/volumeMounts/- + value: + mountPath: /tmp/k8s-metrics-server/metrics-certs + name: metrics-certs + readOnly: true + +# Add the --metrics-cert-path argument for the metrics server +- op: add + path: /spec/template/spec/containers/0/args/- + value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs + +# Add the metrics-server certs volume configuration +- op: add + path: /spec/template/spec/volumes/- + value: + name: metrics-certs + secret: + secretName: metrics-server-cert + optional: false + items: + - key: ca.crt + path: ca.crt + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key diff --git a/pubsub/config/default/kustomization.yaml b/pubsub/config/default/kustomization.yaml new file mode 100644 index 00000000..de925d78 --- /dev/null +++ b/pubsub/config/default/kustomization.yaml @@ -0,0 +1,238 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Adds namespace to all resources. +namespace: pubsub-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: pubsub- + +# 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 +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml +# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. +# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. +# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will +# be able to communicate with the Webhook Server. +#- ../network-policy + +# Uncomment the patches line if you enable Metrics +patches: +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml + target: + kind: Deployment + +# Uncomment the patches line if you enable Metrics and CertManager +# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line. +# This patch will protect the metrics with certManager self-signed certs. +#- path: cert_metrics_manager_patch.yaml +# target: +# kind: Deployment + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +#- path: manager_webhook_patch.yaml +# target: +# kind: Deployment + +# [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: # Uncomment the following block to enable certificates for metrics +# kind: Service +# version: v1 +# name: controller-manager-metrics-service +# fieldPath: metadata.name +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: metrics-certs +# fieldPaths: +# - spec.dnsNames.0 +# - spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 0 +# create: true +# - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor +# kind: ServiceMonitor +# group: monitoring.coreos.com +# version: v1 +# name: controller-manager-metrics-monitor +# fieldPaths: +# - spec.endpoints.0.tlsConfig.serverName +# options: +# delimiter: '.' +# index: 0 +# create: true + +# - source: +# kind: Service +# version: v1 +# name: controller-manager-metrics-service +# fieldPath: metadata.namespace +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: metrics-certs +# fieldPaths: +# - spec.dnsNames.0 +# - spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true +# - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor +# kind: ServiceMonitor +# group: monitoring.coreos.com +# version: v1 +# name: controller-manager-metrics-monitor +# fieldPaths: +# - spec.endpoints.0.tlsConfig.serverName +# options: +# delimiter: '.' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have any webhook +# kind: Service +# version: v1 +# name: webhook-service +# fieldPath: .metadata.name # Name of the service +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# 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 +# name: serving-cert +# fieldPaths: +# - .spec.dnsNames.0 +# - .spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) +# 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 +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: +# - select: +# kind: ValidatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: +# - select: +# kind: MutatingWebhookConfiguration +# 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 +# fieldPath: .metadata.name +# targets: +# - select: +# kind: MutatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true + +# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.namespace # Namespace of the certificate CR +# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. +# +kubebuilder:scaffold:crdkustomizecainjectionns +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert +# fieldPath: .metadata.name +# targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. +# +kubebuilder:scaffold:crdkustomizecainjectionname diff --git a/pubsub/config/default/manager_metrics_patch.yaml b/pubsub/config/default/manager_metrics_patch.yaml new file mode 100644 index 00000000..c0899178 --- /dev/null +++ b/pubsub/config/default/manager_metrics_patch.yaml @@ -0,0 +1,8 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/pubsub/config/default/metrics_service.yaml b/pubsub/config/default/metrics_service.yaml new file mode 100644 index 00000000..689af51d --- /dev/null +++ b/pubsub/config/default/metrics_service.yaml @@ -0,0 +1,22 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: controller-manager + app.kubernetes.io/name: pubsub diff --git a/pubsub/config/manager/kustomization.yaml b/pubsub/config/manager/kustomization.yaml new file mode 100644 index 00000000..c821ec62 --- /dev/null +++ b/pubsub/config/manager/kustomization.yaml @@ -0,0 +1,6 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +resources: +- manager.yaml diff --git a/pubsub/config/manager/manager.yaml b/pubsub/config/manager/manager.yaml new file mode 100644 index 00000000..20746ca6 --- /dev/null +++ b/pubsub/config/manager/manager.yaml @@ -0,0 +1,103 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub + 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: pubsub + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub + 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: + # Projects are configured by default to adhere to the "restricted" Pod Security Standards. + # This ensures that deployments meet the highest security requirements for Kubernetes. + # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: controller:latest + name: manager + ports: [] + securityContext: + readOnlyRootFilesystem: true + 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 + volumeMounts: [] + volumes: [] + serviceAccountName: controller-manager + terminationGracePeriodSeconds: 10 diff --git a/pubsub/config/network-policy/allow-metrics-traffic.yaml b/pubsub/config/network-policy/allow-metrics-traffic.yaml new file mode 100644 index 00000000..14443bd6 --- /dev/null +++ b/pubsub/config/network-policy/allow-metrics-traffic.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This NetworkPolicy allows ingress traffic +# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those +# namespaces are able to gather data from the metrics endpoint. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: allow-metrics-traffic + namespace: system +spec: + podSelector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub + policyTypes: + - Ingress + ingress: + # This allows ingress traffic from any namespace with the label metrics: enabled + - from: + - namespaceSelector: + matchLabels: + metrics: enabled # Only from namespaces with this label + ports: + - port: 8443 + protocol: TCP diff --git a/pubsub/config/network-policy/kustomization.yaml b/pubsub/config/network-policy/kustomization.yaml new file mode 100644 index 00000000..d3c1807d --- /dev/null +++ b/pubsub/config/network-policy/kustomization.yaml @@ -0,0 +1,6 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +resources: +- allow-metrics-traffic.yaml diff --git a/pubsub/config/prometheus/kustomization.yaml b/pubsub/config/prometheus/kustomization.yaml new file mode 100644 index 00000000..94fd0a04 --- /dev/null +++ b/pubsub/config/prometheus/kustomization.yaml @@ -0,0 +1,15 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +resources: +- monitor.yaml + +# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus +# to securely reference certificates created and managed by cert-manager. +# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml +# to mount the "metrics-server-cert" secret in the Manager Deployment. +#patches: +# - path: monitor_tls_patch.yaml +# target: +# kind: ServiceMonitor diff --git a/pubsub/config/prometheus/monitor.yaml b/pubsub/config/prometheus/monitor.yaml new file mode 100644 index 00000000..3fe7de37 --- /dev/null +++ b/pubsub/config/prometheus/monitor.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Prometheus Monitor Service (Metrics) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-monitor + namespace: system +spec: + endpoints: + - path: /metrics + port: https # Ensure this is the name of the port that exposes HTTPS metrics + scheme: https + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + tlsConfig: + # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables + # certificate verification, exposing the system to potential man-in-the-middle attacks. + # For production environments, it is recommended to use cert-manager for automatic TLS certificate management. + # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml, + # which securely references the certificate from the 'metrics-server-cert' secret. + insecureSkipVerify: true + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: pubsub diff --git a/pubsub/config/prometheus/monitor_tls_patch.yaml b/pubsub/config/prometheus/monitor_tls_patch.yaml new file mode 100644 index 00000000..5bc0d408 --- /dev/null +++ b/pubsub/config/prometheus/monitor_tls_patch.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# Patch for Prometheus ServiceMonitor to enable secure TLS configuration +# using certificates managed by cert-manager +- op: replace + path: /spec/endpoints/0/tlsConfig + value: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc + insecureSkipVerify: false + ca: + secret: + name: metrics-server-cert + key: ca.crt + cert: + secret: + name: metrics-server-cert + key: tls.crt + keySecret: + name: metrics-server-cert + key: tls.key diff --git a/pubsub/config/rbac/eventstore_admin_role.yaml b/pubsub/config/rbac/eventstore_admin_role.yaml new file mode 100644 index 00000000..974dae87 --- /dev/null +++ b/pubsub/config/rbac/eventstore_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over pubsub.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: eventstore-admin-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores + verbs: + - '*' +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores/status + verbs: + - get diff --git a/pubsub/config/rbac/eventstore_editor_role.yaml b/pubsub/config/rbac/eventstore_editor_role.yaml new file mode 100644 index 00000000..3c222de3 --- /dev/null +++ b/pubsub/config/rbac/eventstore_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the pubsub.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: eventstore-editor-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores/status + verbs: + - get diff --git a/pubsub/config/rbac/eventstore_viewer_role.yaml b/pubsub/config/rbac/eventstore_viewer_role.yaml new file mode 100644 index 00000000..e6a19527 --- /dev/null +++ b/pubsub/config/rbac/eventstore_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to pubsub.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: eventstore-viewer-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores + verbs: + - get + - list + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores/status + verbs: + - get diff --git a/pubsub/config/rbac/kustomization.yaml b/pubsub/config/rbac/kustomization.yaml new file mode 100644 index 00000000..1e8a95b5 --- /dev/null +++ b/pubsub/config/rbac/kustomization.yaml @@ -0,0 +1,38 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +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 +# The following RBAC configurations are used to protect +# the metrics endpoint with authn/authz. These configurations +# ensure that only authorized users and service accounts +# can access the metrics endpoint. Comment the following +# permissions if you want to disable this protection. +# More info: https://book.kubebuilder.io/reference/metrics.html +- metrics_auth_role.yaml +- metrics_auth_role_binding.yaml +- metrics_reader_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the pubsub itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- subscriber_admin_role.yaml +- subscriber_editor_role.yaml +- subscriber_viewer_role.yaml +- publisher_admin_role.yaml +- publisher_editor_role.yaml +- publisher_viewer_role.yaml +- eventstore_admin_role.yaml +- eventstore_editor_role.yaml +- eventstore_viewer_role.yaml + diff --git a/pubsub/config/rbac/leader_election_role.yaml b/pubsub/config/rbac/leader_election_role.yaml new file mode 100644 index 00000000..e2c406f8 --- /dev/null +++ b/pubsub/config/rbac/leader_election_role.yaml @@ -0,0 +1,44 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: pubsub + 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/pubsub/config/rbac/leader_election_role_binding.yaml b/pubsub/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 00000000..85bee4ed --- /dev/null +++ b/pubsub/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: pubsub + 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/pubsub/config/rbac/metrics_auth_role.yaml b/pubsub/config/rbac/metrics_auth_role.yaml new file mode 100644 index 00000000..67bd0ffc --- /dev/null +++ b/pubsub/config/rbac/metrics_auth_role.yaml @@ -0,0 +1,21 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-auth-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/pubsub/config/rbac/metrics_auth_role_binding.yaml b/pubsub/config/rbac/metrics_auth_role_binding.yaml new file mode 100644 index 00000000..a2f150c9 --- /dev/null +++ b/pubsub/config/rbac/metrics_auth_role_binding.yaml @@ -0,0 +1,16 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-auth-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics-auth-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/pubsub/config/rbac/metrics_reader_role.yaml b/pubsub/config/rbac/metrics_reader_role.yaml new file mode 100644 index 00000000..d6c33933 --- /dev/null +++ b/pubsub/config/rbac/metrics_reader_role.yaml @@ -0,0 +1,13 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/pubsub/config/rbac/publisher_admin_role.yaml b/pubsub/config/rbac/publisher_admin_role.yaml new file mode 100644 index 00000000..c405113e --- /dev/null +++ b/pubsub/config/rbac/publisher_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over pubsub.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: publisher-admin-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - publishers + verbs: + - '*' +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - publishers/status + verbs: + - get diff --git a/pubsub/config/rbac/publisher_editor_role.yaml b/pubsub/config/rbac/publisher_editor_role.yaml new file mode 100644 index 00000000..26faf7a2 --- /dev/null +++ b/pubsub/config/rbac/publisher_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the pubsub.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: publisher-editor-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - publishers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - publishers/status + verbs: + - get diff --git a/pubsub/config/rbac/publisher_viewer_role.yaml b/pubsub/config/rbac/publisher_viewer_role.yaml new file mode 100644 index 00000000..d3525b1d --- /dev/null +++ b/pubsub/config/rbac/publisher_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to pubsub.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: publisher-viewer-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - publishers + verbs: + - get + - list + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - publishers/status + verbs: + - get diff --git a/pubsub/config/rbac/role.yaml b/pubsub/config/rbac/role.yaml new file mode 100644 index 00000000..cfdaeae8 --- /dev/null +++ b/pubsub/config/rbac/role.yaml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores + - publishers + - subscribers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores/finalizers + - publishers/finalizers + - subscribers/finalizers + verbs: + - update +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - eventstores/status + - publishers/status + - subscribers/status + verbs: + - get + - patch + - update diff --git a/pubsub/config/rbac/role_binding.yaml b/pubsub/config/rbac/role_binding.yaml new file mode 100644 index 00000000..b021e7ce --- /dev/null +++ b/pubsub/config/rbac/role_binding.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: pubsub + 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/pubsub/config/rbac/service_account.yaml b/pubsub/config/rbac/service_account.yaml new file mode 100644 index 00000000..0d70b30c --- /dev/null +++ b/pubsub/config/rbac/service_account.yaml @@ -0,0 +1,12 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/pubsub/config/rbac/subscriber_admin_role.yaml b/pubsub/config/rbac/subscriber_admin_role.yaml new file mode 100644 index 00000000..2954f121 --- /dev/null +++ b/pubsub/config/rbac/subscriber_admin_role.yaml @@ -0,0 +1,31 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over pubsub.cp.ei.telekom.de. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: subscriber-admin-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - subscribers + verbs: + - '*' +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - subscribers/status + verbs: + - get diff --git a/pubsub/config/rbac/subscriber_editor_role.yaml b/pubsub/config/rbac/subscriber_editor_role.yaml new file mode 100644 index 00000000..5e8952d1 --- /dev/null +++ b/pubsub/config/rbac/subscriber_editor_role.yaml @@ -0,0 +1,37 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the pubsub.cp.ei.telekom.de. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: subscriber-editor-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - subscribers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - subscribers/status + verbs: + - get diff --git a/pubsub/config/rbac/subscriber_viewer_role.yaml b/pubsub/config/rbac/subscriber_viewer_role.yaml new file mode 100644 index 00000000..67f713da --- /dev/null +++ b/pubsub/config/rbac/subscriber_viewer_role.yaml @@ -0,0 +1,33 @@ +# Copyright 2026 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +# This rule is not used by the project pubsub itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to pubsub.cp.ei.telekom.de resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: pubsub + app.kubernetes.io/managed-by: kustomize + name: subscriber-viewer-role +rules: +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - subscribers + verbs: + - get + - list + - watch +- apiGroups: + - pubsub.cp.ei.telekom.de + resources: + - subscribers/status + verbs: + - get diff --git a/pubsub/docs/pubsub-domain-architecture.md b/pubsub/docs/pubsub-domain-architecture.md new file mode 100644 index 00000000..1bd15cd3 --- /dev/null +++ b/pubsub/docs/pubsub-domain-architecture.md @@ -0,0 +1,181 @@ + + +# PubSub Domain -- Architecture Overview + +This document describes how the **PubSub domain** (`pubsub.cp.ei.telekom.de/v1`) interacts with its surrounding domains in the Control Plane. + +## Domain Interaction Diagram + +```mermaid +flowchart TB + %% ── Styling ────────────────────────────────────────────── + classDef pubsubCls fill:#ef5350,color:#fff,stroke:#c62828,stroke-width:2px + classDef eventCls fill:#4a90d9,color:#fff,stroke:#2c5f8a,stroke-width:2px + classDef backendCls fill:#78909c,color:#fff,stroke:#455a64,stroke-width:2px,stroke-dasharray: 5 5 + + %% ── Event Domain (upstream creator) ───────────────────── + subgraph event["Event Domain"] + direction TB + EventConfig["EventConfig"]:::eventCls + EventExposure["EventExposure"]:::eventCls + EventSubscription["EventSubscription"]:::eventCls + end + + %% ── PubSub Domain (center) ────────────────────────────── + subgraph pubsub["PubSub Domain"] + direction TB + EventStore["EventStore"]:::pubsubCls + Publisher["Publisher"]:::pubsubCls + Subscriber["Subscriber"]:::pubsubCls + + Publisher -. "reads / validates" .-> EventStore + Subscriber -. "reads" .-> Publisher + Subscriber -. "reads" .-> EventStore + end + + %% ── Configuration Backend (external system) ───────────── + subgraph backend["Configuration Backend (Horizon)"] + direction TB + SubscriptionAPI["Subscription API
subscriber.horizon.telekom.de/v1"]:::backendCls + end + + %% ── Event Domain creates PubSub resources ─────────────── + EventConfig -- "creates / owns" --> EventStore + EventExposure -- "creates / owns" --> Publisher + EventSubscription -- "creates / owns" --> Subscriber + + %% ── Subscriber calls external backend ─────────────────── + Subscriber -- "PUT subscription" --> SubscriptionAPI + Subscriber -- "DELETE subscription" --> SubscriptionAPI +``` + +### Legend + +| Arrow style | Meaning | +|---|---| +| **Solid line** (`--creates/owns-->`) | The source controller **creates and owns** this resource (full CRUD lifecycle) | +| **Dashed line** (`-.reads.->`) | The controller **reads** this resource during reconciliation (GET) | +| **Solid line to backend** (`--PUT/DELETE-->`) | The controller makes **HTTP REST calls** to an external service | +| **Dashed border** | External system (not a Kubernetes CRD) | + +## PubSub Domain Resources + +The PubSub domain manages **3 CRDs** under the API group `pubsub.cp.ei.telekom.de/v1`: + +| CRD | Purpose | Creates external resources? | +|---|---|---| +| **EventStore** | Stores connection details (URL, OAuth2 credentials) for the configuration backend | No -- pure configuration, sets Ready condition | +| **Publisher** | Registers an event publisher; validates its EventStore is ready | No (TODO: future REST API registration) | +| **Subscriber** | Registers an event subscription in the configuration backend via REST API | Yes -- calls Horizon Configuration Backend | + +### Internal Dependency Chain + +``` +Subscriber ──reads──▶ Publisher ──reads──▶ EventStore +``` + +The `Subscriber` controller resolves the full chain at reconciliation time: +1. Reads the referenced `Publisher` to obtain event type and publisher ID +2. Reads the `Publisher`'s referenced `EventStore` to obtain backend connection details +3. Uses the EventStore credentials to call the configuration backend REST API + +## Interaction Details + +### EventStore Controller + +The simplest controller. Reconciles `EventStore` resources by validating the configuration and setting status conditions. + +| Aspect | Detail | +|---|---| +| **Watches** | `EventStore` (own resource, `GenerationChangedPredicate`) | +| **Owns** | Nothing | +| **Cross-domain reads** | None | +| **External calls** | None | +| **Status fields** | `conditions` | + +The EventStore is a **leaf resource** with no cross-domain references. It is created and owned by the **Event domain's `EventConfig` controller**. + +### Publisher Controller + +Validates that the referenced EventStore exists and is ready, then sets status conditions. + +| Aspect | Detail | +|---|---| +| **Watches** | `Publisher` (own resource, `ResourceVersionChangedPredicate`) | +| **Owns** | Nothing | +| **Reads** | `pubsub.EventStore` (validates existence and readiness) | +| **External calls** | None (TODO: future config backend registration) | +| **Status fields** | `conditions` | + +The Publisher is created and owned by the **Event domain's `EventExposure` controller**. + +### Subscriber Controller + +The most complex controller. Resolves the Publisher and EventStore chain, generates a deterministic subscription ID, and calls the configuration backend REST API to register or deregister the subscription. + +| Aspect | Detail | +|---|---| +| **Watches** | `Subscriber` (own resource, `ResourceVersionChangedPredicate`) | +| **Owns** | Nothing | +| **Reads** | `pubsub.Publisher` (for event type, publisher ID), `pubsub.EventStore` (for backend connection) | +| **External calls** | `PUT /subscriber.horizon.telekom.de/v1/subscriptions/{id}` (create/update), `DELETE .../{id}` (delete) | +| **Status fields** | `conditions`, `subscriptionId` | + +The Subscriber is created and owned by the **Event domain's `EventSubscription` controller**. + +#### External REST API Details + +The Subscriber handler calls the **Horizon Configuration Backend** using OAuth2 client credentials: + +| Operation | HTTP Method | Path | Auth | +|---|---|---|---| +| Register subscription | `PUT` | `{EventStore.Spec.Url}/subscriber.horizon.telekom.de/v1/subscriptions/{subscriptionID}` | OAuth2 client credentials (token from `EventStore.Spec.TokenUrl`) | +| Deregister subscription | `DELETE` | same path | same | + +The subscription ID is generated deterministically via SHA-1 hash of `"{environment}--{eventType}--{subscriberId}"`. + +The payload is a Kubernetes-style resource envelope: +```json +{ + "apiVersion": "subscriber.horizon.telekom.de/v1", + "kind": "Subscription", + "metadata": { "name": "", "namespace": "default" }, + "spec": { + "environment": "", + "subscription": { + "subscriptionId": "...", + "subscriberId": "...", + "publisherId": "...", + "type": "", + "deliveryType": "Callback|ServerSentEvent", + "callback": "...", + "trigger": { ... }, + "publisherTrigger": { ... } + } + } +} +``` + +## Upstream Domains (Who Creates PubSub Resources) + +The PubSub domain does **not** create resources in other domains. All three PubSub CRDs are created exclusively by the **Event domain**: + +| PubSub Resource | Created by | Event Controller | +|---|---|---| +| `EventStore` | Event domain | `EventConfig` controller | +| `Publisher` | Event domain | `EventExposure` controller | +| `Subscriber` | Event domain | `EventSubscription` controller | + +## Registered Schemes + +The PubSub operator registers only **1 domain** scheme (plus the base client-go scheme): + +| Domain | API Group | Resources | +|---|---|---| +| **PubSub** | `pubsub.cp.ei.telekom.de` | EventStore, Publisher, Subscriber | + +The PubSub operator is intentionally self-contained -- it does not import or register API types from any other domain. Cross-domain orchestration is handled by the Event domain, which creates PubSub resources as owned children. diff --git a/pubsub/go.mod b/pubsub/go.mod new file mode 100644 index 00000000..4014de7b --- /dev/null +++ b/pubsub/go.mod @@ -0,0 +1,133 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +module github.com/telekom/controlplane/pubsub + +go 1.24.9 + +require ( + github.com/telekom/controlplane/common v0.0.0 + github.com/telekom/controlplane/common-server v0.0.0 + github.com/telekom/controlplane/file-manager v0.0.0 + github.com/telekom/controlplane/pubsub/api v0.0.0 +) + +require ( + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.33.0 + k8s.io/apiextensions-apiserver v0.34.2 + k8s.io/apimachinery v0.34.2 + k8s.io/client-go v0.34.2 + sigs.k8s.io/controller-runtime v0.22.4 +) + +replace ( + github.com/telekom/controlplane/common => ../common + github.com/telekom/controlplane/common-server => ../common-server + github.com/telekom/controlplane/file-manager => ../file-manager + github.com/telekom/controlplane/pubsub/api => ./api +) + +require ( + cel.dev/expr v0.24.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/grpc v1.72.1 // indirect + google.golang.org/protobuf v1.36.10 // 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/api v0.34.2 // indirect + k8s.io/apiserver v0.34.2 // indirect + k8s.io/component-base v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/pubsub/go.sum b/pubsub/go.sum new file mode 100644 index 00000000..61cf2698 --- /dev/null +++ b/pubsub/go.sum @@ -0,0 +1,310 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +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/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +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.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE= +k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/component-base v0.34.2 h1:HQRqK9x2sSAsd8+R4xxRirlTjowsg6fWCPwWYeSvogQ= +k8s.io/component-base v0.34.2/go.mod h1:9xw2FHJavUHBFpiGkZoKuYZ5pdtLKe97DEByaA+hHbM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pubsub/go.sum.license b/pubsub/go.sum.license new file mode 100644 index 00000000..be863cd5 --- /dev/null +++ b/pubsub/go.sum.license @@ -0,0 +1,3 @@ +Copyright 2026 Deutsche Telekom IT GmbH + +SPDX-License-Identifier: Apache-2.0 diff --git a/pubsub/internal/controller/eventstore_controller.go b/pubsub/internal/controller/eventstore_controller.go new file mode 100644 index 00000000..74329d31 --- /dev/null +++ b/pubsub/internal/controller/eventstore_controller.go @@ -0,0 +1,53 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/eventstore" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// EventStoreReconciler reconciles a EventStore object +type EventStoreReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*pubsubv1.EventStore] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores/finalizers,verbs=update + +func (r *EventStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &pubsubv1.EventStore{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *EventStoreReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("eventstore-controller") + r.Controller = cc.NewController(&eventstore.EventStoreHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&pubsubv1.EventStore{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} diff --git a/pubsub/internal/controller/eventstore_controller_test.go b/pubsub/internal/controller/eventstore_controller_test.go new file mode 100644 index 00000000..7e786ab3 --- /dev/null +++ b/pubsub/internal/controller/eventstore_controller_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + eventstorehandler "github.com/telekom/controlplane/pubsub/internal/handler/eventstore" +) + +var _ = Describe("EventStore Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + eventstore := &pubsubv1.EventStore{} + + BeforeEach(func() { + By("creating the custom resource for the Kind EventStore") + err := k8sClient.Get(ctx, typeNamespacedName, eventstore) + if err != nil && errors.IsNotFound(err) { + resource := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: pubsubv1.EventStoreSpec{ + Url: "https://eventstore.example.com", + TokenUrl: "https://auth.example.com/token", + ClientId: "test-client-id", + ClientSecret: "test-client-secret", + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &pubsubv1.EventStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance EventStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &EventStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&eventstorehandler.EventStoreHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/pubsub/internal/controller/publisher_controller.go b/pubsub/internal/controller/publisher_controller.go new file mode 100644 index 00000000..ee1fb616 --- /dev/null +++ b/pubsub/internal/controller/publisher_controller.go @@ -0,0 +1,54 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/publisher" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// PublisherReconciler reconciles a Publisher object +type PublisherReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*pubsubv1.Publisher] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=publishers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=publishers/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=publishers/finalizers,verbs=update +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores,verbs=get + +func (r *PublisherReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &pubsubv1.Publisher{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PublisherReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("publisher-controller") + r.Controller = cc.NewController(&publisher.PublisherHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&pubsubv1.Publisher{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} diff --git a/pubsub/internal/controller/publisher_controller_test.go b/pubsub/internal/controller/publisher_controller_test.go new file mode 100644 index 00000000..bc1bb56d --- /dev/null +++ b/pubsub/internal/controller/publisher_controller_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + ctypes "github.com/telekom/controlplane/common/pkg/types" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + publisherhandler "github.com/telekom/controlplane/pubsub/internal/handler/publisher" +) + +var _ = Describe("Publisher Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + publisher := &pubsubv1.Publisher{} + + BeforeEach(func() { + By("creating the custom resource for the Kind Publisher") + err := k8sClient.Get(ctx, typeNamespacedName, publisher) + if err != nil && errors.IsNotFound(err) { + resource := &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: pubsubv1.PublisherSpec{ + EventStore: ctypes.ObjectRef{Name: "test-store", Namespace: "default"}, + EventType: "de.telekom.test.v1", + PublisherId: "test-publisher-id", + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &pubsubv1.Publisher{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Publisher") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &PublisherReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&publisherhandler.PublisherHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/pubsub/internal/controller/schema.go b/pubsub/internal/controller/schema.go new file mode 100644 index 00000000..ab0639a0 --- /dev/null +++ b/pubsub/internal/controller/schema.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +func RegisterSchemesOrDie(scheme *runtime.Scheme) { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(pubsubv1.AddToScheme(scheme)) +} diff --git a/pubsub/internal/controller/schema_test.go b/pubsub/internal/controller/schema_test.go new file mode 100644 index 00000000..85bb9e12 --- /dev/null +++ b/pubsub/internal/controller/schema_test.go @@ -0,0 +1,35 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller_test + +import ( + "testing" + + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/controller" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestRegisterSchemesOrDie(t *testing.T) { + scheme := runtime.NewScheme() + + // Should not panic + controller.RegisterSchemesOrDie(scheme) + + // Verify pubsub types are registered + for _, obj := range []runtime.Object{ + &pubsubv1.EventStore{}, + &pubsubv1.Publisher{}, + &pubsubv1.Subscriber{}, + } { + gvks, _, err := scheme.ObjectKinds(obj) + if err != nil { + t.Fatalf("expected type %T to be registered, got error: %v", obj, err) + } + if len(gvks) == 0 { + t.Fatalf("expected type %T to have at least one GVK registered", obj) + } + } +} diff --git a/pubsub/internal/controller/subscriber_controller.go b/pubsub/internal/controller/subscriber_controller.go new file mode 100644 index 00000000..0c36284f --- /dev/null +++ b/pubsub/internal/controller/subscriber_controller.go @@ -0,0 +1,86 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/subscriber" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// SubscriberReconciler reconciles a Subscriber object +type SubscriberReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*pubsubv1.Subscriber] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=subscribers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=subscribers/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=subscribers/finalizers,verbs=update +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=publishers,verbs=get +// +kubebuilder:rbac:groups=pubsub.cp.ei.telekom.de,resources=eventstores,verbs=get + +func (r *SubscriberReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &pubsubv1.Subscriber{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SubscriberReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("subscriber-controller") + r.Controller = cc.NewController(&subscriber.SubscriberHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&pubsubv1.Subscriber{}, builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} + +func (r *SubscriberReconciler) MapPublisherToSubscriber(ctx context.Context, obj client.Object) []reconcile.Request { + publisher, ok := obj.(*pubsubv1.Publisher) + if !ok { + return nil + } + + list := &pubsubv1.SubscriberList{} + if err := r.Client.List(ctx, list, client.MatchingLabels{ + cconfig.EnvironmentLabelKey: publisher.Labels[cconfig.EnvironmentLabelKey], + cconfig.BuildLabelKey("eventtype"): publisher.Labels[cconfig.BuildLabelKey("eventtype")], + }); err != nil { + return nil + } + + requests := make([]reconcile.Request, len(list.Items)) + for i, subscriber := range list.Items { + if !subscriber.Spec.Publisher.Equals(publisher) { + continue + } + requests[i] = reconcile.Request{ + NamespacedName: client.ObjectKey{ + Name: subscriber.Name, + Namespace: subscriber.Namespace, + }, + } + } + + return requests +} diff --git a/pubsub/internal/controller/subscriber_controller_test.go b/pubsub/internal/controller/subscriber_controller_test.go new file mode 100644 index 00000000..fcf6c4cc --- /dev/null +++ b/pubsub/internal/controller/subscriber_controller_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cc "github.com/telekom/controlplane/common/pkg/controller" + ctypes "github.com/telekom/controlplane/common/pkg/types" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + subscriberhandler "github.com/telekom/controlplane/pubsub/internal/handler/subscriber" +) + +var _ = Describe("Subscriber Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + subscriber := &pubsubv1.Subscriber{} + + BeforeEach(func() { + By("creating the custom resource for the Kind Subscriber") + err := k8sClient.Get(ctx, typeNamespacedName, subscriber) + if err != nil && errors.IsNotFound(err) { + resource := &pubsubv1.Subscriber{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: pubsubv1.SubscriberSpec{ + Publisher: ctypes.ObjectRef{Name: "test-publisher", Namespace: "default"}, + SubscriberId: "test-subscriber-id", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + Callback: "https://callback.example.com", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &pubsubv1.Subscriber{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Subscriber") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + recorder := record.NewFakeRecorder(10) + controllerReconciler := &SubscriberReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: recorder, + } + controllerReconciler.Controller = cc.NewController(&subscriberhandler.SubscriberHandler{}, k8sClient, recorder) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/pubsub/internal/controller/suite_test.go b/pubsub/internal/controller/suite_test.go new file mode 100644 index 00000000..4521a079 --- /dev/null +++ b/pubsub/internal/controller/suite_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "os" + "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" + + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + // +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 ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = pubsubv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + 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") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/pubsub/internal/handler/eventstore/handler.go b/pubsub/internal/handler/eventstore/handler.go new file mode 100644 index 00000000..4f7851d5 --- /dev/null +++ b/pubsub/internal/handler/eventstore/handler.go @@ -0,0 +1,30 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventstore + +import ( + "context" + + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/handler" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" +) + +var _ handler.Handler[*pubsubv1.EventStore] = &EventStoreHandler{} + +type EventStoreHandler struct{} + +func (h *EventStoreHandler) CreateOrUpdate(ctx context.Context, obj *pubsubv1.EventStore) error { + + obj.SetCondition(condition.NewReadyCondition("EventStoreReady", "EventStore configuration is valid")) + obj.SetCondition(condition.NewDoneProcessingCondition("EventStore is ready")) + + return nil +} + +func (h *EventStoreHandler) Delete(ctx context.Context, obj *pubsubv1.EventStore) error { + // EventStore is a configuration resource - no external cleanup needed. + return nil +} diff --git a/pubsub/internal/handler/eventstore/handler_test.go b/pubsub/internal/handler/eventstore/handler_test.go new file mode 100644 index 00000000..af10b8ce --- /dev/null +++ b/pubsub/internal/handler/eventstore/handler_test.go @@ -0,0 +1,73 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventstore_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/telekom/controlplane/common/pkg/condition" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/eventstore" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("EventStoreHandler", func() { + var ( + ctx context.Context + handler *eventstore.EventStoreHandler + ) + + BeforeEach(func() { + ctx = context.Background() + handler = &eventstore.EventStoreHandler{} + }) + + Describe("CreateOrUpdate", func() { + It("should set Ready and DoneProcessing conditions", func() { + obj := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + Spec: pubsubv1.EventStoreSpec{ + Url: "https://config-server.example.com", + TokenUrl: "https://auth.example.com/token", + ClientId: "client-id", + ClientSecret: "client-secret", + }, + } + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(meta.IsStatusConditionTrue(obj.GetConditions(), condition.ConditionTypeReady)).To(BeTrue()) + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond.Reason).To(Equal("EventStoreReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + }) + + Describe("Delete", func() { + It("should return nil without errors", func() { + obj := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + } + + err := handler.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/pubsub/internal/handler/eventstore/suite_test.go b/pubsub/internal/handler/eventstore/suite_test.go new file mode 100644 index 00000000..2f8ff9cc --- /dev/null +++ b/pubsub/internal/handler/eventstore/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventstore_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEventStoreHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventStore Handler Suite") +} diff --git a/pubsub/internal/handler/publisher/handler.go b/pubsub/internal/handler/publisher/handler.go new file mode 100644 index 00000000..aaac3f5f --- /dev/null +++ b/pubsub/internal/handler/publisher/handler.go @@ -0,0 +1,50 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package publisher + +import ( + "context" + + "github.com/pkg/errors" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + "github.com/telekom/controlplane/common/pkg/handler" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +var _ handler.Handler[*pubsubv1.Publisher] = &PublisherHandler{} + +type PublisherHandler struct{} + +func (h *PublisherHandler) CreateOrUpdate(ctx context.Context, obj *pubsubv1.Publisher) error { + c := cclient.ClientFromContextOrDie(ctx) + + // Validate that the referenced EventStore exists and is ready + eventStore := &pubsubv1.EventStore{} + err := c.Get(ctx, obj.Spec.EventStore.K8s(), eventStore) + if err != nil { + if apierrors.IsNotFound(err) { + return ctrlerrors.BlockedErrorf("EventStore %q not found", obj.Spec.EventStore.String()) + } + return errors.Wrapf(err, "failed to get EventStore %q", obj.Spec.EventStore.String()) + } + if err := condition.EnsureReady(eventStore); err != nil { + return ctrlerrors.BlockedErrorf("EventStore %q is not ready", obj.Spec.EventStore.String()) + } + + // TODO: Call quasar/Config Server REST API to register publisher (if needed in the future) + + obj.SetCondition(condition.NewReadyCondition("PublisherReady", "Publisher is valid and EventStore is ready")) + obj.SetCondition(condition.NewDoneProcessingCondition("Publisher is ready")) + + return nil +} + +func (h *PublisherHandler) Delete(ctx context.Context, obj *pubsubv1.Publisher) error { + // TODO: Call quasar/Config Server REST API to deregister publisher (if needed in the future) + return nil +} diff --git a/pubsub/internal/handler/publisher/handler_test.go b/pubsub/internal/handler/publisher/handler_test.go new file mode 100644 index 00000000..39274b45 --- /dev/null +++ b/pubsub/internal/handler/publisher/handler_test.go @@ -0,0 +1,187 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package publisher_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/publisher" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +func newPublisher() *pubsubv1.Publisher { + return &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-publisher", + Namespace: "default", + }, + Spec: pubsubv1.PublisherSpec{ + EventStore: ctypes.ObjectRef{ + Name: "test-eventstore", + Namespace: "default", + }, + EventType: "de.telekom.test.event.v1", + PublisherId: "test-app", + }, + } +} + +func readyEventStore() *pubsubv1.EventStore { + es := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + Spec: pubsubv1.EventStoreSpec{ + Url: "https://config-server.example.com", + TokenUrl: "https://auth.example.com/token", + ClientId: "client-id", + ClientSecret: "client-secret", + }, + } + meta.SetStatusCondition(&es.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return es +} + +var _ = Describe("PublisherHandler", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + handler *publisher.PublisherHandler + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + handler = &publisher.PublisherHandler{} + }) + + Describe("CreateOrUpdate", func() { + It("should set Ready and DoneProcessing when EventStore exists and is ready", func() { + obj := newPublisher() + es := readyEventStore() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *es + }). + Return(nil) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(meta.IsStatusConditionTrue(obj.GetConditions(), condition.ConditionTypeReady)).To(BeTrue()) + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond.Reason).To(Equal("PublisherReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should return BlockedError when EventStore is not found", func() { + obj := newPublisher() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "eventstores"}, "test-eventstore")) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + var blockedErr ctrlerrors.BlockedError + Expect(err).To(BeAssignableToTypeOf(&ctrlerrors.CtrlError{})) + rootCause := unwrapAll(err) + Expect(rootCause.(ctrlerrors.BlockedError)).ToNot(BeNil()) + _ = blockedErr + }) + + It("should return BlockedError when EventStore is not ready", func() { + obj := newPublisher() + es := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + } + // EventStore without Ready condition + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *es + }). + Return(nil) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + }) + + It("should return error when Get fails with unexpected error", func() { + obj := newPublisher() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Return(fmt.Errorf("connection refused")) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("connection refused")) + }) + }) + + Describe("Delete", func() { + It("should return nil", func() { + obj := newPublisher() + + err := handler.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +// unwrapAll follows the pkg/errors Cause chain to the root error. +func unwrapAll(err error) error { + for { + cause, ok := err.(interface{ Cause() error }) + if !ok { + return err + } + err = cause.Cause() + } +} + +// isBlockedError checks if the error implements the BlockedError interface. +func isBlockedError(err error) bool { + be, ok := err.(ctrlerrors.BlockedError) + return ok && be.IsBlocked() +} diff --git a/pubsub/internal/handler/publisher/suite_test.go b/pubsub/internal/handler/publisher/suite_test.go new file mode 100644 index 00000000..0ea6cdd2 --- /dev/null +++ b/pubsub/internal/handler/publisher/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package publisher_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPublisherHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Publisher Handler Suite") +} diff --git a/pubsub/internal/handler/subscriber/handler.go b/pubsub/internal/handler/subscriber/handler.go new file mode 100644 index 00000000..da809aa2 --- /dev/null +++ b/pubsub/internal/handler/subscriber/handler.go @@ -0,0 +1,147 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package subscriber + +import ( + "bytes" + "context" + + "github.com/pkg/errors" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + cconfig "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + "github.com/telekom/controlplane/common/pkg/handler" + "github.com/telekom/controlplane/common/pkg/util/contextutil" + file_api "github.com/telekom/controlplane/file-manager/api" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/util" + "github.com/telekom/controlplane/pubsub/internal/service" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var _ handler.Handler[*pubsubv1.Subscriber] = &SubscriberHandler{} + +type SubscriberHandler struct{} + +func (h *SubscriberHandler) CreateOrUpdate(ctx context.Context, obj *pubsubv1.Subscriber) error { + logger := log.FromContext(ctx) + environment := contextutil.EnvFromContextOrDie(ctx) + + publisher, err := util.GetPublisher(ctx, obj.Spec.Publisher) + if err != nil { + return errors.Wrapf(err, "failed to resolve Publisher %q", obj.Spec.Publisher.String()) + } + + eventStore, err := util.GetEventStore(ctx, publisher.Spec.EventStore) + if err != nil { + return errors.Wrapf(err, "failed to resolve EventStore %q from Publisher %q", publisher.Spec.EventStore.String(), obj.Spec.Publisher.String()) + } + + if cconfig.FeatureFileManager.IsEnabled() { + buf := bytes.NewBuffer(nil) + res, err := getFileManager().DownloadFile(ctx, publisher.Spec.JsonSchema, buf) + if err != nil { + return errors.Wrapf(err, "failed to download JSON schema from Publisher %q", obj.Spec.Publisher.String()) + } + if res.ContentType != "application/json" { + return ctrlerrors.BlockedErrorf("Expected content type application/json for JSON schema, got %q", res.ContentType) + } + // Set the downloaded JSON schema in the payload so it's included in the subscription resource sent to the configuration backend. + publisher.Spec.JsonSchema = buf.String() + } + + subscriptionID := getOrGenerateSubscriptionID(obj, environment, publisher.Spec.EventType) + resource := BuildSubscriptionResource(obj, publisher, subscriptionID, environment) + + configSvc := getConfigService(eventStore) + + err = configSvc.PutSubscription(ctx, subscriptionID, resource) + if err != nil { + return errors.Wrap(err, "failed to register subscription in configuration backend") + } + + logger.Info("Subscription registered in configuration backend", + "subscriptionId", subscriptionID, + "publisher", publisher.Name, + "eventStore", eventStore.Name, + "subscriberId", obj.Spec.SubscriberId) + + obj.Status.SubscriptionId = subscriptionID + obj.SetCondition(condition.NewReadyCondition("SubscriberReady", + "Subscription registered in configuration backend")) + obj.SetCondition(condition.NewDoneProcessingCondition("Subscriber is ready")) + + return nil +} + +func (h *SubscriberHandler) Delete(ctx context.Context, obj *pubsubv1.Subscriber) error { + logger := log.FromContext(ctx) + environment := contextutil.EnvFromContextOrDie(ctx) + c := cclient.ClientFromContextOrDie(ctx) + + publisher := &pubsubv1.Publisher{} + err := c.Get(ctx, obj.Spec.Publisher.K8s(), publisher) + if err != nil { + if apierrors.IsNotFound(err) { + logger.Info("Publisher already deleted, skipping cleanup", + "publisher", obj.Spec.Publisher.String(), + "subscriberId", obj.Spec.SubscriberId) + return nil + } + return errors.Wrapf(err, "failed to resolve Publisher %q during delete", obj.Spec.Publisher.String()) + } + + eventStore := &pubsubv1.EventStore{} + err = c.Get(ctx, publisher.Spec.EventStore.K8s(), eventStore) + if err != nil { + if apierrors.IsNotFound(err) { + logger.Info("EventStore already deleted, skipping cleanup", + "eventStore", publisher.Spec.EventStore.String(), + "subscriberId", obj.Spec.SubscriberId) + return nil + } + return errors.Wrapf(err, "failed to resolve EventStore %q during delete", publisher.Spec.EventStore.String()) + } + + subscriptionID := getOrGenerateSubscriptionID(obj, environment, publisher.Spec.EventType) + resource := BuildSubscriptionResource(obj, publisher, subscriptionID, environment) + + configSvc := getConfigService(eventStore) + + err = configSvc.DeleteSubscription(ctx, subscriptionID, resource) + if err != nil { + return errors.Wrap(err, "failed to deregister subscription from configuration backend") + } + + logger.Info("Subscription deregistered from configuration backend", + "subscriptionId", subscriptionID, + "subscriberId", obj.Spec.SubscriberId) + + return nil +} + +var getFileManager = func() file_api.FileManager { + return file_api.GetFileManager() +} + +var getConfigService = func(eventStore *pubsubv1.EventStore) service.ConfigService { + return service.NewConfigService(service.ConfigServiceConfig{ + BaseURL: eventStore.Spec.Url, + TokenURL: eventStore.Spec.TokenUrl, + ClientID: eventStore.Spec.ClientId, + ClientSecret: eventStore.Spec.ClientSecret, + }) +} + +// getOrGenerateSubscriptionID returns the subscription ID from status if available, +// otherwise generates it deterministically from environment, eventType, and subscriberId. +func getOrGenerateSubscriptionID(obj *pubsubv1.Subscriber, environment, eventType string) string { + if obj.Status.SubscriptionId != "" { + return obj.Status.SubscriptionId + } + return GenerateSubscriptionID(environment, eventType, obj.Spec.SubscriberId) +} diff --git a/pubsub/internal/handler/subscriber/handler_test.go b/pubsub/internal/handler/subscriber/handler_test.go new file mode 100644 index 00000000..a567fb0c --- /dev/null +++ b/pubsub/internal/handler/subscriber/handler_test.go @@ -0,0 +1,426 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package subscriber + +import ( + "context" + "fmt" + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + ctypes "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/contextutil" + file_api "github.com/telekom/controlplane/file-manager/api" + filefake "github.com/telekom/controlplane/file-manager/api/fake" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/service" + "github.com/telekom/controlplane/pubsub/test/mocks" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const testEnvironment = "test-env" + +func newTestSubscriber() *pubsubv1.Subscriber { + return &pubsubv1.Subscriber{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-subscriber", + Namespace: "default", + }, + Spec: pubsubv1.SubscriberSpec{ + Publisher: ctypes.ObjectRef{ + Name: "test-publisher", + Namespace: "default", + }, + SubscriberId: "my-consumer-app", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + Callback: "https://my-app.example.com/events", + }, + }, + } +} + +func newTestPublisher() *pubsubv1.Publisher { + pub := &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-publisher", + Namespace: "default", + }, + Spec: pubsubv1.PublisherSpec{ + EventStore: ctypes.ObjectRef{ + Name: "test-eventstore", + Namespace: "default", + }, + EventType: "de.telekom.test.event.v1", + PublisherId: "test-app", + JsonSchema: "schema-file-id-123", + }, + } + meta.SetStatusCondition(&pub.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return pub +} + +func newTestEventStore() *pubsubv1.EventStore { + es := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + Spec: pubsubv1.EventStoreSpec{ + Url: "https://config-server.example.com", + TokenUrl: "https://auth.example.com/token", + ClientId: "client-id", + ClientSecret: "client-secret", + }, + } + meta.SetStatusCondition(&es.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + return es +} + +var _ = Describe("SubscriberHandler", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + configSvcMock *mocks.MockConfigService + fileManagerMock *filefake.MockFileManager + handler *SubscriberHandler + origGetCfgSvc func(*pubsubv1.EventStore) service.ConfigService + origGetFileMgr func() file_api.FileManager + ) + + BeforeEach(func() { + ctx = context.Background() + ctx = contextutil.WithEnv(ctx, testEnvironment) + + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + + configSvcMock = mocks.NewMockConfigService(GinkgoT()) + fileManagerMock = filefake.NewMockFileManager(GinkgoT()) + + handler = &SubscriberHandler{} + + // Override getConfigService to return our mock + origGetCfgSvc = getConfigService + getConfigService = func(_ *pubsubv1.EventStore) service.ConfigService { + return configSvcMock + } + + // Override getFileManager to return our mock + origGetFileMgr = getFileManager + getFileManager = func() file_api.FileManager { + return fileManagerMock + } + }) + + AfterEach(func() { + getConfigService = origGetCfgSvc + getFileManager = origGetFileMgr + }) + + Describe("CreateOrUpdate", func() { + It("should register subscription and set Ready condition on success", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + eventStore := newTestEventStore() + + By("Setting up mock expectations for GetPublisher") + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + By("Setting up mock expectations for GetEventStore") + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *eventStore + }). + Return(nil) + + By("Setting up mock expectation for DownloadFile") + fileManagerMock.EXPECT(). + DownloadFile(mock.Anything, publisher.Spec.JsonSchema, mock.Anything). + Run(func(_ context.Context, _ string, w io.Writer) { + _, _ = w.Write([]byte(`{"type":"object"}`)) + }). + Return(&file_api.FileDownloadResponse{ContentType: "application/json"}, nil) + + By("Setting up mock expectation for PutSubscription") + configSvcMock.EXPECT(). + PutSubscription(ctx, mock.AnythingOfType("string"), mock.AnythingOfType("service.SubscriptionResource")). + Return(nil) + + By("Calling CreateOrUpdate") + err := handler.CreateOrUpdate(ctx, obj) + + By("Verifying success") + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.SubscriptionId).ToNot(BeEmpty()) + Expect(meta.IsStatusConditionTrue(obj.GetConditions(), condition.ConditionTypeReady)).To(BeTrue()) + + readyCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeReady) + Expect(readyCond.Reason).To(Equal("SubscriberReady")) + + processingCond := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) + Expect(processingCond).ToNot(BeNil()) + Expect(processingCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(processingCond.Reason).To(Equal("Done")) + }) + + It("should use existing SubscriptionId from status", func() { + obj := newTestSubscriber() + obj.Status.SubscriptionId = "existing-subscription-id" + publisher := newTestPublisher() + eventStore := newTestEventStore() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *eventStore + }). + Return(nil) + + fileManagerMock.EXPECT(). + DownloadFile(mock.Anything, publisher.Spec.JsonSchema, mock.Anything). + Run(func(_ context.Context, _ string, w io.Writer) { + _, _ = w.Write([]byte(`{"type":"object"}`)) + }). + Return(&file_api.FileDownloadResponse{ContentType: "application/json"}, nil) + + configSvcMock.EXPECT(). + PutSubscription(ctx, "existing-subscription-id", mock.AnythingOfType("service.SubscriptionResource")). + Return(nil) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + Expect(obj.Status.SubscriptionId).To(Equal("existing-subscription-id")) + }) + + It("should return error when Publisher cannot be resolved", func() { + obj := newTestSubscriber() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "publishers"}, "test-publisher")) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to resolve Publisher")) + }) + + It("should return error when EventStore cannot be resolved", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "eventstores"}, "test-eventstore")) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to resolve EventStore")) + }) + + It("should return error when PutSubscription fails", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + eventStore := newTestEventStore() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *eventStore + }). + Return(nil) + + fileManagerMock.EXPECT(). + DownloadFile(mock.Anything, publisher.Spec.JsonSchema, mock.Anything). + Run(func(_ context.Context, _ string, w io.Writer) { + _, _ = w.Write([]byte(`{"type":"object"}`)) + }). + Return(&file_api.FileDownloadResponse{ContentType: "application/json"}, nil) + + configSvcMock.EXPECT(). + PutSubscription(ctx, mock.AnythingOfType("string"), mock.AnythingOfType("service.SubscriptionResource")). + Return(fmt.Errorf("connection timeout")) + + err := handler.CreateOrUpdate(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to register subscription")) + }) + }) + + Describe("Delete", func() { + It("should deregister subscription on success", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + eventStore := newTestEventStore() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *eventStore + }). + Return(nil) + + configSvcMock.EXPECT(). + DeleteSubscription(ctx, mock.AnythingOfType("string"), mock.AnythingOfType("service.SubscriptionResource")). + Return(nil) + + err := handler.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should skip cleanup when Publisher is already deleted", func() { + obj := newTestSubscriber() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "publishers"}, "test-publisher")) + + err := handler.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should skip cleanup when EventStore is already deleted", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "eventstores"}, "test-eventstore")) + + err := handler.Delete(ctx, obj) + + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return error when Publisher Get fails with unexpected error", func() { + obj := newTestSubscriber() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Return(fmt.Errorf("connection refused")) + + err := handler.Delete(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to resolve Publisher")) + }) + + It("should return error when EventStore Get fails with unexpected error", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Return(fmt.Errorf("connection refused")) + + err := handler.Delete(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to resolve EventStore")) + }) + + It("should return error when DeleteSubscription fails", func() { + obj := newTestSubscriber() + publisher := newTestPublisher() + eventStore := newTestEventStore() + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, mock.AnythingOfType("*v1.Publisher")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *publisher + }). + Return(nil) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, mock.AnythingOfType("*v1.EventStore")). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *eventStore + }). + Return(nil) + + configSvcMock.EXPECT(). + DeleteSubscription(ctx, mock.AnythingOfType("string"), mock.AnythingOfType("service.SubscriptionResource")). + Return(fmt.Errorf("service unavailable")) + + err := handler.Delete(ctx, obj) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to deregister subscription")) + }) + }) +}) diff --git a/pubsub/internal/handler/subscriber/payload.go b/pubsub/internal/handler/subscriber/payload.go new file mode 100644 index 00000000..751f216a --- /dev/null +++ b/pubsub/internal/handler/subscriber/payload.go @@ -0,0 +1,121 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package subscriber + +import ( + "encoding/json" + + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/service" +) + +// BuildSubscriptionResource maps a Subscriber spec and its resolved Publisher +// into a full SubscriptionResource suitable for the configuration backend REST API. +// The configuration backend expects a Kubernetes-style resource with apiVersion, kind, +// metadata, and spec fields. +func BuildSubscriptionResource(subscriber *pubsubv1.Subscriber, publisher *pubsubv1.Publisher, subscriptionID, environment string) service.SubscriptionResource { + spec := subscriber.Spec + delivery := spec.Delivery + + payload := service.SubscriptionPayload{ + SubscriptionId: subscriptionID, + SubscriberId: spec.SubscriberId, + PublisherId: publisher.Spec.PublisherId, + Type: publisher.Spec.EventType, + DeliveryType: convertDeliveryType(delivery.Type), + PayloadType: convertPayloadType(delivery.Payload), + Callback: delivery.Callback, + AdditionalPublisherIds: publisher.Spec.AdditionalPublisherIds, + AppliedScopes: spec.AppliedScopes, + EventRetentionTime: delivery.EventRetentionTime, + RetryableStatusCodes: delivery.RetryableStatusCodes, + RedeliveriesPerSecond: delivery.RedeliveriesPerSecond, + JsonSchema: publisher.Spec.JsonSchema, + } + + if delivery.CircuitBreakerOptOut { + payload.CircuitBreakerOptOut = &delivery.CircuitBreakerOptOut + } + if delivery.EnforceGetHttpRequestMethodForHealthCheck { + payload.EnforceGetHttpRequestMethodForHealthCheck = &delivery.EnforceGetHttpRequestMethodForHealthCheck + } + + payload.Trigger = convertTrigger(spec.Trigger) + payload.PublisherTrigger = convertTrigger(spec.PublisherTrigger) + + return service.SubscriptionResource{ + ApiVersion: service.SubscriptionAPIVersion, + Kind: service.SubscriptionKind, + Metadata: service.SubscriptionMetadata{ + Name: subscriptionID, + Namespace: service.DefaultDataplaneNamespace, + }, + Spec: service.SubscriptionSpec{ + Environment: environment, + Subscription: payload, + }, + } +} + +// convertTrigger maps a K8s SubscriptionTrigger to a client SubscriptionTriggerPayload. +// Returns nil if the input is nil. +func convertTrigger(trigger *pubsubv1.Trigger) *service.SubscriptionTriggerPayload { + if trigger == nil { + return nil + } + + result := &service.SubscriptionTriggerPayload{} + + if trigger.ResponseFilter != nil { + result.ResponseFilterMode = convertResponseFilterMode(trigger.ResponseFilter.Mode) + result.ResponseFilter = trigger.ResponseFilter.Paths + } + + if trigger.SelectionFilter != nil { + result.SelectionFilter = trigger.SelectionFilter.Attributes + + if trigger.SelectionFilter.Expression != nil { + var advancedFilter map[string]any + if err := json.Unmarshal(trigger.SelectionFilter.Expression.Raw, &advancedFilter); err == nil { + result.AdvancedSelectionFilter = advancedFilter + } + } + } + + return result +} + +func convertDeliveryType(deliveryType pubsubv1.DeliveryType) string { + switch deliveryType { + case pubsubv1.DeliveryTypeCallback: + return "callback" + case pubsubv1.DeliveryTypeServerSentEvent: + return "server_sent_event" + default: + panic("unknown delivery type") + } +} + +func convertPayloadType(payloadType pubsubv1.PayloadType) string { + switch payloadType { + case pubsubv1.PayloadTypeData: + return "data" + case pubsubv1.PayloadTypeDataRef: + return "data_ref" + default: + panic("unknown payload type") + } +} + +func convertResponseFilterMode(mode pubsubv1.ResponseFilterMode) string { + switch mode { + case pubsubv1.ResponseFilterModeInclude: + return "INCLUDE" + case pubsubv1.ResponseFilterModeExclude: + return "EXCLUDE" + default: + panic("unknown response filter mode") + } +} diff --git a/pubsub/internal/handler/subscriber/payload_test.go b/pubsub/internal/handler/subscriber/payload_test.go new file mode 100644 index 00000000..91c44805 --- /dev/null +++ b/pubsub/internal/handler/subscriber/payload_test.go @@ -0,0 +1,274 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package subscriber + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/service" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestBuildSubscriptionResource_Envelope(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + }, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "sub-id-123", "playground") + + assert.Equal(t, "subscriber.horizon.telekom.de/v1", resource.ApiVersion) + assert.Equal(t, "Subscription", resource.Kind) + assert.Equal(t, "sub-id-123", resource.Metadata.Name) + assert.Equal(t, service.DefaultDataplaneNamespace, resource.Metadata.Namespace) + assert.Equal(t, "playground", resource.Spec.Environment) +} + +func TestBuildSubscriptionResource_BasicFields(t *testing.T) { + redeliveriesPerSec := 5 + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "my-consumer-app", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + Callback: "https://my-app.example.com/events", + EventRetentionTime: "P7D", + RetryableStatusCodes: []int{502, 503}, + RedeliveriesPerSecond: &redeliveriesPerSec, + }, + AppliedScopes: []string{"scope-a", "scope-b"}, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{ + EventType: "de.telekom.order.created.v1", + PublisherId: "order-service", + AdditionalPublisherIds: []string{"order-service-v2"}, + }, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "sub-id-123", "production") + payload := resource.Spec.Subscription + + assert.Equal(t, "sub-id-123", payload.SubscriptionId) + assert.Equal(t, "my-consumer-app", payload.SubscriberId) + assert.Equal(t, "order-service", payload.PublisherId) + assert.Equal(t, "de.telekom.order.created.v1", payload.Type) + assert.Equal(t, "callback", payload.DeliveryType) + assert.Equal(t, "data", payload.PayloadType) + assert.Equal(t, "https://my-app.example.com/events", payload.Callback) + assert.Equal(t, "P7D", payload.EventRetentionTime) + assert.Equal(t, []int{502, 503}, payload.RetryableStatusCodes) + require.NotNil(t, payload.RedeliveriesPerSecond) + assert.Equal(t, 5, *payload.RedeliveriesPerSecond) + assert.Equal(t, []string{"order-service-v2"}, payload.AdditionalPublisherIds) + assert.Equal(t, []string{"scope-a", "scope-b"}, payload.AppliedScopes) +} + +func TestBuildSubscriptionResource_CircuitBreakerOptOut(t *testing.T) { + t.Run("true sets pointer", func(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + CircuitBreakerOptOut: true, + }, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "id", "env") + payload := resource.Spec.Subscription + require.NotNil(t, payload.CircuitBreakerOptOut) + assert.True(t, *payload.CircuitBreakerOptOut) + }) + + t.Run("false leaves nil (NON_NULL behavior)", func(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + CircuitBreakerOptOut: false, + }, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "id", "env") + assert.Nil(t, resource.Spec.Subscription.CircuitBreakerOptOut) + }) +} + +func TestBuildSubscriptionResource_EnforceGetForHealthCheck(t *testing.T) { + t.Run("true sets pointer", func(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + EnforceGetHttpRequestMethodForHealthCheck: true, + }, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "id", "env") + payload := resource.Spec.Subscription + require.NotNil(t, payload.EnforceGetHttpRequestMethodForHealthCheck) + assert.True(t, *payload.EnforceGetHttpRequestMethodForHealthCheck) + }) + + t.Run("false leaves nil", func(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + }, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "id", "env") + assert.Nil(t, resource.Spec.Subscription.EnforceGetHttpRequestMethodForHealthCheck) + }) +} + +func TestBuildSubscriptionResource_NilTriggers(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeServerSentEvent, + Payload: pubsubv1.PayloadTypeDataRef, + }, + Trigger: nil, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "id", "env") + payload := resource.Spec.Subscription + assert.Nil(t, payload.Trigger) + assert.Nil(t, payload.PublisherTrigger) +} + +func TestBuildSubscriptionResource_WithTrigger(t *testing.T) { + subscriber := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{ + SubscriberId: "sub", + Delivery: pubsubv1.SubscriptionDelivery{ + Type: pubsubv1.DeliveryTypeCallback, + Payload: pubsubv1.PayloadTypeData, + }, + Trigger: &pubsubv1.Trigger{ + ResponseFilter: &pubsubv1.ResponseFilter{ + Mode: pubsubv1.ResponseFilterModeInclude, + Paths: []string{"$.data.orderId", "$.data.status"}, + }, + SelectionFilter: &pubsubv1.SelectionFilter{ + Attributes: map[string]string{"source": "order-service"}, + Expression: &apiextensionsv1.JSON{ + Raw: []byte(`{"op":"eq","field":"type","value":"created"}`), + }, + }, + }, + }, + } + publisher := &pubsubv1.Publisher{ + Spec: pubsubv1.PublisherSpec{EventType: "type", PublisherId: "pub"}, + } + + resource := BuildSubscriptionResource(subscriber, publisher, "id", "env") + payload := resource.Spec.Subscription + + require.NotNil(t, payload.Trigger) + assert.Equal(t, "INCLUDE", payload.Trigger.ResponseFilterMode) + assert.Equal(t, []string{"$.data.orderId", "$.data.status"}, payload.Trigger.ResponseFilter) + assert.Equal(t, map[string]string{"source": "order-service"}, payload.Trigger.SelectionFilter) + require.NotNil(t, payload.Trigger.AdvancedSelectionFilter) + assert.Equal(t, "eq", payload.Trigger.AdvancedSelectionFilter["op"]) + assert.Equal(t, "type", payload.Trigger.AdvancedSelectionFilter["field"]) + assert.Equal(t, "created", payload.Trigger.AdvancedSelectionFilter["value"]) +} + +func TestConvertTrigger_Nil(t *testing.T) { + result := convertTrigger(nil) + assert.Nil(t, result) +} + +func TestConvertTrigger_EmptyTrigger(t *testing.T) { + trigger := &pubsubv1.Trigger{} + result := convertTrigger(trigger) + require.NotNil(t, result) + assert.Empty(t, result.ResponseFilterMode) + assert.Nil(t, result.ResponseFilter) + assert.Nil(t, result.SelectionFilter) + assert.Nil(t, result.AdvancedSelectionFilter) +} + +func TestConvertTrigger_InvalidExpressionJSON(t *testing.T) { + trigger := &pubsubv1.Trigger{ + SelectionFilter: &pubsubv1.SelectionFilter{ + Expression: &apiextensionsv1.JSON{ + Raw: []byte(`invalid json`), + }, + }, + } + result := convertTrigger(trigger) + require.NotNil(t, result) + // Invalid JSON should result in nil AdvancedSelectionFilter (silently ignored) + assert.Nil(t, result.AdvancedSelectionFilter) +} + +func TestGetOrGenerateSubscriptionID_PrefersStatus(t *testing.T) { + obj := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{SubscriberId: "sub"}, + Status: pubsubv1.SubscriberStatus{ + SubscriptionId: "existing-id-from-status", + }, + } + result := getOrGenerateSubscriptionID(obj, "env", "type") + assert.Equal(t, "existing-id-from-status", result) +} + +func TestGetOrGenerateSubscriptionID_GeneratesWhenEmpty(t *testing.T) { + obj := &pubsubv1.Subscriber{ + Spec: pubsubv1.SubscriberSpec{SubscriberId: "sub"}, + Status: pubsubv1.SubscriberStatus{}, + } + result := getOrGenerateSubscriptionID(obj, "env", "type") + expected := GenerateSubscriptionID("env", "type", "sub") + assert.Equal(t, expected, result) + assert.Len(t, result, 40) // SHA-1 hex length +} diff --git a/pubsub/internal/handler/subscriber/subscription_id.go b/pubsub/internal/handler/subscriber/subscription_id.go new file mode 100644 index 00000000..819ed6bb --- /dev/null +++ b/pubsub/internal/handler/subscriber/subscription_id.go @@ -0,0 +1,19 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package subscriber + +import ( + "crypto/sha1" //#nosec G505 -- SHA-1 used for deterministic ID generation, not security + "encoding/hex" + "strings" +) + +// GenerateSubscriptionID generates a deterministic subscription ID by computing +// the SHA-1 hash of "environment--eventType--subscriberId". +func GenerateSubscriptionID(environment, eventType, subscriberID string) string { + data := strings.Join([]string{environment, eventType, subscriberID}, "--") + hash := sha1.Sum([]byte(data)) //#nosec G401 -- SHA-1 used for deterministic ID generation, not security + return hex.EncodeToString(hash[:]) +} diff --git a/pubsub/internal/handler/subscriber/suite_test.go b/pubsub/internal/handler/subscriber/suite_test.go new file mode 100644 index 00000000..b42094dd --- /dev/null +++ b/pubsub/internal/handler/subscriber/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package subscriber + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSubscriberHandlerSuite(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Subscriber Handler Suite") +} diff --git a/pubsub/internal/handler/util/getters.go b/pubsub/internal/handler/util/getters.go new file mode 100644 index 00000000..9dcfb552 --- /dev/null +++ b/pubsub/internal/handler/util/getters.go @@ -0,0 +1,53 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + + "github.com/pkg/errors" + cclient "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +func GetPublisher(ctx context.Context, objRef ctypes.ObjectRef) (*pubsubv1.Publisher, error) { + c := cclient.ClientFromContextOrDie(ctx) + + publisher := &pubsubv1.Publisher{} + err := c.Get(ctx, objRef.K8s(), publisher) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("Publisher %q not found", objRef.String()) + } + return nil, errors.Wrapf(err, "failed to get Publisher %q", objRef.String()) + } + if err := condition.EnsureReady(publisher); err != nil { + return nil, ctrlerrors.BlockedErrorf("Publisher %q is not ready", objRef.String()) + } + + return publisher, nil +} + +func GetEventStore(ctx context.Context, objRef ctypes.ObjectRef) (*pubsubv1.EventStore, error) { + c := cclient.ClientFromContextOrDie(ctx) + + eventStore := &pubsubv1.EventStore{} + err := c.Get(ctx, objRef.K8s(), eventStore) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ctrlerrors.BlockedErrorf("EventStore %q not found", objRef.String()) + } + return nil, errors.Wrapf(err, "failed to get EventStore %q", objRef.String()) + } + if err := condition.EnsureReady(eventStore); err != nil { + return nil, ctrlerrors.BlockedErrorf("EventStore %q is not ready", objRef.String()) + } + + return eventStore, nil +} diff --git a/pubsub/internal/handler/util/getters_test.go b/pubsub/internal/handler/util/getters_test.go new file mode 100644 index 00000000..ccc8eaff --- /dev/null +++ b/pubsub/internal/handler/util/getters_test.go @@ -0,0 +1,230 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" + ctypes "github.com/telekom/controlplane/common/pkg/types" + pubsubv1 "github.com/telekom/controlplane/pubsub/api/v1" + "github.com/telekom/controlplane/pubsub/internal/handler/util" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// unwrapAll follows the pkg/errors Cause chain to the root error. +func unwrapAll(err error) error { + for { + cause, ok := err.(interface{ Cause() error }) + if !ok { + return err + } + err = cause.Cause() + } +} + +// isBlockedError checks if the error implements the BlockedError interface. +func isBlockedError(err error) bool { + be, ok := err.(ctrlerrors.BlockedError) + return ok && be.IsBlocked() +} + +var _ = Describe("GetPublisher", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + objRef ctypes.ObjectRef + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + objRef = ctypes.ObjectRef{Name: "test-publisher", Namespace: "default"} + }) + + It("should return publisher when found and ready", func() { + expected := &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-publisher", + Namespace: "default", + }, + } + meta.SetStatusCondition(&expected.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, &pubsubv1.Publisher{}). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *expected + }). + Return(nil) + + result, err := util.GetPublisher(ctx, objRef) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("test-publisher")) + }) + + It("should return BlockedError when publisher is not found", func() { + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, &pubsubv1.Publisher{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "publishers"}, "test-publisher")) + + result, err := util.GetPublisher(ctx, objRef) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return BlockedError when publisher is not ready", func() { + notReady := &pubsubv1.Publisher{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-publisher", + Namespace: "default", + }, + } + // No Ready condition set + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, &pubsubv1.Publisher{}). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.Publisher) = *notReady + }). + Return(nil) + + result, err := util.GetPublisher(ctx, objRef) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return wrapped error on unexpected Get failure", func() { + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-publisher", Namespace: "default"}, &pubsubv1.Publisher{}). + Return(fmt.Errorf("connection refused")) + + result, err := util.GetPublisher(ctx, objRef) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("connection refused")) + Expect(err.Error()).To(ContainSubstring("failed to get Publisher")) + }) +}) + +var _ = Describe("GetEventStore", func() { + var ( + ctx context.Context + fakeClient *fakeclient.MockJanitorClient + objRef ctypes.ObjectRef + ) + + BeforeEach(func() { + ctx = context.Background() + fakeClient = fakeclient.NewMockJanitorClient(GinkgoT()) + ctx = cclient.WithClient(ctx, fakeClient) + objRef = ctypes.ObjectRef{Name: "test-eventstore", Namespace: "default"} + }) + + It("should return eventstore when found and ready", func() { + expected := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + } + meta.SetStatusCondition(&expected.Status.Conditions, metav1.Condition{ + Type: condition.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Ready", + }) + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *expected + }). + Return(nil) + + result, err := util.GetEventStore(ctx, objRef) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("test-eventstore")) + }) + + It("should return BlockedError when eventstore is not found", func() { + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Return(apierrors.NewNotFound(schema.GroupResource{Group: "pubsub.cp.ei.telekom.de", Resource: "eventstores"}, "test-eventstore")) + + result, err := util.GetEventStore(ctx, objRef) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return BlockedError when eventstore is not ready", func() { + notReady := &pubsubv1.EventStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-eventstore", + Namespace: "default", + }, + } + + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Run(func(_ context.Context, _ types.NamespacedName, out client.Object, _ ...client.GetOption) { + *out.(*pubsubv1.EventStore) = *notReady + }). + Return(nil) + + result, err := util.GetEventStore(ctx, objRef) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + rootCause := unwrapAll(err) + Expect(rootCause).To(Satisfy(isBlockedError)) + Expect(err.Error()).To(ContainSubstring("not ready")) + }) + + It("should return wrapped error on unexpected Get failure", func() { + fakeClient.EXPECT(). + Get(ctx, types.NamespacedName{Name: "test-eventstore", Namespace: "default"}, &pubsubv1.EventStore{}). + Return(fmt.Errorf("connection refused")) + + result, err := util.GetEventStore(ctx, objRef) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("connection refused")) + Expect(err.Error()).To(ContainSubstring("failed to get EventStore")) + }) +}) diff --git a/pubsub/internal/handler/util/suite_test.go b/pubsub/internal/handler/util/suite_test.go new file mode 100644 index 00000000..9fd5b289 --- /dev/null +++ b/pubsub/internal/handler/util/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGetters(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Util Getters Suite") +} diff --git a/pubsub/internal/service/httpclient.go b/pubsub/internal/service/httpclient.go new file mode 100644 index 00000000..402abe7d --- /dev/null +++ b/pubsub/internal/service/httpclient.go @@ -0,0 +1,32 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +var NewAuthorizedHttpClient = func(ctx context.Context, tokenUrl, clientId, clientSecret string) *http.Client { + baseClient := &http.Client{ + Transport: &http.Transport{ + MaxIdleConnsPerHost: 100, + }, + Timeout: 5 * time.Second, + } + + tokenCfg := clientcredentials.Config{ + ClientID: clientId, + ClientSecret: clientSecret, + TokenURL: tokenUrl, + } + + ctx = context.WithValue(ctx, oauth2.HTTPClient, baseClient) + return tokenCfg.Client(ctx) +} diff --git a/pubsub/internal/service/service.go b/pubsub/internal/service/service.go new file mode 100644 index 00000000..104615db --- /dev/null +++ b/pubsub/internal/service/service.go @@ -0,0 +1,116 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/pkg/errors" + + "github.com/telekom/controlplane/common-server/pkg/client" +) + +const ( + pathGVR = "/subscriber.horizon.telekom.de/v1/subscriptions" +) + +type ConfigService interface { + PutSubscription(ctx context.Context, subscriptionID string, resource SubscriptionResource) error + DeleteSubscription(ctx context.Context, subscriptionID string, resource SubscriptionResource) error +} + +type ConfigServiceConfig struct { + BaseURL string + TokenURL string + ClientID string + ClientSecret string +} + +var _ ConfigService = &configService{} + +type configService struct { + BasePath string + httpClient *http.Client +} + +func NewConfigService(config ConfigServiceConfig) ConfigService { + httpClient := NewAuthorizedHttpClient(context.Background(), config.TokenURL, config.ClientID, config.ClientSecret) + + return &configService{BasePath: config.BaseURL, httpClient: httpClient} +} + +func (q *configService) buildURL(subscriptionID string) (*url.URL, error) { + if subscriptionID == "" { + return nil, errors.New("subscriptionID is required to build URL") + } + rawUrl := q.BasePath + pathGVR + "/" + subscriptionID + return url.Parse(rawUrl) +} + +func (q *configService) DeleteSubscription(ctx context.Context, subscriptionID string, resource SubscriptionResource) error { + url, err := q.buildURL(subscriptionID) + if err != nil { + return errors.Wrap(err, "failed to build URL for DeleteSubscription") + } + + body, err := json.Marshal(resource) + if err != nil { + return errors.Wrap(err, "failed to serialize resource for PutSubscription") + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), bytes.NewReader(body)) + if err != nil { + return errors.Wrap(err, "failed to create HTTP request for DeleteSubscription") + } + resp, err := q.httpClient.Do(httpReq) + if err != nil { + return errors.Wrap(err, "HTTP request failed for DeleteSubscription") + } + defer resp.Body.Close() + + return checkResponse(resp, "DeleteSubscription", http.StatusOK, http.StatusNoContent) +} + +func (q *configService) PutSubscription(ctx context.Context, subscriptionID string, resource SubscriptionResource) error { + url, err := q.buildURL(subscriptionID) + if err != nil { + return errors.Wrap(err, "failed to build URL for PutSubscription") + } + + body, err := json.Marshal(resource) + if err != nil { + return errors.Wrap(err, "failed to serialize resource for PutSubscription") + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, url.String(), bytes.NewReader(body)) + if err != nil { + return errors.Wrap(err, "failed to create HTTP request for PutSubscription") + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := q.httpClient.Do(httpReq) + if err != nil { + return errors.Wrap(err, "HTTP request failed for PutSubscription") + } + defer resp.Body.Close() + + return checkResponse(resp, "PutSubscription", http.StatusOK, http.StatusCreated) +} + +func checkResponse(resp *http.Response, operation string, okStatusCodes ...int) error { + msg := fmt.Sprintf("operation %q failed", operation) + respContent, err := io.ReadAll(resp.Body) + if err == nil { + msg = fmt.Sprintf("%s: %q", msg, string(respContent)) + } + + return client.HandleError(resp.StatusCode, msg, okStatusCodes...) +} diff --git a/pubsub/internal/service/service_test.go b/pubsub/internal/service/service_test.go new file mode 100644 index 00000000..e18d34a8 --- /dev/null +++ b/pubsub/internal/service/service_test.go @@ -0,0 +1,223 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("configService", func() { + var ( + ctx context.Context + server *httptest.Server + svc ConfigService + origFn func(ctx context.Context, tokenUrl, clientId, clientSecret string) *http.Client + ) + + BeforeEach(func() { + ctx = context.Background() + // Save and override NewAuthorizedHttpClient so we don't need OAuth2 + origFn = NewAuthorizedHttpClient + }) + + AfterEach(func() { + NewAuthorizedHttpClient = origFn + if server != nil { + server.Close() + } + }) + + // Helper to create a test server and configService pointing at it. + setupServer := func(handler http.HandlerFunc) { + server = httptest.NewServer(handler) + NewAuthorizedHttpClient = func(_ context.Context, _, _, _ string) *http.Client { + return server.Client() + } + svc = NewConfigService(ConfigServiceConfig{ + BaseURL: server.URL, + TokenURL: "https://unused.example.com/token", + ClientID: "test-client", + ClientSecret: "test-secret", + }) + } + + sampleResource := func() SubscriptionResource { + return SubscriptionResource{ + ApiVersion: SubscriptionAPIVersion, + Kind: SubscriptionKind, + Metadata: SubscriptionMetadata{ + Name: "sub-123", + Namespace: "default", + }, + Spec: SubscriptionSpec{ + Environment: "test", + Subscription: SubscriptionPayload{ + SubscriptionId: "sub-123", + SubscriberId: "my-app", + PublisherId: "publisher-app", + Type: "de.telekom.test.v1", + }, + }, + } + } + + Describe("PutSubscription", func() { + It("should succeed with 200 OK", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPut)) + Expect(r.URL.Path).To(Equal(pathGVR + "/sub-123")) + Expect(r.Header.Get("Content-Type")).To(Equal("application/json")) + + body, err := io.ReadAll(r.Body) + Expect(err).ToNot(HaveOccurred()) + var received SubscriptionResource + Expect(json.Unmarshal(body, &received)).To(Succeed()) + Expect(received.Metadata.Name).To(Equal("sub-123")) + + w.WriteHeader(http.StatusOK) + })) + + err := svc.PutSubscription(ctx, "sub-123", sampleResource()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should succeed with 201 Created", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + })) + + err := svc.PutSubscription(ctx, "sub-123", sampleResource()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return error for 400 Bad Request", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad request")) + })) + + err := svc.PutSubscription(ctx, "sub-123", sampleResource()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PutSubscription")) + }) + + It("should return error for 500 Internal Server Error", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal error")) + })) + + err := svc.PutSubscription(ctx, "sub-123", sampleResource()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PutSubscription")) + }) + + It("should return error when subscriptionID is empty", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + err := svc.PutSubscription(ctx, "", sampleResource()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("subscriptionID is required")) + }) + }) + + Describe("DeleteSubscription", func() { + It("should succeed with 200 OK", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodDelete)) + Expect(r.URL.Path).To(Equal(pathGVR + "/sub-123")) + + body, err := io.ReadAll(r.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(len(body)).To(BeNumerically(">", 0)) + + w.WriteHeader(http.StatusOK) + })) + + err := svc.DeleteSubscription(ctx, "sub-123", sampleResource()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should succeed with 204 No Content", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + err := svc.DeleteSubscription(ctx, "sub-123", sampleResource()) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return error for 500 Internal Server Error", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("server error")) + })) + + err := svc.DeleteSubscription(ctx, "sub-123", sampleResource()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("DeleteSubscription")) + }) + + It("should return error when subscriptionID is empty", func() { + setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + err := svc.DeleteSubscription(ctx, "", sampleResource()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("subscriptionID is required")) + }) + }) + + Describe("buildURL", func() { + It("should construct correct URL", func() { + cs := &configService{BasePath: "https://example.com"} + u, err := cs.buildURL("my-sub-id") + Expect(err).ToNot(HaveOccurred()) + Expect(u.String()).To(Equal("https://example.com" + pathGVR + "/my-sub-id")) + }) + + It("should return error for empty subscriptionID", func() { + cs := &configService{BasePath: "https://example.com"} + _, err := cs.buildURL("") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("subscriptionID is required")) + }) + }) + + Describe("checkResponse", func() { + It("should return nil for matching status code", func() { + recorder := httptest.NewRecorder() + recorder.WriteHeader(http.StatusOK) + recorder.WriteString("ok") + resp := recorder.Result() + defer resp.Body.Close() + + err := checkResponse(resp, "TestOp", http.StatusOK) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return error for non-matching status code", func() { + recorder := httptest.NewRecorder() + recorder.WriteHeader(http.StatusBadGateway) + recorder.WriteString("bad gateway") + resp := recorder.Result() + defer resp.Body.Close() + + err := checkResponse(resp, "TestOp", http.StatusOK) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TestOp")) + }) + }) +}) diff --git a/pubsub/internal/service/suite_test.go b/pubsub/internal/service/suite_test.go new file mode 100644 index 00000000..bd609a61 --- /dev/null +++ b/pubsub/internal/service/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Service Suite") +} diff --git a/pubsub/internal/service/types.go b/pubsub/internal/service/types.go new file mode 100644 index 00000000..0b3eebf9 --- /dev/null +++ b/pubsub/internal/service/types.go @@ -0,0 +1,149 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package service + +const ( + // SubscriptionAPIVersion is the Kubernetes API version for Subscription resources + // sent to the configuration backend. + SubscriptionAPIVersion = "subscriber.horizon.telekom.de/v1" + + // SubscriptionKind is the Kubernetes kind for Subscription resources. + SubscriptionKind = "Subscription" + + // DefaultDataplaneNamespace is used as a placeholder namespace until + // EventStoreSpec is extended with a DataplaneNamespace field. + DefaultDataplaneNamespace = "default" +) + +// SubscriptionResource is the full Kubernetes-style resource envelope sent to +// the configuration backend. The configuration API expects a valid Kubernetes resource with +// apiVersion, kind, metadata, and spec fields. +type SubscriptionResource struct { + // ApiVersion is the Kubernetes API version (e.g. "subscriber.horizon.telekom.de/v1"). + ApiVersion string `json:"apiVersion"` + + // Kind is the Kubernetes resource kind (e.g. "Subscription"). + Kind string `json:"kind"` + + // Metadata contains the resource identity fields. + Metadata SubscriptionMetadata `json:"metadata"` + + // Spec contains the subscription specification. + Spec SubscriptionSpec `json:"spec"` +} + +// SubscriptionMetadata holds the Kubernetes-style metadata fields needed by the +// configuration backend. Only name and namespace are required. +type SubscriptionMetadata struct { + // Name is the subscription resource name (the subscription ID). + Name string `json:"name"` + + // Namespace is the target dataplane namespace. + Namespace string `json:"namespace"` +} + +// SubscriptionSpec wraps the subscription body and environment +type SubscriptionSpec struct { + // Environment is the realm/environment name. + // +optional + Environment string `json:"environment,omitempty"` + + // Subscription contains the actual subscription configuration. + Subscription SubscriptionPayload `json:"subscription"` +} + +// SubscriptionTriggerPayload defines filtering criteria sent to the configuration backend. +type SubscriptionTriggerPayload struct { + // ResponseFilterMode controls whether the response filter includes or excludes the specified fields. + // +optional + ResponseFilterMode string `json:"responseFilterMode,omitempty"` + + // ResponseFilter lists the JSON paths to include or exclude from the event payload. + // +optional + ResponseFilter []string `json:"responseFilter,omitempty"` + + // SelectionFilter defines simple key-value equality matches on CloudEvents attributes. + // +optional + SelectionFilter map[string]string `json:"selectionFilter,omitempty"` + + // AdvancedSelectionFilter contains an arbitrary JSON filter expression tree. + // +optional + AdvancedSelectionFilter map[string]any `json:"advancedSelectionFilter,omitempty"` +} + +// SubscriptionPayload contains the subscription fields nested inside +// SubscriptionSpec.Subscription. +type SubscriptionPayload struct { + // SubscriptionId is the self-assigned subscription identifier. + // +optional + SubscriptionId string `json:"subscriptionId,omitempty"` + + // SubscriberId is the unique identifier for the subscriber. + SubscriberId string `json:"subscriberId"` + + // PublisherId is the unique identifier for the publisher. + PublisherId string `json:"publisherId"` + + // CreatedAt is the timestamp when the subscription was created. + // +optional + CreatedAt string `json:"createdAt,omitempty"` + + // Trigger defines subscriber-side filtering criteria for event delivery. + // +optional + Trigger *SubscriptionTriggerPayload `json:"trigger,omitempty"` + + // Type is the event type identifier. + // +optional + Type string `json:"type,omitempty"` + + // Callback is the URL where events are delivered for callback-type subscriptions. + // +optional + Callback string `json:"callback,omitempty"` + + // PayloadType defines the event payload format (e.g. "data", "dataref"). + // +optional + PayloadType string `json:"payloadType,omitempty"` + + // DeliveryType defines the delivery mechanism (e.g. "Callback", "ServerSentEvent"). + // +optional + DeliveryType string `json:"deliveryType,omitempty"` + + // AdditionalPublisherIds allows multiple application IDs to publish to the same event type. + // +optional + AdditionalPublisherIds []string `json:"additionalPublisherIds,omitempty"` + + // AppliedScopes lists the scope names that this subscriber is subscribed to. + // +optional + AppliedScopes []string `json:"appliedScopes,omitempty"` + + // EventRetentionTime defines how long events are retained for this subscriber. + // +optional + EventRetentionTime string `json:"eventRetentionTime,omitempty"` + + // CircuitBreakerOptOut disables the circuit breaker for this subscription. + // +optional + CircuitBreakerOptOut *bool `json:"circuitBreakerOptOut,omitempty"` + + // RetryableStatusCodes defines HTTP status codes that should trigger a retry. + // +optional + RetryableStatusCodes []int `json:"retryableStatusCodes,omitempty"` + + // RedeliveriesPerSecond limits the rate of event redeliveries. + // +optional + RedeliveriesPerSecond *int `json:"redeliveriesPerSecond,omitempty"` + + // PublisherTrigger defines publisher-side filtering criteria applied to this subscriber. + // +optional + PublisherTrigger *SubscriptionTriggerPayload `json:"publisherTrigger,omitempty"` + + // EnforceGetHttpRequestMethodForHealthCheck forces GET for health check probes instead of HEAD. + // +optional + EnforceGetHttpRequestMethodForHealthCheck *bool `json:"enforceGetHttpRequestMethodForHealthCheck,omitempty"` + + // JsonSchema is the JSON schema defining the structure of events published by the associated publisher. + // This is included here for convenience so the configuration backend has all necessary information in one place. + // +optional + JsonSchema string `json:"jsonSchema,omitempty"` +} diff --git a/pubsub/test/e2e/e2e_suite_test.go b/pubsub/test/e2e/e2e_suite_test.go new file mode 100644 index 00000000..d14c3559 --- /dev/null +++ b/pubsub/test/e2e/e2e_suite_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e +// +build e2e + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/telekom/controlplane/pubsub/test/utils" +) + +var ( + // Optional Environment Variables: + // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup. + // These variables are useful if CertManager is already installed, avoiding + // re-installation and conflicts. + skipCertManagerInstall = os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true" + // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster + isCertManagerAlreadyInstalled = false + + // projectImage is the name of the image which will be build and loaded + // with the code source changes to be tested. + projectImage = "example.com/pubsub:v0.0.1" +) + +// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated, +// temporary environment to validate project changes with the purpose of being used in CI jobs. +// The default setup requires Kind, builds/loads the Manager Docker image locally, and installs +// CertManager. +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting pubsub integration test suite\n") + RunSpecs(t, "e2e suite") +} + +var _ = BeforeSuite(func() { + By("building the manager(Operator) image") + cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectImage)) + _, err := utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") + + // TODO(user): If you want to change the e2e test vendor from Kind, ensure the image is + // built and available before running the tests. Also, remove the following block. + By("loading the manager(Operator) image on Kind") + err = utils.LoadImageToKindClusterWithName(projectImage) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") + + // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing. + // To prevent errors when tests run in environments with CertManager already installed, + // we check for its presence before execution. + // Setup CertManager before the suite if not skipped and if not already installed + if !skipCertManagerInstall { + By("checking if cert manager is installed already") + isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled() + if !isCertManagerAlreadyInstalled { + _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n") + Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager") + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n") + } + } +}) + +var _ = AfterSuite(func() { + // Teardown CertManager after the suite if not skipped and if it was not already installed + if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { + _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n") + utils.UninstallCertManager() + } +}) diff --git a/pubsub/test/e2e/e2e_test.go b/pubsub/test/e2e/e2e_test.go new file mode 100644 index 00000000..061c6980 --- /dev/null +++ b/pubsub/test/e2e/e2e_test.go @@ -0,0 +1,325 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e +// +build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/telekom/controlplane/pubsub/test/utils" +) + +// namespace where the project is deployed in +const namespace = "pubsub-system" + +// serviceAccountName created for the project +const serviceAccountName = "pubsub-controller-manager" + +// metricsServiceName is the name of the metrics service of the project +const metricsServiceName = "pubsub-controller-manager-metrics-service" + +// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data +const metricsRoleBindingName = "pubsub-metrics-binding" + +var _ = Describe("Manager", Ordered, func() { + var controllerPodName string + + // Before running the tests, set up the environment by creating the namespace, + // enforce the restricted security policy to the namespace, installing CRDs, + // and deploying the controller. + BeforeAll(func() { + By("creating manager namespace") + cmd := exec.Command("kubectl", "create", "ns", namespace) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + + By("labeling the namespace to enforce the restricted security policy") + cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace, + "pod-security.kubernetes.io/enforce=restricted") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + + By("installing CRDs") + cmd = exec.Command("make", "install") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") + + By("deploying the controller-manager") + cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectImage)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + }) + + // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, + // and deleting the namespace. + AfterAll(func() { + By("cleaning up the curl pod for metrics") + cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace) + _, _ = utils.Run(cmd) + + By("undeploying the controller-manager") + cmd = exec.Command("make", "undeploy") + _, _ = utils.Run(cmd) + + By("uninstalling CRDs") + cmd = exec.Command("make", "uninstall") + _, _ = utils.Run(cmd) + + By("removing manager namespace") + cmd = exec.Command("kubectl", "delete", "ns", namespace) + _, _ = utils.Run(cmd) + }) + + // After each test, check for failures and collect logs, events, + // and pod descriptions for debugging. + AfterEach(func() { + specReport := CurrentSpecReport() + if specReport.Failed() { + By("Fetching controller manager pod logs") + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + controllerLogs, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + } + + By("Fetching Kubernetes events") + cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") + eventsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) + } + + By("Fetching curl-metrics logs") + cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) + } + + By("Fetching controller manager pod description") + cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace) + podDescription, err := utils.Run(cmd) + if err == nil { + fmt.Println("Pod description:\n", podDescription) + } else { + fmt.Println("Failed to describe controller pod") + } + } + }) + + SetDefaultEventuallyTimeout(2 * time.Minute) + SetDefaultEventuallyPollingInterval(time.Second) + + Context("Manager", func() { + It("should run successfully", func() { + By("validating that the controller-manager pod is running as expected") + verifyControllerUp := func(g Gomega) { + // Get the name of the controller-manager pod + cmd := exec.Command("kubectl", "get", + "pods", "-l", "control-plane=controller-manager", + "-o", "go-template={{ range .items }}"+ + "{{ if not .metadata.deletionTimestamp }}"+ + "{{ .metadata.name }}"+ + "{{ \"\\n\" }}{{ end }}{{ end }}", + "-n", namespace, + ) + + podOutput, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") + podNames := utils.GetNonEmptyLines(podOutput) + g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") + controllerPodName = podNames[0] + g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) + + // Validate the pod's status + cmd = exec.Command("kubectl", "get", + "pods", controllerPodName, "-o", "jsonpath={.status.phase}", + "-n", namespace, + ) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") + } + Eventually(verifyControllerUp).Should(Succeed()) + }) + + It("should ensure the metrics endpoint is serving metrics", func() { + By("creating a ClusterRoleBinding for the service account to allow access to metrics") + cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName, + "--clusterrole=pubsub-metrics-reader", + fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName), + ) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") + + By("validating that the metrics service is available") + cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") + + By("getting the service account token") + token, err := serviceAccountToken() + Expect(err).NotTo(HaveOccurred()) + Expect(token).NotTo(BeEmpty()) + + By("ensuring the controller pod is ready") + verifyControllerPodReady := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pod", controllerPodName, "-n", namespace, + "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("True"), "Controller pod not ready") + } + Eventually(verifyControllerPodReady, 3*time.Minute, time.Second).Should(Succeed()) + + By("verifying that the controller manager is serving the metrics server") + verifyMetricsServerStarted := func(g Gomega) { + cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("Serving metrics server"), + "Metrics server not yet started") + } + Eventually(verifyMetricsServerStarted, 3*time.Minute, time.Second).Should(Succeed()) + + // +kubebuilder:scaffold:e2e-metrics-webhooks-readiness + + By("creating the curl-metrics pod to access the metrics endpoint") + cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never", + "--namespace", namespace, + "--image=curlimages/curl:latest", + "--overrides", + fmt.Sprintf(`{ + "spec": { + "containers": [{ + "name": "curl", + "image": "curlimages/curl:latest", + "command": ["/bin/sh", "-c"], + "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"], + "securityContext": { + "readOnlyRootFilesystem": true, + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + }, + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + }], + "serviceAccountName": "%s" + } + }`, token, metricsServiceName, namespace, serviceAccountName)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") + + By("waiting for the curl-metrics pod to complete.") + verifyCurlUp := func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pods", "curl-metrics", + "-o", "jsonpath={.status.phase}", + "-n", namespace) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") + } + Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) + + By("getting the metrics by checking curl-metrics logs") + verifyMetricsAvailable := func(g Gomega) { + metricsOutput, err := getMetricsOutput() + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + g.Expect(metricsOutput).NotTo(BeEmpty()) + g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) + } + Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed()) + }) + + // +kubebuilder:scaffold:e2e-webhooks-checks + + // TODO: Customize the e2e test suite with scenarios specific to your project. + // Consider applying sample/CR(s) and check their status and/or verifying + // the reconciliation by using the metrics, i.e.: + // metricsOutput, err := getMetricsOutput() + // Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + // Expect(metricsOutput).To(ContainSubstring( + // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, + // strings.ToLower(), + // )) + }) +}) + +// serviceAccountToken returns a token for the specified service account in the given namespace. +// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request +// and parsing the resulting token from the API response. +func serviceAccountToken() (string, error) { + const tokenRequestRawString = `{ + "apiVersion": "authentication.k8s.io/v1", + "kind": "TokenRequest" + }` + + // Temporary file to store the token request + secretName := fmt.Sprintf("%s-token-request", serviceAccountName) + tokenRequestFile := filepath.Join("/tmp", secretName) + err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)) + if err != nil { + return "", err + } + + var out string + verifyTokenCreation := func(g Gomega) { + // Execute kubectl command to create the token + cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf( + "/api/v1/namespaces/%s/serviceaccounts/%s/token", + namespace, + serviceAccountName, + ), "-f", tokenRequestFile) + + output, err := cmd.CombinedOutput() + g.Expect(err).NotTo(HaveOccurred()) + + // Parse the JSON output to extract the token + var token tokenRequest + err = json.Unmarshal(output, &token) + g.Expect(err).NotTo(HaveOccurred()) + + out = token.Status.Token + } + Eventually(verifyTokenCreation).Should(Succeed()) + + return out, err +} + +// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. +func getMetricsOutput() (string, error) { + By("getting the curl-metrics logs") + cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace) + return utils.Run(cmd) +} + +// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, +// containing only the token field that we need to extract. +type tokenRequest struct { + Status struct { + Token string `json:"token"` + } `json:"status"` +} diff --git a/pubsub/test/mocks/mock_ConfigService.go b/pubsub/test/mocks/mock_ConfigService.go new file mode 100644 index 00000000..54b95e37 --- /dev/null +++ b/pubsub/test/mocks/mock_ConfigService.go @@ -0,0 +1,137 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + service "github.com/telekom/controlplane/pubsub/internal/service" +) + +// MockConfigService is an autogenerated mock type for the ConfigService type +type MockConfigService struct { + mock.Mock +} + +type MockConfigService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConfigService) EXPECT() *MockConfigService_Expecter { + return &MockConfigService_Expecter{mock: &_m.Mock} +} + +// DeleteSubscription provides a mock function with given fields: ctx, subscriptionID, resource +func (_m *MockConfigService) DeleteSubscription(ctx context.Context, subscriptionID string, resource service.SubscriptionResource) error { + ret := _m.Called(ctx, subscriptionID, resource) + + if len(ret) == 0 { + panic("no return value specified for DeleteSubscription") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, service.SubscriptionResource) error); ok { + r0 = rf(ctx, subscriptionID, resource) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConfigService_DeleteSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSubscription' +type MockConfigService_DeleteSubscription_Call struct { + *mock.Call +} + +// DeleteSubscription is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionID string +// - resource service.SubscriptionResource +func (_e *MockConfigService_Expecter) DeleteSubscription(ctx interface{}, subscriptionID interface{}, resource interface{}) *MockConfigService_DeleteSubscription_Call { + return &MockConfigService_DeleteSubscription_Call{Call: _e.mock.On("DeleteSubscription", ctx, subscriptionID, resource)} +} + +func (_c *MockConfigService_DeleteSubscription_Call) Run(run func(ctx context.Context, subscriptionID string, resource service.SubscriptionResource)) *MockConfigService_DeleteSubscription_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(service.SubscriptionResource)) + }) + return _c +} + +func (_c *MockConfigService_DeleteSubscription_Call) Return(_a0 error) *MockConfigService_DeleteSubscription_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConfigService_DeleteSubscription_Call) RunAndReturn(run func(context.Context, string, service.SubscriptionResource) error) *MockConfigService_DeleteSubscription_Call { + _c.Call.Return(run) + return _c +} + +// PutSubscription provides a mock function with given fields: ctx, subscriptionID, resource +func (_m *MockConfigService) PutSubscription(ctx context.Context, subscriptionID string, resource service.SubscriptionResource) error { + ret := _m.Called(ctx, subscriptionID, resource) + + if len(ret) == 0 { + panic("no return value specified for PutSubscription") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, service.SubscriptionResource) error); ok { + r0 = rf(ctx, subscriptionID, resource) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConfigService_PutSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutSubscription' +type MockConfigService_PutSubscription_Call struct { + *mock.Call +} + +// PutSubscription is a helper method to define mock.On call +// - ctx context.Context +// - subscriptionID string +// - resource service.SubscriptionResource +func (_e *MockConfigService_Expecter) PutSubscription(ctx interface{}, subscriptionID interface{}, resource interface{}) *MockConfigService_PutSubscription_Call { + return &MockConfigService_PutSubscription_Call{Call: _e.mock.On("PutSubscription", ctx, subscriptionID, resource)} +} + +func (_c *MockConfigService_PutSubscription_Call) Run(run func(ctx context.Context, subscriptionID string, resource service.SubscriptionResource)) *MockConfigService_PutSubscription_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(service.SubscriptionResource)) + }) + return _c +} + +func (_c *MockConfigService_PutSubscription_Call) Return(_a0 error) *MockConfigService_PutSubscription_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConfigService_PutSubscription_Call) RunAndReturn(run func(context.Context, string, service.SubscriptionResource) error) *MockConfigService_PutSubscription_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConfigService creates a new instance of MockConfigService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConfigService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConfigService { + mock := &MockConfigService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pubsub/test/utils/utils.go b/pubsub/test/utils/utils.go new file mode 100644 index 00000000..414ec159 --- /dev/null +++ b/pubsub/test/utils/utils.go @@ -0,0 +1,214 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "strings" + + . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck +) + +const ( + certmanagerVersion = "v1.19.1" + certmanagerURLTmpl = "https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml" + + defaultKindBinary = "kind" + defaultKindCluster = "kind" +) + +func warnError(err error) { + _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) +} + +// Run executes the provided command within this context +func Run(cmd *exec.Cmd) (string, error) { + dir, _ := GetProjectDir() + cmd.Dir = dir + + if err := os.Chdir(cmd.Dir); err != nil { + _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %q\n", err) + } + + cmd.Env = append(os.Environ(), "GO111MODULE=on") + command := strings.Join(cmd.Args, " ") + _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("%q failed with error %q: %w", command, string(output), err) + } + + return string(output), nil +} + +// UninstallCertManager uninstalls the cert manager +func UninstallCertManager() { + url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) + cmd := exec.Command("kubectl", "delete", "-f", url) + if _, err := Run(cmd); err != nil { + warnError(err) + } + + // Delete leftover leases in kube-system (not cleaned by default) + kubeSystemLeases := []string{ + "cert-manager-cainjector-leader-election", + "cert-manager-controller", + } + for _, lease := range kubeSystemLeases { + cmd = exec.Command("kubectl", "delete", "lease", lease, + "-n", "kube-system", "--ignore-not-found", "--force", "--grace-period=0") + if _, err := Run(cmd); err != nil { + warnError(err) + } + } +} + +// InstallCertManager installs the cert manager bundle. +func InstallCertManager() error { + url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) + cmd := exec.Command("kubectl", "apply", "-f", url) + if _, err := Run(cmd); err != nil { + return err + } + // Wait for cert-manager-webhook to be ready, which can take time if cert-manager + // was re-installed after uninstalling on a cluster. + cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "5m", + ) + + _, err := Run(cmd) + return err +} + +// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed +// by verifying the existence of key CRDs related to Cert Manager. +func IsCertManagerCRDsInstalled() bool { + // List of common Cert Manager CRDs + certManagerCRDs := []string{ + "certificates.cert-manager.io", + "issuers.cert-manager.io", + "clusterissuers.cert-manager.io", + "certificaterequests.cert-manager.io", + "orders.acme.cert-manager.io", + "challenges.acme.cert-manager.io", + } + + // Execute the kubectl command to get all CRDs + cmd := exec.Command("kubectl", "get", "crds") + output, err := Run(cmd) + if err != nil { + return false + } + + // Check if any of the Cert Manager CRDs are present + crdList := GetNonEmptyLines(output) + for _, crd := range certManagerCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// LoadImageToKindClusterWithName loads a local docker image to the kind cluster +func LoadImageToKindClusterWithName(name string) error { + cluster := defaultKindCluster + if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { + cluster = v + } + kindOptions := []string{"load", "docker-image", name, "--name", cluster} + kindBinary := defaultKindBinary + if v, ok := os.LookupEnv("KIND"); ok { + kindBinary = v + } + cmd := exec.Command(kindBinary, kindOptions...) + _, err := Run(cmd) + return err +} + +// GetNonEmptyLines converts given command output string into individual objects +// according to line breakers, and ignores the empty elements in it. +func GetNonEmptyLines(output string) []string { + var res []string + elements := strings.Split(output, "\n") + for _, element := range elements { + if element != "" { + res = append(res, element) + } + } + + return res +} + +// GetProjectDir will return the directory where the project is +func GetProjectDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return wd, fmt.Errorf("failed to get current working directory: %w", err) + } + wd = strings.ReplaceAll(wd, "/test/e2e", "") + return wd, nil +} + +// UncommentCode searches for target in the file and remove the comment prefix +// of the target content. The target content may span multiple lines. +func UncommentCode(filename, target, prefix string) error { + // false positive + // nolint:gosec + content, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filename, err) + } + strContent := string(content) + + idx := strings.Index(strContent, target) + if idx < 0 { + return fmt.Errorf("unable to find the code %q to be uncomment", target) + } + + out := new(bytes.Buffer) + _, err = out.Write(content[:idx]) + if err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + scanner := bufio.NewScanner(bytes.NewBufferString(target)) + if !scanner.Scan() { + return nil + } + for { + if _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + // Avoid writing a newline in case the previous line was the last in target. + if !scanner.Scan() { + break + } + if _, err = out.WriteString("\n"); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + } + + if _, err = out.Write(content[idx+len(target):]); err != nil { + return fmt.Errorf("failed to write to output: %w", err) + } + + // false positive + // nolint:gosec + if err = os.WriteFile(filename, out.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write file %q: %w", filename, err) + } + + return nil +} diff --git a/pubsub/tools/.mockery.yaml b/pubsub/tools/.mockery.yaml new file mode 100644 index 00000000..7e9997f0 --- /dev/null +++ b/pubsub/tools/.mockery.yaml @@ -0,0 +1,15 @@ +# Copyright 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +with-expecter: True +mockname: "Mock{{.InterfaceName}}" +dir: "../test/mocks" +outpkg: "mocks" +filename: "mock_{{.InterfaceName}}.go" +packages: + github.com/telekom/controlplane/pubsub/internal/service: + config: + interfaces: + ConfigService: + config: diff --git a/rover-ctl/pkg/config/config.go b/rover-ctl/pkg/config/config.go index a4cafdd4..82b6926d 100644 --- a/rover-ctl/pkg/config/config.go +++ b/rover-ctl/pkg/config/config.go @@ -41,4 +41,5 @@ func setDefaults() { // Authentication defaults viper.SetDefault("token", "") // ROVER_TOKEN viper.SetDefault(ConfigKeyTokenURL, "") + viper.SetDefault("access.token", "") // ROVER_ACCESS_LOCAL (only used for testing) } diff --git a/rover-ctl/pkg/config/token.go b/rover-ctl/pkg/config/token.go index 563a8d0f..9206d9b8 100644 --- a/rover-ctl/pkg/config/token.go +++ b/rover-ctl/pkg/config/token.go @@ -233,7 +233,7 @@ func FromContextOrDie(ctx context.Context) *Token { // ensureCorrectBasePath checks and sets the URL path to the expected base path if not already set. // It modifies the provided url.URL in place. func ensureCorrectBasePath(url *url.URL, expectedPath string) { - if expectedPath == "" { + if expectedPath == "" || expectedPath == "/" { return } // If the path is empty or just "/", set it to the expected base path diff --git a/rover-ctl/pkg/handlers/common/base_handler.go b/rover-ctl/pkg/handlers/common/base_handler.go index 2a367b31..2f3ab05c 100644 --- a/rover-ctl/pkg/handlers/common/base_handler.go +++ b/rover-ctl/pkg/handlers/common/base_handler.go @@ -83,12 +83,33 @@ func (h *BaseHandler) WithValidation(validateFunc func(obj types.Object) error) func (h *BaseHandler) Setup(ctx context.Context) *config.Token { token := config.FromContextOrDie(ctx) + if h.httpClient == nil { - h.httpClient = NewAuthorizedHttpClient(ctx, token.TokenUrl, token.ClientId, token.ClientSecret) + version := viper.GetString("version.semver") + userAgentValue := fmt.Sprintf("rover-ctl/%s", version) + + // Check for local access token (only used for testing) + localAccessToken := viper.GetString("access.token") + if localAccessToken != "" { + h.logger.V(1).Info("Using local access token for testing", "token", localAccessToken) + h.httpClient = WithStaticHeaders(http.DefaultClient, http.Header{ + "Authorization": []string{"Bearer " + localAccessToken}, + }) + + } else { + h.httpClient = NewAuthorizedHttpClient(ctx, token.TokenUrl, token.ClientId, token.ClientSecret) + } + + staticHeaders := http.Header{ + "User-Agent": []string{userAgentValue}, + } + h.httpClient = WithStaticHeaders(h.httpClient, staticHeaders) } + if h.serverURL == "" { h.serverURL = token.ServerUrl } + return token } diff --git a/rover-ctl/pkg/handlers/common/httpclient.go b/rover-ctl/pkg/handlers/common/httpclient.go index 99e8f549..463ebe6c 100644 --- a/rover-ctl/pkg/handlers/common/httpclient.go +++ b/rover-ctl/pkg/handlers/common/httpclient.go @@ -30,3 +30,26 @@ var NewAuthorizedHttpClient = func(ctx context.Context, tokenUrl, clientId, clie ctx = context.WithValue(ctx, oauth2.HTTPClient, baseClient) return tokenCfg.Client(ctx) } + +var _ HttpDoer = (*staticHeaderHttpDoer)(nil) + +type staticHeaderHttpDoer struct { + headers http.Header + innerClient HttpDoer +} + +func (s *staticHeaderHttpDoer) Do(req *http.Request) (*http.Response, error) { + for key, values := range s.headers { + for _, value := range values { + req.Header.Add(key, value) + } + } + return s.innerClient.Do(req) +} + +func WithStaticHeaders(client HttpDoer, headers http.Header) HttpDoer { + return &staticHeaderHttpDoer{ + headers: headers, + innerClient: client, + } +} diff --git a/rover-ctl/pkg/handlers/common/status_poller.go b/rover-ctl/pkg/handlers/common/status_poller.go index de1dd264..55834feb 100644 --- a/rover-ctl/pkg/handlers/common/status_poller.go +++ b/rover-ctl/pkg/handlers/common/status_poller.go @@ -20,7 +20,7 @@ type StatusHandler interface { type StatusEvalFunc func(ctx context.Context, status types.ObjectStatus) (continuePolling bool, err error) var defaultStatusEvalFunc StatusEvalFunc = func(_ context.Context, status types.ObjectStatus) (continuePolling bool, err error) { - if status.GetProcessingState() == "done" { + if status.GetProcessingState() == "done" || status.GetProcessingState() == "failed" { return false, nil } return true, nil diff --git a/rover-ctl/pkg/handlers/registry.go b/rover-ctl/pkg/handlers/registry.go index 6ea89885..d913a1b5 100644 --- a/rover-ctl/pkg/handlers/registry.go +++ b/rover-ctl/pkg/handlers/registry.go @@ -50,10 +50,14 @@ func handlerKey(kind, apiVersion string) string { return strings.ToLower(apiVersion) + "/" + strings.ToLower(kind) } +// RegisterHandlers registers all available handlers in the registry +// All handlers should be registered here to be discoverable by the system func RegisterHandlers() { apiSpecHandler := v0.NewApiSpecHandlerInstance() roverHandler := v0.NewRoverHandlerInstance() + eventSpecHandler := v0.NewEventSpecHandlerInstance() RegisterHandler(apiSpecHandler.Kind, apiSpecHandler.APIVersion, apiSpecHandler) RegisterHandler(roverHandler.Kind, roverHandler.APIVersion, roverHandler) + RegisterHandler(eventSpecHandler.Kind, eventSpecHandler.APIVersion, eventSpecHandler) } diff --git a/rover-ctl/pkg/handlers/v0/eventspec.go b/rover-ctl/pkg/handlers/v0/eventspec.go new file mode 100644 index 00000000..b0c8f84d --- /dev/null +++ b/rover-ctl/pkg/handlers/v0/eventspec.go @@ -0,0 +1,97 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v0 + +import ( + "context" + "encoding/json" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/telekom/controlplane/rover-ctl/pkg/handlers/common" + "github.com/telekom/controlplane/rover-ctl/pkg/types" +) + +// EventSpecHandler is a specialized handler for EventSpecification resources +type EventSpecHandler struct { + *common.BaseHandler +} + +func NewEventSpecHandlerInstance() *ApiSpecHandler { + handler := &ApiSpecHandler{ + BaseHandler: common.NewBaseHandler("tcp.ei.telekom.de/v1", "EventSpecification", "eventspecifications", 10).WithValidation(common.ValidateObjectName), + } + + handler.AddHook(common.PreRequestHook, PatchEventSpecificationRequest) + return handler +} + +func PatchEventSpecificationRequest(ctx context.Context, obj types.Object) error { + spec, ok := obj.GetContent()["spec"] + if !ok { + return errors.New("invalid EventSpecification. Missing 'spec'.") + } + specMap, ok := spec.(map[string]any) + if !ok { + return errors.New("invalid EventSpecification. 'spec' should be an object.") + } + + jsonSchema, ok := specMap["specification"] + if ok { + switch v := jsonSchema.(type) { + case string: + var schemaMap map[string]any + err := json.Unmarshal([]byte(v), &schemaMap) + if err != nil { + return errors.Wrap(err, "failed to parse JSON schema") + } + + specMap["specification"], err = resolveJsonSchemaReference(schemaMap) + if err != nil { + return errors.Wrap(err, "failed to resolve JSON schema reference") + } + case map[string]any: + // Already a map, do nothing + default: + return errors.New("invalid EventSpecification. 'specification' should be a JSON string or an object.") + } + } + + obj.SetContent(specMap) + return nil +} + +func resolveJsonSchemaReference(jsonSchema map[string]any) (map[string]any, error) { + if ref, ok := jsonSchema["$ref"]; ok { + refStr, ok := ref.(string) + if !ok { + return nil, errors.New("invalid $ref value in JSON schema") + } + if strings.HasPrefix(refStr, "file://") { + // Handle file reference + filePath := strings.TrimPrefix(refStr, "file://") + stat, err := os.Stat(filePath) + if err != nil { + return nil, errors.Wrap(err, "failed to access JSON schema file") + } + if stat.IsDir() { + return nil, errors.New("JSON schema reference points to a directory, expected a file") + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, errors.Wrap(err, "failed to read JSON schema file") + } + var schemaMap map[string]any + if err := json.Unmarshal(data, &schemaMap); err != nil { + return nil, errors.Wrap(err, "failed to parse JSON schema from file") + } + return schemaMap, nil + } + + } + return jsonSchema, nil + +} diff --git a/rover-ctl/pkg/handlers/v0/rover.go b/rover-ctl/pkg/handlers/v0/rover.go index dc2ba70b..81b886cf 100644 --- a/rover-ctl/pkg/handlers/v0/rover.go +++ b/rover-ctl/pkg/handlers/v0/rover.go @@ -109,9 +109,9 @@ func PatchSubscriptions(subscriptions []any) []map[string]any { for i, subscription := range subscriptionsMaps { if _, exist := subscription["basePath"]; exist { subscriptionsMaps[i]["type"] = "api" - } else if _, exist := subscription["port"]; exist { - subscriptionsMaps[i]["type"] = "port" - } + } else if _, exist := subscription["eventType"]; exist { + subscriptionsMaps[i]["type"] = "event" + } // TODO: add more types as needed security, exist := subscription["security"] if exist { PatchSecurity(security) diff --git a/rover-server/api/openapi.yaml b/rover-server/api/openapi.yaml index 0dce511c..62d15cc3 100644 --- a/rover-server/api/openapi.yaml +++ b/rover-server/api/openapi.yaml @@ -1893,8 +1893,6 @@ components: type: string advancedSelectionFilter: type: object - additionalProperties: - type: object TrustedTeam: description: >- A trusted team is a team that is allowed to access the resource without diff --git a/rover-server/cmd/main.go b/rover-server/cmd/main.go index f11a3cbe..66bcb8ef 100644 --- a/rover-server/cmd/main.go +++ b/rover-server/cmd/main.go @@ -37,7 +37,9 @@ func main() { store.InitOrDie(rootCtx, kconfig.GetConfigOrDie()) - app := cserver.NewApp() + appCfg := cserver.NewAppConfig() + appCfg.CtxLog = log.Log + app := cserver.NewAppWithConfig(appCfg) probesCtrl := cserver.NewProbesController() probesCtrl.Register(app, cserver.ControllerOpts{}) diff --git a/rover-server/go.mod b/rover-server/go.mod index 743d3c94..ef148580 100644 --- a/rover-server/go.mod +++ b/rover-server/go.mod @@ -12,6 +12,7 @@ require ( github.com/telekom/controlplane/application/api v0.0.0 github.com/telekom/controlplane/common v0.0.0 github.com/telekom/controlplane/common-server v0.0.0 + github.com/telekom/controlplane/event/api v0.0.0 github.com/telekom/controlplane/file-manager v0.0.0 github.com/telekom/controlplane/rover/api v0.0.0 github.com/telekom/controlplane/secret-manager v0.0.0 @@ -23,6 +24,7 @@ replace ( github.com/telekom/controlplane/application/api => ../application/api github.com/telekom/controlplane/common => ../common github.com/telekom/controlplane/common-server => ../common-server + github.com/telekom/controlplane/event/api => ../event/api github.com/telekom/controlplane/file-manager => ../file-manager github.com/telekom/controlplane/rover/api => ../rover/api github.com/telekom/controlplane/secret-manager => ../secret-manager @@ -47,6 +49,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/text v0.31.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/apiextensions-apiserver v0.34.2 k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 diff --git a/rover-server/internal/api/server.gen.go b/rover-server/internal/api/server.gen.go index 8efcf72d..9e5cd738 100644 --- a/rover-server/internal/api/server.gen.go +++ b/rover-server/internal/api/server.gen.go @@ -1,7 +1,8 @@ -// Copyright 2025 Deutsche Telekom IT GmbH +// Copyright 2026 Deutsche Telekom IT GmbH // // SPDX-License-Identifier: Apache-2.0 + // Package api provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. @@ -506,10 +507,10 @@ type EventSubscriptionInfoType string // EventTrigger defines model for EventTrigger. type EventTrigger struct { - AdvancedSelectionFilter map[string]map[string]interface{} `json:"advancedSelectionFilter,omitempty,omitzero"` - ResponseFilter []string `json:"responseFilter,omitempty,omitzero"` - ResponseFilterMode EventTriggerResponseFilterMode `json:"responseFilterMode,omitempty,omitzero"` - SelectionFilter map[string]string `json:"selectionFilter,omitempty,omitzero"` + AdvancedSelectionFilter map[string]interface{} `json:"advancedSelectionFilter,omitempty,omitzero"` + ResponseFilter []string `json:"responseFilter,omitempty,omitzero"` + ResponseFilterMode EventTriggerResponseFilterMode `json:"responseFilterMode,omitempty,omitzero"` + SelectionFilter map[string]string `json:"selectionFilter,omitempty,omitzero"` } // EventTriggerResponseFilterMode defines model for EventTrigger.ResponseFilterMode. @@ -1411,69 +1412,69 @@ var swaggerSpec = []string{ "AOhNyALx7ru1lIk4GQ5D2ECkaCQ/zkIYqiZDwYOhxDwk4iZIhWQx8Js1puGCsffDtdnEod3XgTJANwRu", "h2e66U2+jBu1juH3b6nrUKxo4C1BUTXMkpj0a7XKQpi4RkzwNmI4bKUGDpZgCIgr4DMIGG1xVdgU2gLZ", "RRp4eRRFA0jocfQiu5Jt1fySb/EiAiNolLnp8B68sjlBL+fzqyyDNVBNTWIQ5ia9mITAIUT5mDVTvzl7", - "I++naePs9BreSxve0zwt6KLG4NUNbtd97h2CawrRz2KCzAsk1u3rDaYBhDOIIFDzX5BIWpeiUxf+2Kbi", - "ln7IsmeLsfpvd7Xva+seyTOJPUKDKNXafIaO0i8fzF8uR63Ye4ENGEvZQU0slzwYIVG7GROKpXEmxThJ", - "bIIgTkif5CxLkn2N1mxTt5cm5VmDd+d7jMI9ssL2MJffdaAiT5Z/MDpsYnl/lGSRtk+FFjveHqgxPZSa", - "dFHKKajy3u+MuoTxlcmoLKVq6obodk2CtVZilDzG9jhOhTF589SFkkCOCbVuryd1jnC7xTpTnAy4rh2v", - "xPsby2yLIffRoHUgV23Enl2z4OWOhnetenxpcWXPamNxHf7dpC3LpM2nWj+bWn2mLzgu1JhMDF6dzma/", - "Tqbnnu+dvRqPLuc3Z9PR+ehyPj59NfN8bzq6mI5mL2/mk59Hl04RaXTT5hqV6taSMuMSheNkCup7kEfK", - "a0dOFLHbh0b3XmUGcJV3Xp7OR5PTGdL2ce3eV8M3T+GDw+BQOt/1dJz7kVQrc11K/VJcuyoG9tECCwgR", - "o+UEaQhNPASZ+EPpzlTKiec+oZa7wQlSzpVC2gnRztnqCa5qat8gxEVwr+p57jUCyQmnSz5a8tIr5Rtn", - "FKsp+mzTsqSrJt33DDWZGyQ70zgzyFxImOBUrp86jNyuPBzz8WfYdnztyNBZlTm9a32FSLjzu+PbHJYc", - "xFpL5L6BiN1Kelm+68SxHcSuO5RcEbWrN8bbAHmAupbeEGKJS3FpHYazGZjwQQKnOELj8yu0IdjecxCI", - "8ex+nb7Am7v5DAiFOqm+er69PuyUk3V9nBnC+ATpAU6yqyQiNVFlrLXGZUlMQ33jkEj1YwQbTGX1+mFF", - "hdYZrkZCBiCE4SylU2jP5iJiwXv9VwI0NB8VEUYgtclksn2o+seFhdbTM8CpaMmBYVRa8dz4Zs5cN2HG", - "IARetdniNlt5V8zdtpvCsrFN2fi+hd21Y1c5DtXmVEiFGkwVWHTjO2zDZCVrop6qmUZyziJQksCdXrMm", - "IZj0m9KtIUczlvIKD2eXfJsGfkxoalbYo7HInR87G7uOfHd2Rc+YVXuGRtPezq809c/NcJveZUJypIKR", - "X4oYTWOn3xMa7pdpqW9yJbglxp+0KnBd6XwlIC1IeSJ4MV3X0o14KgcAapkV2dXjXHAZMycw+yNabj6L", - "ktSDPYVeTQSZywWnrmIMJFbgAK1OcotFdiWhrGeFWMJAktjpJiySkt2ON/PdeNu4LlYBocJEpC/YWQlR", - "lfB9NZ/OpBCbjuwGSn9+bJhY/bjbK0nXArb3fkZYSJR37r2tSVPS71h2pbmNEkGfIBHU83vde5a1eNxt", - "q2vRGt4meuq7awWHqEoOt7MENzJrO3031dYPSuZ3ck4gWTW0rn4ZPHn647O/a6exrk3hnXj/o39/czz4", - "x7u//bX9glYpdJwunj015SyeDgbxFidJn2gjaRjdXUurmej3uHD08Gz4rpzzAh34Vuy0Xn+vaks1Urpn", - "KsLU3u9py9opR47NHjXthK86UaEegLYp/bWiGTVkj+mStactlGK4+1zJrF74cV3UoRvCGY2reeXFRqzT", - "hduKdd/w3qETlWczY9uR/OoCW5H0BXM79Py9L7VaUPyO263VAR+Pu+60b10PY9wn7Qglbj1atHlddmy/", - "yG+shO0YuWeqRRta6n6i/hIlJitulOck5QkTYEoTZfkEViYgJtfAb4mopj1UkT0rXXTsiukUFyZaVlnc", - "zPAz30mbfme+PjSSkw/T3ayAy2nNWRdig746a0XcAlmtpSs6XuO1fBTn1C1+hMJvU3hocr+My3+gB3KH", - "adp9Me0+lR2sknVsW1IqHnx9s1jQg69bdg71JTX9h0K/Y5eaWnqmv2uzx7l5tayv+4d4KyP1CfHWVciH", - "hngbuRd7ZmvsQs/Do+CN0fZF06eKhjvG3BNdNipep8c73yvfPXAkhNv7DEbx1RUY9V/ayFX/jSJ2aypW", - "ZHpzxcIncs1SieCDUs+IRKVbBu7c4h2++jY5/UvlJkQmrEeX89H0ajqejTzf+3UyfXXu+d6/J5cjVw5P", - "UVBgpnPKNViT0yz0VEtC17/bK2w3AQedvYYjgZYRu/V8T/1T0iDOihamGFgW6TF5dyc4jAk9wVGkkK7+", - "LpkhOIpQ2Y1W6cIWy1ToqhZ5z+KnvoMoosi7q//s7rhOFxm47qYmGdIq6UWfCryTnZC2jGIBnnaA6ugp", - "0gT4hgjGswEqRUH2x54ixz5YsEZKude98OAYpzcmqn1TAdxCXsXBnkNUllEZ6X5r0oO6NqfnArMAp05X", - "8bLUV2vUHAUsHiqNdMgBR7Ew/wyHCWeSBSwasgQoCQcBoxQCOdQjVUIiVp29sypOVv4PB1rRhFhnpHgX", - "r5/fzE+n5+PZzcyUDPz/RcptUR7TNPF8L60Ae888XVuccOg1SgeeXo0zl4WZMndsJBGmcIR+NVdRiUCn", - "V+MiWzvGFK/AJIFr+0j4qF77xfw0ZTiMcSJ8dEEiqDbQVk8zk10c5ZcTT4zlhkw9m/z6jPejvTPzYYAT", - "MiiuS3ljuuRYSJ4G0noJbb0f2+lH4whcawE7xAmp3DbSv65c5twFsVEMhbHaKjSJzQ2tqs3JKzh7L0Ce", - "RlEDMV61/HPLIV80GRYXrHc0tOWXe7TUNV7NqV+qqvr0+LijbuV+9So7K4g5ylhOfs4j/KZA+MBUCB/k", - "Zblds9kOw0o58Tvf++dAl+3u2blU/ltD9szgwdUnx9ewVIJWd3myu0ulXKnu9OPuTkWdWd3j2e4eeWVY", - "1eHJ3/vA5Shgeud7f++Dh3Ih2LtK8aU3hZb0pqnOuDSNdhWm9ahu6B1NdaBNzWie1o4TtPVwbp6YjrOq", - "9Ux8p5hPpHGMteAqBIxDWkisjM43zYKO72wVECOwsiPRfTnS+NwRpo0pjpD+RuhKS+Q0Cc1/ilNUqfNi", - "S4M1Z5SlwrcBYSLQ7XpbDv4SgVZkAxRhXdAMU6EvpjCE0dVkNh9eXedXY3wkCA0AkdptFmU+VCwFU2k6", - "Kzi3xuaa0RYkWgDQIkKIbEd94aVSQpvkwWFdZ75cEnwpgZuPumC3LhGnTW30tpQz8tbzqzDpcu4LQL+l", - "wEl28+fFaK7LuoKOaOoCb0SWQ9NIpFpNWaZRtEWM582Mg2WYBwoxB3TLiZRAswQpA3AOfwbyUO8WGMgD", - "xjkE0s+KwGaBRbvYzMEoQGdmWffUEZpkjkb161ub8fLWO0I2j+np8RMUA1YnuqlJC1rdSqS96JXdddJF", - "ytfAQcNPGRJbrQCRQFfbgxhT9bdZrI8WJm+Ag7kAFgSpyZjWJXTsqs3+FEs5QvNyVVyxZrc1DCGiL6cV", - "JXFNTXbOEk4UovLQTOO0NgzS4DFjZoKQz1m4/WynYzUidtcseP70+Olnm7zrWD49OxtdzU2d68Ox+JjH", - "YuM8a55S+clTO03aZX33aXLnO5Ti4cfmsyB35sTRPu2GsvzDD+NYYQ1TefLDD/qdDt3UStiQUagcJ9FW", - "CR6gIuUu+a8EKNbvwURbMxCEvs481oaJzcE2ly5tl5RKElkxEADZAMLo2fGz0isO6C0910O5kKTjLVXh", - "YBo7sLmfJu946MWhhD9r4vR89Gp04MKviwvbCWyXTtdhg1IEH4jQ+plj1Kb9+WDjsx/JHn+Rw2ny84Eh", - "Dtbal7LWulmx22hzlVUwCQqd4x5MtYOpdjDVLKd8Pm3s89t71WSkg713OFgfomn2ODk+leE3LLKS76el", - "atljnsLTd0LyetQ7dddZ1vKPrcC2XLM5qK9fofr62FrqvnpmKzPt4HVTsXHP2KcjVLsz+Ono8+cIf+4o", - "H3gIgB5k0sGkdgVA3RIjk2eOwof3C4I2BzrY1gfb+mBbWxZx8NnnMYx31Sn+zKZxV2Hdg3H8jQVDnUTd", - "ebK06MrDj9Bo/a0FRZv4aA2LOvFa0/Fbn4FxPHmZPRqv62PkSbEulHvlSwDmwC/4vp7Gfwi0fvOB1v05", - "vG+w1Tlyw+L9ajnh+AudqAcX2MHc/ENEcO8jOfpGcQ+25sHWPNiarXHcr+nQfAwD+FFjwwcD+M8YHf7c", - "lvDeUeLmnP3jxM2+bZHib1z1PkSeD2r3V6927wxpt4onzrIK5t1xbHNjeWfo2jT7c0SrmzXQDgHq+4qu", - "gyT6quLNOZtn8sUUfLtPVNmMdbDtD7b9wba3TDG17x99DsvZUUr2MxvLtVKdB/v4mwoQo4xY6ydBoVwO", - "s9o+Tg1TVyJSLbRuGUXlB1J36Zullrag3B9E7fSbenSkpMhiW3n/VT89kNnMSo5vC6M5+1YwWf9npnZD", - "udaV5kpl7D6njlwppnzQkQ868resI/eXZx0S86P+95vKjjFFyQZGb+IQs42thXZ6NUZDVK6uKdAQZY8W", - "iNYEmuzg2U/kG4F0qCDw7Se2tGomfbNXsv4NveOTU97x4ynbByf24Vj8Qzix27mzb4bIwXF0cBwdHEfl", - "pJBPcTB9JpfTo+ZnHFxOf5KUjB7Op9yU2scNhbKEjbL1tsMFdS8PVM57X6HP5sA7Bz3yC7tX2vi0lzwo", - "Hs5KsAzWTZnwAqhid0AUblH56Sr9IpUuUdcuHKYgrKU4yx7a+qPai7WHxw7c/rWflHsSbj9u2TM50fj7", - "eucjGkK8X7Gax+GUQ57g4Wj8Cl0sO1MDc67fA+9mZr1priTh06sxujXGtHliJnM5mPeqlN1uX3IRKGZ5", - "M/NkrH7KpPqGy8c1E/LOyKTh5kfP9zaYE7yIDJuv85SjJU4j6Z14OCFHlTdimknMM4n5SslI1dvXjo83", - "IQvEu+8e+HKMGvgFlnAzKt5XvZlsFMHB7fAvws47wDQcEE7E91qGNPKsAccDhUfttdCv4ZqnwmKsX8VE", - "OJUsxpIEOIq2xiEhMpzqpzNV64FB7BGa2+dozCNjgpmHNRdbpHE6CGS0A+vYvCbzjaH9Xc4Gdfy/Nq/1", - "uEoB2wB9o0pUcxPtIO7qLHYYR2Zu60B52p3ta59ZfXf3vwEAAP//apXgUjTCAAA=", + "I++naePs9BreSxve0zwt6KLG4NUNbtd97h2CawrRz2KCzAsk1u3rDaYBhDOIIFDzX5BImoaNkbKk2KJJ", + "/12s9n1tvR55grBHaBClWknPVln65YP5y+V/FU243Tp8O4ylpJ8m8kqOiZCoTYoJxdL4iGKcJDbvDyek", + "T86VpbS+tmi2V9tLk8mswbvzPUbhHslee1jB7zpQkefAPxgdNl+8P0qyANqnQosdbw/UmB5K+7kopQpU", + "Wep3Rl0y9sokSpYyMHVDdLsmwVrrJkrMYnvKpsJYsnlGQknOxoRab9aTOke4vV2dmUsGXNeOV8L4jWW2", + "hYb7KMY6Pqs2Ys+uWUxyR8O7VvW8tLiyw7SxuA63bdKWPNLmKq0fOa2u0BccF9pJJgavTmezXyfTc8/3", + "zl6NR5fzm7Pp6Hx0OR+fvpp5vjcdXUxHs5c388nPo0uniDQqZ3ONSiNryYRxicJxMgX1PcgD4LWTJIrY", + "7UODdq8yu7bKOy9P56PJ6Qxps7d2navhcqfwwWFHKFXuejrO3UOqlbkFpX4pblMVA/togQWEiNFy3jOE", + "JsyBTFihdBUq5cRzn1DL3eAEKedKz+yEaOds9bxVNbVvEOIiuFf19PUageSE0yUfLXnplfKNMzjVFH22", + "aVnSVXPpe0aQzMWQndmZGWQuJExwKtdPHbZrV3qN+fgzbDu+diTerMqc3rW+QiTc+d1haw5LDmKtJXLf", + "+MJu3bss33U+2A5i1x1KHobajRrjRIA87lzLWgixxKVws46u2cRK+CCBUxyh8fkV2hBsry8IxHh2bU7f", + "y829dwaEQp1UXz3f3gp2ysm6ms0MYXyCqL+T7Cr5RU1UGSOscQcS01BfJCRS/RjBBlNZvVVYUaF14qqR", + "kAEIYThL6RTaYbmIWPBe/5UADc1HRYQRSG0JmSQeqv5xYaH19AxwKlpSWxiVVjw3vpkz102YMQiBV20m", + "tk1C3hVKt+2msGxsUza+b2F37dhVjkO1ORVSoQZTBRbd+A7bMFlJhqhnYKaRnLMIlCRwZ82sSQgmq6Z0", + "GcjRjKW8wsPZ3d2m3R4TmpoV9mgscp/GzsauI9+dNNEzFNWeeNE0o/ObSv1TLtwWdZmQHBle5Jci9NLY", + "6feEhvslUOoLWgluCd0nrQpcV5ZeCUgLUp7fXUzXtXQjnsp+/VrCRHajOBdcxswJzP6IlgvNoiT1YE+h", + "VxNB5s7AqavGAokVOECrk9xikd00KOtZIZYwkCR2ev+KXGO3P818N040rmtQQKgwEel7c1ZCVCV8X82n", + "M9fDZhm7gdKfHxsmVj/u9sq9tYDtvZ8RFhLlnXtva9KU9DuWXWlugz/QJ/YD9bRd955lLR532+patIa3", + "iZ767lrBIaqSw+0swY2E2U7fTbX1g3L0nZwTSFaNmKtfBk+e/vjs79oXrEtOeCfe/+jf3xwP/vHub39t", + "v3dVigini2dPTZWKp4NBvMVJ0ieISBpGd9fSaib6Pe4RPTzJvSuVvEAHvhU7rdffq9pSjZTumWEwtdd2", + "2pJxygFhs0dNO+Grzj+ox5Vtpn6tFkYN2WO6ZO3ZCKXQ7D43Lav3eFz3b+iGcEbjarp4sRHrdOG2Yt0X", + "t3foROXZzNh2JL+6wFYkfcGUDT1/77uqFhS/49JqdcDH46477VvXwxj3STtCiVuPFm1elx3bL/KLKGE7", + "Ru6ZQdGGlrqfqL9EicmKG+U5SXnCBJiKQ1magJUJiMk18FsiqtkMVWTPSvcXu2I6xT2IllUWFy78zHfS", + "pt+Zrw+N5OTDdDcr4HJac9aF2KCvzhIQt0BWa+kKetd4LR/FOXWLH6Hw2xQemtwv4/If6IHcYZp2X0y7", + "T2UHq2Qd25aUigffyiwW9OBblJ1DfUlN/6HQ79ilppae6e/a7HFuXi2Z6/4h3spIfUK8dRXyoSHeRkrF", + "nkkYu9Dz8Ch4Y7R90fSpouGOMfdEl42K1+nxzvfKVwoced72moJRfHVhRf2XNnLVf6OI3ZpCFJneXLHw", + "iVyzVCL4oNQzIlHp8oA7ZXiHr75NTv9SueCQCevR5Xw0vZqOZyPP936dTF+de77378nlyJWaU9QJmOlU", + "cQ3W5DQLPdVyy/Xv9mbaTcBBJ6XhSKBlxG4931P/lDSIs6KFqfGVRXpMOt0JDmNCT3AUKaSrv0tmCI4i", + "VHajVbqwxTIVulhF3rP4qe8giijy7uo/uzuu00UGrrupyXG0SnrRpwLvZCekLaNYgKcdoDp6ijQBviGC", + "8WyASq2P/bGnyLEPFqyRUu51Lzw4xumNiWrfVAC3kFdxsOcQlWVURrrfmvSgrs3pucAswKnTVbwso9Ua", + "NUcBi4dKIx1ywFEszD/DYcKZZAGLhiwBSsJBwCiFQA71SJWQiFVn76yKk1X1w4FWNCHWGSnexevnN/PT", + "6fl4djMzlQD/f5FJW1S9NE0830srwN4z/dbWHBx6jYqAp1fjzGVhpswdG0mEKRyhX80NUyLQ6dW4SMKO", + "McUrMLnd2j4SPqqXdDE/TRkOY5wIH12QCKoNtNXTTFAXR/mdwxNjuSFTpia/FeP9aK/CfBjghAyKW1De", + "mC45FpKngbReQlvGx3b60TgC11rADnFCKpeI9K8rlzl3QWwUQ2GstgpNYnNDq2pz8sLM3guQp1HUQIxX", + "rerccsgXTYbFvekdDW1V5R4tdelWc+qXiqU+PT7uKEe5XxnKzsJgjuqUk5/zCL+p+z0whb8HebVt12y2", + "w7BSJfzO9/450NW4e3YuVfXWkD0zeHD1yfE1LFWW1V2e7O5SqUKqO/24u1NRPlb3eLa7R17wVXV48vc+", + "cDnqkt753t/74KFc3/WuUlPpTaElvWmqMy5No12FaT2qG3pHUx1oUzOap7XjBG09nJsnpuOsaj0T3ynm", + "E2kcYy24CgHjkBYSK6PzTbNO4ztb3MMIrOxIdN95ND53hGljiiOkvxG60hI5TULzn+IUVeq82NJgzRll", + "qfBtQJgIdLveloO/RKAV2QBFWNcpw1To+yYMYXQ1mc2HV9f5jRcfCUIDQKR2SUWZDxVLwRSQzurIrbG5", + "PbQFiRYAtIgQIttR32OpVMYmeXBYl48vV/peSuDmo67DrSu/aVMbvS3ljLz1/CpMukr7AtBvKXCSXeh5", + "MZrraq2gI5q6bhuR5dA0EqlWU5ZpFG0R43kz42AZ5oFCzAHdciIl0CxBygCcw5+BPNS7BQbygHEOgfSz", + "2q5ZYNEuNnMwCtCZWdY9dYQmmaNR/frWZry89Y6QzWN6evwExYDViW5KzYJWtxJp729lV5h07fE1cNDw", + "U4bEVitAJNBF9CDGVP1tFuujhckb4GDudQVBajKmdWUcu2qzP8VSjtC8XOxWrNltDUOI6DtnRaVbU2qd", + "s4QThag8NNM4rQ2DNHjMmJkg5HMWbj/b6ViNiN0165g/PX762SbvOpZPz85GV3NTvvpwLD7msdg4z5qn", + "VH7y1E6TdlnffZrc+Q6lePix+drHnTlxtE+7oSz/8MM4VljDVJ788IN+fkM3tRI2ZBQqx0m0VYIHqEi5", + "S/4rAYr1My/R1gwEoa8zj7VhYnOwzV1K2yWlkkRWDARANoAwenb8rPQ4A3pLz/VQLiTpeEtVOJjGDmzu", + "p8k73m9xKOHPmjg9H70aHbjw6+LCdgLbpdN12KAUwQcitH7mGLVpfz7Y+OxHssdf5HCa/HxgiIO19qWs", + "tW5W7DbaXNUSTIJC57gHU+1gqh1MNcspn08b+/z2XjUZ6WDvHQ7Wh2iaPU6OT2X4DYus5PtpqVr2mBfu", + "9J2QvMz0Tt11lrX8YyuwLddsDurrV6i+PraWuq+e2cpMO3jdFGLcM/bpCNXuDH46+vw5wp87qgIeAqAH", + "mXQwqV0BULfEyOSZo57h/YKgzYEOtvXBtj7Y1pZFHHz2eQzjXeWHP7Np3FUv92Acf2PBUCdRd54sLbry", + "8CM0Wn9rQdEmPlrDok681nT81tddHC9Zuh//d6HcK18CMAd+wff1NP5DoPWbD7Tuz+F9g63OkRsW71fL", + "Ccdf6EQ9uMAO5uYfIoJ7H8nRN4p7sDUPtubB1myN435Nh+ZjGMCPGhs+GMB/xujw57aE944SN+fsHydu", + "9m2LFH/jqvch8nxQu796tXtnSLtVPHGWVTDvjmObG8s7Q9em2Z8jWt2sgXYIUN9XdB0k0VcVb87ZPJMv", + "puDbfaLKZqyDbX+w7Q+2vWWKqX3/6HNYzo5Ssp/ZWK6V6jzYx99UgBhlxFo/CQrlcpjV9nFqmLoSkWqh", + "dcsoKr97ukvfLLW0BeX+IGqn39SjIyVFFtvKs6766YHMZlZyfFsYzdm3gsn6PzO1G8q1rjRXKmP3OXXk", + "SjHlg4580JG/ZR25vzzrkJgf9b/fVHaMKUo2MHoTh5htbC2006sxGqJydU2Bhih7tEC0JtBkB89+It8I", + "pEMFgW8/saVVM+mbvZL1b+gdn5zyjh9P2T44sQ/H4h/Cid3OnX0zRA6Oo4Pj6OA4KieFfIqD6TO5nB41", + "P+PgcvqTpGT0cD7lptQ+biiUJWyUrbcdLqh7eaBy3vsKfTYH3jnokV/YvdLGp73kQfFwVoJlsG7KhBdA", + "FbsDonCLyk9X6RepdIm6duEwBWEtxVn20NYf1V6sPTx24Pav/aTck3D7ccueyYnG39c7H9EQ4v2K1TwO", + "pxzyBA9H41foYtmZGphz/R54NzPrTXMlCZ9ejdGtMabNEzOZy8G8V6XsdvuSi0Axy5uZJ2P1UybVN1w+", + "rpmQd0YmDTc/er63wZzgRWTYfJ2nHC1xGknvxMMJOaq8EdNMYp5JzFdKRqrevnZ8vAlZIN5998CXY9TA", + "L7CEm1HxvurNZKMIDm6HfxF23gGm4YBwIr7XMqSRZw04Hig8aq+Ffg3XPBUWY/0qJsKpZDGWJMBRtDUO", + "CZHhVD+dqVoPDGKP0Nw+R2MeGRPMPKy52CKN00Egox1Yx+Y1mW8M7e9yNqjj/7V5rcdVCtgG6BtVopqb", + "aAdxV2exwzgyc1sHytPubF/7zOq7u/8NAAD//5b3gAoLwgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 65badac9..8bf8cb0e 100755 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -1,4 +1,240 @@ +[EventSpecification Controller Get EventSpecification resource should return the EventSpecification successfully - 1] +{ + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" +} +--- + +[EventSpecification Controller Get EventSpecification resource should fail to get a non-existent EventSpecification - 1] +{ + "detail": "Resource eni--hyperion--blabla not found", + "instance": "", + "status": 404, + "title": "Not found", + "type": "NotFound" +} +--- + +[EventSpecification Controller Get EventSpecification resource should fail to get an EventSpecification from a different team - 1] +{ + "detail": "Access to requested resource not allowed", + "instance": "", + "status": 403, + "title": "Access Denied", + "type": "Forbidden" +} +--- + +[EventSpecification Controller GetAll EventSpecifications resource should return all EventSpecifications successfully - 1] +{ + "_links": { + "next": "", + "self": "http://example.com/eventspecifications?cursor=" + }, + "items": [ + { + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" + } + ] +} +--- + +[EventSpecification Controller GetAll EventSpecifications resource should return all EventSpecifications successfully - 2] +{ + "_links": { + "next": "", + "self": "http://example.com/eventspecifications?cursor=" + }, + "items": [ + { + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" + } + ] +} +--- + +[EventSpecification Controller GetAll EventSpecifications resource should return an empty list if no EventSpecifications exist - 1] +{ + "_links": { + "next": "", + "self": "http://example.com/eventspecifications?cursor=" + }, + "items": [ + { + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" + } + ] +} +--- + +[EventSpecification Controller GetAll EventSpecifications resource should return an empty list if no EventSpecifications exist - 2] +{ + "_links": { + "next": "", + "self": "http://example.com/eventspecifications?cursor=" + }, + "items": [ + { + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" + } + ] +} +--- + +[EventSpecification Controller Delete EventSpecification resource should fail to delete a non-existent EventSpecification - 1] +{ + "detail": "Resource eni--hyperion--blabla not found", + "instance": "", + "status": 404, + "title": "Not found", + "type": "NotFound" +} +--- + +[EventSpecification Controller Delete EventSpecification resource should fail to delete an EventSpecification from a different team - 1] +{ + "detail": "Access to requested resource not allowed", + "instance": "", + "status": 403, + "title": "Access Denied", + "type": "Forbidden" +} +--- + +[EventSpecification Controller GetStatus EventSpecification resource should return the status of the EventSpecification successfully - 1] +{ + "overallStatus": "complete", + "processingState": "done", + "state": "complete" +} +--- + +[EventSpecification Controller GetStatus EventSpecification resource should fail to get the status of a non-existent EventSpecification - 1] +{ + "detail": "Resource eni--hyperion--blabla not found", + "instance": "", + "status": 404, + "title": "Not found", + "type": "NotFound" +} +--- + +[EventSpecification Controller GetStatus EventSpecification resource should fail to get the status of an EventSpecification from a different team - 1] +{ + "detail": "Access to requested resource not allowed", + "instance": "", + "status": 403, + "title": "Access Denied", + "type": "Forbidden" +} +--- + +[EventSpecification Controller Update EventSpecification resource should update the EventSpecification successfully - 1] +{ + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" +} +--- + +[EventSpecification Controller Update EventSpecification resource should fail to update an EventSpecification from a different team - 1] +{ + "detail": "Access to requested resource not allowed", + "instance": "", + "status": 403, + "title": "Access Denied", + "type": "Forbidden" +} +--- + [Rover Controller GetAll rover resources should return all rovers successfully - 1] { "_links": { @@ -263,7 +499,6 @@ "irisTokenEndpointUrl": "https://iris-distcp1-dataplane1.dev.dhei.telekom.de/auth/realms/poc/protocol/openid-connect/token", "name": "rover-local-sub", "stargateIssuerUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de:443/auth/realms/poc", - "stargatePublishEventUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/horizon/events/v1", "stargateUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/", "status": "blocked", "subscriptions": [ @@ -335,7 +570,6 @@ "irisTokenEndpointUrl": "https://iris-distcp1-dataplane1.dev.dhei.telekom.de/auth/realms/poc/protocol/openid-connect/token", "name": "rover-local-sub", "stargateIssuerUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de:443/auth/realms/poc", - "stargatePublishEventUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/horizon/events/v1", "stargateUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/", "status": "blocked", "subscriptions": [ @@ -667,39 +901,6 @@ } --- -[ApiSpecification Controller GetAll ApiSpecifications resource should return an empty list if no ApiSpecifications exist - 2] -{ - "_links": { - "next": "", - "self": "http://example.com/apispecifications?cursor=" - }, - "items": [ - { - "category": "other", - "id": "eni--hyperion--eni-distr-v1", - "name": "eni-distr-v1", - "specification": { - "info": { - "title": "Rover API", - "version": "1.0.0" - }, - "openapi": "3.0.0", - "servers": [ - { - "url": "http://rover-api.com/eni/distr/v1" - } - ] - }, - "status": { - "processingState": "done", - "state": "complete" - }, - "vendorApi": false - } - ] -} ---- - [ApiSpecification Controller Delete ApiSpecification resource should fail to delete a non-existent ApiSpecification - 1] { "detail": "Resource eni--hyperion--blabla not found", @@ -773,16 +974,6 @@ } --- -[ApiSpecification Controller Update ApiSpecification resource should fail to update a non-existent ApiSpecification - 1] -{ - "detail": "Resource eni--hyperion--not-there-v1 not found", - "instance": "", - "status": 404, - "title": "Not found", - "type": "NotFound" -} ---- - [ApiSpecification Controller Update ApiSpecification resource should fail to update an ApiSpecification from a different team - 1] { "detail": "Access to requested resource not allowed", diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 16a8b0e2..cfdd1275 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -220,7 +220,7 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return res, err } - return status.MapApiSpecificationResponse(ctx, apiSpec) + return status.MapResponse(ctx, apiSpec) } func (a *ApiSpecificationController) uploadFile(ctx context.Context, specMarshaled []byte, id mapper.ResourceIdInfo) (*filesapi.FileUploadResponse, error) { diff --git a/rover-server/internal/controller/apispecification_test.go b/rover-server/internal/controller/apispecification_test.go index ea50ba91..5df00204 100644 --- a/rover-server/internal/controller/apispecification_test.go +++ b/rover-server/internal/controller/apispecification_test.go @@ -19,6 +19,8 @@ import ( "github.com/telekom/controlplane/rover-server/internal/api" ) +// TODO: fix the unit-tests. Use Once() or Twice() for mocks + var _ = Describe("ApiSpecification Controller", func() { specV3 := ` @@ -32,7 +34,7 @@ servers: Context("Get ApiSpecification resource", func() { It("should return the ApiSpecification successfully", func() { - mockFileManager.EXPECT().DownloadFile(mock.Anything, mock.Anything, mock.Anything). + mockFileManager.EXPECT().DownloadFile(mock.Anything, "randomId", mock.Anything). RunAndReturn(func(_ context.Context, _ string, w io.Writer) (*fileApi.FileDownloadResponse, error) { w.Write([]byte(specV3)) @@ -42,6 +44,7 @@ servers: ContentType: "application/yaml", }, nil }) + req := httptest.NewRequest(http.MethodGet, "/apispecifications/eni--hyperion--eni-distr-v1", nil) responseGroup, err := ExecuteRequest(req, groupToken) ExpectStatusWithBody(responseGroup, err, http.StatusOK, "application/json") @@ -62,7 +65,7 @@ servers: Context("GetAll ApiSpecifications resource", func() { It("should return all ApiSpecifications successfully", func() { - mockFileManager.EXPECT().DownloadFile(mock.Anything, mock.Anything, mock.Anything). + mockFileManager.EXPECT().DownloadFile(mock.Anything, "randomId", mock.Anything). RunAndReturn(func(_ context.Context, _ string, w io.Writer) (*fileApi.FileDownloadResponse, error) { w.Write([]byte(specV3)) @@ -83,11 +86,8 @@ servers: It("should return an empty list if no ApiSpecifications exist", func() { req := httptest.NewRequest(http.MethodGet, "/apispecifications", nil) - responseGroup, err := ExecuteRequest(req, groupToken) + responseGroup, err := ExecuteRequest(req, teamNoResources) ExpectStatusWithBody(responseGroup, err, http.StatusOK, "application/json") - - responseTeam, err := ExecuteRequest(req, teamToken) - ExpectStatusWithBody(responseTeam, err, http.StatusOK, "application/json") }) }) @@ -170,6 +170,16 @@ servers: FileId: "randomId", ContentType: "application/yaml", }, nil) + mockFileManager.EXPECT().DownloadFile(mock.Anything, "randomId", mock.Anything). + RunAndReturn(func(_ context.Context, _ string, w io.Writer) (*fileApi.FileDownloadResponse, error) { + + w.Write([]byte(specV3)) + + return &fileApi.FileDownloadResponse{ + FileHash: "randomHash", + ContentType: "application/yaml", + }, nil + }) req := httptest.NewRequest(http.MethodPut, "/apispecifications/eni--hyperion--eni-distr-v1", bytes.NewReader(apiSpecification)) @@ -177,29 +187,6 @@ servers: ExpectStatusWithBody(responseGroup, err, http.StatusAccepted, "application/json") }) - It("should fail to update a non-existent ApiSpecification", func() { - var apiSpecification, _ = json.Marshal(api.ApiSpecificationCreateRequest{ - Specification: map[string]interface{}{ - "openapi": "3.0.0", - "info": map[string]interface{}{ - "title": "Rover API", - "version": "1.0.0", - "x-api-category": "test", - "x-vendor": "true", - }, - "servers": []map[string]interface{}{ - { - "url": "http://rover-api.com/not/there/v1", - }, - }, - }, - }) - req := httptest.NewRequest(http.MethodPut, "/apispecifications/eni--hyperion--not-there-v1", - bytes.NewReader(apiSpecification)) - responseGroup, err := ExecuteRequest(req, groupToken) - ExpectStatusWithBody(responseGroup, err, http.StatusNotFound, "application/problem+json") - }) - It("should fail to update an ApiSpecification from a different team", func() { var apiSpecification, _ = json.Marshal(api.ApiSpecificationCreateRequest{ Specification: map[string]interface{}{ diff --git a/rover-server/internal/controller/eventspecification.go b/rover-server/internal/controller/eventspecification.go index 44a81d14..bf91601b 100644 --- a/rover-server/internal/controller/eventspecification.go +++ b/rover-server/internal/controller/eventspecification.go @@ -5,27 +5,45 @@ package controller import ( + "bytes" "context" + "encoding/json" + "io" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/log" + "github.com/pkg/errors" + "github.com/telekom/controlplane/common-server/pkg/problems" + "github.com/telekom/controlplane/common-server/pkg/store" + filesapi "github.com/telekom/controlplane/file-manager/api" + "github.com/telekom/controlplane/rover-server/internal/file" + roverv1 "github.com/telekom/controlplane/rover/api/v1" "github.com/telekom/controlplane/rover-server/internal/api" + "github.com/telekom/controlplane/rover-server/internal/mapper" + "github.com/telekom/controlplane/rover-server/internal/mapper/eventspecification/in" + "github.com/telekom/controlplane/rover-server/internal/mapper/eventspecification/out" + "github.com/telekom/controlplane/rover-server/internal/mapper/status" "github.com/telekom/controlplane/rover-server/internal/server" + s "github.com/telekom/controlplane/rover-server/pkg/store" + + cconfig "github.com/telekom/controlplane/common/pkg/config" ) var _ server.EventSpecificationController = &EventSpecificationController{} -type EventSpecificationController struct{} +type EventSpecificationController struct { + Store store.ObjectStore[*roverv1.EventSpecification] +} func NewEventSpecificationController() *EventSpecificationController { - return &EventSpecificationController{} + return &EventSpecificationController{ + Store: s.EventSpecificationStore, + } } -// TODO EventSpecificationController: Implement the EventSpecificationController interface. -// Currently this is not in focus of the development, but it is already included in the openapi specification. - // Create implements server.EventSpecificationController. +// This is a declarative API — clients should use PUT (Update) instead. func (e *EventSpecificationController) Create(ctx context.Context, req api.EventSpecificationCreateRequest) (api.EventSpecificationResponse, error) { log.Infof("EventSpecification: Create not implemented. EventSpecification is: %+v", req) return api.EventSpecificationResponse{}, @@ -34,34 +52,190 @@ func (e *EventSpecificationController) Create(ctx context.Context, req api.Event // Delete implements server.EventSpecificationController. func (e *EventSpecificationController) Delete(ctx context.Context, resourceId string) error { - log.Infof("EventSpecification: Delete not implemented. ResourceId is: %s.", resourceId) - return fiber.NewError(fiber.StatusNotImplemented, "Delete not implemented") + id, err := mapper.ParseResourceId(ctx, resourceId) + if err != nil { + return err + } + + if cconfig.FeatureFileManager.IsEnabled() { + // Delete the optional specification file from file-manager + fileId := generateFileId(id) + err = file.GetFileManager().DeleteFile(ctx, fileId) + if err != nil { + if !errors.Is(err, file.ErrNotFound) { + return err + } + // File not found is acceptable — specification is optional + } + } + + ns := id.Environment + "--" + id.Namespace + err = e.Store.Delete(ctx, ns, id.Name) + if err != nil { + if problems.IsNotFound(err) { + return problems.NotFound(resourceId) + } + return err + } + return nil } // Get implements server.EventSpecificationController. -func (e *EventSpecificationController) Get(ctx context.Context, resourceId string) (api.EventSpecificationResponse, error) { - log.Infof("EventSpecification: Get not implemented. ResourceId is: %s.", resourceId) - return api.EventSpecificationResponse{}, - fiber.NewError(fiber.StatusNotImplemented, "Get not implemented") +func (e *EventSpecificationController) Get(ctx context.Context, resourceId string) (res api.EventSpecificationResponse, err error) { + id, err := mapper.ParseResourceId(ctx, resourceId) + if err != nil { + return res, err + } + + ns := id.Environment + "--" + id.Namespace + eventSpec, err := e.Store.Get(ctx, ns, id.Name) + if err != nil { + if problems.IsNotFound(err) { + return res, problems.NotFound(resourceId) + } + return res, err + } + + var specContent map[string]any + specContent, err = e.downloadSpecification(ctx, eventSpec.Spec.Specification) + if err != nil { + return res, err + } + + return out.MapResponse(eventSpec, specContent) } // GetAll implements server.EventSpecificationController. func (e *EventSpecificationController) GetAll(ctx context.Context, params api.GetAllEventSpecificationsParams) (*api.EventSpecificationListResponse, error) { - log.Info("EventSpecification: GetAll not implemented") - return nil, fiber.NewError(fiber.StatusNotImplemented, "GetAll not implemented") + listOpts := store.NewListOpts() + listOpts.Cursor = params.Cursor + + objList, err := e.Store.List(ctx, listOpts) + if err != nil { + return nil, err + } + + list := make([]api.EventSpecificationResponse, 0, len(objList.Items)) + for _, eventSpec := range objList.Items { + specContent, err := e.downloadSpecification(ctx, eventSpec.Spec.Specification) + if err != nil { + return nil, problems.InternalServerError("Failed to download resource", err.Error()) + } + + resp, err := out.MapResponse(eventSpec, specContent) + if err != nil { + return nil, problems.InternalServerError("Failed to map resource", err.Error()) + } + list = append(list, resp) + } + + return &api.EventSpecificationListResponse{ + UnderscoreLinks: api.Links{ + Next: objList.Links.Next, + Self: objList.Links.Self, + }, + Items: list, + }, nil +} + +// Update implements server.EventSpecificationController. +func (e *EventSpecificationController) Update(ctx context.Context, resourceId string, req api.EventSpecification) (res api.EventSpecificationResponse, err error) { + id, err := mapper.ParseResourceId(ctx, resourceId) + if err != nil { + return res, err + } + + // Handle the optional specification payload + var specOrFileId string + if req.Specification != nil && len(req.Specification) > 0 { + specMarshaled, marshalErr := json.Marshal(req.Specification) + if marshalErr != nil { + return res, problems.BadRequest(marshalErr.Error()) + } + + uploadRes, err := e.uploadFile(ctx, specMarshaled, id) + if err != nil { + return res, err + } + if uploadRes != nil { + specOrFileId = uploadRes.FileId + } + } + + eventSpec, err := in.MapRequest(req, specOrFileId, id) + if err != nil { + return res, problems.BadRequest(err.Error()) + } + EnsureLabelsOrDie(ctx, eventSpec) + + err = e.Store.CreateOrReplace(ctx, eventSpec) + if err != nil { + return res, err + } + + return e.Get(ctx, resourceId) } // GetStatus implements server.EventSpecificationController. -func (e *EventSpecificationController) GetStatus(ctx context.Context, resourceId string) (api.ResourceStatusResponse, error) { - log.Infof("EventSpecification: GetStatus not implemented. ResourceId is: %s.", resourceId) - return api.ResourceStatusResponse{}, - fiber.NewError(fiber.StatusNotImplemented, "GetStatus not implemented") +func (e *EventSpecificationController) GetStatus(ctx context.Context, resourceId string) (res api.ResourceStatusResponse, err error) { + id, err := mapper.ParseResourceId(ctx, resourceId) + if err != nil { + return res, err + } + ns := id.Environment + "--" + id.Namespace + eventSpec, err := e.Store.Get(ctx, ns, id.Name) + if err != nil { + if problems.IsNotFound(err) { + return res, problems.NotFound(resourceId) + } + return res, err + } + + return status.MapResponse(ctx, eventSpec) } -// Update implements server.EventSpecificationController. -func (e *EventSpecificationController) Update(ctx context.Context, resourceId string, req api.EventSpecification) (api.EventSpecificationResponse, error) { - log.Infof("EventSpecification: Update not implemented. ResourceId is: %s. EventSpecification is: %+v", resourceId, req) - return api.EventSpecificationResponse{}, - fiber.NewError(fiber.StatusNotImplemented, "Update not implemented") +func (e *EventSpecificationController) uploadFile(ctx context.Context, specMarshaled []byte, id mapper.ResourceIdInfo) (res *filesapi.FileUploadResponse, err error) { + if !cconfig.FeatureFileManager.IsEnabled() { + return nil, nil + } + + fileId := generateFileId(id) + fileContentType := "application/json" + return file.GetFileManager().UploadFile(ctx, fileId, fileContentType, bytes.NewReader(specMarshaled)) +} + +// downloadSpecification retrieves the optional specification file content. +// Returns nil if no specification is stored (fileId is empty). +func (e *EventSpecificationController) downloadSpecification(ctx context.Context, fileId string) (map[string]any, error) { + if !cconfig.FeatureFileManager.IsEnabled() { + return nil, nil + } + + if fileId == "" { + return nil, nil + } + + var b bytes.Buffer + _, err := file.GetFileManager().DownloadFile(ctx, fileId, &b) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(&b) + if err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, nil + } + + m := make(map[string]any) + err = json.Unmarshal(data, &m) + if err != nil { + return nil, err + } + + return m, nil } diff --git a/rover-server/internal/controller/eventspecification_test.go b/rover-server/internal/controller/eventspecification_test.go index b181f740..6b06c3e5 100644 --- a/rover-server/internal/controller/eventspecification_test.go +++ b/rover-server/internal/controller/eventspecification_test.go @@ -6,44 +6,129 @@ package controller import ( "bytes" + "context" "encoding/json" + "io" "net/http" "net/http/httptest" . "github.com/onsi/ginkgo/v2" - + "github.com/stretchr/testify/mock" + fileApi "github.com/telekom/controlplane/file-manager/api" "github.com/telekom/controlplane/rover-server/internal/api" ) +// TODO: fix the unit-tests. Use Once() or Twice() for mocks + var _ = Describe("EventSpecification Controller", func() { - Context("GetAll EventSpecifications", func() { - It("should return StatusNotImplemented", func() { + + specJson := `{"type":"object","properties":{"id":{"type":"string"}}}` + + Context("Get EventSpecification resource", func() { + It("should return the EventSpecification successfully", func() { + mockFileManager.EXPECT().DownloadFile(mock.Anything, "eventRandomId", mock.Anything). + RunAndReturn(func(_ context.Context, _ string, w io.Writer) (*fileApi.FileDownloadResponse, error) { + + w.Write([]byte(specJson)) + + return &fileApi.FileDownloadResponse{ + FileHash: "randomHash", + ContentType: "application/json", + }, nil + }) + req := httptest.NewRequest(http.MethodGet, "/eventspecifications/eni--hyperion--tardis-horizon-demo-cetus-v1", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusOK, "application/json") + }) + + It("should fail to get a non-existent EventSpecification", func() { + req := httptest.NewRequest(http.MethodGet, "/eventspecifications/eni--hyperion--blabla", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusNotFound, "application/problem+json") + }) + + It("should fail to get an EventSpecification from a different team", func() { + req := httptest.NewRequest(http.MethodGet, "/eventspecifications/other--team--tardis-horizon-demo-cetus-v1", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusForbidden, "application/problem+json") + }) + }) + + Context("GetAll EventSpecifications resource", func() { + It("should return all EventSpecifications successfully", func() { + mockFileManager.EXPECT().DownloadFile(mock.Anything, "eventRandomId", mock.Anything). + RunAndReturn(func(_ context.Context, _ string, w io.Writer) (*fileApi.FileDownloadResponse, error) { + + w.Write([]byte(specJson)) + + return &fileApi.FileDownloadResponse{ + FileHash: "randomHash", + ContentType: "application/json", + }, nil + }) + req := httptest.NewRequest(http.MethodGet, "/eventspecifications", nil) - ExpectStatusNotImplemented(ExecuteRequest(req, groupToken)) - ExpectStatusNotImplemented(ExecuteRequest(req, teamToken)) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusOK, "application/json") + + responseTeam, err := ExecuteRequest(req, teamToken) + ExpectStatusWithBody(responseTeam, err, http.StatusOK, "application/json") + }) + + It("should return an empty list if no EventSpecifications exist", func() { + req := httptest.NewRequest(http.MethodGet, "/eventspecifications", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusOK, "application/json") + + responseTeam, err := ExecuteRequest(req, teamToken) + ExpectStatusWithBody(responseTeam, err, http.StatusOK, "application/json") }) }) - Context("Get EventSpecification resource", func() { - It("should return StatusNotImplemented", func() { - req := httptest.NewRequest(http.MethodGet, "/eventspecifications/eni--hyperion--horizon-local-sub", nil) - ExpectStatusNotImplemented(ExecuteRequest(req, groupToken)) + + Context("Delete EventSpecification resource", func() { + It("should delete the EventSpecification successfully", func() { + mockFileManager.EXPECT().DeleteFile(mock.Anything, mock.Anything).Return(nil) + req := httptest.NewRequest(http.MethodDelete, "/eventspecifications/eni--hyperion--tardis-horizon-demo-cetus-v1", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatus(responseGroup, err, http.StatusNoContent, "") + }) + + It("should fail to delete a non-existent EventSpecification", func() { + mockFileManager.EXPECT().DeleteFile(mock.Anything, mock.Anything).Return(nil) + req := httptest.NewRequest(http.MethodDelete, "/eventspecifications/eni--hyperion--blabla", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusNotFound, "application/problem+json") + }) + + It("should fail to delete an EventSpecification from a different team", func() { + req := httptest.NewRequest(http.MethodDelete, "/eventspecifications/other--team--tardis-horizon-demo-cetus-v1", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusForbidden, "application/problem+json") }) }) + Context("GetStatus EventSpecification resource", func() { - It("should return StatusNotImplemented", func() { - req := httptest.NewRequest(http.MethodGet, "/eventspecifications/eni--hyperion--horizon-local-sub/status", nil) - ExpectStatusNotImplemented(ExecuteRequest(req, groupToken)) + It("should return the status of the EventSpecification successfully", func() { + req := httptest.NewRequest(http.MethodGet, "/eventspecifications/eni--hyperion--tardis-horizon-demo-cetus-v1/status", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusOK, "application/json") }) - }) - Context("Delete EventSpecification resource", func() { - It("should return StatusNotImplemented", func() { - req := httptest.NewRequest(http.MethodDelete, "/eventspecifications/eni--hyperion--horizon-local-sub", nil) - ExpectStatusNotImplemented(ExecuteRequest(req, groupToken)) + + It("should fail to get the status of a non-existent EventSpecification", func() { + req := httptest.NewRequest(http.MethodGet, "/eventspecifications/eni--hyperion--blabla/status", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusNotFound, "application/problem+json") + }) + + It("should fail to get the status of an EventSpecification from a different team", func() { + req := httptest.NewRequest(http.MethodGet, "/eventspecifications/other--team--tardis-horizon-demo-cetus-v1/status", nil) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusForbidden, "application/problem+json") }) }) + Context("Create EventSpecification resource", func() { It("should return StatusNotImplemented", func() { - // Create a request with a JSON body var eventSpecification, _ = json.Marshal(api.EventSpecificationCreateRequest{ Category: "SYSTEM", Description: "Horizon demo provider", @@ -55,17 +140,49 @@ var _ = Describe("EventSpecification Controller", func() { ExpectStatusNotImplemented(ExecuteRequest(req, teamToken)) }) }) + Context("Update EventSpecification resource", func() { - It("should return StatusNotImplemented", func() { - // Create a request with a JSON body + It("should update the EventSpecification successfully", func() { var eventSpecification, _ = json.Marshal(api.EventSpecification{ Category: "SYSTEM", Description: "Horizon demo provider", Type: "tardis.horizon.demo.cetus.v1", Version: "1.0.0", + Specification: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + }, + }, + }, }) - req := httptest.NewRequest(http.MethodPut, "/eventspecifications/eni--hyperion--horizon-local-sub", bytes.NewReader(eventSpecification)) - ExpectStatusNotImplemented(ExecuteRequest(req, groupToken)) + + mockFileManager.EXPECT().UploadFile(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &fileApi.FileUploadResponse{ + FileHash: "randomHash", + FileId: "randomId", + ContentType: "application/json", + }, nil) + + req := httptest.NewRequest(http.MethodPut, "/eventspecifications/eni--hyperion--tardis-horizon-demo-cetus-v1", + bytes.NewReader(eventSpecification)) + + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusAccepted, "application/json") + }) + + It("should fail to update an EventSpecification from a different team", func() { + var eventSpecification, _ = json.Marshal(api.EventSpecification{ + Category: "SYSTEM", + Description: "Horizon demo provider", + Type: "tardis.horizon.demo.other.v1", + Version: "1.0.0", + }) + req := httptest.NewRequest(http.MethodPut, "/eventspecifications/other--team--tardis-horizon-demo-other-v1", + bytes.NewReader(eventSpecification)) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusForbidden, "application/problem+json") }) }) }) diff --git a/rover-server/internal/controller/rover.go b/rover-server/internal/controller/rover.go index ba36b8c6..56ba2c8c 100644 --- a/rover-server/internal/controller/rover.go +++ b/rover-server/internal/controller/rover.go @@ -6,6 +6,7 @@ package controller import ( "context" + "fmt" "github.com/go-logr/logr" "github.com/gofiber/fiber/v2" @@ -129,6 +130,10 @@ func (r *RoverController) Update(ctx context.Context, resourceId string, req api EnsureLabelsOrDie(ctx, obj) obj.Labels[config.BuildLabelKey("application")] = id.Name + if err := r.guardPubSubFeature(ctx, req, config.FeaturePubSub.IsEnabled()); err != nil { + return res, err + } + err = r.Store.CreateOrReplace(ctx, obj) if err != nil { return res, err @@ -153,7 +158,7 @@ func (r *RoverController) GetStatus(ctx context.Context, resourceId string) (res return res, err } - return status.MapRoverResponse(ctx, rover) + return status.MapResponse(ctx, rover) } // GetApplicationInfo implements server.RoverController. @@ -263,3 +268,44 @@ func (r *RoverController) ResetRoverSecret(ctx context.Context, resourceId strin }, nil } + +func (r *RoverController) guardPubSubFeature(ctx context.Context, res api.RoverUpdateRequest, isEnabled bool) problems.Problem { + if isEnabled { + return nil + } + + fields := []problems.Field{} + + for i, e := range res.Exposures { + d, err := e.Discriminator() + if err != nil { + continue + } + if d == "event" { + fields = append(fields, problems.Field{ + Field: fmt.Sprintf("exposures[%d]", i), + Detail: "Pub/Sub features are not enabled, but the request contains an event exposure", + }) + } + } + + for i, s := range res.Subscriptions { + d, err := s.Discriminator() + if err != nil { + continue + } + if d == "event" { + fields = append(fields, problems.Field{ + Field: fmt.Sprintf("exposures[%d]", i), + Detail: "Pub/Sub features are not enabled, but the request contains an event exposure", + }) + } + } + + if len(fields) > 0 { + msg := "The request contains Pub/Sub features, but this feature is not enabled on the server." + return problems.Builder().Detail(msg).Title("Feature has not been enabled").Status(400).Fields(fields...).Build() + } + + return nil +} diff --git a/rover-server/internal/controller/suite_controller_test.go b/rover-server/internal/controller/suite_controller_test.go index efe0c9ae..93a1602a 100644 --- a/rover-server/internal/controller/suite_controller_test.go +++ b/rover-server/internal/controller/suite_controller_test.go @@ -17,12 +17,15 @@ import ( . "github.com/onsi/gomega" cserver "github.com/telekom/controlplane/common-server/pkg/server" securitymock "github.com/telekom/controlplane/common-server/pkg/server/middleware/security/mock" + cstore "github.com/telekom/controlplane/common-server/pkg/store" "github.com/telekom/controlplane/file-manager/api" filefake "github.com/telekom/controlplane/file-manager/api/fake" "github.com/telekom/controlplane/rover-server/internal/file" "k8s.io/client-go/rest" kconfig "sigs.k8s.io/controller-runtime/pkg/client/config" + "github.com/stretchr/testify/mock" + eventv1 "github.com/telekom/controlplane/event/api/v1" "github.com/telekom/controlplane/rover-server/internal/config" "github.com/telekom/controlplane/rover-server/internal/server" "github.com/telekom/controlplane/rover-server/pkg/log" @@ -38,6 +41,7 @@ var ctx context.Context var cancel context.CancelFunc var teamToken string var groupToken string +var teamNoResources string var app *fiber.App var mockFileManager *filefake.MockFileManager @@ -56,6 +60,22 @@ var InitOrDie = func(ctx context.Context, cfg *rest.Config) { store.ApplicationStore = mocks.NewApplicationStoreMock(GinkgoT()) store.ApplicationSecretStore = store.ApplicationStore store.ZoneStore = mocks.NewZoneStoreMock(GinkgoT()) + store.EventSpecificationStore = mocks.NewEventSpecificationStoreMock(GinkgoT()) + + eventExposureMock := mocks.NewMockObjectStore[*eventv1.EventExposure](GinkgoT()) + eventExposureMock.EXPECT().List(mock.Anything, mock.Anything).Return( + &cstore.ListResponse[*eventv1.EventExposure]{Items: []*eventv1.EventExposure{}}, nil).Maybe() + store.EventExposureStore = eventExposureMock + + eventSubscriptionMock := mocks.NewMockObjectStore[*eventv1.EventSubscription](GinkgoT()) + eventSubscriptionMock.EXPECT().List(mock.Anything, mock.Anything).Return( + &cstore.ListResponse[*eventv1.EventSubscription]{Items: []*eventv1.EventSubscription{}}, nil).Maybe() + store.EventSubscriptionStore = eventSubscriptionMock + + eventConfigMock := mocks.NewMockObjectStore[*eventv1.EventConfig](GinkgoT()) + eventConfigMock.EXPECT().List(mock.Anything, mock.Anything).Return( + &cstore.ListResponse[*eventv1.EventConfig]{Items: []*eventv1.EventConfig{}}, nil).Maybe() + store.EventConfigStore = eventConfigMock } mockFileManager = filefake.NewMockFileManager(GinkgoT()) @@ -78,6 +98,7 @@ var _ = BeforeSuite(func() { // Can be done once the issue with the team token is fixed in common-server teamToken = securitymock.NewMockAccessToken("poc", "eni", "hyperion", []string{"tardis:team:all"}) groupToken = securitymock.NewMockAccessToken("poc", "eni", "hyperion", []string{"tardis:group:all"}) + teamNoResources = securitymock.NewMockAccessToken("poc", "eni", "nohyper", []string{"tardis:team:all"}) // Create a new Fiber app app = cserver.NewApp() diff --git a/rover-server/internal/mapper/applicationinfo/__snapshots__/out_test.snap b/rover-server/internal/mapper/applicationinfo/__snapshots__/out_test.snap index e62f6188..91342cb6 100755 --- a/rover-server/internal/mapper/applicationinfo/__snapshots__/out_test.snap +++ b/rover-server/internal/mapper/applicationinfo/__snapshots__/out_test.snap @@ -70,7 +70,6 @@ "irisTokenEndpointUrl": "https://iris-distcp1-dataplane1.dev.dhei.telekom.de/auth/realms/poc/protocol/openid-connect/token", "name": "", "stargateIssuerUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de:443/auth/realms/poc", - "stargatePublishEventUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/horizon/events/v1", "stargateUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/", "status": "blocked", "subscriptions": null, @@ -110,7 +109,6 @@ "irisTokenEndpointUrl": "https://iris-distcp1-dataplane1.dev.dhei.telekom.de/auth/realms/poc/protocol/openid-connect/token", "name": "rover-local-sub", "stargateIssuerUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de:443/auth/realms/poc", - "stargatePublishEventUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/horizon/events/v1", "stargateUrl": "https://stargate-distcp1-dataplane1.dev.dhei.telekom.de/", "status": "blocked", "subscriptions": [ diff --git a/rover-server/internal/mapper/applicationinfo/out.go b/rover-server/internal/mapper/applicationinfo/out.go index bf6df22d..4bcc0f59 100644 --- a/rover-server/internal/mapper/applicationinfo/out.go +++ b/rover-server/internal/mapper/applicationinfo/out.go @@ -6,11 +6,14 @@ package applicationinfo import ( "context" + "strings" "github.com/pkg/errors" + "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common/pkg/condition" "github.com/telekom/controlplane/common/pkg/config" "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" roverv1 "github.com/telekom/controlplane/rover/api/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -80,8 +83,7 @@ func FillApplicationInfo(ctx context.Context, rover *roverv1.Rover, appInfo *api return errors.Wrap(err, "failed to get application") } - zoneStore := store.ZoneStore - zone, err := zoneStore.Get(ctx, rover.Labels[config.EnvironmentLabelKey], rover.Spec.Zone) + zone, err := store.ZoneStore.Get(ctx, rover.Labels[config.EnvironmentLabelKey], rover.Spec.Zone) if err != nil { if zone != nil { WriteStatus(zone, appInfo, err) @@ -96,7 +98,6 @@ func FillApplicationInfo(ctx context.Context, rover *roverv1.Rover, appInfo *api appInfo.StargateIssuerUrl = zone.Status.Links.LmsIssuer appInfo.StargateUrl = zone.Status.Links.Url - appInfo.StargatePublishEventUrl = zone.Status.Links.Url + HorizonPublishEventPathSuffix appInfo.Status = status.GetOverallStatus(rover.Status.Conditions) return nil @@ -111,9 +112,13 @@ func FillSubscriptionInfo(ctx context.Context, rover *roverv1.Rover, appInfo *ap } apiSubStore := store.ApiSubscriptionStore + eventSubStore := store.EventSubscriptionStore - appInfo.Subscriptions = make([]api.SubscriptionInfo, len(rover.Status.ApiSubscriptions)) - for i, sub := range rover.Status.ApiSubscriptions { + totalSubs := len(rover.Status.ApiSubscriptions) + len(rover.Status.EventSubscriptions) + appInfo.Subscriptions = make([]api.SubscriptionInfo, 0, totalSubs) + + // Map API subscriptions + for _, sub := range rover.Status.ApiSubscriptions { apiSub, err := apiSubStore.Get(ctx, sub.Namespace, sub.Name) if err != nil { WriteStatus(apiSub, appInfo, err) @@ -132,7 +137,28 @@ func FillSubscriptionInfo(ctx context.Context, rover *roverv1.Rover, appInfo *ap return errors.Wrap(err, "failed to convert api subscription info") } - appInfo.Subscriptions[i] = subInfo + appInfo.Subscriptions = append(appInfo.Subscriptions, subInfo) + } + + // Map event subscriptions + for _, sub := range rover.Status.EventSubscriptions { + eventSub, err := eventSubStore.Get(ctx, sub.Namespace, sub.Name) + if err != nil { + WriteStatus(eventSub, appInfo, err) + continue + } + + if err := condition.EnsureReady(eventSub); err != nil { + WriteStatus(eventSub, appInfo, err) + } + + subInfo := api.SubscriptionInfo{} + eventSubInfo := mapEventSubscriptionInfo(eventSub) + if err := subInfo.FromEventSubscriptionInfo(eventSubInfo); err != nil { + return errors.Wrap(err, "failed to convert event subscription info") + } + + appInfo.Subscriptions = append(appInfo.Subscriptions, subInfo) } return nil @@ -147,9 +173,13 @@ func FillExposureInfo(ctx context.Context, rover *roverv1.Rover, appInfo *api.Ap } apiExpStore := store.ApiExposureStore + eventExpStore := store.EventExposureStore + + totalExps := len(rover.Status.ApiExposures) + len(rover.Status.EventExposures) + appInfo.Exposures = make([]api.ExposureInfo, 0, totalExps) - appInfo.Exposures = make([]api.ExposureInfo, len(rover.Status.ApiExposures)) - for i, exp := range rover.Status.ApiExposures { + // Map API exposures + for _, exp := range rover.Status.ApiExposures { apiExp, err := apiExpStore.Get(ctx, exp.Namespace, exp.Name) if err != nil { WriteStatus(apiExp, appInfo, err) @@ -171,8 +201,96 @@ func FillExposureInfo(ctx context.Context, rover *roverv1.Rover, appInfo *api.Ap return errors.Wrap(err, "failed to convert api exposure info") } - appInfo.Exposures[i] = expInfo + appInfo.Exposures = append(appInfo.Exposures, expInfo) + } + + // Map event exposures + for _, exp := range rover.Status.EventExposures { + eventExp, err := eventExpStore.Get(ctx, exp.Namespace, exp.Name) + if err != nil { + WriteStatus(eventExp, appInfo, err) + continue + } + + if err := condition.EnsureReady(eventExp); err != nil { + WriteStatus(eventExp, appInfo, err) + } + + expInfo := api.ExposureInfo{} + eventExpInfo := mapEventExposureInfo(eventExp) + if err := expInfo.FromEventExposureInfo(eventExpInfo); err != nil { + return errors.Wrap(err, "failed to convert event exposure info") + } + + appInfo.Exposures = append(appInfo.Exposures, expInfo) + } + + // Fill the Publish Event URL if there are event exposures + bCtx, ok := security.FromContext(ctx) + if len(rover.Status.EventExposures) > 0 && ok { + zone, err := store.ZoneStore.Get(ctx, bCtx.Environment, rover.Spec.Zone) + if err != nil { + return errors.Wrap(err, "failed to get zone") + } + + if appInfo.StargatePublishEventUrl == "" { + eventCfg, err := store.EventConfigStore.Get(ctx, zone.Status.Namespace, bCtx.Environment) + if err != nil { + return errors.Wrap(err, "failed to get event config") + } + appInfo.StargatePublishEventUrl = eventCfg.Status.PublishURL + } } return nil } + +// mapEventSubscriptionInfo maps an event domain EventSubscription to the API's EventSubscriptionInfo. +// Only core identifying fields are mapped, matching the pattern of the API subscription info mapper. +func mapEventSubscriptionInfo(in *eventv1.EventSubscription) api.EventSubscriptionInfo { + return api.EventSubscriptionInfo{ + EventType: in.Spec.EventType, + DeliveryType: string(in.Spec.Delivery.Type), + PayloadType: string(in.Spec.Delivery.Payload), + Type: "event", + } +} + +// mapEventExposureInfo maps an event domain EventExposure to the API's EventExposureInfo. +// Only core identifying fields are mapped, matching the pattern of the API exposure info mapper. +func mapEventExposureInfo(in *eventv1.EventExposure) api.EventExposureInfo { + return api.EventExposureInfo{ + EventType: in.Spec.EventType, + Visibility: toApiVisibilityFromEvent(in.Spec.Visibility), + Approval: toApiApprovalStrategyFromEvent(in.Spec.Approval.Strategy), + Type: "event", + } +} + +// toApiVisibilityFromEvent converts event domain Visibility to API Visibility. +func toApiVisibilityFromEvent(visibility eventv1.Visibility) api.Visibility { + switch visibility { + case eventv1.VisibilityWorld: + return api.WORLD + case eventv1.VisibilityZone: + return api.ZONE + case eventv1.VisibilityEnterprise: + return api.ENTERPRISE + default: + return api.Visibility(strings.ToUpper(string(visibility))) + } +} + +// toApiApprovalStrategyFromEvent converts event domain ApprovalStrategy to API ApprovalStrategy. +func toApiApprovalStrategyFromEvent(strategy eventv1.ApprovalStrategy) api.ApprovalStrategy { + switch strategy { + case eventv1.ApprovalStrategyAuto: + return api.AUTO + case eventv1.ApprovalStrategySimple: + return api.SIMPLE + case eventv1.ApprovalStrategyFourEyes: + return api.FOUREYES + default: + return api.ApprovalStrategy(strings.ToUpper(string(strategy))) + } +} diff --git a/rover-server/internal/mapper/eventspecification/in/__snapshots__/eventspecification_test.snap b/rover-server/internal/mapper/eventspecification/in/__snapshots__/eventspecification_test.snap new file mode 100755 index 00000000..241def59 --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/in/__snapshots__/eventspecification_test.snap @@ -0,0 +1,25 @@ + +[EventSpecification Mapper MapRequest must map an EventSpecification to a CRD correctly - 1] +&v1.EventSpecification{ + TypeMeta: v1.TypeMeta{Kind:"EventSpecification", APIVersion:"rover.cp.ei.telekom.de/v1"}, + ObjectMeta: v1.ObjectMeta{ + Name: "tardis-horizon-demo-cetus-v1", + GenerateName: "", + Namespace: "poc--eni--hyperion", + SelfLink: "", + UID: "", + ResourceVersion: "", + Generation: 0, + CreationTimestamp: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + DeletionTimestamp: (*v1.Time)(nil), + DeletionGracePeriodSeconds: (*int64)(nil), + Labels: {"cp.ei.telekom.de/environment":"poc"}, + Annotations: {}, + OwnerReferences: nil, + Finalizers: nil, + ManagedFields: nil, + }, + Spec: v1.EventSpecificationSpec{Type:"tardis.horizon.demo.cetus.v1", Version:"1.0.0", Description:"Horizon demo provider", Specification:"test-file-id"}, + Status: v1.EventSpecificationStatus{}, +} +--- diff --git a/rover-server/internal/mapper/eventspecification/in/eventspecification.go b/rover-server/internal/mapper/eventspecification/in/eventspecification.go new file mode 100644 index 00000000..06d74827 --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/in/eventspecification.go @@ -0,0 +1,49 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package in + +import ( + "strings" + + "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/config" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/telekom/controlplane/rover-server/internal/api" + "github.com/telekom/controlplane/rover-server/internal/mapper" +) + +// MapRequest maps an API EventSpecification request to the CRD representation. +// It sets the TypeMeta, name (derived from the event type), namespace, labels, +// and the file-manager reference for the optional specification payload. +func MapRequest(req api.EventSpecification, specOrFileId string, id mapper.ResourceIdInfo) (*roverv1.EventSpecification, error) { + eventSpec := &roverv1.EventSpecification{} + + eventSpec.TypeMeta = metav1.TypeMeta{ + Kind: "EventSpecification", + APIVersion: "rover.cp.ei.telekom.de/v1", + } + + eventSpec.Spec.Type = req.Type + eventSpec.Spec.Version = req.Version + eventSpec.Spec.Description = req.Description + + // Derive the resource name from the event type (dots → hyphens) + eventSpec.Name = strings.ToLower(strings.ReplaceAll(req.Type, ".", "-")) + + if eventSpec.Name != id.Name { + return nil, errors.Errorf("event specification name %q does not match expected name %q", eventSpec.Name, id.Name) + } + + eventSpec.Namespace = id.Environment + "--" + id.Namespace + eventSpec.Labels = map[string]string{ + config.EnvironmentLabelKey: id.Environment, + } + + eventSpec.Spec.Specification = specOrFileId + + return eventSpec, nil +} diff --git a/rover-server/internal/mapper/eventspecification/in/eventspecification_test.go b/rover-server/internal/mapper/eventspecification/in/eventspecification_test.go new file mode 100644 index 00000000..1eb37c63 --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/in/eventspecification_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package in + +import ( + "github.com/gkampitakis/go-snaps/snaps" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/telekom/controlplane/rover-server/internal/mapper" +) + +var _ = Describe("EventSpecification Mapper", func() { + Context("MapRequest", func() { + It("must map an EventSpecification to a CRD correctly", func() { + specOrFileId := "test-file-id" + + result, err := MapRequest(eventSpecification, specOrFileId, resourceIdInfo) + Expect(err).To(BeNil()) + Expect(result).ToNot(BeNil()) + snaps.MatchSnapshot(GinkgoT(), result) + }) + + It("must return an error if the derived name does not match the resource id name", func() { + mismatchedId := mapper.ResourceIdInfo{ + Name: "wrong-name", + Environment: "poc", + Namespace: "eni--hyperion", + } + + result, err := MapRequest(eventSpecification, "test-file-id", mismatchedId) + Expect(result).To(BeNil()) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("does not match expected name")) + }) + }) +}) diff --git a/rover-server/internal/mapper/eventspecification/in/suite_eventspec_in_test.go b/rover-server/internal/mapper/eventspecification/in/suite_eventspec_in_test.go new file mode 100644 index 00000000..9b5d7f68 --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/in/suite_eventspec_in_test.go @@ -0,0 +1,42 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package in + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/telekom/controlplane/rover-server/internal/api" + "github.com/telekom/controlplane/rover-server/internal/mapper" +) + +var ( + eventSpecification = api.EventSpecification{ + Type: "tardis.horizon.demo.cetus.v1", + Version: "1.0.0", + Description: "Horizon demo provider", + Specification: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + }, + }, + }, + } + + resourceIdInfo = mapper.ResourceIdInfo{ + Name: "tardis-horizon-demo-cetus-v1", + Environment: "poc", + Namespace: "eni--hyperion", + } +) + +func TestEventSpecificationMapper(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventSpecification Mapper Suite") +} diff --git a/rover-server/internal/mapper/eventspecification/out/__snapshots__/eventspecification_test.snap b/rover-server/internal/mapper/eventspecification/out/__snapshots__/eventspecification_test.snap new file mode 100755 index 00000000..1c4430b7 --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/out/__snapshots__/eventspecification_test.snap @@ -0,0 +1,38 @@ + +[EventSpecificationResponse Mapper MapResponse must map an EventSpecification CRD to an EventSpecificationResponse correctly - 1] +{ + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "specification": { + "properties": { + "id": { + "type": "string" + } + }, + "type": "object" + }, + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" +} +--- + +[EventSpecificationResponse Mapper MapResponse must return an error if the input EventSpecification CRD is nil - 1] +api.EventSpecificationResponse{} +--- + +[EventSpecificationResponse Mapper MapResponse must omit specification from response when specContent is nil - 1] +{ + "description": "Horizon demo provider", + "id": "eni--hyperion--tardis-horizon-demo-cetus-v1", + "status": { + "processingState": "done", + "state": "complete" + }, + "type": "tardis.horizon.demo.cetus.v1", + "version": "1.0.0" +} +--- diff --git a/rover-server/internal/mapper/eventspecification/out/eventspecification.go b/rover-server/internal/mapper/eventspecification/out/eventspecification.go new file mode 100644 index 00000000..75d892ee --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/out/eventspecification.go @@ -0,0 +1,36 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package out + +import ( + "github.com/pkg/errors" + "github.com/telekom/controlplane/rover-server/internal/api" + "github.com/telekom/controlplane/rover-server/internal/mapper" + "github.com/telekom/controlplane/rover-server/internal/mapper/status" + roverv1 "github.com/telekom/controlplane/rover/api/v1" +) + +// MapResponse maps an EventSpecification CRD and its optional file content +// to the API response type. +func MapResponse(in *roverv1.EventSpecification, specContent map[string]any) (res api.EventSpecificationResponse, err error) { + if in == nil { + return res, errors.New("input event specification crd is nil") + } + + res = api.EventSpecificationResponse{ + Type: in.Spec.Type, + Version: in.Spec.Version, + Description: in.Spec.Description, + Id: mapper.MakeResourceId(in), + } + + if specContent != nil { + res.Specification = specContent + } + + res.Status = status.MapStatus(in.Status.Conditions) + + return +} diff --git a/rover-server/internal/mapper/eventspecification/out/eventspecification_test.go b/rover-server/internal/mapper/eventspecification/out/eventspecification_test.go new file mode 100644 index 00000000..1f2c867f --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/out/eventspecification_test.go @@ -0,0 +1,51 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package out + +import ( + "github.com/gkampitakis/go-snaps/snaps" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("EventSpecificationResponse Mapper", func() { + Context("MapResponse", func() { + It("must map an EventSpecification CRD to an EventSpecificationResponse correctly", func() { + specContent := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + }, + }, + } + + output, err := MapResponse(eventSpecification, specContent) + + Expect(err).To(BeNil()) + Expect(output).ToNot(BeNil()) + snaps.MatchJSON(GinkgoT(), output) + }) + + It("must return an error if the input EventSpecification CRD is nil", func() { + output, err := MapResponse(nil, nil) + + Expect(output).ToNot(BeNil()) + snaps.MatchSnapshot(GinkgoT(), output) + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("input event specification crd is nil")) + }) + + It("must omit specification from response when specContent is nil", func() { + output, err := MapResponse(eventSpecification, nil) + + Expect(err).To(BeNil()) + Expect(output).ToNot(BeNil()) + Expect(output.Specification).To(BeNil()) + snaps.MatchJSON(GinkgoT(), output) + }) + }) +}) diff --git a/rover-server/internal/mapper/eventspecification/out/suite_eventspec_out_test.go b/rover-server/internal/mapper/eventspecification/out/suite_eventspec_out_test.go new file mode 100644 index 00000000..bfbcde4a --- /dev/null +++ b/rover-server/internal/mapper/eventspecification/out/suite_eventspec_out_test.go @@ -0,0 +1,28 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package out + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + + "github.com/telekom/controlplane/rover-server/test/mocks" +) + +var ( + eventSpecification *roverv1.EventSpecification +) + +func TestMapper(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "EventSpecification Out Mapper Suite") +} + +var _ = BeforeSuite(func() { + eventSpecification = mocks.GetEventSpecification(GinkgoT(), mocks.EventSpecificationFileName) +}) diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap index 000960ae..c3491153 100755 --- a/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap +++ b/rover-server/internal/mapper/rover/in/__snapshots__/exposure_test.snap @@ -52,7 +52,16 @@ [Exposure Mapper mapExposure must map an EventExposure correctly - 1] &v1.Exposure{ Api: (*v1.ApiExposure)(nil), - Event: &v1.EventExposure{EventType:"tardis.horizon.demo.cetus.v1"}, + Event: &v1.EventExposure{ + EventType: "tardis.horizon.demo.cetus.v1", + Visibility: "World", + Approval: v1.Approval{ + Strategy: "Simple", + TrustedTeams: nil, + }, + Scopes: nil, + AdditionalPublisherIds: nil, + }, } --- diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap index 8f0056e5..67a3c9d6 100755 --- a/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap +++ b/rover-server/internal/mapper/rover/in/__snapshots__/subscription_test.snap @@ -29,7 +29,12 @@ [Subscription Mapper mapSubscription must map an EventSubscription correctly - 1] &v1.Subscription{ Api: (*v1.ApiSubscription)(nil), - Event: &v1.EventSubscription{EventType:"test-event"}, + Event: &v1.EventSubscription{ + EventType: "test-event", + Delivery: v1.EventDelivery{}, + Trigger: (*v1.EventTrigger)(nil), + Scopes: nil, + }, } --- diff --git a/rover-server/internal/mapper/rover/in/exposure.go b/rover-server/internal/mapper/rover/in/exposure.go index c7a164f3..184577e1 100644 --- a/rover-server/internal/mapper/rover/in/exposure.go +++ b/rover-server/internal/mapper/rover/in/exposure.go @@ -5,12 +5,14 @@ package in import ( + "encoding/json" "strings" "github.com/pkg/errors" roverv1 "github.com/telekom/controlplane/rover/api/v1" "golang.org/x/text/cases" "golang.org/x/text/language" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "github.com/telekom/controlplane/rover-server/internal/api" ) @@ -35,9 +37,7 @@ func mapExposure(in *api.Exposure, out *roverv1.Exposure) error { return errors.Wrap(err, "failed to convert to EventExposure") } - out.Event = &roverv1.EventExposure{ - EventType: eventExp.EventType, - } + out.Event = mapEventExposure(eventExp) default: return errors.Errorf("unknown exposure type: %s", expType) @@ -195,3 +195,76 @@ func mapTrustedTeams(in api.ApiExposure, out *roverv1.ApiExposure) { } } } + +func mapEventExposure(in api.EventExposure) *roverv1.EventExposure { + out := &roverv1.EventExposure{ + EventType: in.EventType, + Visibility: toRoverVisibility(in.Visibility), + Approval: roverv1.Approval{ + Strategy: toRoverApprovalStrategy(in.Approval), + }, + } + + // Map trusted teams + if in.TrustedTeams != nil { + out.Approval.TrustedTeams = make([]roverv1.TrustedTeam, len(in.TrustedTeams)) + for i, team := range in.TrustedTeams { + parts := strings.Split(team.Team, "--") + if len(parts) != 2 { + continue + } + out.Approval.TrustedTeams[i] = roverv1.TrustedTeam{ + Group: parts[0], + Team: parts[1], + } + } + } + + // Map scopes + if in.Scopes != nil { + out.Scopes = make([]roverv1.EventScope, len(in.Scopes)) + for i, scope := range in.Scopes { + out.Scopes[i] = roverv1.EventScope{ + Name: scope.Name, + } + if scope.Trigger.ResponseFilter != nil || scope.Trigger.SelectionFilter != nil || scope.Trigger.AdvancedSelectionFilter != nil { + if t := mapEventTrigger(scope.Trigger); t != nil { + out.Scopes[i].Trigger = *t + } + } + } + } + + // Map additional publisher IDs + if in.AdditionalPublisherIds != nil { + out.AdditionalPublisherIds = in.AdditionalPublisherIds + } + + return out +} + +func mapEventTrigger(in api.EventTrigger) *roverv1.EventTrigger { + out := &roverv1.EventTrigger{} + + if in.ResponseFilter != nil { + out.ResponseFilter = &roverv1.EventResponseFilter{ + Paths: in.ResponseFilter, + Mode: roverv1.EventResponseFilterMode(in.ResponseFilterMode), + } + } + + if in.SelectionFilter != nil || in.AdvancedSelectionFilter != nil { + out.SelectionFilter = &roverv1.EventSelectionFilter{} + if in.SelectionFilter != nil { + out.SelectionFilter.Attributes = in.SelectionFilter + } + if in.AdvancedSelectionFilter != nil { + jsonBytes, err := json.Marshal(in.AdvancedSelectionFilter) + if err == nil { + out.SelectionFilter.Expression = &apiextensionsv1.JSON{Raw: jsonBytes} + } + } + } + + return out +} diff --git a/rover-server/internal/mapper/rover/in/fuzzy_match.go b/rover-server/internal/mapper/rover/in/fuzzy_match.go new file mode 100644 index 00000000..eb86fbc9 --- /dev/null +++ b/rover-server/internal/mapper/rover/in/fuzzy_match.go @@ -0,0 +1,31 @@ +// Copyright 2026 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package in + +import roverv1 "github.com/telekom/controlplane/rover/api/v1" + +// FuzzyMatchEventDeliveryType performs a fuzzy match on the input string to determine the EventDeliveryType. +func FuzzyMatchEventDeliveryType(in string) roverv1.EventDeliveryType { + switch in { + case "callback", "call-back", "call_back", "callBack", "Callback": + return roverv1.EventDeliveryTypeCallback + case "sse", "server-sent-event", "server_sent_event", "ServerSentEvent": + return roverv1.EventDeliveryTypeServerSentEvent + default: + return roverv1.EventDeliveryType(in) + } +} + +// FuzzyMatchEventPayloadType performs a fuzzy match on the input string to determine the EventPayloadType. +func FuzzyMatchEventPayloadType(in string) roverv1.EventPayloadType { + switch in { + case "data", "Data": + return roverv1.EventPayloadTypeData + case "data-ref", "dataref", "data_ref", "DataRef": + return roverv1.EventPayloadTypeDataRef + default: + return roverv1.EventPayloadType(in) + } +} diff --git a/rover-server/internal/mapper/rover/in/subscription.go b/rover-server/internal/mapper/rover/in/subscription.go index 74f8d392..53999347 100644 --- a/rover-server/internal/mapper/rover/in/subscription.go +++ b/rover-server/internal/mapper/rover/in/subscription.go @@ -5,8 +5,11 @@ package in import ( + "encoding/json" + "github.com/pkg/errors" roverv1 "github.com/telekom/controlplane/rover/api/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "github.com/telekom/controlplane/rover-server/internal/api" ) @@ -30,9 +33,7 @@ func mapSubscription(in *api.Subscription, out *roverv1.Subscription) error { return errors.Wrap(err, "failed to convert to EventSubscription") } - out.Event = &roverv1.EventSubscription{ - EventType: eventSub.EventType, - } + out.Event = mapEventSubscription(eventSub) default: return errors.Errorf("unknown subscription type: %s", subType) @@ -110,3 +111,72 @@ func mapSubscriptionTraffic(in api.ApiSubscription, out *roverv1.ApiSubscription } } } + +func mapEventSubscription(in api.EventSubscription) *roverv1.EventSubscription { + out := &roverv1.EventSubscription{ + EventType: in.EventType, + } + + // Map delivery configuration + out.Delivery = roverv1.EventDelivery{ + Type: FuzzyMatchEventDeliveryType(in.DeliveryType), + Payload: FuzzyMatchEventPayloadType(in.PayloadType), + } + if in.Callback != "" { + out.Delivery.Callback = in.Callback + } + if in.EventRetentionTime != "" { + out.Delivery.EventRetentionTime = in.EventRetentionTime + } + if in.CircuitBreakerOptOut { + out.Delivery.CircuitBreakerOptOut = in.CircuitBreakerOptOut + } + if in.RetryableStatusCodes != nil { + out.Delivery.RetryableStatusCodes = in.RetryableStatusCodes + } + if in.RedeliveriesPerSecond != 0 { + redeliveries := in.RedeliveriesPerSecond + out.Delivery.RedeliveriesPerSecond = &redeliveries + } + if in.EnforceGetHttpRequestMethodForHealthCheck { + out.Delivery.EnforceGetHttpRequestMethodForHealthCheck = in.EnforceGetHttpRequestMethodForHealthCheck + } + + // Map trigger + if in.Trigger.ResponseFilter != nil || in.Trigger.SelectionFilter != nil || in.Trigger.AdvancedSelectionFilter != nil { + out.Trigger = mapEventTriggerForSubscription(in.Trigger) + } + + // Map scopes + if in.Scopes != nil { + out.Scopes = in.Scopes + } + + return out +} + +func mapEventTriggerForSubscription(in api.EventTrigger) *roverv1.EventTrigger { + out := &roverv1.EventTrigger{} + + if in.ResponseFilter != nil { + out.ResponseFilter = &roverv1.EventResponseFilter{ + Paths: in.ResponseFilter, + Mode: roverv1.EventResponseFilterMode(in.ResponseFilterMode), + } + } + + if in.SelectionFilter != nil || in.AdvancedSelectionFilter != nil { + out.SelectionFilter = &roverv1.EventSelectionFilter{} + if in.SelectionFilter != nil { + out.SelectionFilter.Attributes = in.SelectionFilter + } + if in.AdvancedSelectionFilter != nil { + jsonBytes, err := json.Marshal(in.AdvancedSelectionFilter) + if err == nil { + out.SelectionFilter.Expression = &apiextensionsv1.JSON{Raw: jsonBytes} + } + } + } + + return out +} diff --git a/rover-server/internal/mapper/rover/out/exposure.go b/rover-server/internal/mapper/rover/out/exposure.go index 9b414c44..733b7714 100644 --- a/rover-server/internal/mapper/rover/out/exposure.go +++ b/rover-server/internal/mapper/rover/out/exposure.go @@ -5,6 +5,7 @@ package out import ( + "encoding/json" "strings" "github.com/pkg/errors" @@ -63,9 +64,64 @@ func mapApiExposure(in *roverv1.ApiExposure) api.ApiExposure { } func mapEventExposure(in *roverv1.EventExposure) api.EventExposure { - return api.EventExposure{ - EventType: in.EventType, + out := api.EventExposure{ + EventType: in.EventType, + Visibility: toApiVisibility(in.Visibility), + Approval: toApiApprovalStrategy(in.Approval.Strategy), + } + + // Map trusted teams + if in.Approval.TrustedTeams != nil { + out.TrustedTeams = make([]api.TrustedTeam, len(in.Approval.TrustedTeams)) + for i, team := range in.Approval.TrustedTeams { + out.TrustedTeams[i] = api.TrustedTeam{ + Team: team.Group + "--" + team.Team, + } + } + } + + // Map scopes + if in.Scopes != nil { + out.Scopes = make([]api.EventScope, len(in.Scopes)) + for i, scope := range in.Scopes { + out.Scopes[i] = api.EventScope{ + Name: scope.Name, + } + if scope.Trigger.ResponseFilter != nil || scope.Trigger.SelectionFilter != nil { + out.Scopes[i].Trigger = mapEventTriggerOut(&scope.Trigger) + } + } + } + + // Map additional publisher IDs + if in.AdditionalPublisherIds != nil { + out.AdditionalPublisherIds = in.AdditionalPublisherIds } + + return out +} + +func mapEventTriggerOut(in *roverv1.EventTrigger) api.EventTrigger { + out := api.EventTrigger{} + + if in.ResponseFilter != nil { + out.ResponseFilter = in.ResponseFilter.Paths + out.ResponseFilterMode = api.EventTriggerResponseFilterMode(in.ResponseFilter.Mode) + } + + if in.SelectionFilter != nil { + if in.SelectionFilter.Attributes != nil { + out.SelectionFilter = in.SelectionFilter.Attributes + } + if in.SelectionFilter.Expression != nil && in.SelectionFilter.Expression.Raw != nil { + var advFilter map[string]any + if err := json.Unmarshal(in.SelectionFilter.Expression.Raw, &advFilter); err == nil { + out.AdvancedSelectionFilter = advFilter + } + } + } + + return out } func toApiVisibility(visibility roverv1.Visibility) api.Visibility { diff --git a/rover-server/internal/mapper/rover/out/subscription.go b/rover-server/internal/mapper/rover/out/subscription.go index 326661e3..0570b507 100644 --- a/rover-server/internal/mapper/rover/out/subscription.go +++ b/rover-server/internal/mapper/rover/out/subscription.go @@ -5,6 +5,7 @@ package out import ( + "encoding/json" "reflect" "github.com/pkg/errors" @@ -31,9 +32,66 @@ func mapSubscription(in *roverv1.Subscription, out *api.Subscription) error { } func mapEventSubscription(in *roverv1.EventSubscription) api.EventSubscription { - return api.EventSubscription{ - EventType: in.EventType, + out := api.EventSubscription{ + EventType: in.EventType, + DeliveryType: string(in.Delivery.Type), + PayloadType: string(in.Delivery.Payload), } + + // Map delivery fields + if in.Delivery.Callback != "" { + out.Callback = in.Delivery.Callback + } + if in.Delivery.EventRetentionTime != "" { + out.EventRetentionTime = in.Delivery.EventRetentionTime + } + if in.Delivery.CircuitBreakerOptOut { + out.CircuitBreakerOptOut = in.Delivery.CircuitBreakerOptOut + } + if in.Delivery.RetryableStatusCodes != nil { + out.RetryableStatusCodes = in.Delivery.RetryableStatusCodes + } + if in.Delivery.RedeliveriesPerSecond != nil { + out.RedeliveriesPerSecond = *in.Delivery.RedeliveriesPerSecond + } + if in.Delivery.EnforceGetHttpRequestMethodForHealthCheck { + out.EnforceGetHttpRequestMethodForHealthCheck = in.Delivery.EnforceGetHttpRequestMethodForHealthCheck + } + + // Map trigger + if in.Trigger != nil { + out.Trigger = mapEventTriggerOutForSubscription(in.Trigger) + } + + // Map scopes + if in.Scopes != nil { + out.Scopes = in.Scopes + } + + return out +} + +func mapEventTriggerOutForSubscription(in *roverv1.EventTrigger) api.EventTrigger { + out := api.EventTrigger{} + + if in.ResponseFilter != nil { + out.ResponseFilter = in.ResponseFilter.Paths + out.ResponseFilterMode = api.EventTriggerResponseFilterMode(in.ResponseFilter.Mode) + } + + if in.SelectionFilter != nil { + if in.SelectionFilter.Attributes != nil { + out.SelectionFilter = in.SelectionFilter.Attributes + } + if in.SelectionFilter.Expression != nil && in.SelectionFilter.Expression.Raw != nil { + var advFilter map[string]any + if err := json.Unmarshal(in.SelectionFilter.Expression.Raw, &advFilter); err == nil { + out.AdvancedSelectionFilter = advFilter + } + } + } + + return out } func mapApiSubscription(in *roverv1.ApiSubscription) api.ApiSubscription { diff --git a/rover-server/internal/mapper/status/__snapshots__/response_test.snap b/rover-server/internal/mapper/status/__snapshots__/response_test.snap new file mode 100755 index 00000000..cb99c4db --- /dev/null +++ b/rover-server/internal/mapper/status/__snapshots__/response_test.snap @@ -0,0 +1,20 @@ + +[Response Mapper MapApiSpecificationResponse must map ApiSpecification response correctly - 1] +{ + "createdAt": "", + "overallStatus": "complete", + "processedAt": "", + "processingState": "done", + "state": "complete" +} +--- + +[Response Mapper MapEventSpecificationResponse must map EventSpecification response correctly - 1] +{ + "createdAt": "", + "overallStatus": "complete", + "processedAt": "", + "processingState": "done", + "state": "complete" +} +--- diff --git a/rover-server/internal/mapper/status/problems.go b/rover-server/internal/mapper/status/problems.go index 321725d6..8148512f 100644 --- a/rover-server/internal/mapper/status/problems.go +++ b/rover-server/internal/mapper/status/problems.go @@ -32,6 +32,12 @@ func GetAllProblems(ctx context.Context, rover *v1.Rover) []api.Problem { messages, _ = getAllProblemsInApplications(ctx, rover) problems = append(problems, messages...) + messages, _ = getAllProblemsInEventExposures(ctx, rover) + problems = append(problems, messages...) + + messages, _ = getAllProblemsInEventSubscriptions(ctx, rover) + problems = append(problems, messages...) + return problems } @@ -73,3 +79,29 @@ func getAllProblemsInApiExposures(ctx context.Context, rover *v1.Rover) ([]api.P func getAllProblemsInApplications(ctx context.Context, rover *v1.Rover) ([]api.Problem, error) { return GetAllProblemsInSubResource(ctx, rover, roverStore.ApplicationStore) } + +// getAllProblemsInEventExposures retrieves all problems in event exposures for a given Rover resource. +// +// Parameters: +// - ctx: The context for the operation. +// - rover: The Rover resource whose event exposure problems are being retrieved. +// +// Returns: +// - []api.Problem: A slice of problems. +// - error: Any error encountered during the retrieval process. +func getAllProblemsInEventExposures(ctx context.Context, rover *v1.Rover) ([]api.Problem, error) { + return GetAllProblemsInSubResource(ctx, rover, roverStore.EventExposureStore) +} + +// getAllProblemsInEventSubscriptions retrieves all problems in event subscriptions for a given Rover resource. +// +// Parameters: +// - ctx: The context for the operation. +// - rover: The Rover resource whose event subscription problems are being retrieved. +// +// Returns: +// - []api.Problem: A slice of problems. +// - error: Any error encountered during the retrieval process. +func getAllProblemsInEventSubscriptions(ctx context.Context, rover *v1.Rover) ([]api.Problem, error) { + return GetAllProblemsInSubResource(ctx, rover, roverStore.EventSubscriptionStore) +} diff --git a/rover-server/internal/mapper/status/resources.go b/rover-server/internal/mapper/status/resources.go index 30313c8b..7eaec7b4 100644 --- a/rover-server/internal/mapper/status/resources.go +++ b/rover-server/internal/mapper/status/resources.go @@ -14,6 +14,7 @@ import ( cstore "github.com/telekom/controlplane/common-server/pkg/store" "github.com/telekom/controlplane/common/pkg/condition" "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" v1 "github.com/telekom/controlplane/rover/api/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -25,7 +26,7 @@ import ( type SubResource interface { types.Object commonStore.Object - *apiv1.ApiSubscription | *apiv1.ApiExposure | *applicationv1.Application + *apiv1.ApiSubscription | *apiv1.ApiExposure | *applicationv1.Application | *eventv1.EventSubscription | *eventv1.EventExposure } // GetAllProblemsInSubResource retrieves all problems in a sub-resource for a given Rover resource. diff --git a/rover-server/internal/mapper/status/response.go b/rover-server/internal/mapper/status/response.go index 9ade0d56..7ae50b86 100644 --- a/rover-server/internal/mapper/status/response.go +++ b/rover-server/internal/mapper/status/response.go @@ -8,7 +8,7 @@ import ( "context" "time" - ghErrors "github.com/pkg/errors" + "github.com/pkg/errors" "github.com/telekom/controlplane/common/pkg/condition" "github.com/telekom/controlplane/common/pkg/types" "github.com/telekom/controlplane/rover-server/internal/api" @@ -18,70 +18,29 @@ import ( // MapResponse maps the status of a generic resource to a ResourceStatusResponse. func MapResponse(ctx context.Context, obj types.Object) (api.ResourceStatusResponse, error) { + if obj == nil { + return api.ResourceStatusResponse{}, errors.New("input object is nil") + } status := MapStatus(obj.GetConditions()) processing := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) - - return api.ResourceStatusResponse{ - CreatedAt: obj.GetCreationTimestamp().Time, - ProcessedAt: processing.LastTransitionTime.Time, - State: status.State, - ProcessingState: status.ProcessingState, - OverallStatus: CalculateOverallStatus(status.State, status.ProcessingState), - }, nil -} - -// MapApiSpecificationResponse maps the status of an ApiSpecification resource to a ResourceStatusResponse. -func MapApiSpecificationResponse(ctx context.Context, apiSpec *v1.ApiSpecification) (api.ResourceStatusResponse, error) { - if apiSpec == nil { - return api.ResourceStatusResponse{}, ghErrors.New("input apiSpec is nil") + var processedAt time.Time + if processing != nil { + processedAt = processing.LastTransitionTime.Time } - status := MapStatus(apiSpec.GetConditions()) - processing := meta.FindStatusCondition(apiSpec.GetConditions(), condition.ConditionTypeProcessing) - return api.ResourceStatusResponse{ - CreatedAt: apiSpec.GetCreationTimestamp().Time, - ProcessedAt: processing.LastTransitionTime.Time, - State: status.State, - ProcessingState: status.ProcessingState, - OverallStatus: CalculateOverallStatus(status.State, status.ProcessingState), - }, nil -} - -// MapRoverResponse maps the status of a Rover resource to a ResourceStatusResponse. -// It retrieves the conditions of the Rover, maps them to a status, and checks for any sub-resource -// conditions with error states. -// -// Parameters: -// - ctx: The context for the operation. -// - rover: The Rover resource whose status is being mapped. -// -// Returns: -// - api.ResourceStatusResponse: The mapped status response of the Rover resource. -// - error: Any error encountered during the mapping process. -func MapRoverResponse(ctx context.Context, rover *v1.Rover) (api.ResourceStatusResponse, error) { - if rover == nil { - return api.ResourceStatusResponse{}, ghErrors.New("input rover is nil") - } - status := MapStatus(rover.GetConditions()) - var errors = []api.Problem{} + var problems = []api.Problem{} - if status.State != api.Complete { + if rover, ok := obj.(*v1.Rover); ok && status.State != api.Complete { // Load all sub resources and check for conditions with error state - errors = append(errors, GetAllProblems(ctx, rover)...) - } - - processing := meta.FindStatusCondition(rover.GetConditions(), condition.ConditionTypeProcessing) - var processedAtTime time.Time - if processing != nil { - processedAtTime = processing.LastTransitionTime.Time + problems = append(problems, GetAllProblems(ctx, rover)...) } return api.ResourceStatusResponse{ - CreatedAt: rover.GetCreationTimestamp().Time, - ProcessedAt: processedAtTime, + CreatedAt: obj.GetCreationTimestamp().Time, + ProcessedAt: processedAt, State: status.State, ProcessingState: status.ProcessingState, OverallStatus: CalculateOverallStatus(status.State, status.ProcessingState), - Errors: errors, + Errors: problems, }, nil } diff --git a/rover-server/internal/mapper/status/status_test.go b/rover-server/internal/mapper/status/status_test.go index 5d713a0e..b0b027cc 100644 --- a/rover-server/internal/mapper/status/status_test.go +++ b/rover-server/internal/mapper/status/status_test.go @@ -29,7 +29,7 @@ var _ = Describe("Rover Status Mapper", func() { Context("MapRoverResponse", func() { It("must map rover response correctly", func() { - response, err := MapRoverResponse(ctx, rover) + response, err := MapResponse(ctx, rover) Expect(response).ToNot(BeNil()) snaps.MatchJSON(GinkgoT(), response) @@ -38,12 +38,12 @@ var _ = Describe("Rover Status Mapper", func() { }) It("must return an error if the input rover is nil", func() { - response, err := MapRoverResponse(ctx, nil) + response, err := MapResponse(ctx, nil) Expect(response).ToNot(BeNil()) Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("input rover is nil")) + Expect(err.Error()).To(ContainSubstring("input object is nil")) }) It("must map rover response correctly when processing condition is missing", func() { @@ -58,7 +58,7 @@ var _ = Describe("Rover Status Mapper", func() { }, } - response, err := MapRoverResponse(ctx, roverNoProcessing) + response, err := MapResponse(ctx, roverNoProcessing) Expect(response).ToNot(BeNil()) snaps.MatchJSON(GinkgoT(), response) diff --git a/rover-server/internal/mapper/status/suite_status_test.go b/rover-server/internal/mapper/status/suite_status_test.go index f53f4593..7bafbf74 100644 --- a/rover-server/internal/mapper/status/suite_status_test.go +++ b/rover-server/internal/mapper/status/suite_status_test.go @@ -13,6 +13,9 @@ import ( "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client/config" + "github.com/stretchr/testify/mock" + storeLib "github.com/telekom/controlplane/common-server/pkg/store" + eventv1 "github.com/telekom/controlplane/event/api/v1" "github.com/telekom/controlplane/rover-server/pkg/store" "github.com/telekom/controlplane/rover-server/test/mocks" ) @@ -31,6 +34,18 @@ var InitOrDie = func(ctx context.Context, cfg *rest.Config) { store.ApiExposureStore = mocks.NewApiExposureStoreMock(GinkgoT()) store.ApplicationStore = mocks.NewApplicationStoreMock(GinkgoT()) store.ZoneStore = mocks.NewZoneStoreMock(GinkgoT()) + eventConfigMock := mocks.NewMockObjectStore[*eventv1.EventConfig](GinkgoT()) + store.EventConfigStore = eventConfigMock + + eventSubscriptionMock := mocks.NewMockObjectStore[*eventv1.EventSubscription](GinkgoT()) + eventSubscriptionMock.EXPECT().List(mock.Anything, mock.Anything).Return( + &storeLib.ListResponse[*eventv1.EventSubscription]{Items: []*eventv1.EventSubscription{}}, nil).Maybe() + store.EventSubscriptionStore = eventSubscriptionMock + + eventExposureMock := mocks.NewMockObjectStore[*eventv1.EventExposure](GinkgoT()) + eventExposureMock.EXPECT().List(mock.Anything, mock.Anything).Return( + &storeLib.ListResponse[*eventv1.EventExposure]{Items: []*eventv1.EventExposure{}}, nil).Maybe() + store.EventExposureStore = eventExposureMock } } diff --git a/rover-server/internal/server/eventspecification_server.go b/rover-server/internal/server/eventspecification_server.go index 8e0e5118..1c9a3070 100644 --- a/rover-server/internal/server/eventspecification_server.go +++ b/rover-server/internal/server/eventspecification_server.go @@ -24,6 +24,11 @@ func (s *Server) GetAllEventSpecifications(c *fiber.Ctx) error { return server.ReturnWithProblem(c, nil, err) } + res.UnderscoreLinks.Self = buildCursorUrl(c.BaseURL(), c.Path(), res.UnderscoreLinks.Self) + if res.UnderscoreLinks.Next != "" { + res.UnderscoreLinks.Next = buildCursorUrl(c.BaseURL(), c.Path(), res.UnderscoreLinks.Next) + } + return c.JSON(res) } diff --git a/rover-server/pkg/store/stores.go b/rover-server/pkg/store/stores.go index 8f2f47f5..d318683c 100644 --- a/rover-server/pkg/store/stores.go +++ b/rover-server/pkg/store/stores.go @@ -13,7 +13,10 @@ import ( applicationv1 "github.com/telekom/controlplane/application/api/v1" "github.com/telekom/controlplane/common-server/pkg/store" "github.com/telekom/controlplane/common-server/pkg/store/inmemory" + "github.com/telekom/controlplane/common-server/pkg/store/noop" "github.com/telekom/controlplane/common-server/pkg/store/secrets" + cconfig "github.com/telekom/controlplane/common/pkg/config" + eventv1 "github.com/telekom/controlplane/event/api/v1" roverv1 "github.com/telekom/controlplane/rover/api/v1" secretsapi "github.com/telekom/controlplane/secret-manager/api" "k8s.io/apimachinery/pkg/runtime/schema" @@ -28,9 +31,13 @@ var ApplicationStore store.ObjectStore[*applicationv1.Application] var ApplicationSecretStore store.ObjectStore[*applicationv1.Application] var ApiSpecificationStore store.ObjectStore[*roverv1.ApiSpecification] +var EventSpecificationStore store.ObjectStore[*roverv1.EventSpecification] var ApiSubscriptionStore store.ObjectStore[*apiv1.ApiSubscription] var ApiExposureStore store.ObjectStore[*apiv1.ApiExposure] +var EventExposureStore store.ObjectStore[*eventv1.EventExposure] +var EventSubscriptionStore store.ObjectStore[*eventv1.EventSubscription] var ZoneStore store.ObjectStore[*adminv1.Zone] +var EventConfigStore store.ObjectStore[*eventv1.EventConfig] var dynamicClient dynamic.Interface @@ -56,6 +63,19 @@ var InitOrDie = func(ctx context.Context, cfg *rest.Config) { ApplicationStore = NewOrDie[*applicationv1.Application](ctx, applicationv1.GroupVersion.WithResource("applications"), applicationv1.GroupVersion.WithKind("Application")) ApiSubscriptionStore = NewOrDie[*apiv1.ApiSubscription](ctx, apiv1.GroupVersion.WithResource("apisubscriptions"), apiv1.GroupVersion.WithKind("ApiSubscription")) ApiExposureStore = NewOrDie[*apiv1.ApiExposure](ctx, apiv1.GroupVersion.WithResource("apiexposures"), apiv1.GroupVersion.WithKind("ApiExposure")) + + if cconfig.FeaturePubSub.IsEnabled() { + EventSpecificationStore = NewOrDie[*roverv1.EventSpecification](ctx, roverv1.GroupVersion.WithResource("eventspecifications"), roverv1.GroupVersion.WithKind("EventSpecification")) + EventExposureStore = NewOrDie[*eventv1.EventExposure](ctx, eventv1.GroupVersion.WithResource("eventexposures"), eventv1.GroupVersion.WithKind("EventExposure")) + EventSubscriptionStore = NewOrDie[*eventv1.EventSubscription](ctx, eventv1.GroupVersion.WithResource("eventsubscriptions"), eventv1.GroupVersion.WithKind("EventSubscription")) + EventConfigStore = NewOrDie[*eventv1.EventConfig](ctx, eventv1.GroupVersion.WithResource("eventconfigs"), eventv1.GroupVersion.WithKind("EventConfig")) + } else { + EventSpecificationStore = noop.NewStore[*roverv1.EventSpecification](roverv1.GroupVersion.WithResource("eventspecifications"), roverv1.GroupVersion.WithKind("EventSpecification")) + EventExposureStore = noop.NewStore[*eventv1.EventExposure](eventv1.GroupVersion.WithResource("eventexposures"), eventv1.GroupVersion.WithKind("EventExposure")) + EventSubscriptionStore = noop.NewStore[*eventv1.EventSubscription](eventv1.GroupVersion.WithResource("eventsubscriptions"), eventv1.GroupVersion.WithKind("EventSubscription")) + EventConfigStore = noop.NewStore[*eventv1.EventConfig](eventv1.GroupVersion.WithResource("eventconfigs"), eventv1.GroupVersion.WithKind("EventConfig")) + } + ZoneStore = NewOrDie[*adminv1.Zone](ctx, adminv1.GroupVersion.WithResource("zones"), adminv1.GroupVersion.WithKind("Zone")) secretsApi := secretsapi.NewSecrets() diff --git a/rover-server/test/mocks/mock_ObjectStore.go b/rover-server/test/mocks/mock_ObjectStore.go index 0e2305f6..14c69e0a 100644 --- a/rover-server/test/mocks/mock_ObjectStore.go +++ b/rover-server/test/mocks/mock_ObjectStore.go @@ -1,4 +1,4 @@ -// Copyright 2025 Deutsche Telekom IT GmbH +// Copyright 2026 Deutsche Telekom IT GmbH // // SPDX-License-Identifier: Apache-2.0 diff --git a/rover-server/test/mocks/mocks.go b/rover-server/test/mocks/mocks.go index 1edacd74..24781a2d 100644 --- a/rover-server/test/mocks/mocks.go +++ b/rover-server/test/mocks/mocks.go @@ -19,13 +19,14 @@ import ( ) const ( - ApiSpecificationFileName = "apiSpecification.json" - OpenApiFileName = "openapi.yaml" - apiSubscriptionFileName = "apiSubscription.json" - apiExposureFileName = "apiExposure.json" - applicationFileName = "application.json" - RoverFileName = "rover.json" - zoneFileName = "zone.json" + ApiSpecificationFileName = "apiSpecification.json" + EventSpecificationFileName = "eventSpecification.json" + OpenApiFileName = "openapi.yaml" + apiSubscriptionFileName = "apiSubscription.json" + apiExposureFileName = "apiExposure.json" + applicationFileName = "application.json" + RoverFileName = "rover.json" + zoneFileName = "zone.json" ) func GetRover(testing ginkgo.FullGinkgoTInterface, filePath string) *roverv1.Rover { @@ -82,6 +83,15 @@ func GetApiSpecification(testing ginkgo.FullGinkgoTInterface, filePath string) * return &apiSpecification } +func GetEventSpecification(testing ginkgo.FullGinkgoTInterface, filePath string) *roverv1.EventSpecification { + file := data.ReadFile(testing, filePath) + var eventSpecification roverv1.EventSpecification + err := json.Unmarshal(file, &eventSpecification) + require.NoError(testing, err) + + return &eventSpecification +} + func GetOpenApi(testing ginkgo.FullGinkgoTInterface, filePath string) *map[string]any { file := data.ReadFile(testing, filePath) var openapi map[string]any diff --git a/rover-server/test/mocks/mocks_EventSpecification.go b/rover-server/test/mocks/mocks_EventSpecification.go new file mode 100644 index 00000000..92cb9bf3 --- /dev/null +++ b/rover-server/test/mocks/mocks_EventSpecification.go @@ -0,0 +1,78 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package mocks + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/mock" + "github.com/telekom/controlplane/common-server/pkg/problems" + "github.com/telekom/controlplane/common-server/pkg/store" + roverv1 "github.com/telekom/controlplane/rover/api/v1" +) + +func NewEventSpecificationStoreMock(testing ginkgo.FullGinkgoTInterface) store.ObjectStore[*roverv1.EventSpecification] { + mockStore := NewMockObjectStore[*roverv1.EventSpecification](testing) + ConfigureEventSpecificationStoreMock(testing, mockStore) + return mockStore +} + +func ConfigureEventSpecificationStoreMock(testing ginkgo.FullGinkgoTInterface, mockedStore *MockObjectStore[*roverv1.EventSpecification]) { + configureEventSpecification(testing, mockedStore) + configureEventSpecificationNotFound(mockedStore) +} + +func configureEventSpecification(testing ginkgo.FullGinkgoTInterface, mockedStore *MockObjectStore[*roverv1.EventSpecification]) { + eventSpecification := GetEventSpecification(testing, EventSpecificationFileName) + + mockedStore.EXPECT().Get( + mock.AnythingOfType("*context.valueCtx"), + mock.MatchedBy(func(s string) bool { + return s == "poc--eni--hyperion" + }), + mock.MatchedBy(func(s string) bool { + return s == "tardis-horizon-demo-cetus-v1" + }), + ).Return(eventSpecification, nil).Maybe() + + mockedStore.EXPECT().List( + mock.AnythingOfType("*context.valueCtx"), + mock.Anything, + ).Return( + &store.ListResponse[*roverv1.EventSpecification]{ + Items: []*roverv1.EventSpecification{eventSpecification}}, nil).Maybe() + + mockedStore.EXPECT().Delete( + mock.AnythingOfType("*context.valueCtx"), + mock.MatchedBy(func(s string) bool { + return s == "poc--eni--hyperion" + }), + mock.MatchedBy(func(s string) bool { + return s == "tardis-horizon-demo-cetus-v1" + }), + ).Return(nil).Maybe() + + mockedStore.EXPECT().CreateOrReplace( + mock.AnythingOfType("*context.valueCtx"), + mock.AnythingOfType("*v1.EventSpecification"), + ).Return(nil).Maybe() +} + +func configureEventSpecificationNotFound(mockedStore *MockObjectStore[*roverv1.EventSpecification]) { + mockedStore.EXPECT().Get( + mock.AnythingOfType("*context.valueCtx"), + mock.AnythingOfType("string"), + mock.MatchedBy(func(s string) bool { + return s != "tardis-horizon-demo-cetus-v1" + }), + ).Return(nil, problems.NotFound("eventspec not found")).Maybe() + + mockedStore.EXPECT().Delete( + mock.AnythingOfType("*context.valueCtx"), + mock.AnythingOfType("string"), + mock.MatchedBy(func(s string) bool { + return s != "tardis-horizon-demo-cetus-v1" + }), + ).Return(problems.NotFound("eventspec not found")).Maybe() +} diff --git a/rover/api/v1/event_types.go b/rover/api/v1/event_types.go new file mode 100644 index 00000000..f926b841 --- /dev/null +++ b/rover/api/v1/event_types.go @@ -0,0 +1,127 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// EventDeliveryType defines how events are delivered to subscribers. +// +kubebuilder:validation:Enum=Callback;ServerSentEvent +type EventDeliveryType string + +const ( + EventDeliveryTypeCallback EventDeliveryType = "Callback" + EventDeliveryTypeServerSentEvent EventDeliveryType = "ServerSentEvent" +) + +// EventPayloadType defines the event payload format. +// +kubebuilder:validation:Enum=Data;DataRef +type EventPayloadType string + +const ( + EventPayloadTypeData EventPayloadType = "Data" + EventPayloadTypeDataRef EventPayloadType = "DataRef" +) + +// EventDelivery configures how events are delivered to the subscriber. +// +kubebuilder:validation:XValidation:rule="self.type == 'Callback' ? self.callback != \"\" : !has(self.callback)",message="callback is required for deliveryType 'Callback' and must not be set for 'ServerSentEvent'" +type EventDelivery struct { + // Type defines the delivery mechanism. + // +kubebuilder:default=Callback + Type EventDeliveryType `json:"type"` + + // Payload defines the event payload format. + // +kubebuilder:default=Data + Payload EventPayloadType `json:"payload"` + + // Callback is the URL where events are delivered. + // Required when type is "callback", must not be set for "ServerSentEvent". + // +kubebuilder:validation:Format=uri + // +kubebuilder:validation:Optional + Callback string `json:"callback,omitempty"` + + // EventRetentionTime defines how long events are retained for this subscriber. + // +kubebuilder:validation:Format=duration + // +kubebuilder:validation:Optional + EventRetentionTime string `json:"eventRetentionTime,omitempty"` + + // CircuitBreakerOptOut disables the circuit breaker for this subscription. + // +kubebuilder:validation:Optional + CircuitBreakerOptOut bool `json:"circuitBreakerOptOut,omitempty"` + + // RetryableStatusCodes defines HTTP status codes that should trigger a retry. + // +kubebuilder:validation:Optional + RetryableStatusCodes []int `json:"retryableStatusCodes,omitempty"` + + // RedeliveriesPerSecond limits the rate of event redeliveries. + // +kubebuilder:validation:Optional + RedeliveriesPerSecond *int `json:"redeliveriesPerSecond,omitempty"` + + // EnforceGetHttpRequestMethodForHealthCheck forces GET for health check probes instead of HEAD. + // +kubebuilder:validation:Optional + EnforceGetHttpRequestMethodForHealthCheck bool `json:"enforceGetHttpRequestMethodForHealthCheck,omitempty"` +} + +// EventResponseFilterMode controls whether the response filter includes or excludes the specified fields. +// +kubebuilder:validation:Enum=Include;Exclude +type EventResponseFilterMode string + +const ( + EventResponseFilterModeInclude EventResponseFilterMode = "Include" + EventResponseFilterModeExclude EventResponseFilterMode = "Exclude" +) + +// EventResponseFilter controls which fields are included or excluded from the event payload. +type EventResponseFilter struct { + // Paths lists the JSON paths to include or exclude from the event payload. + // +kubebuilder:validation:Optional + Paths []string `json:"paths,omitempty"` + + // Mode controls whether the listed paths are included or excluded. + // +kubebuilder:validation:Optional + // +kubebuilder:default=Include + Mode EventResponseFilterMode `json:"mode,omitempty"` +} + +// EventSelectionFilter defines criteria for selecting which events are delivered. +type EventSelectionFilter struct { + // Attributes defines simple key-value equality matches on CloudEvents attributes. + // All entries are AND-ed together. + // +kubebuilder:validation:Optional + Attributes map[string]string `json:"attributes,omitempty"` + + // Expression contains an arbitrary JSON filter expression tree + // using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + // that is passed through to the configuration backend without structural validation. + // +kubebuilder:validation:Optional + Expression *apiextensionsv1.JSON `json:"expression,omitempty"` +} + +// EventTrigger defines filtering criteria for event delivery. +type EventTrigger struct { + // ResponseFilter controls payload shaping (which fields to return). + // +kubebuilder:validation:Optional + ResponseFilter *EventResponseFilter `json:"responseFilter,omitempty"` + + // SelectionFilter controls event matching (which events to deliver). + // +kubebuilder:validation:Optional + SelectionFilter *EventSelectionFilter `json:"selectionFilter,omitempty"` +} + +// EventScope defines a named scope with required trigger-based filtering for event exposure. +// Scopes allow publishers to partition their events and apply publisher-side filters. +// Each scope must define a trigger that specifies which events belong to it. +type EventScope struct { + // Name is the unique identifier for this scope. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Trigger defines publisher-side filtering criteria for this scope. + // Every scope must define a trigger. + // +kubebuilder:validation:Required + Trigger EventTrigger `json:"trigger"` +} diff --git a/rover/api/v1/eventspecification_types.go b/rover/api/v1/eventspecification_types.go new file mode 100644 index 00000000..077e36c3 --- /dev/null +++ b/rover/api/v1/eventspecification_types.go @@ -0,0 +1,103 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "strings" + + "github.com/telekom/controlplane/common/pkg/types" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// MakeEventSpecificationName generates a name for the EventType resource +// based on the event type identifier of the EventSpecification. +// It replaces dots with hyphens and lowercases the result. +func MakeEventSpecificationName(eventSpec *EventSpecification) string { + return strings.ToLower(strings.ReplaceAll(eventSpec.Spec.Type, ".", "-")) +} + +// EventSpecificationSpec defines the desired state of EventSpecification. +type EventSpecificationSpec struct { + // Type is the dot-separated event type identifier (e.g. "de.telekom.eni.quickstart.v1"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]+(\.[a-z0-9]+)*$` + Type string `json:"type"` + + // Version of the event type specification (e.g. "1.0.0"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^\d+.*$` + Version string `json:"version"` + + // Description provides a human-readable summary of this event type. + // +optional + Description string `json:"description,omitempty"` + + // Specification contains the file ID reference from the file manager for + // the optional JSON schema that describes the event payload. + // +optional + Specification string `json:"specification,omitempty"` +} + +// EventSpecificationStatus defines the observed state of EventSpecification. +type EventSpecificationStatus struct { + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // EventType references the EventType CR created from this specification. + EventType types.ObjectRef `json:"eventType,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// EventSpecification is the Schema for the eventspecifications API. +// It defines an event type's metadata and creates the corresponding EventType +// singleton in the event domain, analogous to how ApiSpecification creates Api resources. +type EventSpecification struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventSpecificationSpec `json:"spec,omitempty"` + Status EventSpecificationStatus `json:"status,omitempty"` +} + +var _ types.Object = &EventSpecification{} + +func (r *EventSpecification) GetConditions() []metav1.Condition { + return r.Status.Conditions +} + +func (r *EventSpecification) SetCondition(condition metav1.Condition) bool { + return meta.SetStatusCondition(&r.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +type EventSpecificationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventSpecification `json:"items"` +} + +var _ types.ObjectList = &EventSpecificationList{} + +func (r *EventSpecificationList) GetItems() []types.Object { + items := make([]types.Object, len(r.Items)) + for i := range r.Items { + items[i] = &r.Items[i] + } + return items +} + +func init() { + SchemeBuilder.Register(&EventSpecification{}, &EventSpecificationList{}) +} diff --git a/rover/api/v1/rover_types.go b/rover/api/v1/rover_types.go index df907101..76e7f8c3 100644 --- a/rover/api/v1/rover_types.go +++ b/rover/api/v1/rover_types.go @@ -26,6 +26,10 @@ type RoverStatus struct { ApiSubscriptions []types.ObjectRef `json:"apiSubscriptions,omitempty"` // ApiExposures are references to ApiExposure resources created by this Rover ApiExposures []types.ObjectRef `json:"apiExposures,omitempty"` + // EventExposures are references to EventExposure resources created by this Rover + EventExposures []types.ObjectRef `json:"eventExposures,omitempty"` + // EventSubscriptions are references to EventSubscription resources created by this Rover + EventSubscriptions []types.ObjectRef `json:"eventSubscriptions,omitempty"` } //+kubebuilder:object:root=true @@ -251,10 +255,27 @@ func (apiExp *ApiExposure) HasM2M() bool { // EventExposure defines an event that is published by this Rover type EventExposure struct { - // EventType identifies the type of event that is published + // EventType identifies the type of event that is published (e.g. "de.telekom.eni.quickstart.v1") // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 EventType string `json:"eventType"` + + // Visibility defines who can see and subscribe to this event + // +kubebuilder:validation:Enum=World;Zone;Enterprise + // +kubebuilder:default=Enterprise + Visibility Visibility `json:"visibility"` + + // Approval defines the approval workflow required for subscriptions to this event + // +kubebuilder:validation:Required + Approval Approval `json:"approval"` + + // Scopes defines named scopes with optional publisher-side trigger filtering + // +kubebuilder:validation:Optional + Scopes []EventScope `json:"scopes,omitempty"` + + // AdditionalPublisherIds allows multiple application IDs to publish to the same event type + // +kubebuilder:validation:Optional + AdditionalPublisherIds []string `json:"additionalPublisherIds,omitempty"` } // ApiSubscription defines an API that this Rover consumes @@ -297,10 +318,23 @@ func (apiSub *ApiSubscription) HasM2MClient() bool { // EventSubscription defines an event that this Rover subscribes to type EventSubscription struct { - // EventType identifies the type of event to subscribe to + // EventType identifies the type of event to subscribe to (e.g. "de.telekom.eni.quickstart.v1") // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 EventType string `json:"eventType"` + + // Delivery configures how events are delivered to the subscriber + // +kubebuilder:validation:Required + Delivery EventDelivery `json:"delivery"` + + // Trigger defines subscriber-side filtering criteria for event delivery + // +kubebuilder:validation:Optional + Trigger *EventTrigger `json:"trigger,omitempty"` + + // Scopes selects which publisher-defined scopes to subscribe to + // Must match scope names defined on the corresponding EventExposure + // +kubebuilder:validation:Optional + Scopes []string `json:"scopes,omitempty"` } // Approval defines the approval workflow for API exposure diff --git a/rover/api/v1/zz_generated.deepcopy.go b/rover/api/v1/zz_generated.deepcopy.go index fadcf42c..7075a73d 100644 --- a/rover/api/v1/zz_generated.deepcopy.go +++ b/rover/api/v1/zz_generated.deepcopy.go @@ -10,6 +10,7 @@ package v1 import ( "github.com/telekom/controlplane/common/pkg/types" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -285,9 +286,47 @@ func (in *ConsumerRateLimits) DeepCopy() *ConsumerRateLimits { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventDelivery) DeepCopyInto(out *EventDelivery) { + *out = *in + if in.RetryableStatusCodes != nil { + in, out := &in.RetryableStatusCodes, &out.RetryableStatusCodes + *out = make([]int, len(*in)) + copy(*out, *in) + } + if in.RedeliveriesPerSecond != nil { + in, out := &in.RedeliveriesPerSecond, &out.RedeliveriesPerSecond + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventDelivery. +func (in *EventDelivery) DeepCopy() *EventDelivery { + if in == nil { + return nil + } + out := new(EventDelivery) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EventExposure) DeepCopyInto(out *EventExposure) { *out = *in + in.Approval.DeepCopyInto(&out.Approval) + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]EventScope, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AdditionalPublisherIds != nil { + in, out := &in.AdditionalPublisherIds, &out.AdditionalPublisherIds + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventExposure. @@ -300,9 +339,180 @@ func (in *EventExposure) DeepCopy() *EventExposure { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventResponseFilter) DeepCopyInto(out *EventResponseFilter) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventResponseFilter. +func (in *EventResponseFilter) DeepCopy() *EventResponseFilter { + if in == nil { + return nil + } + out := new(EventResponseFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventScope) DeepCopyInto(out *EventScope) { + *out = *in + in.Trigger.DeepCopyInto(&out.Trigger) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventScope. +func (in *EventScope) DeepCopy() *EventScope { + if in == nil { + return nil + } + out := new(EventScope) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventSelectionFilter) DeepCopyInto(out *EventSelectionFilter) { + *out = *in + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Expression != nil { + in, out := &in.Expression, &out.Expression + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSelectionFilter. +func (in *EventSelectionFilter) DeepCopy() *EventSelectionFilter { + if in == nil { + return nil + } + out := new(EventSelectionFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventSpecification) DeepCopyInto(out *EventSpecification) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSpecification. +func (in *EventSpecification) DeepCopy() *EventSpecification { + if in == nil { + return nil + } + out := new(EventSpecification) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventSpecification) 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 *EventSpecificationList) DeepCopyInto(out *EventSpecificationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventSpecification, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSpecificationList. +func (in *EventSpecificationList) DeepCopy() *EventSpecificationList { + if in == nil { + return nil + } + out := new(EventSpecificationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventSpecificationList) 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 *EventSpecificationSpec) DeepCopyInto(out *EventSpecificationSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSpecificationSpec. +func (in *EventSpecificationSpec) DeepCopy() *EventSpecificationSpec { + if in == nil { + return nil + } + out := new(EventSpecificationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventSpecificationStatus) DeepCopyInto(out *EventSpecificationStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.EventType.DeepCopyInto(&out.EventType) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSpecificationStatus. +func (in *EventSpecificationStatus) DeepCopy() *EventSpecificationStatus { + if in == nil { + return nil + } + out := new(EventSpecificationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EventSubscription) DeepCopyInto(out *EventSubscription) { *out = *in + in.Delivery.DeepCopyInto(&out.Delivery) + if in.Trigger != nil { + in, out := &in.Trigger, &out.Trigger + *out = new(EventTrigger) + (*in).DeepCopyInto(*out) + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventSubscription. @@ -315,6 +525,31 @@ func (in *EventSubscription) DeepCopy() *EventSubscription { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventTrigger) DeepCopyInto(out *EventTrigger) { + *out = *in + if in.ResponseFilter != nil { + in, out := &in.ResponseFilter, &out.ResponseFilter + *out = new(EventResponseFilter) + (*in).DeepCopyInto(*out) + } + if in.SelectionFilter != nil { + in, out := &in.SelectionFilter, &out.SelectionFilter + *out = new(EventSelectionFilter) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventTrigger. +func (in *EventTrigger) DeepCopy() *EventTrigger { + if in == nil { + return nil + } + out := new(EventTrigger) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Exposure) DeepCopyInto(out *Exposure) { *out = *in @@ -326,7 +561,7 @@ func (in *Exposure) DeepCopyInto(out *Exposure) { if in.Event != nil { in, out := &in.Event, &out.Event *out = new(EventExposure) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -708,6 +943,20 @@ func (in *RoverStatus) DeepCopyInto(out *RoverStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.EventExposures != nil { + in, out := &in.EventExposures, &out.EventExposures + *out = make([]types.ObjectRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.EventSubscriptions != nil { + in, out := &in.EventSubscriptions, &out.EventSubscriptions + *out = make([]types.ObjectRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoverStatus. @@ -821,7 +1070,7 @@ func (in *Subscription) DeepCopyInto(out *Subscription) { if in.Event != nil { in, out := &in.Event, &out.Event *out = new(EventSubscription) - **out = **in + (*in).DeepCopyInto(*out) } } diff --git a/rover/cmd/main.go b/rover/cmd/main.go index bb9f3d42..092c8aed 100644 --- a/rover/cmd/main.go +++ b/rover/cmd/main.go @@ -31,6 +31,8 @@ import ( adminv1 "github.com/telekom/controlplane/admin/api/v1" apiapi "github.com/telekom/controlplane/api/api/v1" applicationv1 "github.com/telekom/controlplane/application/api/v1" + cconfig "github.com/telekom/controlplane/common/pkg/config" + eventv1 "github.com/telekom/controlplane/event/api/v1" organizationv1 "github.com/telekom/controlplane/organization/api/v1" roverv1 "github.com/telekom/controlplane/rover/api/v1" @@ -55,6 +57,9 @@ func init() { utilruntime.Must(applicationv1.AddToScheme(scheme)) utilruntime.Must(adminv1.AddToScheme(scheme)) utilruntime.Must(organizationv1.AddToScheme(scheme)) + if cconfig.FeaturePubSub.IsEnabled() { + utilruntime.Must(eventv1.AddToScheme(scheme)) + } //+kubebuilder:scaffold:scheme } @@ -182,6 +187,16 @@ func main() { os.Exit(1) } + if cconfig.FeaturePubSub.IsEnabled() { + if err = (&controller.EventSpecificationReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EventSpecification") + os.Exit(1) + } + } + if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err = webhookv1.SetupWebhookWithManager(mgr, secretsapi.API()); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Rover") diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_eventspecifications.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_eventspecifications.yaml new file mode 100644 index 00000000..b82a050c --- /dev/null +++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_eventspecifications.yaml @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +# +# SPDX-License-Identifier: Apache-2.0 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: eventspecifications.rover.cp.ei.telekom.de +spec: + group: rover.cp.ei.telekom.de + names: + kind: EventSpecification + listKind: EventSpecificationList + plural: eventspecifications + singular: eventspecification + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + EventSpecification is the Schema for the eventspecifications API. + It defines an event type's metadata and creates the corresponding EventType + singleton in the event domain, analogous to how ApiSpecification creates Api 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: + description: EventSpecificationSpec defines the desired state of EventSpecification. + properties: + description: + description: Description provides a human-readable summary of this + event type. + type: string + specification: + description: |- + Specification contains the file ID reference from the file manager for + the optional JSON schema that describes the event payload. + type: string + type: + description: Type is the dot-separated event type identifier (e.g. + "de.telekom.eni.quickstart.v1"). + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]+(\.[a-z0-9]+)*$ + type: string + version: + description: Version of the event type specification (e.g. "1.0.0"). + pattern: ^\d+.*$ + type: string + required: + - type + - version + type: object + status: + description: EventSpecificationStatus defines the observed state of EventSpecification. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + eventType: + description: EventType references the EventType CR created from this + specification. + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml index db36053c..f4afb173 100644 --- a/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml +++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml @@ -460,13 +460,133 @@ spec: event: description: Event defines an Event-based service exposure configuration properties: + additionalPublisherIds: + description: AdditionalPublisherIds allows multiple application + IDs to publish to the same event type + items: + type: string + type: array + approval: + description: Approval defines the approval workflow required + for subscriptions to this event + properties: + strategy: + default: Simple + description: Strategy defines the approval process required + for this API + enum: + - Auto + - Simple + - FourEyes + type: string + trustedTeams: + description: |- + TrustedTeams identifies teams that are trusted for approving this API + Per default your own team is trusted + items: + description: TrustedTeam identifies a team that is + trusted for approvals + properties: + group: + description: Group identifies the organizational + group for this trusted team + minLength: 1 + type: string + team: + description: Team identifies the specific team + within the group + minLength: 1 + type: string + required: + - group + - team + type: object + maxItems: 10 + minItems: 0 + type: array + required: + - strategy + type: object eventType: description: EventType identifies the type of event that - is published + is published (e.g. "de.telekom.eni.quickstart.v1") minLength: 1 type: string + scopes: + description: Scopes defines named scopes with optional publisher-side + trigger filtering + items: + description: |- + EventScope defines a named scope with required trigger-based filtering for event exposure. + Scopes allow publishers to partition their events and apply publisher-side filters. + Each scope must define a trigger that specifies which events belong to it. + properties: + name: + description: Name is the unique identifier for this + scope. + minLength: 1 + type: string + trigger: + description: |- + Trigger defines publisher-side filtering criteria for this scope. + Every scope must define a trigger. + properties: + responseFilter: + description: ResponseFilter controls payload shaping + (which fields to return). + properties: + mode: + default: Include + description: Mode controls whether the listed + paths are included or excluded. + enum: + - Include + - Exclude + type: string + paths: + description: Paths lists the JSON paths to + include or exclude from the event payload. + items: + type: string + type: array + type: object + selectionFilter: + description: SelectionFilter controls event matching + (which events to deliver). + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes defines simple key-value equality matches on CloudEvents attributes. + All entries are AND-ed together. + type: object + expression: + description: |- + Expression contains an arbitrary JSON filter expression tree + using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + that is passed through to the configuration backend without structural validation. + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + required: + - name + - trigger + type: object + type: array + visibility: + default: Enterprise + description: Visibility defines who can see and subscribe + to this event + enum: + - World + - Zone + - Enterprise + type: string required: + - approval - eventType + - visibility type: object type: object x-kubernetes-validations: @@ -645,12 +765,118 @@ spec: description: Event defines an Event-based service subscription configuration properties: + delivery: + description: Delivery configures how events are delivered + to the subscriber + properties: + callback: + description: |- + Callback is the URL where events are delivered. + Required when type is "callback", must not be set for "ServerSentEvent". + format: uri + type: string + circuitBreakerOptOut: + description: CircuitBreakerOptOut disables the circuit + breaker for this subscription. + type: boolean + enforceGetHttpRequestMethodForHealthCheck: + description: EnforceGetHttpRequestMethodForHealthCheck + forces GET for health check probes instead of HEAD. + type: boolean + eventRetentionTime: + description: EventRetentionTime defines how long events + are retained for this subscriber. + format: duration + type: string + payload: + default: Data + description: Payload defines the event payload format. + enum: + - Data + - DataRef + type: string + redeliveriesPerSecond: + description: RedeliveriesPerSecond limits the rate of + event redeliveries. + type: integer + retryableStatusCodes: + description: RetryableStatusCodes defines HTTP status + codes that should trigger a retry. + items: + type: integer + type: array + type: + default: Callback + description: Type defines the delivery mechanism. + enum: + - Callback + - ServerSentEvent + type: string + required: + - payload + - type + type: object + x-kubernetes-validations: + - message: callback is required for deliveryType 'Callback' + and must not be set for 'ServerSentEvent' + rule: 'self.type == ''Callback'' ? self.callback != "" + : !has(self.callback)' eventType: description: EventType identifies the type of event to subscribe - to + to (e.g. "de.telekom.eni.quickstart.v1") minLength: 1 type: string + scopes: + description: |- + Scopes selects which publisher-defined scopes to subscribe to + Must match scope names defined on the corresponding EventExposure + items: + type: string + type: array + trigger: + description: Trigger defines subscriber-side filtering criteria + for event delivery + properties: + responseFilter: + description: ResponseFilter controls payload shaping + (which fields to return). + properties: + mode: + default: Include + description: Mode controls whether the listed paths + are included or excluded. + enum: + - Include + - Exclude + type: string + paths: + description: Paths lists the JSON paths to include + or exclude from the event payload. + items: + type: string + type: array + type: object + selectionFilter: + description: SelectionFilter controls event matching + (which events to deliver). + properties: + attributes: + additionalProperties: + type: string + description: |- + Attributes defines simple key-value equality matches on CloudEvents attributes. + All entries are AND-ed together. + type: object + expression: + description: |- + Expression contains an arbitrary JSON filter expression tree + using logical operators (and, or) and comparisons (eq, ge, gt, le, lt, ne) + that is passed through to the configuration backend without structural validation. + x-kubernetes-preserve-unknown-fields: true + type: object + type: object required: + - delivery - eventType type: object type: object @@ -796,6 +1022,52 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + eventExposures: + description: EventExposures are references to EventExposure resources + created by this Rover + items: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: array + eventSubscriptions: + description: EventSubscriptions are references to EventSubscription + resources created by this Rover + items: + description: |- + ObjectRef is a reference to a Kubernetes object + It is similar to types.NamespacedName but has the required json tags for serialization + properties: + name: + type: string + namespace: + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - name + - namespace + type: object + type: array type: object required: - spec diff --git a/rover/config/crd/kustomization.yaml b/rover/config/crd/kustomization.yaml index ed161cbd..b6648f7a 100644 --- a/rover/config/crd/kustomization.yaml +++ b/rover/config/crd/kustomization.yaml @@ -8,6 +8,7 @@ resources: - bases/rover.cp.ei.telekom.de_rovers.yaml - bases/rover.cp.ei.telekom.de_apispecifications.yaml +- bases/rover.cp.ei.telekom.de_eventspecifications.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/rover/config/rbac/role.yaml b/rover/config/rbac/role.yaml index 331942fb..c8bc87bb 100644 --- a/rover/config/rbac/role.yaml +++ b/rover/config/rbac/role.yaml @@ -10,6 +10,7 @@ rules: - apiGroups: - admin.cp.ei.telekom.de resources: + - eventconfigs - zones verbs: - get @@ -56,6 +57,20 @@ rules: verbs: - create - patch +- apiGroups: + - event.cp.ei.telekom.de + resources: + - eventexposures + - eventsubscriptions + - eventtypes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - organization.cp.ei.telekom.de resources: @@ -68,6 +83,7 @@ rules: - rover.cp.ei.telekom.de resources: - apispecifications + - eventspecifications - rovers verbs: - create @@ -81,6 +97,7 @@ rules: - rover.cp.ei.telekom.de resources: - apispecifications/finalizers + - eventspecifications/finalizers - rovers/finalizers verbs: - update @@ -88,6 +105,7 @@ rules: - rover.cp.ei.telekom.de resources: - apispecifications/status + - eventspecifications/status - rovers/status verbs: - get diff --git a/rover/config/samples/kustomization.yaml b/rover/config/samples/kustomization.yaml deleted file mode 100644 index 22092922..00000000 --- a/rover/config/samples/kustomization.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright 2025 Deutsche Telekom IT GmbH -# -# SPDX-License-Identifier: Apache-2.0 - -## Append samples of your project ## -resources: -- rover_v1_rover.yaml -- rover_v1_apispecification.yaml - -commonLabels: - cp.ei.telekom.de/environment: poc -#+kubebuilder:scaffold:manifestskustomizesamples diff --git a/rover/config/samples/rover_v1_rover.yaml b/rover/config/samples/rover_v1_rover.yaml deleted file mode 100644 index c8119ebe..00000000 --- a/rover/config/samples/rover_v1_rover.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2025 Deutsche Telekom IT GmbH -# -# SPDX-License-Identifier: Apache-2.0 - -apiVersion: rover.cp.ei.telekom.de/v1 -kind: Rover -metadata: - labels: - app.kubernetes.io/name: rover - app.kubernetes.io/managed-by: kustomize - cp.ei.telekom.de/environment: foo - name: rover-sample -spec: - zone: zone-a - exposures: - - api: - basePath: /eni/foo/v2 - upstream: http://foo-service:8080 - visibility: World - approval: Auto - subscriptions: - - api: - basePath: /eni/foo/v2 - oauth2Scopes: - - read - - write diff --git a/rover/config/samples/rover_v1_rover_remote_org.yaml b/rover/config/samples/rover_v1_rover_remote_org.yaml deleted file mode 100644 index 1a8a67cb..00000000 --- a/rover/config/samples/rover_v1_rover_remote_org.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2025 Deutsche Telekom IT GmbH -# -# SPDX-License-Identifier: Apache-2.0 - -apiVersion: rover.cp.ei.telekom.de/v1 -kind: Rover -metadata: - labels: - app.kubernetes.io/name: rover - app.kubernetes.io/managed-by: kustomize - cp.ei.telekom.de/environment: poc - name: rover-sample-remote -spec: - zone: dataplane1 - subscriptions: - - api: - basePath: /eni/foo/v2 - organization: esp - oauth2Scopes: - - read - - write \ No newline at end of file diff --git a/rover/config/samples/rover_v1_rover_remote_org_proxy.yaml b/rover/config/samples/rover_v1_rover_remote_org_proxy.yaml deleted file mode 100644 index 01c12b42..00000000 --- a/rover/config/samples/rover_v1_rover_remote_org_proxy.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2025 Deutsche Telekom IT GmbH -# -# SPDX-License-Identifier: Apache-2.0 - -apiVersion: rover.cp.ei.telekom.de/v1 -kind: Rover -metadata: - labels: - app.kubernetes.io/name: rover - app.kubernetes.io/managed-by: kustomize - cp.ei.telekom.de/environment: poc - name: rover-sample-remote -spec: - zone: dataplane2 - subscriptions: - - api: - basePath: /eni/bar/v2 - organization: esp - oauth2Scopes: - - read - - write \ No newline at end of file diff --git a/rover/go.mod b/rover/go.mod index 8992f13d..b9321520 100644 --- a/rover/go.mod +++ b/rover/go.mod @@ -12,6 +12,7 @@ require ( github.com/telekom/controlplane/application/api v0.0.0 github.com/telekom/controlplane/common v0.0.0 github.com/telekom/controlplane/common-server v0.0.0 + github.com/telekom/controlplane/event/api v0.0.0 github.com/telekom/controlplane/organization/api v0.0.0 github.com/telekom/controlplane/rover/api v0.0.0 github.com/telekom/controlplane/secret-manager v0.0.0 @@ -35,6 +36,7 @@ replace ( github.com/telekom/controlplane/application/api => ../application/api github.com/telekom/controlplane/common => ../common github.com/telekom/controlplane/common-server => ../common-server + github.com/telekom/controlplane/event/api => ../event/api github.com/telekom/controlplane/organization/api => ../organization/api github.com/telekom/controlplane/rover/api => ./api github.com/telekom/controlplane/secret-manager => ../secret-manager diff --git a/rover/internal/controller/eventspecification_controller.go b/rover/internal/controller/eventspecification_controller.go new file mode 100644 index 00000000..84b43ddc --- /dev/null +++ b/rover/internal/controller/eventspecification_controller.go @@ -0,0 +1,57 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + cconfig "github.com/telekom/controlplane/common/pkg/config" + cc "github.com/telekom/controlplane/common/pkg/controller" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + + eventspec_handler "github.com/telekom/controlplane/rover/internal/handler/eventspecification" + + eventv1 "github.com/telekom/controlplane/event/api/v1" + rover "github.com/telekom/controlplane/rover/api/v1" +) + +// EventSpecificationReconciler reconciles an EventSpecification object +type EventSpecificationReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + cc.Controller[*rover.EventSpecification] +} + +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch + +// +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=eventspecifications,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=eventspecifications/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=eventspecifications/finalizers,verbs=update +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventtypes,verbs=get;list;watch;create;update;patch;delete + +func (r *EventSpecificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.Controller.Reconcile(ctx, req, &rover.EventSpecification{}) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *EventSpecificationReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("eventspecification-controller") + r.Controller = cc.NewController(&eventspec_handler.EventSpecificationHandler{}, r.Client, r.Recorder) + + return ctrl.NewControllerManagedBy(mgr). + For(&rover.EventSpecification{}). + Owns(&eventv1.EventType{}). + WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). + Complete(r) +} diff --git a/rover/internal/controller/index.go b/rover/internal/controller/index.go index 54ed8728..6940871f 100644 --- a/rover/internal/controller/index.go +++ b/rover/internal/controller/index.go @@ -10,7 +10,9 @@ import ( apiapi "github.com/telekom/controlplane/api/api/v1" applicationv1 "github.com/telekom/controlplane/application/api/v1" + cconfig "github.com/telekom/controlplane/common/pkg/config" "github.com/telekom/controlplane/common/pkg/controller/index" + eventv1 "github.com/telekom/controlplane/event/api/v1" ctrl "sigs.k8s.io/controller-runtime" ) @@ -39,4 +41,22 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } + if cconfig.FeaturePubSub.IsEnabled() { + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &eventv1.EventExposure{}) + if err != nil { + ctrl.Log.Error(err, "unable to create ownerIndex for EventExposure") + os.Exit(1) + } + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &eventv1.EventSubscription{}) + if err != nil { + ctrl.Log.Error(err, "unable to create ownerIndex for EventSubscription") + os.Exit(1) + } + err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &eventv1.EventType{}) + if err != nil { + ctrl.Log.Error(err, "unable to create ownerIndex for EventType") + os.Exit(1) + } + } + } diff --git a/rover/internal/controller/rover_controller.go b/rover/internal/controller/rover_controller.go index 416e16bf..ee19f539 100644 --- a/rover/internal/controller/rover_controller.go +++ b/rover/internal/controller/rover_controller.go @@ -19,6 +19,7 @@ import ( apiapi "github.com/telekom/controlplane/api/api/v1" application "github.com/telekom/controlplane/application/api/v1" + eventv1 "github.com/telekom/controlplane/event/api/v1" rover "github.com/telekom/controlplane/rover/api/v1" ) @@ -43,6 +44,9 @@ type RoverReconciler struct { // +kubebuilder:rbac:groups=api.cp.ei.telekom.de,resources=apiexposures,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=api.cp.ei.telekom.de,resources=apicategories,verbs=get;list;watch +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventexposures,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=event.cp.ei.telekom.de,resources=eventsubscriptions,verbs=get;list;watch;create;update;patch;delete + // +kubebuilder:rbac:groups=application.cp.ei.telekom.de,resources=applications,verbs=get;list;watch;create;update;patch;delete func (r *RoverReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -54,14 +58,20 @@ func (r *RoverReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Recorder = mgr.GetEventRecorderFor("rover-controller") r.Controller = cc.NewController(&rover_handler.RoverHandler{}, r.Client, r.Recorder) - return ctrl.NewControllerManagedBy(mgr). + b := ctrl.NewControllerManagedBy(mgr). For(&rover.Rover{}). Owns(&apiapi.ApiSubscription{}). Owns(&apiapi.ApiExposure{}). - Owns(&application.Application{}). - WithOptions(controller.Options{ - MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, - RateLimiter: cc.NewRateLimiter(), - }). + Owns(&application.Application{}) + + if cconfig.FeaturePubSub.IsEnabled() { + b = b.Owns(&eventv1.EventExposure{}). + Owns(&eventv1.EventSubscription{}) + } + + return b.WithOptions(controller.Options{ + MaxConcurrentReconciles: cconfig.MaxConcurrentReconciles, + RateLimiter: cc.NewRateLimiter(), + }). Complete(r) } diff --git a/rover/internal/handler/eventspecification/handler.go b/rover/internal/handler/eventspecification/handler.go new file mode 100644 index 00000000..b971281f --- /dev/null +++ b/rover/internal/handler/eventspecification/handler.go @@ -0,0 +1,79 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package eventspecification + +import ( + "context" + + "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/handler" + "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +var _ handler.Handler[*roverv1.EventSpecification] = (*EventSpecificationHandler)(nil) + +type EventSpecificationHandler struct{} + +func (h *EventSpecificationHandler) CreateOrUpdate(ctx context.Context, eventSpec *roverv1.EventSpecification) error { + + c := client.ClientFromContextOrDie(ctx) + name := roverv1.MakeEventSpecificationName(eventSpec) + + eventType := &eventv1.EventType{ + ObjectMeta: metav1.ObjectMeta{ + Name: labelutil.NormalizeNameValue(name), + Namespace: eventSpec.Namespace, + }, + } + + eventSpec.Status.EventType = *types.ObjectRefFromObject(eventType) + + mutator := func() error { + err := controllerutil.SetControllerReference(eventSpec, eventType, c.Scheme()) + if err != nil { + return errors.Wrap(err, "failed to set controller reference") + } + + eventType.Labels = map[string]string{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(eventSpec.Spec.Type), + } + + eventType.Spec = eventv1.EventTypeSpec{ + Type: eventSpec.Spec.Type, + Version: eventSpec.Spec.Version, + Description: eventSpec.Spec.Description, + Specification: eventSpec.Spec.Specification, + } + + return nil + } + + _, err := c.CreateOrUpdate(ctx, eventType, mutator) + if err != nil { + return errors.Wrap(err, "failed to create or update EventType") + } + + if c.AnyChanged() { + eventSpec.SetCondition(condition.NewProcessingCondition("Provisioning", "EventType updated")) + eventSpec.SetCondition(condition.NewNotReadyCondition("Provisioning", "EventType is not ready")) + + } else { + eventSpec.SetCondition(condition.NewDoneProcessingCondition("EventType created")) + eventSpec.SetCondition(condition.NewReadyCondition("Provisioned", "EventType is ready")) + } + + return nil +} + +func (h *EventSpecificationHandler) Delete(ctx context.Context, obj *roverv1.EventSpecification) error { + return nil +} diff --git a/rover/internal/handler/rover/api/exposure.go b/rover/internal/handler/rover/api/exposure.go index 0806eb52..ed5832ca 100644 --- a/rover/internal/handler/rover/api/exposure.go +++ b/rover/internal/handler/rover/api/exposure.go @@ -115,7 +115,7 @@ func mapTrustedTeamsToApiTrustedTeams(ctx context.Context, c client.JanitorClien namespace := contextutil.EnvFromContextOrDie(ctx) + "--" + team.Group + "--" + team.Team t, err := organizationv1.FindTeamForNamespace(ctx, namespace) if err != nil && apierrors.IsNotFound(err) { - log.Info(fmt.Sprintf("Trusted team %s/%s not found", team.Group, team.Team), "error", err) + log.Info(fmt.Sprintf("Trusted team %s/%s not found", team.Group, team.Team)) } else if err != nil { return nil, err diff --git a/rover/internal/handler/rover/event/exposure.go b/rover/internal/handler/rover/event/exposure.go new file mode 100644 index 00000000..cc17d8e9 --- /dev/null +++ b/rover/internal/handler/rover/event/exposure.go @@ -0,0 +1,193 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package event + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/contextutil" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + organizationv1 "github.com/telekom/controlplane/organization/api/v1" + rover "github.com/telekom/controlplane/rover/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleExposure creates or updates an EventExposure resource owned by the given Rover. +func HandleExposure(ctx context.Context, c client.JanitorClient, owner *rover.Rover, exp *rover.EventExposure) error { + log := log.FromContext(ctx) + log.V(1).Info("Handle EventExposure", "eventType", exp.EventType) + + name := MakeName(owner.Name, exp.EventType) + + eventExposure := &eventv1.EventExposure{ + ObjectMeta: metav1.ObjectMeta{ + Name: labelutil.NormalizeNameValue(name), + Namespace: owner.Namespace, + }, + } + + environment := contextutil.EnvFromContextOrDie(ctx) + zoneRef := types.ObjectRef{ + Name: owner.Spec.Zone, + Namespace: environment, + } + + mutator := func() error { + err := controllerutil.SetControllerReference(owner, eventExposure, c.Scheme()) + if err != nil { + return errors.Wrap(err, "failed to set controller reference") + } + + eventExposure.Labels = map[string]string{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(exp.EventType), + config.BuildLabelKey("zone"): labelutil.NormalizeLabelValue(zoneRef.Name), + config.BuildLabelKey("application"): labelutil.NormalizeLabelValue(owner.Name), + } + + // Map trusted teams from rover Group/Team format to resolved team names + trustedTeams, err := mapTrustedTeams(ctx, c, exp.Approval.TrustedTeams) + if err != nil { + return errors.Wrap(err, "failed to map trusted teams") + } + + // Add owner team to trusted teams + ownerTeam, err := organizationv1.FindTeamForObject(ctx, owner) + if err != nil && apierrors.IsNotFound(err) { + log.Info(fmt.Sprintf("Team not found for application %s, err: %v", owner.Name, err)) + } else if err != nil { + return err + } else { + trustedTeams = append(trustedTeams, ownerTeam.GetName()) + } + + eventExposure.Spec = eventv1.EventExposureSpec{ + EventType: exp.EventType, + Visibility: eventv1.Visibility(exp.Visibility.String()), + Approval: eventv1.Approval{ + Strategy: eventv1.ApprovalStrategy(exp.Approval.Strategy), + TrustedTeams: trustedTeams, + }, + Zone: zoneRef, + Provider: types.TypedObjectRef{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "application.cp.ei.telekom.de/v1", + }, + ObjectRef: *owner.Status.Application, + }, + Scopes: mapEventScopes(exp.Scopes), + AdditionalPublisherIds: exp.AdditionalPublisherIds, + } + + return nil + } + + _, err := c.CreateOrUpdate(ctx, eventExposure, mutator) + if err != nil { + return errors.Wrap(err, "failed to create or update EventExposure") + } + + owner.Status.EventExposures = append(owner.Status.EventExposures, types.ObjectRef{ + Name: eventExposure.Name, + Namespace: eventExposure.Namespace, + }) + return nil +} + +// mapTrustedTeams resolves rover TrustedTeam references (Group/Team) to team resource names. +func mapTrustedTeams(ctx context.Context, c client.JanitorClient, teams []rover.TrustedTeam) ([]string, error) { + log := log.FromContext(ctx) + if len(teams) == 0 { + return nil, nil + } + + resolved := make([]string, 0, len(teams)) + for _, team := range teams { + namespace := contextutil.EnvFromContextOrDie(ctx) + "--" + team.Group + "--" + team.Team + t, err := organizationv1.FindTeamForNamespace(ctx, namespace) + if err != nil && apierrors.IsNotFound(err) { + log.Info(fmt.Sprintf("Trusted team %s/%s not found", team.Group, team.Team)) + } else if err != nil { + return nil, err + } else { + resolved = append(resolved, t.GetName()) + } + } + + return resolved, nil +} + +// mapEventScopes converts rover EventScope types to event-domain EventScope types. +func mapEventScopes(roverScopes []rover.EventScope) []eventv1.EventScope { + if len(roverScopes) == 0 { + return nil + } + + scopes := make([]eventv1.EventScope, len(roverScopes)) + for i, s := range roverScopes { + scopes[i] = eventv1.EventScope{ + Name: s.Name, + Trigger: mapEventTriggerValue(s.Trigger), + } + } + return scopes +} + +// mapEventTrigger converts a rover EventTrigger pointer to an event-domain EventTrigger pointer. +// Used for subscriber-side triggers where the trigger is optional. +func mapEventTrigger(roverTrigger *rover.EventTrigger) *eventv1.EventTrigger { + if roverTrigger == nil { + return nil + } + + trigger := &eventv1.EventTrigger{} + + if roverTrigger.ResponseFilter != nil { + trigger.ResponseFilter = &eventv1.ResponseFilter{ + Paths: roverTrigger.ResponseFilter.Paths, + Mode: eventv1.ResponseFilterMode(roverTrigger.ResponseFilter.Mode), + } + } + + if roverTrigger.SelectionFilter != nil { + trigger.SelectionFilter = &eventv1.SelectionFilter{ + Attributes: roverTrigger.SelectionFilter.Attributes, + Expression: roverTrigger.SelectionFilter.Expression, + } + } + + return trigger +} + +// mapEventTriggerValue converts a rover EventTrigger value to an event-domain EventTrigger value. +// Used for scope triggers where the trigger is required. +func mapEventTriggerValue(roverTrigger rover.EventTrigger) eventv1.EventTrigger { + trigger := eventv1.EventTrigger{} + + if roverTrigger.ResponseFilter != nil { + trigger.ResponseFilter = &eventv1.ResponseFilter{ + Paths: roverTrigger.ResponseFilter.Paths, + Mode: eventv1.ResponseFilterMode(roverTrigger.ResponseFilter.Mode), + } + } + + if roverTrigger.SelectionFilter != nil { + trigger.SelectionFilter = &eventv1.SelectionFilter{ + Attributes: roverTrigger.SelectionFilter.Attributes, + Expression: roverTrigger.SelectionFilter.Expression, + } + } + + return trigger +} diff --git a/rover/internal/handler/rover/event/subscription.go b/rover/internal/handler/rover/event/subscription.go new file mode 100644 index 00000000..ae12d859 --- /dev/null +++ b/rover/internal/handler/rover/event/subscription.go @@ -0,0 +1,97 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package event + +import ( + "context" + + "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/client" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/types" + "github.com/telekom/controlplane/common/pkg/util/contextutil" + "github.com/telekom/controlplane/common/pkg/util/labelutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" + rover "github.com/telekom/controlplane/rover/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// HandleSubscription creates or updates an EventSubscription resource owned by the given Rover. +func HandleSubscription(ctx context.Context, c client.JanitorClient, owner *rover.Rover, sub *rover.EventSubscription) error { + log := log.FromContext(ctx) + log.V(1).Info("Handle EventSubscription", "eventType", sub.EventType) + + name := MakeName(owner.Name, sub.EventType) + + eventSubscription := &eventv1.EventSubscription{ + ObjectMeta: metav1.ObjectMeta{ + Name: labelutil.NormalizeNameValue(name), + Namespace: owner.Namespace, + }, + } + + environment := contextutil.EnvFromContextOrDie(ctx) + zoneRef := types.ObjectRef{ + Name: owner.Spec.Zone, + Namespace: environment, + } + + mutator := func() error { + err := controllerutil.SetControllerReference(owner, eventSubscription, c.Scheme()) + if err != nil { + return errors.Wrap(err, "failed to set controller reference") + } + + eventSubscription.Labels = map[string]string{ + eventv1.EventTypeLabelKey: labelutil.NormalizeLabelValue(sub.EventType), + config.BuildLabelKey("zone"): labelutil.NormalizeLabelValue(zoneRef.Name), + config.BuildLabelKey("application"): labelutil.NormalizeLabelValue(owner.Name), + } + + eventSubscription.Spec = eventv1.EventSubscriptionSpec{ + EventType: sub.EventType, + Zone: zoneRef, + Requestor: types.TypedObjectRef{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "application.cp.ei.telekom.de/v1", + }, + ObjectRef: *owner.Status.Application, + }, + Delivery: mapDelivery(sub.Delivery), + Trigger: mapEventTrigger(sub.Trigger), + Scopes: sub.Scopes, + } + + return nil + } + + _, err := c.CreateOrUpdate(ctx, eventSubscription, mutator) + if err != nil { + return errors.Wrap(err, "failed to create or update EventSubscription") + } + + owner.Status.EventSubscriptions = append(owner.Status.EventSubscriptions, types.ObjectRef{ + Name: eventSubscription.Name, + Namespace: eventSubscription.Namespace, + }) + return nil +} + +// mapDelivery converts a rover EventDelivery to an event-domain Delivery. +func mapDelivery(roverDelivery rover.EventDelivery) eventv1.Delivery { + return eventv1.Delivery{ + Type: eventv1.DeliveryType(roverDelivery.Type), + Payload: eventv1.PayloadType(roverDelivery.Payload), + Callback: roverDelivery.Callback, + EventRetentionTime: roverDelivery.EventRetentionTime, + CircuitBreakerOptOut: roverDelivery.CircuitBreakerOptOut, + RetryableStatusCodes: roverDelivery.RetryableStatusCodes, + RedeliveriesPerSecond: roverDelivery.RedeliveriesPerSecond, + EnforceGetHttpRequestMethodForHealthCheck: roverDelivery.EnforceGetHttpRequestMethodForHealthCheck, + } +} diff --git a/rover/internal/handler/rover/event/util.go b/rover/internal/handler/rover/event/util.go new file mode 100644 index 00000000..e203abd4 --- /dev/null +++ b/rover/internal/handler/rover/event/util.go @@ -0,0 +1,15 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package event + +import ( + eventv1 "github.com/telekom/controlplane/event/api/v1" +) + +// MakeName generates a deterministic resource name for an event exposure or subscription. +// It combines the owner (application) name with the normalized event type name. +func MakeName(ownerName, eventType string) string { + return ownerName + "--" + eventv1.MakeEventTypeName(eventType) +} diff --git a/rover/internal/handler/rover/handler.go b/rover/internal/handler/rover/handler.go index 93ccb641..f856c663 100644 --- a/rover/internal/handler/rover/handler.go +++ b/rover/internal/handler/rover/handler.go @@ -9,10 +9,14 @@ import ( "strings" "github.com/go-logr/logr" + "github.com/telekom/controlplane/common/pkg/config" + "github.com/telekom/controlplane/common/pkg/errors/ctrlerrors" "github.com/telekom/controlplane/common/pkg/types" "github.com/telekom/controlplane/common/pkg/util/contextutil" + eventv1 "github.com/telekom/controlplane/event/api/v1" "github.com/telekom/controlplane/rover/internal/handler/rover/api" "github.com/telekom/controlplane/rover/internal/handler/rover/application" + "github.com/telekom/controlplane/rover/internal/handler/rover/event" "github.com/pkg/errors" apiapi "github.com/telekom/controlplane/api/api/v1" @@ -32,6 +36,10 @@ func (h *RoverHandler) CreateOrUpdate(ctx context.Context, roverObj *roverv1.Rov log := logr.FromContextOrDiscard(ctx) c.AddKnownTypeToState(&apiapi.ApiExposure{}) c.AddKnownTypeToState(&apiapi.ApiSubscription{}) + if config.FeaturePubSub.IsEnabled() { + c.AddKnownTypeToState(&eventv1.EventExposure{}) + c.AddKnownTypeToState(&eventv1.EventSubscription{}) + } // Create Application from Rover err := application.HandleApplication(ctx, c, roverObj) @@ -41,17 +49,34 @@ func (h *RoverHandler) CreateOrUpdate(ctx context.Context, roverObj *roverv1.Rov // Handle exposures roverObj.Status.ApiExposures = make([]types.ObjectRef, 0, len(roverObj.Spec.Exposures)) + roverObj.Status.EventExposures = make([]types.ObjectRef, 0, len(roverObj.Spec.Exposures)) + seenDescriminators := make(map[string]struct{}) + for _, exp := range roverObj.Spec.Exposures { switch exp.Type() { case roverv1.TypeApi: + if _, exists := seenDescriminators[exp.Api.BasePath]; exists { + return ctrlerrors.BlockedErrorf("duplicate API base path in exposures: %s", exp.Api.BasePath) + } + seenDescriminators[exp.Api.BasePath] = struct{}{} err := api.HandleExposure(ctx, c, roverObj, exp.Api) if err != nil { return errors.Wrap(err, "failed to handle exposure") } case roverv1.TypeEvent: - log.Info("event exposure not implemented, skipping") - continue + if _, exists := seenDescriminators[exp.Event.EventType]; exists { + return ctrlerrors.BlockedErrorf("duplicate event type in exposures: %s", exp.Event.EventType) + } + seenDescriminators[exp.Event.EventType] = struct{}{} + if !config.FeaturePubSub.IsEnabled() { + log.Info("event exposure skipped, feature has not been enabled") + continue + } + err := event.HandleExposure(ctx, c, roverObj, exp.Event) + if err != nil { + return errors.Wrap(err, "failed to handle event exposure") + } default: return errors.New("unknown exposure type: " + exp.Type().String()) @@ -60,6 +85,7 @@ func (h *RoverHandler) CreateOrUpdate(ctx context.Context, roverObj *roverv1.Rov // Handle subscriptions roverObj.Status.ApiSubscriptions = make([]types.ObjectRef, 0, len(roverObj.Spec.Subscriptions)) + roverObj.Status.EventSubscriptions = make([]types.ObjectRef, 0, len(roverObj.Spec.Subscriptions)) for _, sub := range roverObj.Spec.Subscriptions { switch sub.Type() { case roverv1.TypeApi: @@ -69,8 +95,14 @@ func (h *RoverHandler) CreateOrUpdate(ctx context.Context, roverObj *roverv1.Rov } case roverv1.TypeEvent: - log.Info("event subscription not implemented, skipping") - continue + if !config.FeaturePubSub.IsEnabled() { + log.Info("event subscription skipped, feature has not been enabled") + continue + } + err := event.HandleSubscription(ctx, c, roverObj, sub.Event) + if err != nil { + return errors.Wrap(err, "failed to handle event subscription") + } default: return errors.New("unknown subscription type: " + sub.Type().String()) @@ -96,15 +128,18 @@ func (h *RoverHandler) CreateOrUpdate(ctx context.Context, roverObj *roverv1.Rov } func (h *RoverHandler) Delete(ctx context.Context, rover *roverv1.Rover) error { - envId := contextutil.EnvFromContextOrDie(ctx) - parts := strings.SplitN(rover.GetNamespace(), "--", 2) - teamId := parts[1] - appId := rover.GetName() - err := secretsapi.API().DeleteApplication(ctx, envId, teamId, appId) - if err != nil { - // If this fails, we have an internal problem - rover.SetCondition(condition.NewNotReadyCondition("DeletionFailed", "Failed to delete application from secret manager")) - return errors.Wrap(err, "failed to delete application from secret manager") + + if config.FeatureSecretManager.IsEnabled() { + envId := contextutil.EnvFromContextOrDie(ctx) + parts := strings.SplitN(rover.GetNamespace(), "--", 2) + teamId := parts[1] + appId := rover.GetName() + err := secretsapi.API().DeleteApplication(ctx, envId, teamId, appId) + if err != nil { + // If this fails, we have an internal problem + rover.SetCondition(condition.NewNotReadyCondition("DeletionFailed", "Failed to delete application from secret manager")) + return errors.Wrap(err, "failed to delete application from secret manager") + } } return nil diff --git a/rover/internal/webhook/v1/rover_webhook.go b/rover/internal/webhook/v1/rover_webhook.go index 4b45c970..b8228190 100644 --- a/rover/internal/webhook/v1/rover_webhook.go +++ b/rover/internal/webhook/v1/rover_webhook.go @@ -7,17 +7,19 @@ package v1 import ( "context" "fmt" + "slices" "strings" "github.com/go-logr/logr" "github.com/pkg/errors" adminv1 "github.com/telekom/controlplane/admin/api/v1" + cconfig "github.com/telekom/controlplane/common/pkg/config" "github.com/telekom/controlplane/common/pkg/controller" cerrors "github.com/telekom/controlplane/common/pkg/errors" "github.com/telekom/controlplane/common/pkg/types" + eventv1 "github.com/telekom/controlplane/event/api/v1" organizationv1 "github.com/telekom/controlplane/organization/api/v1" roverv1 "github.com/telekom/controlplane/rover/api/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" @@ -52,7 +54,7 @@ type RoverDefaulter struct { var _ webhook.CustomDefaulter = &RoverDefaulter{} func (r *RoverDefaulter) Default(ctx context.Context, obj runtime.Object) error { - roverlog.Info("default") + roverlog.V(2).Info("default") rover, ok := obj.(*roverv1.Rover) if !ok { return apierrors.NewBadRequest("not a rover") @@ -72,6 +74,7 @@ func (r *RoverDefaulter) Default(ctx context.Context, obj runtime.Object) error // +kubebuilder:webhook:path=/validate-rover-cp-ei-telekom-de-v1-rover,mutating=false,failurePolicy=fail,sideEffects=None,groups=rover.cp.ei.telekom.de,resources=rovers,verbs=create;update,versions=v1,name=vrover.kb.io,admissionReviewVersions=v1 // +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch +// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=eventconfigs,verbs=get;list;watch type RoverValidator struct { client client.Client @@ -80,19 +83,19 @@ type RoverValidator struct { var _ webhook.CustomValidator = &RoverValidator{} func (r *RoverValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { - roverlog.Info("validate create") + roverlog.V(2).Info("validate create") return r.ValidateCreateOrUpdate(ctx, obj) } func (r *RoverValidator) ValidateUpdate(ctx context.Context, oldObj, obj runtime.Object) (admission.Warnings, error) { - roverlog.Info("validate update") + roverlog.V(2).Info("validate update") return r.ValidateCreateOrUpdate(ctx, obj) } func (r *RoverValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { - roverlog.Info("validate delete") + roverlog.V(2).Info("validate delete") return nil, nil // No validation needed on delete } @@ -106,7 +109,7 @@ func (r *RoverValidator) ValidateCreateOrUpdate(ctx context.Context, obj runtime log := roverlog.WithValues("name", rover.GetName(), "namespace", rover.GetNamespace()) ctx = logr.NewContext(ctx, log) - log.Info("validate create or update") + log.V(2).Info("validate create or update") valErr := cerrors.NewValidationError(roverv1.GroupVersion.WithKind("Rover").GroupKind(), rover) @@ -120,7 +123,8 @@ func (r *RoverValidator) ValidateCreateOrUpdate(ctx context.Context, obj runtime Name: rover.Spec.Zone, Namespace: environment, } - if exists, err := r.ResourceMustExist(ctx, zoneRef, &adminv1.Zone{}); !exists { + zone := &adminv1.Zone{} + if exists, err := r.ResourceMustExist(ctx, zoneRef, zone); !exists { if err != nil { return nil, err } @@ -128,19 +132,42 @@ func (r *RoverValidator) ValidateCreateOrUpdate(ctx context.Context, obj runtime return nil, valErr.BuildError() } + // Validate that if the rover subscribes to or exposes events, the zone actually supports it + + subscribesToEvents := slices.ContainsFunc(rover.Spec.Subscriptions, func(sub roverv1.Subscription) bool { + return sub.Type() == roverv1.TypeEvent + }) + exposesEvents := slices.ContainsFunc(rover.Spec.Exposures, func(exp roverv1.Exposure) bool { + return exp.Type() == roverv1.TypeEvent + }) + + if cconfig.FeaturePubSub.IsEnabled() && (subscribesToEvents || exposesEvents) { + eventConfigRef := client.ObjectKey{ + Name: environment, + Namespace: zone.Status.Namespace, + } + eventConfig := eventv1.EventConfig{} + if exists, err := r.ResourceMustExist(ctx, eventConfigRef, &eventConfig); !exists { + if err != nil { + return nil, err + } + valErr.AddInvalidError(field.NewPath("spec").Child("zone"), rover.Spec.Zone, fmt.Sprintf("zone '%s' does not support event subscriptions or exposures", rover.Spec.Zone)) + } + } + if err := MustNotHaveDuplicates(valErr, rover.Spec.Subscriptions, rover.Spec.Exposures); err != nil { return nil, err } for i, sub := range rover.Spec.Subscriptions { - log.Info("validate subscription", "index", i, "subscription", sub) + log.V(2).Info("validate subscription", "index", i, "subscription", sub) if err := r.ValidateSubscription(ctx, valErr, environment, sub, i); err != nil { return nil, err } } for i, exposure := range rover.Spec.Exposures { - log.Info("validate exposure", "index", i, "exposure", exposure) + log.V(2).Info("validate exposure", "index", i, "exposure", exposure) if err := r.ValidateExposure(ctx, valErr, environment, exposure, zoneRef, i); err != nil { return nil, err } @@ -160,79 +187,20 @@ func (r *RoverValidator) ResourceMustExist(ctx context.Context, objRef client.Ob return true, nil } -func (r *RoverValidator) ValidateSubscription(ctx context.Context, valErr *cerrors.ValidationError, environment string, sub roverv1.Subscription, idx int) error { - logr.FromContextOrDiscard(ctx).Info("validate subscription") - - if sub.Api != nil && sub.Api.Organization != "" { - remoteOrgRef := client.ObjectKey{ - Name: sub.Api.Organization, - Namespace: environment, - } - if found, err := r.ResourceMustExist(ctx, remoteOrgRef, &adminv1.RemoteOrganization{}); !found { - if err != nil { - return err - } - valErr.AddInvalidError( - field.NewPath("spec").Child("subscriptions").Index(idx).Child("api").Child("organization"), - sub.Api.Organization, fmt.Sprintf("remote organization '%s' not found", sub.Api.Organization), - ) - } - } - - return nil -} - func (r *RoverValidator) ValidateExposure(ctx context.Context, valErr *cerrors.ValidationError, environment string, exposure roverv1.Exposure, zoneRef client.ObjectKey, idx int) error { - if exposure.Api != nil { - for _, upstream := range exposure.Api.Upstreams { - if upstream.URL == "" { - valErr.AddRequiredError( - field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams").Index(0).Child("url"), - "upstream URL must not be empty", - ) - // Skip further URL validation if it's empty - continue - } - if !strings.HasPrefix(upstream.URL, "http://") && !strings.HasPrefix(upstream.URL, "https://") { - valErr.AddInvalidError( - field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams").Index(0).Child("url"), - upstream.URL, "upstream URL must start with http:// or https://", - ) - } - if strings.Contains(upstream.URL, "localhost") { - valErr.AddInvalidError( - field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams").Index(0).Child("url"), - upstream.URL, "upstream URL must not contain 'localhost'", - ) - } - } - - // Validate rate limits if they are set - if err := r.validateExposureRateLimit(ctx, valErr, exposure, idx); err != nil { - return errors.Wrap(err, "failed to validate exposure rate limits") - } - - // Check if all upstreams have a weight set or none - all, none := CheckWeightSetOnAllOrNone(exposure.Api.Upstreams) - if !all && !none { - valErr.AddInvalidError( - field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams"), - exposure.Api.Upstreams, "all upstreams must have a weight set or none must have a weight set", - ) - } - - if err := r.validateApproval(ctx, valErr, environment, exposure.Api.Approval); err != nil { - return errors.Wrap(err, "failed to validate approval") - } + switch exposure.Type() { + case roverv1.TypeApi: + return r.ValidateApiExposure(ctx, valErr, environment, exposure, zoneRef, idx) + case roverv1.TypeEvent: + return r.ValidateEventExposure(ctx, valErr, environment, exposure, zoneRef, idx) + default: + valErr.AddInvalidError( + field.NewPath("spec").Child("exposures").Index(idx).Child("type"), + exposure.Type(), fmt.Sprintf("unknown exposure type %q", exposure.Type()), + ) + return nil } - - // Header removal is generally allowed everywhere, except the "Authorization" header, which is only allowed to be configured for removal on external zones - currently space/canis - if err := r.validateRemoveHeaders(ctx, valErr, exposure, zoneRef, idx); err != nil { - return err - } - - return nil } func (r *RoverValidator) validateExposureRateLimit(ctx context.Context, valErr *cerrors.ValidationError, exposure roverv1.Exposure, idx int) error { @@ -456,3 +424,102 @@ func (r *RoverValidator) GetTeam(ctx context.Context, teamRef client.ObjectKey) return team, err } + +func (r *RoverValidator) ValidateEventExposure(ctx context.Context, valErr *cerrors.ValidationError, environment string, exposure roverv1.Exposure, zoneRef client.ObjectKey, idx int) error { + if exposure.Event == nil { + return nil + } + + if !cconfig.FeaturePubSub.IsEnabled() { + return nil + } + + if err := r.validateApproval(ctx, valErr, environment, exposure.Event.Approval); err != nil { + return errors.Wrap(err, "failed to validate approval") + } + + return nil +} + +func (r *RoverValidator) ValidateApiExposure(ctx context.Context, valErr *cerrors.ValidationError, environment string, exposure roverv1.Exposure, zoneRef client.ObjectKey, idx int) error { + if exposure.Api == nil { + return nil + } + + for _, upstream := range exposure.Api.Upstreams { + if upstream.URL == "" { + valErr.AddRequiredError( + field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams").Index(0).Child("url"), + "upstream URL must not be empty", + ) + // Skip further URL validation if it's empty + continue + } + if !strings.HasPrefix(upstream.URL, "http://") && !strings.HasPrefix(upstream.URL, "https://") { + valErr.AddInvalidError( + field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams").Index(0).Child("url"), + upstream.URL, "upstream URL must start with http:// or https://", + ) + } + if strings.Contains(upstream.URL, "localhost") { + valErr.AddInvalidError( + field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams").Index(0).Child("url"), + upstream.URL, "upstream URL must not contain 'localhost'", + ) + } + } + + // Validate rate limits if they are set + if err := r.validateExposureRateLimit(ctx, valErr, exposure, idx); err != nil { + return errors.Wrap(err, "failed to validate exposure rate limits") + } + + // Check if all upstreams have a weight set or none + all, none := CheckWeightSetOnAllOrNone(exposure.Api.Upstreams) + if !all && !none { + valErr.AddInvalidError( + field.NewPath("spec").Child("exposures").Index(idx).Child("api").Child("upstreams"), + exposure.Api.Upstreams, "all upstreams must have a weight set or none must have a weight set", + ) + } + + if err := r.validateApproval(ctx, valErr, environment, exposure.Api.Approval); err != nil { + return errors.Wrap(err, "failed to validate approval") + } + + // Header removal is generally allowed everywhere, except the "Authorization" header, which is only allowed to be configured for removal on external zones - currently space/canis + if err := r.validateRemoveHeaders(ctx, valErr, exposure, zoneRef, idx); err != nil { + return err + } + + return nil +} + +func (r *RoverValidator) ValidateSubscription(ctx context.Context, valErr *cerrors.ValidationError, environment string, sub roverv1.Subscription, idx int) error { + switch sub.Type() { + case roverv1.TypeApi: + // TODO: in the future this might also be relevant for event + if sub.Api.Organization != "" { + remoteOrgRef := client.ObjectKey{ + Name: sub.Api.Organization, + Namespace: environment, + } + if found, err := r.ResourceMustExist(ctx, remoteOrgRef, &adminv1.RemoteOrganization{}); !found { + if err != nil { + return err + } + valErr.AddInvalidError( + field.NewPath("spec").Child("subscriptions").Index(idx).Child("api").Child("organization"), + sub.Api.Organization, fmt.Sprintf("remote organization '%s' not found", sub.Api.Organization), + ) + } + } + return nil + + case roverv1.TypeEvent: + // There is no special validation needed at this time. + return nil + } + + return nil +} diff --git a/rover/internal/webhook/v1/secrets.go b/rover/internal/webhook/v1/secrets.go index 2174cd8c..4dbef024 100644 --- a/rover/internal/webhook/v1/secrets.go +++ b/rover/internal/webhook/v1/secrets.go @@ -11,6 +11,7 @@ import ( "github.com/go-logr/logr" "github.com/pkg/errors" + "github.com/telekom/controlplane/common/pkg/config" "github.com/telekom/controlplane/common/pkg/controller" "github.com/telekom/controlplane/common/pkg/util/labelutil" roverv1 "github.com/telekom/controlplane/rover/api/v1" @@ -130,6 +131,12 @@ func SetExternalSecrets(ctx context.Context, rover *roverv1.Rover, availableSecr func OnboardApplication(ctx context.Context, rover *roverv1.Rover, secretManager secretsapi.SecretManager) error { log := logr.FromContextOrDiscard(ctx) + + if !config.FeatureSecretManager.IsEnabled() { + log.Info("Secret Manager integration is disabled, skipping onboarding") + return nil + } + if secretManager == nil { return nil }