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
+ 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(+
+ 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