diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3dc6895 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +*.iml +/event-publisher-nats +/event-publisher-proxy +vendor +licenses diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..1afe962 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,281 @@ +# This code is licensed under the terms of the MIT license. + +## Golden config for golangci-lint v1.49.0 +# +# This is the best config for golangci-lint based on my experience and opinion. +# It is very strict, but not extremely strict. +# Feel free to adopt and change it for your needs. + +run: + # Timeout for analysis, e.g. 30s, 5m. + # Default: 1m + timeout: 3m + + +# This file contains only configs which differ from defaults. +# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml +linters-settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled + # Default: 0.0 + package-average: 10.0 + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + + gocognit: + # Minimal code complexity to report + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be find in https://go-critic.github.io/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + gomnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date` + # Default: [] + ignored-functions: + - os.Chmod + - os.Mkdir + - os.MkdirAll + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets + - prometheus.ExponentialBucketsRange + - prometheus.LinearBuckets + - strconv.FormatFloat + - strconv.FormatInt + - strconv.FormatUint + - strconv.ParseFloat + - strconv.ParseInt + - strconv.ParseUint + + gomodguard: + blocked: + # List of blocked modules. + # Default: [] + modules: + - github.com/golang/protobuf: + recommendations: + - google.golang.org/protobuf + reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" + - github.com/satori/go.uuid: + recommendations: + - github.com/google/uuid + reason: "satori's package is not maintained" + - github.com/gofrs/uuid: + recommendations: + - github.com/google/uuid + reason: "see recommendation from dev-infra team: https://confluence.gtforge.com/x/gQI6Aw" + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `go tool vet help` to see all analyzers. + # Default: [] + disable: + - fieldalignment # too strict + # Settings per analyzer. + settings: + shadow: + # Whether to be strict about shadowing; can be noisy. + # Default: false + strict: false + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit, lll ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + rowserrcheck: + # database/sql is always checked + # Default: [] + packages: + - github.com/jmoiron/sqlx + + tenv: + # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. + # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. + # Default: false + all: true + + +linters: + disable-all: true + enable: + ## enabled by default + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - gosimple # specializes in simplifying a code + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - typecheck # like the front-end of a Go compiler, parses and type-checks Go code + - unused # checks for unused constants, variables, functions and types + ## disabled by default + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + #- contextcheck # checks the function whether use a non-inherited context # TODO: enable after golangci-lint uses https://github.com/sylvia7788/contextcheck/releases/tag/v1.0.7 + - cyclop # checks function and package cyclomatic complexity + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - execinquery # checks query string in Query function which reads your Go src files and warning it finds + - exhaustive # checks exhaustiveness of enum switch statements + - exportloopref # checks for pointers to enclosing loop variables + - forbidigo # forbids identifiers + - funlen # tool for detection of long functions + - gochecknoglobals # checks that no global variables exist + - gochecknoinits # checks that no init functions are present in Go code + - gocognit # computes and checks the cognitive complexity of functions + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godot # checks if comments end in a period + - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt + - gomnd # detects magic numbers + ## - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - lll # reports long lines + - makezero # finds slice declarations with non-zero initial length + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - stylecheck # is a replacement for golint + - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 + # - testpackage # makes you use a separate _test package + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # checks if all structure fields are initialized + #- gci # controls golang package import order and makes it always deterministic + #- godox # detects FIXME, TODO and other comment keywords + #- goheader # checks is file header matches to pattern + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + + ## disabled + #- containedctx # detects struct contained context.Context field + #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- goerr113 # [too strict] checks the errors handling expressions + #- gofmt # [replaced by goimports] checks whether code was gofmt-ed + #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- logrlint # [owner archived repository] checks logr arguments + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + + ## deprecated + #- deadcode # [deprecated, replaced by unused] finds unused code + #- exhaustivestruct # [deprecated, replaced by exhaustruct] checks if all struct's fields are initialized + #- golint # [deprecated, replaced by revive] golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes + #- ifshort # [deprecated] checks that your code uses short syntax for if-statements whenever possible + #- interfacer # [deprecated] suggests narrower interface types + #- maligned # [deprecated, replaced by govet fieldalignment] detects Go structs that would take less memory if their fields were sorted + #- nosnakecase # [deprecated, replaced by revive var-naming] detects snake case of variable naming and function name + #- scopelint # [deprecated, replaced by exportloopref] checks for unpinned variables in go programs + #- structcheck # [deprecated, replaced by unused] finds unused struct fields + #- varcheck # [deprecated, replaced by unused] finds unused global variables and constants + + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + + exclude-rules: + - source: "^//\\s*go:generate\\s" + linters: [ lll ] + - source: "(noinspection|TODO)" + linters: [ godot ] + - source: "//noinspection" + linters: [ gocritic ] + - source: "^\\s+if _, ok := err\\.\\([^.]+\\.InternalError\\); ok {" + linters: [ errorlint ] + - path: "_test\\.go" + linters: + - bodyclose + - dupl + - funlen + - goconst + - gosec + - noctx + - wrapcheck + - text: 'shadow: declaration of "(err|ctx)" shadows declaration at' + linters: [ govet ] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61a5c87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM europe-docker.pkg.dev/kyma-project/prod/external/golang:1.21.3-alpine3.18 as builder + +ARG DOCK_PKG_DIR=/go/src/github.com/kyma-project/kyma/components/event-publisher-proxy + +WORKDIR $DOCK_PKG_DIR +COPY . $DOCK_PKG_DIR + +RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on go build -o event-publisher-proxy ./cmd/event-publisher-proxy + +FROM gcr.io/distroless/static:nonroot +LABEL source = git@github.com:kyma-project/kyma.git +USER nonroot:nonroot + +WORKDIR / +COPY --from=builder /go/src/github.com/kyma-project/kyma/components/event-publisher-proxy/event-publisher-proxy . + + +ENTRYPOINT ["/event-publisher-proxy"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6969685 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +APP_NAME = event-publisher-proxy +APP_PATH = components/$(APP_NAME) +BUILDPACK = eu.gcr.io/kyma-project/test-infra/buildpack-golang:v20220407-4da6c929 +SCRIPTS_DIR = $(realpath $(shell pwd)/../..)/common/makefiles + +# fail on lint issues +override IGNORE_LINTING_ISSUES = +override ENTRYPOINT = cmd/main.go + +include $(SCRIPTS_DIR)/generic-make-go.mk + +VERIFY_IGNORE := /vendor\|/mocks + +release: + $(MAKE) gomod-release-local + +path-to-referenced-charts: + @echo "resources/event-publisher-proxy" + +.PHONY: clean +clean: resolve_clean + +resolve_clean: + rm -rf vendor + +build-local: test-local + +.PHONY: generate +generate: + go generate ./... diff --git a/cmd/event-publisher-proxy/main.go b/cmd/event-publisher-proxy/main.go new file mode 100644 index 0000000..d4174ac --- /dev/null +++ b/cmd/event-publisher-proxy/main.go @@ -0,0 +1,94 @@ +package main + +import ( + golog "log" + + "github.com/kelseyhightower/envconfig" + kymalogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/prometheus/client_golang/prometheus" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/commander" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/commander/eventmesh" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/commander/nats" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/latency" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" +) + +const ( + backendEventMesh = "beb" + backendNATS = "nats" +) + +type Config struct { + // Backend used for Eventing. It could be "nats" or "beb". + Backend string `envconfig:"BACKEND" required:"true"` + + // AppLogFormat defines the log format. + AppLogFormat string `envconfig:"APP_LOG_FORMAT" default:"json"` + + // AppLogLevel defines the log level. + AppLogLevel string `envconfig:"APP_LOG_LEVEL" default:"info"` +} + +func main() { + opts := options.New() + if err := opts.Parse(); err != nil { + golog.Fatalf("Failed to parse options, error: %v", err) + } + + // parse the config for main: + cfg := new(Config) + if err := envconfig.Process("", cfg); err != nil { + golog.Fatalf("Failed to read configuration, error: %v", err) + } + + // init the logger + logger, err := kymalogger.New(cfg.AppLogFormat, cfg.AppLogLevel) + if err != nil { + golog.Fatalf("Failed to initialize logger, error: %v", err) + } + defer func() { + if err := logger.WithContext().Sync(); err != nil { + golog.Printf("Failed to flush logger, error: %v", err) + } + }() + setupLogger := logger.WithContext().With("backend", cfg.Backend) + + // metrics collector + metricsCollector := metrics.NewCollector(latency.NewBucketsProvider()) + prometheus.MustRegister(metricsCollector) + metricsCollector.SetHealthStatus(true) + + // Instantiate configured commander. + var c commander.Commander + switch cfg.Backend { + case backendEventMesh: + c = eventmesh.NewCommander(opts, metricsCollector, logger) + case backendNATS: + c = nats.NewCommander(opts, metricsCollector, logger) + default: + setupLogger.Fatalf("Invalid publisher backend: %v", cfg.Backend) + } + + // Init the commander. + if err := c.Init(); err != nil { + setupLogger.Fatalw("Commander initialization failed", "error", err) + } + + // Start the metrics server. + metricsServer := metrics.NewServer(logger) + defer metricsServer.Stop() + if err := metricsServer.Start(opts.MetricsAddress); err != nil { + setupLogger.Infow("Failed to start metrics server", "error", err) + } + + setupLogger.Infof("Starting publisher to: %v", cfg.Backend) + + // Start the commander. + if err := c.Start(); err != nil { + setupLogger.Fatalw("Failed to to start publisher", "error", err) + } + + setupLogger.Info("Shutdown the Event Publisher") +} diff --git a/config/event-publisher-nats/100-service.yaml b/config/event-publisher-nats/100-service.yaml new file mode 100644 index 0000000..58a7aad --- /dev/null +++ b/config/event-publisher-nats/100-service.yaml @@ -0,0 +1,40 @@ +apiVersion: v1 +kind: Service +metadata: + name: eventing-event-publisher-proxy + labels: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: eventing-event-publisher-proxy-metrics + labels: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing + ports: + - name: http-metrics + port: 80 + protocol: TCP + targetPort: http-metrics +--- diff --git a/config/event-publisher-nats/110-clusterrole.yaml b/config/event-publisher-nats/110-clusterrole.yaml new file mode 100644 index 0000000..78755d5 --- /dev/null +++ b/config/event-publisher-nats/110-clusterrole.yaml @@ -0,0 +1,23 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: eventing-event-publisher-nats + labels: + app.kubernetes.io/instance: eventing +rules: + - apiGroups: + - eventing.kyma-project.io + resources: + - subscriptions + verbs: + - get + - list + - watch + - apiGroups: + - applicationconnector.kyma-project.io + resources: + - applications + verbs: + - get + - list + - watch diff --git a/config/event-publisher-nats/115-clusterrolebinding.yaml b/config/event-publisher-nats/115-clusterrolebinding.yaml new file mode 100644 index 0000000..fbedd53 --- /dev/null +++ b/config/event-publisher-nats/115-clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: eventing-event-publisher-nats + labels: + app.kubernetes.io/instance: eventing +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: eventing-event-publisher-nats +subjects: + - kind: ServiceAccount + name: eventing-event-publisher-nats + namespace: kyma-system # Note: use the same namespace used by ko apply diff --git a/config/event-publisher-nats/125-serviceaccount.yaml b/config/event-publisher-nats/125-serviceaccount.yaml new file mode 100644 index 0000000..7d328b5 --- /dev/null +++ b/config/event-publisher-nats/125-serviceaccount.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: eventing-event-publisher-nats + labels: + app.kubernetes.io/instance: eventing diff --git a/config/event-publisher-nats/200-deployment.yaml b/config/event-publisher-nats/200-deployment.yaml new file mode 100644 index 0000000..b1a58e8 --- /dev/null +++ b/config/event-publisher-nats/200-deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: eventing-event-publisher-nats + labels: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing + strategy: + type: RollingUpdate + template: + metadata: + labels: + app.kubernetes.io/instance: eventing + app.kubernetes.io/name: event-publisher-nats + kyma-project.io/dashboard: eventing + spec: + serviceAccountName: eventing-event-publisher-nats + containers: + - env: + - name: BACKEND + value: "nats" + - name: PORT + value: "8080" + - name: NATS_URL + value: eventing-nats.kyma-system.svc.cluster.local + - name: REQUEST_TIMEOUT + value: 5s + - name: LEGACY_NAMESPACE + value: kyma + - name: EVENT_TYPE_PREFIX + value: sap.kyma.custom + - name: APP_LOG_FORMAT + value: "json" + - name: APP_LOG_LEVEL + value: "info" + image: ko://github.com/kyma-project/kyma/components/event-publisher-proxy/cmd/event-publisher-proxy + imagePullPolicy: IfNotPresent + name: event-publisher-proxy + ports: + - containerPort: 8080 + name: http + protocol: TCP + - containerPort: 9090 + name: http-metrics + protocol: TCP + livenessProbe: + failureThreshold: 3 + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 2 + successThreshold: 1 + timeoutSeconds: 1 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /readyz + port: 8080 + scheme: HTTP +--- diff --git a/config/event-publisher-proxy/100-secret.yaml b/config/event-publisher-proxy/100-secret.yaml new file mode 100644 index 0000000..e9c88ea --- /dev/null +++ b/config/event-publisher-proxy/100-secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +data: + client-id: ZmFrZS1pZA== + client-secret: ZmFrZS1zZWNyZXQ= + token-endpoint: ZmFrZS10b2tlbi1lcA== + ems-publish-url: ZmFrZS1lbXMtcHVibGlzaC11cmw= +kind: Secret +metadata: + labels: + app: event-publisher-proxy + name: event-publisher-proxy diff --git a/config/event-publisher-proxy/200-service.yaml b/config/event-publisher-proxy/200-service.yaml new file mode 100644 index 0000000..3b4b0e7 --- /dev/null +++ b/config/event-publisher-proxy/200-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: event-publisher-proxy +spec: + type: ClusterIP + selector: + app: event-publisher-proxy + ports: + - protocol: TCP + port: 80 + targetPort: 8080 diff --git a/config/event-publisher-proxy/300-deployment.yaml b/config/event-publisher-proxy/300-deployment.yaml new file mode 100644 index 0000000..1c1782b --- /dev/null +++ b/config/event-publisher-proxy/300-deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: event-publisher-proxy + name: event-publisher-proxy +spec: + replicas: 1 + selector: + matchLabels: + app: event-publisher-proxy + strategy: + type: RollingUpdate + template: + metadata: + labels: + app: event-publisher-proxy + spec: + containers: + - env: + - name: BACKEND + value: "beb" + - name: CLIENT_ID + valueFrom: + secretKeyRef: + name: event-publisher-proxy + key: client-id + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: + name: event-publisher-proxy + key: client-secret + - name: TOKEN_ENDPOINT + valueFrom: + secretKeyRef: + name: event-publisher-proxy + key: token-endpoint + - name: EMS_PUBLISH_URL + valueFrom: + secretKeyRef: + name: event-publisher-proxy + key: ems-publish-url + - name: APP_LOG_FORMAT + value: "json" + - name: APP_LOG_LEVEL + value: "info" + image: ko://github.com/kyma-project/kyma/components/event-publisher-proxy/cmd/event-publisher-proxy + imagePullPolicy: IfNotPresent + name: event-publisher-proxy + ports: + - containerPort: 8080 + name: http + protocol: TCP + livenessProbe: + failureThreshold: 3 + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 2 + successThreshold: 1 + timeoutSeconds: 1 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /readyz + port: 8080 + scheme: HTTP diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d9b1e14 --- /dev/null +++ b/go.mod @@ -0,0 +1,92 @@ +module github.com/kyma-project/kyma/components/event-publisher-proxy + +go 1.21 + +require ( + github.com/cloudevents/sdk-go/v2 v2.14.0 + github.com/google/uuid v1.3.1 + github.com/gorilla/mux v1.8.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/kyma-project/kyma/components/application-operator v0.0.0-20230127165033-ec8e43477eca + github.com/kyma-project/kyma/components/eventing-controller v0.0.0-20231023131930-0990d091c639 + github.com/nats-io/nats-server/v2 v2.10.3 + github.com/nats-io/nats.go v1.31.0 + github.com/onsi/gomega v1.28.1 + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.17.0 + github.com/stretchr/testify v1.8.4 + go.opencensus.io v0.24.0 + go.uber.org/zap v1.26.0 + golang.org/x/oauth2 v0.13.0 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 + k8s.io/api v0.28.3 + k8s.io/apimachinery v0.28.3 + k8s.io/client-go v0.28.3 + sigs.k8s.io/controller-runtime v0.16.3 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/zapr v1.2.4 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/kyma-project/kyma/common/logging v0.0.0-20231020092259-d58329d50da1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/minio/highwayhash v1.0.2 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/jwt/v2 v2.5.2 // indirect + github.com/nats-io/nkeys v0.4.5 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.28.3 // indirect + k8s.io/component-base v0.28.3 // indirect + k8s.io/klog/v2 v2.100.1 // indirect + k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.3.0 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) + +replace github.com/prometheus/client_golang => github.com/prometheus/client_golang v1.14.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..691e680 --- /dev/null +++ b/go.sum @@ -0,0 +1,352 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/avast/retry-go/v3 v3.1.1 h1:49Scxf4v8PmiQ/nY0aY3p0hDueqSmc7++cBbtiDGu2g= +github.com/avast/retry-go/v3 v3.1.1/go.mod h1:6cXRK369RpzFL3UQGqIUp9Q7GDrams+KsYWrfNA1/nQ= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +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/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudevents/sdk-go/v2 v2.14.0 h1:Nrob4FwVgi5L4tV9lhjzZcjYqFVyJzsA56CwPaPfv6s= +github.com/cloudevents/sdk-go/v2 v2.14.0/go.mod h1:xDmKfzNjM8gBvjaF8ijFjM1VYOVUEeUfapHMUX1T5To= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= +github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +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/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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/kyma-project/api-gateway v0.0.0-20231020123059-319383e7e6e5 h1:9ycfI/VSV9kj7jbheCzRs8+bfCZ8KENjZrJuz+citjA= +github.com/kyma-project/api-gateway v0.0.0-20231020123059-319383e7e6e5/go.mod h1:ZMaRPv9fLs1rUlKMOjsdNh6B4R6aALVqypeQam6mqBg= +github.com/kyma-project/kyma/common/logging v0.0.0-20231020092259-d58329d50da1 h1:Lur/R654ghUmsZiNrSrQBDjxCAvkb/7CueB0X/VQbKg= +github.com/kyma-project/kyma/common/logging v0.0.0-20231020092259-d58329d50da1/go.mod h1:JGb5RBi8Uz+RZ/jf54+qA+RqY6uPQBJ8pO1w3KSwm1Q= +github.com/kyma-project/kyma/components/application-operator v0.0.0-20230127165033-ec8e43477eca h1:7UpCIk6+sMCOhPfolAlppRugSln5M4T8/dHJm8x0erc= +github.com/kyma-project/kyma/components/application-operator v0.0.0-20230127165033-ec8e43477eca/go.mod h1:Tog02gZ1VT7yvFmhSqmiuGZpDYt18zTF4kr6E0N9ttk= +github.com/kyma-project/kyma/components/eventing-controller v0.0.0-20231023131930-0990d091c639 h1:50OACQOQhG1KheBrBI3fT7iXKnc13bHX32CWiH4P60A= +github.com/kyma-project/kyma/components/eventing-controller v0.0.0-20231023131930-0990d091c639/go.mod h1:sE1dyneTNw8RyyDsl1KSS+9rhMRcaZ2nE8JfwuOOGAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= +github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/jwt/v2 v2.5.2 h1:DhGH+nKt+wIkDxM6qnVSKjokq5t59AZV5HRcFW0zJwU= +github.com/nats-io/jwt/v2 v2.5.2/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI= +github.com/nats-io/nats-server/v2 v2.10.3 h1:nk2QVLpJUh3/AhZCJlQdTfj2oeLDvWnn1Z6XzGlNFm0= +github.com/nats-io/nats-server/v2 v2.10.3/go.mod h1:lzrskZ/4gyMAh+/66cCd+q74c6v7muBypzfWhP/MAaM= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= +github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= +github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= +github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.28.1 h1:MijcGUbfYuznzK/5R4CPNoUP/9Xvuo20sXfEm6XxoTA= +github.com/onsi/gomega v1.28.1/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= +github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +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.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +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.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +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= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +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/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.28.3 h1:Gj1HtbSdB4P08C8rs9AR94MfSGpRhJgsS+GF9V26xMM= +k8s.io/api v0.28.3/go.mod h1:MRCV/jr1dW87/qJnZ57U5Pak65LGmQVkKTzf3AtKFHc= +k8s.io/apiextensions-apiserver v0.28.3 h1:Od7DEnhXHnHPZG+W9I97/fSQkVpVPQx2diy+2EtmY08= +k8s.io/apiextensions-apiserver v0.28.3/go.mod h1:NE1XJZ4On0hS11aWWJUTNkmVB03j9LM7gJSisbRt8Lc= +k8s.io/apimachinery v0.28.3 h1:B1wYx8txOaCQG0HmYF6nbpU8dg6HvA06x5tEffvOe7A= +k8s.io/apimachinery v0.28.3/go.mod h1:uQTKmIqs+rAYaq+DFaoD2X7pcjLOqbQX2AOiO0nIpb8= +k8s.io/client-go v0.28.3 h1:2OqNb72ZuTZPKCl+4gTKvqao0AMOl9f3o2ijbAj3LI4= +k8s.io/client-go v0.28.3/go.mod h1:LTykbBp9gsA7SwqirlCXBWtK0guzfhpoW4qSm7i9dxo= +k8s.io/component-base v0.28.3 h1:rDy68eHKxq/80RiMb2Ld/tbH8uAE75JdCqJyi6lXMzI= +k8s.io/component-base v0.28.3/go.mod h1:fDJ6vpVNSk6cRo5wmDa6eKIG7UlIQkaFmZN2fYgIUD8= +k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= +k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 h1:LyMgNKD2P8Wn1iAwQU5OhxCKlKJy0sHc+PcDwFB24dQ= +k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.16.3 h1:2TuvuokmfXvDUamSx1SuAOO3eTyye+47mJCigwG62c4= +sigs.k8s.io/controller-runtime v0.16.3/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.3.0 h1:UZbZAZfX0wV2zr7YZorDz6GXROfDFj6LvqCRm4VUVKk= +sigs.k8s.io/structured-merge-diff/v4 v4.3.0/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/httpconsts.go b/internal/httpconsts.go new file mode 100644 index 0000000..9d134c2 --- /dev/null +++ b/internal/httpconsts.go @@ -0,0 +1,12 @@ +package internal + +const ( + HeaderContentType = "Content-Type" + ContentTypeApplicationJSON = "application/json" + ContentTypeApplicationCloudEventsJSON = "application/cloudevents+json" + + CeIDHeader = "ce-id" + CeTypeHeader = "ce-type" + CeSourceHeader = "ce-source" + CeSpecVersionHeader = "ce-specversion" +) diff --git a/pkg/application/applicationtest/applicationtest.go b/pkg/application/applicationtest/applicationtest.go new file mode 100644 index 0000000..0755a37 --- /dev/null +++ b/pkg/application/applicationtest/applicationtest.go @@ -0,0 +1,17 @@ +// Package applicationtest provides utilities for Application testing. +package applicationtest + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + applicationv1alpha1 "github.com/kyma-project/kyma/components/application-operator/pkg/apis/applicationconnector/v1alpha1" +) + +func NewApplication(name string, labels map[string]string) *applicationv1alpha1.Application { + return &applicationv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + } +} diff --git a/pkg/application/clean.go b/pkg/application/clean.go new file mode 100644 index 0000000..e7a3f28 --- /dev/null +++ b/pkg/application/clean.go @@ -0,0 +1,60 @@ +package application + +import ( + "regexp" + "strings" + + applicationv1alpha1 "github.com/kyma-project/kyma/components/application-operator/pkg/apis/applicationconnector/v1alpha1" +) + +const ( + // TypeLabel is an optional label for the application to determine its type. + TypeLabel = "application-type" +) + +var ( + // invalidApplicationNameSegment used to match and replace none-alphanumeric characters in the application name. + invalidApplicationNameSegment = regexp.MustCompile(`\W|_`) +) + +// GetCleanTypeOrName cleans the application name form none-alphanumeric characters and returns it +// if the application type label exists, it will be cleaned and returned instead of the application name. +func GetCleanTypeOrName(application *applicationv1alpha1.Application) string { + if application == nil { + return "" + } + applicationName := application.Name + for k, v := range application.Labels { + if strings.ToLower(k) == TypeLabel { + applicationName = v + break + } + } + return GetCleanName(applicationName) +} + +// GetTypeOrName returns the application name. +// if the application type label exists, it will be returned instead of the application name. +func GetTypeOrName(application *applicationv1alpha1.Application) string { + if application == nil { + return "" + } + applicationName := application.Name + for k, v := range application.Labels { + if strings.ToLower(k) == TypeLabel { + applicationName = v + break + } + } + return applicationName +} + +// GetCleanName cleans the name form none-alphanumeric characters and returns the clean name. +func GetCleanName(name string) string { + return invalidApplicationNameSegment.ReplaceAllString(name, "") +} + +// IsCleanName returns true if the name contains alphanumeric characters only, otherwise returns false. +func IsCleanName(name string) bool { + return !invalidApplicationNameSegment.MatchString(name) +} diff --git a/pkg/application/clean_test.go b/pkg/application/clean_test.go new file mode 100644 index 0000000..16cb9aa --- /dev/null +++ b/pkg/application/clean_test.go @@ -0,0 +1,139 @@ +package application + +import ( + "testing" + + "github.com/stretchr/testify/require" + + applicationv1alpha1 "github.com/kyma-project/kyma/components/application-operator/pkg/apis/applicationconnector/v1alpha1" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" +) + +func TestCleanName(t *testing.T) { + testCases := []struct { + givenApplication *applicationv1alpha1.Application + wantName string + }{ + // application type label is missing, then use the application name + { + givenApplication: applicationtest.NewApplication( + "alphanumeric0123", + nil), + wantName: "alphanumeric0123", + }, + { + givenApplication: applicationtest.NewApplication( + "alphanumeric0123", + map[string]string{"ignore-me": "value"}), + wantName: "alphanumeric0123", + }, + { + givenApplication: applicationtest.NewApplication( + "with.!@#none-$%^alphanumeric_&*-characters", + nil), + wantName: "withnonealphanumericcharacters", + }, + { + givenApplication: applicationtest.NewApplication( + "with.!@#none-$%^alphanumeric_&*-characters", + map[string]string{"ignore-me": "value"}), + wantName: "withnonealphanumericcharacters", + }, + // application type label is available, then use it instead of the application name + { + givenApplication: applicationtest.NewApplication( + "alphanumeric0123", + map[string]string{TypeLabel: "apptype"}), + wantName: "apptype", + }, + { + givenApplication: applicationtest.NewApplication( + "with.!@#none-$%^alphanumeric_&*-characters", + map[string]string{TypeLabel: "apptype"}), + wantName: "apptype", + }, + { + givenApplication: applicationtest.NewApplication( + "alphanumeric0123", + map[string]string{TypeLabel: "apptype=with.!@#none-$%^alphanumeric_&*-characters"}), + wantName: "apptypewithnonealphanumericcharacters", + }, + { + givenApplication: applicationtest.NewApplication( + "with.!@#none-$%^alphanumeric_&*-characters", + map[string]string{TypeLabel: "apptype=with.!@#none-$%^alphanumeric_&*-characters"}), + wantName: "apptypewithnonealphanumericcharacters", + }, + } + + for _, tc := range testCases { + if gotName := GetCleanTypeOrName(tc.givenApplication); tc.wantName != gotName { + t.Errorf("Clean application name:[%s] failed, want:[%v] but got:[%v]", tc.givenApplication.Name, tc.wantName, gotName) + } + } +} + +func Test_GetTypeOrName(t *testing.T) { + testCases := []struct { + name string + givenApplication *applicationv1alpha1.Application + wantName string + }{ + // application type label is missing, then use the application name + { + name: "Should return application name if no labels", + givenApplication: applicationtest.NewApplication("alphanumeric0123", nil), + wantName: "alphanumeric0123", + }, + { + name: "Should return application name if label with right key does not exists", + givenApplication: applicationtest.NewApplication("alphanumeric0123", map[string]string{"ignore-me": "value"}), + wantName: "alphanumeric0123", + }, + { + name: "Should return application name as unclean", + givenApplication: applicationtest.NewApplication("with.!@#none-$%^alphanumeric_&*-characters", nil), + wantName: "with.!@#none-$%^alphanumeric_&*-characters", + }, + // application type label is available, then use it instead of the application name + { + name: "Should return application label instead of name", + givenApplication: applicationtest.NewApplication("alphanumeric0123", map[string]string{TypeLabel: "apptype"}), + wantName: "apptype", + }, + { + name: "Should return application label as unclean", + givenApplication: applicationtest.NewApplication("alphanumeric0123", map[string]string{TypeLabel: "apptype=with.!@#none-$%^alphanumeric_&*-characters"}), + wantName: "apptype=with.!@#none-$%^alphanumeric_&*-characters", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.wantName, GetTypeOrName(tc.givenApplication)) + }) + } +} + +func TestIsCleanName(t *testing.T) { + testCases := []struct { + givenName string + wantClean bool + }{ + {givenName: "with.dot", wantClean: false}, + {givenName: "with-dash", wantClean: false}, + {givenName: "with_underscore", wantClean: false}, + {givenName: "with#special$characters", wantClean: false}, + {givenName: "alphabetical", wantClean: true}, + {givenName: "alphanumeric0123", wantClean: true}, + } + + for _, tc := range testCases { + if gotClean := IsCleanName(tc.givenName); tc.wantClean != gotClean { + t.Errorf("Is clean application name:[%s] failed, want:[%v] but got:[%v]", tc.givenName, tc.wantClean, gotClean) + } + } +} diff --git a/pkg/application/fake/lister.go b/pkg/application/fake/lister.go new file mode 100644 index 0000000..ed1de51 --- /dev/null +++ b/pkg/application/fake/lister.go @@ -0,0 +1,31 @@ +package fake + +import ( + "context" + "log" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + dynamicfake "k8s.io/client-go/dynamic/fake" + + applicationv1alpha1 "github.com/kyma-project/kyma/components/application-operator/pkg/apis/applicationconnector/v1alpha1" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" +) + +func NewApplicationListerOrDie(ctx context.Context, app *applicationv1alpha1.Application) *application.Lister { + scheme := setupSchemeOrDie() + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme, app) + return application.NewLister(ctx, dynamicClient) +} + +func setupSchemeOrDie() *runtime.Scheme { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + log.Fatalf("Failed to setup scheme with error: %v", err) + } + if err := applicationv1alpha1.AddToScheme(scheme); err != nil { + log.Fatalf("Failed to setup scheme with error: %v", err) + } + return scheme +} diff --git a/pkg/application/lister.go b/pkg/application/lister.go new file mode 100644 index 0000000..93ec16a --- /dev/null +++ b/pkg/application/lister.go @@ -0,0 +1,61 @@ +package application + +import ( + "context" + "errors" + "time" + + applicationv1alpha1 "github.com/kyma-project/kyma/components/application-operator/pkg/apis/applicationconnector/v1alpha1" + kymalogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/tools/cache" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/informers" +) + +type Lister struct { + lister cache.GenericLister +} + +func NewLister(ctx context.Context, client dynamic.Interface) *Lister { + const defaultResync = 10 * time.Second + gvr := GroupVersionResource() + factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, defaultResync, v1.NamespaceAll, nil) + factory.ForResource(gvr) + lister := factory.ForResource(gvr).Lister() + logger, _ := kymalogger.New("json", "error") + informers.WaitForCacheSyncOrDie(ctx, factory, logger) + return &Lister{lister: lister} +} + +func (l Lister) Get(name string) (*applicationv1alpha1.Application, error) { + object, err := l.lister.Get(name) + if err != nil { + return nil, err + } + + u, ok := object.(*unstructured.Unstructured) + if !ok { + return nil, errors.New("failed to convert runtime object to unstructured") + } + + a := &applicationv1alpha1.Application{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, a); err != nil { + return nil, err + } + + return a, nil +} + +func GroupVersionResource() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: applicationv1alpha1.SchemeGroupVersion.Group, + Version: applicationv1alpha1.SchemeGroupVersion.Version, + Resource: "applications", + } +} diff --git a/pkg/cloudevents/builder/eventmesh.go b/pkg/cloudevents/builder/eventmesh.go new file mode 100644 index 0000000..80553f8 --- /dev/null +++ b/pkg/cloudevents/builder/eventmesh.go @@ -0,0 +1,40 @@ +package builder + +import ( + cev2event "github.com/cloudevents/sdk-go/v2/event" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" +) + +// Perform a compile-time check. +var _ CloudEventBuilder = &EventMeshBuilder{} + +func NewEventMeshBuilder(prefix string, eventMeshNamespace string, cleaner cleaner.Cleaner, + applicationLister *application.Lister, logger *logger.Logger) CloudEventBuilder { + genericBuilder := GenericBuilder{ + typePrefix: prefix, + applicationLister: applicationLister, + logger: logger, + cleaner: cleaner, + } + + return &EventMeshBuilder{ + genericBuilder: &genericBuilder, + eventMeshNamespace: eventMeshNamespace, + } +} + +func (emb *EventMeshBuilder) Build(event cev2event.Event) (*cev2event.Event, error) { + ceEvent, err := emb.genericBuilder.Build(event) + if err != nil { + return nil, err + } + + // set eventMesh namespace as event source (required by EventMesh) + ceEvent.SetSource(emb.eventMeshNamespace) + + return ceEvent, err +} diff --git a/pkg/cloudevents/builder/eventmesh_test.go b/pkg/cloudevents/builder/eventmesh_test.go new file mode 100644 index 0000000..c115d72 --- /dev/null +++ b/pkg/cloudevents/builder/eventmesh_test.go @@ -0,0 +1,138 @@ +package builder + +import ( + "context" + "encoding/json" + "fmt" + golog "log" + "testing" + + cloudevents "github.com/cloudevents/sdk-go/v2" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/fake" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" + kymalogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + "github.com/stretchr/testify/require" +) + +func Test_EventMesh_Build(t *testing.T) { + t.Parallel() + + const sampleEventMeshNamespace = "/default/sample1/kyma1" + const eventMeshPrefix = "one.two.three" + + // init the logger + logger, err := kymalogger.New("json", "debug") + if err != nil { + golog.Fatalf("Failed to initialize logger, error: %v", err) + } + + testCases := []struct { + name string + givenSource string + givenType string + givenApplicationName string + givenApplicationLabels map[string]string + wantType string + wantSource string + wantError bool + }{ + { + name: "should return correct source and type (without application)", + givenSource: "source1", + givenType: "order.created.v1", + givenApplicationName: "appName1", + wantType: fmt.Sprintf("%s.source1.order.created.v1", eventMeshPrefix), + wantSource: sampleEventMeshNamespace, + }, + { + name: "should return cleaned source and type (without application)", + givenSource: "source1", + givenType: "o-rder.creat ed.v1", + givenApplicationName: "appName1", + wantType: fmt.Sprintf("%s.source1.order.created.v1", eventMeshPrefix), + wantSource: sampleEventMeshNamespace, + }, + { + name: "should return merged type segments if exceeds segments limit", + givenSource: "source1", + givenType: "haha.hehe.hmm.order.created.v1", + givenApplicationName: "appName1", + wantType: fmt.Sprintf("%s.source1.hahahehehmmorder.created.v1", eventMeshPrefix), + wantSource: sampleEventMeshNamespace, + }, + { + name: "should return application name as source", + givenSource: "appName1", + givenType: "order.created.v1", + givenApplicationName: "appName1", + wantType: fmt.Sprintf("%s.appName1.order.created.v1", eventMeshPrefix), + wantSource: sampleEventMeshNamespace, + }, + { + name: "should return application label as source", + givenSource: "appName1", + givenType: "order.created.v1", + givenApplicationName: "appName1", + givenApplicationLabels: map[string]string{application.TypeLabel: "t..e--s__t!!a@@p##p%%t^^y&&p**e"}, + wantType: fmt.Sprintf("%s.testapptype.order.created.v1", eventMeshPrefix), + wantSource: sampleEventMeshNamespace, + }, + { + name: "should return error if empty type", + givenSource: "source1", + givenType: "", + givenApplicationName: "appName1", + wantError: true, + }, + } + + for _, testCase := range testCases { + tc := testCase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // given + // build cloud event + builder := testingutils.NewCloudEventBuilder() + payload, _ := builder.BuildStructured() + newEvent := cloudevents.NewEvent() + err = json.Unmarshal([]byte(payload), &newEvent) + require.NoError(t, err) + newEvent.SetType(tc.givenType) + newEvent.SetSource(tc.givenSource) + + appLister := fake.NewApplicationListerOrDie( + context.Background(), + applicationtest.NewApplication(tc.givenApplicationName, tc.givenApplicationLabels)) + + eventMeshBuilder := NewEventMeshBuilder( + eventMeshPrefix, + sampleEventMeshNamespace, + cleaner.NewEventMeshCleaner(logger), + appLister, + logger, + ) + + // when + buildEvent, buildErr := eventMeshBuilder.Build(newEvent) + + // then + if tc.wantError { + require.Error(t, buildErr) + } else { + require.NoError(t, buildErr) + require.Equal(t, tc.wantSource, buildEvent.Source()) + require.Equal(t, tc.wantType, buildEvent.Type()) + + // check that original type header exists + originalType, ok := buildEvent.Extensions()[OriginalTypeHeaderName] + require.True(t, ok) + require.Equal(t, tc.givenType, originalType) + } + }) + } +} diff --git a/pkg/cloudevents/builder/generic.go b/pkg/cloudevents/builder/generic.go new file mode 100644 index 0000000..ae09129 --- /dev/null +++ b/pkg/cloudevents/builder/generic.go @@ -0,0 +1,98 @@ +package builder + +import ( + "fmt" + "strings" + + cev2event "github.com/cloudevents/sdk-go/v2/event" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + "go.uber.org/zap" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" +) + +// Perform a compile-time check. +var _ CloudEventBuilder = &GenericBuilder{} + +var ( + // jsBuilderName used as the logger name. + genericBuilderName = "generic-type-builder" +) + +func NewGenericBuilder(typePrefix string, cleaner cleaner.Cleaner, applicationLister *application.Lister, logger *logger.Logger) CloudEventBuilder { + return &GenericBuilder{ + typePrefix: typePrefix, + applicationLister: applicationLister, + logger: logger, + cleaner: cleaner, + } +} + +func (gb *GenericBuilder) isApplicationListerEnabled() bool { + return gb.applicationLister != nil +} + +func (gb *GenericBuilder) Build(event cev2event.Event) (*cev2event.Event, error) { + // format logger + namedLogger := gb.namedLogger(event.Source(), event.Type()) + + // clean the source + cleanSource, err := gb.cleaner.CleanSource(gb.GetAppNameOrSource(event.Source(), namedLogger)) + if err != nil { + return nil, err + } + + // clean the event type + cleanEventType, err := gb.cleaner.CleanEventType(event.Type()) + if err != nil { + return nil, err + } + + // build event type + finalEventType := gb.getFinalSubject(cleanSource, cleanEventType) + + // validate if the segments are not empty + segments := strings.Split(finalEventType, ".") + if DoesEmptySegmentsExist(segments) { + return nil, fmt.Errorf("event type cannot have empty segments after cleaning: %s", finalEventType) + } + namedLogger.Debugf("using event type: %s", finalEventType) + + ceEvent := event.Clone() + // set original type header + ceEvent.SetExtension(OriginalTypeHeaderName, event.Type()) + // set prefixed type + ceEvent.SetType(finalEventType) + // validate the final cloud event + if err = ceEvent.Validate(); err != nil { + return nil, err + } + + return &ceEvent, nil +} + +// getFinalSubject returns the final prefixed event type. +func (gb *GenericBuilder) getFinalSubject(source, eventType string) string { + return fmt.Sprintf("%s.%s.%s", gb.typePrefix, source, eventType) +} + +// GetAppNameOrSource returns the application name if exists, otherwise returns source name. +func (gb *GenericBuilder) GetAppNameOrSource(source string, namedLogger *zap.SugaredLogger) string { + var appName = source + if gb.isApplicationListerEnabled() { + if appObj, err := gb.applicationLister.Get(source); err == nil && appObj != nil { + appName = application.GetTypeOrName(appObj) + namedLogger.With("application", source).Debug("Using application name: %s as source.", appName) + } else { + namedLogger.With("application", source).Debug("Cannot find application.") + } + } + + return appName +} + +func (gb *GenericBuilder) namedLogger(source, eventType string) *zap.SugaredLogger { + return gb.logger.WithContext().Named(genericBuilderName).With("source", source, "type", eventType) +} diff --git a/pkg/cloudevents/builder/generic_test.go b/pkg/cloudevents/builder/generic_test.go new file mode 100644 index 0000000..31d63f7 --- /dev/null +++ b/pkg/cloudevents/builder/generic_test.go @@ -0,0 +1,259 @@ +package builder + +import ( + "context" + "encoding/json" + golog "log" + "testing" + + cloudevents "github.com/cloudevents/sdk-go/v2" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/fake" + kymalogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + "github.com/stretchr/testify/require" +) + +func Test_Build(t *testing.T) { + t.Parallel() + + // init the logger + logger, err := kymalogger.New("json", "debug") + if err != nil { + golog.Fatalf("Failed to initialize logger, error: %v", err) + } + + testCases := []struct { + name string + givenSource string + givenType string + givenApplicationName string + givenApplicationLabels map[string]string + wantType string + wantSource string + wantError bool + }{ + { + name: "should return correct source and type (without application)", + givenSource: "source1", + givenType: "order.created.v1", + givenApplicationName: "appName1", + wantType: "prefix.source1.order.created.v1", + wantSource: "source1", + }, + { + name: "should return cleaned source and type (without application)", + givenSource: "source1", + givenType: "o-rder.creat ed.v1", + givenApplicationName: "appName1", + wantType: "prefix.source1.o-rder.created.v1", + wantSource: "source1", + }, + { + name: "should return application name as source", + givenSource: "appName1", + givenType: "order.created.v1", + givenApplicationName: "appName1", + wantType: "prefix.appName1.order.created.v1", + wantSource: "appName1", + }, + { + name: "should return application label as source", + givenSource: "appName1", + givenType: "order.created.v1", + givenApplicationName: "appName1", + givenApplicationLabels: map[string]string{application.TypeLabel: "t..est-apptype"}, + wantType: "prefix.test-apptype.order.created.v1", + wantSource: "appName1", + }, + { + name: "should return error for providing invalid uri reference as source", + givenSource: "a@@p##p%%", + givenType: "order.created.v1", + givenApplicationName: "appName1", + givenApplicationLabels: map[string]string{application.TypeLabel: "a@@p##p%%"}, + wantError: true, + }, + { + name: "should return error if empty type", + givenSource: "source1", + givenType: "", + givenApplicationName: "appName1", + wantError: true, + }, + { + name: "should return error if empty source", + givenSource: "", + givenType: "order.created.v1", + givenApplicationName: "appName1", + wantError: true, + }, + } + + for _, testCase := range testCases { + tc := testCase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // given + // build cloud event + builder := testingutils.NewCloudEventBuilder() + payload, _ := builder.BuildStructured() + newEvent := cloudevents.NewEvent() + err = json.Unmarshal([]byte(payload), &newEvent) + require.NoError(t, err) + newEvent.SetType(tc.givenType) + newEvent.SetSource(tc.givenSource) + + appLister := fake.NewApplicationListerOrDie( + context.Background(), + applicationtest.NewApplication(tc.givenApplicationName, tc.givenApplicationLabels)) + + genericBuilder := &GenericBuilder{ + typePrefix: "prefix", + applicationLister: appLister, + logger: logger, + cleaner: cleaner.NewJetStreamCleaner(logger), + } + + // when + buildEvent, buildErr := genericBuilder.Build(newEvent) + + // then + if tc.wantError { + require.Error(t, buildErr) + } else { + require.NoError(t, buildErr) + require.Equal(t, tc.wantSource, buildEvent.Source()) + require.Equal(t, tc.wantType, buildEvent.Type()) + + // check that original type header exists + originalType, ok := buildEvent.Extensions()[OriginalTypeHeaderName] + require.True(t, ok) + require.Equal(t, tc.givenType, originalType) + } + }) + } +} + +func Test_GetAppNameOrSource(t *testing.T) { + t.Parallel() + + // init the logger + logger, err := kymalogger.New("json", "debug") + if err != nil { + golog.Fatalf("Failed to initialize logger, error: %v", err) + } + + testCases := []struct { + name string + givenApplicationName string + givenApplicationLabels map[string]string + givenSource string + givenApplicationListerEnabled bool + wantSource string + }{ + { + name: "should return application name instead of source name", + givenSource: "appName1", + givenApplicationName: "appName1", + givenApplicationListerEnabled: true, + wantSource: "appName1", + }, + { + name: "should return application label instead of source name or app name", + givenSource: "appName1", + givenApplicationName: "appName1", + givenApplicationListerEnabled: true, + givenApplicationLabels: map[string]string{application.TypeLabel: "testapptype"}, + wantSource: "testapptype", + }, + { + name: "should return non-clean application label", + givenSource: "appName1", + givenApplicationName: "appName1", + givenApplicationListerEnabled: true, + givenApplicationLabels: map[string]string{application.TypeLabel: "t..e--s__t!!a@@p##p%%t^^y&&p**e"}, + wantSource: "t..e--s__t!!a@@p##p%%t^^y&&p**e", + }, + { + name: "should return source name as application does not exists", + givenSource: "noapp1", + givenApplicationName: "appName1", + givenApplicationListerEnabled: true, + givenApplicationLabels: map[string]string{application.TypeLabel: "testapptype"}, + wantSource: "noapp1", + }, + { + name: "should return source name when application lister is disabled", + givenSource: "appName1", + givenApplicationName: "appName1", + givenApplicationListerEnabled: false, + givenApplicationLabels: map[string]string{application.TypeLabel: "testapptype"}, + wantSource: "appName1", + }, + } + for _, testCase := range testCases { + tc := testCase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + app := applicationtest.NewApplication(tc.givenApplicationName, tc.givenApplicationLabels) + + var appLister *application.Lister + if tc.givenApplicationListerEnabled { + appLister = fake.NewApplicationListerOrDie(context.Background(), app) + } + + genericBuilder := &GenericBuilder{ + applicationLister: appLister, + logger: logger, + } + + namedLogger := logger.WithContext().Named(genericBuilderName).With("source", tc.givenSource) + require.Equal(t, tc.wantSource, genericBuilder.GetAppNameOrSource(tc.givenSource, namedLogger)) + }) + } +} + +func Test_getFinalSubject(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + givenTypePrefix string + givenSource string + givenType string + wantSubject string + }{ + { + name: "should return correct subject", + givenTypePrefix: "prefix", + givenSource: "test1", + givenType: "test2", + wantSubject: "prefix.test1.test2", + }, + { + name: "should return correct subject", + givenTypePrefix: "kyma", + givenSource: "inapp", + givenType: "order.created.v1", + wantSubject: "kyma.inapp.order.created.v1", + }, + } + for _, testCase := range testCases { + tc := testCase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + genericBuilder := &GenericBuilder{ + typePrefix: tc.givenTypePrefix, + } + + require.Equal(t, tc.wantSubject, genericBuilder.getFinalSubject(tc.givenSource, tc.givenType)) + }) + } +} diff --git a/pkg/cloudevents/builder/types.go b/pkg/cloudevents/builder/types.go new file mode 100644 index 0000000..f2764df --- /dev/null +++ b/pkg/cloudevents/builder/types.go @@ -0,0 +1,28 @@ +package builder + +import ( + cev2event "github.com/cloudevents/sdk-go/v2/event" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" +) + +const ( + OriginalTypeHeaderName = "originaltype" +) + +type CloudEventBuilder interface { + Build(event cev2event.Event) (*cev2event.Event, error) +} + +type GenericBuilder struct { + typePrefix string + applicationLister *application.Lister // applicationLister will be nil when disabled. + cleaner cleaner.Cleaner + logger *logger.Logger +} + +type EventMeshBuilder struct { + genericBuilder *GenericBuilder + eventMeshNamespace string +} diff --git a/pkg/cloudevents/builder/utils.go b/pkg/cloudevents/builder/utils.go new file mode 100644 index 0000000..9ea907b --- /dev/null +++ b/pkg/cloudevents/builder/utils.go @@ -0,0 +1,10 @@ +package builder + +func DoesEmptySegmentsExist(segments []string) bool { + for _, segment := range segments { + if segment == "" { + return true + } + } + return false +} diff --git a/pkg/cloudevents/builder/utils_test.go b/pkg/cloudevents/builder/utils_test.go new file mode 100644 index 0000000..ee483d0 --- /dev/null +++ b/pkg/cloudevents/builder/utils_test.go @@ -0,0 +1,34 @@ +package builder + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_checkForEmptySegments(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenSegments []string + wantResult bool + }{ + { + name: "should pass if all segments are non-empty", + givenSegments: []string{"one", "two", "three"}, + wantResult: false, + }, + { + name: "should fail if any segment is empty", + givenSegments: []string{"one", "", "three"}, + wantResult: true, + }, + } + for _, testCase := range testCases { + tc := testCase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.wantResult, DoesEmptySegmentsExist(tc.givenSegments)) + }) + } +} diff --git a/pkg/cloudevents/eventtype/build.go b/pkg/cloudevents/eventtype/build.go new file mode 100644 index 0000000..92352bd --- /dev/null +++ b/pkg/cloudevents/eventtype/build.go @@ -0,0 +1,13 @@ +package eventtype + +import ( + "fmt" + "strings" +) + +func build(prefix, applicationName, event, version string) string { + if len(strings.TrimSpace(prefix)) == 0 { + return fmt.Sprintf("%s.%s.%s", applicationName, event, version) + } + return fmt.Sprintf("%s.%s.%s.%s", prefix, applicationName, event, version) +} diff --git a/pkg/cloudevents/eventtype/build_test.go b/pkg/cloudevents/eventtype/build_test.go new file mode 100644 index 0000000..580e029 --- /dev/null +++ b/pkg/cloudevents/eventtype/build_test.go @@ -0,0 +1,35 @@ +package eventtype + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuilder(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenPrefix, givenApplicationName, givenEvent, givenVersion string + wantEventType string + }{ + { + name: "prefix is empty", + givenPrefix: "", givenApplicationName: "test.app-1", givenEvent: "order.created", givenVersion: "v1", + wantEventType: "test.app-1.order.created.v1", + }, + { + name: "prefix is not empty", + givenPrefix: "prefix", givenApplicationName: "test.app-1", givenEvent: "order.created", givenVersion: "v1", + wantEventType: "prefix.test.app-1.order.created.v1", + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + eventType := build(tc.givenPrefix, tc.givenApplicationName, tc.givenEvent, tc.givenVersion) + assert.Equal(t, tc.wantEventType, eventType) + }) + } +} diff --git a/pkg/cloudevents/eventtype/clean.go b/pkg/cloudevents/eventtype/clean.go new file mode 100644 index 0000000..43c4959 --- /dev/null +++ b/pkg/cloudevents/eventtype/clean.go @@ -0,0 +1,80 @@ +package eventtype + +import ( + "regexp" + + "golang.org/x/xerrors" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "go.uber.org/zap" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" +) + +var ( + // invalidEventTypeSegment used to match and replace none-alphanumeric characters in the event-type segments + // as per SAP Event spec https://github.tools.sap/CentralEngineering/sap-event-specification#type. + invalidEventTypeSegment = regexp.MustCompile("[^a-zA-Z0-9.]") +) + +const ( + // cleanerName used as the logger name. + cleanerName = "event-type-cleaner" +) + +type Cleaner interface { + Clean(eventType string) (string, error) +} + +type cleaner struct { + eventTypePrefix string + applicationLister *application.Lister // applicationLister will be nil when disabled. + logger *logger.Logger +} + +// compile-time check. +var _ Cleaner = &cleaner{} + +func NewCleaner(eventTypePrefix string, applicationLister *application.Lister, logger *logger.Logger) Cleaner { + return &cleaner{eventTypePrefix: eventTypePrefix, applicationLister: applicationLister, logger: logger} +} + +func (c *cleaner) isApplicationListerEnabled() bool { + return c.applicationLister != nil +} + +// Clean cleans the event-type from none-alphanumeric characters and returns it +// or returns an error if the event-type parsing failed. +func (c *cleaner) Clean(eventType string) (string, error) { + // format logger + namedLogger := c.namedLogger(eventType) + + appName, event, version, err := parse(eventType, c.eventTypePrefix) + if err != nil { + return "", xerrors.Errorf("failed to parse event-type=%s with prefix=%s: %v", eventType, c.eventTypePrefix, err) + } + + // clean the application name + eventTypeClean := build(c.eventTypePrefix, application.GetCleanName(appName), event, version) + if c.isApplicationListerEnabled() { + if appObj, err := c.applicationLister.Get(appName); err == nil { + eventTypeClean = build(c.eventTypePrefix, application.GetCleanTypeOrName(appObj), event, version) + } else { + namedLogger.With("application", appName).Debug("Cannot find application") + } + } + + // clean the event-type segments + eventTypeClean = cleanEventType(eventTypeClean) + namedLogger.With("before", eventType, "after", eventTypeClean).Debug("Clean event-type") + + return eventTypeClean, nil +} + +func (c *cleaner) namedLogger(eventType string) *zap.SugaredLogger { + return c.logger.WithContext().Named(cleanerName).With("prefix", c.eventTypePrefix, "type", eventType) +} + +func cleanEventType(eventType string) string { + return invalidEventTypeSegment.ReplaceAllString(eventType, "") +} diff --git a/pkg/cloudevents/eventtype/clean_test.go b/pkg/cloudevents/eventtype/clean_test.go new file mode 100644 index 0000000..90b6edb --- /dev/null +++ b/pkg/cloudevents/eventtype/clean_test.go @@ -0,0 +1,211 @@ +package eventtype + +import ( + "context" + "testing" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/fake" +) + +//nolint:lll // we need long lines here as the event types can get very long +func TestCleaner(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenEventTypePrefix string + givenApplicationName string + givenApplicationLabels map[string]string + givenApplicationListerEnabled bool + givenEventType string + wantEventType string + wantError bool + }{ + // valid even-types for existing applications + { + name: "success if prefix is empty", + givenEventTypePrefix: "", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenEventType: "testapp.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "testapp.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name is clean", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenEventType: "prefix.testapp.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name is clean and event has more than two segments", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenEventType: "prefix.testapp.Segment1.Segment2.Segment3.Segment4-Part1-Part2-Ä.Segment5-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Segment2Segment3Segment4Part1Part2.Segment5Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application type label is clean", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenApplicationLabels: map[string]string{application.TypeLabel: "testapptype"}, + givenEventType: "prefix.testapp.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapptype.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application type label is clean and event has more than two segments", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenApplicationLabels: map[string]string{application.TypeLabel: "testapptype"}, + givenEventType: "prefix.testapp.Segment1.Segment2.Segment3.Segment4-Part1-Part2-Ä.Segment5-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapptype.Segment1Segment2Segment3Segment4Part1Part2.Segment5Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name needs to be cleaned", + givenEventTypePrefix: "prefix", + givenApplicationName: "te--s__t!!a@@p##p%%", + givenApplicationListerEnabled: true, + givenEventType: "prefix.te--s__t!!a@@p##p%%.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name needs to be cleaned and event has more than two segments", + givenEventTypePrefix: "prefix", + givenApplicationName: "te--s__t!!a@@p##p%%", + givenApplicationListerEnabled: true, + givenEventType: "prefix.te--s__t!!a@@p##p%%.Segment1.Segment2.Segment3.Segment4-Part1-Part2-Ä.Segment5-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Segment2Segment3Segment4Part1Part2.Segment5Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application type label needs to be cleaned", + givenEventTypePrefix: "prefix", + givenApplicationName: "te--s__t!!a@@p##p%%", + givenApplicationLabels: map[string]string{application.TypeLabel: "t..e--s__t!!a@@p##p%%t^^y&&p**e"}, + givenApplicationListerEnabled: true, + givenEventType: "prefix.te--s__t!!a@@p##p%%.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapptype.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application type label needs to be cleaned and event has more than two segments", + givenEventTypePrefix: "prefix", + givenApplicationName: "te--s__t!!a@@p##p%%", + givenApplicationLabels: map[string]string{application.TypeLabel: "t..e--s__t!!a@@p##p%%t^^y&&p**e"}, + givenApplicationListerEnabled: true, + givenEventType: "prefix.te--s__t!!a@@p##p%%.Segment1.Segment2.Segment3.Segment4-Part1-Part2-Ä.Segment5-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapptype.Segment1Segment2Segment3Segment4Part1Part2.Segment5Part1Part2.v1", + wantError: false, + }, + { + name: "success if the application lister is disabled", + givenEventTypePrefix: "prefix", + givenApplicationName: "te--s__t!!a@@p##p%%", + givenApplicationLabels: map[string]string{application.TypeLabel: "t..e--s__t!!a@@p##p%%t^^y&&p**e"}, + givenApplicationListerEnabled: false, + givenEventType: "prefix.te--s__t!!a@@p##p%%.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + // valid even-types for non-existing applications to simulate in-cluster eventing + { + name: "success if the given application name is clean for non-existing application", + givenEventTypePrefix: "prefix", + givenApplicationName: "", + givenApplicationListerEnabled: true, + givenEventType: "prefix.test-app.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name is clean for non-existing application and event has more than two segments", + givenEventTypePrefix: "prefix", + givenApplicationName: "", + givenApplicationListerEnabled: true, + givenEventType: "prefix.test-app.Segment1.Segment2.Segment3.Segment4-Part1-Part2-Ä.Segment5-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Segment2Segment3Segment4Part1Part2.Segment5Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name is not clean for non-existing application", + givenEventTypePrefix: "prefix", + givenApplicationName: "", + givenApplicationListerEnabled: true, + givenEventType: "prefix.testapp.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Part1Part2.Segment2Part1Part2.v1", + wantError: false, + }, + { + name: "success if the given application name is not clean for non-existing application and event has more than two segments", + givenEventTypePrefix: "prefix", + givenApplicationName: "", + givenApplicationListerEnabled: true, + givenEventType: "prefix.testapp.Segment1.Segment2.Segment3.Segment4-Part1-Part2-Ä.Segment5-Part1-Part2-Ä.v1", + wantEventType: "prefix.testapp.Segment1Segment2Segment3Segment4Part1Part2.Segment5Part1Part2.v1", + wantError: false, + }, + // invalid even-types + { + name: "fail if the prefix is invalid", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenEventType: "invalid.prefix.testapp.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantError: true, + }, + { + name: "fail if the prefix is missing", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenEventType: "testapp.Segment1-Part1-Part2-Ä.Segment2-Part1-Part2-Ä.v1", + wantError: true, + }, + { + name: "fail if the event-type is incomplete", + givenEventTypePrefix: "prefix", + givenApplicationName: "testapp", + givenApplicationListerEnabled: true, + givenEventType: "prefix.testapp.Segment1-Part1-Part2-Ä.v1", + wantError: true, + }, + } + + mockedLogger, err := logger.New("json", "info") + require.NoError(t, err) + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + app := applicationtest.NewApplication(tc.givenApplicationName, tc.givenApplicationLabels) + + var appLister *application.Lister + if tc.givenApplicationListerEnabled { + appLister = fake.NewApplicationListerOrDie(context.Background(), app) + } + + cleaner := NewCleaner(tc.givenEventTypePrefix, appLister, mockedLogger) + eventType, err := cleaner.Clean(tc.givenEventType) + require.Equal(t, tc.wantError, err != nil) + if !tc.wantError { + require.Equal(t, tc.wantEventType, eventType) + } + }) + } +} diff --git a/pkg/cloudevents/eventtype/eventtypetest/eventtypetest.go b/pkg/cloudevents/eventtype/eventtypetest/eventtypetest.go new file mode 100644 index 0000000..4c2a624 --- /dev/null +++ b/pkg/cloudevents/eventtype/eventtypetest/eventtypetest.go @@ -0,0 +1,21 @@ +// Package eventtypetest provides utilities for eventype testing. +package eventtypetest + +type CleanerFunc func(string) (string, error) + +type CleanerStub struct { + CleanType string + Error error +} + +func (c CleanerStub) Clean(_ string) (string, error) { + return c.CleanType, c.Error +} + +func (cf CleanerFunc) Clean(eventType string) (string, error) { + return cf(eventType) +} + +var DefaultCleaner = func(eventType string) (string, error) { + return eventType, nil +} diff --git a/pkg/cloudevents/eventtype/parse.go b/pkg/cloudevents/eventtype/parse.go new file mode 100644 index 0000000..1dbf210 --- /dev/null +++ b/pkg/cloudevents/eventtype/parse.go @@ -0,0 +1,40 @@ +package eventtype + +import ( + "errors" + "fmt" + "strings" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/builder" +) + +// parse splits the event-type using the given prefix and returns the application name, event and version +// or an error if the event-type format is invalid. +// A valid even-type format should be: prefix.application.event.version or application.event.version +// where event should consist of at least two segments separated by "." (e.g. businessObject.operation). +// Constraint: the application segment in the input event-type should not contain ".". +func parse(eventType, prefix string) (string, string, string, error) { + if !strings.HasPrefix(eventType, prefix) { + return "", "", "", errors.New("prefix not found") + } + + // remove the prefix + eventType = strings.ReplaceAll(eventType, prefix, "") + eventType = strings.TrimPrefix(eventType, ".") + + // make sure that the remaining string has at least 4 segments separated by "." + // (e.g. application.businessObject.operation.version) + parts := strings.Split(eventType, ".") + if len(parts) < 4 || builder.DoesEmptySegmentsExist(parts) { + return "", "", "", errors.New("invalid format") + } + + // parse the event-type segments + applicationName := parts[0] + businessObject := strings.Join(parts[1:len(parts)-2], "") // combine segments + operation := parts[len(parts)-2] + version := parts[len(parts)-1] + event := fmt.Sprintf("%s.%s", businessObject, operation) + + return applicationName, event, version, nil +} diff --git a/pkg/cloudevents/eventtype/parse_test.go b/pkg/cloudevents/eventtype/parse_test.go new file mode 100644 index 0000000..f17fe0f --- /dev/null +++ b/pkg/cloudevents/eventtype/parse_test.go @@ -0,0 +1,79 @@ +package eventtype + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParser(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenEventType string + givenPrefix string + wantApplicationName string + wantEvent string + wantVersion string + wantError bool + }{ + { + name: "should fail if prefix is missing", + givenEventType: "prefix.test.app.name.123.order.created.v1", + givenPrefix: "missing.prefix", + wantError: true, + }, + { + name: "should fail if prefix is duplicated", + givenEventType: "one.two.one.two.prefix.test.app.name.123.order.created.v1", + givenPrefix: "one.two", + wantError: true, + }, + { + name: "should fail if event-type is incomplete", + givenEventType: "prefix.order.created.v1", + givenPrefix: "prefix", + wantError: true, + }, + { + name: "should succeed if prefix is empty", + givenEventType: "application.order.created.v1", + givenPrefix: "", + wantApplicationName: "application", + wantEvent: "order.created", + wantVersion: "v1", + wantError: false, + }, + { + name: "should succeed if event has two segments", + givenEventType: "prefix.test_app-123.Segment1.Segment2.v1", + givenPrefix: "prefix", + wantApplicationName: "test_app-123", + wantEvent: "Segment1.Segment2", + wantVersion: "v1", + wantError: false, + }, + { + name: "should succeed if event has more than two segments", + givenEventType: "prefix.test_app-123.Segment1.Segment2.Segment3.Segment4.Segment5.v1", + givenPrefix: "prefix", + wantApplicationName: "test_app-123", + wantEvent: "Segment1Segment2Segment3Segment4.Segment5", + wantVersion: "v1", + wantError: false, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + applicationName, event, version, err := parse(tc.givenEventType, tc.givenPrefix) + require.Equal(t, tc.wantError, err != nil) + if !tc.wantError { + require.Equal(t, tc.wantApplicationName, applicationName) + require.Equal(t, tc.wantEvent, event) + require.Equal(t, tc.wantVersion, version) + } + }) + } +} diff --git a/pkg/cloudevents/utils.go b/pkg/cloudevents/utils.go new file mode 100644 index 0000000..6e5eb67 --- /dev/null +++ b/pkg/cloudevents/utils.go @@ -0,0 +1,32 @@ +package cloudevents + +import ( + "context" + "net/http" + + "github.com/pkg/errors" + + "github.com/cloudevents/sdk-go/v2/binding" + cehttp "github.com/cloudevents/sdk-go/v2/protocol/http" +) + +// WriteRequestWithHeaders writes a CloudEvent HTTP request with the given message and adds the given headers to it. +func WriteRequestWithHeaders( + ctx context.Context, + message binding.Message, + req *http.Request, + headers http.Header, + transformers ...binding.Transformer) error { + err := cehttp.WriteRequest(ctx, message, req, transformers...) + if err != nil { + return errors.Wrap(err, "failed to write Request") + } + + for k, v := range headers { + for _, vv := range v { + req.Header.Add(k, vv) + } + } + + return nil +} diff --git a/pkg/cloudevents/utils_test.go b/pkg/cloudevents/utils_test.go new file mode 100644 index 0000000..e49a354 --- /dev/null +++ b/pkg/cloudevents/utils_test.go @@ -0,0 +1,58 @@ +package cloudevents + +import ( + "context" + "net/http" + "net/http/httptest" + "net/textproto" + "reflect" + "testing" + + cehttp "github.com/cloudevents/sdk-go/v2/protocol/http" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/eventmesh" +) + +func TestWriteRequestWithHeaders(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Add(internal.HeaderContentType, internal.ContentTypeApplicationCloudEventsJSON) + + message := cehttp.NewMessageFromHttpRequest(req) + defer func() { _ = message.Finish(nil) }() + + additionalHeaders := http.Header{ + "qos": []string{string(eventmesh.QosAtLeastOnce)}, + "accept": []string{internal.ContentTypeApplicationJSON}, + "key1": []string{"value1", "value2"}, + "key2": []string{"value3"}, + } + additionalHeadersCopy := copyHeaders(additionalHeaders) + + ctx := context.Background() + err := WriteRequestWithHeaders(ctx, message, req, additionalHeaders) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(additionalHeaders, additionalHeadersCopy) { + t.Fatal("Write request with headers should not change the input HTTP headers") + } + + for k, v := range additionalHeaders { + vv, ok := req.Header[textproto.CanonicalMIMEHeaderKey(k)] + if !ok || !reflect.DeepEqual(v, vv) { + t.Fatal("The HTTP request should contain the given HTTP headers") + } + } +} + +func copyHeaders(headers http.Header) http.Header { + headersCopy := make(http.Header) + for k, v := range headers { + headersCopy[k] = v + } + return headersCopy +} diff --git a/pkg/commander/commander.go b/pkg/commander/commander.go new file mode 100644 index 0000000..f43872c --- /dev/null +++ b/pkg/commander/commander.go @@ -0,0 +1,13 @@ +package commander + +// Commander defines the interface of different implementations. +type Commander interface { + // Init allows main() to pass flag values to the commander instance. + Init() error + + // Start runs the initialized commander instance. + Start() error + + // Stop stops the commander instance. + Stop() error +} diff --git a/pkg/commander/eventmesh/eventmesh.go b/pkg/commander/eventmesh/eventmesh.go new file mode 100644 index 0000000..7664b13 --- /dev/null +++ b/pkg/commander/eventmesh/eventmesh.go @@ -0,0 +1,156 @@ +package eventmesh + +import ( + "context" + + "github.com/kelseyhightower/envconfig" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/builder" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler/health" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/informers" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/oauth" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/receiver" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/eventmesh" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/signals" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/subscribed" + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + + "go.uber.org/zap" + "golang.org/x/xerrors" + "k8s.io/client-go/dynamic" + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // TODO: remove as this is only used in a development setup + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +const ( + backend = "beb" + commanderName = backend + "-commander" +) + +// Commander implements the Commander interface. +type Commander struct { + cancel context.CancelFunc + envCfg *env.EventMeshConfig + logger *logger.Logger + metricsCollector *metrics.Collector + opts *options.Options +} + +// NewCommander creates the Commander for publisher to EventMesh. +func NewCommander(opts *options.Options, metricsCollector *metrics.Collector, logger *logger.Logger) *Commander { + return &Commander{ + metricsCollector: metricsCollector, + logger: logger, + envCfg: new(env.EventMeshConfig), + opts: opts, + } +} + +// Init implements the Commander interface and initializes the publisher to BEB. +func (c *Commander) Init() error { + if err := envconfig.Process("", c.envCfg); err != nil { + return xerrors.Errorf("failed to read configuration for %s : %v", commanderName, err) + } + return nil +} + +// Start implements the Commander interface and starts the publisher. +func (c *Commander) Start() error { + c.namedLogger().Infow("Starting Event Publisher", "configuration", c.envCfg.String(), "startup arguments", c.opts) + + // configure message receiver + messageReceiver := receiver.NewHTTPMessageReceiver(c.envCfg.Port) + + // assure uniqueness + var ctx context.Context + ctx, c.cancel = context.WithCancel(signals.NewContext()) + + // configure auth client + client := oauth.NewClient(ctx, c.envCfg) + defer client.CloseIdleConnections() + + // configure message sender + messageSender := eventmesh.NewSender(c.envCfg.EventMeshPublishURL, client, c.logger) + + // cluster config + k8sConfig := config.GetConfigOrDie() + + // setup application lister + var applicationLister *application.Lister + if c.envCfg.ApplicationCRDEnabled { + dynamicClient := dynamic.NewForConfigOrDie(k8sConfig) + applicationLister = application.NewLister(ctx, dynamicClient) + c.namedLogger().Info("Application CR lister is enabled!") + } else { + c.namedLogger().Info("Application CR lister is disabled!") + } + + // configure legacyTransformer + legacyTransformer := legacy.NewTransformer( + c.envCfg.EventMeshNamespace, + c.envCfg.EventTypePrefix, + applicationLister, + ) + + // Configure Subscription Lister + subDynamicSharedInfFactory := subscribed.GenerateSubscriptionInfFactory(k8sConfig) + subLister := subDynamicSharedInfFactory.ForResource(subscribed.GVR).Lister() + subscribedProcessor := &subscribed.Processor{ + SubscriptionLister: &subLister, + Prefix: c.envCfg.EventTypePrefix, + Namespace: c.envCfg.EventMeshNamespace, + Logger: c.logger, + } + // Sync informer cache or die + c.namedLogger().Info("Waiting for informers caches to sync") + informers.WaitForCacheSyncOrDie(ctx, subDynamicSharedInfFactory, c.logger) + c.namedLogger().Info("Informers were successfully synced") + + // configure event type cleaner + eventTypeCleanerV1 := eventtype.NewCleaner(c.envCfg.EventTypePrefix, applicationLister, c.logger) + + // configure event type cleaner for subscription CRD v1alpha2 + eventTypeCleaner := cleaner.NewEventMeshCleaner(c.logger) + + // configure cloud event builder for subscription CRD v1alpha2 + ceBuilder := builder.NewEventMeshBuilder(c.envCfg.EventTypePrefix, c.envCfg.EventMeshNamespace, eventTypeCleaner, + applicationLister, c.logger) + + // start handler which blocks until it receives a shutdown signal + if err := handler.New( + messageReceiver, + messageSender, + health.NewChecker(), + c.envCfg.RequestTimeout, + legacyTransformer, + c.opts, + subscribedProcessor, + c.logger, + c.metricsCollector, + eventTypeCleanerV1, + ceBuilder, + c.envCfg.EventTypePrefix, + env.EventMeshBackend, + ).Start(ctx); err != nil { + return xerrors.Errorf("failed to start handler for %s : %v", commanderName, err) + } + c.namedLogger().Info("Event Publisher was shut down") + return nil +} + +// Stop implements the Commander interface and stops the publisher. +func (c *Commander) Stop() error { + c.cancel() + return nil +} + +func (c *Commander) namedLogger() *zap.SugaredLogger { + return c.logger.WithContext().Named(commanderName).With("backend", backend) +} diff --git a/pkg/commander/nats/nats.go b/pkg/commander/nats/nats.go new file mode 100644 index 0000000..4343e49 --- /dev/null +++ b/pkg/commander/nats/nats.go @@ -0,0 +1,167 @@ +package nats + +import ( + "context" + + "github.com/kelseyhightower/envconfig" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/builder" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/informers" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" + pkgnats "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/nats" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/receiver" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/jetstream" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/signals" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/subscribed" + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + + "go.uber.org/zap" + "golang.org/x/xerrors" + "k8s.io/client-go/dynamic" + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // TODO: remove as this is only required in a dev setup + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +const ( + natsBackend = "nats" + natsCommanderName = natsBackend + "-commander" +) + +// Commander implements the Commander interface. +type Commander struct { + cancel context.CancelFunc + metricsCollector *metrics.Collector + logger *logger.Logger + envCfg *env.NATSConfig + opts *options.Options +} + +// NewCommander creates the Commander for publisher to NATS. +func NewCommander(opts *options.Options, metricsCollector *metrics.Collector, logger *logger.Logger) *Commander { + return &Commander{ + envCfg: new(env.NATSConfig), + logger: logger, + metricsCollector: metricsCollector, + opts: opts, + } +} + +// Init implements the Commander interface and initializes the publisher to NATS. +func (c *Commander) Init() error { + if err := envconfig.Process("", c.envCfg); err != nil { + return xerrors.Errorf("failed to read configuration for %s : %v", natsCommanderName, err) + } + return nil +} + +// Start implements the Commander interface and starts the publisher. +func (c *Commander) Start() error { + c.namedLogger().Infow("Starting Event Publisher", "configuration", c.envCfg.String(), "startup arguments", c.opts) + + // assure uniqueness + var ctx context.Context + ctx, c.cancel = context.WithCancel(signals.NewContext()) + + // configure message receiver + messageReceiver := receiver.NewHTTPMessageReceiver(c.envCfg.Port) + + // connect to nats + connection, err := pkgnats.Connect(c.envCfg.URL, + pkgnats.WithRetryOnFailedConnect(c.envCfg.RetryOnFailedConnect), + pkgnats.WithMaxReconnects(c.envCfg.MaxReconnects), + pkgnats.WithReconnectWait(c.envCfg.ReconnectWait), + pkgnats.WithName("Kyma Publisher"), + ) + if err != nil { + return xerrors.Errorf("failed to connect to backend server for %s : %v", natsCommanderName, err) + } + defer connection.Close() + + // configure the message sender + messageSender := jetstream.NewSender(ctx, connection, c.envCfg, c.opts, c.logger) + + // cluster config + k8sConfig := config.GetConfigOrDie() + + // setup application lister + var applicationLister *application.Lister + if c.envCfg.ApplicationCRDEnabled { + dynamicClient := dynamic.NewForConfigOrDie(k8sConfig) + applicationLister = application.NewLister(ctx, dynamicClient) + c.namedLogger().Info("Application CR lister is enabled!") + } else { + c.namedLogger().Info("Application CR lister is disabled!") + } + + // configure legacyTransformer + legacyTransformer := legacy.NewTransformer( + c.envCfg.ToConfig().EventMeshNamespace, + c.envCfg.ToConfig().EventTypePrefix, + applicationLister, + ) + + // configure Subscription Lister + subDynamicSharedInfFactory := subscribed.GenerateSubscriptionInfFactory(k8sConfig) + subLister := subDynamicSharedInfFactory.ForResource(subscribed.GVR).Lister() + subscribedProcessor := &subscribed.Processor{ + SubscriptionLister: &subLister, + Prefix: c.envCfg.ToConfig().EventTypePrefix, + Namespace: c.envCfg.ToConfig().EventMeshNamespace, + Logger: c.logger, + } + + // sync informer cache or die + c.namedLogger().Info("Waiting for informers caches to sync") + informers.WaitForCacheSyncOrDie(ctx, subDynamicSharedInfFactory, c.logger) + c.namedLogger().Info("Informers are synced successfully") + + // configure event type cleaner + eventTypeCleanerV1 := eventtype.NewCleaner(c.envCfg.EventTypePrefix, applicationLister, c.logger) + + // configure event type cleaner for subscription CRD v1alpha2 + eventTypeCleaner := cleaner.NewJetStreamCleaner(c.logger) + + // configure cloud event builder for subscription CRD v1alpha2 + ceBuilder := builder.NewGenericBuilder(env.JetStreamSubjectPrefix, eventTypeCleaner, + applicationLister, c.logger) + + // start handler which blocks until it receives a shutdown signal + h := handler.New( + messageReceiver, + messageSender, + messageSender, + c.envCfg.RequestTimeout, + legacyTransformer, + c.opts, + subscribedProcessor, + c.logger, + c.metricsCollector, + eventTypeCleanerV1, + ceBuilder, + c.envCfg.EventTypePrefix, + env.JetStreamBackend, + ) + if err := h.Start(ctx); err != nil { + return xerrors.Errorf("failed to start handler for %s : %v", natsCommanderName, err) + } + + c.namedLogger().Infof("Event Publisher was shut down") + + return nil +} + +// Stop implements the Commander interface and stops the publisher. +func (c *Commander) Stop() error { + c.cancel() + return nil +} + +func (c *Commander) namedLogger() *zap.SugaredLogger { + return c.logger.WithContext().Named(natsCommanderName).With("backend", natsBackend) +} diff --git a/pkg/env/eventmesh_config.go b/pkg/env/eventmesh_config.go new file mode 100644 index 0000000..4c058f9 --- /dev/null +++ b/pkg/env/eventmesh_config.go @@ -0,0 +1,42 @@ +package env + +import ( + "fmt" + "net/http" + "time" +) + +// compile time check. +var _ fmt.Stringer = &EventMeshConfig{} + +// EventMeshConfig represents the environment config for the Event Publisher to EventMesh. +type EventMeshConfig struct { + Port int `envconfig:"INGRESS_PORT" default:"8080"` + ClientID string `envconfig:"CLIENT_ID" required:"true"` + ClientSecret string `envconfig:"CLIENT_SECRET" required:"true"` + TokenEndpoint string `envconfig:"TOKEN_ENDPOINT" required:"true"` + EventMeshPublishURL string `envconfig:"EMS_PUBLISH_URL" required:"true"` + MaxIdleConns int `envconfig:"MAX_IDLE_CONNS" default:"100"` + MaxIdleConnsPerHost int `envconfig:"MAX_IDLE_CONNS_PER_HOST" default:"2"` + RequestTimeout time.Duration `envconfig:"REQUEST_TIMEOUT" default:"5s"` + // EventMeshNamespace is the name of the namespace in EventMesh which is used as the event source for legacy events. + EventMeshNamespace string `envconfig:"BEB_NAMESPACE" required:"true"` + // EventTypePrefix is the prefix of each event as per the eventing specification. + // It follows the eventType format: ... + EventTypePrefix string `envconfig:"EVENT_TYPE_PREFIX" default:""` + ApplicationCRDEnabled bool `envconfig:"APPLICATION_CRD_ENABLED" default:"true"` +} + +// ConfigureTransport receives an HTTP transport and configure its max idle connection properties. +func (c *EventMeshConfig) ConfigureTransport(transport *http.Transport) { + transport.MaxIdleConns = c.MaxIdleConns + transport.MaxIdleConnsPerHost = c.MaxIdleConnsPerHost +} + +// String implements the fmt.Stringer interface. +func (c *EventMeshConfig) String() string { + return fmt.Sprintf("EventMeshConfig{ Port: %v; TokenEndPoint: %v; EmsPublishURL: %v; "+ + "MaxIdleConns: %v; MaxIdleConnsPerHost: %v; RequestTimeout: %v; BEBNamespace: %v; EventTypePrefix: %v }", + c.Port, c.TokenEndpoint, c.EventMeshPublishURL, c.MaxIdleConns, + c.MaxIdleConnsPerHost, c.RequestTimeout, c.EventMeshNamespace, c.EventTypePrefix) +} diff --git a/pkg/env/eventmesh_config_test.go b/pkg/env/eventmesh_config_test.go new file mode 100644 index 0000000..7b1f215 --- /dev/null +++ b/pkg/env/eventmesh_config_test.go @@ -0,0 +1,28 @@ +package env + +import ( + "net/http" + "testing" +) + +func TestConfigureTransport(t *testing.T) { + t.Parallel() + + const ( + maxIdleConnections = 100 + maxIdleConnectionsPerHost = 200 + ) + + transport := &http.Transport{} + cfg := EventMeshConfig{MaxIdleConns: maxIdleConnections, MaxIdleConnsPerHost: maxIdleConnectionsPerHost} + cfg.ConfigureTransport(transport) + + if transport.MaxIdleConns != maxIdleConnections { + t.Errorf("HTTP Transport MaxIdleConns is misconfigured want: %d but got: %d", + maxIdleConnections, transport.MaxIdleConns) + } + if transport.MaxIdleConnsPerHost != maxIdleConnectionsPerHost { + t.Errorf("HTTP Transport MaxIdleConnsPerHost is misconfigured want: %d but got: %d", + maxIdleConnectionsPerHost, transport.MaxIdleConnsPerHost) + } +} diff --git a/pkg/env/nats_config.go b/pkg/env/nats_config.go new file mode 100644 index 0000000..671b0b6 --- /dev/null +++ b/pkg/env/nats_config.go @@ -0,0 +1,45 @@ +package env + +import ( + "fmt" + "time" +) + +// compile time check. +var _ fmt.Stringer = &NATSConfig{} + +const JetStreamSubjectPrefix = "kyma" + +// NATSConfig represents the environment config for the Event Publisher to NATS. +type NATSConfig struct { + Port int `envconfig:"INGRESS_PORT" default:"8080"` + URL string `envconfig:"NATS_URL" required:"true"` + RetryOnFailedConnect bool `envconfig:"RETRY_ON_FAILED_CONNECT" default:"true"` + MaxReconnects int `envconfig:"MAX_RECONNECTS" default:"-1"` // Negative means keep try reconnecting. + ReconnectWait time.Duration `envconfig:"RECONNECT_WAIT" default:"5s"` + RequestTimeout time.Duration `envconfig:"REQUEST_TIMEOUT" default:"5s"` + ApplicationCRDEnabled bool `envconfig:"APPLICATION_CRD_ENABLED" default:"true"` + + // Legacy Namespace is used as the event source for legacy events + LegacyNamespace string `envconfig:"LEGACY_NAMESPACE" default:"kyma"` + // EventTypePrefix is the prefix of each event as per the eventing specification + // It follows the eventType format: ... + EventTypePrefix string `envconfig:"EVENT_TYPE_PREFIX" default:"kyma"` + + // JetStream-specific configs + JSStreamName string `envconfig:"JS_STREAM_NAME" default:"kyma"` +} + +// ToConfig converts to a default EventMeshConfig. +func (c *NATSConfig) ToConfig() *EventMeshConfig { + cfg := &EventMeshConfig{ + EventMeshNamespace: c.LegacyNamespace, + EventTypePrefix: c.EventTypePrefix, + } + return cfg +} + +// String implements the fmt.Stringer interface. +func (c *NATSConfig) String() string { + return fmt.Sprintf("%#v", c) +} diff --git a/pkg/env/types.go b/pkg/env/types.go new file mode 100644 index 0000000..062d4d7 --- /dev/null +++ b/pkg/env/types.go @@ -0,0 +1,8 @@ +package env + +type ActiveBackend string + +const ( + JetStreamBackend = "JetStream" + EventMeshBackend = "EventMesh" +) diff --git a/pkg/eventmesh/types.go b/pkg/eventmesh/types.go new file mode 100644 index 0000000..2415d78 --- /dev/null +++ b/pkg/eventmesh/types.go @@ -0,0 +1,8 @@ +package eventmesh + +type Qos string + +const ( + // QosAtLeastOnce the quality of service supported by EMS to send Events with at least once guarantee. + QosAtLeastOnce Qos = "AT_LEAST_ONCE" +) diff --git a/pkg/handler/endpoints.go b/pkg/handler/endpoints.go new file mode 100644 index 0000000..23772bd --- /dev/null +++ b/pkg/handler/endpoints.go @@ -0,0 +1,7 @@ +package handler + +const ( + PublishEndpoint = "/publish" + LegacyEndpointPattern = "/{application}/v1/events" + SubscribedEndpointPattern = "/{application}/v1/events/subscribed" +) diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go new file mode 100644 index 0000000..673c94c --- /dev/null +++ b/pkg/handler/handler.go @@ -0,0 +1,359 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/api" + + "github.com/gorilla/mux" + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "go.uber.org/zap" + + "github.com/cloudevents/sdk-go/v2/binding" + cev2client "github.com/cloudevents/sdk-go/v2/client" + cev2event "github.com/cloudevents/sdk-go/v2/event" + cev2http "github.com/cloudevents/sdk-go/v2/protocol/http" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/builder" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler/health" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/receiver" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/subscribed" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/tracing" +) + +// EventingHandler is responsible for receiving HTTP requests and dispatching them to the Backend. +// It also assures that the messages received are compliant with the Cloud Events spec. +type EventingHandler interface { + Start(ctx context.Context) error +} +type Handler struct { + Name string + // Receiver receives incoming HTTP requests + Receiver *receiver.HTTPMessageReceiver + // Sender sends requests to the broker + Sender sender.GenericSender + HealthChecker health.Checker + // Defaulter sets default values to incoming events + Defaulter cev2client.EventDefaulter + // LegacyTransformer handles transformations needed to handle legacy events + LegacyTransformer legacy.RequestToCETransformer + // RequestTimeout timeout for outgoing requests + RequestTimeout time.Duration + // SubscribedProcessor processes requests for /:app/v1/events/subscribed endpoint + SubscribedProcessor *subscribed.Processor + // Logger default logger + Logger *logger.Logger + // Options configures HTTP server + Options *options.Options + // collector collects metrics + collector metrics.PublishingMetricsCollector + // eventTypeCleaner cleans the cloud event type + eventTypeCleaner eventtype.Cleaner + // builds the cloud event according to Subscription v1alpha2 specifications + ceBuilder builder.CloudEventBuilder + router *mux.Router + activeBackend env.ActiveBackend + OldEventTypePrefix string +} + +// New returns a new HTTP Handler instance. +func New(receiver *receiver.HTTPMessageReceiver, sender sender.GenericSender, healthChecker health.Checker, + requestTimeout time.Duration, legacyTransformer legacy.RequestToCETransformer, opts *options.Options, + subscribedProcessor *subscribed.Processor, logger *logger.Logger, collector metrics.PublishingMetricsCollector, + eventTypeCleaner eventtype.Cleaner, ceBuilder builder.CloudEventBuilder, oldEventTypePrefix string, + activeBackend env.ActiveBackend) *Handler { + return &Handler{ + Name: "", + Receiver: receiver, + Sender: sender, + HealthChecker: healthChecker, + Defaulter: nil, + LegacyTransformer: legacyTransformer, + RequestTimeout: requestTimeout, + SubscribedProcessor: subscribedProcessor, + Logger: logger, + Options: opts, + collector: collector, + eventTypeCleaner: eventTypeCleaner, + ceBuilder: ceBuilder, + router: nil, + activeBackend: activeBackend, + OldEventTypePrefix: oldEventTypePrefix, + } +} + +// setupMux configures the request router for all required endpoints. +func (h *Handler) setupMux() { + router := mux.NewRouter() + router.Use(h.collector.MetricsMiddleware()) + router.HandleFunc(PublishEndpoint, h.maxBytes(h.publishCloudEvents)).Methods(http.MethodPost) + router.HandleFunc(LegacyEndpointPattern, h.maxBytes(h.publishLegacyEventsAsCE)).Methods(http.MethodPost) + router.HandleFunc( + SubscribedEndpointPattern, + h.maxBytes(h.SubscribedProcessor.ExtractEventsFromSubscriptions)).Methods(http.MethodGet) + router.HandleFunc(health.ReadinessURI, h.maxBytes(h.HealthChecker.ReadinessCheck)) + router.HandleFunc(health.LivenessURI, h.maxBytes(h.HealthChecker.LivenessCheck)) + h.router = router +} + +// Start starts the Handler with the given context. +func (h *Handler) Start(ctx context.Context) error { + h.setupMux() + return h.Receiver.StartListen(ctx, h.router, h.Logger) +} + +// maxBytes installs a MaxBytesReader onto the request, so that incoming request that is larger than a given size +// will cause an error. +func (h *Handler) maxBytes(f http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, h.Options.MaxRequestSize) + f(w, r) + } +} + +// handleSendEventAndRecordMetricsLegacy handles the publishing of metrics. +// It writes to the user request if any error occurs. +// Otherwise, returns the result. +func (h *Handler) handleSendEventAndRecordMetricsLegacy( + writer http.ResponseWriter, request *http.Request, event *cev2event.Event) error { + err := h.sendEventAndRecordMetrics(request.Context(), event, h.Sender.URL(), request.Header) + if err != nil { + h.namedLogger().Error(err) + httpStatus := http.StatusInternalServerError + var pubErr sender.PublishError + if errors.As(err, &pubErr) { + httpStatus = pubErr.Code() + } + h.LegacyTransformer.WriteCEResponseAsLegacyResponse(writer, httpStatus, event, err.Error()) + return err + } + return nil +} + +// handlePublishLegacyEvent handles the publishing of events for Subscription v1alpha2 CRD. +// It writes to the user request if any error occurs. +// Otherwise, return the published event. +func (h *Handler) handlePublishLegacyEvent(w http.ResponseWriter, r *http.Request, + data *api.PublishRequestData) (*cev2event.Event, error) { + ceEvent, err := h.LegacyTransformer.TransformPublishRequestToCloudEvent(data) + if err != nil { + legacy.WriteJSONResponse(w, legacy.ErrorResponse(http.StatusInternalServerError, err)) + return nil, nil + } + + // build a new cloud event instance as per specifications per backend + event, err := h.ceBuilder.Build(*ceEvent) + if err != nil { + legacy.WriteJSONResponse(w, legacy.ErrorResponseBadRequest(err.Error())) + return nil, err + } + + err = h.handleSendEventAndRecordMetricsLegacy(w, r, event) + if err != nil { + return nil, err + } + + return event, err +} + +// handlePublishLegacyEventV1alpha1 handles the publishing of events for Subscription v1alpha1 CRD. +// It writes to the user request if any error occurs. +// Otherwise, return the published event. +func (h *Handler) handlePublishLegacyEventV1alpha1(w http.ResponseWriter, r *http.Request, + data *api.PublishRequestData) (*cev2event.Event, error) { + event, _ := h.LegacyTransformer.WriteLegacyRequestsToCE(w, data) + if event == nil { + h.namedLogger().Error("Failed to transform legacy event to CloudEvent, event is nil") + return nil, nil + } + + err := h.handleSendEventAndRecordMetricsLegacy(w, r, event) + if err != nil { + return nil, err + } + + return event, err +} + +// publishLegacyEventsAsCE converts an incoming request in legacy event format to a cloudevent and dispatches it using +// the configured GenericSender. +func (h *Handler) publishLegacyEventsAsCE(w http.ResponseWriter, r *http.Request) { + // extract publish data from request + publishRequestData, errResp, _ := h.LegacyTransformer.ExtractPublishRequestData(r) + if errResp != nil { + legacy.WriteJSONResponse(w, errResp) + return + } + + // publish event for Subscription + publishedEvent, err := h.handlePublishLegacyEvent(w, r, publishRequestData) + // if publishedEvent is nil, then it means that the publishing failed + // and the response is already returned to the user + if err != nil { + return + } + + // publish event for Subscription v1alpha1 + // In case: the active backend is JetStream + // then we will publish event on both possible subjects + // i.e. with prefix (`sap.kyma.custom`) and without prefix + // this behaviour will be deprecated when we remove support for JetStream with Subscription `exact` typeMatching + if h.activeBackend == env.JetStreamBackend { + publishedEvent, err = h.handlePublishLegacyEventV1alpha1(w, r, publishRequestData) + // if publishedEvent is nil, then it means that the publishing failed + // and the response is already returned to the user + if err != nil { + return + } + } + + // return success response to user + // change response as per old error codes + h.LegacyTransformer.WriteCEResponseAsLegacyResponse(w, http.StatusNoContent, publishedEvent, "") +} + +// publishCloudEvents validates an incoming cloudevent and dispatches it using +// the configured GenericSender. +func (h *Handler) publishCloudEvents(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + event, err := extractCloudEventFromRequest(r) + if err != nil { + h.namedLogger().With().Error(err) + e := writeResponse(w, http.StatusBadRequest, []byte(err.Error())) + if e != nil { + h.namedLogger().Error(e) + } + return + } + + eventTypeOriginal := event.Type() + + //nolint:nestif // it will be improved when v1alpha1 is deprecated. + if !strings.HasPrefix(eventTypeOriginal, h.OldEventTypePrefix) { + // build a new cloud event instance as per specifications per backend + event, err = h.ceBuilder.Build(*event) + if err != nil { + e := writeResponse(w, http.StatusBadRequest, []byte(err.Error())) + if e != nil { + h.namedLogger().Error(e) + } + return + } + } else { + eventTypeClean, err := h.eventTypeCleaner.Clean(eventTypeOriginal) + if err != nil { + h.namedLogger().Error(err) + e := writeResponse(w, http.StatusBadRequest, []byte(err.Error())) + if e != nil { + h.namedLogger().Error(e) + } + return + } + event.SetType(eventTypeClean) + } + + err = h.sendEventAndRecordMetrics(ctx, event, h.Sender.URL(), r.Header) + if err != nil { + httpStatus := http.StatusInternalServerError + var pubErr sender.PublishError + if errors.As(err, &pubErr) { + httpStatus = pubErr.Code() + } + w.WriteHeader(httpStatus) + h.namedLogger().With().Error(err) + return + } + err = writeResponse(w, http.StatusNoContent, []byte("")) + if err != nil { + h.namedLogger().With().Error(err) + } +} + +// extractCloudEventFromRequest converts an incoming CloudEvent request to an Event. +func extractCloudEventFromRequest(r *http.Request) (*cev2event.Event, error) { + message := cev2http.NewMessageFromHttpRequest(r) + defer func() { _ = message.Finish(nil) }() + + event, err := binding.ToEvent(context.Background(), message) + if err != nil { + return nil, err + } + + err = event.Validate() + if err != nil { + return nil, err + } + return event, nil +} + +// sendEventAndRecordMetrics dispatches an Event and records metrics based on dispatch success. +func (h *Handler) sendEventAndRecordMetrics(ctx context.Context, event *cev2event.Event, + host string, header http.Header) error { + ctx, cancel := context.WithTimeout(ctx, h.RequestTimeout) + defer cancel() + h.applyDefaults(ctx, event) + tracing.AddTracingContextToCEExtensions(header, event) + start := time.Now() + err := h.Sender.Send(ctx, event) + duration := time.Since(start) + if err != nil { + var pubErr sender.PublishError + code := 500 + if errors.As(err, &pubErr) { + code = pubErr.Code() + } + h.collector.RecordBackendLatency(duration, code, host) + return err + } + originalEventType := event.Type() + originalTypeHeader, ok := event.Extensions()[builder.OriginalTypeHeaderName] + if !ok { + h.namedLogger().With().Debugw("event header doesn't exist", "header", + builder.OriginalTypeHeaderName) + } else { + originalEventType, ok = originalTypeHeader.(string) + if !ok { + h.namedLogger().With().Warnw("failed to convert event original event type extension value to string", + builder.OriginalTypeHeaderName, originalTypeHeader) + originalEventType = event.Type() + } + } + h.collector.RecordEventType(originalEventType, event.Source(), http.StatusNoContent) + h.collector.RecordBackendLatency(duration, http.StatusNoContent, host) + return nil +} + +// writeResponse writes the HTTP response given the status code and response body. +func writeResponse(writer http.ResponseWriter, statusCode int, respBody []byte) error { + writer.WriteHeader(statusCode) + + if respBody == nil { + return nil + } + _, err := writer.Write(respBody) + return err +} + +// applyDefaults applies the default values (if any) to the given Cloud Event. +func (h *Handler) applyDefaults(ctx context.Context, event *cev2event.Event) { + if h.Defaulter != nil { + newEvent := h.Defaulter(ctx, *event) + *event = newEvent + } +} + +func (h *Handler) namedLogger() *zap.SugaredLogger { + return h.Logger.WithContext().Named(h.Name) +} diff --git a/pkg/handler/handler_test.go b/pkg/handler/handler_test.go new file mode 100644 index 0000000..cf96c93 --- /dev/null +++ b/pkg/handler/handler_test.go @@ -0,0 +1,405 @@ +//nolint:lll // output directly from prometheus +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/api" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/legacytest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/common" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/jetstream" + + eclogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/kyma-project/kyma/components/eventing-controller/pkg/backend/cleaner" + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/fake" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/builder" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype/eventtypetest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/histogram/mocks" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/metricstest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +func TestHandler_publishCloudEvents(t *testing.T) { + type fields struct { + Sender sender.GenericSender + collector metrics.PublishingMetricsCollector + eventTypeCleaner eventtype.Cleaner + } + type args struct { + request *http.Request + } + + const bucketsFunc = "Buckets" + latency := new(mocks.BucketsProvider) + latency.On(bucketsFunc).Return(nil) + latency.Test(t) + + tests := []struct { + name string + fields fields + args args + wantStatus int + wantBody []byte + wantTEF string + }{ + { + name: "Publish structured Cloudevent for Subscription v1alpha1", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + BackendURL: "FOO", + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidStructuredRequestV1Alpha1(t), + }, + wantStatus: 204, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "/default/sap.kyma/id", ""), + }, + { + name: "Publish binary Cloudevent for Subscription v1alpha1", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + BackendURL: "FOO", + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequestV1Alpha1(t), + }, + wantStatus: 204, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "/default/sap.kyma/id", ""), + }, + { + name: "Publish structured Cloudevent", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + BackendURL: "FOO", + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidStructuredRequest(t), + }, + wantStatus: 204, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "testapp1023", "order.created.v1"), + }, + { + name: "Publish binary Cloudevent", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + BackendURL: "FOO", + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequest(t), + }, + wantStatus: 204, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "testapp1023", "order.created.v1"), + }, + { + name: "Publish invalid structured CloudEvent", + fields: fields{ + Sender: &GenericSenderStub{}, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateInvalidStructuredRequest(t), + }, + wantStatus: 400, + wantBody: []byte("type: MUST be a non-empty string\n"), + }, + { + name: "Publish invalid binary CloudEvent", + fields: fields{ + Sender: &GenericSenderStub{}, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateInvalidBinaryRequest(t), + }, + wantStatus: 400, + }, + { + name: "Publish binary CloudEvent but cannot send", + fields: fields{ + Sender: &GenericSenderStub{ + Err: common.BackendPublishError{}, + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequest(t), + }, + wantStatus: 500, + wantTEF: metricstest.MakeTEFBackendDuration(500, ""), + }, + { + name: "Publish binary CloudEvent but backend is full", + fields: fields{ + Sender: &GenericSenderStub{ + Err: jetstream.ErrNoSpaceLeftOnDevice, + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequest(t), + }, + wantStatus: 507, + wantTEF: metricstest.MakeTEFBackendDuration(507, ""), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + logger, err := eclogger.New("text", "debug") + assert.NoError(t, err) + + app := applicationtest.NewApplication("appName1", nil) + appLister := fake.NewApplicationListerOrDie(context.Background(), app) + + ceBuilder := builder.NewGenericBuilder("prefix", cleaner.NewJetStreamCleaner(logger), appLister, logger) + + h := &Handler{ + Sender: tt.fields.Sender, + Logger: logger, + collector: tt.fields.collector, + eventTypeCleaner: tt.fields.eventTypeCleaner, + ceBuilder: ceBuilder, + Options: &options.Options{}, + OldEventTypePrefix: testingutils.OldEventTypePrefix, + } + writer := httptest.NewRecorder() + + // when + h.publishCloudEvents(writer, tt.args.request) + + // then + assert.Equal(t, tt.wantStatus, writer.Result().StatusCode) + body, err := io.ReadAll(writer.Result().Body) + assert.NoError(t, err) + if tt.wantBody != nil { + assert.Equal(t, tt.wantBody, body) + } + + metricstest.EnsureMetricMatchesTextExpositionFormat(t, h.collector, tt.wantTEF) + }) + } +} + +func TestHandler_publishLegacyEventsAsCE(t *testing.T) { + // define common given variables + appLister := NewApplicationListerOrDie(context.Background(), "testapp") + + // set mock for latency metrics + latency := new(mocks.BucketsProvider) + latency.On("Buckets").Return(nil) + + tests := []struct { + name string + givenSender sender.GenericSender + givenLegacyTransformer legacy.RequestToCETransformer + givenCollector metrics.PublishingMetricsCollector + givenRequest *http.Request + wantHTTPStatus int + wantTEF string + }{ + { + name: "Send valid legacy event", + givenSender: &GenericSenderStub{ + BackendURL: "FOO", + }, + givenLegacyTransformer: legacy.NewTransformer("namespace", "im.a.prefix", appLister), + givenCollector: metrics.NewCollector(latency), + givenRequest: legacytest.ValidLegacyRequestOrDie(t, "v1", "testapp", "object.created"), + wantHTTPStatus: http.StatusOK, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "testapp", "object.created.v1"), + }, + { + name: "Send valid legacy event but cannot send to backend due to target not found (e.g. stream missing)", + givenSender: &GenericSenderStub{ + Err: common.ErrBackendTargetNotFound, + BackendURL: "FOO", + }, + givenLegacyTransformer: legacy.NewTransformer("namespace", "im.a.prefix", appLister), + givenCollector: metrics.NewCollector(latency), + givenRequest: legacytest.ValidLegacyRequestOrDie(t, "v1", "testapp", "object.created"), + wantHTTPStatus: http.StatusNotFound, + wantTEF: metricstest.MakeTEFBackendDuration(404, "FOO"), + }, + { + name: "Send valid legacy event but cannot send to backend due to full storage", + givenSender: &GenericSenderStub{ + Err: common.ErrInsufficientStorage, + BackendURL: "FOO", + }, + givenLegacyTransformer: legacy.NewTransformer("namespace", "im.a.prefix", appLister), + givenCollector: metrics.NewCollector(latency), + givenRequest: legacytest.ValidLegacyRequestOrDie(t, "v1", "testapp", "object.created"), + wantHTTPStatus: 507, + wantTEF: metricstest.MakeTEFBackendDuration(507, "FOO"), + }, + { + name: "Send valid legacy event but cannot send to backend", + givenSender: &GenericSenderStub{ + Err: common.BackendPublishError{}, + BackendURL: "FOO", + }, + givenLegacyTransformer: legacy.NewTransformer("namespace", "im.a.prefix", appLister), + givenCollector: metrics.NewCollector(latency), + givenRequest: legacytest.ValidLegacyRequestOrDie(t, "v1", "testapp", "object.created"), + wantHTTPStatus: 500, + wantTEF: metricstest.MakeTEFBackendDuration(500, "FOO"), + }, + { + name: "Send invalid legacy event", + givenSender: &GenericSenderStub{ + BackendURL: "FOO", + }, + givenLegacyTransformer: legacy.NewTransformer("namespace", "im.a.prefix", appLister), + givenCollector: metrics.NewCollector(latency), + givenRequest: legacytest.InvalidLegacyRequestOrDie(t, "v1", "testapp", "object.created"), + wantHTTPStatus: 400, + // this is a client error. We do record an error metric for requests that cannot even be decoded correctly. + wantTEF: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + logger, err := eclogger.New("text", "debug") + require.NoError(t, err) + + ceBuilder := builder.NewGenericBuilder("prefix", cleaner.NewJetStreamCleaner(logger), appLister, logger) + + h := &Handler{ + Sender: tt.givenSender, + Logger: logger, + LegacyTransformer: tt.givenLegacyTransformer, + collector: tt.givenCollector, + ceBuilder: ceBuilder, + Options: &options.Options{}, + } + writer := httptest.NewRecorder() + + // when + h.publishLegacyEventsAsCE(writer, tt.givenRequest) + + // then + require.Equal(t, tt.wantHTTPStatus, writer.Result().StatusCode) + body, err := io.ReadAll(writer.Result().Body) + require.NoError(t, err) + + if tt.wantHTTPStatus == http.StatusOK { + ok := &api.PublishResponse{} + err = json.Unmarshal(body, ok) + require.NoError(t, err) + } else { + nok := &api.Error{} + err = json.Unmarshal(body, nok) + require.NoError(t, err) + } + + metricstest.EnsureMetricMatchesTextExpositionFormat(t, h.collector, tt.wantTEF) + }) + } +} + +// CreateValidStructuredRequestV1Alpha2 creates a structured cloudevent as http request. +func CreateValidStructuredRequest(t *testing.T) *http.Request { + t.Helper() + reader := strings.NewReader(`{ + "specversion":"1.0", + "type":"order.created.v1", + "source":"testapp1023", + "id":"8945ec08-256b-11eb-9928-acde48001122", + "data":{ + "foo":"bar" + } + }`) + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Content-Type", "application/cloudevents+json") + return req +} + +// CreateBrokenRequest creates a structured cloudevent request that cannot be parsed. +func CreateBrokenRequest(t *testing.T) *http.Request { + t.Helper() + reader := strings.NewReader("I AM JUST A BROKEN REQUEST") + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Content-Type", "application/cloudevents+json") + return req +} + +// CreateInvalidStructuredRequestV1Alpha2 creates an invalid structured cloudevent as http request. +// The `type` is missing. +func CreateInvalidStructuredRequest(t *testing.T) *http.Request { + t.Helper() + reader := strings.NewReader(`{ + "specversion":"1.0", + "source":"testapp1023", + "id":"8945ec08-256b-11eb-9928-acde48001122", + "data":{ + "foo":"bar" + }}`) + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Content-Type", "application/cloudevents+json") + return req +} + +// CreateValidBinaryRequestV1Alpha2 creates a valid binary cloudevent as http request. +func CreateValidBinaryRequest(t *testing.T) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", strings.NewReader(`{"foo":"bar"}`)) + req.Header.Add("Ce-Specversion", "1.0") + req.Header.Add("Ce-Type", "order.created.v1") + req.Header.Add("Ce-Source", "testapp1023") + req.Header.Add("Ce-ID", "8945ec08-256b-11eb-9928-acde48001122") + return req +} + +// CreateInvalidBinaryRequestV1Alpha2 creates an invalid binary cloudevent as http request. The `type` is missing. +func CreateInvalidBinaryRequest(t *testing.T) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", strings.NewReader(`{"foo":"bar"}`)) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Ce-Specversion", "1.0") + req.Header.Add("Ce-Source", "testapp1023") + req.Header.Add("Ce-ID", "8945ec08-256b-11eb-9928-acde48001122") + return req +} diff --git a/pkg/handler/handler_v1alpha1_test.go b/pkg/handler/handler_v1alpha1_test.go new file mode 100644 index 0000000..ea640e0 --- /dev/null +++ b/pkg/handler/handler_v1alpha1_test.go @@ -0,0 +1,690 @@ +//nolint:lll // this test uses many long lines directly from prometheus output +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/builder" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/common" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cloudevents/sdk-go/v2/client" + cev2event "github.com/cloudevents/sdk-go/v2/event" + eclogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/fake" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents/eventtype/eventtypetest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/histogram/mocks" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/metricstest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +func Test_extractCloudEventFromRequest(t *testing.T) { + type args struct { + request *http.Request + } + type wants struct { + event *cev2event.Event + errorAssertionFunc assert.ErrorAssertionFunc + } + tests := []struct { + name string + args args + wantType string + wants wants + }{ + { + name: "Valid event", + args: args{ + request: CreateValidStructuredRequestV1Alpha1(t), + }, + wantType: fmt.Sprintf("sap.kyma.custom.%s", testingutils.CloudEventType), + wants: wants{ + event: CreateCloudEvent(t), + errorAssertionFunc: assert.NoError, + }, + }, + { + name: "Invalid event", + args: args{ + request: CreateInvalidStructuredRequestV1Alpha1(t), + }, + wants: wants{ + event: nil, + errorAssertionFunc: assert.Error, + }, + }, + { + name: "Entirely broken Request", + args: args{ + request: CreateBrokenRequestV1Alpha1(t), + }, + wants: wants{ + event: nil, + errorAssertionFunc: assert.Error, + }, + }, + { + name: "Valid event", + args: args{ + request: CreateValidBinaryRequestV1Alpha1(t), + }, + wantType: fmt.Sprintf("sap.kyma.custom.%s", testingutils.CloudEventType), + wants: wants{ + event: CreateCloudEvent(t), + errorAssertionFunc: assert.NoError, + }, + }, + { + name: "Invalid event", + args: args{ + request: CreateInvalidBinaryRequestV1Alpha1(t), + }, + wants: wants{ + event: nil, + errorAssertionFunc: assert.Error, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotEvent, err := extractCloudEventFromRequest(tt.args.request) + if tt.wantType != "" { + tt.wants.event.SetType(tt.wantType) + } + if !tt.wants.errorAssertionFunc(t, err, fmt.Sprintf("extractCloudEventFromRequest(%v)", tt.args.request)) { + return + } + assert.Equalf(t, tt.wants.event, gotEvent, "extractCloudEventFromRequest(%v)", tt.args.request) + }) + } +} + +func Test_writeResponse(t *testing.T) { + type args struct { + statusCode int + respBody []byte + } + tests := []struct { + name string + args args + assertionFunc assert.ErrorAssertionFunc + }{ + { + name: "Response and body", + args: args{ + statusCode: 200, + respBody: []byte("foo"), + }, + assertionFunc: assert.NoError, + }, + { + name: "Response and no body", + args: args{ + statusCode: 200, + respBody: nil, + }, + assertionFunc: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + writer := httptest.NewRecorder() + + // when + err := writeResponse(writer, tt.args.statusCode, tt.args.respBody) + + // then + tt.assertionFunc(t, err, fmt.Sprintf("writeResponse(%v, %v)", tt.args.statusCode, tt.args.respBody)) + assert.Equal(t, tt.args.statusCode, writer.Result().StatusCode) + body, err := io.ReadAll(writer.Result().Body) + assert.NoError(t, err) + if tt.args.respBody != nil { + assert.Equal(t, tt.args.respBody, body) + } else { + assert.Equal(t, []byte(""), body) + } + }) + } +} + +func TestHandler_publishCloudEvents_v1alpha1(t *testing.T) { + type fields struct { + Sender sender.GenericSender + collector metrics.PublishingMetricsCollector + eventTypeCleaner eventtype.Cleaner + } + type args struct { + request *http.Request + } + + const bucketsFunc = "Buckets" + latency := new(mocks.BucketsProvider) + latency.On(bucketsFunc).Return(nil) + latency.Test(t) + + tests := []struct { + name string + fields fields + args args + wantStatus int + wantBody []byte + wantTEF string + }{ + { + name: "Publish structured Cloudevent", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + BackendURL: "FOO", + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidStructuredRequestV1Alpha1(t), + }, + wantStatus: 204, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "/default/sap.kyma/id", ""), + }, + { + name: "Publish binary Cloudevent", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + BackendURL: "FOO", + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequestV1Alpha1(t), + }, + wantStatus: 204, + wantTEF: metricstest.MakeTEFBackendDuration(204, "FOO") + + metricstest.MakeTEFEventTypePublished(204, "/default/sap.kyma/id", ""), + }, + { + name: "Publish invalid structured CloudEvent", + fields: fields{ + Sender: &GenericSenderStub{}, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateInvalidStructuredRequestV1Alpha1(t), + }, + wantStatus: 400, + wantBody: []byte("type: MUST be a non-empty string\n"), + }, + { + name: "Publish invalid binary CloudEvent", + fields: fields{ + Sender: &GenericSenderStub{}, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateInvalidBinaryRequestV1Alpha1(t), + }, + wantStatus: 400, + }, + { + name: "Publish binary CloudEvent but cannot clean", + fields: fields{ + Sender: &GenericSenderStub{}, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{ + CleanType: "", + Error: fmt.Errorf("I cannot clean"), + }, + }, + args: args{ + request: CreateValidBinaryRequestV1Alpha1(t), + }, + wantStatus: 400, + wantBody: []byte("I cannot clean"), + wantTEF: "", // client error will not be recorded as EPP internal error. So no metric will be updated. + }, + { + name: "Publish binary CloudEvent but cannot send", + fields: fields{ + Sender: &GenericSenderStub{ + Err: common.BackendPublishError{}, + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequestV1Alpha1(t), + }, + wantStatus: 500, + wantTEF: metricstest.MakeTEFBackendDuration(500, ""), + }, + { + name: "Publish binary CloudEvent but backend is full", + fields: fields{ + Sender: &GenericSenderStub{ + Err: common.ErrInsufficientStorage, + }, + collector: metrics.NewCollector(latency), + eventTypeCleaner: &eventtypetest.CleanerStub{}, + }, + args: args{ + request: CreateValidBinaryRequestV1Alpha1(t), + }, + wantStatus: http.StatusInsufficientStorage, + wantTEF: metricstest.MakeTEFBackendDuration(507, ""), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + logger, err := eclogger.New("text", "debug") + assert.NoError(t, err) + + h := &Handler{ + Sender: tt.fields.Sender, + Logger: logger, + collector: tt.fields.collector, + eventTypeCleaner: tt.fields.eventTypeCleaner, + Options: &options.Options{}, + } + writer := httptest.NewRecorder() + + // when + h.publishCloudEvents(writer, tt.args.request) + + // then + assert.Equal(t, tt.wantStatus, writer.Result().StatusCode) + body, err := io.ReadAll(writer.Result().Body) + assert.NoError(t, err) + if tt.wantBody != nil { + assert.Equal(t, tt.wantBody, body) + } + + metricstest.EnsureMetricMatchesTextExpositionFormat(t, h.collector, tt.wantTEF) + }) + } +} + +func TestHandler_maxBytes(t *testing.T) { + type fields struct { + maxBytes int + } + tests := []struct { + name string + fields fields + wantStatus int + }{ + { + name: "request small enough", + fields: fields{ + maxBytes: 10000, + }, + wantStatus: 200, + }, + { + name: "request too large", + fields: fields{ + maxBytes: 1, + }, + wantStatus: 400, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + h := &Handler{ + Options: &options.Options{ + MaxRequestSize: int64(tt.fields.maxBytes), + }, + } + writer := httptest.NewRecorder() + var mberr *http.MaxBytesError + f := func(writer http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + if errors.As(err, &mberr) { + writer.WriteHeader(http.StatusBadRequest) + } + writer.WriteHeader(http.StatusOK) + } + + // when + h.maxBytes(f)(writer, &http.Request{ + Method: http.MethodPost, + Body: io.NopCloser(strings.NewReader(strings.Repeat("#", 5))), + }) + + // then + assert.Equal(t, tt.wantStatus, writer.Result().StatusCode) + }) + } +} + +func TestHandler_sendEventAndRecordMetrics(t *testing.T) { + type fields struct { + Sender sender.GenericSender + Defaulter client.EventDefaulter + collector metrics.PublishingMetricsCollector + } + type args struct { + ctx context.Context + host string + event *cev2event.Event + header http.Header + } + type wants struct { + result sender.PublishError + assertionFunc assert.ErrorAssertionFunc + metricErrors int + metricTotal int + metricLatency int + metricPublished int + metricLatencyTEF string + metricPublishedTotalTEF string + } + + const bucketsFunc = "Buckets" + latency := new(mocks.BucketsProvider) + latency.On(bucketsFunc).Return(nil) + latency.Test(t) + latencyMetricTEF := ` + # HELP eventing_epp_backend_duration_milliseconds The duration of sending events to the messaging server in milliseconds + # TYPE eventing_epp_backend_duration_milliseconds histogram + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.005"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.01"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.025"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.05"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.1"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.25"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="0.5"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="1"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="2.5"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="5"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="10"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="204",destination_service="foo",le="+Inf"} 1 + eventing_epp_backend_duration_milliseconds_sum{code="204",destination_service="foo"} 0 + eventing_epp_backend_duration_milliseconds_count{code="204",destination_service="foo"} 1 + ` + + ceEvent := CreateCloudEvent(t) + ceEventWithOriginalEventType := ceEvent.Clone() + ceEventWithOriginalEventType.SetExtension(builder.OriginalTypeHeaderName, testingutils.CloudEventNameAndVersion) + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "No Error", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + SleepDuration: 0, + }, + Defaulter: nil, + collector: metrics.NewCollector(latency), + }, + args: args{ + ctx: context.Background(), + host: "foo", + event: ceEvent, + }, + wants: wants{ + assertionFunc: assert.NoError, + metricErrors: 0, + metricTotal: 1, + metricLatency: 1, + metricPublished: 1, + metricLatencyTEF: latencyMetricTEF, + metricPublishedTotalTEF: ` + # HELP eventing_epp_event_type_published_total The total number of events published for a given eventTypeLabel + # TYPE eventing_epp_event_type_published_total counter + eventing_epp_event_type_published_total{code="204",event_source="/default/sap.kyma/id",event_type="prefix.testapp1023.order.created.v1"} 1 + `, + }, + }, + { + name: "No Error - set original event type top published metric", + fields: fields{ + Sender: &GenericSenderStub{ + Err: nil, + SleepDuration: 0, + }, + Defaulter: nil, + collector: metrics.NewCollector(latency), + }, + args: args{ + ctx: context.Background(), + host: "foo", + event: &ceEventWithOriginalEventType, + }, + wants: wants{ + assertionFunc: assert.NoError, + metricErrors: 0, + metricTotal: 1, + metricLatency: 1, + metricPublished: 1, + metricLatencyTEF: latencyMetricTEF, + metricPublishedTotalTEF: ` + # HELP eventing_epp_event_type_published_total The total number of events published for a given eventTypeLabel + # TYPE eventing_epp_event_type_published_total counter + eventing_epp_event_type_published_total{code="204",event_source="/default/sap.kyma/id",event_type="order.created.v1"} 1 + `, + }, + }, + { + name: "Sending not successful, error returned", + fields: fields{ + Sender: &GenericSenderStub{ + Err: common.BackendPublishError{}, + SleepDuration: 5, + }, + Defaulter: nil, + collector: metrics.NewCollector(latency), + }, + args: args{ + ctx: context.Background(), + host: "foo", + event: &cev2event.Event{}, + }, + wants: wants{ + result: nil, + assertionFunc: assert.Error, + metricErrors: 1, + metricTotal: 1, + metricLatency: 1, + metricPublished: 0, + metricLatencyTEF: metricstest.MakeTEFBackendDuration(500, "foo"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + logger, _ := eclogger.New("text", "debug") + h := &Handler{ + Sender: tt.fields.Sender, + Defaulter: tt.fields.Defaulter, + collector: tt.fields.collector, + Logger: logger, + } + + // when + err := h.sendEventAndRecordMetrics(tt.args.ctx, tt.args.event, tt.args.host, tt.args.header) + + // then + if !tt.wants.assertionFunc(t, err, fmt.Sprintf("sendEventAndRecordMetrics(%v, %v, %v)", tt.args.ctx, tt.args.host, tt.args.event)) { + return + } + metricstest.EnsureMetricLatency(t, h.collector, tt.wants.metricLatency) + metricstest.EnsureMetricEventTypePublished(t, h.collector, tt.wants.metricPublished) + metricstest.EnsureMetricMatchesTextExpositionFormat(t, h.collector, tt.wants.metricLatencyTEF, "eventing_epp_backend_duration_milliseconds") + metricstest.EnsureMetricMatchesTextExpositionFormat(t, h.collector, tt.wants.metricPublishedTotalTEF, "eventing_epp_event_type_published_total") + }) + } +} + +func TestHandler_sendEventAndRecordMetrics_TracingAndDefaults(t *testing.T) { + // given + stub := &GenericSenderStub{ + SleepDuration: 0, + Err: common.BackendPublishError{HTTPCode: http.StatusInternalServerError}, + } + + const bucketsFunc = "Buckets" + latency := new(mocks.BucketsProvider) + latency.On(bucketsFunc).Return(nil) + latency.Test(t) + logger, _ := eclogger.New("text", "debug") + h := &Handler{ + Sender: stub, + Defaulter: nil, + collector: metrics.NewCollector(latency), + Logger: logger, + } + header := http.Header{} + headers := []string{"traceparent", "X-B3-TraceId", "X-B3-ParentSpanId", "X-B3-SpanId", "X-B3-Sampled", "X-B3-Flags"} + + for _, v := range headers { + header.Add(v, v) + } + expectedExtensions := map[string]any{ + "traceparent": "traceparent", + "b3traceid": "X-B3-TraceId", + "b3parentspanid": "X-B3-ParentSpanId", + "b3spanid": "X-B3-SpanId", + "b3sampled": "X-B3-Sampled", + "b3flags": "X-B3-Flags", + } + // when + err := h.sendEventAndRecordMetrics(context.Background(), CreateCloudEvent(t), "", header) + + // then + assert.Error(t, err) + assert.Equal(t, expectedExtensions, stub.ReceivedEvent.Context.GetExtensions()) +} + +func CreateCloudEvent(t *testing.T) *cev2event.Event { + builder := testingutils.NewCloudEventBuilder( + testingutils.WithCloudEventType(testingutils.CloudEventTypeWithPrefix), + ) + payload, _ := builder.BuildStructured() + newEvent := cloudevents.NewEvent() + err := json.Unmarshal([]byte(payload), &newEvent) + assert.NoError(t, err) + newEvent.SetType(testingutils.CloudEventTypeWithPrefix) + err = newEvent.SetData("", map[string]any{"foo": "bar"}) + assert.NoError(t, err) + + return &newEvent +} + +// CreateValidStructuredRequestV1Alpha1 creates a structured cloudevent as http request. +func CreateValidStructuredRequestV1Alpha1(t *testing.T) *http.Request { + t.Helper() + s := `{ + "specversion":"1.0", + "type":"sap.kyma.custom.testapp1023.order.created.v1", + "source":"/default/sap.kyma/id", + "id":"8945ec08-256b-11eb-9928-acde48001122", + "data":{"foo":"bar"} + }` + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", strings.NewReader(s)) + req.Header.Add("Content-Type", "application/cloudevents+json") + return req +} + +// CreateBrokenRequestV1Alpha1 creates a structured cloudevent request that cannot be parsed. +func CreateBrokenRequestV1Alpha1(t *testing.T) *http.Request { + t.Helper() + reader := strings.NewReader("I AM JUST A BROKEN REQUEST") + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Content-Type", "application/cloudevents+json") + return req +} + +// CreateInvalidStructuredRequestV1Alpha1 creates an invalid structured cloudevent as http request. The `type` is missing. +func CreateInvalidStructuredRequestV1Alpha1(t *testing.T) *http.Request { + t.Helper() + s := `{ + "specversion":"1.0", + "source":"/default/sap.kyma/id", + "id":"8945ec08-256b-11eb-9928-acde48001122", + "data": { + "foo":"bar" + } + }` + reader := strings.NewReader(s) + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Content-Type", "application/cloudevents+json") + return req +} + +// CreateValidBinaryRequestV1Alpha1 creates a valid binary cloudevent as http request. +func CreateValidBinaryRequestV1Alpha1(t *testing.T) *http.Request { + t.Helper() + reader := strings.NewReader(`{"foo":"bar"}`) + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Ce-Specversion", "1.0") + req.Header.Add("Ce-Type", "sap.kyma.custom.testapp1023.order.created.v1") + req.Header.Add("Ce-Source", "/default/sap.kyma/id") + req.Header.Add("Ce-ID", "8945ec08-256b-11eb-9928-acde48001122") + return req +} + +// CreateInvalidBinaryRequestV1Alpha1 creates an invalid binary cloudevent as http request. The `type` is missing. +func CreateInvalidBinaryRequestV1Alpha1(t *testing.T) *http.Request { + t.Helper() + reader := strings.NewReader(`{"foo":"bar"}`) + req := httptest.NewRequest(http.MethodPost, "http://localhost/publish", reader) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Ce-Specversion", "1.0") + req.Header.Add("Ce-Source", "/default/sap.kyma/id") + req.Header.Add("Ce-ID", "8945ec08-256b-11eb-9928-acde48001122") + return req +} + +type GenericSenderStub struct { + SleepDuration time.Duration + Err sender.PublishError + ReceivedEvent *cev2event.Event + BackendURL string +} + +func (g *GenericSenderStub) Send(_ context.Context, event *cev2event.Event) sender.PublishError { + g.ReceivedEvent = event + time.Sleep(g.SleepDuration) + return g.Err +} + +func (g *GenericSenderStub) URL() string { + return g.BackendURL +} + +func NewApplicationListerOrDie(ctx context.Context, appName string) *application.Lister { + app := applicationtest.NewApplication(appName, nil) + appLister := fake.NewApplicationListerOrDie(ctx, app) + return appLister +} diff --git a/pkg/handler/health/health.go b/pkg/handler/health/health.go new file mode 100644 index 0000000..9656fee --- /dev/null +++ b/pkg/handler/health/health.go @@ -0,0 +1,84 @@ +package health + +import ( + "net/http" +) + +const ( + // LivenessURI is the endpoint URI used for liveness check. + LivenessURI = "/healthz" + + // ReadinessURI is the endpoint URI used for readiness check. + ReadinessURI = "/readyz" + + // StatusCodeHealthy is the status code which indicates a healthy state. + StatusCodeHealthy = http.StatusOK + + // StatusCodeNotHealthy is the status code which indicates a not healthy state. + StatusCodeNotHealthy = http.StatusInternalServerError +) + +type Checker interface { + ReadinessCheck(w http.ResponseWriter, r *http.Request) + LivenessCheck(w http.ResponseWriter, r *http.Request) +} + +// ConfigurableChecker represents a health checker. +type ConfigurableChecker struct { + livenessCheck http.HandlerFunc + readinessCheck http.HandlerFunc +} + +func (c ConfigurableChecker) ReadinessCheck(w http.ResponseWriter, r *http.Request) { + c.readinessCheck(w, r) +} + +func (c ConfigurableChecker) LivenessCheck(w http.ResponseWriter, r *http.Request) { + c.livenessCheck(w, r) +} + +// CheckerOpt represents a health checker option. +type CheckerOpt func(*ConfigurableChecker) + +// NewChecker returns a new instance of ConfigurableChecker initialized with the default liveness and readiness checks. +func NewChecker(opts ...CheckerOpt) *ConfigurableChecker { + c := &ConfigurableChecker{ + livenessCheck: DefaultCheck, + readinessCheck: DefaultCheck, + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +// DefaultCheck always writes a 2XX status code for the given http.ResponseWriter. +func DefaultCheck(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(StatusCodeHealthy) +} + +// WithLivenessCheck returns CheckerOpt which sets the liveness check for the given http.HandlerFunc. +// It panics if the given http.HandlerFunc is nil. +func WithLivenessCheck(h http.HandlerFunc) CheckerOpt { + if h == nil { + panic("liveness handler is nil") + } + + return func(c *ConfigurableChecker) { + c.livenessCheck = h + } +} + +// WithReadinessCheck returns CheckerOpt which sets the readiness check for the given http.HandlerFunc. +// It panics if the given http.HandlerFunc is nil. +func WithReadinessCheck(h http.HandlerFunc) CheckerOpt { + if h == nil { + panic("readiness handler is nil") + } + + return func(c *ConfigurableChecker) { + c.readinessCheck = h + } +} diff --git a/pkg/handler/health/health_test.go b/pkg/handler/health/health_test.go new file mode 100644 index 0000000..54b0916 --- /dev/null +++ b/pkg/handler/health/health_test.go @@ -0,0 +1,122 @@ +package health + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChecker(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + useCustomLivenessCheck bool // use the givenCustomLivenessCheck instead of the default one + useCustomReadinessCheck bool // use the givenCustomReadinessCheck instead of the default one + givenCustomLivenessCheck http.HandlerFunc // custom liveness check (can be nil) + givenCustomReadinessCheck http.HandlerFunc // custom readiness check (can be nil) + wantPanicForNilHealthChecks bool // panic if provided health checks is nil + wantLivenessStatusCode int // expected liveness status code + wantReadinessStatusCode int // expected readiness status code + }{ + { + name: "should report default health checks status-codes", + useCustomLivenessCheck: false, + useCustomReadinessCheck: false, + wantLivenessStatusCode: StatusCodeHealthy, + wantReadinessStatusCode: StatusCodeHealthy, + }, + { + name: "should report default health checks and next handler status-codes", + useCustomLivenessCheck: false, + useCustomReadinessCheck: false, + wantLivenessStatusCode: StatusCodeHealthy, + wantReadinessStatusCode: StatusCodeHealthy, + }, + { + name: "should panic if provided liveness check is nil", + useCustomLivenessCheck: true, + useCustomReadinessCheck: false, + givenCustomLivenessCheck: nil, + wantPanicForNilHealthChecks: true, + }, + { + name: "should panic if provided readiness check is nil", + useCustomLivenessCheck: false, + useCustomReadinessCheck: true, + givenCustomReadinessCheck: nil, + wantPanicForNilHealthChecks: true, + }, + { + name: "should report custom health checks and next handler status-codes", + useCustomLivenessCheck: true, + useCustomReadinessCheck: true, + givenCustomLivenessCheck: handlerFuncWithStatusCode(http.StatusOK), + givenCustomReadinessCheck: handlerFuncWithStatusCode(http.StatusAccepted), + wantPanicForNilHealthChecks: false, + wantLivenessStatusCode: http.StatusOK, + wantReadinessStatusCode: http.StatusAccepted, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + defer func() { + r := recover() + + if !assert.Equal(t, tc.wantPanicForNilHealthChecks, r != nil) { + t.Log(r) + } + }() + + var opts []CheckerOpt + if tc.useCustomLivenessCheck { + opts = append(opts, WithLivenessCheck(tc.givenCustomLivenessCheck)) + } + if tc.useCustomReadinessCheck { + opts = append(opts, WithReadinessCheck(tc.givenCustomReadinessCheck)) + } + checker := NewChecker(opts...) + + if tc.useCustomLivenessCheck { + assertResponseLivenessStatusCode(t, LivenessURI, checker, tc.wantLivenessStatusCode) + } else { + assertResponseLivenessStatusCode(t, LivenessURI, checker, StatusCodeHealthy) + } + + if tc.useCustomReadinessCheck { + assertResponseReadinessStatusCode(t, ReadinessURI, checker, tc.wantReadinessStatusCode) + } else { + assertResponseReadinessStatusCode(t, ReadinessURI, checker, StatusCodeHealthy) + } + }) + } +} + +func assertResponseLivenessStatusCode(t *testing.T, endpoint string, checker *ConfigurableChecker, statusCode int) { + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, endpoint, nil) + + checker.LivenessCheck(writer, request) + + require.Equal(t, statusCode, writer.Result().StatusCode) +} + +func assertResponseReadinessStatusCode(t *testing.T, endpoint string, checker *ConfigurableChecker, statusCode int) { + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, endpoint, nil) + + checker.ReadinessCheck(writer, request) + + require.Equal(t, statusCode, writer.Result().StatusCode) +} + +func handlerFuncWithStatusCode(statusCode int) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(statusCode) + } +} diff --git a/pkg/informers/sync.go b/pkg/informers/sync.go new file mode 100644 index 0000000..e9648e9 --- /dev/null +++ b/pkg/informers/sync.go @@ -0,0 +1,61 @@ +package informers + +import ( + "context" + "time" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/dynamicinformer" +) + +const ( + DefaultResyncPeriod = 10 * time.Second +) + +type waitForCacheSyncFunc func(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool + +// WaitForCacheSyncOrDie waits for the cache to sync. If sync fails everything stops. +func WaitForCacheSyncOrDie(ctx context.Context, dc dynamicinformer.DynamicSharedInformerFactory, logger *logger.Logger) { + dc.Start(ctx.Done()) + + ctx, cancel := context.WithTimeout(context.Background(), DefaultResyncPeriod) + defer cancel() + + err := hasSynced(ctx, dc.WaitForCacheSync) + if err != nil { + logger.WithContext().Fatalw("Failed to sync informer caches", "error", err) + } +} + +func hasSynced(ctx context.Context, fn waitForCacheSyncFunc) error { + // synced gets closed as soon as fn returns + synced := make(chan struct{}) + // closing stopWait forces fn to return, which happens whenever ctx + // gets canceled + stopWait := make(chan struct{}) + defer close(stopWait) + + // close the synced channel if the `WaitForCacheSync()` finished the execution cleanly + go func() { + informersCacheSync := fn(stopWait) + res := true + for _, sync := range informersCacheSync { + if !sync { + res = false + } + } + if res { + close(synced) + } + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-synced: + } + + return nil +} diff --git a/pkg/legacy/api/types.go b/pkg/legacy/api/types.go new file mode 100644 index 0000000..dad8dfe --- /dev/null +++ b/pkg/legacy/api/types.go @@ -0,0 +1,58 @@ +package api + +import "net/http" + +// AnyValue implements the service definition of AnyValue. +type AnyValue any + +// PublishRequestData holds request data. +type PublishRequestData struct { + PublishEventParameters *PublishEventParametersV1 + ApplicationName string + Headers http.Header + URLPath string +} + +// PublishRequestV1 implements the service definition of PublishRequestV1. +type PublishRequestV1 struct { + EventType string `json:"event-type,omitempty"` + EventTypeVersion string `json:"event-type-version,omitempty"` + EventID string `json:"event-id,omitempty"` + EventTime string `json:"event-time,omitempty"` + Data AnyValue `json:"data,omitempty"` +} + +// PublishEventParametersV1 holds parameters to PublishEvent. +type PublishEventParametersV1 struct { + PublishrequestV1 PublishRequestV1 `json:"publishrequest,omitempty"` +} + +// PublishResponse implements the service definition of PublishResponse. +type PublishResponse struct { + EventID string `json:"event-id,omitempty"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +// Error implements the service definition of APIError. +type Error struct { + Status int `json:"status,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + MoreInfo string `json:"moreInfo,omitempty"` + Details []ErrorDetail `json:"details,omitempty"` +} + +// ErrorDetail implements the service definition of APIErrorDetail. +type ErrorDetail struct { + Field string `json:"field,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + MoreInfo string `json:"moreInfo,omitempty"` +} + +// PublishEventResponses holds responses of PublishEvent. +type PublishEventResponses struct { + Ok *PublishResponse + Error *Error +} diff --git a/pkg/legacy/constants.go b/pkg/legacy/constants.go new file mode 100644 index 0000000..2ded4c3 --- /dev/null +++ b/pkg/legacy/constants.go @@ -0,0 +1,33 @@ +package legacy + +// Allowed patterns for the Event components. +const ( + AllowedEventTypeVersionChars = `[a-zA-Z0-9]+` + AllowedEventIDChars = `^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$` +) + +// Error messages. +const ( + ErrorMessageBadPayload = "Bad payload syntax" + ErrorMessageRequestBodyTooLarge = "Request body too large" + ErrorMessageMissingField = "Missing field" + ErrorMessageInvalidField = "Invalid field" +) + +// Error type definitions. +const ( + ErrorTypeBadPayload = "bad_payload_syntax" + ErrorTypeRequestBodyTooLarge = "request_body_too_large" + ErrorTypeMissingField = "missing_field" + ErrorTypeValidationViolation = "validation_violation" + ErrorTypeInvalidField = "invalid_field" +) + +// Field definitions. +const ( + FieldEventID = "event-id" + FieldEventTime = "event-time" + FieldEventType = "event-type" + FieldEventTypeVersion = "event-type-version" + FieldData = "data" +) diff --git a/pkg/legacy/error_responses.go b/pkg/legacy/error_responses.go new file mode 100644 index 0000000..ce5e9b4 --- /dev/null +++ b/pkg/legacy/error_responses.go @@ -0,0 +1,118 @@ +package legacy + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/api" +) + +// An HTTPErrorResponse represents an error with a status code and an error message. +type HTTPErrorResponse struct { + Code int `json:"code"` + Error string `json:"error"` +} + +// ErrorResponseBadRequest returns an error of type PublishEventResponses with BadRequest status code. +func ErrorResponseBadRequest(moreInfo string) *api.PublishEventResponses { + var details []api.ErrorDetail + apiError := api.Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeBadPayload, + Message: ErrorMessageBadPayload, + MoreInfo: moreInfo, + Details: details, + } + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} + +// ErrorResponseRequestBodyTooLarge returns an error of type PublishEventResponses with BadRequest status code. +func ErrorResponseRequestBodyTooLarge(moreInfo string) *api.PublishEventResponses { + var details []api.ErrorDetail + apiError := api.Error{ + Status: http.StatusRequestEntityTooLarge, + Type: ErrorTypeRequestBodyTooLarge, + Message: ErrorMessageRequestBodyTooLarge, + MoreInfo: moreInfo, + Details: details, + } + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} + +// ErrorResponseMissingFieldEventType returns an error of type PublishEventResponses for missing EventType field. +func ErrorResponseMissingFieldEventType() *api.PublishEventResponses { + return CreateMissingFieldError(FieldEventType) +} + +// ErrorResponseMissingFieldEventTypeVersion returns an error of type PublishEventResponses +// for missing EventTypeVersion field. +func ErrorResponseMissingFieldEventTypeVersion() *api.PublishEventResponses { + return CreateMissingFieldError(FieldEventTypeVersion) +} + +// ErrorResponseWrongEventTypeVersion returns an error of type PublishEventResponses for wrong EventTypeVersion field. +func ErrorResponseWrongEventTypeVersion() *api.PublishEventResponses { + return CreateInvalidFieldError(FieldEventTypeVersion) +} + +// ErrorResponseMissingFieldEventTime returns an error of type PublishEventResponses for missing EventTime field. +func ErrorResponseMissingFieldEventTime() *api.PublishEventResponses { + return CreateMissingFieldError(FieldEventTime) +} + +// ErrorResponseWrongEventTime returns an error of type PublishEventResponses for wrong EventTime field. +func ErrorResponseWrongEventTime() *api.PublishEventResponses { + return CreateInvalidFieldError(FieldEventTime) +} + +// ErrorResponseWrongEventID returns an error of type PublishEventResponses for wrong EventID field. +func ErrorResponseWrongEventID() *api.PublishEventResponses { + return CreateInvalidFieldError(FieldEventID) +} + +// ErrorResponseMissingFieldData returns an error of type PublishEventResponses for missing Data field. +func ErrorResponseMissingFieldData() *api.PublishEventResponses { + return CreateMissingFieldError(FieldData) +} + +// ErrorResponse returns an error of type PublishEventResponses with the given status and error. +func ErrorResponse(status int, err error) *api.PublishEventResponses { + return &api.PublishEventResponses{Error: &api.Error{Status: status, Message: err.Error()}} +} + +// CreateMissingFieldError creates an error for a missing field. +func CreateMissingFieldError(field any) *api.PublishEventResponses { + apiErrorDetail := api.ErrorDetail{ + Field: field.(string), + Type: ErrorTypeMissingField, + Message: ErrorMessageMissingField, + MoreInfo: "", + } + details := []api.ErrorDetail{apiErrorDetail} + apiError := api.Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeValidationViolation, + Message: ErrorMessageMissingField, + MoreInfo: "", + Details: details, + } + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} + +// CreateInvalidFieldError creates an error for an invalid field. +func CreateInvalidFieldError(field any) *api.PublishEventResponses { + apiErrorDetail := api.ErrorDetail{ + Field: field.(string), + Type: ErrorTypeInvalidField, + Message: ErrorMessageInvalidField, + MoreInfo: "", + } + details := []api.ErrorDetail{apiErrorDetail} + apiError := api.Error{ + Status: http.StatusBadRequest, + Type: ErrorTypeValidationViolation, + Message: ErrorMessageInvalidField, + MoreInfo: "", + Details: details, + } + return &api.PublishEventResponses{Ok: nil, Error: &apiError} +} diff --git a/pkg/legacy/helpers.go b/pkg/legacy/helpers.go new file mode 100644 index 0000000..2e66e8e --- /dev/null +++ b/pkg/legacy/helpers.go @@ -0,0 +1,75 @@ +package legacy + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + kymalogger "github.com/kyma-project/kyma/components/eventing-controller/logger" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/api" +) + +const ( + // eventTypeFormat is driven by BEB specification. + // An EventType must have at least 4 segments separated by dots in the form of: + // `...`. + eventTypeFormat = "%s.%s.%s.%s" + + // eventTypeFormatWithoutPrefix must have at least 3 segments separated by dots in the form of: + // `..`. + eventTypeFormatWithoutPrefix = "%s.%s.%s" + + legacyEventsName = "legacy-events" +) + +// ParseApplicationNameFromPath returns application name from the URL. +// The format of the URL is: /:application-name/v1/... +// returns empty string if application-name cannot be found. +func ParseApplicationNameFromPath(path string) string { + path = strings.TrimLeft(path, "/") + application, _, ok := strings.Cut(path, "/") + if ok { + return application + } + return "" +} + +// is2XXStatusCode checks whether status code is a 2XX status code. +func is2XXStatusCode(statusCode int) bool { + return statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices +} + +// WriteJSONResponse writes a JSON response. +func WriteJSONResponse(w http.ResponseWriter, resp *api.PublishEventResponses) { + encoder := json.NewEncoder(w) + w.Header().Set(internal.HeaderContentType, internal.ContentTypeApplicationJSON) + + if resp.Error != nil { + w.WriteHeader(resp.Error.Status) + _ = encoder.Encode(resp.Error) + return + } + + if resp.Ok != nil { + _ = encoder.Encode(resp.Ok) + return + } + + // init the contexted logger + logger, _ := kymalogger.New("json", "error") + namedLogger := logger.WithContext().Named(legacyEventsName) + + namedLogger.Error("Received an empty response") +} + +// formatEventType joins the given prefix, application, eventType and version with a "." as a separator. +// It ignores the prefix if it is empty. +func formatEventType(prefix, application, eventType, version string) string { + if len(strings.TrimSpace(prefix)) == 0 { + return fmt.Sprintf(eventTypeFormatWithoutPrefix, application, eventType, version) + } + return fmt.Sprintf(eventTypeFormat, prefix, application, eventType, version) +} diff --git a/pkg/legacy/helpers_test.go b/pkg/legacy/helpers_test.go new file mode 100644 index 0000000..b69cab9 --- /dev/null +++ b/pkg/legacy/helpers_test.go @@ -0,0 +1,90 @@ +package legacy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseApplicationNameFromPath(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenInputPath string + wantAppName string + }{ + { + name: "should return application when correct path is used", + givenInputPath: "/application/v1/events", + wantAppName: "application", + }, + { + name: "should return application when extra slash is in the path", + givenInputPath: "//application/v1/events", + wantAppName: "application", + }, + { + name: "should return empty string when no slash contained in cleaned up path", + givenInputPath: "//events", + wantAppName: "", + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotAppName := ParseApplicationNameFromPath(tc.givenInputPath) + assert.Equal(t, tc.wantAppName, gotAppName) + }) + } +} + +func TestFormatEventType(t *testing.T) { + testCases := []struct { + givenPrefix string + givenApplication string + givenEventType string + givenVersion string + wantEventType string + }{ + { + givenPrefix: "prefix", + givenApplication: "app", + givenEventType: "order.foo", + givenVersion: "v1", + wantEventType: "prefix.app.order.foo.v1", + }, + { + givenPrefix: "prefix", + givenApplication: "app", + givenEventType: "order-foo", + givenVersion: "v1", + wantEventType: "prefix.app.order-foo.v1", + }, + { + givenPrefix: "prefix", + givenApplication: "app", + givenEventType: "order-foo.bar@123", + givenVersion: "v1", + wantEventType: "prefix.app.order-foo.bar@123.v1", + }, + { + givenPrefix: "", + givenApplication: "app", + givenEventType: "order-foo", + givenVersion: "v1", + wantEventType: "app.order-foo.v1", + }, + { + givenPrefix: "", + givenApplication: "app", + givenEventType: "order-foo.bar@123", + givenVersion: "v1", + wantEventType: "app.order-foo.bar@123.v1", + }, + } + for _, tc := range testCases { + eventType := formatEventType(tc.givenPrefix, tc.givenApplication, tc.givenEventType, tc.givenVersion) + assert.Equal(t, tc.wantEventType, eventType) + } +} diff --git a/pkg/legacy/legacy.go b/pkg/legacy/legacy.go new file mode 100644 index 0000000..793e6cf --- /dev/null +++ b/pkg/legacy/legacy.go @@ -0,0 +1,263 @@ +package legacy + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + cev2 "github.com/cloudevents/sdk-go/v2/event" + "github.com/google/uuid" + "github.com/pkg/errors" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + apiv1 "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/api" +) + +var ( + validEventTypeVersion = regexp.MustCompile(AllowedEventTypeVersionChars) + validEventID = regexp.MustCompile(AllowedEventIDChars) +) + +const ( + requestBodyTooLargeErrorMessage = "http: request body too large" + eventTypeVersionExtensionKey = "eventtypeversion" +) + +type RequestToCETransformer interface { + ExtractPublishRequestData(*http.Request) (*apiv1.PublishRequestData, *apiv1.PublishEventResponses, error) + TransformPublishRequestToCloudEvent(*apiv1.PublishRequestData) (*cev2.Event, error) + WriteLegacyRequestsToCE(http.ResponseWriter, *apiv1.PublishRequestData) (*cev2.Event, string) + WriteCEResponseAsLegacyResponse(http.ResponseWriter, int, *cev2.Event, string) +} + +type Transformer struct { + eventMeshNamespace string + eventTypePrefix string + applicationLister *application.Lister // applicationLister will be nil when disabled. +} + +func NewTransformer(bebNamespace string, eventTypePrefix string, applicationLister *application.Lister) *Transformer { + return &Transformer{ + eventMeshNamespace: bebNamespace, + eventTypePrefix: eventTypePrefix, + applicationLister: applicationLister, + } +} + +func (t *Transformer) isApplicationListerEnabled() bool { + return t.applicationLister != nil +} + +// CheckParameters validates the parameters in the request and sends error responses if found invalid. +func (t *Transformer) checkParameters(parameters *apiv1.PublishEventParametersV1) (response *apiv1.PublishEventResponses) { + if parameters == nil { + return ErrorResponseBadRequest(ErrorMessageBadPayload) + } + if len(parameters.PublishrequestV1.EventType) == 0 { + return ErrorResponseMissingFieldEventType() + } + if len(parameters.PublishrequestV1.EventTypeVersion) == 0 { + return ErrorResponseMissingFieldEventTypeVersion() + } + if !validEventTypeVersion.MatchString(parameters.PublishrequestV1.EventTypeVersion) { + return ErrorResponseWrongEventTypeVersion() + } + if len(parameters.PublishrequestV1.EventTime) == 0 { + return ErrorResponseMissingFieldEventTime() + } + if _, err := time.Parse(time.RFC3339, parameters.PublishrequestV1.EventTime); err != nil { + return ErrorResponseWrongEventTime() + } + if len(parameters.PublishrequestV1.EventID) > 0 && !validEventID.MatchString(parameters.PublishrequestV1.EventID) { + return ErrorResponseWrongEventID() + } + if parameters.PublishrequestV1.Data == nil { + return ErrorResponseMissingFieldData() + } + if d, ok := (parameters.PublishrequestV1.Data).(string); ok && len(d) == 0 { + return ErrorResponseMissingFieldData() + } + // OK + return &apiv1.PublishEventResponses{} +} + +// ExtractPublishRequestData extracts the data for publishing event from the given legacy event request. +func (t *Transformer) ExtractPublishRequestData(request *http.Request) (*apiv1.PublishRequestData, *apiv1.PublishEventResponses, error) { + // parse request body to PublishRequestV1 + if request.Body == nil || request.ContentLength == 0 { + resp := ErrorResponseBadRequest(ErrorMessageBadPayload) + return nil, resp, errors.New(resp.Error.Message) + } + + parameters := &apiv1.PublishEventParametersV1{} + decoder := json.NewDecoder(request.Body) + if err := decoder.Decode(¶meters.PublishrequestV1); err != nil { + var resp *apiv1.PublishEventResponses + if err.Error() == requestBodyTooLargeErrorMessage { + resp = ErrorResponseRequestBodyTooLarge(err.Error()) + } else { + resp = ErrorResponseBadRequest(err.Error()) + } + return nil, resp, errors.New(resp.Error.Message) + } + + // validate the PublishRequestV1 for missing / incoherent values + checkResp := t.checkParameters(parameters) + if checkResp.Error != nil { + return nil, checkResp, errors.New(checkResp.Error.Message) + } + + appName := ParseApplicationNameFromPath(request.URL.Path) + publishRequestData := &apiv1.PublishRequestData{ + PublishEventParameters: parameters, + ApplicationName: appName, + URLPath: request.URL.Path, + Headers: request.Header, + } + + return publishRequestData, nil, nil +} + +// WriteLegacyRequestsToCE transforms the legacy event to cloudevent from the given request. +// It also returns the original event-type without cleanup as the second return type. +func (t *Transformer) WriteLegacyRequestsToCE(writer http.ResponseWriter, publishData *apiv1.PublishRequestData) (*cev2.Event, string) { + uncleanedAppName := publishData.ApplicationName + + // clean the application name form non-alphanumeric characters + // handle non-existing applications + appName := application.GetCleanName(uncleanedAppName) + // check if we need to use name from application CR. + if t.isApplicationListerEnabled() { + if appObj, err := t.applicationLister.Get(uncleanedAppName); err == nil { + // handle existing applications + appName = application.GetCleanTypeOrName(appObj) + } + } + + event, err := t.convertPublishRequestToCloudEvent(appName, publishData.PublishEventParameters) + if err != nil { + response := ErrorResponse(http.StatusInternalServerError, err) + WriteJSONResponse(writer, response) + return nil, "" + } + + // prepare the original event-type without cleanup + eventType := formatEventType(t.eventTypePrefix, publishData.ApplicationName, + publishData.PublishEventParameters.PublishrequestV1.EventType, + publishData.PublishEventParameters.PublishrequestV1.EventTypeVersion) + + return event, eventType +} + +func (t *Transformer) WriteCEResponseAsLegacyResponse(writer http.ResponseWriter, statusCode int, event *cev2.Event, msg string) { + response := &apiv1.PublishEventResponses{} + // Fail + if !is2XXStatusCode(statusCode) { + response.Error = &apiv1.Error{ + Status: statusCode, + Message: msg, + } + WriteJSONResponse(writer, response) + return + } + + // Success + response.Ok = &apiv1.PublishResponse{EventID: event.ID()} + WriteJSONResponse(writer, response) +} + +// TransformPublishRequestToCloudEvent converts the given publish request to a CloudEvent with raw values. +func (t *Transformer) TransformPublishRequestToCloudEvent(publishRequestData *apiv1.PublishRequestData) (*cev2.Event, error) { + source := publishRequestData.ApplicationName + publishRequest := publishRequestData.PublishEventParameters + + // instantiate a new cloudEvent object + event := cev2.New(cev2.CloudEventsVersionV1) + eventName := publishRequest.PublishrequestV1.EventType + eventTypeVersion := publishRequest.PublishrequestV1.EventTypeVersion + + // set type by combining type and version (.) e.g. order.created.v1 + event.SetType(fmt.Sprintf("%s.%s", eventName, eventTypeVersion)) + event.SetSource(source) + event.SetExtension(eventTypeVersionExtensionKey, eventTypeVersion) + event.SetDataContentType(internal.ContentTypeApplicationJSON) + + // set cloudEvent time + evTime, err := time.Parse(time.RFC3339, publishRequest.PublishrequestV1.EventTime) + if err != nil { + return nil, errors.Wrap(err, "failed to parse time from the external publish request") + } + event.SetTime(evTime) + + // set cloudEvent data + if err := event.SetData(internal.ContentTypeApplicationJSON, publishRequest.PublishrequestV1.Data); err != nil { + return nil, errors.Wrap(err, "failed to set data to CloudEvent data field") + } + + // set the event id from the request if it is available + // otherwise generate a new one + if len(publishRequest.PublishrequestV1.EventID) > 0 { + event.SetID(publishRequest.PublishrequestV1.EventID) + } else { + event.SetID(uuid.New().String()) + } + + return &event, nil +} + +// convertPublishRequestToCloudEvent converts the given publish request to a CloudEvent. +func (t *Transformer) convertPublishRequestToCloudEvent(appName string, publishRequest *apiv1.PublishEventParametersV1) (*cev2.Event, error) { + if !application.IsCleanName(appName) { + return nil, errors.New("application name should be cleaned from none-alphanumeric characters") + } + + event := cev2.New(cev2.CloudEventsVersionV1) + + evTime, err := time.Parse(time.RFC3339, publishRequest.PublishrequestV1.EventTime) + if err != nil { + return nil, errors.Wrap(err, "failed to parse time from the external publish request") + } + event.SetTime(evTime) + + if err := event.SetData(internal.ContentTypeApplicationJSON, publishRequest.PublishrequestV1.Data); err != nil { + return nil, errors.Wrap(err, "failed to set data to CloudEvent data field") + } + + // set the event id from the request if it is available + // otherwise generate a new one + if len(publishRequest.PublishrequestV1.EventID) > 0 { + event.SetID(publishRequest.PublishrequestV1.EventID) + } else { + event.SetID(uuid.New().String()) + } + + eventName := combineEventNameSegments(removeNonAlphanumeric(publishRequest.PublishrequestV1.EventType)) + prefix := removeNonAlphanumeric(t.eventTypePrefix) + eventType := formatEventType(prefix, appName, eventName, publishRequest.PublishrequestV1.EventTypeVersion) + event.SetType(eventType) + event.SetSource(t.eventMeshNamespace) + event.SetExtension(eventTypeVersionExtensionKey, publishRequest.PublishrequestV1.EventTypeVersion) + event.SetDataContentType(internal.ContentTypeApplicationJSON) + return &event, nil +} + +// combineEventNameSegments returns an eventName with exactly two segments separated by "." if the given event-type +// has two or more segments separated by "." (e.g. "Account.Order.Created" becomes "AccountOrder.Created"). +func combineEventNameSegments(eventName string) string { + parts := strings.Split(eventName, ".") + if len(parts) > 1 { + businessObject := strings.Join(parts[0:len(parts)-1], "") + operation := parts[len(parts)-1] + eventName = fmt.Sprintf("%s.%s", businessObject, operation) + } + return eventName +} + +// removeNonAlphanumeric returns an eventName without any non-alphanumerical character besides dot ("."). +func removeNonAlphanumeric(eventType string) string { + return regexp.MustCompile("[^a-zA-Z0-9.]+").ReplaceAllString(eventType, "") +} diff --git a/pkg/legacy/legacy_test.go b/pkg/legacy/legacy_test.go new file mode 100644 index 0000000..d428edd --- /dev/null +++ b/pkg/legacy/legacy_test.go @@ -0,0 +1,468 @@ +package legacy + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + cev2event "github.com/cloudevents/sdk-go/v2/event" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/applicationtest" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/application/fake" + legacyapi "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/api" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy/legacytest" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +const ( + eventTypeMultiSegment = "Segment1.Segment2.Segment3.Segment4.Segment5" + eventTypeMultiSegmentCombined = "Segment1Segment2Segment3Segment4.Segment5" +) + +// TestTransformLegacyRequestsToCE ensures that TransformLegacyRequestsToCE transforms a http request containing +// a legacy request to a valid cloud event by creating mock http requests. +func TestTransformLegacyRequestsToCE(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + // the event type has the structure + // ... + // or, more specific + // ...... + // e.g. "sap.kyma.custom.varkestest.ordertest.created.v1" + // where "sap.kyma.custom" is the (or ..), + // "varkestest" is the + // "ordertest.created" ts the (or .) + // and "v2" is the + // + // derived from that a legacy event path (a.k.a. endpoint) has the structure + // http://:///events + // e.g. http://localhost:8081/varkestest/v1/events + givenPrefix string + givenApplication string + givenTypeLabel string + givenApplicationListerEnabled bool + givenEventName string + wantVersion string + wantType string + }{ + { + name: "clean", + givenPrefix: "pre1.pre2.pre3", + givenApplication: "app", + givenTypeLabel: "", + givenApplicationListerEnabled: true, + givenEventName: "object.do", + wantVersion: "v1", + wantType: "pre1.pre2.pre3.app.object.do.v1", + }, + { + name: "not clean app name", + givenPrefix: "pre1.pre2.pre3", + givenApplication: "no-app", + givenTypeLabel: "", + givenApplicationListerEnabled: true, + givenEventName: "object.do", + wantVersion: "v1", + wantType: "pre1.pre2.pre3.noapp.object.do.v1", + }, + { + name: "event name too many segments", + givenPrefix: "pre1.pre2.pre3", + givenApplication: "app", + givenTypeLabel: "", + givenApplicationListerEnabled: true, + givenEventName: "too.many.dots.object.do", + wantVersion: "v1", + wantType: "pre1.pre2.pre3.app.toomanydotsobject.do.v1", + }, + { + name: "with event type label", + givenPrefix: "pre1.pre2.pre3", + givenApplication: "app", + givenTypeLabel: "different", + givenApplicationListerEnabled: true, + givenEventName: "object.do", + wantVersion: "v1", + wantType: "pre1.pre2.pre3.different.object.do.v1", + }, + { + name: "with application lister disabled", + givenPrefix: "pre1.pre2.pre3", + givenApplication: "app", + givenTypeLabel: "different", + givenApplicationListerEnabled: false, + givenEventName: "object.do", + wantVersion: "v1", + wantType: "pre1.pre2.pre3.app.object.do.v1", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + request, err := legacytest.ValidLegacyRequest(tc.wantVersion, tc.givenApplication, tc.givenEventName) + assert.NoError(t, err) + + writer := httptest.NewRecorder() + app := applicationtest.NewApplication(tc.givenApplication, applicationTypeLabel(tc.givenTypeLabel)) + + var appLister *application.Lister + if tc.givenApplicationListerEnabled { + appLister = fake.NewApplicationListerOrDie(ctx, app) + } + + transformer := NewTransformer("test", tc.givenPrefix, appLister) + publishData, errResp, _ := transformer.ExtractPublishRequestData(request) + assert.Nil(t, errResp) + gotEvent, gotEventType := transformer.WriteLegacyRequestsToCE(writer, publishData) + wantEventType := formatEventType(tc.givenPrefix, tc.givenApplication, tc.givenEventName, tc.wantVersion) + assert.Equal(t, wantEventType, gotEventType) + + // check eventType + gotType := gotEvent.Context.GetType() + assert.Equal(t, tc.wantType, gotType) + + // check extensions 'eventtypeversion' + gotVersion, ok := gotEvent.Extensions()["eventtypeversion"].(string) + assert.True(t, ok) + assert.Equal(t, tc.wantVersion, gotVersion) + + // check HTTP ContentType set properly + gotContentType := gotEvent.Context.GetDataContentType() + assert.Equal(t, internal.ContentTypeApplicationJSON, gotContentType) + }) + } +} + +func applicationTypeLabel(label string) map[string]string { + if label != "" { + return map[string]string{application.TypeLabel: label} + } + return nil +} + +func TestConvertPublishRequestToCloudEvent(t *testing.T) { + givenEventID := testingutils.EventID + givenApplicationName := testingutils.ApplicationName + givenEventTypePrefix := testingutils.Prefix + givenTimeNow := time.Now().Format(time.RFC3339) + givenLegacyEventVersion := testingutils.EventVersion + givenPublishReqParams := &legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventID: givenEventID, + EventType: eventTypeMultiSegment, + EventTime: givenTimeNow, + EventTypeVersion: givenLegacyEventVersion, + Data: testingutils.EventData, + }, + } + + wantEventMeshNamespace := testingutils.MessagingNamespace + wantEventID := givenEventID + wantEventType := formatEventType(givenEventTypePrefix, givenApplicationName, eventTypeMultiSegmentCombined, givenLegacyEventVersion) + wantTimeNowFormatted, _ := time.Parse(time.RFC3339, givenTimeNow) + wantDataContentType := internal.ContentTypeApplicationJSON + + legacyTransformer := NewTransformer(wantEventMeshNamespace, givenEventTypePrefix, nil) + gotEvent, err := legacyTransformer.convertPublishRequestToCloudEvent(givenApplicationName, givenPublishReqParams) + require.NoError(t, err) + assert.Equal(t, wantEventMeshNamespace, gotEvent.Context.GetSource()) + assert.Equal(t, wantEventID, gotEvent.Context.GetID()) + assert.Equal(t, wantEventType, gotEvent.Context.GetType()) + assert.Equal(t, wantTimeNowFormatted, gotEvent.Context.GetTime()) + assert.Equal(t, wantDataContentType, gotEvent.Context.GetDataContentType()) + + wantLegacyEventVersion := givenLegacyEventVersion + gotExtension, err := gotEvent.Context.GetExtension(eventTypeVersionExtensionKey) + assert.NoError(t, err) + assert.Equal(t, wantLegacyEventVersion, gotExtension) +} + +func TestCombineEventTypeSegments(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenEventType string + wantEventType string + }{ + { + name: "event-type with two segments", + givenEventType: testingutils.EventName, + wantEventType: testingutils.EventName, + }, + { + name: "event-type with more than two segments", + givenEventType: eventTypeMultiSegment, + wantEventType: eventTypeMultiSegmentCombined, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if gotEventType := combineEventNameSegments(tc.givenEventType); tc.wantEventType != gotEventType { + t.Fatalf("invalid event-type want: %s, got: %s", tc.wantEventType, gotEventType) + } + }) + } +} + +func TestRemoveNonAlphanumeric(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + givenEventType string + wantEventType string + }{ + { + name: "unclean", + givenEventType: "1-2+3=4.t&h$i#s.t!h@a%t.t;o/m$f*o{o]lery", + wantEventType: "1234.this.that.tomfoolery", + }, + { + name: "clean", + givenEventType: "1234.this.that", + wantEventType: "1234.this.that", + }, + { + name: "single unclean segment", + givenEventType: "t_o_m_f_o_o_l_e_r_y", + wantEventType: "tomfoolery", + }, + { + name: "empty", + givenEventType: "", + wantEventType: "", + }, + } + for _, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("%s eventType", tc.name), func(t *testing.T) { + t.Parallel() + + gotEventType := removeNonAlphanumeric(tc.givenEventType) + assert.Equal(t, tc.wantEventType, gotEventType) + }) + } +} + +func TestExtractPublishRequestData(t *testing.T) { + t.Parallel() + const givenVersion = "v1" + const givenPrefix = "pre1.pre2.pre3" + const givenApplication = "app" + const givenEventName = "object.do" + + testCases := []struct { + name string + givenLegacyRequestFunc func() (*http.Request, error) + wantPublishRequestData *legacyapi.PublishRequestData + wantErrorResponse *legacyapi.PublishEventResponses + }{ + { + name: "should fail if request body is empty", + givenLegacyRequestFunc: func() (*http.Request, error) { + return legacytest.InvalidLegacyRequestWithEmptyBody(givenVersion, givenApplication) + }, + wantErrorResponse: ErrorResponseBadRequest(ErrorMessageBadPayload), + }, + { + name: "should fail if request body has missing parameters", + givenLegacyRequestFunc: func() (*http.Request, error) { + return legacytest.InvalidLegacyRequest(givenVersion, givenApplication, givenEventName) + }, + wantErrorResponse: ErrorResponseMissingFieldEventType(), + }, + { + name: "should succeed if request body is valid", + givenLegacyRequestFunc: func() (*http.Request, error) { + return legacytest.ValidLegacyRequest(givenVersion, givenApplication, givenEventName) + }, + wantPublishRequestData: &legacyapi.PublishRequestData{ + PublishEventParameters: &legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventType: "object.do", + EventTypeVersion: "v1", + EventTime: "2020-04-02T21:37:00Z", + Data: "{\"legacy\":\"event\"}", + }, + }, + ApplicationName: "app", + URLPath: "/app/v1/events", + }, + }, + { + name: "should succeed if app name has a special character", + givenLegacyRequestFunc: func() (*http.Request, error) { + return legacytest.ValidLegacyRequest(givenVersion, "no-app", givenEventName) + }, + wantPublishRequestData: &legacyapi.PublishRequestData{ + PublishEventParameters: &legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventType: "object.do", + EventTypeVersion: "v1", + EventTime: "2020-04-02T21:37:00Z", + Data: "{\"legacy\":\"event\"}", + }, + }, + ApplicationName: "no-app", + URLPath: "/no-app/v1/events", + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // given + request, err := tc.givenLegacyRequestFunc() + require.NoError(t, err) + // set expected header + if tc.wantPublishRequestData != nil { + tc.wantPublishRequestData.Headers = request.Header + } + + transformer := NewTransformer("test", givenPrefix, nil) + + // when + publishData, errResp, err := transformer.ExtractPublishRequestData(request) + + // then + if tc.wantErrorResponse != nil { + require.Error(t, err) + require.Equal(t, *tc.wantErrorResponse, *errResp) + } else { + require.NoError(t, err) + require.Nil(t, errResp) + require.Equal(t, *tc.wantPublishRequestData, *publishData) + } + }) + } +} + +func TestTransformPublishRequestToCloudEvent(t *testing.T) { + t.Parallel() + const givenVersion = "v1" + const givenPrefix = "pre1.pre2.pre3" + const givenApplication = "app" + const givenEventName = "object.do" + + testCases := []struct { + name string + givenPublishEventParameters legacyapi.PublishEventParametersV1 + wantCloudEventFunc func() (cev2event.Event, error) + wantErrorResponse legacyapi.PublishEventResponses + wantError bool + wantEventType string + wantSource string + wantData string + wantEventID string + }{ + { + name: "should succeed if publish data is valid", + givenPublishEventParameters: legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventID: testingutils.EventID, + EventType: givenEventName, + EventTypeVersion: givenVersion, + EventTime: "2020-04-02T21:37:00Z", + Data: map[string]string{"key": "value"}, + }, + }, + wantError: false, + wantEventID: testingutils.EventID, + wantEventType: "object.do.v1", + wantSource: givenApplication, + wantData: `{"key":"value"}`, + }, + { + name: "should set new event ID when not provided", + givenPublishEventParameters: legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventType: givenEventName, + EventTypeVersion: givenVersion, + EventTime: "2020-04-02T21:37:00Z", + Data: map[string]string{"key": "value"}, + }, + }, + wantError: false, + wantEventType: "object.do.v1", + wantSource: givenApplication, + wantData: `{"key":"value"}`, + }, + { + name: "should fail if event time is invalid", + givenPublishEventParameters: legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventType: givenEventName, + EventTypeVersion: givenVersion, + EventTime: "20dsadsa20-04-02T21:37:00Z", + Data: map[string]string{"key": "value"}, + }, + }, + wantError: true, + }, + { + name: "should fail if event data is not json", + givenPublishEventParameters: legacyapi.PublishEventParametersV1{ + PublishrequestV1: legacyapi.PublishRequestV1{ + EventType: givenEventName, + EventTypeVersion: givenVersion, + EventTime: "20dsadsa20-04-02T21:37:00Z", + Data: "test", + }, + }, + wantError: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // given + givenPublishRequestData := legacyapi.PublishRequestData{ + PublishEventParameters: &tc.givenPublishEventParameters, + ApplicationName: givenApplication, + } + transformer := NewTransformer("test", givenPrefix, nil) + + // when + ceEvent, err := transformer.TransformPublishRequestToCloudEvent(&givenPublishRequestData) + + // then + if tc.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + + require.Equal(t, tc.wantEventType, ceEvent.Type()) + require.Equal(t, tc.wantSource, ceEvent.Source()) + require.Equal(t, tc.wantData, string(ceEvent.Data())) + require.NotEmpty(t, ceEvent.ID()) + if tc.wantEventID != "" { + require.Equal(t, tc.wantEventID, ceEvent.ID()) + } + } + }) + } +} diff --git a/pkg/legacy/legacytest/test.go b/pkg/legacy/legacytest/test.go new file mode 100644 index 0000000..d5f0deb --- /dev/null +++ b/pkg/legacy/legacytest/test.go @@ -0,0 +1,60 @@ +package legacytest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func InvalidLegacyRequest(version, appname, eventType string) (*http.Request, error) { + body, err := json.Marshal(map[string]string{ + "eventtype": eventType, + "eventtypeversion": version, + "eventtime": "2020-04-02T21:37:00Z", + "data": "{\"legacy\":\"event\"}", + }) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("http://localhost:8080/%s/%s/events", appname, version) + return http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewBuffer(body)) +} + +func ValidLegacyRequest(version, appname, eventType string) (*http.Request, error) { + body, err := json.Marshal(map[string]string{ + "event-type": eventType, + "event-type-version": version, + "event-time": "2020-04-02T21:37:00Z", + "data": "{\"legacy\":\"event\"}", + }) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("http://localhost:8080/%s/%s/events", appname, version) + return http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewBuffer(body)) +} + +func InvalidLegacyRequestOrDie(t *testing.T, version, appname, eventType string) *http.Request { + r, err := InvalidLegacyRequest(version, appname, eventType) + assert.NoError(t, err) + return r +} + +func ValidLegacyRequestOrDie(t *testing.T, version, appname, eventType string) *http.Request { + t.Helper() + r, err := ValidLegacyRequest(version, appname, eventType) + assert.NoError(t, err) + return r +} + +func InvalidLegacyRequestWithEmptyBody(version, appname string) (*http.Request, error) { + url := fmt.Sprintf("http://localhost:8080/%s/%s/events", appname, version) + return http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil) +} diff --git a/pkg/metrics/collector.go b/pkg/metrics/collector.go new file mode 100644 index 0000000..29f42fd --- /dev/null +++ b/pkg/metrics/collector.go @@ -0,0 +1,175 @@ +package metrics + +import ( + "fmt" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/histogram" +) + +const ( + // HealthKey name of the health metric. + HealthKey = "eventing_epp_health" + // HealthHelp help text for the Health metric. + healthHelp = "The current health of the system. `1` indicates a healthy system" + + // BackendLatencyKey name of the backendLatency metric. + BackendLatencyKey = "eventing_epp_backend_duration_milliseconds" + // backendLatencyHelp help text for the backendLatency metric. + backendLatencyHelp = "The duration of sending events to the messaging server in milliseconds" + + // durationKey name of the duration metric. + durationKey = "eventing_epp_requests_duration_seconds" + // durationHelp help text for the duration metric. + durationHelp = "The duration of processing an incoming request (includes sending to the backend)" + + // RequestsKey name of the Requests metric. + RequestsKey = "eventing_epp_requests_total" + // requestsHelp help text for event requests metric. + requestsHelp = "The total number of requests" + + // EventTypePublishedMetricKey name of the eventTypeLabel metric. + EventTypePublishedMetricKey = "eventing_epp_event_type_published_total" + // eventTypePublishedMetricHelp help text for the eventTypeLabel metric. + eventTypePublishedMetricHelp = "The total number of events published for a given eventTypeLabel" + // methodLabel label for the method used in the http request. + + methodLabel = "method" + // responseCodeLabel name of the status code labels used by multiple metrics. + responseCodeLabel = "code" + // pathLabel name of the path service label. + pathLabel = "path" + // destSvcLabel name of the destination service label used by multiple metrics. + destSvcLabel = "destination_service" + // eventTypeLabel name of the event type label used by metrics. + eventTypeLabel = "event_type" + // eventSourceLabel name of the event source label used by metrics. + eventSourceLabel = "event_source" +) + +// PublishingMetricsCollector interface provides a Prometheus compatible Collector with additional convenience methods +// for recording epp specific metrics. +type PublishingMetricsCollector interface { + prometheus.Collector + RecordBackendLatency(duration time.Duration, statusCode int, destSvc string) + RecordEventType(eventType, eventSource string, statusCode int) + MetricsMiddleware() mux.MiddlewareFunc +} + +var _ PublishingMetricsCollector = &Collector{} + +// Collector implements the prometheus.Collector interface. +type Collector struct { + backendLatency *prometheus.HistogramVec + + duration *prometheus.HistogramVec + requests *prometheus.CounterVec + + eventType *prometheus.CounterVec + + health *prometheus.GaugeVec +} + +// NewCollector creates a new instance of Collector. +func NewCollector(latency histogram.BucketsProvider) *Collector { + return &Collector{ + //nolint:promlinter // we follow the same pattern as istio. so a millisecond unit if fine here + backendLatency: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: BackendLatencyKey, + Help: backendLatencyHelp, + Buckets: latency.Buckets(), + }, + []string{responseCodeLabel, destSvcLabel}, + ), + eventType: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: EventTypePublishedMetricKey, + Help: eventTypePublishedMetricHelp, + }, + []string{eventTypeLabel, eventSourceLabel, responseCodeLabel}, + ), + + duration: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: durationKey, + Help: durationHelp, + Buckets: []float64{0.001, 0.002, 0.004, 0.008, 0.016, 0.032, 0.050, 0.075, + 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 0.9, + 1, 1.5, 2, 3, 5}, + }, + []string{responseCodeLabel, methodLabel, pathLabel}, + ), + requests: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: RequestsKey, + Help: requestsHelp, + }, + []string{responseCodeLabel, methodLabel, pathLabel}, + ), + health: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: HealthKey, + Help: healthHelp, + }, + nil, + ), + } +} + +// Describe implements the prometheus.Collector interface Describe method. +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + c.backendLatency.Describe(ch) + c.eventType.Describe(ch) + c.requests.Describe(ch) + c.duration.Describe(ch) + c.health.Describe(ch) +} + +// Collect implements the prometheus.Collector interface Collect method. +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + c.backendLatency.Collect(ch) + c.eventType.Collect(ch) + c.requests.Collect(ch) + c.duration.Collect(ch) + c.health.Collect(ch) +} + +// RecordLatency records a backendLatencyHelp metric. +func (c *Collector) RecordBackendLatency(duration time.Duration, statusCode int, destSvc string) { + c.backendLatency.WithLabelValues(fmt.Sprint(statusCode), destSvc).Observe(float64(duration.Milliseconds())) +} + +// SetHealthStatus updates the health metric. +func (c *Collector) SetHealthStatus(healthy bool) { + var v float64 + if healthy { + v = 1 + } + c.health.WithLabelValues().Set(v) +} + +// RecordEventType records an eventType metric. +func (c *Collector) RecordEventType(eventType, eventSource string, statusCode int) { + c.eventType.WithLabelValues(eventType, eventSource, fmt.Sprint(statusCode)).Inc() +} + +// MetricsMiddleware returns a http.Handler that can be used as middleware in gorilla.mux to track +// latencies for all handled paths in the gorilla router. +func (c *Collector) MetricsMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route := mux.CurrentRoute(r) + path, _ := route.GetPathTemplate() + promhttp.InstrumentHandlerDuration( + c.duration.MustCurryWith(prometheus.Labels{pathLabel: path}), + promhttp.InstrumentHandlerCounter(c.requests.MustCurryWith(prometheus.Labels{pathLabel: path}), next), + ).ServeHTTP(w, r) + }) + } +} diff --git a/pkg/metrics/collector_test.go b/pkg/metrics/collector_test.go new file mode 100644 index 0000000..ab90fb2 --- /dev/null +++ b/pkg/metrics/collector_test.go @@ -0,0 +1,104 @@ +package metrics + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/histogram/mocks" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics/latency" +) + +func TestNewCollector(t *testing.T) { + // given + latency := new(mocks.BucketsProvider) + latency.On("Buckets").Return(nil) + + // when + collector := NewCollector(latency) + + // then + assert.NotNil(t, collector) + assert.NotNil(t, collector.backendLatency) + assert.NotNil(t, collector.backendLatency.MetricVec) + assert.NotNil(t, collector.eventType) + assert.NotNil(t, collector.eventType.MetricVec) + latency.AssertExpectations(t) +} + +func TestCollector_MetricsMiddleware(t *testing.T) { + router := mux.NewRouter() + c := NewCollector(latency.BucketsProvider{}) + router.Use(c.MetricsMiddleware()) + router.HandleFunc("/test", func(writer http.ResponseWriter, request *http.Request) { + time.Sleep(6 * time.Millisecond) + writer.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(router) + defer srv.Close() + http.Get(srv.URL + "/test") //nolint: errcheck // this call never fails as it is a testserver + //nolint: lll // prometheus tef follows + tef := ` + # HELP eventing_epp_requests_duration_seconds The duration of processing an incoming request (includes sending to the backend) + # TYPE eventing_epp_requests_duration_seconds histogram + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.001"} 0 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.002"} 0 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.004"} 0 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.008"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.016"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.032"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.05"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.075"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.1"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.15"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.2"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.25"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.3"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.35"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.4"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.45"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.5"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.6"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.7"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.8"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="0.9"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="1"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="1.5"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="2"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="3"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="5"} 1 + eventing_epp_requests_duration_seconds_bucket{code="200",method="get",path="/test",le="+Inf"} 1 + eventing_epp_requests_duration_seconds_sum{code="200",method="get",path="/test"} 0.006837792 + eventing_epp_requests_duration_seconds_count{code="200",method="get",path="/test"} 1 + # HELP eventing_epp_requests_total The total number of requests + # TYPE eventing_epp_requests_total counter + eventing_epp_requests_total{code="200",method="get",path="/test"} 1 +` + if err := ignoreErr(testutil.CollectAndCompare(c, strings.NewReader(tef)), + "eventing_epp_requests_duration_seconds_sum"); err != nil { + t.Fatalf("%v", err) + } +} + +// Hack to filter out validation of the sum calculated by the metric. +func ignoreErr(err error, metric string) error { + for _, line := range strings.Split(err.Error(), "\n") { + if line == "--- metric output does not match expectation; want" || line == "+++ got:" { + continue + } + if strings.HasPrefix(strings.TrimSpace(line), "+") || + strings.HasPrefix(strings.TrimSpace(line), "-") { + if !(strings.HasPrefix(strings.TrimSpace(line), "+"+metric) || + strings.HasPrefix(strings.TrimSpace(line), "-"+metric)) { + return err + } + } + } + return nil +} diff --git a/pkg/metrics/histogram/buckets.go b/pkg/metrics/histogram/buckets.go new file mode 100644 index 0000000..89d729e --- /dev/null +++ b/pkg/metrics/histogram/buckets.go @@ -0,0 +1,6 @@ +package histogram + +//go:generate mockery --name BucketsProvider +type BucketsProvider interface { + Buckets() []float64 +} diff --git a/pkg/metrics/histogram/mocks/BucketsProvider.go b/pkg/metrics/histogram/mocks/BucketsProvider.go new file mode 100644 index 0000000..7efa77e --- /dev/null +++ b/pkg/metrics/histogram/mocks/BucketsProvider.go @@ -0,0 +1,26 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// BucketsProvider is an autogenerated mock type for the BucketsProvider type +type BucketsProvider struct { + mock.Mock +} + +// Buckets provides a mock function with given fields: +func (_m *BucketsProvider) Buckets() []float64 { + ret := _m.Called() + + var r0 []float64 + if rf, ok := ret.Get(0).(func() []float64); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]float64) + } + } + + return r0 +} diff --git a/pkg/metrics/latency/latency.go b/pkg/metrics/latency/latency.go new file mode 100644 index 0000000..94c58a3 --- /dev/null +++ b/pkg/metrics/latency/latency.go @@ -0,0 +1,26 @@ +package latency + +import "github.com/prometheus/client_golang/prometheus" + +const ( + // Note: The following configuration generates the histogram buckets [2 4 8 16 32 64 128 256 512 1024]. + + // start value of the prometheus.ExponentialBuckets start parameter. + start float64 = 2.0 + // factor value of the prometheus.ExponentialBuckets factor parameter. + factor float64 = 2.0 + // count value of the prometheus.ExponentialBuckets count parameter. + count int = 10 +) + +type BucketsProvider struct { + buckets []float64 +} + +func NewBucketsProvider() *BucketsProvider { + return &BucketsProvider{buckets: prometheus.ExponentialBuckets(start, factor, count)} +} + +func (p BucketsProvider) Buckets() []float64 { + return p.buckets +} diff --git a/pkg/metrics/latency/latency_test.go b/pkg/metrics/latency/latency_test.go new file mode 100644 index 0000000..8d1114c --- /dev/null +++ b/pkg/metrics/latency/latency_test.go @@ -0,0 +1,66 @@ +package latency + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewBucketsProvider(t *testing.T) { + tests := []struct { + name string + want *BucketsProvider + }{ + { + name: "default latency buckets", + want: &BucketsProvider{ + buckets: []float64{2, 4, 8, 16, 32, 64, 128, 256, 512, 1024}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + got := NewBucketsProvider() + + // then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBucketsProvider_Buckets(t *testing.T) { + tests := []struct { + name string + buckets []float64 + want []float64 + }{ + { + name: "nil buckets", + buckets: nil, + want: nil, + }, + { + name: "empty buckets", + buckets: []float64{}, + want: []float64{}, + }, + { + name: "non-empty buckets", + buckets: []float64{1, 2, 3}, + want: []float64{1, 2, 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + p := BucketsProvider{buckets: tt.buckets} + + // when + got := p.Buckets() + + // then + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/metrics/metricstest/metricstest.go b/pkg/metrics/metricstest/metricstest.go new file mode 100644 index 0000000..8d29fe9 --- /dev/null +++ b/pkg/metrics/metricstest/metricstest.go @@ -0,0 +1,108 @@ +// Package metricstest provides utilities for metrics testing. +package metricstest + +import ( + "strconv" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/metrics" +) + +// EnsureMetricLatency ensures metric eventing_epp_backend_duration_seconds exists. +func EnsureMetricLatency(t *testing.T, collector metrics.PublishingMetricsCollector, count int) { + ensureMetricCount(t, collector, metrics.BackendLatencyKey, count) +} + +// EnsureMetricEventTypePublished ensures metric eventing_epp_event_type_published_total exists. +func EnsureMetricEventTypePublished(t *testing.T, collector metrics.PublishingMetricsCollector, count int) { + ensureMetricCount(t, collector, metrics.EventTypePublishedMetricKey, count) +} + +func ensureMetricCount(t *testing.T, collector metrics.PublishingMetricsCollector, metric string, expectedCount int) { + if count := testutil.CollectAndCount(collector, metric); count != expectedCount { + t.Fatalf("invalid count for metric:%s, want:%d, got:%d", metric, expectedCount, count) + } +} + +// EnsureMetricMatchesTextExpositionFormat ensures that metrics collected by the given collector +// match the given metric output in TextExpositionFormat. +// This is useful to compare metrics with their given labels. +func EnsureMetricMatchesTextExpositionFormat(t *testing.T, collector metrics.PublishingMetricsCollector, tef string, metricNames ...string) { + if err := testutil.CollectAndCompare(collector, strings.NewReader(tef), metricNames...); err != nil { + t.Fatalf("%v", err) + } +} + +type PublishingMetricsCollectorStub struct { +} + +func (p PublishingMetricsCollectorStub) Describe(_ chan<- *prometheus.Desc) { +} + +func (p PublishingMetricsCollectorStub) Collect(_ chan<- prometheus.Metric) { +} + +func (p PublishingMetricsCollectorStub) RecordError() { +} + +func (p PublishingMetricsCollectorStub) RecordLatency(_ time.Duration, _ int, _ string) { +} + +func (p PublishingMetricsCollectorStub) RecordEventType(_, _ string, _ int) { +} + +func (p PublishingMetricsCollectorStub) RecordRequests(_ int, _ string) { +} + +//nolint:lll // that's how TEF has to look like +func MakeTEFBackendDuration(code int, service string) string { + tef := strings.ReplaceAll(`# HELP eventing_epp_backend_duration_milliseconds The duration of sending events to the messaging server in milliseconds + # TYPE eventing_epp_backend_duration_milliseconds histogram + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.005"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.01"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.025"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.05"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.1"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.25"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="0.5"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="1"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="2.5"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="5"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="10"} 1 + eventing_epp_backend_duration_milliseconds_bucket{code="%%code%%",destination_service="%%service%%",le="+Inf"} 1 + eventing_epp_backend_duration_milliseconds_sum{code="%%code%%",destination_service="%%service%%"} 0 + eventing_epp_backend_duration_milliseconds_count{code="%%code%%",destination_service="%%service%%"} 1 + `, "%%code%%", strconv.Itoa(code)) + return strings.ReplaceAll(tef, "%%service%%", service) +} + +func MakeTEFBackendRequests(code int, service string) string { + tef := strings.ReplaceAll(`# HELP eventing_epp_backend_requests_total The total number of backend requests + # TYPE eventing_epp_backend_requests_total counter + eventing_epp_backend_requests_total{code="%%code%%",destination_service="%%service%%"} 1 + `, "%%code%%", strconv.Itoa(code)) + return strings.ReplaceAll(tef, "%%service%%", service) +} + +//nolint:lll // that's how TEF has to look like +func MakeTEFBackendErrors() string { + return `# HELP eventing_epp_backend_errors_total The total number of backend errors while sending events to the messaging server + # TYPE eventing_epp_backend_errors_total counter + eventing_epp_backend_errors_total 1 + ` +} + +//nolint:lll // that's how TEF has to look like +func MakeTEFEventTypePublished(code int, source, eventtype string) string { + tef := strings.ReplaceAll(`# HELP eventing_epp_event_type_published_total The total number of events published for a given eventTypeLabel + # TYPE eventing_epp_event_type_published_total counter + eventing_epp_event_type_published_total{code="204",event_source="%%source%%",event_type="%%type%%"} 1 + `, "%%code%%", strconv.Itoa(code)) + tef = strings.ReplaceAll(tef, "%%source%%", source) + return strings.ReplaceAll(tef, "%%type%%", eventtype) +} diff --git a/pkg/metrics/server.go b/pkg/metrics/server.go new file mode 100644 index 0000000..4f265af --- /dev/null +++ b/pkg/metrics/server.go @@ -0,0 +1,62 @@ +package metrics + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.uber.org/zap" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" +) + +const ( + metricsServerLoggerName = "metrics-server" + readHeaderTimeout = 5 * time.Second +) + +type Server struct { + srv http.Server + logger *logger.Logger +} + +func NewServer(logger *logger.Logger) *Server { + return &Server{logger: logger} +} + +func (s *Server) Start(address string) error { + if len(strings.TrimSpace(address)) > 0 { + s.srv = http.Server{ + Handler: promhttp.Handler(), + ReadHeaderTimeout: readHeaderTimeout, + } + + listener, err := net.Listen("tcp", address) + if err != nil { + return err + } + + s.namedLogger().Infof("Metrics server started on %v", address) + go func() { + err := s.srv.Serve(listener) + if !errors.Is(err, http.ErrServerClosed) { + s.logger.WithContext().Fatal(err) + } + }() + } + + return nil +} + +func (s *Server) Stop() { + if err := s.srv.Shutdown(context.Background()); err != nil { + s.namedLogger().Warnw("Failed to shutdown metrics server", "error", err) + } +} +func (s *Server) namedLogger() *zap.SugaredLogger { + return s.logger.WithContext().Named(metricsServerLoggerName) +} diff --git a/pkg/nats/connect.go b/pkg/nats/connect.go new file mode 100644 index 0000000..a2dc3fe --- /dev/null +++ b/pkg/nats/connect.go @@ -0,0 +1,33 @@ +package nats + +import ( + "fmt" + + "github.com/nats-io/nats.go" +) + +type Opt = nats.Option + +var ( + WithRetryOnFailedConnect = nats.RetryOnFailedConnect + WithMaxReconnects = nats.MaxReconnects + WithReconnectWait = nats.ReconnectWait + WithName = nats.Name +) + +// Connect returns a NATS connection that is ready for use, or an error if connection to the NATS server failed. +// It uses the nats.Connect function which is thread-safe. +func Connect(url string, opts ...Opt) (*nats.Conn, error) { + connection, err := nats.Connect(url, + opts..., + ) + if err != nil { + return nil, err + } + + if status := connection.Status(); status != nats.CONNECTED { + return nil, fmt.Errorf("NATS connection not connected with status:%v", status) + } + + return connection, err +} diff --git a/pkg/nats/connect_test.go b/pkg/nats/connect_test.go new file mode 100644 index 0000000..95069e0 --- /dev/null +++ b/pkg/nats/connect_test.go @@ -0,0 +1,64 @@ +package nats_test + +import ( + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + + pkgnats "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/nats" + publishertesting "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +func TestConnect(t *testing.T) { + testCases := []struct { + name string + givenRetryOnFailedConnect bool + givenMaxReconnect int + givenReconnectWait time.Duration + }{ + { + name: "do not retry failed connections", + givenRetryOnFailedConnect: false, + givenMaxReconnect: 0, + givenReconnectWait: time.Millisecond, + }, + { + name: "keep retrying failed connections", + givenRetryOnFailedConnect: true, + givenMaxReconnect: -1, + givenReconnectWait: time.Millisecond, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // given + natsServer := publishertesting.StartNATSServer() + assert.NotNil(t, natsServer) + defer natsServer.Shutdown() + + clientURL := natsServer.ClientURL() + assert.NotEmpty(t, clientURL) + + // when + connection, err := pkgnats.Connect(clientURL, + pkgnats.WithRetryOnFailedConnect(tc.givenRetryOnFailedConnect), + pkgnats.WithMaxReconnects(tc.givenMaxReconnect), + pkgnats.WithReconnectWait(tc.givenReconnectWait), + ) + assert.Nil(t, err) + assert.NotNil(t, connection) + defer func() { connection.Close() }() + + // then + assert.Equal(t, connection.Status(), nats.CONNECTED) + assert.Equal(t, clientURL, connection.Opts.Servers[0]) + assert.Equal(t, tc.givenRetryOnFailedConnect, connection.Opts.RetryOnFailedConnect) + assert.Equal(t, tc.givenMaxReconnect, connection.Opts.MaxReconnect) + assert.Equal(t, tc.givenReconnectWait, connection.Opts.ReconnectWait) + }) + } +} diff --git a/pkg/oauth/client.go b/pkg/oauth/client.go new file mode 100644 index 0000000..f2653ab --- /dev/null +++ b/pkg/oauth/client.go @@ -0,0 +1,33 @@ +package oauth + +import ( + "context" + "net/http" + + "go.opencensus.io/plugin/ochttp" + "golang.org/x/oauth2" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/tracing/propagation/tracecontextb3" +) + +// NewClient returns a new HTTP client which have nested transports for handling oauth2 security, +// HTTP connection pooling, and tracing. +func NewClient(ctx context.Context, cfg *env.EventMeshConfig) *http.Client { + // configure auth client + config := Config(cfg) + client := config.Client(ctx) + + // configure connection transport + var base = http.DefaultTransport.(*http.Transport).Clone() + cfg.ConfigureTransport(base) + client.Transport.(*oauth2.Transport).Base = base + + // configure tracing transport + client.Transport = &ochttp.Transport{ + Base: client.Transport, + Propagation: tracecontextb3.TraceContextEgress, + } + + return client +} diff --git a/pkg/oauth/client_test.go b/pkg/oauth/client_test.go new file mode 100644 index 0000000..0cc2b32 --- /dev/null +++ b/pkg/oauth/client_test.go @@ -0,0 +1,121 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "go.opencensus.io/plugin/ochttp" + "golang.org/x/oauth2" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +func TestNewClient(t *testing.T) { + t.Parallel() + + const ( + maxIdleConns = 100 + maxIdleConnsPerHost = 200 + ) + + cfg := &env.EventMeshConfig{MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdleConnsPerHost} + client := NewClient(context.Background(), cfg) + defer client.CloseIdleConnections() + + ocTransport, ok := client.Transport.(*ochttp.Transport) + if !ok { + t.Errorf("Failed to convert to OpenCensus transport") + } + + secTransport, ok := ocTransport.Base.(*oauth2.Transport) + if !ok { + t.Errorf("Failed to convert to oauth2 transport") + } + + httpTransport, ok := secTransport.Base.(*http.Transport) + if !ok { + t.Errorf("Failed to convert to HTTP transport") + } + + if httpTransport.MaxIdleConns != maxIdleConns { + t.Errorf("HTTP Client Transport MaxIdleConns is misconfigured want: %d but got: %d", + maxIdleConns, httpTransport.MaxIdleConns) + } + if httpTransport.MaxIdleConnsPerHost != maxIdleConnsPerHost { + t.Errorf("HTTP Client Transport MaxIdleConnsPerHost is misconfigured want: %d but got: %d", + maxIdleConnsPerHost, httpTransport.MaxIdleConnsPerHost) + } +} + +func TestGetToken(t *testing.T) { + t.Parallel() + + const ( + tokenEndpoint = "/token" + eventsEndpoint = "/events" + eventsHTTP400Endpoint = "/events400" + ) + + testCases := []struct { + name string + delay time.Duration + requestsCount int + givenExpiresInSec int + wantGeneratedTokensCount int + }{ + { + name: "Token expires every 60 seconds", + delay: time.Millisecond, + requestsCount: 50, + givenExpiresInSec: 60, + wantGeneratedTokensCount: 1, + }, + { + name: "Token expires every second", + delay: time.Second + time.Millisecond, + requestsCount: 5, + givenExpiresInSec: 1, + wantGeneratedTokensCount: 5, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + mockServer := testingutils.NewMockServer(testingutils.WithExpiresIn(test.givenExpiresInSec)) + mockServer.Start(t, tokenEndpoint, eventsEndpoint, eventsHTTP400Endpoint) + defer mockServer.Close() + + emsCEURL := fmt.Sprintf("%s%s", mockServer.URL(), eventsEndpoint) + authURL := fmt.Sprintf("%s%s", mockServer.URL(), tokenEndpoint) + cfg := testingutils.NewEnvConfig(emsCEURL, authURL) + client := NewClient(context.Background(), cfg) + defer client.CloseIdleConnections() + + for i := 0; i < test.requestsCount; i++ { + req, err := http.NewRequest(http.MethodPost, emsCEURL, nil) + if err != nil { + t.Errorf("Failed to create HTTP request with error: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Failed to post HTTP request with error: %v", err) + } + _ = resp.Body.Close() + + time.Sleep(test.delay) + } + + if got := mockServer.GeneratedTokensCount(); got != test.wantGeneratedTokensCount { + t.Fatalf("Tokens count does not match, want: %d but got: %d", test.wantGeneratedTokensCount, got) + } + }) + } +} diff --git a/pkg/oauth/config.go b/pkg/oauth/config.go new file mode 100644 index 0000000..bf55a70 --- /dev/null +++ b/pkg/oauth/config.go @@ -0,0 +1,16 @@ +package oauth + +import ( + "golang.org/x/oauth2/clientcredentials" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" +) + +// Config returns a new oauth2 client credentials config instance. +func Config(cfg *env.EventMeshConfig) clientcredentials.Config { + return clientcredentials.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + TokenURL: cfg.TokenEndpoint, + } +} diff --git a/pkg/oauth/config_test.go b/pkg/oauth/config_test.go new file mode 100644 index 0000000..c698c1e --- /dev/null +++ b/pkg/oauth/config_test.go @@ -0,0 +1,24 @@ +package oauth + +import ( + "testing" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" +) + +func TestConfig(t *testing.T) { + t.Parallel() + + cfg := &env.EventMeshConfig{ClientID: "someID", ClientSecret: "someSecret", TokenEndpoint: "someEndpoint"} + conf := Config(cfg) + + if cfg.ClientID != conf.ClientID { + t.Errorf("Client IDs do not match want:%s but got:%s", cfg.ClientID, conf.ClientID) + } + if cfg.ClientSecret != conf.ClientSecret { + t.Errorf("Client secrets do not match want:%s but got:%s", cfg.ClientSecret, conf.ClientSecret) + } + if cfg.TokenEndpoint != conf.TokenURL { + t.Errorf("Token URLs do not match want:%s but got:%s", cfg.TokenEndpoint, conf.TokenURL) + } +} diff --git a/pkg/options/options.go b/pkg/options/options.go new file mode 100644 index 0000000..180d1dd --- /dev/null +++ b/pkg/options/options.go @@ -0,0 +1,39 @@ +package options + +import ( + "flag" + "fmt" +) + +const ( + maxRequestSize = 65536 + metricEndpointPort = ":9090" + + // All the available arguments. + argMaxRequestSize = "max-request-size" + argMetricsAddress = "metrics-addr" +) + +type Options struct { + MaxRequestSize int64 + MetricsAddress string +} + +func New() *Options { + return &Options{} +} + +func (o *Options) Parse() error { + flag.Int64Var(&o.MaxRequestSize, argMaxRequestSize, maxRequestSize, "The maximum request size in bytes.") + flag.StringVar(&o.MetricsAddress, argMetricsAddress, metricEndpointPort, "The address the metric endpoint binds to.") + flag.Parse() + + return nil +} + +func (o Options) String() string { + return fmt.Sprintf("--%s=%v --%s=%v", + argMaxRequestSize, o.MaxRequestSize, + argMetricsAddress, o.MetricsAddress, + ) +} diff --git a/pkg/receiver/receiver.go b/pkg/receiver/receiver.go new file mode 100644 index 0000000..6baf097 --- /dev/null +++ b/pkg/receiver/receiver.go @@ -0,0 +1,82 @@ +package receiver + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "go.opencensus.io/plugin/ochttp" + + kymalogger "github.com/kyma-project/kyma/components/eventing-controller/logger" +) + +const ( + // defaultShutdownTimeout is the default timeout for the receiver to shut down. + defaultShutdownTimeout = 1 * time.Minute + readHeaderTimeout = 5 * time.Second + + receiverName = "receiver" +) + +// HTTPMessageReceiver is responsible for receiving messages over HTTP. +type HTTPMessageReceiver struct { + Host string + Port int + handler http.Handler + server *http.Server + listener net.Listener +} + +// NewHTTPMessageReceiver returns a new NewHTTPMessageReceiver instance with the given Port. +func NewHTTPMessageReceiver(port int) *HTTPMessageReceiver { + return &HTTPMessageReceiver{Port: port} +} + +// StartListen starts the HTTP message receiver and blocks until it receives a shutdown signal. +func (r *HTTPMessageReceiver) StartListen(ctx context.Context, handler http.Handler, logger *kymalogger.Logger) error { + var err error + if r.listener, err = net.Listen("tcp", fmt.Sprintf("%v:%d", r.Host, r.Port)); err != nil { + return err + } + + r.handler = createHandler(handler) + r.server = &http.Server{ + Addr: r.listener.Addr().String(), + Handler: r.handler, + ReadHeaderTimeout: readHeaderTimeout, + } + + errChan := make(chan error, 1) + go func() { + errChan <- r.server.Serve(r.listener) + }() + + // init the contexted logger + namedLogger := logger.WithContext().Named(receiverName) + + namedLogger.Info("Event Publisher Receiver has started.") + + // wait for the server to return or ctx.Done(). + select { + case <-ctx.Done(): + logger.WithContext().Info("shutdown") + ctx, cancel := context.WithTimeout(context.Background(), defaultShutdownTimeout) + defer cancel() + err := r.server.Shutdown(ctx) + <-errChan // Wait for server goroutine to exit + return err + case err := <-errChan: + return err + } +} + +// createHandler returns a new opentracing HTTP handler wrapper for the given HTTP handler. +func createHandler(handler http.Handler) http.Handler { + return &ochttp.Handler{Handler: handler} +} + +func (r *HTTPMessageReceiver) BaseURL() string { + return fmt.Sprintf("http://%s", r.listener.Addr()) +} diff --git a/pkg/receiver/receiver_test.go b/pkg/receiver/receiver_test.go new file mode 100644 index 0000000..d67f411 --- /dev/null +++ b/pkg/receiver/receiver_test.go @@ -0,0 +1,80 @@ +package receiver + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +// a mocked http.Handler. +type testHandler struct{} + +func (h *testHandler) ServeHTTP(http.ResponseWriter, *http.Request) {} + +var _ http.Handler = (*testHandler)(nil) + +func TestNewHttpMessageReceiver(t *testing.T) { + port := testingutils.GeneratePortOrDie() + r := NewHTTPMessageReceiver(port) + if r.Port != port { + t.Errorf("Port should be: %d is: %d", port, r.Port) + } +} + +// Test that the receiver shutdown when receiving stop signal. +func TestStartListener(t *testing.T) { + timeout := time.Second * 10 + r := fixtureReceiver() + mockedLogger, _ := logger.New("json", "info") + ctx := context.Background() + + // used to simulate sending a stop signal + ctx, cancelFunc := context.WithCancel(ctx) + + // start receiver + wg := sync.WaitGroup{} + start := make(chan bool, 1) + defer close(start) + wg.Add(1) + go func(t *testing.T) { + defer wg.Done() + start <- true + t.Log("starting receiver in goroutine") + if err := r.StartListen(ctx, &testHandler{}, mockedLogger); err != nil { + t.Errorf("error while starting HTTPMessageReceiver: %v", err) + } + t.Log("receiver goroutine ends here") + }(t) + + // wait for goroutine to start + <-start + + // stop it + cancelFunc() + c := make(chan struct{}) + go func() { + defer close(c) + wg.Wait() + }() + + t.Log("Waiting for receiver to stop") + select { + // receiver shutdown properly + case <-c: + t.Log("Waiting for receiver to stop [done]") + break + // receiver shutdown in time + case <-time.Tick(timeout): + t.Fatalf("Expected receiver to shutdown after timeout: %v\n", timeout) + } +} + +func fixtureReceiver() *HTTPMessageReceiver { + return &HTTPMessageReceiver{Port: 0, Host: "localhost"} +} diff --git a/pkg/sender/common/backendpublisherror.go b/pkg/sender/common/backendpublisherror.go new file mode 100644 index 0000000..b7d6520 --- /dev/null +++ b/pkg/sender/common/backendpublisherror.go @@ -0,0 +1,55 @@ +package common + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" +) + +var _ sender.PublishError = &BackendPublishError{} + +//nolint:lll //reads better this way +var ( + ErrInsufficientStorage = BackendPublishError{HTTPCode: http.StatusInsufficientStorage, Info: "insufficient storage on backend"} + ErrBackendTargetNotFound = BackendPublishError{HTTPCode: http.StatusNotFound, Info: "publishing target on backend not found"} + ErrClientNoConnection = BackendPublishError{HTTPCode: http.StatusBadGateway, Info: "no connection to backend"} + ErrInternalBackendError = BackendPublishError{HTTPCode: http.StatusInternalServerError, Info: "internal error on backend"} + ErrClientConversionFailed = BackendPublishError{HTTPCode: http.StatusBadRequest, Info: "conversion to target format failed"} +) + +type BackendPublishError struct { + HTTPCode int + Info string + err error +} + +func (e BackendPublishError) Error() string { + return e.Info +} + +func (e *BackendPublishError) Unwrap() error { + return e.err +} + +func (e *BackendPublishError) Wrap(wrappedError error) { + e.err = wrappedError +} + +func (e BackendPublishError) Code() int { + if e.HTTPCode == 0 { + return http.StatusInternalServerError + } + return e.HTTPCode +} + +func (e BackendPublishError) Message() string { + return e.Info +} + +func (e *BackendPublishError) Is(target error) bool { + t, ok := target.(*BackendPublishError) //nolint:errorlint //we dont want to check the error chain here + if !ok { + return false + } + return (e.HTTPCode == t.HTTPCode) && (e.Info == t.Info) +} diff --git a/pkg/sender/eventmesh/eventmesh.go b/pkg/sender/eventmesh/eventmesh.go new file mode 100644 index 0000000..97e20cd --- /dev/null +++ b/pkg/sender/eventmesh/eventmesh.go @@ -0,0 +1,104 @@ +package eventmesh + +import ( + "context" + "io" + "net/http" + + "github.com/cloudevents/sdk-go/v2/binding" + cev2event "github.com/cloudevents/sdk-go/v2/event" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "go.uber.org/zap" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/cloudevents" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/eventmesh" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler/health" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/common" +) + +var _ sender.GenericSender = &Sender{} + +var ( + // additionalHeaders are the required headers by EMS for publish requests. + // Any alteration or removal of those headers might cause publish requests to fail. + additionalHeaders = http.Header{ + "qos": []string{string(eventmesh.QosAtLeastOnce)}, + "Accept": []string{internal.ContentTypeApplicationJSON}, + } +) + +const ( + backend = "eventmesh" + handlerName = "eventmesh-handler" +) + +// Sender is responsible for sending messages over HTTP. +type Sender struct { + Client *http.Client + Target string + logger *logger.Logger +} + +func (s *Sender) URL() string { + return s.Target +} + +func (s *Sender) Checker() *health.ConfigurableChecker { + return &health.ConfigurableChecker{} +} + +func (s *Sender) Send(ctx context.Context, event *cev2event.Event) sender.PublishError { + request, err := s.NewRequestWithTarget(ctx, s.Target) + if err != nil { + e := common.ErrInternalBackendError + e.Wrap(err) + return e + } + + message := binding.ToMessage(event) + defer func() { _ = message.Finish(nil) }() + + err = cloudevents.WriteRequestWithHeaders(ctx, message, request, additionalHeaders) + if err != nil { + s.namedLogger().Error("error", err) + e := common.ErrInternalBackendError + e.Wrap(err) + return e + } + + resp, err := s.Client.Do(request) + if err != nil { + s.namedLogger().Error("error", err) + e := common.ErrInternalBackendError + e.Wrap(err) + return e + } + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return nil + } + body, err := io.ReadAll(resp.Body) + defer func() { _ = resp.Body.Close() }() + if err != nil { + s.namedLogger().Error("error", err) + return common.ErrInternalBackendError + } + s.namedLogger().Error("error", string(body), "code", resp.StatusCode) + return common.BackendPublishError{HTTPCode: resp.StatusCode, Info: string(body)} +} + +// NewSender returns a new Sender instance with the given target and client. +func NewSender(target string, c *http.Client, l *logger.Logger) *Sender { + return &Sender{Client: c, Target: target, logger: l} +} + +// NewRequestWithTarget returns a new HTTP POST request with the given context and target. +func (s *Sender) NewRequestWithTarget(ctx context.Context, target string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, http.MethodPost, target, nil) +} + +func (s *Sender) namedLogger() *zap.SugaredLogger { + return s.logger.WithContext().Named(handlerName).With("backend", backend) +} diff --git a/pkg/sender/eventmesh/eventmesh_test.go b/pkg/sender/eventmesh/eventmesh_test.go new file mode 100644 index 0000000..65a283d --- /dev/null +++ b/pkg/sender/eventmesh/eventmesh_test.go @@ -0,0 +1,210 @@ +package eventmesh + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/oauth" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/common" + testing2 "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +const ( + // mock server endpoints. + eventsEndpoint = "/events" + eventsHTTP400Endpoint = "/events400" + + // connection settings. + maxIdleConns = 100 + maxIdleConnsPerHost = 200 +) + +func TestNewHttpMessageSender(t *testing.T) { + t.Parallel() + + client := oauth.NewClient(context.Background(), &env.EventMeshConfig{}) + defer client.CloseIdleConnections() + mockedLogger, err := logger.New("json", "info") + require.NoError(t, err) + msgSender := NewSender(eventsEndpoint, client, mockedLogger) + if msgSender.Target != eventsEndpoint { + t.Errorf("Message sender target is misconfigured want: %s but got: %s", eventsEndpoint, msgSender.Target) + } + if msgSender.Client != client { + t.Errorf("Message sender client is misconfigured want: %#v but got: %#v", client, msgSender.Client) + } +} + +func TestNewRequestWithTarget(t *testing.T) { + t.Parallel() + + cfg := &env.EventMeshConfig{MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdleConnsPerHost} + client := oauth.NewClient(context.Background(), cfg) + defer client.CloseIdleConnections() + + mockedLogger, err := logger.New("json", "info") + require.NoError(t, err) + msgSender := NewSender(eventsEndpoint, client, mockedLogger) + + type ctxKey struct{} + const ctxValue = "testValue" + ctx := context.WithValue(context.Background(), ctxKey{}, ctxValue) + req, err := msgSender.NewRequestWithTarget(ctx, eventsEndpoint) + if err != nil { + t.Errorf("Failed to create a CloudEvent HTTP request with error: %v", err) + } + if req == nil { + t.Error("Failed to create a CloudEvent HTTP request want new request but got nil") + return + } + if req.Method != http.MethodPost { + t.Errorf("HTTP request has invalid method want: %s but got: %s", http.MethodPost, req.Method) + } + if req.URL.Path != eventsEndpoint { + t.Errorf("HTTP request has invalid target want: %s but got: %s", eventsEndpoint, req.URL.Path) + } + if len(req.Header) > 0 { + t.Error("HTTP request should be created with empty headers") + } + if req.Close != false { + t.Errorf("HTTP request close is invalid want: %v but got: %v", false, req.Close) + } + if req.Body != nil { + t.Error("HTTP request should be created with empty body") + } + if req.Context() != ctx { + t.Errorf("HTTP request context does not match original context want: %#v, but got %#v", ctx, req.Context()) + } + if got := req.Context().Value(ctxKey{}); got != ctxValue { + t.Errorf("HTTP request context key:value do not match mant: %v:%v but got %v:%v", ctxKey{}, ctxValue, ctxKey{}, got) + } +} + +func TestSender_Send_Error(t *testing.T) { + type fields struct { + Target string + } + type args struct { + // timeout is one easy way to trigger an error on sending + timeout time.Duration + builder *testing2.CloudEventBuilder + } + var tests = []struct { + name string + fields fields + args args + want sender.PublishError + wantErr bool + }{ + { + name: "valid event", + fields: fields{ + Target: "https://127.1.1.1:12345/idontexist", + }, + args: args{ + timeout: 1 * time.Millisecond, + builder: testing2.NewCloudEventBuilder(), + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hOk := &HandlerStub{ResponseStatus: 204} + hFail := &HandlerStub{ResponseStatus: 400} + mux := http.NewServeMux() + mux.HandleFunc(eventsEndpoint, hOk.ServeHTTP) + mux.HandleFunc(eventsHTTP400Endpoint, hFail.ServeHTTP) + server := httptest.NewServer(mux) + mockedLogger, err := logger.New("json", "info") + require.NoError(t, err) + s := NewSender(tt.fields.Target, server.Client(), mockedLogger) + ctx, cancel := context.WithTimeout(context.Background(), tt.args.timeout) + defer cancel() + err = s.Send(ctx, tt.args.builder.Build(t)) + if (err != nil) != tt.wantErr { + t.Errorf("Send() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} +func TestSender_Send(t *testing.T) { + type fields struct { + Target string + } + type args struct { + ctx context.Context + builder *testing2.CloudEventBuilder + } + var tests = []struct { + name string + fields fields + args args + want sender.PublishError + wantErr error + }{ + { + name: "valid event, backend 400", + fields: fields{ + Target: eventsHTTP400Endpoint, + }, + args: args{ + ctx: context.Background(), + builder: testing2.NewCloudEventBuilder(), + }, + wantErr: common.BackendPublishError{ + HTTPCode: 400, + }, + }, + { + name: "valid event", + fields: fields{ + Target: eventsEndpoint, + }, + args: args{ + ctx: context.Background(), + builder: testing2.NewCloudEventBuilder(), + }, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hOk := &HandlerStub{ResponseStatus: 204} + hFail := &HandlerStub{ResponseStatus: 400} + mux := http.NewServeMux() + mux.HandleFunc(eventsEndpoint, hOk.ServeHTTP) + mux.HandleFunc(eventsHTTP400Endpoint, hFail.ServeHTTP) + server := httptest.NewServer(mux) + target, err := url.JoinPath(server.URL, tt.fields.Target) + assert.NoError(t, err) + mockedLogger, err := logger.New("json", "info") + require.NoError(t, err) + s := NewSender(target, server.Client(), mockedLogger) + err = s.Send(tt.args.ctx, tt.args.builder.Build(t)) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +type HandlerStub struct { + Request http.Request + ResponseStatus int +} + +func (h *HandlerStub) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.Request = *request + writer.WriteHeader(h.ResponseStatus) +} diff --git a/pkg/sender/helper.go b/pkg/sender/helper.go new file mode 100644 index 0000000..41399fc --- /dev/null +++ b/pkg/sender/helper.go @@ -0,0 +1,28 @@ +package sender + +import ( + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +type PublishingMetricsCollectorStub struct { +} + +func (p PublishingMetricsCollectorStub) Describe(chan<- *prometheus.Desc) { +} + +func (p PublishingMetricsCollectorStub) Collect(chan<- prometheus.Metric) { +} + +func (p PublishingMetricsCollectorStub) RecordError() { +} + +func (p PublishingMetricsCollectorStub) RecordLatency(time.Duration, int, string) { +} + +func (p PublishingMetricsCollectorStub) RecordEventType(string, string, int) { +} + +func (p PublishingMetricsCollectorStub) RecordRequests(int, string) { +} diff --git a/pkg/sender/jetstream/health.go b/pkg/sender/jetstream/health.go new file mode 100644 index 0000000..9f337b5 --- /dev/null +++ b/pkg/sender/jetstream/health.go @@ -0,0 +1,18 @@ +package jetstream + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler/health" +) + +// ReadinessCheck returns an instance of http.HandlerFunc that checks the readiness of the given NATS Handler. +// It checks the NATS server connection status and reports 2XX if connected, otherwise reports 5XX. +// It panics if the given NATS Handler is nil. +func (s *Sender) ReadinessCheck(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(health.StatusCodeHealthy) +} + +func (s *Sender) LivenessCheck(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(health.StatusCodeHealthy) +} diff --git a/pkg/sender/jetstream/jetstream.go b/pkg/sender/jetstream/jetstream.go new file mode 100644 index 0000000..86cdbc7 --- /dev/null +++ b/pkg/sender/jetstream/jetstream.go @@ -0,0 +1,155 @@ +package jetstream + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/nats-io/nats.go" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "go.uber.org/zap" + + "github.com/cloudevents/sdk-go/v2/event" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/handler/health" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/sender/common" +) + +const ( + JSStoreFailedCode = 10077 + natsBackend = "nats" + handlerName = "jetstream-handler" + noSpaceLeftErrMessage = "no space left on device" +) + +// compile time check. +var _ sender.GenericSender = &Sender{} +var _ health.Checker = &Sender{} + +//nolint:lll // reads better this way +var ( + ErrNotConnected = common.BackendPublishError{HTTPCode: http.StatusBadGateway, Info: "no connection to NATS JetStream server"} + ErrCannotSendToStream = common.BackendPublishError{HTTPCode: http.StatusGatewayTimeout, Info: "cannot send to stream"} + ErrNoSpaceLeftOnDevice = common.BackendPublishError{HTTPCode: http.StatusInsufficientStorage, Info: "insufficient resources on target stream"} +) + +// Sender is responsible for sending messages over HTTP. +type Sender struct { + ctx context.Context + logger *logger.Logger + connection *nats.Conn + envCfg *env.NATSConfig + opts *options.Options +} + +func (s *Sender) URL() string { + return s.envCfg.URL +} + +// NewSender returns a new NewSender instance with the given NATS connection. +func NewSender(ctx context.Context, connection *nats.Conn, envCfg *env.NATSConfig, opts *options.Options, logger *logger.Logger) *Sender { + return &Sender{ctx: ctx, connection: connection, envCfg: envCfg, opts: opts, logger: logger} +} + +// ConnectionStatus returns nats.code for the NATS connection used by the Sender. +func (s *Sender) ConnectionStatus() nats.Status { + return s.connection.Status() +} + +// Send dispatches the event to the NATS backend in JetStream mode. +// If the NATS connection is not open, it returns an error. +func (s *Sender) Send(_ context.Context, event *event.Event) sender.PublishError { + if s.ConnectionStatus() != nats.CONNECTED { + return ErrNotConnected + } + + jsCtx, err := s.connection.JetStream() + if err != nil { + s.namedLogger().Error("error", err) + return common.ErrClientNoConnection + } + + msg, err := s.eventToNATSMsg(event) + if err != nil { + s.namedLogger().Error("error", err) + e := common.ErrClientConversionFailed + e.Wrap(err) + return e + } + + // send the event + _, err = jsCtx.PublishMsg(msg) + if err != nil { + s.namedLogger().Errorw("Cannot send event to backend", "error", err) + return natsErrorToPublishError(err) + } + return nil +} + +func natsErrorToPublishError(err error) sender.PublishError { + if errors.Is(err, nats.ErrNoStreamResponse) { + return ErrCannotSendToStream + } + + if strings.Contains(err.Error(), noSpaceLeftErrMessage) { + return ErrNoSpaceLeftOnDevice + } + + var apiErr nats.JetStreamError + e := common.BackendPublishError{HTTPCode: http.StatusInternalServerError} + if errors.As(err, &apiErr) { + if apiErr.APIError().ErrorCode == JSStoreFailedCode { + return ErrNoSpaceLeftOnDevice + } + e.HTTPCode = apiErr.APIError().Code + e.Info = apiErr.APIError().Description + e.Wrap(err) + return e + } + return common.ErrInternalBackendError +} + +// eventToNATSMsg translates cloud event into the NATS Msg. +func (s *Sender) eventToNATSMsg(event *event.Event) (*nats.Msg, error) { + header := make(nats.Header) + header.Set(internal.HeaderContentType, event.DataContentType()) + header.Set(internal.CeSpecVersionHeader, event.SpecVersion()) + header.Set(internal.CeTypeHeader, event.Type()) + header.Set(internal.CeSourceHeader, event.Source()) + header.Set(internal.CeIDHeader, event.ID()) + + eventJSON, err := json.Marshal(event) + if err != nil { + return nil, err + } + + return &nats.Msg{ + Subject: s.getJsSubjectToPublish(event.Type()), + Header: header, + Data: eventJSON, + }, err +} + +// getJsSubjectToPublish appends stream name to subject if needed. +func (s *Sender) getJsSubjectToPublish(subject string) string { + // do not append prefix, if event type prefix is not present. + if !strings.HasPrefix(subject, s.envCfg.EventTypePrefix) { + return subject + } + + // append prefix, for v1alpha1 subscriptions. + return fmt.Sprintf("%s.%s", env.JetStreamSubjectPrefix, subject) +} + +func (s *Sender) namedLogger() *zap.SugaredLogger { + return s.logger.WithContext().Named(handlerName).With("backend", natsBackend, "jetstream enabled", true) +} diff --git a/pkg/sender/jetstream/jetstream_test.go b/pkg/sender/jetstream/jetstream_test.go new file mode 100644 index 0000000..9d629f2 --- /dev/null +++ b/pkg/sender/jetstream/jetstream_test.go @@ -0,0 +1,287 @@ +package jetstream + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/options" + + "github.com/stretchr/testify/require" + + "github.com/cloudevents/sdk-go/v2/event" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" + testingutils "github.com/kyma-project/kyma/components/event-publisher-proxy/testing" +) + +func TestJetStreamMessageSender(t *testing.T) { + testCases := []struct { + name string + givenStream bool + givenStreamMaxBytes int64 + givenNATSConnectionClosed bool + wantErr error + wantStatusCode int + }{ + { + name: "send in jetstream mode should not succeed if stream doesn't exist", + givenStream: false, + givenNATSConnectionClosed: false, + wantErr: ErrCannotSendToStream, + }, + { + name: "send in jetstream mode should not succeed if stream is full", + givenStream: true, + givenStreamMaxBytes: 1, + givenNATSConnectionClosed: false, + wantErr: ErrNoSpaceLeftOnDevice, + }, + { + name: "send in jetstream mode should succeed if NATS connection is open and the stream exists", + givenStream: true, + givenStreamMaxBytes: 5000, + givenNATSConnectionClosed: false, + wantErr: nil, + }, + { + name: "send in jetstream mode should fail if NATS connection is not open", + givenNATSConnectionClosed: true, + wantErr: ErrNotConnected, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // arrange + testEnv := setupTestEnvironment(t) + natsServer, connection, mockedLogger := testEnv.Server, testEnv.Connection, testEnv.Logger + + defer func() { + natsServer.Shutdown() + connection.Close() + }() + + if tc.givenStream { + sc := getStreamConfig(tc.givenStreamMaxBytes) + cc := getConsumerConfig() + addStream(t, connection, sc) + addConsumer(t, connection, sc, cc) + } + + ce := createCloudEvent(t) + + ctx := context.Background() + sender := NewSender(context.Background(), connection, testEnv.Config, &options.Options{}, mockedLogger) + + if tc.givenNATSConnectionClosed { + connection.Close() + } + + // act + err := sender.Send(ctx, ce) + + testEnv.Logger.WithContext().Errorf("err: %v", err) + + // assert + assert.ErrorIs(t, err, tc.wantErr) + }) + } +} + +// helper functions and structs + +type TestEnvironment struct { + Connection *nats.Conn + Config *env.NATSConfig + Logger *logger.Logger + Sender *Sender + Server *server.Server + JsContext *nats.JetStreamContext +} + +// setupTestEnvironment sets up the resources and mocks required for testing. +func setupTestEnvironment(t *testing.T) *TestEnvironment { + natsServer := testingutils.StartNATSServer() + require.NotNil(t, natsServer) + + connection, err := testingutils.ConnectToNATSServer(natsServer.ClientURL()) + require.NotNil(t, connection) + require.NoError(t, err) + + natsConfig := CreateNATSJsConfig(natsServer.ClientURL()) + + mockedLogger, err := logger.New("json", "info") + require.NoError(t, err) + + jsCtx, err := connection.JetStream() + require.NoError(t, err) + + sender := &Sender{ + connection: connection, + envCfg: natsConfig, + logger: mockedLogger, + } + + return &TestEnvironment{ + Connection: connection, + Config: natsConfig, + Logger: mockedLogger, + Sender: sender, + Server: natsServer, + JsContext: &jsCtx, + } +} + +// createCloudEvent build a cloud event. +func createCloudEvent(t *testing.T) *event.Event { + jsType := fmt.Sprintf("%s.%s", testingutils.StreamName, testingutils.CloudEventTypeWithPrefix) + builder := testingutils.NewCloudEventBuilder( + testingutils.WithCloudEventType(jsType), + ) + payload, _ := builder.BuildStructured() + newEvent := cloudevents.NewEvent() + newEvent.SetType(jsType) + err := json.Unmarshal([]byte(payload), &newEvent) + assert.NoError(t, err) + + return &newEvent +} + +// getStreamConfig inits a testing stream config. +func getStreamConfig(maxBytes int64) *nats.StreamConfig { + return &nats.StreamConfig{ + Name: testingutils.StreamName, + Subjects: []string{fmt.Sprintf("%s.>", env.JetStreamSubjectPrefix)}, + Storage: nats.MemoryStorage, + Retention: nats.InterestPolicy, + Discard: nats.DiscardNew, + MaxBytes: maxBytes, + } +} + +func getConsumerConfig() *nats.ConsumerConfig { + return &nats.ConsumerConfig{ + Durable: "test", + DeliverPolicy: nats.DeliverAllPolicy, + AckPolicy: nats.AckExplicitPolicy, + FilterSubject: fmt.Sprintf("%v.%v", env.JetStreamSubjectPrefix, testingutils.CloudEventTypeWithPrefix), + } +} + +// addStream creates a stream for the test events. +func addStream(t *testing.T, connection *nats.Conn, config *nats.StreamConfig) { + js, err := connection.JetStream() + assert.NoError(t, err) + info, err := js.AddStream(config) + t.Logf("%+v", info) + assert.NoError(t, err) +} + +func addConsumer(t *testing.T, connection *nats.Conn, sc *nats.StreamConfig, config *nats.ConsumerConfig) { + js, err := connection.JetStream() + assert.NoError(t, err) + info, err := js.AddConsumer(sc.Name, config) + t.Logf("%+v", info) + assert.NoError(t, err) +} + +func CreateNATSJsConfig(url string) *env.NATSConfig { + return &env.NATSConfig{ + JSStreamName: testingutils.StreamName, + URL: url, + ReconnectWait: time.Second, + EventTypePrefix: testingutils.OldEventTypePrefix, + } +} + +func TestSender_URL(t *testing.T) { + type fields struct { + envCfg *env.NATSConfig + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "URL is correct", + want: "FOO", + fields: fields{ + envCfg: &env.NATSConfig{ + URL: "FOO", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Sender{ + envCfg: tt.fields.envCfg, + } + assert.Equalf(t, tt.want, s.URL(), "URL()") + }) + } +} + +func TestSender_getJsSubjectToPublish(t *testing.T) { + t.Parallel() + + type fields struct { + opts *options.Options + } + tests := []struct { + name string + fields fields + subject string + want string + }{ + { + name: "Appends JS prefix for v1alpha1 subscription", + subject: "sap.kyma.custom.noapp.order.created.v1", + want: "kyma.sap.kyma.custom.noapp.order.created.v1", + fields: fields{ + opts: &options.Options{}, + }, + }, + { + name: "Appends JS prefix for v1alpha2 exact type matching subscription", + subject: "sap.kyma.custom.noapp.order.created.v1", + want: "kyma.sap.kyma.custom.noapp.order.created.v1", + fields: fields{ + opts: &options.Options{}, + }, + }, + { + name: "Does not append JS prefix for v1alpha2 standard type matching subscription", + subject: "kyma.noapp.order.created.v1", + want: "kyma.noapp.order.created.v1", + fields: fields{ + opts: &options.Options{}, + }, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := &Sender{ + opts: tc.fields.opts, + envCfg: CreateNATSJsConfig(""), + } + assert.Equal(t, tc.want, s.getJsSubjectToPublish(tc.subject)) + }) + } +} diff --git a/pkg/sender/sender.go b/pkg/sender/sender.go new file mode 100644 index 0000000..b1e9c60 --- /dev/null +++ b/pkg/sender/sender.go @@ -0,0 +1,18 @@ +package sender + +import ( + "context" + + "github.com/cloudevents/sdk-go/v2/event" +) + +type GenericSender interface { + Send(context.Context, *event.Event) PublishError + URL() string +} + +type PublishError interface { + error + Code() int + Message() string +} diff --git a/pkg/signals/signals.go b/pkg/signals/signals.go new file mode 100644 index 0000000..6877765 --- /dev/null +++ b/pkg/signals/signals.go @@ -0,0 +1,82 @@ +package signals + +import ( + "context" + "errors" + "os" + "os/signal" + "syscall" + "time" +) + +var ( + // onlyOneSignalHandler to make sure that only one signal handler is registered. + onlyOneSignalHandler = make(chan struct{}) + + // shutdownSignals array of system signals to cause shutdown. + shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM} +) + +// SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned +// which is closed on one of these signals. If a second signal is caught, the program +// is terminated with exit code 1. +func SetupSignalHandler() <-chan struct{} { + close(onlyOneSignalHandler) // panics when called twice + + return setupStopChannel() +} + +func setupStopChannel() <-chan struct{} { + stop := make(chan struct{}) + //nolint:gomnd // sending a signal will trigger a graceful shutdown, sending a second signal will force stop + osSignal := make(chan os.Signal, 2) + signal.Notify(osSignal, shutdownSignals...) + go func() { + <-osSignal + close(stop) + <-osSignal + os.Exit(1) // second signal. Exit directly. + }() + + return stop +} + +// signalContext represents a signal context. +type signalContext struct { + stopCh <-chan struct{} +} + +// NewContext creates a new singleton context with SetupSignalHandler() +// as our Done() channel. This method can be called only once. +func NewContext() context.Context { + return &signalContext{stopCh: SetupSignalHandler()} +} + +// Deadline implements context.Context. +// +//nolint:nakedret,nonamedreturns //same implementation in the std library +func (scc *signalContext) Deadline() (deadline time.Time, ok bool) { + return +} + +// Done implements context.Context. +func (scc *signalContext) Done() <-chan struct{} { + return scc.stopCh +} + +// Err implements context.Context. +func (scc *signalContext) Err() error { + select { + case _, ok := <-scc.Done(): + if !ok { + return errors.New("received a termination signal") + } + default: + } + return nil +} + +// Value implements context.Context. +func (scc *signalContext) Value(any) any { + return nil +} diff --git a/pkg/subscribed/helpers.go b/pkg/subscribed/helpers.go new file mode 100644 index 0000000..d19048d --- /dev/null +++ b/pkg/subscribed/helpers.go @@ -0,0 +1,188 @@ +package subscribed + +import ( + "fmt" + "strings" + + eventingv1alpha2 "github.com/kyma-project/kyma/components/eventing-controller/api/v1alpha2" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/rest" + + eventingv1alpha1 "github.com/kyma-project/kyma/components/eventing-controller/api/v1alpha1" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/informers" +) + +var ( + GVR = schema.GroupVersionResource{ + Version: eventingv1alpha2.GroupVersion.Version, + Group: eventingv1alpha2.GroupVersion.Group, + Resource: "subscriptions", + } + GVRV1alpha1 = schema.GroupVersionResource{ + Version: eventingv1alpha1.GroupVersion.Version, + Group: eventingv1alpha1.GroupVersion.Group, + Resource: "subscriptions", + } +) + +// ConvertRuntimeObjToSubscriptionV1alpha1 converts a runtime.Object to a v1alpha1 version of Subscription object +// by converting to unstructured in between. +func ConvertRuntimeObjToSubscriptionV1alpha1(sObj runtime.Object) (*eventingv1alpha1.Subscription, error) { + sub := &eventingv1alpha1.Subscription{} + if subUnstructured, ok := sObj.(*unstructured.Unstructured); ok { + err := runtime.DefaultUnstructuredConverter.FromUnstructured(subUnstructured.Object, sub) + if err != nil { + return nil, err + } + } + return sub, nil +} + +// ConvertRuntimeObjToSubscription converts a runtime.Object to a Subscription object +// by converting to unstructured in between. +func ConvertRuntimeObjToSubscription(sObj runtime.Object) (*eventingv1alpha2.Subscription, error) { + sub := &eventingv1alpha2.Subscription{} + if subUnstructured, ok := sObj.(*unstructured.Unstructured); ok { + err := runtime.DefaultUnstructuredConverter.FromUnstructured(subUnstructured.Object, sub) + if err != nil { + return nil, err + } + } + return sub, nil +} + +// GenerateSubscriptionInfFactory generates DynamicSharedInformerFactory for Subscription. +func GenerateSubscriptionInfFactory(k8sConfig *rest.Config) dynamicinformer.DynamicSharedInformerFactory { + subDynamicClient := dynamic.NewForConfigOrDie(k8sConfig) + dFilteredSharedInfFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(subDynamicClient, + informers.DefaultResyncPeriod, + v1.NamespaceAll, + nil, + ) + dFilteredSharedInfFactory.ForResource(GVR) + return dFilteredSharedInfFactory +} + +// ConvertEventsMapToSlice converts a map of Events to a slice of Events. +func ConvertEventsMapToSlice(eventsMap map[Event]bool) []Event { + result := make([]Event, 0) + for k := range eventsMap { + result = append(result, k) + } + return result +} + +// AddUniqueEventsToResult returns a map of unique Events which also contains the events eventsSubSet. +func AddUniqueEventsToResult(eventsSubSet []Event, uniqEvents map[Event]bool) map[Event]bool { + if len(uniqEvents) == 0 { + uniqEvents = make(map[Event]bool) + } + for _, event := range eventsSubSet { + if !uniqEvents[event] { + uniqEvents[event] = true + } + } + return uniqEvents +} + +// FilterEventTypeVersions returns a slice of Events: +// if the event source matches the appName for typeMatching standard +// if the . is present in the eventType for typeMatching exact. +func FilterEventTypeVersions(eventTypePrefix, appName string, subscription *eventingv1alpha2.Subscription) []Event { + events := make([]Event, 0) + prefixAndAppName := fmt.Sprintf("%s.%s.", eventTypePrefix, appName) + + for _, eventType := range subscription.Spec.Types { + if subscription.Spec.TypeMatching == eventingv1alpha2.TypeMatchingExact { + // in case of type matching exact, we have app name as a part of event type + if strings.HasPrefix(eventType, prefixAndAppName) { + eventTypeVersion := strings.ReplaceAll(eventType, prefixAndAppName, "") + event := buildEvent(eventTypeVersion) + events = append(events, event) + } + } else { + // in case of type matching standard, the source must be app name + if appName == subscription.Spec.Source { + event := buildEvent(eventType) + events = append(events, event) + } + } + } + return events +} + +// it receives event and type version, e.g. order.created.v1 and returns `{Name: order.created, Version: v1}`. +func buildEvent(eventTypeAndVersion string) Event { + lastDotIndex := strings.LastIndex(eventTypeAndVersion, ".") + eventName := eventTypeAndVersion[:lastDotIndex] + eventVersion := eventTypeAndVersion[lastDotIndex+1:] + return Event{ + Name: eventName, + Version: eventVersion, + } +} + +// FilterEventTypeVersionsV1alpha1 returns a slice of Events for v1alpha1 version of Subscription resource: +// 1. if the eventType matches the format: .. +// E.g. sap.kyma.custom.varkes.order.created.v0 +// 2. if the eventSource matches BEBNamespace name. +func FilterEventTypeVersionsV1alpha1(eventTypePrefix, bebNs, appName string, filters *eventingv1alpha1.BEBFilters) []Event { + events := make([]Event, 0) + if filters == nil { + return events + } + for _, filter := range filters.Filters { + if filter == nil { + continue + } + + prefix := preparePrefix(eventTypePrefix, appName) + + if filter.EventSource == nil || filter.EventType == nil { + continue + } + + // TODO revisit the filtration logic as part of https://github.com/kyma-project/kyma/issues/10761 + + // filter by event-source if exists + if len(strings.TrimSpace(filter.EventSource.Value)) > 0 && !strings.EqualFold(filter.EventSource.Value, bebNs) { + continue + } + + if !strings.HasPrefix(filter.EventType.Value, prefix) { + continue + } + + // remove the prefix + eventTypeVersion := strings.TrimPrefix(filter.EventType.Value, prefix) + + eventTypeVersionArr := strings.Split(eventTypeVersion, ".") + + // join all segments except the last to form the eventType + eventType := strings.Join(eventTypeVersionArr[:len(eventTypeVersionArr)-1], ".") + + // the last segment is the version + version := eventTypeVersionArr[len(eventTypeVersionArr)-1] + + event := Event{ + Name: eventType, + Version: version, + } + events = append(events, event) + } + return events +} + +func preparePrefix(eventTypePrefix string, appName string) string { + if len(strings.TrimSpace(eventTypePrefix)) == 0 { + return strings.ToLower(fmt.Sprintf("%s.", appName)) + } + return strings.ToLower(fmt.Sprintf("%s.%s.", eventTypePrefix, appName)) +} diff --git a/pkg/subscribed/helpers_test.go b/pkg/subscribed/helpers_test.go new file mode 100644 index 0000000..946e9a6 --- /dev/null +++ b/pkg/subscribed/helpers_test.go @@ -0,0 +1,416 @@ +package subscribed + +import ( + "reflect" + "testing" + + eventingv1alpha2 "github.com/kyma-project/kyma/components/eventing-controller/api/v1alpha2" + + eventingv1alpha1 "github.com/kyma-project/kyma/components/eventing-controller/api/v1alpha1" +) + +func TestFilterEventTypeVersions(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + appName string + subscription *eventingv1alpha2.Subscription + expectedEvents []Event + }{ + { + name: "should return no events when there is no subscription", + appName: "fooapp", + subscription: &eventingv1alpha2.Subscription{}, + expectedEvents: make([]Event, 0), + }, { + name: "should return a slice of events when eventTypes are provided", + appName: "foovarkes", + subscription: &eventingv1alpha2.Subscription{ + Spec: eventingv1alpha2.SubscriptionSpec{ + Source: "foovarkes", + Types: []string{ + "order.created.v1", + "order.created.v2", + }, + }, + }, + expectedEvents: []Event{ + NewEvent("order.created", "v1"), + NewEvent("order.created", "v2"), + }, + }, { + name: "should return no event if app name is different than subscription source", + appName: "foovarkes", + subscription: &eventingv1alpha2.Subscription{ + Spec: eventingv1alpha2.SubscriptionSpec{ + Source: "diff-source", + Types: []string{ + "order.created.v1", + "order.created.v2", + }, + }, + }, + expectedEvents: []Event{}, + }, { + name: "should return event types if event type consists of eventType and appName for typeMaching exact", + appName: "foovarkes", + subscription: &eventingv1alpha2.Subscription{ + Spec: eventingv1alpha2.SubscriptionSpec{ + Source: "/default/sap.kyma/tunas-develop", + TypeMatching: eventingv1alpha2.TypeMatchingExact, + Types: []string{ + "sap.kyma.custom.foovarkes.order.created.v1", + "sap.kyma.custom.foovarkes.order.created.v2", + }, + }, + }, + expectedEvents: []Event{ + NewEvent("order.created", "v1"), + NewEvent("order.created", "v2"), + }, + }, { + name: "should return no event if app name is not part of external event types", + appName: "foovarkes", + subscription: &eventingv1alpha2.Subscription{ + Spec: eventingv1alpha2.SubscriptionSpec{ + Source: "/default/sap.kyma/tunas-develop", + TypeMatching: eventingv1alpha2.TypeMatchingExact, + Types: []string{ + "sap.kyma.custom.difffoovarkes.order.created.v1", + "sap.kyma.custom.difffoovarkes.order.created.v2", + }, + }, + }, + expectedEvents: []Event{}, + }, { + name: "should return event type only with 'sap.kyma.custom' prefix and appname", + appName: "foovarkes", + subscription: &eventingv1alpha2.Subscription{ + Spec: eventingv1alpha2.SubscriptionSpec{ + Source: "/default/sap.kyma/tunas-develop", + TypeMatching: eventingv1alpha2.TypeMatchingExact, + Types: []string{ + "foo.prefix.custom.foovarkes.order.created.v1", + "sap.kyma.custom.foovarkes.order.created.v2", + "sap.kyma.custom.diffvarkes.order.created.v2", + }, + }, + }, + expectedEvents: []Event{ + NewEvent("order.created", "v2"), + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotEvents := FilterEventTypeVersions("sap.kyma.custom", tc.appName, tc.subscription) + if !reflect.DeepEqual(tc.expectedEvents, gotEvents) { + t.Errorf("Received incorrect events, Wanted: %v, Got: %v", tc.expectedEvents, gotEvents) + } + }) + } +} + +func TestBuildEventType(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + appName string + want Event + }{ + { + name: "should return no events when there is no subscription", + appName: "order.created.v1", + want: Event{ + Name: "order.created", + Version: "v1", + }, + }, { + name: "should return a slice of events when eventTypes are provided", + appName: "product.order.created.v1", + want: Event{ + Name: "product.order.created", + Version: "v1", + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + event := buildEvent(tc.appName) + if !reflect.DeepEqual(tc.want, event) { + t.Errorf("Received incorrect events, Wanted: %v, Got: %v", tc.want, event) + } + }) + } +} + +func TestFilterEventTypeVersionsV1alpha1(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + appName string + eventTypePrefix string + bebNs string + filters *eventingv1alpha1.BEBFilters + expectedEvents []Event + }{ + { + name: "should return no events when nil filters are provided", + appName: "fooapp", + eventTypePrefix: "foo.prefix", + bebNs: "foo.bebns", + filters: nil, + expectedEvents: make([]Event, 0), + }, { + name: "should return a slice of events when filters are provided", + appName: "foovarkes", + eventTypePrefix: "foo.prefix.custom", + bebNs: "/default/foo.kyma/kt1", + filters: NewEventMeshFilters(WithOneEventMeshFilter), + expectedEvents: []Event{ + NewEvent("order.created", "v1"), + }, + }, { + name: "should return multiple events in a slice when multiple filters are provided", + appName: "foovarkes", + eventTypePrefix: "foo.prefix.custom", + bebNs: "/default/foo.kyma/kt1", + filters: NewEventMeshFilters(WithMultipleEventMeshFiltersFromSameSource), + expectedEvents: []Event{ + NewEvent("order.created", "v1"), + NewEvent("order.created", "v1"), + NewEvent("order.created", "v1"), + }, + }, { + name: "should return no events when filters sources(bebNamespace) don't match", + appName: "foovarkes", + eventTypePrefix: "foo.prefix.custom", + bebNs: "foo-dont-match", + filters: NewEventMeshFilters(WithMultipleEventMeshFiltersFromSameSource), + expectedEvents: []Event{}, + }, { + name: "should return 2 events(out of multiple) which matches two sources (bebNamespace and empty)", + appName: "foovarkes", + eventTypePrefix: "foo.prefix.custom", + bebNs: "foo-match", + filters: NewEventMeshFilters(WithMultipleEventMeshFiltersFromDiffSource), + expectedEvents: []Event{ + NewEvent("order.created", "v1"), + NewEvent("order.created", "v1"), + }, + }, { + name: "should return 2 out 3 events in a slice when filters with different eventTypePrefix are provided", + appName: "foovarkes", + eventTypePrefix: "foo.prefix.custom", + bebNs: "/default/foo.kyma/kt1", + filters: NewEventMeshFilters(WithMultipleEventMeshFiltersFromDiffEventTypePrefix), + expectedEvents: []Event{ + NewEvent("order.created", "v1"), + NewEvent("order.created", "v1"), + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotEvents := FilterEventTypeVersionsV1alpha1(tc.eventTypePrefix, tc.bebNs, tc.appName, tc.filters) + if !reflect.DeepEqual(tc.expectedEvents, gotEvents) { + t.Errorf("Received incorrect events, Wanted: %v, Got: %v", tc.expectedEvents, gotEvents) + } + }) + } +} + +func TestConvertEventsMapToSlice(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + inputMap map[Event]bool + wantedEvents []Event + }{ + { + name: "should return events from the map in a slice", + inputMap: map[Event]bool{ + NewEvent("foo", "v1"): true, + NewEvent("bar", "v2"): true, + }, + wantedEvents: []Event{ + NewEvent("foo", "v1"), + NewEvent("bar", "v2"), + }, + }, { + name: "should return no events for an empty map of events", + inputMap: map[Event]bool{}, + wantedEvents: []Event{}, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotEvents := ConvertEventsMapToSlice(tc.inputMap) + for _, event := range gotEvents { + found := false + for _, wantEvent := range tc.wantedEvents { + if event == wantEvent { + found = true + continue + } + } + if !found { + t.Errorf("incorrect slice of events, wanted: %v, got: %v", tc.wantedEvents, gotEvents) + } + } + }) + } +} + +func TestAddUniqueEventsToResult(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + eventsSubSet []Event + givenUniqEventsAlready map[Event]bool + wantedUniqEvents map[Event]bool + }{ + { + name: "should return unique events along with the existing ones", + eventsSubSet: []Event{ + NewEvent("foo", "v1"), + NewEvent("bar", "v1"), + }, + givenUniqEventsAlready: map[Event]bool{ + NewEvent("bar-already-existing", "v1"): true, + }, + wantedUniqEvents: map[Event]bool{ + NewEvent("foo", "v1"): true, + NewEvent("bar", "v1"): true, + NewEvent("bar-already-existing", "v1"): true, + }, + }, { + name: "should return unique new events from the subset provided only", + eventsSubSet: []Event{ + NewEvent("foo", "v1"), + NewEvent("bar", "v1"), + }, + givenUniqEventsAlready: nil, + wantedUniqEvents: map[Event]bool{ + NewEvent("foo", "v1"): true, + NewEvent("bar", "v1"): true, + }, + }, { + name: "should return existing unique events when an empty subset provided", + eventsSubSet: []Event{}, + givenUniqEventsAlready: map[Event]bool{ + NewEvent("foo", "v1"): true, + NewEvent("bar", "v1"): true, + }, + wantedUniqEvents: map[Event]bool{ + NewEvent("foo", "v1"): true, + NewEvent("bar", "v1"): true, + }, + }, { + name: "should return no unique events when an empty subset provided", + eventsSubSet: []Event{}, + givenUniqEventsAlready: map[Event]bool{}, + wantedUniqEvents: map[Event]bool{}, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotUniqEvents := AddUniqueEventsToResult(tc.eventsSubSet, tc.givenUniqEventsAlready) + if !reflect.DeepEqual(tc.wantedUniqEvents, gotUniqEvents) { + t.Errorf("incorrect unique events, wanted: %v, got: %v", tc.wantedUniqEvents, gotUniqEvents) + } + }) + } +} + +type EventMeshFilterOption func(filter *eventingv1alpha1.BEBFilters) + +func NewEventMeshFilters(opts ...EventMeshFilterOption) *eventingv1alpha1.BEBFilters { + newFilters := &eventingv1alpha1.BEBFilters{} + for _, opt := range opts { + opt(newFilters) + } + + return newFilters +} + +func WithOneEventMeshFilter(filters *eventingv1alpha1.BEBFilters) { + evSource := "/default/foo.kyma/kt1" + evType := "foo.prefix.custom.foovarkes.order.created.v1" + filters.Filters = []*eventingv1alpha1.EventMeshFilter{ + NewEventMeshFilter(evSource, evType), + } +} + +func WithMultipleEventMeshFiltersFromSameSource(filters *eventingv1alpha1.BEBFilters) { + evSource := "/default/foo.kyma/kt1" + evType := "foo.prefix.custom.foovarkes.order.created.v1" + filters.Filters = []*eventingv1alpha1.EventMeshFilter{ + NewEventMeshFilter(evSource, evType), + NewEventMeshFilter(evSource, evType), + NewEventMeshFilter(evSource, evType), + } +} + +func WithMultipleEventMeshFiltersFromDiffSource(filters *eventingv1alpha1.BEBFilters) { + evSource1 := "foo-match" + evSource2 := "/default/foo.different/kt1" + evSource3 := "/default/foo.different2/kt1" + evSource4 := "" + evType := "foo.prefix.custom.foovarkes.order.created.v1" + filters.Filters = []*eventingv1alpha1.EventMeshFilter{ + NewEventMeshFilter(evSource1, evType), + NewEventMeshFilter(evSource2, evType), + NewEventMeshFilter(evSource3, evType), + NewEventMeshFilter(evSource4, evType), + } +} + +func WithMultipleEventMeshFiltersFromDiffEventTypePrefix(filters *eventingv1alpha1.BEBFilters) { + evSource := "/default/foo.kyma/kt1" + evType1 := "foo.prefix.custom.foovarkes.order.created.v1" + evType2 := "foo.prefixdifferent.custom.foovarkes.order.created.v1" + filters.Filters = []*eventingv1alpha1.EventMeshFilter{ + NewEventMeshFilter(evSource, evType1), + NewEventMeshFilter(evSource, evType2), + NewEventMeshFilter(evSource, evType1), + } +} + +func NewEventMeshFilter(evSource, evType string) *eventingv1alpha1.EventMeshFilter { + return &eventingv1alpha1.EventMeshFilter{ + EventSource: &eventingv1alpha1.Filter{ + Property: "source", + Value: evSource, + }, + EventType: &eventingv1alpha1.Filter{ + Property: "type", + Value: evType, + }, + } +} + +func NewEvent(name, version string) Event { + return Event{ + Name: name, + Version: version, + } +} diff --git a/pkg/subscribed/processor.go b/pkg/subscribed/processor.go new file mode 100644 index 0000000..4e8c678 --- /dev/null +++ b/pkg/subscribed/processor.go @@ -0,0 +1,65 @@ +package subscribed + +import ( + "net/http" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "go.uber.org/zap" + + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy" +) + +const processorName = "processor" + +type Processor struct { + SubscriptionLister *cache.GenericLister + Prefix string + Namespace string + Logger *logger.Logger +} + +func (p Processor) extractEventsFromSubscriptions( + writer http.ResponseWriter, + request *http.Request, +) { + eventsMap := make(map[Event]bool) + subsList, err := (*p.SubscriptionLister).List(labels.Everything()) + if err != nil { + p.namedLogger().Errorw("Failed to fetch subscriptions", "error", err) + RespondWithErrorAndLog(err, writer) + return + } + + appName := legacy.ParseApplicationNameFromPath(request.URL.Path) + for _, sObj := range subsList { + sub, err := ConvertRuntimeObjToSubscription(sObj) + + if err != nil { + p.namedLogger().Errorw("Failed to convert a runtime obj to a Subscription", "error", err) + continue + } + if sub.Spec.Types != nil { + eventsForSub := FilterEventTypeVersions(p.Prefix, appName, sub) + eventsMap = AddUniqueEventsToResult(eventsForSub, eventsMap) + } + } + events := ConvertEventsMapToSlice(eventsMap) + RespondWithBody(writer, Events{ + EventsInfo: events, + }, http.StatusOK) +} + +func (p Processor) ExtractEventsFromSubscriptions(writer http.ResponseWriter, request *http.Request) { + p.extractEventsFromSubscriptions(writer, request) +} + +func (p Processor) ExtractEventsFromSubscriptionsV1alpha1(writer http.ResponseWriter, request *http.Request) { + p.extractEventsFromSubscriptions(writer, request) +} + +func (p Processor) namedLogger() *zap.SugaredLogger { + return p.Logger.WithContext().Named(processorName) +} diff --git a/pkg/subscribed/response.go b/pkg/subscribed/response.go new file mode 100644 index 0000000..92414fe --- /dev/null +++ b/pkg/subscribed/response.go @@ -0,0 +1,57 @@ +package subscribed + +import ( + "encoding/json" + "net/http" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + "go.uber.org/zap" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/legacy" +) + +const responseName = "response" + +// RespondWithBody sends http response with json body. +func RespondWithBody(w http.ResponseWriter, events Events, httpCode int) { + respond(w, httpCode) + if err := json.NewEncoder(w).Encode(events); err != nil { + namedLogger().Error(err) + } +} + +// RespondWithErrorAndLog logs error and sends http response with error json body. +func RespondWithErrorAndLog(e error, w http.ResponseWriter) { + namedLogger().Error(e.Error()) + respond(w, http.StatusInternalServerError) + err := json.NewEncoder(w).Encode(legacy.HTTPErrorResponse{ + Code: http.StatusInternalServerError, + Error: e.Error(), + }) + if err != nil { + namedLogger().Error(err) + } +} + +func respond(w http.ResponseWriter, httpCode int) { + w.Header().Set(internal.HeaderContentType, internal.ContentTypeApplicationJSON) + w.WriteHeader(httpCode) + namedLogger().Infof("Response code from \"subscribed\" request: HTTP %d", httpCode) +} + +// Events represents collection of all events with subscriptions. +type Events struct { + EventsInfo []Event `json:"eventsInfo"` +} + +// Event represents basic information about event. +type Event struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func namedLogger() *zap.SugaredLogger { + log, _ := logger.New("json", "info") + return log.WithContext().Named(responseName) +} diff --git a/pkg/tracing/helpers.go b/pkg/tracing/helpers.go new file mode 100644 index 0000000..2b38f6a --- /dev/null +++ b/pkg/tracing/helpers.go @@ -0,0 +1,59 @@ +package tracing + +import ( + "fmt" + "net/http" + + cev2 "github.com/cloudevents/sdk-go/v2/event" + "github.com/cloudevents/sdk-go/v2/extensions" +) + +const ( + traceParentKey = "traceparent" + b3TraceIDKey = "X-B3-TraceId" + b3ParentSpanIDKey = "X-B3-ParentSpanId" + b3SpanIDKey = "X-B3-SpanId" + b3SampledKey = "X-B3-Sampled" + b3FlagsKey = "X-B3-Flags" + + b3TraceIDCEExtensionsKey = "b3traceid" + b3ParentSpanIDCEExtensionsKey = "b3parentspanid" + b3SpanIDCEExtensionsKey = "b3spanid" + b3SampledCEExtensionsKey = "b3sampled" + b3FlagsCEExtensionsKey = "b3flags" +) + +func AddTracingContextToCEExtensions(reqHeaders http.Header, event *cev2.Event) { + traceParent := reqHeaders.Get(traceParentKey) + if len(traceParent) > 0 { + st := extensions.DistributedTracingExtension{ + TraceParent: fmt.Sprintf("%v", traceParent), + } + st.AddTracingAttributes(event) + } + + b3TraceID := reqHeaders.Get(b3TraceIDKey) + if len(b3TraceID) > 0 { + event.SetExtension(b3TraceIDCEExtensionsKey, b3TraceID) + } + + b3ParentSpanID := reqHeaders.Get(b3ParentSpanIDKey) + if len(b3ParentSpanID) > 0 { + event.SetExtension(b3ParentSpanIDCEExtensionsKey, b3ParentSpanID) + } + + b3SpanID := reqHeaders.Get(b3SpanIDKey) + if len(b3SpanID) > 0 { + event.SetExtension(b3SpanIDCEExtensionsKey, b3SpanID) + } + + b3Sampled := reqHeaders.Get(b3SampledKey) + if len(b3Sampled) > 0 { + event.SetExtension(b3SampledCEExtensionsKey, b3Sampled) + } + + b3Flags := reqHeaders.Get(b3FlagsKey) + if len(b3Flags) > 0 { + event.SetExtension(b3FlagsCEExtensionsKey, b3Flags) + } +} diff --git a/pkg/tracing/helpers_test.go b/pkg/tracing/helpers_test.go new file mode 100644 index 0000000..952a799 --- /dev/null +++ b/pkg/tracing/helpers_test.go @@ -0,0 +1,70 @@ +package tracing + +import ( + "net/http" + "testing" + + cev2event "github.com/cloudevents/sdk-go/v2/event" + + . "github.com/onsi/gomega" +) + +func TestAddTracingContextToCEExtensions(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + testCases := []struct { + name string + headers http.Header + expectedExtensions map[string]any + }{ + { + name: "headers with w3c tracing headers", + headers: func() http.Header { + headers := http.Header{} + headers.Add(traceParentKey, "traceparent") + return headers + }(), + expectedExtensions: map[string]any{ + traceParentKey: "traceparent", + }, + }, { + name: "headers with b3 tracing headers", + headers: func() http.Header { + headers := http.Header{} + headers.Add(b3TraceIDKey, "traceID") + headers.Add(b3ParentSpanIDKey, "parentspanID") + headers.Add(b3SpanIDKey, "spanID") + headers.Add(b3SampledKey, "1") + headers.Add(b3FlagsKey, "1") + + return headers + }(), + expectedExtensions: map[string]any{ + b3TraceIDCEExtensionsKey: "traceID", + b3ParentSpanIDCEExtensionsKey: "parentspanID", + b3SpanIDCEExtensionsKey: "spanID", + b3SampledCEExtensionsKey: "1", + b3FlagsCEExtensionsKey: "1", + }, + }, { + name: "headers without tracing headers", + headers: func() http.Header { + headers := http.Header{} + headers.Add("foo", "bar") + return headers + }(), + expectedExtensions: nil, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + event := cev2event.New() + AddTracingContextToCEExtensions(tc.headers, &event) + g.Expect(event.Extensions()).To(Equal(tc.expectedExtensions)) + }) + } +} diff --git a/pkg/tracing/propagation/http_format_sequence.go b/pkg/tracing/propagation/http_format_sequence.go new file mode 100644 index 0000000..579bb5d --- /dev/null +++ b/pkg/tracing/propagation/http_format_sequence.go @@ -0,0 +1,37 @@ +package propagation + +import ( + "net/http" + + "go.opencensus.io/trace" + "go.opencensus.io/trace/propagation" +) + +// HTTPFormatSequence is a propagation.HTTPFormat that applies multiple other propagation formats. +// For incoming requests, it will use the first SpanContext it can find, checked in the order of +// HTTPFormatSequence.Ingress. +// For outgoing requests, it will apply all the formats to the outgoing request, in the order of +// HTTPFormatSequence.Egress. +type HTTPFormatSequence struct { + Ingress []propagation.HTTPFormat + Egress []propagation.HTTPFormat +} + +var _ propagation.HTTPFormat = (*HTTPFormatSequence)(nil) + +// SpanContextFromRequest satisfies the propagation.HTTPFormat interface. +func (h *HTTPFormatSequence) SpanContextFromRequest(req *http.Request) (trace.SpanContext, bool) { + for _, format := range h.Ingress { + if sc, ok := format.SpanContextFromRequest(req); ok { + return sc, true + } + } + return trace.SpanContext{}, false +} + +// SpanContextToRequest satisfies the propagation.HTTPFormat interface. +func (h *HTTPFormatSequence) SpanContextToRequest(sc trace.SpanContext, req *http.Request) { + for _, format := range h.Egress { + format.SpanContextToRequest(sc, req) + } +} diff --git a/pkg/tracing/propagation/tracecontextb3/http_format.go b/pkg/tracing/propagation/tracecontextb3/http_format.go new file mode 100644 index 0000000..bffcdab --- /dev/null +++ b/pkg/tracing/propagation/tracecontextb3/http_format.go @@ -0,0 +1,21 @@ +package tracecontextb3 + +import ( + "go.opencensus.io/plugin/ochttp/propagation/b3" + "go.opencensus.io/plugin/ochttp/propagation/tracecontext" + ocpropagation "go.opencensus.io/trace/propagation" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/tracing/propagation" +) + +// TraceContextEgress is a propagation.HTTPFormat that reads both TraceContext and B3 tracing +// formats, preferring TraceContext. It always writes TraceContext format exclusively. +var TraceContextEgress = &propagation.HTTPFormatSequence{ + Ingress: []ocpropagation.HTTPFormat{ + &tracecontext.HTTPFormat{}, + &b3.HTTPFormat{}, + }, + Egress: []ocpropagation.HTTPFormat{ + &tracecontext.HTTPFormat{}, + }, +} diff --git a/testing/env_config.go b/testing/env_config.go new file mode 100644 index 0000000..be40285 --- /dev/null +++ b/testing/env_config.go @@ -0,0 +1,62 @@ +package testing + +import ( + "time" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/env" +) + +const ( + defaultPort = 8080 +) + +func NewEnvConfig(emsCEURL, authURL string, opts ...EnvConfigOption) *env.EventMeshConfig { + envConfig := &env.EventMeshConfig{ + Port: defaultPort, + EventMeshPublishURL: emsCEURL, + TokenEndpoint: authURL, + RequestTimeout: time.Minute, + } + for _, opt := range opts { + opt(envConfig) + } + return envConfig +} + +type EnvConfigOption func(e *env.EventMeshConfig) + +func WithPort(port int) EnvConfigOption { + return func(e *env.EventMeshConfig) { + e.Port = port + } +} + +func WithMaxIdleConns(maxIdleConns int) EnvConfigOption { + return func(e *env.EventMeshConfig) { + e.MaxIdleConns = maxIdleConns + } +} + +func WithMaxIdleConnsPerHost(maxIdleConnsPerHost int) EnvConfigOption { + return func(e *env.EventMeshConfig) { + e.MaxIdleConnsPerHost = maxIdleConnsPerHost + } +} + +func WithRequestTimeout(requestTimeout time.Duration) EnvConfigOption { + return func(e *env.EventMeshConfig) { + e.RequestTimeout = requestTimeout + } +} + +func WithNamespace(namespace string) EnvConfigOption { + return func(e *env.EventMeshConfig) { + e.EventMeshNamespace = namespace + } +} + +func WithEventTypePrefix(eventTypePrefix string) EnvConfigOption { + return func(e *env.EventMeshConfig) { + e.EventTypePrefix = eventTypePrefix + } +} diff --git a/testing/fixtures.go b/testing/fixtures.go new file mode 100644 index 0000000..054d83b --- /dev/null +++ b/testing/fixtures.go @@ -0,0 +1,211 @@ +package testing + +import ( + "fmt" + "net/http" + "strings" + "testing" + + cev2 "github.com/cloudevents/sdk-go/v2/event" + "github.com/stretchr/testify/assert" + + "github.com/kyma-project/kyma/components/event-publisher-proxy/internal" +) + +const ( + ApplicationName = "testapp1023" + ApplicationNameNotClean = "testapp_1-0+2=3" + + MessagingNamespace = "/messaging.namespace" + Prefix = "prefix" + EmptyPrefix = "" + + EventID = "8945ec08-256b-11eb-9928-acde48001122" + EventData = `{\"key\":\"value\"}` + EventName = "order.created" + EventVersion = "v1" + + CloudEventNameAndVersion = EventName + "." + EventVersion + CloudEventType = ApplicationName + "." + CloudEventNameAndVersion + CloudEventTypeNotClean = ApplicationNameNotClean + "." + CloudEventNameAndVersion + CloudEventTypeWithPrefix = Prefix + "." + CloudEventType + CloudEventTypeWithPrefixNotClean = Prefix + "." + CloudEventTypeNotClean + CloudEventSource = "/default/sap.kyma/id" + CloudEventSpecVersion = "1.0" + + LegacyEventTime = "2020-04-02T21:37:00Z" + + OldEventTypePrefix = "sap.kyma.custom" +) + +type Event struct { + id string + data string + eventType string +} + +type CloudEvent struct { + Event + specVersion string + eventSource string + dataContentType string +} + +type CloudEventBuilder struct { + CloudEvent +} + +type CloudEventBuilderOpt func(*CloudEventBuilder) + +func NewCloudEventBuilder(opts ...CloudEventBuilderOpt) *CloudEventBuilder { + builder := &CloudEventBuilder{ + CloudEvent{ + Event: Event{ + id: EventID, + data: EventData, + eventType: CloudEventTypeWithPrefix, + }, + specVersion: CloudEventSpecVersion, + eventSource: CloudEventSource, + dataContentType: internal.ContentTypeApplicationJSON, + }, + } + for _, opt := range opts { + opt(builder) + } + return builder +} + +func WithCloudEventID(id string) CloudEventBuilderOpt { + return func(b *CloudEventBuilder) { + b.id = id + } +} + +func WithCloudEventSource(eventSource string) CloudEventBuilderOpt { + return func(b *CloudEventBuilder) { + b.eventSource = eventSource + } +} + +func WithCloudEventSpecVersion(specVersion string) CloudEventBuilderOpt { + return func(b *CloudEventBuilder) { + b.specVersion = specVersion + } +} + +func WithCloudEventType(eventType string) CloudEventBuilderOpt { + return func(b *CloudEventBuilder) { + b.eventType = eventType + } +} + +func addAsHeaderIfPresent(header http.Header, key, value string) { + if len(strings.TrimSpace(key)) == 0 || len(strings.TrimSpace(value)) == 0 { + return + } + header.Add(key, value) +} + +func (b *CloudEventBuilder) BuildBinary() (string, http.Header) { + payload := fmt.Sprintf(`"%s"`, b.data) + headers := make(http.Header) + addAsHeaderIfPresent(headers, CeIDHeader, b.id) + addAsHeaderIfPresent(headers, CeTypeHeader, b.eventType) + addAsHeaderIfPresent(headers, CeSourceHeader, b.eventSource) + addAsHeaderIfPresent(headers, CeSpecVersionHeader, b.specVersion) + return payload, headers +} + +func (b *CloudEventBuilder) BuildStructured() (string, http.Header) { + payload := `{ + "id":"` + b.id + `", + "type":"` + b.eventType + `", + "source":"` + b.eventSource + `", + "specversion":"` + b.specVersion + `", + "datacontenttype":"` + b.dataContentType + `" + }` + headers := http.Header{internal.HeaderContentType: []string{internal.ContentTypeApplicationCloudEventsJSON}} + return payload, headers +} + +func (b *CloudEventBuilder) Build(t *testing.T) *cev2.Event { + e := cev2.New(b.specVersion) + assert.NoError(t, e.Context.SetID(b.id)) + assert.NoError(t, e.Context.SetType(b.eventType)) + assert.NoError(t, e.Context.SetSource(b.eventSource)) + assert.NoError(t, e.SetData(b.dataContentType, b.data)) + return &e +} + +type LegacyEvent struct { + Event + eventTime string + eventTypeVersion string +} + +type LegacyEventBuilder struct { + LegacyEvent +} + +type LegacyEventBuilderOpt func(*LegacyEventBuilder) + +func NewLegacyEventBuilder(opts ...LegacyEventBuilderOpt) *LegacyEventBuilder { + builder := &LegacyEventBuilder{ + LegacyEvent{ + Event: Event{ + id: EventID, + data: EventData, + eventType: EventName, + }, + eventTime: LegacyEventTime, + eventTypeVersion: EventVersion, + }, + } + for _, opt := range opts { + opt(builder) + } + return builder +} + +func WithLegacyEventID(id string) LegacyEventBuilderOpt { + return func(b *LegacyEventBuilder) { + b.id = id + } +} + +func WithLegacyEventType(eventType string) LegacyEventBuilderOpt { + return func(b *LegacyEventBuilder) { + b.eventType = eventType + } +} + +func WithLegacyEventTime(eventTime string) LegacyEventBuilderOpt { + return func(b *LegacyEventBuilder) { + b.eventTime = eventTime + } +} + +func WithLegacyEventTypeVersion(eventTypeVersion string) LegacyEventBuilderOpt { + return func(b *LegacyEventBuilder) { + b.eventTypeVersion = eventTypeVersion + } +} + +func WithLegacyEventData(data string) LegacyEventBuilderOpt { + return func(b *LegacyEventBuilder) { + b.data = data + } +} + +func (b *LegacyEventBuilder) Build() (string, http.Header) { + payload := `{ + "data": "` + b.data + `", + "event-id": "` + b.id + `", + "event-type":"` + b.eventType + `", + "event-time": "` + b.eventTime + `", + "event-type-version":"` + b.eventTypeVersion + `" + }` + headers := http.Header{internal.HeaderContentType: []string{internal.ContentTypeApplicationJSON}} + return payload, headers +} diff --git a/testing/mock_server.go b/testing/mock_server.go new file mode 100644 index 0000000..f52f33c --- /dev/null +++ b/testing/mock_server.go @@ -0,0 +1,112 @@ +package testing + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Validator is used to validate incoming requests to the mock server. +type Validator func(r *http.Request) error + +type MockServer struct { + server *httptest.Server + responseTime time.Duration // server response time + expiresInSec int // token expiry in seconds + generatedTokensCount int // generated tokens count + validator Validator // validate the received requests form publishers +} + +func NewMockServer(opts ...MockServerOption) *MockServer { + mockServer := &MockServer{expiresInSec: 0, generatedTokensCount: 0, responseTime: 0} + for _, opt := range opts { + opt(mockServer) + } + return mockServer +} + +type MockServerOption func(m *MockServer) + +func WithExpiresIn(expiresIn int) MockServerOption { + return func(m *MockServer) { + m.expiresInSec = expiresIn + } +} + +func WithResponseTime(responseTime time.Duration) MockServerOption { + return func(m *MockServer) { + m.responseTime = responseTime + } +} + +func WithValidator(validator Validator) MockServerOption { + return func(m *MockServer) { + m.validator = validator + } +} + +func (m *MockServer) Start(t *testing.T, tokenEndpoint, eventsEndpoint, eventsWithHTTP400 string) { + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(m.responseTime) + + switch r.URL.String() { + case tokenEndpoint: + { + m.generatedTokensCount++ + token := fmt.Sprintf("access_token=token-%d&token_type=bearer&expires_in=%d", time.Now().UnixNano(), m.expiresInSec) + if _, err := w.Write([]byte(token)); err != nil { + t.Errorf("failed to write HTTP response") + } + } + case eventsEndpoint: + { + if err := m.validateRequest(r); err != nil { + t.Errorf("request validatation failed with error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } + case eventsWithHTTP400: + { + if err := m.validateRequest(r); err != nil { + t.Errorf("request validatation failed with error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write([]byte("invalid request")); err != nil { + t.Errorf("failed to write message: %v", err) + } + } + default: + { + t.Errorf("mock server supports the following endpoints only: [%s]", tokenEndpoint) + } + } + })) +} + +func (m *MockServer) validateRequest(r *http.Request) error { + if m.validator == nil { + return nil + } + + return m.validator(r) +} + +func (m *MockServer) URL() string { + return m.server.URL +} + +func (m *MockServer) GeneratedTokensCount() int { + return m.generatedTokensCount +} + +func (m *MockServer) Close() { + m.server.Close() +} diff --git a/testing/nats.go b/testing/nats.go new file mode 100644 index 0000000..698c84b --- /dev/null +++ b/testing/nats.go @@ -0,0 +1,37 @@ +package testing + +import ( + "time" + + "github.com/kyma-project/kyma/components/eventing-controller/logger" + + pkgnats "github.com/kyma-project/kyma/components/event-publisher-proxy/pkg/nats" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats-server/v2/test" + "github.com/nats-io/nats.go" +) + +const ( + StreamName = "kyma" + maxReconnects = 3 +) + +func StartNATSServer() *server.Server { + opts := test.DefaultTestOptions + opts.Port = server.RANDOM_PORT + opts.JetStream = true + opts.Host = "localhost" + + log, _ := logger.New("json", "info") + log.WithContext().Info("Starting test NATS Server in JetStream mode") + return test.RunServer(&opts) +} + +func ConnectToNATSServer(url string) (*nats.Conn, error) { + return pkgnats.Connect(url, + pkgnats.WithRetryOnFailedConnect(true), + pkgnats.WithMaxReconnects(maxReconnects), + pkgnats.WithReconnectWait(time.Second), + ) +} diff --git a/testing/utils.go b/testing/utils.go new file mode 100644 index 0000000..d7a78ca --- /dev/null +++ b/testing/utils.go @@ -0,0 +1,94 @@ +package testing + +import ( + "crypto/rand" + "fmt" + "io" + "log" + "net" + "strconv" + "time" + + eventingv1alpha1 "github.com/kyma-project/kyma/components/eventing-controller/api/v1alpha1" +) + +// binary cloudevent headers. +const ( + CeIDHeader = "ce-id" + CeTypeHeader = "ce-type" + CeSourceHeader = "ce-source" + CeSpecVersionHeader = "ce-specversion" +) + +type SubscriptionOpt func(*eventingv1alpha1.Subscription) + +// GeneratePortOrDie generates a random 5 digit port or fail. +func GeneratePortOrDie() int { + tick := time.NewTicker(500 * time.Millisecond) //nolint:gomnd //the tickerinterval is only required here + defer tick.Stop() + + timeout := time.NewTimer(time.Minute) + defer timeout.Stop() + + for { + select { + case <-tick.C: + { + port, err := generatePort() + if err != nil { + break + } + + if !isPortAvailable(port) { + break + } + + return port + } + case <-timeout.C: + { + log.Fatal("Failed to generate port") + } + } + } +} + +func generatePort() (int, error) { + table := [...]byte{'1', '2', '3', '4', '5', '6', '7', '8', '9'} + max := 4 + // Add 4 as prefix to make it 5 digits but less than 65535 + add4AsPrefix := "4" + b := make([]byte, max) + n, err := io.ReadAtLeast(rand.Reader, b, max) + if n != max { + return 0, err + } + if err != nil { + return 0, err + } + for i := 0; i < len(b); i++ { + b[i] = table[int(b[i])%len(table)] + } + + num, err := strconv.Atoi(fmt.Sprintf("%s%s", add4AsPrefix, string(b))) + if err != nil { + return 0, err + } + + return num, nil +} + +// isPortAvailable returns true if the port is available for use, otherwise returns false. +func isPortAvailable(port int) bool { + address := fmt.Sprintf("localhost:%d", port) + listener, err := net.Listen("tcp", address) + if err != nil { + return false + } + + if err := listener.Close(); err != nil { + return false + } + + return true +}