From 3ed2fc6cf1edd6cafa55a2b0b8cab19edcd0fc7d Mon Sep 17 00:00:00 2001 From: Florian Bacher Date: Tue, 28 Jun 2022 08:58:37 +0200 Subject: [PATCH] feat: Move commonly used modules from keptn/keptn into sub-packages of go-utils (#483) * feat: Move commonly used modules from keptn/keptn into sub-packages of go-utils Signed-off-by: Florian Bacher * feat: Move commonly used modules from keptn/keptn into sub-packages of go-utils Signed-off-by: Florian Bacher * merge master Signed-off-by: Florian Bacher * move internal api client utils to api package Signed-off-by: Florian Bacher * try to fix sync issue Signed-off-by: Florian Bacher * remove -race flag from test Signed-off-by: Florian Bacher --- .github/workflows/tests.yml | 2 +- go.mod | 29 +- go.sum | 60 +- pkg/api/utils/internal.go | 238 ++++++++ pkg/api/utils/internal_test.go | 51 ++ .../connector/controlplane/controlplane.go | 234 ++++++++ .../controlplane/controlplane_test.go | 539 ++++++++++++++++++ .../connector/eventmatcher/eventmatcher.go | 43 ++ .../eventmatcher/eventmatcher_test.go | 266 +++++++++ pkg/sdk/connector/eventsource/eventsource.go | 23 + .../connector/eventsource/http/eventapi.go | 120 ++++ .../eventsource/http/eventapi_test.go | 103 ++++ .../eventsource/http/fake/eventAPI.go | 114 ++++ .../eventsource/http/fake/getEventAPI.go | 71 +++ .../eventsource/http/fake/sendEventAPI.go | 70 +++ pkg/sdk/connector/eventsource/http/http.go | 164 ++++++ .../connector/eventsource/http/http_test.go | 173 ++++++ pkg/sdk/connector/eventsource/http/utils.go | 150 +++++ .../connector/eventsource/http/utils_test.go | 97 ++++ pkg/sdk/connector/eventsource/nats/nats.go | 130 +++++ .../connector/eventsource/nats/nats_test.go | 294 ++++++++++ pkg/sdk/connector/fake/eventsource.go | 44 ++ pkg/sdk/connector/fake/logapi.go | 236 ++++++++ pkg/sdk/connector/fake/shipyardeventapi.go | 71 +++ pkg/sdk/connector/fake/subscriptionsource.go | 35 ++ pkg/sdk/connector/fake/uniformapi.go | 33 ++ .../connector/logforwarder/logforwarder.go | 95 +++ .../logforwarder/logforwarder_test.go | 93 +++ pkg/sdk/connector/logger/log.go | 70 +++ pkg/sdk/connector/nats/nats.go | 215 +++++++ pkg/sdk/connector/nats/nats_test.go | 256 +++++++++ .../subscriptionsource/subscriptionsource.go | 147 +++++ .../subscriptionsource_test.go | 241 ++++++++ pkg/sdk/connector/types/types.go | 26 + pkg/sdk/events.go | 141 +++++ pkg/sdk/events_test.go | 352 ++++++++++++ pkg/sdk/example/handler.go | 48 ++ pkg/sdk/example/handler_test.go | 36 ++ pkg/sdk/example/main.go | 18 + .../events/greeting.triggered-0.json | 15 + pkg/sdk/internal/api/client.go | 106 ++++ pkg/sdk/internal/api/client_test.go | 130 +++++ pkg/sdk/internal/api/initializer.go | 46 ++ pkg/sdk/internal/api/initializer_test.go | 74 +++ pkg/sdk/internal/config/config.go | 71 +++ pkg/sdk/keptn.go | 411 +++++++++++++ pkg/sdk/keptn_fake.go | 219 +++++++ pkg/sdk/keptn_test.go | 201 +++++++ pkg/sdk/log.go | 70 +++ pkg/sdk/taskregistry.go | 46 ++ 50 files changed, 6493 insertions(+), 24 deletions(-) create mode 100644 pkg/api/utils/internal.go create mode 100644 pkg/api/utils/internal_test.go create mode 100644 pkg/sdk/connector/controlplane/controlplane.go create mode 100644 pkg/sdk/connector/controlplane/controlplane_test.go create mode 100644 pkg/sdk/connector/eventmatcher/eventmatcher.go create mode 100644 pkg/sdk/connector/eventmatcher/eventmatcher_test.go create mode 100644 pkg/sdk/connector/eventsource/eventsource.go create mode 100644 pkg/sdk/connector/eventsource/http/eventapi.go create mode 100644 pkg/sdk/connector/eventsource/http/eventapi_test.go create mode 100644 pkg/sdk/connector/eventsource/http/fake/eventAPI.go create mode 100644 pkg/sdk/connector/eventsource/http/fake/getEventAPI.go create mode 100644 pkg/sdk/connector/eventsource/http/fake/sendEventAPI.go create mode 100644 pkg/sdk/connector/eventsource/http/http.go create mode 100644 pkg/sdk/connector/eventsource/http/http_test.go create mode 100644 pkg/sdk/connector/eventsource/http/utils.go create mode 100644 pkg/sdk/connector/eventsource/http/utils_test.go create mode 100644 pkg/sdk/connector/eventsource/nats/nats.go create mode 100644 pkg/sdk/connector/eventsource/nats/nats_test.go create mode 100644 pkg/sdk/connector/fake/eventsource.go create mode 100644 pkg/sdk/connector/fake/logapi.go create mode 100644 pkg/sdk/connector/fake/shipyardeventapi.go create mode 100644 pkg/sdk/connector/fake/subscriptionsource.go create mode 100644 pkg/sdk/connector/fake/uniformapi.go create mode 100644 pkg/sdk/connector/logforwarder/logforwarder.go create mode 100644 pkg/sdk/connector/logforwarder/logforwarder_test.go create mode 100644 pkg/sdk/connector/logger/log.go create mode 100644 pkg/sdk/connector/nats/nats.go create mode 100644 pkg/sdk/connector/nats/nats_test.go create mode 100644 pkg/sdk/connector/subscriptionsource/subscriptionsource.go create mode 100644 pkg/sdk/connector/subscriptionsource/subscriptionsource_test.go create mode 100644 pkg/sdk/connector/types/types.go create mode 100644 pkg/sdk/events.go create mode 100644 pkg/sdk/events_test.go create mode 100644 pkg/sdk/example/handler.go create mode 100644 pkg/sdk/example/handler_test.go create mode 100644 pkg/sdk/example/main.go create mode 100644 pkg/sdk/example/test-assets/events/greeting.triggered-0.json create mode 100644 pkg/sdk/internal/api/client.go create mode 100644 pkg/sdk/internal/api/client_test.go create mode 100644 pkg/sdk/internal/api/initializer.go create mode 100644 pkg/sdk/internal/api/initializer_test.go create mode 100644 pkg/sdk/internal/config/config.go create mode 100644 pkg/sdk/keptn.go create mode 100644 pkg/sdk/keptn_fake.go create mode 100644 pkg/sdk/keptn_test.go create mode 100644 pkg/sdk/log.go create mode 100644 pkg/sdk/taskregistry.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72256204..9cf3dd1b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,4 +18,4 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - name: Test - run: go test -race -v ./... + run: go test -v ./... diff --git a/go.mod b/go.mod index 025318d9..9e909e72 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,22 @@ module github.com/keptn/go-utils go 1.17 require ( + github.com/avast/retry-go v3.0.0+incompatible github.com/benbjohnson/clock v1.3.0 github.com/cloudevents/sdk-go/observability/opentelemetry/v2 v2.0.0-20211001212819-74757a691209 - github.com/cloudevents/sdk-go/v2 v2.9.0 + github.com/cloudevents/sdk-go/v2 v2.10.0 github.com/google/uuid v1.3.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/nats-io/nats-server/v2 v2.8.4 + github.com/nats-io/nats.go v1.16.0 + github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.1 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.27.0 - go.opentelemetry.io/otel v1.2.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.32.0 + go.opentelemetry.io/otel v1.7.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.2.0 go.opentelemetry.io/otel/sdk v1.2.0 - go.opentelemetry.io/otel/trace v1.2.0 + go.opentelemetry.io/otel/trace v1.7.0 + golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b k8s.io/api v0.22.11 k8s.io/apimachinery v0.22.11 @@ -25,32 +31,37 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/felixge/httpsnoop v1.0.2 // indirect - github.com/go-logr/logr v1.2.2 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.6 // indirect + github.com/google/go-cmp v0.5.7 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/googleapis/gnostic v0.5.5 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.14.4 // indirect + github.com/minio/highwayhash v1.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/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a // indirect + github.com/nats-io/nkeys v0.3.0 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/onsi/ginkgo v1.16.4 // indirect github.com/onsi/gomega v1.15.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.2.0 // indirect - go.opentelemetry.io/otel/internal/metric v0.25.0 // indirect - go.opentelemetry.io/otel/metric v0.25.0 // indirect + go.opentelemetry.io/otel/metric v0.30.0 // indirect go.opentelemetry.io/proto/otlp v0.10.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.19.0 // indirect + golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect - golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect diff --git a/go.sum b/go.sum index f56f2a68..f5906d78 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -67,8 +69,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudevents/sdk-go/observability/opentelemetry/v2 v2.0.0-20211001212819-74757a691209 h1:pR23jlIJMXGMxljxP6QYytEsMQpPU2WT3Wjp1FWYOq0= github.com/cloudevents/sdk-go/observability/opentelemetry/v2 v2.0.0-20211001212819-74757a691209/go.mod h1:DmxtN+a7U9ktD8I0nTlI9CCrin/Tf7OdXxE3KBTjlOw= github.com/cloudevents/sdk-go/v2 v2.5.0/go.mod h1:nlXhgFkf0uTopxmRXalyMwS2LG70cRGPrxzmjJgSG0U= -github.com/cloudevents/sdk-go/v2 v2.9.0 h1:StQ9q2JuGvclGFoT7kpTdQm+qjW0LQzg51CgUF4ncpY= -github.com/cloudevents/sdk-go/v2 v2.9.0/go.mod h1:GpCBmUj7DIRiDhVvsK5d6WCbgTWs8DxAWTRtAwQmIXs= +github.com/cloudevents/sdk-go/v2 v2.10.0 h1:sz0pbNBGh1iRspqLGe/2cXhDghZZpvNPHwKPucVbh+8= +github.com/cloudevents/sdk-go/v2 v2.10.0/go.mod h1:GpCBmUj7DIRiDhVvsK5d6WCbgTWs8DxAWTRtAwQmIXs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -109,8 +111,11 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.2 h1:ahHml/yUpnlb96Rp8HCvtYVPY8ZYpxq3g7UYchIYwbs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= @@ -162,8 +167,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/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.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -211,8 +217,12 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +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.14.4 h1:eijASRJcobkVtSt81Olfh7JX43osYLwy5krOJo6YEu4= +github.com/klauspost/compress v1.14.4/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -221,6 +231,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +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/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -232,6 +244,17 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a h1:lem6QCvxR0Y28gth9P+wV2K/zYUUAkJ+55U8cpS0p5I= +github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k= +github.com/nats-io/nats-server/v2 v2.8.4 h1:0jQzze1T9mECg8YZEl8+WYUXb9JKluJfCBriPUtluB4= +github.com/nats-io/nats-server/v2 v2.8.4/go.mod h1:8zZa+Al3WsESfmgSs98Fi06dRWLH5Bnq90m5bKD/eT4= +github.com/nats-io/nats.go v1.15.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= +github.com/nats-io/nats.go v1.16.0 h1:zvLE7fGBQYW6MWaFaRdsgm9qT39PJDQoju+DS8KsO1g= +github.com/nats-io/nats.go v1.16.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= +github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8= +github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= +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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -257,6 +280,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= @@ -264,6 +289,7 @@ 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/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -288,28 +314,28 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/contrib v0.23.0 h1:MgRuo0JZZX8J9WLRjyd7OpTSbaLOdQXXJa6SnZvlWLM= go.opentelemetry.io/contrib v0.23.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.23.0/go.mod h1:wLrbAf2Qb+kFsEjowrxOcuy2SE0dcY0VwFiiYCmUeFQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.27.0 h1:0BgiNWjN7rUWO9HdjF4L12r8OW86QkVQcYmCjnayJLo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.27.0/go.mod h1:bdvm3YpMxWAgEfQhtTBaVR8ceXPRuRBSQrvOBnIlHxc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.32.0 h1:mac9BKRqwaX6zxHPDe3pvmWpwuuIM0vuXv2juCnQevE= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.32.0/go.mod h1:5eCOqeGphOyz6TsY3ZDNjE33SM/TFAK3RGuCL2naTgY= go.opentelemetry.io/otel v1.0.0-RC3/go.mod h1:Ka5j3ua8tZs4Rkq4Ex3hwgBgOchyPVq5S6P2lz//nKQ= go.opentelemetry.io/otel v1.0.0/go.mod h1:AjRVh9A5/5DE7S+mZtTR6t8vpKKryam+0lREnfmS4cg= -go.opentelemetry.io/otel v1.2.0 h1:YOQDvxO1FayUcT9MIhJhgMyNO1WqoduiyvQHzGN0kUQ= go.opentelemetry.io/otel v1.2.0/go.mod h1:aT17Fk0Z1Nor9e0uisf98LrntPGMnk4frBO9+dkf69I= +go.opentelemetry.io/otel v1.7.0 h1:Z2lA3Tdch0iDcrhJXDIlC94XE+bxok1F9B+4Lz/lGsM= +go.opentelemetry.io/otel v1.7.0/go.mod h1:5BdUoMIz5WEs0vt0CUEMtSSaTSHBBVwrhnz7+nrD5xk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.2.0 h1:xzbcGykysUh776gzD1LUPsNNHKWN0kQWDnJhn1ddUuk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.2.0/go.mod h1:14T5gr+Y6s2AgHPqBMgnGwp04csUjQmYXFWPeiBoq5s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.2.0 h1:VsgsSCDwOSuO8eMVh63Cd4nACMqgjpmAeJSIvVNneD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.2.0/go.mod h1:9mLBBnPRf3sf+ASVH2p9xREXVBvwib02FxcKnavtExg= go.opentelemetry.io/otel/internal/metric v0.23.0/go.mod h1:z+RPiDJe30YnCrOhFGivwBS+DU1JU/PiLKkk4re2DNY= -go.opentelemetry.io/otel/internal/metric v0.25.0 h1:w/7RXe16WdPylaIXDgcYM6t/q0K5lXgSdZOEbIEyliE= -go.opentelemetry.io/otel/internal/metric v0.25.0/go.mod h1:Nhuw26QSX7d6n4duoqAFi5KOQR4AuzyMcl5eXOgwxtc= go.opentelemetry.io/otel/metric v0.23.0/go.mod h1:G/Nn9InyNnIv7J6YVkQfpc0JCfKBNJaERBGw08nqmVQ= -go.opentelemetry.io/otel/metric v0.25.0 h1:7cXOnCADUsR3+EOqxPaSKwhEuNu0gz/56dRN1hpIdKw= -go.opentelemetry.io/otel/metric v0.25.0/go.mod h1:E884FSpQfnJOMMUaq+05IWlJ4rjZpk2s/F1Ju+TEEm8= +go.opentelemetry.io/otel/metric v0.30.0 h1:Hs8eQZ8aQgs0U49diZoaS6Uaxw3+bBE3lcMUKBFIk3c= +go.opentelemetry.io/otel/metric v0.30.0/go.mod h1:/ShZ7+TS4dHzDFmfi1kSXMhMVubNoP0oIaBp70J6UXU= go.opentelemetry.io/otel/sdk v1.2.0 h1:wKN260u4DesJYhyjxDa7LRFkuhH7ncEVKU37LWcyNIo= go.opentelemetry.io/otel/sdk v1.2.0/go.mod h1:jNN8QtpvbsKhgaC6V5lHiejMoKD+V8uadoSafgHPx1U= go.opentelemetry.io/otel/trace v1.0.0-RC3/go.mod h1:VUt2TUYd8S2/ZRX09ZDFZQwn2RqfMB5MzO17jBojGxo= go.opentelemetry.io/otel/trace v1.0.0/go.mod h1:PXTWqayeFUlJV1YDNhsJYB184+IvAH814St6o6ajzIs= -go.opentelemetry.io/otel/trace v1.2.0 h1:Ys3iqbqZhcf28hHzrm5WAquMkDHNZTUkw7KHbuNjej0= go.opentelemetry.io/otel/trace v1.2.0/go.mod h1:N5FLswTubnxKxOJHM7XZC074qpeEdLy3CgAVsdMucK0= +go.opentelemetry.io/otel/trace v1.7.0 h1:O37Iogk1lEkMRXewVtZ1BBTVn5JEp8GrJvP92bJqC6o= +go.opentelemetry.io/otel/trace v1.7.0/go.mod h1:fzLSB9nqR2eXzxPXb2JW9IKE+ScyXA48yyE4TNvoHqU= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.10.0 h1:n7brgtEbDvXEgGyKKo8SobKT1e9FewlDtXzkVP5djoE= go.opentelemetry.io/proto/otlp v0.10.0/go.mod h1:zG20xCK0szZ1xdokeSOwEcmlXu+x9kkdRe6N1DhKcfU= @@ -329,7 +355,10 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38= +golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -379,8 +408,8 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb h1:8tDJ3aechhddbdPAxpycgXHJRMLpk/Ab+aa4OgdN5/g= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= 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= @@ -394,6 +423,7 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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-20180909124046-d0be0721c37e/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -438,6 +468,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -459,6 +490,7 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/api/utils/internal.go b/pkg/api/utils/internal.go new file mode 100644 index 00000000..ca6c5aac --- /dev/null +++ b/pkg/api/utils/internal.go @@ -0,0 +1,238 @@ +package api + +import ( + "github.com/benbjohnson/clock" + "github.com/keptn/go-utils/pkg/api/models" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "net/http" + "time" +) + +// InternalAPISet is an implementation of APISet +// which can be used from within the Keptn control plane +type InternalAPISet struct { + apimap InClusterAPIMappings + httpClient *http.Client + apiHandler *InternalAPIHandler + authHandler *AuthHandler + eventHandler *EventHandler + logHandler *LogHandler + projectHandler *ProjectHandler + resourceHandler *ResourceHandler + secretHandler *SecretHandler + sequenceControlHandler *SequenceControlHandler + serviceHandler *ServiceHandler + stageHandler *StageHandler + uniformHandler *UniformHandler + shipyardControlHandler *ShipyardControllerHandler +} + +// InternalService is used to enumerate internal Keptn services +type InternalService int + +const ( + ConfigurationService InternalService = iota + ShipyardController + ApiService + SecretService + MongoDBDatastore +) + +// InClusterAPIMappings maps a keptn service name to its reachable domain name +type InClusterAPIMappings map[InternalService]string + +// DefaultInClusterAPIMappings gives you the default InClusterAPIMappings +var DefaultInClusterAPIMappings = InClusterAPIMappings{ + ConfigurationService: "configuration-service:8080", + ShipyardController: "shipyard-controller:8080", + ApiService: "api-service:8080", + SecretService: "secret-service:8080", + MongoDBDatastore: "mongodb-datastore:8080", +} + +// NewInternal creates a new InternalAPISet usable for calling keptn services from within the control plane +func NewInternal(client *http.Client, apiMappings ...InClusterAPIMappings) (*InternalAPISet, error) { + var apimap InClusterAPIMappings + if len(apiMappings) > 0 { + apimap = apiMappings[0] + } else { + apimap = DefaultInClusterAPIMappings + } + + if client == nil { + client = &http.Client{} + } + + as := &InternalAPISet{} + as.httpClient = client + + as.apiHandler = &InternalAPIHandler{ + shipyardControllerApiHandler: &APIHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + }, + } + + as.authHandler = &AuthHandler{ + BaseURL: apimap[ApiService], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + as.logHandler = &LogHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: getClientTransport(as.httpClient.Transport)}, + Scheme: "http", + LogCache: []models.LogEntry{}, + TheClock: clock.New(), + SyncInterval: 1 * time.Minute, + } + + as.eventHandler = &EventHandler{ + BaseURL: apimap[MongoDBDatastore], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + + as.projectHandler = &ProjectHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + + as.resourceHandler = &ResourceHandler{ + BaseURL: apimap[ConfigurationService], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + as.secretHandler = &SecretHandler{ + BaseURL: apimap[SecretService], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + as.sequenceControlHandler = &SequenceControlHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + as.serviceHandler = &ServiceHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + as.shipyardControlHandler = &ShipyardControllerHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: wrapOtelTransport(getClientTransport(as.httpClient.Transport))}, + Scheme: "http", + } + as.stageHandler = &StageHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: otelhttp.NewTransport(as.httpClient.Transport)}, + Scheme: "http", + } + as.uniformHandler = &UniformHandler{ + BaseURL: apimap[ShipyardController], + HTTPClient: &http.Client{Transport: getClientTransport(as.httpClient.Transport)}, + Scheme: "http", + } + return as, nil +} + +// APIV1 retrieves the APIHandler +func (c *InternalAPISet) APIV1() APIV1Interface { + return c.apiHandler +} + +// AuthV1 retrieves the AuthHandler +func (c *InternalAPISet) AuthV1() AuthV1Interface { + return c.authHandler +} + +// EventsV1 retrieves the EventHandler +func (c *InternalAPISet) EventsV1() EventsV1Interface { + return c.eventHandler +} + +// LogsV1 retrieves the LogHandler +func (c *InternalAPISet) LogsV1() LogsV1Interface { + return c.logHandler +} + +// ProjectsV1 retrieves the ProjectHandler +func (c *InternalAPISet) ProjectsV1() ProjectsV1Interface { + return c.projectHandler +} + +// ResourcesV1 retrieves the ResourceHandler +func (c *InternalAPISet) ResourcesV1() ResourcesV1Interface { + return c.resourceHandler +} + +// SecretsV1 retrieves the SecretHandler +func (c *InternalAPISet) SecretsV1() SecretsV1Interface { + return c.secretHandler +} + +// SequencesV1 retrieves the SequenceControlHandler +func (c *InternalAPISet) SequencesV1() SequencesV1Interface { + return c.sequenceControlHandler +} + +// ServicesV1 retrieves the ServiceHandler +func (c *InternalAPISet) ServicesV1() ServicesV1Interface { + return c.serviceHandler +} + +// StagesV1 retrieves the StageHandler +func (c *InternalAPISet) StagesV1() StagesV1Interface { + return c.stageHandler +} + +// UniformV1 retrieves the UniformHandler +func (c *InternalAPISet) UniformV1() UniformV1Interface { + return c.uniformHandler +} + +// ShipyardControlV1 retrieves the ShipyardControllerHandler +func (c *InternalAPISet) ShipyardControlV1() ShipyardControlV1Interface { + return c.shipyardControlHandler +} + +// InternalAPIHandler is used instead of APIHandler from go-utils because we cannot support +// (unauthenticated) internal calls to the api-service at the moment. So this implementation +// will panic as soon as a client wants to call these methods +type InternalAPIHandler struct { + shipyardControllerApiHandler *APIHandler +} + +func (i *InternalAPIHandler) SendEvent(event models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) { + panic("SendEvent() is not not supported for internal usage") +} + +func (i *InternalAPIHandler) TriggerEvaluation(project string, stage string, service string, evaluation models.Evaluation) (*models.EventContext, *models.Error) { + return i.shipyardControllerApiHandler.TriggerEvaluation(project, stage, service, evaluation) +} + +func (i *InternalAPIHandler) CreateProject(project models.CreateProject) (string, *models.Error) { + return i.shipyardControllerApiHandler.CreateProject(project) +} + +func (i *InternalAPIHandler) UpdateProject(project models.CreateProject) (string, *models.Error) { + return i.shipyardControllerApiHandler.UpdateProject(project) +} + +func (i *InternalAPIHandler) DeleteProject(project models.Project) (*models.DeleteProjectResponse, *models.Error) { + return i.shipyardControllerApiHandler.DeleteProject(project) +} + +func (i *InternalAPIHandler) CreateService(project string, service models.CreateService) (string, *models.Error) { + return i.shipyardControllerApiHandler.CreateService(project, service) +} + +func (i *InternalAPIHandler) DeleteService(project string, service string) (*models.DeleteServiceResponse, *models.Error) { + return i.shipyardControllerApiHandler.DeleteService(project, service) +} + +func (i *InternalAPIHandler) GetMetadata() (*models.Metadata, *models.Error) { + panic("GetMetadata() is not not supported for internal usage") +} diff --git a/pkg/api/utils/internal_test.go b/pkg/api/utils/internal_test.go new file mode 100644 index 00000000..eeaa8ab0 --- /dev/null +++ b/pkg/api/utils/internal_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestApiSetInternalMappings(t *testing.T) { + t.Run("TestInternalAPISet - Default API Mappings", func(t *testing.T) { + internal, err := NewInternal(nil) + require.Nil(t, err) + require.NotNil(t, internal) + assert.Equal(t, DefaultInClusterAPIMappings[MongoDBDatastore], internal.EventsV1().(*EventHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ApiService], internal.AuthV1().(*AuthHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.APIV1().(*InternalAPIHandler).shipyardControllerApiHandler.BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.ShipyardControlV1().(*ShipyardControllerHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.UniformV1().(*UniformHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.LogsV1().(*LogHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.SequencesV1().(*SequenceControlHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.StagesV1().(*StageHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[SecretService], internal.SecretsV1().(*SecretHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ConfigurationService], internal.ResourcesV1().(*ResourceHandler).BaseURL) + assert.Equal(t, DefaultInClusterAPIMappings[ShipyardController], internal.ProjectsV1().(*ProjectHandler).BaseURL) + }) + + t.Run("TestInternalAPISet - Override Mappings", func(t *testing.T) { + overrideMappings := InClusterAPIMappings{ + ConfigurationService: "special-configuration-service:8080", + ShipyardController: "special-shipyard-controller:8080", + ApiService: "speclial-api-service:8080", + SecretService: "special-secret-service:8080", + MongoDBDatastore: "special-monogodb-datastore:8080", + } + internal, err := NewInternal(nil, overrideMappings) + require.Nil(t, err) + require.NotNil(t, internal) + assert.Equal(t, overrideMappings[MongoDBDatastore], internal.EventsV1().(*EventHandler).BaseURL) + assert.Equal(t, overrideMappings[ApiService], internal.AuthV1().(*AuthHandler).BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.APIV1().(*InternalAPIHandler).shipyardControllerApiHandler.BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.ShipyardControlV1().(*ShipyardControllerHandler).BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.UniformV1().(*UniformHandler).BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.LogsV1().(*LogHandler).BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.SequencesV1().(*SequenceControlHandler).BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.StagesV1().(*StageHandler).BaseURL) + assert.Equal(t, overrideMappings[SecretService], internal.SecretsV1().(*SecretHandler).BaseURL) + assert.Equal(t, overrideMappings[ConfigurationService], internal.ResourcesV1().(*ResourceHandler).BaseURL) + assert.Equal(t, overrideMappings[ShipyardController], internal.ProjectsV1().(*ProjectHandler).BaseURL) + }) + +} diff --git a/pkg/sdk/connector/controlplane/controlplane.go b/pkg/sdk/connector/controlplane/controlplane.go new file mode 100644 index 00000000..f8e8a562 --- /dev/null +++ b/pkg/sdk/connector/controlplane/controlplane.go @@ -0,0 +1,234 @@ +package controlplane + +import ( + "context" + "errors" + "fmt" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/sdk/connector/eventmatcher" + "github.com/keptn/go-utils/pkg/sdk/connector/eventsource" + "github.com/keptn/go-utils/pkg/sdk/connector/logforwarder" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" + "github.com/keptn/go-utils/pkg/sdk/connector/subscriptionsource" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "log" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +type EventSender = types.EventSender +type EventSenderKeyType = types.EventSenderKeyType +type RegistrationData = types.RegistrationData + +const tmpDataDistributorKey = "distributor" + +var ErrEventHandleFatal = errors.New("fatal event handling error") + +// Integration represents a Keptn Service that wants to receive events from the Keptn Control plane +type Integration interface { + // OnEvent is called when a new event was received + OnEvent(context.Context, models.KeptnContextExtendedCE) error + + // RegistrationData is called to get the initial registration data + RegistrationData() types.RegistrationData +} + +// ControlPlane can be used to connect to the Keptn Control Plane +type ControlPlane struct { + subscriptionSource subscriptionsource.SubscriptionSource + eventSource eventsource.EventSource + currentSubscriptions []models.EventSubscription + logger logger.Logger + registered bool + integrationID string + logForwarder logforwarder.LogForwarder +} + +// WithLogger sets the logger to use +func WithLogger(logger logger.Logger) func(plane *ControlPlane) { + return func(ns *ControlPlane) { + ns.logger = logger + } +} + +// RunWithGracefulShutdown starts the controlplane component which takes care of registering +// the integration and handling events and subscriptions. Further, it supports graceful shutdown handling +// when receiving a SIGHUB, SIGINT, SIGQUIT, SIGARBT or SIGTERM signal. +// +// This call is blocking. +// +//If you want to start the controlplane component with an own context you need to call the Regiser(ctx,integration) +// method on your own +func RunWithGracefulShutdown(controlPlane *ControlPlane, integration Integration, shutdownTimeout time.Duration) error { + ctxShutdown, cancel := context.WithCancel(context.Background()) + defer cancel() + + ctxShutdown, _ = signal.NotifyContext(ctxShutdown, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGABRT, syscall.SIGTERM) + go func() { + <-ctxShutdown.Done() + time.Sleep(shutdownTimeout) // shutdown timeout + log.Printf("failed to gracefully shutdown") + os.Exit(1) + }() + + return controlPlane.Register(ctxShutdown, integration) +} + +// New creates a new ControlPlane +// It is using a SubscriptionSource source to get information about current uniform subscriptions +// as well as an EventSource to actually receive events from Keptn +// and a LogForwarder to forward error logs +func New(subscriptionSource subscriptionsource.SubscriptionSource, eventSource eventsource.EventSource, logForwarder logforwarder.LogForwarder, opts ...func(plane *ControlPlane)) *ControlPlane { + cp := &ControlPlane{ + subscriptionSource: subscriptionSource, + eventSource: eventSource, + currentSubscriptions: []models.EventSubscription{}, + logger: logger.NewDefaultLogger(), + logForwarder: logForwarder, + registered: false, + } + for _, o := range opts { + o(cp) + } + return cp +} + +// Register is initially used to register the Keptn integration to the Control Plane +func (cp *ControlPlane) Register(ctx context.Context, integration Integration) error { + eventUpdates := make(chan types.EventUpdate) + subscriptionUpdates := make(chan []models.EventSubscription) + errC := make(chan error) + + var err error + registrationData := integration.RegistrationData() + cp.logger.Debugf("Registering integration %s", integration.RegistrationData().Name) + cp.integrationID, err = cp.subscriptionSource.Register(models.Integration(registrationData)) + if err != nil { + return fmt.Errorf("could not register integration: %w", err) + } + cp.logger.Debugf("Registered with integration ID %s", cp.integrationID) + registrationData.ID = cp.integrationID + + // WaitGroup used for synchronized shutdown of eventsource and subscription source + // during cancellation of the context + wg := &sync.WaitGroup{} + wg.Add(2) + + cp.logger.Debugf("Starting event source for integration ID %s", cp.integrationID) + if err := cp.eventSource.Start(ctx, registrationData, eventUpdates, errC, wg); err != nil { + return err + } + cp.logger.Debugf("Event source started with data: %+v", registrationData) + cp.logger.Debugf("Starting subscription source for integration ID %s", cp.integrationID) + if err := cp.subscriptionSource.Start(ctx, registrationData, subscriptionUpdates, errC, wg); err != nil { + return err + } + cp.logger.Debug("Subscription source started") + cp.registered = true + for { + select { + // event updates + case event := <-eventUpdates: + cp.logger.Debug("Got new event update") + err := cp.handle(ctx, event, integration) + if errors.Is(err, ErrEventHandleFatal) { + return err + } + + // subscription updates + case subscriptions := <-subscriptionUpdates: + cp.logger.Debugf("ControlPlane: Got a subscription update with %d subscriptions", len(subscriptions)) + cp.currentSubscriptions = subscriptions + cp.eventSource.OnSubscriptionUpdate(subscriptions) + + // control plane cancelled via context + case <-ctx.Done(): + cp.logger.Debug("Controlplane cancelled via context. Unregistering...") + wg.Wait() + cp.registered = false + return nil + + // control plane cancelled via error in either one of the sub components + case e := <-errC: + cp.logger.Debugf("Stopping control plane due to error: %v", e) + cp.cleanup() + cp.logger.Debug("Waiting for components to shutdown") + wg.Wait() + cp.registered = false + return nil + } + } +} + +// IsRegistered can be called to detect whether the controlPlane is registered and ready to receive events +func (cp *ControlPlane) IsRegistered() bool { + return cp.registered +} + +func (cp *ControlPlane) handle(ctx context.Context, eventUpdate types.EventUpdate, integration Integration) error { + cp.logger.Debugf("Received an event of type: %s", *eventUpdate.KeptnEvent.Type) + for _, subscription := range cp.currentSubscriptions { + if subscription.Event == eventUpdate.MetaData.Subject { + cp.logger.Debugf("Check if event matches subscription %s", subscription.ID) + matcher := eventmatcher.New(subscription) + if matcher.Matches(eventUpdate.KeptnEvent) { + cp.logger.Info("Forwarding matched event update: ", eventUpdate.KeptnEvent.ID) + if err := cp.forwardMatchedEvent(ctx, eventUpdate, integration, subscription); err != nil { + return err + } + } + } + } + return nil +} + +func (cp *ControlPlane) getSender(sender types.EventSender) types.EventSender { + if cp.logForwarder != nil { + return func(ce models.KeptnContextExtendedCE) error { + err := cp.logForwarder.Forward(ce, cp.integrationID) + if err != nil { + cp.logger.Warnf("could not forward event") + } + return sender(ce) + } + } else { + return sender + } +} + +func (cp *ControlPlane) forwardMatchedEvent(ctx context.Context, eventUpdate types.EventUpdate, integration Integration, subscription models.EventSubscription) error { + err := eventUpdate.KeptnEvent.AddTemporaryData( + tmpDataDistributorKey, + types.AdditionalSubscriptionData{ + SubscriptionID: subscription.ID, + }, + models.AddTemporaryDataOptions{ + OverwriteIfExisting: true, + }, + ) + if err != nil { + cp.logger.Warnf("Could not append subscription data to event: %v", err) + } + if err := integration.OnEvent(context.WithValue(ctx, types.EventSenderKey, cp.getSender(cp.eventSource.Sender())), eventUpdate.KeptnEvent); err != nil { + if errors.Is(err, ErrEventHandleFatal) { + cp.logger.Errorf("Fatal error during handling of event: %v", err) + return err + } + cp.logger.Warnf("Error during handling of event: %v", err) + } + return nil +} + +func (cp *ControlPlane) cleanup() { + cp.logger.Info("Stopping subscription source...") + if err := cp.subscriptionSource.Stop(); err != nil { + log.Fatalf("Unable to stop subscription source: %v", err) + } + cp.logger.Info("Stopping event source...") + if err := cp.eventSource.Stop(); err != nil { + log.Fatalf("Unable to stop event source: %v", err) + } +} diff --git a/pkg/sdk/connector/controlplane/controlplane_test.go b/pkg/sdk/connector/controlplane/controlplane_test.go new file mode 100644 index 00000000..c5db13fc --- /dev/null +++ b/pkg/sdk/connector/controlplane/controlplane_test.go @@ -0,0 +1,539 @@ +package controlplane + +import ( + "context" + "fmt" + "github.com/keptn/go-utils/pkg/sdk/connector/fake" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "reflect" + "sync" + "testing" + "time" + + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + "github.com/stretchr/testify/require" +) + +type ExampleIntegration struct { + OnEventFn func(ctx context.Context, ce models.KeptnContextExtendedCE) error + RegistrationDataFn func() types.RegistrationData +} + +func (e ExampleIntegration) OnEvent(ctx context.Context, ce models.KeptnContextExtendedCE) error { + if e.OnEventFn != nil { + return e.OnEventFn(ctx, ce) + } + panic("implement me") +} + +func (e ExampleIntegration) RegistrationData() types.RegistrationData { + if e.RegistrationDataFn != nil { + return e.RegistrationDataFn() + } + panic("implement me") +} + +type LogForwarderMock struct { + ForwardFn func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error +} + +func (l LogForwarderMock) Forward(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + if l.ForwardFn != nil { + return l.ForwardFn(keptnEvent, integrationID) + } + panic("implement me") +} + +func TestControlPlaneInitialRegistrationFails(t *testing.T) { + ssm := &fake.SubscriptionSourceMock{ + RegisterFn: func(integration models.Integration) (string, error) { + return "", fmt.Errorf("some err") + }, + } + esm := &fake.EventSourceMock{} + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + integration := ExampleIntegration{RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }} + err := New(ssm, esm, fm).Register(context.TODO(), integration) + require.Error(t, err) +} + +func TestControlPlaneEventSourceFailsToStart(t *testing.T) { + ssm := &fake.SubscriptionSourceMock{ + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + return fmt.Errorf("error occured") + }} + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + integration := ExampleIntegration{RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }} + err := New(ssm, esm, fm).Register(context.TODO(), integration) + require.Error(t, err) +} + +func TestControlPlaneSubscriptionSourceFailsToStart(t *testing.T) { + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + return fmt.Errorf("error occured") + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + return nil + }} + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + integration := ExampleIntegration{RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }} + err := New(ssm, esm, fm).Register(context.TODO(), integration) + require.Error(t, err) +} + +func TestControlPlaneInboundEventIsForwardedToIntegration(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + var integrationReceivedEvent models.KeptnContextExtendedCE + + mtx := &sync.Mutex{} + eventUpdate := types.EventUpdate{KeptnEvent: models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")}, MetaData: types.EventUpdateMetaData{Subject: "sh.keptn.event.echo.triggered"}} + + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + subsChan = c + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + eventChan = ces + return nil + }, + OnSubscriptionUpdateFn: func(strings []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + } + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + + controlPlane := New(ssm, esm, fm) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { + integrationReceivedEvent = ce + return nil + }, + } + go controlPlane.Register(context.TODO(), integration) + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + + subsChan <- []models.EventSubscription{{ID: "some-id", Event: "sh.keptn.event.echo.triggered", Filter: models.EventSubscriptionFilter{}}} + eventChan <- eventUpdate + + require.Eventually(t, func() bool { + mtx.Lock() + defer mtx.Unlock() + eventUpdate.KeptnEvent.Data = integrationReceivedEvent.Data + return reflect.DeepEqual(eventUpdate.KeptnEvent, integrationReceivedEvent) + }, time.Second, time.Millisecond*100) + + eventData := map[string]interface{}{} + err := integrationReceivedEvent.DataAs(&eventData) + require.Nil(t, err) + + require.Equal(t, map[string]interface{}{ + "temporaryData": map[string]interface{}{ + "distributor": map[string]interface{}{ + "subscriptionID": "some-id", + }, + }, + }, eventData) +} + +func TestControlPlaneInboundEventIsForwardedToIntegrationWithoutLogForwarder(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + var integrationReceivedEvent models.KeptnContextExtendedCE + eventUpdate := types.EventUpdate{KeptnEvent: models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")}, MetaData: types.EventUpdateMetaData{Subject: "sh.keptn.event.echo.triggered"}} + + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + subsChan = c + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + eventChan = ces + return nil + }, + OnSubscriptionUpdateFn: func(strings []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + } + + controlPlane := New(ssm, esm, nil) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { + integrationReceivedEvent = ce + return nil + }, + } + go controlPlane.Register(context.TODO(), integration) + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + + subsChan <- []models.EventSubscription{{ID: "some-id", Event: "sh.keptn.event.echo.triggered", Filter: models.EventSubscriptionFilter{}}} + eventChan <- eventUpdate + + require.Eventually(t, func() bool { + eventUpdate.KeptnEvent.Data = integrationReceivedEvent.Data + return reflect.DeepEqual(eventUpdate.KeptnEvent, integrationReceivedEvent) + }, time.Second, time.Millisecond*100) + + eventData := map[string]interface{}{} + err := integrationReceivedEvent.DataAs(&eventData) + require.Nil(t, err) + + require.Equal(t, map[string]interface{}{ + "temporaryData": map[string]interface{}{ + "distributor": map[string]interface{}{ + "subscriptionID": "some-id", + }, + }, + }, eventData) +} + +func TestControlPlaneIntegrationIDIsForwarded(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + var integrationReceivedEvent models.KeptnContextExtendedCE + eventUpdate := types.EventUpdate{KeptnEvent: models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")}, MetaData: types.EventUpdateMetaData{Subject: "sh.keptn.event.echo.triggered"}} + + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + if data.ID != "some-other-id" { + return fmt.Errorf("error occured") + } + subsChan = c + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-other-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + if data.ID != "some-other-id" { + return fmt.Errorf("error occured") + } + eventChan = ces + return nil + }, + OnSubscriptionUpdateFn: func(subscriptions []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + } + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + + controlPlane := New(ssm, esm, fm) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { + integrationReceivedEvent = ce + return nil + }, + } + go controlPlane.Register(context.TODO(), integration) + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + + subsChan <- []models.EventSubscription{{ID: "some-id", Event: "sh.keptn.event.echo.triggered", Filter: models.EventSubscriptionFilter{}}} + eventChan <- eventUpdate + + require.Eventually(t, func() bool { + eventUpdate.KeptnEvent.Data = integrationReceivedEvent.Data + return reflect.DeepEqual(eventUpdate.KeptnEvent, integrationReceivedEvent) + }, time.Second, time.Millisecond*100) + + eventData := map[string]interface{}{} + err := integrationReceivedEvent.DataAs(&eventData) + require.Nil(t, err) + + require.Equal(t, map[string]interface{}{ + "temporaryData": map[string]interface{}{ + "distributor": map[string]interface{}{ + "subscriptionID": "some-id", + }, + }, + }, eventData) +} + +func TestControlPlaneIntegrationOnEventThrowsIgnoreableError(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + var integrationReceivedEvent bool + + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + subsChan = c + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + eventChan = ces + return nil + }, + OnSubscriptionUpdateFn: func(subscriptions []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + } + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + + controlPlane := New(ssm, esm, fm) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { + integrationReceivedEvent = true + return fmt.Errorf("could not handle event: %w", fmt.Errorf("error occured")) + }, + } + var controlPlaneErr error + go func() { controlPlaneErr = controlPlane.Register(context.TODO(), integration) }() + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + + subsChan <- []models.EventSubscription{{ID: "some-id", Event: "sh.keptn.event.echo.triggered", Filter: models.EventSubscriptionFilter{}}} + eventChan <- types.EventUpdate{KeptnEvent: models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")}, MetaData: types.EventUpdateMetaData{Subject: "sh.keptn.event.echo.triggered"}} + + require.Eventually(t, func() bool { return integrationReceivedEvent }, time.Second, time.Millisecond*100) + require.Never(t, func() bool { return controlPlaneErr != nil }, time.Second, time.Millisecond*100) +} + +func TestControlPlaneIntegrationOnEventThrowsFatalError(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + var integrationReceivedEvent bool + + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + subsChan = c + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + eventChan = ces + return nil + }, + OnSubscriptionUpdateFn: func(subscriptions []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + } + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + + controlPlane := New(ssm, esm, fm) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { + integrationReceivedEvent = true + return fmt.Errorf("could not handle event: %w", ErrEventHandleFatal) + }, + } + var controlPlaneErr error + go func() { controlPlaneErr = controlPlane.Register(context.TODO(), integration) }() + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + + subsChan <- []models.EventSubscription{{ID: "some-id", Event: "sh.keptn.event.echo.triggered", Filter: models.EventSubscriptionFilter{}}} + eventChan <- types.EventUpdate{KeptnEvent: models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")}, MetaData: types.EventUpdateMetaData{Subject: "sh.keptn.event.echo.triggered"}} + + require.Eventually(t, func() bool { return integrationReceivedEvent }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return controlPlaneErr != nil }, time.Second, time.Millisecond*100) +} + +func TestControlPlane_IsRegistered(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + subsChan = c + go func() { + <-ctx.Done() + wg.Done() + }() + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-id", nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, ces chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + eventChan = ces + go func() { + <-ctx.Done() + wg.Done() + }() + return nil + }, + OnSubscriptionUpdateFn: func(subscriptions []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + } + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + + controlPlane := New(ssm, esm, fm) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { + return nil + }, + } + ctx, cancel := context.WithCancel(context.TODO()) + + require.False(t, controlPlane.IsRegistered()) + + go func() { _ = controlPlane.Register(ctx, integration) }() + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + require.True(t, controlPlane.IsRegistered()) + + cancel() + + require.Eventually(t, func() bool { + return !controlPlane.IsRegistered() + }, time.Second, 100*time.Millisecond) +} + +func TestControlPlane_StoppedByReceivingErrEvent(t *testing.T) { + var eventChan chan types.EventUpdate + var subsChan chan []models.EventSubscription + var errorC chan error + var eventSourceStopCalled bool + var subscriptionSourceStopCalled bool + //var integrationReceivedEvent models.KeptnContextExtendedCE + //eventUpdate := types.EventUpdate{KeptnEvent: models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")}, MetaData: types.EventUpdateMetaData{Subject: "sh.keptn.event.echo.triggered"}} + callBackSender := func(ce models.KeptnContextExtendedCE) error { return nil } + + ssm := &fake.SubscriptionSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, subC chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + subsChan = subC + errorC = errC + return nil + }, + RegisterFn: func(integration models.Integration) (string, error) { + return "some-other-id", nil + }, + StopFn: func() error { + subscriptionSourceStopCalled = true + return nil + }, + } + esm := &fake.EventSourceMock{ + StartFn: func(ctx context.Context, data types.RegistrationData, evC chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + eventChan = evC + errorC = errC + go func() { + errorC <- fmt.Errorf("LKJ") //SOMETHING WENT WRONG + }() + return nil + }, + OnSubscriptionUpdateFn: func(subscriptions []models.EventSubscription) {}, + SenderFn: func() types.EventSender { return callBackSender }, + StopFn: func() error { + eventSourceStopCalled = true + return nil + }, + } + fm := &LogForwarderMock{ + ForwardFn: func(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + return nil + }, + } + + controlPlane := New(ssm, esm, fm) + + integration := ExampleIntegration{ + RegistrationDataFn: func() types.RegistrationData { return types.RegistrationData{} }, + OnEventFn: func(ctx context.Context, ce models.KeptnContextExtendedCE) error { return nil }, + } + + go controlPlane.Register(context.TODO(), integration) + require.Eventually(t, func() bool { return subsChan != nil }, time.Second, time.Millisecond*100) + require.Eventually(t, func() bool { return eventChan != nil }, time.Second, time.Millisecond*100) + + go func() { + fmt.Println("printing to channel") + fmt.Println(errorC) + errorC <- fmt.Errorf("some-error") + }() + + fmt.Println("waiting for err on channel") + <-errorC + + require.Eventually(t, func() bool { + return subscriptionSourceStopCalled && eventSourceStopCalled + }, time.Second, 100*time.Millisecond) +} diff --git a/pkg/sdk/connector/eventmatcher/eventmatcher.go b/pkg/sdk/connector/eventmatcher/eventmatcher.go new file mode 100644 index 00000000..f9db9f4c --- /dev/null +++ b/pkg/sdk/connector/eventmatcher/eventmatcher.go @@ -0,0 +1,43 @@ +package eventmatcher + +import ( + "strings" + + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/sliceutils" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" +) + +// EventMatcher is used to check whether an event contains is containing information +// about a specif event, stage or service +type EventMatcher struct { + Project string + Stage string + Service string +} + +// New creates a new EventMatcher that is configured +// with information about project, stage and service filter contained in an event subscription +func New(subscription models.EventSubscription) *EventMatcher { + return &EventMatcher{ + Project: strings.Join(subscription.Filter.Projects, ","), + Stage: strings.Join(subscription.Filter.Stages, ","), + Service: strings.Join(subscription.Filter.Services, ","), + } +} + +// Matches checks whether a Keptn event matches the information of the currently configured +// EventMatcher +func (ef EventMatcher) Matches(e models.KeptnContextExtendedCE) bool { + generalEventData := &v0_2_0.EventData{} + if err := e.DataAs(generalEventData); err != nil { + return false + } + + if ef.Project != "" && !sliceutils.ContainsStr(strings.Split(ef.Project, ","), generalEventData.Project) || + ef.Stage != "" && !sliceutils.ContainsStr(strings.Split(ef.Stage, ","), generalEventData.Stage) || + ef.Service != "" && !sliceutils.ContainsStr(strings.Split(ef.Service, ","), generalEventData.Service) { + return false + } + return true +} diff --git a/pkg/sdk/connector/eventmatcher/eventmatcher_test.go b/pkg/sdk/connector/eventmatcher/eventmatcher_test.go new file mode 100644 index 00000000..13353b11 --- /dev/null +++ b/pkg/sdk/connector/eventmatcher/eventmatcher_test.go @@ -0,0 +1,266 @@ +package eventmatcher + +import ( + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/stretchr/testify/require" + "testing" +) + +func TestEventMatcherUnableToDecodeEventData(t *testing.T) { + require.False(t, EventMatcher{}.Matches(models.KeptnContextExtendedCE{Data: 0})) +} + +func TestEventMatcher_Matches(t *testing.T) { + type fields struct { + Project string + Stage string + Service string + } + type args struct { + e models.KeptnContextExtendedCE + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + name: "no filter", + fields: fields{ + Project: "", + Stage: "", + Service: "", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "exact filter", + fields: fields{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "partial filter project", + fields: fields{ + Project: "pr1", + Stage: "", + Service: "", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "partial filter stage", + fields: fields{ + Project: "", + Stage: "st1", + Service: "", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "partial filter service", + fields: fields{ + Project: "", + Stage: "", + Service: "sv1", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "partial filter project stage", + fields: fields{ + Project: "pr1", + Stage: "st1", + Service: "", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "partial filter project service", + fields: fields{ + Project: "pr1", + Stage: "", + Service: "sv1", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "partial filter stage service", + fields: fields{ + Project: "", + Stage: "st1", + Service: "sv1", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: true, + }, + { + name: "full filter project - mismatch", + fields: fields{ + Project: "pr2", + Stage: "st1", + Service: "sv1", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: false, + }, + { + name: "full filter stage - mismatch", + fields: fields{ + Project: "pr1", + Stage: "st2", + Service: "sv1", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: false, + }, + { + name: "full filter service - mismatch", + fields: fields{ + Project: "pr1", + Stage: "st1", + Service: "sv2", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: false, + }, + { + name: "partial filter project - mismatch", + fields: fields{ + Project: "pr2", + Stage: "", + Service: "", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: false, + }, + { + name: "partial filter stage - mismatch", + fields: fields{ + Project: "", + Stage: "st2", + Service: "", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: false, + }, + { + name: "partial filter service - mismatch", + fields: fields{ + Project: "", + Stage: "", + Service: "sv2", + }, + args: args{ + e: models.KeptnContextExtendedCE{Data: v0_2_0.EventData{ + Project: "pr1", + Stage: "st1", + Service: "sv1", + }}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ef := EventMatcher{ + Project: tt.fields.Project, + Stage: tt.fields.Stage, + Service: tt.fields.Service, + } + if got := ef.Matches(tt.args.e); got != tt.want { + t.Errorf("Matches() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/sdk/connector/eventsource/eventsource.go b/pkg/sdk/connector/eventsource/eventsource.go new file mode 100644 index 00000000..190ee9a5 --- /dev/null +++ b/pkg/sdk/connector/eventsource/eventsource.go @@ -0,0 +1,23 @@ +package eventsource + +import ( + "context" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" +) + +// EventSource is anything that can be used +// to get events from the Keptn Control Plane +type EventSource interface { + // Start triggers the execution of the EventSource + Start(context.Context, types.RegistrationData, chan types.EventUpdate, chan error, *sync.WaitGroup) error + // OnSubscriptionUpdate can be called to tell the EventSource that + // the current subscriptions have been changed + OnSubscriptionUpdate([]models.EventSubscription) + // Sender returns a component that gives the possiblity to send events back + // to the Keptn Control plane + Sender() types.EventSender + //Stop is stopping the EventSource + Stop() error +} diff --git a/pkg/sdk/connector/eventsource/http/eventapi.go b/pkg/sdk/connector/eventsource/http/eventapi.go new file mode 100644 index 00000000..bf45f8ec --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/eventapi.go @@ -0,0 +1,120 @@ +package http + +import ( + "fmt" + "github.com/avast/retry-go" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" + "time" +) + +//go:generate moq -pkg fake -skip-ensure -out ./fake/eventAPI.go . EventAPI +type EventAPI interface { + Send(models.KeptnContextExtendedCE) error + Get(api.EventFilter) ([]*models.KeptnContextExtendedCE, error) +} + +//go:generate moq -pkg fake -skip-ensure -out ./fake/sendEventAPI.go . SendEventAPI +type SendEventAPI interface { + SendEvent(models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) +} + +//go:generate moq -pkg fake -skip-ensure -out ./fake/getEventAPI.go . GetEventAPI +type GetEventAPI interface { + GetOpenTriggeredEvents(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) +} + +const ( + defaultSendRetryAttempts = uint(3) + defaultGetRetryAttempts = uint(3) + defaultSendRetryDelay = time.Second * 3 + defaultGetRetryDelay = time.Second * 3 +) + +type HTTPEventAPI struct { + eventGetterAPI GetEventAPI + eventSenderAPI SendEventAPI + maxGetRetries uint + getRetryDelay time.Duration + maxSendRetries uint + sendRetryDelay time.Duration + logger logger.Logger +} + +func WithMaxGetRetries(r uint) func(eventAPI *HTTPEventAPI) { + return func(eventAPI *HTTPEventAPI) { + eventAPI.maxGetRetries = r + } +} + +func WithMaxSendRetries(r uint) func(eventAPI *HTTPEventAPI) { + return func(eventAPI *HTTPEventAPI) { + eventAPI.maxSendRetries = r + } +} + +func WithSendRetryDelay(d time.Duration) func(eventAPI *HTTPEventAPI) { + return func(eventAPI *HTTPEventAPI) { + eventAPI.sendRetryDelay = d + } +} + +func WithGetRetryDelay(d time.Duration) func(eventAPI *HTTPEventAPI) { + return func(eventAPI *HTTPEventAPI) { + eventAPI.getRetryDelay = d + } +} + +// TODO: this should be called Withlogger +func WithLog(logger logger.Logger) func(eventAPI *HTTPEventAPI) { + return func(eventAPI *HTTPEventAPI) { + eventAPI.logger = logger + } +} + +func NewEventAPI(getAPI GetEventAPI, sendAPI SendEventAPI, options ...func(eventAPI *HTTPEventAPI)) *HTTPEventAPI { + a := &HTTPEventAPI{ + eventGetterAPI: getAPI, + eventSenderAPI: sendAPI, + maxGetRetries: defaultGetRetryAttempts, + maxSendRetries: defaultSendRetryAttempts, + getRetryDelay: defaultGetRetryDelay, + sendRetryDelay: defaultSendRetryDelay, + logger: logger.NewDefaultLogger(), + } + for _, o := range options { + o(a) + } + return a +} + +func (ea *HTTPEventAPI) Send(e models.KeptnContextExtendedCE) error { + + err := retry.Do(func() error { + if _, err := ea.eventSenderAPI.SendEvent(e); err != nil { + msg := "Unable to send event" + if err.GetMessage() != "" { + msg = msg + ": " + err.GetMessage() + } + ea.logger.Warnf(msg) + return fmt.Errorf(msg) + } + return nil + }, retry.Attempts(ea.maxSendRetries), retry.Delay(ea.sendRetryDelay), retry.DelayType(retry.FixedDelay)) + + return err +} + +func (ea *HTTPEventAPI) Get(filter api.EventFilter) (events []*models.KeptnContextExtendedCE, err error) { + err = retry.Do(func() error { + events, err = ea.eventGetterAPI.GetOpenTriggeredEvents(filter) + if err != nil { + msg := fmt.Sprintf("Unable to get events: %s", err.Error()) + ea.logger.Warn(msg) + return fmt.Errorf(msg) + } + return nil + }, retry.Attempts(ea.maxGetRetries), retry.Delay(ea.getRetryDelay), retry.DelayType(retry.FixedDelay)) + return +} diff --git a/pkg/sdk/connector/eventsource/http/eventapi_test.go b/pkg/sdk/connector/eventsource/http/eventapi_test.go new file mode 100644 index 00000000..63591805 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/eventapi_test.go @@ -0,0 +1,103 @@ +package http + +import ( + "fmt" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/common/strutils" + "github.com/keptn/go-utils/pkg/sdk/connector/eventsource/http/fake" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestHTTPEventAPI_GetSend(t *testing.T) { + getEventAPI := &fake.GetEventAPIMock{} + sendEventAPI := &fake.SendEventAPIMock{} + + t.Run("send succeeds", func(*testing.T) { + sendAttempts := 0 + sendEventAPI.SendEventFunc = func(keptnContextExtendedCE models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) { + sendAttempts++ + return &models.EventContext{}, nil + } + eventAPI := NewEventAPI(getEventAPI, sendEventAPI, WithSendRetryDelay(time.Millisecond)) + err := eventAPI.Send(models.KeptnContextExtendedCE{}) + require.NoError(t, err) + require.Equal(t, 1, sendAttempts) + }) + + t.Run("get succeeds", func(*testing.T) { + getAttempts := 0 + getEventAPI.GetOpenTriggeredEventsFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + getAttempts++ + return []*models.KeptnContextExtendedCE{{ID: "id"}}, nil + } + eventAPI := NewEventAPI(getEventAPI, sendEventAPI, WithSendRetryDelay(time.Millisecond)) + events, err := eventAPI.Get(api.EventFilter{}) + require.Len(t, events, 1) + require.NoError(t, err) + require.Equal(t, 1, getAttempts) + }) + +} + +func TestHTTPEventAPI_GetSendFails(t *testing.T) { + getEventAPI := &fake.GetEventAPIMock{} + sendEventAPI := &fake.SendEventAPIMock{} + + t.Run("send event fails - default number of retries", func(*testing.T) { + sendAttempts := uint(0) + sendEventAPI.SendEventFunc = func(keptnContextExtendedCE models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) { + sendAttempts++ + return nil, &models.Error{ + Message: strutils.Stringp("just failed"), + } + } + sendAttempts = 0 + eventAPI := NewEventAPI(getEventAPI, sendEventAPI, WithSendRetryDelay(time.Millisecond)) + err := eventAPI.Send(models.KeptnContextExtendedCE{}) + require.Error(t, err) + require.Equal(t, defaultSendRetryAttempts, sendAttempts) + }) + + t.Run("send event fails - custom number of retries applied", func(*testing.T) { + sendAttempts := 0 + sendEventAPI.SendEventFunc = func(keptnContextExtendedCE models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) { + sendAttempts++ + return nil, &models.Error{ + Message: strutils.Stringp("just failed"), + } + } + eventAPI := NewEventAPI(getEventAPI, sendEventAPI, WithSendRetryDelay(time.Millisecond), WithMaxSendRetries(10)) + err := eventAPI.Send(models.KeptnContextExtendedCE{}) + require.Error(t, err) + require.Equal(t, 10, sendAttempts) + }) + + t.Run("get events fails - default number of retries", func(*testing.T) { + getAttempts := uint(0) + getEventAPI.GetOpenTriggeredEventsFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + getAttempts++ + return nil, fmt.Errorf("fail") + } + eventAPI := NewEventAPI(getEventAPI, sendEventAPI, WithGetRetryDelay(time.Millisecond)) + events, err := eventAPI.Get(api.EventFilter{}) + require.Error(t, err) + require.Nil(t, events) + require.Equal(t, defaultGetRetryAttempts, getAttempts) + }) + + t.Run("get events fails - custom number of retries applied", func(*testing.T) { + getAttempts := 0 + getEventAPI.GetOpenTriggeredEventsFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + getAttempts++ + return nil, fmt.Errorf("fail") + } + eventAPI := NewEventAPI(getEventAPI, sendEventAPI, WithGetRetryDelay(time.Millisecond), WithMaxGetRetries(10)) + events, err := eventAPI.Get(api.EventFilter{}) + require.Error(t, err) + require.Nil(t, events) + require.Equal(t, 10, getAttempts) + }) +} diff --git a/pkg/sdk/connector/eventsource/http/fake/eventAPI.go b/pkg/sdk/connector/eventsource/http/fake/eventAPI.go new file mode 100644 index 00000000..a3221435 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/fake/eventAPI.go @@ -0,0 +1,114 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package fake + +import ( + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "sync" +) + +// EventAPIMock is a mock implementation of http.EventAPI. +// +// func TestSomethingThatUsesEventAPI(t *testing.T) { +// +// // make and configure a mocked http.EventAPI +// mockedEventAPI := &EventAPIMock{ +// GetFunc: func(eventFilter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { +// panic("mock out the Get method") +// }, +// SendFunc: func(keptnContextExtendedCE models.KeptnContextExtendedCE) error { +// panic("mock out the Send method") +// }, +// } +// +// // use mockedEventAPI in code that requires http.EventAPI +// // and then make assertions. +// +// } +type EventAPIMock struct { + // GetFunc mocks the Get method. + GetFunc func(eventFilter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) + + // SendFunc mocks the Send method. + SendFunc func(keptnContextExtendedCE models.KeptnContextExtendedCE) error + + // calls tracks calls to the methods. + calls struct { + // Get holds details about calls to the Get method. + Get []struct { + // EventFilter is the eventFilter argument value. + EventFilter api.EventFilter + } + // Send holds details about calls to the Send method. + Send []struct { + // KeptnContextExtendedCE is the keptnContextExtendedCE argument value. + KeptnContextExtendedCE models.KeptnContextExtendedCE + } + } + lockGet sync.RWMutex + lockSend sync.RWMutex +} + +// Get calls GetFunc. +func (mock *EventAPIMock) Get(eventFilter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + if mock.GetFunc == nil { + panic("EventAPIMock.GetFunc: method is nil but EventAPI.Get was just called") + } + callInfo := struct { + EventFilter api.EventFilter + }{ + EventFilter: eventFilter, + } + mock.lockGet.Lock() + mock.calls.Get = append(mock.calls.Get, callInfo) + mock.lockGet.Unlock() + return mock.GetFunc(eventFilter) +} + +// GetCalls gets all the calls that were made to Get. +// Check the length with: +// len(mockedEventAPI.GetCalls()) +func (mock *EventAPIMock) GetCalls() []struct { + EventFilter api.EventFilter +} { + var calls []struct { + EventFilter api.EventFilter + } + mock.lockGet.RLock() + calls = mock.calls.Get + mock.lockGet.RUnlock() + return calls +} + +// Send calls SendFunc. +func (mock *EventAPIMock) Send(keptnContextExtendedCE models.KeptnContextExtendedCE) error { + if mock.SendFunc == nil { + panic("EventAPIMock.SendFunc: method is nil but EventAPI.Send was just called") + } + callInfo := struct { + KeptnContextExtendedCE models.KeptnContextExtendedCE + }{ + KeptnContextExtendedCE: keptnContextExtendedCE, + } + mock.lockSend.Lock() + mock.calls.Send = append(mock.calls.Send, callInfo) + mock.lockSend.Unlock() + return mock.SendFunc(keptnContextExtendedCE) +} + +// SendCalls gets all the calls that were made to Send. +// Check the length with: +// len(mockedEventAPI.SendCalls()) +func (mock *EventAPIMock) SendCalls() []struct { + KeptnContextExtendedCE models.KeptnContextExtendedCE +} { + var calls []struct { + KeptnContextExtendedCE models.KeptnContextExtendedCE + } + mock.lockSend.RLock() + calls = mock.calls.Send + mock.lockSend.RUnlock() + return calls +} diff --git a/pkg/sdk/connector/eventsource/http/fake/getEventAPI.go b/pkg/sdk/connector/eventsource/http/fake/getEventAPI.go new file mode 100644 index 00000000..a0167e41 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/fake/getEventAPI.go @@ -0,0 +1,71 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package fake + +import ( + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "sync" +) + +// GetEventAPIMock is a mock implementation of http.GetEventAPI. +// +// func TestSomethingThatUsesGetEventAPI(t *testing.T) { +// +// // make and configure a mocked http.GetEventAPI +// mockedGetEventAPI := &GetEventAPIMock{ +// GetOpenTriggeredEventsFunc: func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { +// panic("mock out the GetOpenTriggeredEvents method") +// }, +// } +// +// // use mockedGetEventAPI in code that requires http.GetEventAPI +// // and then make assertions. +// +// } +type GetEventAPIMock struct { + // GetOpenTriggeredEventsFunc mocks the GetOpenTriggeredEvents method. + GetOpenTriggeredEventsFunc func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) + + // calls tracks calls to the methods. + calls struct { + // GetOpenTriggeredEvents holds details about calls to the GetOpenTriggeredEvents method. + GetOpenTriggeredEvents []struct { + // Filter is the filter argument value. + Filter api.EventFilter + } + } + lockGetOpenTriggeredEvents sync.RWMutex +} + +// GetOpenTriggeredEvents calls GetOpenTriggeredEventsFunc. +func (mock *GetEventAPIMock) GetOpenTriggeredEvents(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + if mock.GetOpenTriggeredEventsFunc == nil { + panic("GetEventAPIMock.GetOpenTriggeredEventsFunc: method is nil but GetEventAPI.GetOpenTriggeredEvents was just called") + } + callInfo := struct { + Filter api.EventFilter + }{ + Filter: filter, + } + mock.lockGetOpenTriggeredEvents.Lock() + mock.calls.GetOpenTriggeredEvents = append(mock.calls.GetOpenTriggeredEvents, callInfo) + mock.lockGetOpenTriggeredEvents.Unlock() + return mock.GetOpenTriggeredEventsFunc(filter) +} + +// GetOpenTriggeredEventsCalls gets all the calls that were made to GetOpenTriggeredEvents. +// Check the length with: +// len(mockedGetEventAPI.GetOpenTriggeredEventsCalls()) +func (mock *GetEventAPIMock) GetOpenTriggeredEventsCalls() []struct { + Filter api.EventFilter +} { + var calls []struct { + Filter api.EventFilter + } + mock.lockGetOpenTriggeredEvents.RLock() + calls = mock.calls.GetOpenTriggeredEvents + mock.lockGetOpenTriggeredEvents.RUnlock() + return calls +} diff --git a/pkg/sdk/connector/eventsource/http/fake/sendEventAPI.go b/pkg/sdk/connector/eventsource/http/fake/sendEventAPI.go new file mode 100644 index 00000000..080d8a5b --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/fake/sendEventAPI.go @@ -0,0 +1,70 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package fake + +import ( + "github.com/keptn/go-utils/pkg/api/models" + "sync" +) + +// SendEventAPIMock is a mock implementation of http.SendEventAPI. +// +// func TestSomethingThatUsesSendEventAPI(t *testing.T) { +// +// // make and configure a mocked http.SendEventAPI +// mockedSendEventAPI := &SendEventAPIMock{ +// SendEventFunc: func(keptnContextExtendedCE models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) { +// panic("mock out the SendEvent method") +// }, +// } +// +// // use mockedSendEventAPI in code that requires http.SendEventAPI +// // and then make assertions. +// +// } +type SendEventAPIMock struct { + // SendEventFunc mocks the SendEvent method. + SendEventFunc func(keptnContextExtendedCE models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) + + // calls tracks calls to the methods. + calls struct { + // SendEvent holds details about calls to the SendEvent method. + SendEvent []struct { + // KeptnContextExtendedCE is the keptnContextExtendedCE argument value. + KeptnContextExtendedCE models.KeptnContextExtendedCE + } + } + lockSendEvent sync.RWMutex +} + +// SendEvent calls SendEventFunc. +func (mock *SendEventAPIMock) SendEvent(keptnContextExtendedCE models.KeptnContextExtendedCE) (*models.EventContext, *models.Error) { + if mock.SendEventFunc == nil { + panic("SendEventAPIMock.SendEventFunc: method is nil but SendEventAPI.SendEvent was just called") + } + callInfo := struct { + KeptnContextExtendedCE models.KeptnContextExtendedCE + }{ + KeptnContextExtendedCE: keptnContextExtendedCE, + } + mock.lockSendEvent.Lock() + mock.calls.SendEvent = append(mock.calls.SendEvent, callInfo) + mock.lockSendEvent.Unlock() + return mock.SendEventFunc(keptnContextExtendedCE) +} + +// SendEventCalls gets all the calls that were made to SendEvent. +// Check the length with: +// len(mockedSendEventAPI.SendEventCalls()) +func (mock *SendEventAPIMock) SendEventCalls() []struct { + KeptnContextExtendedCE models.KeptnContextExtendedCE +} { + var calls []struct { + KeptnContextExtendedCE models.KeptnContextExtendedCE + } + mock.lockSendEvent.RLock() + calls = mock.calls.SendEvent + mock.lockSendEvent.RUnlock() + return calls +} diff --git a/pkg/sdk/connector/eventsource/http/http.go b/pkg/sdk/connector/eventsource/http/http.go new file mode 100644 index 00000000..67b90ee6 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/http.go @@ -0,0 +1,164 @@ +package http + +import ( + "context" + "errors" + "github.com/benbjohnson/clock" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" + "time" +) + +var ErrMaxPollRetriesExceeded = errors.New("maximum retries for polling event api exceeded") + +//go:generate moq -pkg fake -skip-ensure -out ../../fake/shipyardeventapi.go . shipyardEventAPI:ShipyardEventAPIMock +type shipyardEventAPI api.ShipyardControlV1Interface + +// WithLogger sets the logger to use +func WithLogger(logger logger.Logger) func(plane *HTTPEventSource) { + return func(ns *HTTPEventSource) { + ns.logger = logger + } +} + +// WithMaxPollingAttempts sets the max number of attempts the HTTPEventSource shall retry to poll for new +// events when failing +func WithMaxPollingAttempts(maxPollingAttempts int) func(plane *HTTPEventSource) { + return func(ns *HTTPEventSource) { + ns.maxAttempts = maxPollingAttempts + } +} + +// WithPollingInterval sets the interval between doing consecutive HTTP calls to the Keptn API to get new events +func WithPollingInterval(interval time.Duration) func(plane *HTTPEventSource) { + return func(ns *HTTPEventSource) { + ns.pollInterval = interval + } +} + +// New creates a new HTTPEventSource to be used for running a service on the remote execution plane +func New(clock clock.Clock, eventGetSender EventAPI, opts ...func(source *HTTPEventSource)) *HTTPEventSource { + e := &HTTPEventSource{ + mutex: &sync.Mutex{}, + clock: clock, + eventAPI: eventGetSender, + currentSubscriptions: []models.EventSubscription{}, + pollInterval: time.Second, + maxAttempts: 10, + quitC: make(chan struct{}, 1), + cache: NewCache(), + logger: logger.NewDefaultLogger(), + } + for _, o := range opts { + o(e) + } + return e +} + +type HTTPEventSource struct { + mutex *sync.Mutex + clock clock.Clock + eventAPI EventAPI + currentSubscriptions []models.EventSubscription + pollInterval time.Duration + maxAttempts int + quitC chan struct{} + cache *cache + logger logger.Logger +} + +func (hes *HTTPEventSource) Start(ctx context.Context, data types.RegistrationData, updates chan types.EventUpdate, errChan chan error, wg *sync.WaitGroup) error { + ticker := hes.clock.Ticker(time.Second) + go func() { + failedPolls := 1 + for { + select { + case <-ticker.C: + if err := hes.doPoll(updates); err != nil { + failedPolls++ + if failedPolls > hes.maxAttempts { + hes.logger.Errorf("Reached max number of attempts to poll for new events") + errChan <- ErrMaxPollRetriesExceeded + wg.Done() + return + } + } + case <-ctx.Done(): + close(updates) + wg.Done() + return + case <-hes.quitC: + close(updates) + wg.Done() + return + } + + } + }() + return nil +} + +func (hes *HTTPEventSource) OnSubscriptionUpdate(subscriptions []models.EventSubscription) { + hes.mutex.Lock() + defer hes.mutex.Unlock() + hes.currentSubscriptions = subscriptions +} + +func (hes *HTTPEventSource) Sender() types.EventSender { + return hes.eventAPI.Send +} + +func (hes *HTTPEventSource) Stop() error { + hes.quitC <- struct{}{} + return nil +} + +func (hes *HTTPEventSource) doPoll(eventUpdates chan types.EventUpdate) error { + hes.mutex.Lock() + subscriptions := hes.currentSubscriptions + hes.mutex.Unlock() + for _, sub := range subscriptions { + events, err := hes.eventAPI.Get(getEventFilterForSubscription(sub)) + if err != nil { + hes.logger.Warnf("Could not retrieve events of type %s: %s", sub.Event, err) + return err + } + for _, e := range events { + if hes.cache.contains(sub.ID, e.ID) { + continue + } + eventUpdates <- types.EventUpdate{ + KeptnEvent: *e, + MetaData: types.EventUpdateMetaData{Subject: sub.Event}, + } + hes.cache.Add(sub.ID, e.ID) + } + } + return nil +} + +// getEventFilterForSubscription returns the event filter for the subscription +// Per default, it only sets the event type of the subscription. +// If exactly one project, stage or service is specified respectively, they are included in the filter. +// However, this is only a (very) short term solution for the RBAC use case. +// In the long term, we should just pass the subscription ID in the request, since the backend knows the required filters associated with the subscription. +func getEventFilterForSubscription(subscription models.EventSubscription) api.EventFilter { + eventFilter := api.EventFilter{ + EventType: subscription.Event, + } + + if len(subscription.Filter.Projects) == 1 { + eventFilter.Project = subscription.Filter.Projects[0] + } + if len(subscription.Filter.Stages) == 1 { + eventFilter.Stage = subscription.Filter.Stages[0] + } + if len(subscription.Filter.Services) == 1 { + eventFilter.Service = subscription.Filter.Services[0] + } + + return eventFilter +} diff --git a/pkg/sdk/connector/eventsource/http/http_test.go b/pkg/sdk/connector/eventsource/http/http_test.go new file mode 100644 index 00000000..94a062e9 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/http_test.go @@ -0,0 +1,173 @@ +package http + +import ( + "context" + "fmt" + "github.com/benbjohnson/clock" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/common/strutils" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/keptn/go-utils/pkg/sdk/connector/eventsource/http/fake" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "github.com/stretchr/testify/require" + "sync" + "testing" + "time" +) + +func TestEventSourceCanBeStoppedViaContext(t *testing.T) { + eventGetSender := &fake.EventAPIMock{} + eventGetSender.GetFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + return []*models.KeptnContextExtendedCE{{ + Type: strutils.Stringp("sh.keptn.event.task.triggered"), + }}, nil + } + eventChan := make(chan types.EventUpdate) + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + err := New(clock.New(), eventGetSender).Start(ctx, types.RegistrationData{}, eventChan, make(chan error), wg) + require.NoError(t, err) + cancel() + <-eventChan + wg.Wait() +} + +func TestEventSourceCanBeStopped(t *testing.T) { + eventGetSender := &fake.EventAPIMock{} + eventGetSender.GetFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + return []*models.KeptnContextExtendedCE{{ + Type: strutils.Stringp("sh.keptn.event.task.triggered"), + }}, nil + } + eventChan := make(chan types.EventUpdate) + + wg := &sync.WaitGroup{} + wg.Add(1) + es := New(clock.New(), eventGetSender) + es.Start(context.TODO(), types.RegistrationData{}, eventChan, make(chan error), wg) + es.Stop() + <-eventChan + wg.Wait() +} + +func TestAPICallFailsAfterMaxAttempts(t *testing.T) { + eventGetSender := &fake.EventAPIMock{} + eventGetSender.GetFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + return nil, fmt.Errorf("error") + } + + eventChan := make(chan types.EventUpdate) + errChan := make(chan error) + eventsource := New(clock.New(), eventGetSender) + wg := &sync.WaitGroup{} + wg.Add(1) + eventsource.maxAttempts = 2 + + err := eventsource.Start(context.TODO(), types.RegistrationData{}, eventChan, errChan, wg) + eventsource.OnSubscriptionUpdate([]models.EventSubscription{{Event: "sh.keptn.event.task.triggered"}}) + require.NoError(t, err) + <-errChan + wg.Wait() +} + +func TestAPIReceiveEvents(t *testing.T) { + eventGetSender := &fake.EventAPIMock{} + eventGetSender.GetFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + return []*models.KeptnContextExtendedCE{ + { + Type: strutils.Stringp("sh.keptn.event.task.triggered"), + }, + { + Type: strutils.Stringp("sh.keptn.event.task2.triggered"), + }}, nil + } + clock := clock.NewMock() + eventsource := New(clock, eventGetSender) + eventChan := make(chan types.EventUpdate) + + err := eventsource.Start(context.TODO(), types.RegistrationData{}, eventChan, make(chan error), &sync.WaitGroup{}) + eventsource.OnSubscriptionUpdate([]models.EventSubscription{{ID: "id1", Event: "sh.keptn.event.task.triggered"}, {ID: "id2", Event: "sh.keptn.event.task2.triggered"}}) + require.NoError(t, err) + clock.Add(time.Second) + <-eventChan + clock.Add(time.Second) + <-eventChan +} + +func TestAPIReceiveEventsWithMoreAdvancedFilters(t *testing.T) { + eventGetSender := &fake.EventAPIMock{} + eventGetSender.GetFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + return []*models.KeptnContextExtendedCE{ + { + Data: v0_2_0.EventData{ + Project: "project1", + Stage: "stage1", + Service: "service1", + }, + Type: strutils.Stringp("sh.keptn.event.task.triggered"), + }, + { + Type: strutils.Stringp("sh.keptn.event.task2.triggered"), + }}, nil + } + clock := clock.NewMock() + eventsource := New(clock, eventGetSender) + eventChan := make(chan types.EventUpdate) + + err := eventsource.Start(context.TODO(), types.RegistrationData{}, eventChan, make(chan error), &sync.WaitGroup{}) + eventsource.OnSubscriptionUpdate([]models.EventSubscription{{ID: "id1", Event: "sh.keptn.event.task.triggered", Filter: models.EventSubscriptionFilter{ + Projects: []string{"project1"}, + Stages: []string{"stage1"}, + Services: []string{"service1"}, + }}}) + require.NoError(t, err) + clock.Add(time.Second) + <-eventChan +} + +func TestAPIPassEventOnlyOnce(t *testing.T) { + eventGetSender := &fake.EventAPIMock{} + eventGetSender.GetFunc = func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + return []*models.KeptnContextExtendedCE{ + { + Type: strutils.Stringp("sh.keptn.event.task.triggered"), + }, + }, nil + } + clock := clock.NewMock() + eventsource := New(clock, eventGetSender) + eventChan := make(chan types.EventUpdate) + + err := eventsource.Start(context.TODO(), types.RegistrationData{}, eventChan, make(chan error), &sync.WaitGroup{}) + eventsource.OnSubscriptionUpdate([]models.EventSubscription{{ID: "id1", Event: "sh.keptn.event.task.triggered"}}) + require.NoError(t, err) + + eventsReceived := 0 + go func() { + for { + <-eventChan + eventsReceived++ + } + }() + clock.Add(time.Second) + clock.Add(time.Second) + time.Sleep(time.Second) + require.Equal(t, 1, eventsReceived) +} + +func TestEventSourceGetSender(t *testing.T) { + senderCalled := false + sender := func(keptnContextExtendedCE models.KeptnContextExtendedCE) error { + senderCalled = true + return nil + } + eventGetSender := &fake.EventAPIMock{ + SendFunc: sender, + } + err := New(clock.New(), eventGetSender).Sender()(models.KeptnContextExtendedCE{}) + require.NoError(t, err) + require.True(t, senderCalled) + +} diff --git a/pkg/sdk/connector/eventsource/http/utils.go b/pkg/sdk/connector/eventsource/http/utils.go new file mode 100644 index 00000000..28efabf4 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/utils.go @@ -0,0 +1,150 @@ +package http + +import ( + "github.com/keptn/go-utils/pkg/api/models" + "sync" +) + +// cache is used to store key value data +type cache struct { + sync.RWMutex + cache map[string][]string +} + +// NewCache creates a new cache +func NewCache() *cache { + return &cache{ + cache: make(map[string][]string), + } +} + +// Add adds a new element for a given key to the cache +func (c *cache) Add(key, element string) { + c.Lock() + defer c.Unlock() + + eventsForTopic := c.cache[key] + for _, id := range eventsForTopic { + if id == element { + return + } + } + + c.cache[key] = append(c.cache[key], element) +} + +// Get returns all elements for a given key from the cache +func (c *cache) Get(key string) []string { + c.RLock() + defer c.RUnlock() + + cp := make([]string, len(c.cache[key])) + copy(cp, c.cache[key]) + return cp +} + +// Remove removes an element for a given key from the cache +func (c *cache) Remove(key, element string) bool { + c.Lock() + defer c.Unlock() + + eventsForTopic := c.cache[key] + for index, id := range eventsForTopic { + if id == element { + // found, make sure to store the result back in c.cache[key] + c.cache[key] = append(eventsForTopic[:index], eventsForTopic[index+1:]...) + return true + } + } + return false +} + +// Contains checks whether the given element for a topic name is contained in the cache +func (c *cache) Contains(key, element string) bool { + c.RLock() + defer c.RUnlock() + + return c.contains(key, element) +} + +// Keep deletes all elements for a topic from the cache except the ones given by events +func (c *cache) Keep(key string, elements []string) { + c.Lock() + defer c.Unlock() + + // keeping 0 elements, means clearing the cache + if len(elements) == 0 { + c.clear(key) + } + + // convert to raw ids without duplicates + ids := dedup(elements) + + // if none of the ids is known cached do nothing + if !c.containsSlice(key, ids) { + return + } + + currentEventsForTopic := c.cache[key] + eventsToKeep := []string{} + for _, idOfEventToKeep := range ids { + for _, e := range currentEventsForTopic { + if idOfEventToKeep == e { + eventsToKeep = append(eventsToKeep, e) + } + } + } + c.cache[key] = eventsToKeep +} + +// Lenghts returns the number of cached elements for a given topic +func (c *cache) Length(key string) int { + c.RLock() + defer c.RUnlock() + return len(c.cache[key]) +} + +func (c *cache) clear(key string) { + c.cache[key] = []string{} +} + +func (c *cache) contains(key, element string) bool { + eventsForTopic := c.cache[key] + for _, id := range eventsForTopic { + if id == element { + return true + } + } + return false +} + +func (c *cache) containsSlice(key string, elements []string) bool { + contains := false + for _, id := range elements { + if c.contains(key, id) { + contains = true + break + } + } + return contains +} + +func dedup(elements []string) []string { + result := make([]string, 0, len(elements)) + temp := map[string]struct{}{} + for _, el := range elements { + if _, ok := temp[el]; !ok { + temp[el] = struct{}{} + result = append(result, el) + } + } + return result +} + +func ToIds(events []*models.KeptnContextExtendedCE) []string { + ids := []string{} + for _, e := range events { + ids = append(ids, e.ID) + } + return ids +} diff --git a/pkg/sdk/connector/eventsource/http/utils_test.go b/pkg/sdk/connector/eventsource/http/utils_test.go new file mode 100644 index 00000000..a45ff8e1 --- /dev/null +++ b/pkg/sdk/connector/eventsource/http/utils_test.go @@ -0,0 +1,97 @@ +package http + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestAddEvent(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + cache.Add("t2", "e3") + + assert.True(t, cache.Contains("t1", "e1")) + assert.True(t, cache.Contains("t1", "e2")) + assert.False(t, cache.Contains("t1", "e3")) + assert.True(t, cache.Contains("t2", "e3")) +} + +func TestAddEventTwice(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + cache.Add("t1", "e2") + assert.Equal(t, 2, cache.Length("t1")) + assert.Equal(t, 2, len(cache.Get("t1"))) +} + +func TestAddRemoveEvent(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + cache.Add("t1", "e3") + + assert.Equal(t, 3, cache.Length("t1")) + + cache.Remove("t1", "e1") + assert.Equal(t, 2, cache.Length("t1")) + assert.True(t, cache.Contains("t1", "e2")) + assert.True(t, cache.Contains("t1", "e3")) + + cache.Remove("t1", "e3") + assert.Equal(t, 1, cache.Length("t1")) + assert.True(t, cache.Contains("t1", "e2")) +} + +func TestKeep_NonExistingEvent(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + cache.Add("t1", "e3") + + require.Equal(t, 3, cache.Length("t1")) + cache.Keep("t1", []string{"e0"}) + assert.Equal(t, 3, cache.Length("t1")) +} + +func TestKeep_WithDuplicates(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + + require.Equal(t, 2, cache.Length("t1")) + cache.Keep("t1", []string{"e2", "e2"}) + assert.Equal(t, 1, cache.Length("t1")) +} + +func TestKeep_WithEmptyEvents(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + + require.Equal(t, 2, cache.Length("t1")) + cache.Keep("t1", []string{}) + assert.Equal(t, 0, cache.Length("t1")) +} + +func TestKeep(t *testing.T) { + cache := NewCache() + cache.Add("t1", "e1") + cache.Add("t1", "e2") + cache.Add("t2", "e3") + cache.Add("t2", "e4") + cache.Add("t2", "e5") + + cache.Keep("t1", []string{"e2"}) + cache.Keep("t2", []string{"e3", "e5"}) + + assert.Equal(t, 1, cache.Length("t1")) + assert.Equal(t, 2, cache.Length("t2")) + assert.False(t, cache.Contains("t1", "e1")) + assert.True(t, cache.Contains("t1", "e2")) + assert.True(t, cache.Contains("t2", "e3")) + assert.False(t, cache.Contains("t2", "e4")) + assert.True(t, cache.Contains("t2", "e5")) +} diff --git a/pkg/sdk/connector/eventsource/nats/nats.go b/pkg/sdk/connector/eventsource/nats/nats.go new file mode 100644 index 00000000..4bef56bc --- /dev/null +++ b/pkg/sdk/connector/eventsource/nats/nats.go @@ -0,0 +1,130 @@ +package nats + +import ( + "context" + "encoding/json" + "fmt" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "reflect" + "sort" + "sync" + + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" + natseventsource "github.com/keptn/go-utils/pkg/sdk/connector/nats" + "github.com/nats-io/nats.go" +) + +// NATSEventSource is an implementation of EventSource +// that is using the NATS event broker internally +type NATSEventSource struct { + currentSubjects []string + connector natseventsource.NATS + eventProcessFn natseventsource.ProcessEventFn + queueGroup string + logger logger.Logger +} + +// New creates a new NATSEventSource +func New(natsConnector natseventsource.NATS, opts ...func(source *NATSEventSource)) *NATSEventSource { + e := &NATSEventSource{ + currentSubjects: []string{}, + connector: natsConnector, + eventProcessFn: func(event *nats.Msg) error { return nil }, + logger: logger.NewDefaultLogger(), + } + for _, o := range opts { + o(e) + } + return e +} + +// WithLogger sets the logger to use +func WithLogger(logger logger.Logger) func(*NATSEventSource) { + return func(ns *NATSEventSource) { + ns.logger = logger + } +} + +func (n *NATSEventSource) Start(ctx context.Context, registrationData types.RegistrationData, eventChannel chan types.EventUpdate, errChan chan error, wg *sync.WaitGroup) error { + n.queueGroup = registrationData.Name + n.eventProcessFn = func(event *nats.Msg) error { + keptnEvent := models.KeptnContextExtendedCE{} + if err := json.Unmarshal(event.Data, &keptnEvent); err != nil { + return fmt.Errorf("could not unmarshal message: %w", err) + } + eventChannel <- types.EventUpdate{ + KeptnEvent: keptnEvent, + MetaData: types.EventUpdateMetaData{event.Sub.Subject}, + } + return nil + } + if err := n.connector.QueueSubscribeMultiple(n.currentSubjects, n.queueGroup, n.eventProcessFn); err != nil { + return fmt.Errorf("could not start NATS event source: %w", err) + } + go func() { + defer wg.Done() + <-ctx.Done() + if err := n.connector.UnsubscribeAll(); err != nil { + n.logger.Errorf("Unable to unsubscribe from NATS: %v", err) + return + } + n.logger.Debug("Unsubscribed from NATS") + }() + return nil +} + +func (n *NATSEventSource) OnSubscriptionUpdate(subj []models.EventSubscription) { + s := dedup(subjects(subj)) + n.logger.Debugf("Updating subscriptions") + if !isEqual(n.currentSubjects, s) { + n.logger.Debugf("Cleaning up %d old subscriptions", len(n.currentSubjects)) + err := n.connector.UnsubscribeAll() + n.logger.Debug("Unsubscribed from previous subscriptions") + if err != nil { + n.logger.Errorf("Could not handle subscription update: %v", err) + return + } + n.logger.Debugf("Subscribing to %d topics", len(s)) + if err := n.connector.QueueSubscribeMultiple(s, n.queueGroup, n.eventProcessFn); err != nil { + n.logger.Errorf("Could not handle subscription update: %v", err) + return + } + n.currentSubjects = s + n.logger.Debugf("Subscription to %d topics successful", len(s)) + } +} + +func (n *NATSEventSource) Sender() types.EventSender { + return n.connector.Publish +} + +func (n *NATSEventSource) Stop() error { + return n.connector.Disconnect() +} + +func isEqual(a1 []string, a2 []string) bool { + sort.Strings(a2) + sort.Strings(a1) + return reflect.DeepEqual(a1, a2) +} + +func dedup(elements []string) []string { + result := make([]string, 0, len(elements)) + temp := map[string]struct{}{} + for _, el := range elements { + if _, ok := temp[el]; !ok { + temp[el] = struct{}{} + result = append(result, el) + } + } + return result +} + +func subjects(subscriptions []models.EventSubscription) []string { + var ret []string + for _, s := range subscriptions { + ret = append(ret, s.Event) + } + return ret +} diff --git a/pkg/sdk/connector/eventsource/nats/nats_test.go b/pkg/sdk/connector/eventsource/nats/nats_test.go new file mode 100644 index 00000000..eb4ca84c --- /dev/null +++ b/pkg/sdk/connector/eventsource/nats/nats_test.go @@ -0,0 +1,294 @@ +package nats + +import ( + "context" + "fmt" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" + "testing" + "time" + + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + nats2 "github.com/keptn/go-utils/pkg/sdk/connector/nats" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" +) + +type NATSConnectorMock struct { + SubscribeFn func(string, nats2.ProcessEventFn) error + QueueSubscribeFn func(string, string, nats2.ProcessEventFn) error + SubscribeMultipleFn func([]string, nats2.ProcessEventFn) error + QueueSubscribeMultipleFn func([]string, string, nats2.ProcessEventFn) error + QueueSubscribeMultipleCalls int + PublishFn func(ce models.KeptnContextExtendedCE) error + PublishCalls int + DisconnectFn func() error + DisconnectCalls int + UnsubscribeAllFn func() error + UnsubscribeAllCalls int + QueueGroup string + ProcessEventFn nats2.ProcessEventFn +} + +func (ncm *NATSConnectorMock) Subscribe(subject string, fn nats2.ProcessEventFn) error { + if ncm.SubscribeFn != nil { + return ncm.SubscribeFn(subject, fn) + } + panic("implement me") +} + +func (ncm *NATSConnectorMock) QueueSubscribe(subject string, queueGroup string, fn nats2.ProcessEventFn) error { + if ncm.QueueSubscribeFn != nil { + return ncm.QueueSubscribeFn(queueGroup, subject, fn) + } + panic("implement me") +} + +func (ncm *NATSConnectorMock) SubscribeMultiple(subjects []string, fn nats2.ProcessEventFn) error { + if ncm.SubscribeMultipleFn != nil { + return ncm.SubscribeMultipleFn(subjects, fn) + } + panic("implement me") +} + +func (ncm *NATSConnectorMock) QueueSubscribeMultiple(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { + ncm.ProcessEventFn = fn + ncm.QueueSubscribeMultipleCalls++ + + if ncm.QueueSubscribeMultipleFn != nil { + return ncm.QueueSubscribeMultipleFn(subjects, queueGroup, fn) + } + panic("implement me") +} + +func (ncm *NATSConnectorMock) Publish(event models.KeptnContextExtendedCE) error { + ncm.PublishCalls++ + if ncm.PublishFn != nil { + return ncm.PublishFn(event) + } + panic("implement me") +} + +func (ncm *NATSConnectorMock) Disconnect() error { + ncm.DisconnectCalls++ + if ncm.DisconnectFn != nil { + return ncm.DisconnectFn() + } + panic("implement me") +} + +func (ncm *NATSConnectorMock) UnsubscribeAll() error { + ncm.UnsubscribeAllCalls++ + if ncm.UnsubscribeAllFn != nil { + return ncm.UnsubscribeAllFn() + } + panic("implement me") +} + +func TestEventSourceForwardsEventToChannel(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return nil }, + } + eventChannel := make(chan types.EventUpdate) + eventSource := New(natsConnectorMock) + wg := &sync.WaitGroup{} + wg.Add(1) + eventSource.Start(context.TODO(), types.RegistrationData{}, eventChannel, make(chan error), wg) + eventSource.OnSubscriptionUpdate([]models.EventSubscription{{Event: "a"}}) + event := models.KeptnContextExtendedCE{ID: "id"} + jsonEvent, _ := event.ToJSON() + e := &nats.Msg{Data: jsonEvent, Sub: &nats.Subscription{Subject: "subscription"}} //models.KeptnContextExtendedCE{ID: "id"} + go natsConnectorMock.ProcessEventFn(e) + eventFromChan := <-eventChannel + require.Equal(t, eventFromChan.KeptnEvent, event) +} + +func TestEventSourceCancelDisconnectsFromBroker(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return nil }, + } + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + New(natsConnectorMock).Start(ctx, types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + cancel() + require.Eventually(t, func() bool { return natsConnectorMock.UnsubscribeAllCalls == 1 }, 2*time.Second, 100*time.Millisecond) +} + +func TestEventSourceCallsWaitGroupDuringCancellation(t *testing.T) { + t.Run("WaitGroup called", func(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return nil }, + } + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + New(natsConnectorMock).Start(ctx, types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + cancel() + wg.Wait() + }) + t.Run("WaitGroup called - error in shutdown logic", func(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return fmt.Errorf("ohoh") }, + } + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + New(natsConnectorMock).Start(ctx, types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + cancel() + wg.Wait() + }) +} + +func TestEventSourceCancelDisconnectFromBrokerFails(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return fmt.Errorf("error occured") }, + } + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + New(natsConnectorMock).Start(ctx, types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + cancel() + require.Eventually(t, func() bool { return natsConnectorMock.UnsubscribeAllCalls == 1 }, 2*time.Second, 100*time.Millisecond) +} + +func TestEventSourceQueueSubscribeFails(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{QueueSubscribeMultipleFn: func(strings []string, s string, fn nats2.ProcessEventFn) error { return fmt.Errorf("error occured") }} + eventSource := New(natsConnectorMock) + wg := &sync.WaitGroup{} + wg.Add(1) + + err := eventSource.Start(context.TODO(), types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + require.Error(t, err) +} + +func TestEventSourceOnSubscriptionUpdate(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return nil }, + } + eventSource := New(natsConnectorMock) + wg := &sync.WaitGroup{} + wg.Add(1) + + err := eventSource.Start(context.TODO(), types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + require.NoError(t, err) + require.Equal(t, 1, natsConnectorMock.QueueSubscribeMultipleCalls) + eventSource.OnSubscriptionUpdate([]models.EventSubscription{{Event: "a"}}) + require.Equal(t, 1, natsConnectorMock.UnsubscribeAllCalls) + require.Equal(t, 2, natsConnectorMock.QueueSubscribeMultipleCalls) +} + +func TestEventSourceOnSubscriptionupdateWithDuplicatedSubjects(t *testing.T) { + var receivedSubjects []string + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { + receivedSubjects = subjects + return nil + }, + UnsubscribeAllFn: func() error { return nil }, + } + eventSource := New(natsConnectorMock) + err := eventSource.Start(context.TODO(), types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), &sync.WaitGroup{}) + require.NoError(t, err) + require.Equal(t, 1, natsConnectorMock.QueueSubscribeMultipleCalls) + eventSource.OnSubscriptionUpdate([]models.EventSubscription{{Event: "a"}, {Event: "a"}}) + require.Equal(t, 1, natsConnectorMock.UnsubscribeAllCalls) + require.Equal(t, 2, natsConnectorMock.QueueSubscribeMultipleCalls) + require.Equal(t, 1, len(receivedSubjects)) +} + +func TestEventSourceOnSubscriptiOnUpdateUnsubscribeAllFails(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return fmt.Errorf("error occured") }, + } + eventSource := New(natsConnectorMock) + wg := &sync.WaitGroup{} + wg.Add(1) + + err := eventSource.Start(context.TODO(), types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + require.NoError(t, err) + require.Equal(t, 1, natsConnectorMock.QueueSubscribeMultipleCalls) + eventSource.OnSubscriptionUpdate([]models.EventSubscription{{Event: "a"}}) + require.Equal(t, 1, natsConnectorMock.UnsubscribeAllCalls) + require.Equal(t, 1, natsConnectorMock.QueueSubscribeMultipleCalls) +} + +func TestEventSourceOnSubscriptionUpdateQueueSubscribeMultipleFails(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + QueueSubscribeMultipleFn: func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { return nil }, + UnsubscribeAllFn: func() error { return nil }, + } + eventSource := New(natsConnectorMock) + wg := &sync.WaitGroup{} + wg.Add(1) + + err := eventSource.Start(context.TODO(), types.RegistrationData{}, make(chan types.EventUpdate), make(chan error), wg) + require.NoError(t, err) + require.Equal(t, 1, natsConnectorMock.QueueSubscribeMultipleCalls) + natsConnectorMock.QueueSubscribeMultipleFn = func(subjects []string, queueGroup string, fn nats2.ProcessEventFn) error { + return fmt.Errorf("error occured") + } + eventSource.OnSubscriptionUpdate([]models.EventSubscription{{Event: "a"}}) + require.Equal(t, 1, natsConnectorMock.UnsubscribeAllCalls) + require.Equal(t, 2, natsConnectorMock.QueueSubscribeMultipleCalls) +} + +func TestEventSourceGetSender(t *testing.T) { + event := models.KeptnContextExtendedCE{ID: "id", Type: strutils.Stringp("something")} + natsConnectorMock := &NATSConnectorMock{ + PublishFn: func(ce models.KeptnContextExtendedCE) error { + require.Equal(t, event, ce) + return nil + }, + } + sendFn := New(natsConnectorMock).Sender() + require.NotNil(t, sendFn) + err := sendFn(event) + require.NoError(t, err) + require.Equal(t, 1, natsConnectorMock.PublishCalls) +} + +func TestEventSourceSenderFails(t *testing.T) { + event := models.KeptnContextExtendedCE{ID: "id", Type: strutils.Stringp("something")} + natsConnectorMock := &NATSConnectorMock{ + PublishFn: func(ce models.KeptnContextExtendedCE) error { + require.Equal(t, event, ce) + return fmt.Errorf("error occured") + }, + } + sendFn := New(natsConnectorMock).Sender() + require.NotNil(t, sendFn) + err := sendFn(event) + require.Error(t, err) + require.Equal(t, 1, natsConnectorMock.PublishCalls) +} + +func TestEventSourceStopDisconnectsFromEventBroker(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + DisconnectFn: func() error { + return nil + }, + } + err := New(natsConnectorMock).Stop() + require.NoError(t, err) + require.Equal(t, 1, natsConnectorMock.DisconnectCalls) +} + +func TestEventSourceStopFails(t *testing.T) { + natsConnectorMock := &NATSConnectorMock{ + DisconnectFn: func() error { + return fmt.Errorf("error occured") + }, + } + err := New(natsConnectorMock).Stop() + require.Error(t, err) + require.Equal(t, 1, natsConnectorMock.DisconnectCalls) +} diff --git a/pkg/sdk/connector/fake/eventsource.go b/pkg/sdk/connector/fake/eventsource.go new file mode 100644 index 00000000..ffecdf6e --- /dev/null +++ b/pkg/sdk/connector/fake/eventsource.go @@ -0,0 +1,44 @@ +package fake + +import ( + "context" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" +) + +type EventSourceMock struct { + StartFn func(context.Context, types.RegistrationData, chan types.EventUpdate, chan error, *sync.WaitGroup) error + OnSubscriptionUpdateFn func([]models.EventSubscription) + SenderFn func() types.EventSender + StopFn func() error +} + +func (e *EventSourceMock) Start(ctx context.Context, data types.RegistrationData, eventC chan types.EventUpdate, errC chan error, wg *sync.WaitGroup) error { + if e.StartFn != nil { + return e.StartFn(ctx, data, eventC, errC, wg) + } + panic("implement me") +} + +func (e *EventSourceMock) OnSubscriptionUpdate(subscriptions []models.EventSubscription) { + if e.OnSubscriptionUpdateFn != nil { + e.OnSubscriptionUpdateFn(subscriptions) + return + } + panic("implement me") +} + +func (e *EventSourceMock) Sender() types.EventSender { + if e.SenderFn != nil { + return e.SenderFn() + } + panic("implement me") +} + +func (e *EventSourceMock) Stop() error { + if e.StopFn != nil { + return e.StopFn() + } + panic("implement me") +} diff --git a/pkg/sdk/connector/fake/logapi.go b/pkg/sdk/connector/fake/logapi.go new file mode 100644 index 00000000..921ee5ac --- /dev/null +++ b/pkg/sdk/connector/fake/logapi.go @@ -0,0 +1,236 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package fake + +import ( + "context" + "github.com/keptn/go-utils/pkg/api/models" + "sync" +) + +// LogAPIMock is a mock implementation of controlplane.logAPI. +// +// func TestSomethingThatUseslogAPI(t *testing.T) { +// +// // make and configure a mocked controlplane.logAPI +// mockedlogAPI := &LogAPIMock{ +// DeleteLogsFunc: func(filter models.LogFilter) error { +// panic("mock out the DeleteLogs method") +// }, +// FlushFunc: func() error { +// panic("mock out the Flush method") +// }, +// GetLogsFunc: func(params models.GetLogsParams) (*models.GetLogsResponse, error) { +// panic("mock out the GetLogs method") +// }, +// LogFunc: func(logs []models.LogEntry) { +// panic("mock out the Log method") +// }, +// StartFunc: func(ctx context.Context) { +// panic("mock out the Start method") +// }, +// } +// +// // use mockedlogAPI in code that requires controlplane.logAPI +// // and then make assertions. +// +// } +type LogAPIMock struct { + // DeleteLogsFunc mocks the DeleteLogs method. + DeleteLogsFunc func(filter models.LogFilter) error + + // FlushFunc mocks the Flush method. + FlushFunc func() error + + // GetLogsFunc mocks the GetLogs method. + GetLogsFunc func(params models.GetLogsParams) (*models.GetLogsResponse, error) + + // LogFunc mocks the Log method. + LogFunc func(logs []models.LogEntry) + + // StartFunc mocks the Start method. + StartFunc func(ctx context.Context) + + // calls tracks calls to the methods. + calls struct { + // DeleteLogs holds details about calls to the DeleteLogs method. + DeleteLogs []struct { + // Filter is the filter argument value. + Filter models.LogFilter + } + // Flush holds details about calls to the Flush method. + Flush []struct { + } + // GetLogs holds details about calls to the GetLogs method. + GetLogs []struct { + // Params is the params argument value. + Params models.GetLogsParams + } + // Log holds details about calls to the Log method. + Log []struct { + // Logs is the logs argument value. + Logs []models.LogEntry + } + // Start holds details about calls to the Start method. + Start []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + } + lockDeleteLogs sync.RWMutex + lockFlush sync.RWMutex + lockGetLogs sync.RWMutex + lockLog sync.RWMutex + lockStart sync.RWMutex +} + +// DeleteLogs calls DeleteLogsFunc. +func (mock *LogAPIMock) DeleteLogs(filter models.LogFilter) error { + if mock.DeleteLogsFunc == nil { + panic("LogAPIMock.DeleteLogsFunc: method is nil but logAPI.DeleteLogs was just called") + } + callInfo := struct { + Filter models.LogFilter + }{ + Filter: filter, + } + mock.lockDeleteLogs.Lock() + mock.calls.DeleteLogs = append(mock.calls.DeleteLogs, callInfo) + mock.lockDeleteLogs.Unlock() + return mock.DeleteLogsFunc(filter) +} + +// DeleteLogsCalls gets all the calls that were made to DeleteLogs. +// Check the length with: +// len(mockedlogAPI.DeleteLogsCalls()) +func (mock *LogAPIMock) DeleteLogsCalls() []struct { + Filter models.LogFilter +} { + var calls []struct { + Filter models.LogFilter + } + mock.lockDeleteLogs.RLock() + calls = mock.calls.DeleteLogs + mock.lockDeleteLogs.RUnlock() + return calls +} + +// Flush calls FlushFunc. +func (mock *LogAPIMock) Flush() error { + if mock.FlushFunc == nil { + panic("LogAPIMock.FlushFunc: method is nil but logAPI.Flush was just called") + } + callInfo := struct { + }{} + mock.lockFlush.Lock() + mock.calls.Flush = append(mock.calls.Flush, callInfo) + mock.lockFlush.Unlock() + return mock.FlushFunc() +} + +// FlushCalls gets all the calls that were made to Flush. +// Check the length with: +// len(mockedlogAPI.FlushCalls()) +func (mock *LogAPIMock) FlushCalls() []struct { +} { + var calls []struct { + } + mock.lockFlush.RLock() + calls = mock.calls.Flush + mock.lockFlush.RUnlock() + return calls +} + +// GetLogs calls GetLogsFunc. +func (mock *LogAPIMock) GetLogs(params models.GetLogsParams) (*models.GetLogsResponse, error) { + if mock.GetLogsFunc == nil { + panic("LogAPIMock.GetLogsFunc: method is nil but logAPI.GetLogs was just called") + } + callInfo := struct { + Params models.GetLogsParams + }{ + Params: params, + } + mock.lockGetLogs.Lock() + mock.calls.GetLogs = append(mock.calls.GetLogs, callInfo) + mock.lockGetLogs.Unlock() + return mock.GetLogsFunc(params) +} + +// GetLogsCalls gets all the calls that were made to GetLogs. +// Check the length with: +// len(mockedlogAPI.GetLogsCalls()) +func (mock *LogAPIMock) GetLogsCalls() []struct { + Params models.GetLogsParams +} { + var calls []struct { + Params models.GetLogsParams + } + mock.lockGetLogs.RLock() + calls = mock.calls.GetLogs + mock.lockGetLogs.RUnlock() + return calls +} + +// Log calls LogFunc. +func (mock *LogAPIMock) Log(logs []models.LogEntry) { + if mock.LogFunc == nil { + panic("LogAPIMock.LogFunc: method is nil but logAPI.Log was just called") + } + callInfo := struct { + Logs []models.LogEntry + }{ + Logs: logs, + } + mock.lockLog.Lock() + mock.calls.Log = append(mock.calls.Log, callInfo) + mock.lockLog.Unlock() + mock.LogFunc(logs) +} + +// LogCalls gets all the calls that were made to Log. +// Check the length with: +// len(mockedlogAPI.LogCalls()) +func (mock *LogAPIMock) LogCalls() []struct { + Logs []models.LogEntry +} { + var calls []struct { + Logs []models.LogEntry + } + mock.lockLog.RLock() + calls = mock.calls.Log + mock.lockLog.RUnlock() + return calls +} + +// Start calls StartFunc. +func (mock *LogAPIMock) Start(ctx context.Context) { + if mock.StartFunc == nil { + panic("LogAPIMock.StartFunc: method is nil but logAPI.Start was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockStart.Lock() + mock.calls.Start = append(mock.calls.Start, callInfo) + mock.lockStart.Unlock() + mock.StartFunc(ctx) +} + +// StartCalls gets all the calls that were made to Start. +// Check the length with: +// len(mockedlogAPI.StartCalls()) +func (mock *LogAPIMock) StartCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockStart.RLock() + calls = mock.calls.Start + mock.lockStart.RUnlock() + return calls +} diff --git a/pkg/sdk/connector/fake/shipyardeventapi.go b/pkg/sdk/connector/fake/shipyardeventapi.go new file mode 100644 index 00000000..e558adce --- /dev/null +++ b/pkg/sdk/connector/fake/shipyardeventapi.go @@ -0,0 +1,71 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package fake + +import ( + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "sync" +) + +// ShipyardEventAPIMock is a mock implementation of httpeventsource.shipyardEventAPI. +// +// func TestSomethingThatUsesshipyardEventAPI(t *testing.T) { +// +// // make and configure a mocked httpeventsource.shipyardEventAPI +// mockedshipyardEventAPI := &ShipyardEventAPIMock{ +// GetOpenTriggeredEventsFunc: func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { +// panic("mock out the GetOpenTriggeredEvents method") +// }, +// } +// +// // use mockedshipyardEventAPI in code that requires httpeventsource.shipyardEventAPI +// // and then make assertions. +// +// } +type ShipyardEventAPIMock struct { + // GetOpenTriggeredEventsFunc mocks the GetOpenTriggeredEvents method. + GetOpenTriggeredEventsFunc func(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) + + // calls tracks calls to the methods. + calls struct { + // GetOpenTriggeredEvents holds details about calls to the GetOpenTriggeredEvents method. + GetOpenTriggeredEvents []struct { + // Filter is the filter argument value. + Filter api.EventFilter + } + } + lockGetOpenTriggeredEvents sync.RWMutex +} + +// GetOpenTriggeredEvents calls GetOpenTriggeredEventsFunc. +func (mock *ShipyardEventAPIMock) GetOpenTriggeredEvents(filter api.EventFilter) ([]*models.KeptnContextExtendedCE, error) { + if mock.GetOpenTriggeredEventsFunc == nil { + panic("ShipyardEventAPIMock.GetOpenTriggeredEventsFunc: method is nil but shipyardEventAPI.GetOpenTriggeredEvents was just called") + } + callInfo := struct { + Filter api.EventFilter + }{ + Filter: filter, + } + mock.lockGetOpenTriggeredEvents.Lock() + mock.calls.GetOpenTriggeredEvents = append(mock.calls.GetOpenTriggeredEvents, callInfo) + mock.lockGetOpenTriggeredEvents.Unlock() + return mock.GetOpenTriggeredEventsFunc(filter) +} + +// GetOpenTriggeredEventsCalls gets all the calls that were made to GetOpenTriggeredEvents. +// Check the length with: +// len(mockedshipyardEventAPI.GetOpenTriggeredEventsCalls()) +func (mock *ShipyardEventAPIMock) GetOpenTriggeredEventsCalls() []struct { + Filter api.EventFilter +} { + var calls []struct { + Filter api.EventFilter + } + mock.lockGetOpenTriggeredEvents.RLock() + calls = mock.calls.GetOpenTriggeredEvents + mock.lockGetOpenTriggeredEvents.RUnlock() + return calls +} diff --git a/pkg/sdk/connector/fake/subscriptionsource.go b/pkg/sdk/connector/fake/subscriptionsource.go new file mode 100644 index 00000000..7c6628a6 --- /dev/null +++ b/pkg/sdk/connector/fake/subscriptionsource.go @@ -0,0 +1,35 @@ +package fake + +import ( + "context" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" +) + +type SubscriptionSourceMock struct { + StartFn func(context.Context, types.RegistrationData, chan []models.EventSubscription, chan error, *sync.WaitGroup) error + RegisterFn func(integration models.Integration) (string, error) + StopFn func() error +} + +func (u *SubscriptionSourceMock) Start(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + if u.StartFn != nil { + return u.StartFn(ctx, data, c, errC, wg) + } + panic("Start() not set") +} + +func (u *SubscriptionSourceMock) Register(integration models.Integration) (string, error) { + if u.RegisterFn != nil { + return u.RegisterFn(integration) + } + panic("RegisterFn() not set") +} + +func (u *SubscriptionSourceMock) Stop() error { + if u.StopFn != nil { + return u.StopFn() + } + panic("StopFn() not set") +} diff --git a/pkg/sdk/connector/fake/uniformapi.go b/pkg/sdk/connector/fake/uniformapi.go new file mode 100644 index 00000000..f9bea352 --- /dev/null +++ b/pkg/sdk/connector/fake/uniformapi.go @@ -0,0 +1,33 @@ +package fake + +import "github.com/keptn/go-utils/pkg/api/models" + +type UniformAPIMock struct { + RegisterIntegrationFn func(models.Integration) (string, error) + PingFn func(string) (*models.Integration, error) +} + +func (m *UniformAPIMock) Ping(integrationID string) (*models.Integration, error) { + if m.PingFn != nil { + return m.PingFn(integrationID) + } + panic("Ping() not implemented") +} +func (m *UniformAPIMock) RegisterIntegration(integration models.Integration) (string, error) { + if m.RegisterIntegrationFn != nil { + return m.RegisterIntegrationFn(integration) + } + panic("RegisterIntegraiton() not imiplemented") +} + +func (m *UniformAPIMock) CreateSubscription(integrationID string, subscription models.EventSubscription) (string, error) { + panic("implement me") +} + +func (m *UniformAPIMock) UnregisterIntegration(integrationID string) error { + panic("implement me") +} + +func (m *UniformAPIMock) GetRegistrations() ([]*models.Integration, error) { + panic("implement me") +} diff --git a/pkg/sdk/connector/logforwarder/logforwarder.go b/pkg/sdk/connector/logforwarder/logforwarder.go new file mode 100644 index 00000000..77cc02ae --- /dev/null +++ b/pkg/sdk/connector/logforwarder/logforwarder.go @@ -0,0 +1,95 @@ +package logforwarder + +import ( + "fmt" + "strings" + + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + keptnv2 "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" +) + +//go:generate moq -pkg fake -skip-ensure -out ./fake/logapi.go . logAPI:LogAPIMock +type logAPI api.LogsV1Interface + +type LogForwarder interface { + Forward(keptnEvent models.KeptnContextExtendedCE, integrationID string) error +} + +var _ LogForwarder = LogForwardingHandler{} + +type LogForwardingHandler struct { + logApi api.LogsV1Interface + logger logger.Logger +} + +func New(logApi api.LogsV1Interface, opts ...func(handler *LogForwardingHandler)) *LogForwardingHandler { + l := &LogForwardingHandler{ + logApi: logApi, + logger: logger.NewDefaultLogger(), + } + for _, o := range opts { + o(l) + } + return l +} + +// WithLogger sets the logger to use +func WithLogger(logger logger.Logger) func(*LogForwardingHandler) { + return func(lfh *LogForwardingHandler) { + lfh.logger = logger + } +} + +func (l LogForwardingHandler) Forward(keptnEvent models.KeptnContextExtendedCE, integrationID string) error { + if integrationID == "" { + return nil + } + l.logger.Infof("Forwarding logs for service with integrationID `%s`", integrationID) + if strings.HasSuffix(*keptnEvent.Type, ".finished") { + eventData := &keptnv2.EventData{} + if err := keptnv2.EventDataAs(keptnEvent, eventData); err != nil { + return fmt.Errorf("could not decode Keptn event data: %w", err) + } + + taskName, _, err := keptnv2.ParseTaskEventType(*keptnEvent.Type) + if err != nil { + return fmt.Errorf("could not parse Keptn event type: %w", err) + } + + if eventData.Status == keptnv2.StatusErrored { + l.logger.Info("Received '.finished' event with status 'errored'. Forwarding log message to log ingestion API") + l.logApi.Log([]models.LogEntry{{ + IntegrationID: integrationID, + Message: eventData.Message, + KeptnContext: keptnEvent.Shkeptncontext, + Task: taskName, + TriggeredID: keptnEvent.Triggeredid, + }}) + l.logApi.Flush() + } + return nil + } else if *keptnEvent.Type == keptnv2.ErrorLogEventName { + l.logger.Info("Received 'log.error' event. Forwarding log message to log ingestion API") + + eventData := &keptnv2.ErrorLogEvent{} + if err := keptnv2.EventDataAs(keptnEvent, eventData); err != nil { + return fmt.Errorf("unable decode Keptn event data: %w", err) + } + + if eventData.IntegrationID != "" { + // overwrite default integrationID if it has been set in the event + integrationID = eventData.IntegrationID + } + l.logApi.Log([]models.LogEntry{{ + IntegrationID: integrationID, + Message: eventData.Message, + KeptnContext: keptnEvent.Shkeptncontext, + Task: eventData.Task, + TriggeredID: keptnEvent.Triggeredid, + }}) + l.logApi.Flush() + } + return nil +} diff --git a/pkg/sdk/connector/logforwarder/logforwarder_test.go b/pkg/sdk/connector/logforwarder/logforwarder_test.go new file mode 100644 index 00000000..ff9d50ba --- /dev/null +++ b/pkg/sdk/connector/logforwarder/logforwarder_test.go @@ -0,0 +1,93 @@ +package logforwarder + +import ( + "github.com/keptn/go-utils/pkg/sdk/connector/fake" + "testing" + + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + keptnv2 "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/stretchr/testify/require" +) + +func TestLogForwarderNoForward(t *testing.T) { + logHandler := &fake.LogAPIMock{} + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.triggered")} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.Nil(t, err) + require.Len(t, logHandler.LogCalls(), 0) +} + +func TestLogForwarderNoIntegrationID(t *testing.T) { + logHandler := &fake.LogAPIMock{} + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.finished")} + err := logForwarder.Forward(keptnEvent, "") + require.Nil(t, err) + require.Len(t, logHandler.LogCalls(), 0) +} + +func TestLogForwarderFinishedNoForward(t *testing.T) { + logHandler := &fake.LogAPIMock{} + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.finished"), Data: keptnv2.EventData{Status: keptnv2.StatusSucceeded}} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.Nil(t, err) + require.Len(t, logHandler.LogCalls(), 0) +} + +func TestLogForwarderFinishedInvalidEventType(t *testing.T) { + logHandler := &fake.LogAPIMock{} + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.finished"), Data: "some invalid data"} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.NotNil(t, err) + require.Len(t, logHandler.LogCalls(), 0) +} + +func TestLogForwarderFinishedForward(t *testing.T) { + logHandler := &fake.LogAPIMock{ + LogFunc: func(logs []models.LogEntry) {}, + FlushFunc: func() error { return nil }, + } + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.event.echo.finished"), Data: keptnv2.EventData{Status: keptnv2.StatusErrored}} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.Nil(t, err) + require.Len(t, logHandler.LogCalls(), 1) +} + +func TestLogForwarderErrorInvalidEventType(t *testing.T) { + logHandler := &fake.LogAPIMock{} + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.log.error"), Data: "some invalid data"} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.NotNil(t, err) + require.Len(t, logHandler.LogCalls(), 0) +} + +func TestLogForwarderErrorForward(t *testing.T) { + logHandler := &fake.LogAPIMock{ + LogFunc: func(logs []models.LogEntry) {}, + FlushFunc: func() error { return nil }, + } + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.log.error")} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.Nil(t, err) + require.Len(t, logHandler.LogCalls(), 1) +} + +func TestLogForwarderErrorEventIntegrationID(t *testing.T) { + logHandler := &fake.LogAPIMock{ + LogFunc: func(logs []models.LogEntry) {}, + FlushFunc: func() error { return nil }, + } + logForwarder := New(logHandler) + keptnEvent := models.KeptnContextExtendedCE{ID: "some-id", Type: strutils.Stringp("sh.keptn.log.error"), Data: keptnv2.ErrorLogEvent{IntegrationID: "some-new-id"}} + err := logForwarder.Forward(keptnEvent, "some-other-id") + require.Nil(t, err) + require.Len(t, logHandler.LogCalls(), 1) + require.Equal(t, logHandler.LogCalls()[0].Logs[0].IntegrationID, "some-new-id") +} diff --git a/pkg/sdk/connector/logger/log.go b/pkg/sdk/connector/logger/log.go new file mode 100644 index 00000000..914c9246 --- /dev/null +++ b/pkg/sdk/connector/logger/log.go @@ -0,0 +1,70 @@ +package logger + +import ( + "log" + "os" +) + +// Logger interface used by the go sdk +type Logger interface { + Debug(v ...interface{}) + Debugf(format string, v ...interface{}) + Info(v ...interface{}) + Infof(format string, v ...interface{}) + Warn(v ...interface{}) + Warnf(format string, v ...interface{}) + Error(v ...interface{}) + Errorf(format string, v ...interface{}) + Fatal(v ...interface{}) + Fatalf(format string, v ...interface{}) +} + +// DefaultLogger implementation of Logger using the go log package +type DefaultLogger struct { + logger *log.Logger +} + +// NewDefaultLogger creates a new Default Logger +func NewDefaultLogger() *DefaultLogger { + return &DefaultLogger{logger: log.New(os.Stdout, "", 5)} +} + +func (d DefaultLogger) Debug(v ...interface{}) { + d.logger.Println(v...) +} + +func (d DefaultLogger) Debugf(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Info(v ...interface{}) { + d.logger.Println(v...) +} + +func (d DefaultLogger) Infof(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Warn(v ...interface{}) { + d.logger.Println(v...) +} + +func (d DefaultLogger) Warnf(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Error(v ...interface{}) { + d.logger.Print(v...) +} + +func (d DefaultLogger) Errorf(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Fatal(v ...interface{}) { + d.logger.Fatal(v...) +} + +func (d DefaultLogger) Fatalf(format string, v ...interface{}) { + d.logger.Fatalf(format, v...) +} diff --git a/pkg/sdk/connector/nats/nats.go b/pkg/sdk/connector/nats/nats.go new file mode 100644 index 00000000..973d294c --- /dev/null +++ b/pkg/sdk/connector/nats/nats.go @@ -0,0 +1,215 @@ +package nats + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" + "github.com/nats-io/nats.go" + "os" + "time" +) + +var _ NATS = (*NatsConnector)(nil) + +const ( + EnvVarNatsURL = "NATS_URL" + EnvVarNatsURLDefault = "nats://keptn-nats" + CloudEventsVersionV1 = "1.0" +) + +type NATS interface { + Subscribe(subject string, fn ProcessEventFn) error + QueueSubscribe(queueGroup string, subject string, fn ProcessEventFn) error + SubscribeMultiple(subjects []string, fn ProcessEventFn) error + QueueSubscribeMultiple(subjects []string, queueGroup string, fn ProcessEventFn) error + Publish(event models.KeptnContextExtendedCE) error + Disconnect() error + UnsubscribeAll() error +} + +var ( + ErrSubAlreadySubscribed = errors.New("already subscribed") + ErrSubNilMessageProcessor = errors.New("message processor is nil") + ErrSubEmptySubject = errors.New("empty subject") + ErrPubEventTypeMissing = errors.New("event is missing the event type") +) + +// ProcessEventFn is used to process a received keptn event +type ProcessEventFn func(msg *nats.Msg) error + +// NatsConnector can be used to subscribe to certain events +// on the NATS event system +type NatsConnector struct { + connection *nats.Conn + connectURL string + subscriptions map[string]*nats.Subscription + logger logger.Logger +} + +// WithLogger sets the logger to use +func WithLogger(logger logger.Logger) func(*NatsConnector) { + return func(n *NatsConnector) { + n.logger = logger + } +} + +// New returns an initialised NatsConnector with a nil connection +func New(connectURL string, opts ...func(connector *NatsConnector)) *NatsConnector { + nc := &NatsConnector{ + connection: &nats.Conn{}, + connectURL: connectURL, + subscriptions: make(map[string]*nats.Subscription), + logger: logger.NewDefaultLogger(), + } + for _, o := range opts { + o(nc) + } + return nc +} + +// NewFromEnv returns a NatsConnector to NATS. +// The URL is read from the environment variable "NATS_URL" +// If the URL is not set via the environment variable "NATS_URL", +// it falls back to the default URL "nats://keptn-nats" +func NewFromEnv() *NatsConnector { + natsURL := os.Getenv(EnvVarNatsURL) + if natsURL == "" { + natsURL = EnvVarNatsURLDefault + } + return New(natsURL) +} + +// ensureConnection connects a NatsConnector or returns the existing connection to NATS +// Note that this will automatically and indefinitely try to reconnect +// as soon as it looses connection +func (nc *NatsConnector) ensureConnection() (*nats.Conn, error) { + + if !nc.connection.IsConnected() { + var err error + nc.connection, err = nats.Connect(nc.connectURL, nats.MaxReconnects(-1)) + + if err != nil { + return nil, fmt.Errorf("could not connect to NATS: %w", err) + } + } + + return nc.connection, nil +} + +// UnsubscribeAll deletes all current subscriptions +func (nc *NatsConnector) UnsubscribeAll() error { + for _, s := range nc.subscriptions { + if err := s.Unsubscribe(); err != nil { + return fmt.Errorf("unable to unsubscribe from subject %s: %w", s.Subject, err) + } + } + nc.subscriptions = make(map[string]*nats.Subscription) + return nil +} + +// Subscribe adds a subscription to a specific subject to the NatsConnector. +// It takes the subject as string (usually the event type) and a function fn +// being called when an event is received +func (nc *NatsConnector) Subscribe(subject string, fn ProcessEventFn) error { + return nc.QueueSubscribe(subject, "", fn) +} + +// QueueSubscribe adds a queue subscription to the NatsConnector +func (nc *NatsConnector) QueueSubscribe(subject string, queueGroup string, fn ProcessEventFn) error { + if subject == "" { + return ErrSubEmptySubject + } + if fn == nil { + return ErrSubNilMessageProcessor + } + return nc.queueSubscribe(subject, queueGroup, fn) +} + +// SubscribeMultiple adds multiple subscriptions to the NatsConnector +func (nc *NatsConnector) SubscribeMultiple(subjects []string, fn ProcessEventFn) error { + return nc.QueueSubscribeMultiple(subjects, "", fn) +} + +// QueueSubscribeMultiple adds multiple queue subscriptions to the NatsConnector +func (nc *NatsConnector) QueueSubscribeMultiple(subjects []string, queueGroup string, fn ProcessEventFn) error { + if fn == nil { + return ErrSubNilMessageProcessor + } + + // Immediately verify if the Connection is valid, to avoid starting go routines for a + // faulty nats connection with no subjects + if _, err := nc.ensureConnection(); err != nil { + return err + } + + for _, sub := range subjects { + nc.logger.Debug("Subscribing to topic %s", sub) + if err := nc.queueSubscribe(sub, queueGroup, fn); err != nil { + return fmt.Errorf("could not subscribe to subject %s: %w", sub, err) + } + nc.logger.Debug("Successfully subscribed to topic %s", sub) + } + return nil +} + +// Publish sends a keptn event to the message broker +func (nc *NatsConnector) Publish(event models.KeptnContextExtendedCE) error { + if event.Type == nil || *event.Type == "" { + return ErrPubEventTypeMissing + } + // ensure that the mandatory fields time, id and specversion are set in the CloudEvent + event.Time = time.Now().UTC() + event.Specversion = CloudEventsVersionV1 + if event.ID == "" { + event.ID = uuid.New().String() + } + serializedEvent, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("could not publish event: %w", err) + } + conn, err := nc.ensureConnection() + if err != nil { + return fmt.Errorf("could not connect to NATS to publish event: %w", err) + } + return conn.Publish(*event.Type, serializedEvent) +} + +// Disconnect disconnects/closes the connection to NATS +func (nc *NatsConnector) Disconnect() error { + connection, err := nc.ensureConnection() + if err != nil { + return fmt.Errorf("could not disconnect from NATS: %w", err) + } + connection.Close() + return nil +} + +func (nc *NatsConnector) queueSubscribe(subject string, queueGroup string, fn ProcessEventFn) error { + conn, err := nc.ensureConnection() + if err != nil { + return fmt.Errorf("could not queue: %w", err) + } + sub, err := conn.QueueSubscribe(subject, queueGroup, func(m *nats.Msg) { + err := fn(m) + if err != nil { + nc.logger.Errorf("Could not process message %s: %v\n", string(m.Data), err) + } + }) + + if err != nil { + return fmt.Errorf("could not subscribe to subject %s: %w", subject, err) + } + if nc.subscriptions == nil { + nc.subscriptions = make(map[string]*nats.Subscription) + } + + if _, ok := nc.subscriptions[subject]; ok { + return ErrSubAlreadySubscribed + } + + nc.subscriptions[subject] = sub + return nil +} diff --git a/pkg/sdk/connector/nats/nats_test.go b/pkg/sdk/connector/nats/nats_test.go new file mode 100644 index 00000000..25a87d3d --- /dev/null +++ b/pkg/sdk/connector/nats/nats_test.go @@ -0,0 +1,256 @@ +package nats_test + +import ( + "encoding/json" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + nats2 "github.com/keptn/go-utils/pkg/sdk/connector/nats" + "github.com/nats-io/nats-server/v2/server" + natstest "github.com/nats-io/nats-server/v2/test" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" + "os" + "testing" + "time" +) + +func TestNewFromEnv(t *testing.T) { + svr, shutdown := runNATSServer() + defer shutdown() + os.Setenv(nats2.EnvVarNatsURL, svr.ClientURL()) + defer os.Unsetenv(nats2.EnvVarNatsURL) + sub := nats2.NewFromEnv() + require.NotNil(t, sub) +} + +func TestConnectFails(t *testing.T) { + nc := nats2.New("nats://something:3456") + require.NotNil(t, nc) + err := nc.Disconnect() + require.NotNil(t, err) +} + +func TestDisconnect(t *testing.T) { + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + err := nc.Disconnect() + require.Nil(t, err) + require.Eventually(t, func() bool { return svr.NumClients() == 0 }, 10*time.Second, time.Second) +} + +func TestSubscribe(t *testing.T) { + received := false + msg := `{ + "data": "", + "id": "6de83495-4f83-481c-8dbe-fcceb2e0243b", + "source": "my-service", + "specversion": "1.0", + "type": "sh.keptn.events.task.started", + "shkeptncontext": "3c9ffbbb-6e1d-4789-9fee-6e63b4bcc1fb" + }` + + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + + err := nc.Subscribe("subj", func(msg *nats.Msg) error { + received = true + return nil + }) + require.Nil(t, err) + localClient, _ := nats.Connect(svr.ClientURL()) + defer localClient.Close() + + localClient.Publish("subj", []byte(msg)) + require.Eventually(t, func() bool { + return received + }, 10*time.Second, time.Second) +} + +func TestSubscribeTwice(t *testing.T) { + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + + err := nc.Subscribe("subj", func(msg *nats.Msg) error { return nil }) + require.Nil(t, err) + err = nc.Subscribe("subj", func(msg *nats.Msg) error { return nil }) + require.ErrorIs(t, err, nats2.ErrSubAlreadySubscribed) +} + +func TestSubscribeEmptySubject(t *testing.T) { + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + err := nc.Subscribe("", func(msg *nats.Msg) error { return nil }) + require.ErrorIs(t, err, nats2.ErrSubEmptySubject) +} + +func TestSubscribeWithEmptyProcessFn(t *testing.T) { + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + err := nc.Subscribe("subj", nil) + require.ErrorIs(t, err, nats2.ErrSubNilMessageProcessor) +} + +func TestSubscribeMultiple(t *testing.T) { + numberReceived := 0 + msg := `{}` + + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + + subjects := []string{"subj1", "subj2"} + + err := nc.SubscribeMultiple(subjects, func(msg *nats.Msg) error { + numberReceived++ + return nil + }) + require.Nil(t, err) + localClient, _ := nats.Connect(svr.ClientURL()) + defer localClient.Close() + + require.NoError(t, localClient.Publish("subj1", []byte(msg))) + require.NoError(t, localClient.Publish("subj2", []byte(msg))) + + require.Eventually(t, func() bool { + return numberReceived == 2 + }, 10*time.Second, time.Second) +} + +func TestSubscribeMultipleFails(t *testing.T) { + numberReceived := 0 + nc := nats2.New("myverywrongurl") + err := nc.SubscribeMultiple([]string{}, func(msg *nats.Msg) error { + numberReceived++ + return nil + }) + require.ErrorContains(t, err, "could not connect to NATS: dial tcp: lookup myverywrongurl") +} + +func TestUnsubscribeAll(t *testing.T) { + msg := `{}` + + svr, shutDown := runNATSServer() + defer shutDown() + + receivedBeforeUnsubscribeAll := false + receivedAfterUnsubscribeAll := false + + nc := nats2.New(svr.ClientURL()) + + err := nc.Subscribe("subj", func(msg *nats.Msg) error { + receivedBeforeUnsubscribeAll = true + return nil + }) + require.NoError(t, err) + localClient, _ := nats.Connect(svr.ClientURL()) + defer localClient.Close() + require.NoError(t, localClient.Publish("subj", []byte(msg))) + require.Eventually(t, func() bool { + return receivedBeforeUnsubscribeAll + }, 10*time.Second, time.Second) + + err = nc.UnsubscribeAll() + require.NoError(t, err) + + require.NoError(t, localClient.Publish("subj", []byte(msg))) + require.False(t, receivedAfterUnsubscribeAll) +} + +func TestPublish(t *testing.T) { + received := false + msg := models.KeptnContextExtendedCE{ + Type: strutils.Stringp("subj"), + Data: v0_2_0.EventData{ + Project: "someProject", + Stage: "someStage", + Service: "someService", + }, + } + + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + + err := nc.Subscribe("subj", func(e *nats.Msg) error { + received = true + ev := &models.KeptnContextExtendedCE{} + err := json.Unmarshal(e.Data, ev) + require.Nil(t, err) + require.NotEmpty(t, ev.Time) + require.NotEmpty(t, ev.ID) + require.Equal(t, nats2.CloudEventsVersionV1, ev.Specversion) + return nil + }) + require.Nil(t, err) + + err = nc.Publish(msg) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return received + }, 10*time.Second, time.Second) +} + +func TestPublishWithID(t *testing.T) { + received := false + msg := models.KeptnContextExtendedCE{ + ID: "my-id", + Type: strutils.Stringp("subj"), + Data: v0_2_0.EventData{ + Project: "someProject", + Stage: "someStage", + Service: "someService", + }, + } + + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + + err := nc.Subscribe("subj", func(e *nats.Msg) error { + received = true + ev := &models.KeptnContextExtendedCE{} + err := json.Unmarshal(e.Data, ev) + require.Nil(t, err) + require.NotEmpty(t, ev.Time) + require.Equal(t, "my-id", ev.ID) + require.Equal(t, nats2.CloudEventsVersionV1, ev.Specversion) + return nil + }) + require.Nil(t, err) + + err = nc.Publish(msg) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return received + }, 10*time.Second, time.Second) +} + +func TestPublishEventMissingType(t *testing.T) { + msg := models.KeptnContextExtendedCE{} + svr, shutdown := runNATSServer() + defer shutdown() + nc := nats2.New(svr.ClientURL()) + require.NotNil(t, nc) + err := nc.Publish(msg) + require.ErrorIs(t, err, nats2.ErrPubEventTypeMissing) + +} + +func runNATSServer() (*server.Server, func()) { + svr := natstest.RunRandClientPortServer() + return svr, func() { svr.Shutdown() } +} diff --git a/pkg/sdk/connector/subscriptionsource/subscriptionsource.go b/pkg/sdk/connector/subscriptionsource/subscriptionsource.go new file mode 100644 index 00000000..779108b9 --- /dev/null +++ b/pkg/sdk/connector/subscriptionsource/subscriptionsource.go @@ -0,0 +1,147 @@ +package subscriptionsource + +import ( + "context" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" + "time" + + "github.com/benbjohnson/clock" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/sdk/connector/logger" +) + +type SubscriptionSource interface { + Start(context.Context, types.RegistrationData, chan []models.EventSubscription, chan error, *sync.WaitGroup) error + Register(integration models.Integration) (string, error) + Stop() error +} + +var _ SubscriptionSource = FixedSubscriptionSource{} +var _ SubscriptionSource = (*UniformSubscriptionSource)(nil) + +// UniformSubscriptionSource represents a source for uniform subscriptions +type UniformSubscriptionSource struct { + uniformAPI api.UniformV1Interface + clock clock.Clock + fetchInterval time.Duration + logger logger.Logger + quitC chan struct{} +} + +func (s *UniformSubscriptionSource) Register(integration models.Integration) (string, error) { + integrationID, err := s.uniformAPI.RegisterIntegration(integration) + if err != nil { + return "", err + } + return integrationID, nil +} + +// WithFetchInterval specifies the interval the subscription source should +// use when polling for new subscriptions +func WithFetchInterval(interval time.Duration) func(s *UniformSubscriptionSource) { + return func(s *UniformSubscriptionSource) { + s.fetchInterval = interval + } +} + +// WithLogger sets the logger to use +func WithLogger(logger logger.Logger) func(s *UniformSubscriptionSource) { + return func(s *UniformSubscriptionSource) { + s.logger = logger + } +} + +// New creates a new UniformSubscriptionSource +func New(uniformAPI api.UniformV1Interface, options ...func(source *UniformSubscriptionSource)) *UniformSubscriptionSource { + s := &UniformSubscriptionSource{ + uniformAPI: uniformAPI, + clock: clock.New(), + fetchInterval: time.Second * 5, + quitC: make(chan struct{}, 1), + logger: logger.NewDefaultLogger()} + for _, o := range options { + o(s) + } + return s +} + +// Start triggers the execution of the UniformSubscriptionSource +func (s *UniformSubscriptionSource) Start(ctx context.Context, registrationData types.RegistrationData, subscriptionChannel chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + s.logger.Debugf("UniformSubscriptionSource: Starting to fetch subscriptions for Integration ID %s", registrationData.ID) + ticker := s.clock.Ticker(s.fetchInterval) + go func() { + s.ping(registrationData.ID, subscriptionChannel) + for { + select { + case <-ctx.Done(): + wg.Done() + return + case <-ticker.C: + s.ping(registrationData.ID, subscriptionChannel) + case <-s.quitC: + wg.Done() + return + } + } + }() + return nil +} + +func (s *UniformSubscriptionSource) Stop() error { + s.quitC <- struct{}{} + return nil +} + +func (s *UniformSubscriptionSource) ping(registrationId string, subscriptionChannel chan []models.EventSubscription) { + s.logger.Debugf("UniformSubscriptionSource: Renewing Integration ID %s", registrationId) + updatedIntegrationData, err := s.uniformAPI.Ping(registrationId) + if err != nil { + s.logger.Errorf("Unable to ping control plane: %v", err) + return + } + s.logger.Debugf("UniformSubscriptionSource: Ping successful, got %d subscriptions for %s", len(updatedIntegrationData.Subscriptions), registrationId) + subscriptionChannel <- updatedIntegrationData.Subscriptions +} + +// FixedSubscriptionSource can be used to use a fixed list of subscriptions rather than +// consulting the Keptn API for subscriptions. +// This is useful when you want to consume events from an event source, but NOT register +// as an Keptn integration to the control plane +type FixedSubscriptionSource struct { + fixedSubscriptions []models.EventSubscription +} + +// WithFixedSubscriptions adds a fixed list of subscriptions to the FixedSubscriptionSource +func WithFixedSubscriptions(subscriptions ...models.EventSubscription) func(s *FixedSubscriptionSource) { + return func(s *FixedSubscriptionSource) { + s.fixedSubscriptions = subscriptions + } +} + +// NewFixedSubscriptionSource creates a new instance of FixedSubscriptionSource +func NewFixedSubscriptionSource(options ...func(source *FixedSubscriptionSource)) *FixedSubscriptionSource { + fss := &FixedSubscriptionSource{fixedSubscriptions: []models.EventSubscription{}} + for _, o := range options { + o(fss) + } + return fss +} + +func (s FixedSubscriptionSource) Start(ctx context.Context, data types.RegistrationData, c chan []models.EventSubscription, errC chan error, wg *sync.WaitGroup) error { + go func() { + c <- s.fixedSubscriptions + <-ctx.Done() + wg.Done() + }() + return nil +} + +func (s FixedSubscriptionSource) Register(integration models.Integration) (string, error) { + return "", nil +} + +func (s FixedSubscriptionSource) Stop() error { + return nil +} diff --git a/pkg/sdk/connector/subscriptionsource/subscriptionsource_test.go b/pkg/sdk/connector/subscriptionsource/subscriptionsource_test.go new file mode 100644 index 00000000..5ba42549 --- /dev/null +++ b/pkg/sdk/connector/subscriptionsource/subscriptionsource_test.go @@ -0,0 +1,241 @@ +package subscriptionsource + +import ( + "context" + "fmt" + "github.com/keptn/go-utils/pkg/sdk/connector/fake" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "sync" + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/stretchr/testify/require" +) + +func TestSubscriptionSourceCPPingFails(t *testing.T) { + initialRegistrationData := types.RegistrationData{} + + uniformInterface := &fake.UniformAPIMock{ + PingFn: func(s string) (*models.Integration, error) { + return nil, fmt.Errorf("error occured") + }} + subscriptionUpdates := make(chan []models.EventSubscription) + go func() { + <-subscriptionUpdates + require.FailNow(t, "got subscription event via channel") + }() + + subscriptionSource := New(uniformInterface) + clock := clock.NewMock() + subscriptionSource.clock = clock + wg := &sync.WaitGroup{} + wg.Add(1) + err := subscriptionSource.Start(context.TODO(), initialRegistrationData, subscriptionUpdates, make(chan error), wg) + require.NoError(t, err) + clock.Add(5 * time.Second) +} + +func TestSubscriptionSourceWithFetchInterval(t *testing.T) { + integrationID := "iID" + integrationName := "integrationName" + pingCount := 0 + + initialRegistrationData := types.RegistrationData{ + Name: integrationName, + MetaData: models.MetaData{}, + Subscriptions: []models.EventSubscription{{Event: "keptn.event", Filter: models.EventSubscriptionFilter{}}}, + ID: integrationID, + } + + uniformInterface := &fake.UniformAPIMock{ + PingFn: func(id string) (*models.Integration, error) { + pingCount++ + require.Equal(t, id, integrationID) + return &models.Integration{ + ID: integrationID, + Name: integrationName, + MetaData: models.MetaData{}, + Subscriptions: []models.EventSubscription{{ID: "sID", Event: "keptn.event", Filter: models.EventSubscriptionFilter{}}}, + }, nil + }, + } + + subscriptionSource := New(uniformInterface, WithFetchInterval(10*time.Second)) + clock := clock.NewMock() + subscriptionSource.clock = clock + + subscriptionUpdates := make(chan []models.EventSubscription) + wg := &sync.WaitGroup{} + wg.Add(1) + + err := subscriptionSource.Start(context.TODO(), initialRegistrationData, subscriptionUpdates, make(chan error), wg) + require.NoError(t, err) + for i := 0; i < 100; i++ { + clock.Add(10 * time.Second) + <-subscriptionUpdates + } + require.Equal(t, 100, pingCount) +} + +func TestSubscriptionSourceCancel(t *testing.T) { + integrationID := "iID" + integrationName := "integrationName" + pingCount := 0 + + initialRegistrationData := types.RegistrationData{ + Name: integrationName, + MetaData: models.MetaData{}, + Subscriptions: []models.EventSubscription{{Event: "keptn.event", Filter: models.EventSubscriptionFilter{}}}, + ID: integrationID, + } + + uniformInterface := &fake.UniformAPIMock{ + PingFn: func(id string) (*models.Integration, error) { + pingCount++ + require.Equal(t, id, integrationID) + return &models.Integration{ + ID: integrationID, + Name: integrationName, + MetaData: models.MetaData{}, + Subscriptions: []models.EventSubscription{{ID: "sID", Event: "keptn.event", Filter: models.EventSubscriptionFilter{}}}, + }, nil + }, + } + + subscriptionSource := New(uniformInterface, WithFetchInterval(10*time.Second)) + clock := clock.NewMock() + subscriptionSource.clock = clock + + subscriptionUpdates := make(chan []models.EventSubscription) + + go func() { + for { + <-subscriptionUpdates + } + }() + + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + err := subscriptionSource.Start(ctx, initialRegistrationData, subscriptionUpdates, make(chan error), wg) + require.Eventually(t, func() bool { return pingCount == 1 }, 3*time.Second, time.Millisecond*100) + require.NoError(t, err) + clock.Add(10 * time.Second) + require.Equal(t, 2, pingCount) + cancel() + clock.Add(10 * time.Second) + require.Equal(t, 2, pingCount) + wg.Wait() +} + +func TestSubscriptionSource(t *testing.T) { + integrationID := "iID" + integrationName := "integrationName" + subscriptionID := "sID" + + initialRegistrationData := types.RegistrationData{ + Name: integrationName, + MetaData: models.MetaData{}, + Subscriptions: []models.EventSubscription{{Event: "keptn.event", Filter: models.EventSubscriptionFilter{}}}, + ID: integrationID, + } + + uniformInterface := &fake.UniformAPIMock{ + PingFn: func(id string) (*models.Integration, error) { + require.Equal(t, id, integrationID) + return &models.Integration{ + ID: integrationID, + Name: integrationName, + MetaData: models.MetaData{}, + Subscriptions: []models.EventSubscription{{ID: subscriptionID, Event: "keptn.event", Filter: models.EventSubscriptionFilter{}}}, + }, nil + }, + } + + subscriptionSource := New(uniformInterface) + clock := clock.NewMock() + subscriptionSource.clock = clock + + subscriptionUpdates := make(chan []models.EventSubscription) + wg := &sync.WaitGroup{} + wg.Add(1) + + err := subscriptionSource.Start(context.TODO(), initialRegistrationData, subscriptionUpdates, make(chan error), wg) + require.NoError(t, err) + clock.Add(5 * time.Second) + subs := <-subscriptionUpdates + require.Equal(t, 1, len(subs)) + clock.Add(5 * time.Second) + subs = <-subscriptionUpdates + require.Equal(t, 1, len(subs)) +} + +func TestFixedSubscriptionSource_WithSubscriptions(t *testing.T) { + fss := NewFixedSubscriptionSource(WithFixedSubscriptions(models.EventSubscription{Event: "some.event"})) + subchan := make(chan []models.EventSubscription) + err := fss.Start(context.TODO(), types.RegistrationData{}, subchan, make(chan error), &sync.WaitGroup{}) + require.NoError(t, err) + updates := <-subchan + require.Equal(t, 1, len(updates)) + require.Equal(t, []models.EventSubscription{{Event: "some.event"}}, updates) +} + +func TestFixedSubscriptionSourcer_WithNoSubscriptions(t *testing.T) { + fss := NewFixedSubscriptionSource() + subchan := make(chan []models.EventSubscription) + err := fss.Start(context.TODO(), types.RegistrationData{}, subchan, make(chan error), &sync.WaitGroup{}) + require.NoError(t, err) + updates := <-subchan + require.Equal(t, 0, len(updates)) +} + +func TestFixedSubscriptionSource_CallsWaitGroup(t *testing.T) { + fss := NewFixedSubscriptionSource() + subchan := make(chan []models.EventSubscription) + + ctx, cancel := context.WithCancel(context.TODO()) + wg := &sync.WaitGroup{} + wg.Add(1) + fss.Start(ctx, types.RegistrationData{}, subchan, make(chan error), wg) + <-subchan + cancel() + wg.Wait() +} + +func TestFixedSubscriptionSourcer_Register(t *testing.T) { + fss := NewFixedSubscriptionSource() + initialRegistrationData := types.RegistrationData{} + s, err := fss.Register(models.Integration(initialRegistrationData)) + require.NoError(t, err) + require.Equal(t, "", s) +} + +func TestSubscriptionRegistrationSucceeds(t *testing.T) { + initialRegistrationData := types.RegistrationData{} + uniformInterface := &fake.UniformAPIMock{ + RegisterIntegrationFn: func(i models.Integration) (string, error) { + return "some-id", nil + }, + } + + subscriptionSource := New(uniformInterface) + id, err := subscriptionSource.Register(models.Integration(initialRegistrationData)) + require.NoError(t, err) + require.Equal(t, id, "some-id") +} + +func TestSubscriptionRegistrationFails(t *testing.T) { + initialRegistrationData := types.RegistrationData{} + uniformInterface := &fake.UniformAPIMock{ + RegisterIntegrationFn: func(i models.Integration) (string, error) { + return "", fmt.Errorf("some error") + }, + } + + subscriptionSource := New(uniformInterface) + id, err := subscriptionSource.Register(models.Integration(initialRegistrationData)) + require.Error(t, err) + require.Equal(t, id, "") +} diff --git a/pkg/sdk/connector/types/types.go b/pkg/sdk/connector/types/types.go new file mode 100644 index 00000000..a4ef9d60 --- /dev/null +++ b/pkg/sdk/connector/types/types.go @@ -0,0 +1,26 @@ +package types + +import ( + "github.com/keptn/go-utils/pkg/api/models" +) + +type RegistrationData models.Integration + +type AdditionalSubscriptionData struct { + SubscriptionID string `json:"subscriptionID"` +} + +type EventUpdate struct { + KeptnEvent models.KeptnContextExtendedCE + MetaData EventUpdateMetaData +} + +type EventUpdateMetaData struct { + Subject string +} + +type EventSenderKeyType struct{} + +var EventSenderKey = EventSenderKeyType{} + +type EventSender func(ce models.KeptnContextExtendedCE) error diff --git a/pkg/sdk/events.go b/pkg/sdk/events.go new file mode 100644 index 00000000..8f69efbf --- /dev/null +++ b/pkg/sdk/events.go @@ -0,0 +1,141 @@ +package sdk + +import ( + "fmt" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + keptnv2 "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "time" +) + +func createStartedEvent(source string, parentEvent models.KeptnContextExtendedCE) (*models.KeptnContextExtendedCE, error) { + if parentEvent.Shkeptncontext == "" { + return nil, fmt.Errorf("unable to get keptn context from parent event %s", parentEvent.ID) + } + startedEventType, err := keptnv2.ReplaceEventTypeKind(*parentEvent.Type, "started") + if err != nil { + return nil, fmt.Errorf("unable to create '.started' event for parent event %s: %w", parentEvent.ID, err) + } + eventData := keptnv2.EventData{} + parentEvent.DataAs(&eventData) + return createEvent(source, startedEventType, parentEvent, eventData), nil +} + +func createFinishedEvent(source string, parentEvent models.KeptnContextExtendedCE, eventData interface{}) (*models.KeptnContextExtendedCE, error) { + if parentEvent.Type == nil { + return nil, fmt.Errorf("unable to get keptn event type from event %s", parentEvent.ID) + } + + if parentEvent.Shkeptncontext == "" { + return nil, fmt.Errorf("unable to get keptn context from parent event %s", parentEvent.ID) + } + finishedEventType, err := keptnv2.ReplaceEventTypeKind(*parentEvent.Type, "finished") + if err != nil { + return nil, fmt.Errorf("unable to create '.finished' event: %v from %s", err, *parentEvent.Type) + } + var genericEventData map[string]interface{} + err = keptnv2.Decode(eventData, &genericEventData) + if err != nil || genericEventData == nil { + return nil, fmt.Errorf("unable to decode generic event data") + } + + if genericEventData["status"] == nil || genericEventData["status"] == "" { + genericEventData["status"] = "succeeded" + } + + if genericEventData["result"] == nil || genericEventData["result"] == "" { + genericEventData["result"] = "pass" + } + return createEvent(source, finishedEventType, parentEvent, genericEventData), nil +} + +func createFinishedEventWithError(source string, parentEvent models.KeptnContextExtendedCE, eventData interface{}, errVal *Error) (*models.KeptnContextExtendedCE, error) { + if errVal == nil { + errVal = &Error{} + } + commonEventData := keptnv2.EventData{} + if eventData == nil { + parentEvent.DataAs(&commonEventData) + } + commonEventData.Result = errVal.ResultType + commonEventData.Status = errVal.StatusType + commonEventData.Message = errVal.Message + + finishedEventType, err := keptnv2.ReplaceEventTypeKind(*parentEvent.Type, "finished") + if err != nil { + return nil, fmt.Errorf("unable to create '.finished' event for parent event %s: %w", parentEvent.ID, err) + } + return createEvent(source, finishedEventType, parentEvent, commonEventData), nil +} + +func createErrorEvent(source string, parentEvent models.KeptnContextExtendedCE, eventData interface{}, errVal *Error) (*models.KeptnContextExtendedCE, error) { + if errVal == nil { + errVal = &Error{} + } + + if keptnv2.IsTaskEventType(*parentEvent.Type) && keptnv2.IsTriggeredEventType(*parentEvent.Type) { + errorFinishedEvent, err := createFinishedEventWithError(source, parentEvent, eventData, errVal) + if err != nil { + return nil, err + } + return errorFinishedEvent, nil + } + errorLogEvent, err := createErrorLogEvent(source, parentEvent, eventData, errVal) + if err != nil { + return nil, err + } + return errorLogEvent, nil +} + +func createErrorLogEvent(source string, parentEvent models.KeptnContextExtendedCE, eventData interface{}, errVal *Error) (*models.KeptnContextExtendedCE, error) { + if parentEvent.Type == nil { + return nil, fmt.Errorf("unable to get keptn event type from parent event %s", parentEvent.ID) + } + + if parentEvent.Shkeptncontext == "" { + return nil, fmt.Errorf("unable to get keptn context from parent event %s", parentEvent.ID) + } + if errVal == nil { + errVal = &Error{} + } + + if keptnv2.IsTaskEventType(*parentEvent.Type) && keptnv2.IsTriggeredEventType(*parentEvent.Type) { + errorFinishedEvent, err := createFinishedEventWithError(source, parentEvent, eventData, errVal) + if err != nil { + return nil, err + } + return errorFinishedEvent, nil + } + errorEventData := keptnv2.ErrorLogEvent{} + if eventData == nil { + parentEvent.DataAs(&errorEventData) + } + if keptnv2.IsTaskEventType(*parentEvent.Type) { + taskName, _, err := keptnv2.ParseTaskEventType(*parentEvent.Type) + if err == nil && taskName != "" { + errorEventData.Task = taskName + } + } + errorEventData.Message = errVal.Message + if parentEvent.Shkeptncontext == "" { + return nil, fmt.Errorf("unable to get keptn context from parent event %s", parentEvent.ID) + } + return createEvent(source, keptnv2.ErrorLogEventName, parentEvent, errorEventData), nil +} + +func createEvent(source string, eventType string, parentEvent models.KeptnContextExtendedCE, eventData interface{}) *models.KeptnContextExtendedCE { + return &models.KeptnContextExtendedCE{ + ID: uuid.NewString(), + Triggeredid: parentEvent.ID, + Shkeptncontext: parentEvent.Shkeptncontext, + Contenttype: cloudevents.ApplicationJSON, + Data: eventData, + Source: strutils.Stringp(source), + Shkeptnspecversion: shkeptnspecversion, + Specversion: cloudeventsversion, + Time: time.Now().UTC(), + Type: strutils.Stringp(eventType), + } +} diff --git a/pkg/sdk/events_test.go b/pkg/sdk/events_test.go new file mode 100644 index 00000000..6e6c5f9f --- /dev/null +++ b/pkg/sdk/events_test.go @@ -0,0 +1,352 @@ +package sdk + +import ( + "fmt" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/stretchr/testify/require" + "testing" +) + +func Test_createFinishedEvent(t *testing.T) { + type args struct { + source string + parentEvent models.KeptnContextExtendedCE + eventData interface{} + } + tests := []struct { + name string + args args + assertEvent func(*models.KeptnContextExtendedCE) bool + wantErr bool + }{ + { + name: "missing event type", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{}, + eventData: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "missing event context", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Type: strutils.Stringp("sh.keptn.event.evaluation.triggered"), + }, + eventData: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "event type cannot be replaced", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abcde", + Type: strutils.Stringp("something.weird"), + }, + eventData: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "passed event data missing", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{}, + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "defalut status succeeded", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{}, + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: v0_2_0.EventData{}, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { + return ce.Data.(map[string]interface{})["status"] == string(v0_2_0.StatusSucceeded) && ce.Data.(map[string]interface{})["result"] == string(v0_2_0.ResultPass) + }, + wantErr: false, + }, + { + name: "defalut result pass", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{}, + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: v0_2_0.EventData{}, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { + return ce.Data.(map[string]interface{})["result"] == "pass" + }, + wantErr: false, + }, + { + name: "passed status reused", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{}, + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: v0_2_0.EventData{Status: v0_2_0.StatusErrored, Result: v0_2_0.ResultFailed}, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { + fmt.Println(ce.Data.(map[string]interface{})["status"]) + return ce.Data.(map[string]interface{})["status"] == string(v0_2_0.StatusErrored) && ce.Data.(map[string]interface{})["result"] == string(v0_2_0.ResultFailed) + }, + wantErr: false, + }, + { + name: "correct event type", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{}, + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: v0_2_0.EventData{Status: v0_2_0.StatusSucceeded, Result: v0_2_0.ResultPass}, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { + fmt.Println(ce.Data.(map[string]interface{})["status"]) + return *ce.Type == "sh.keptn.event.eval.finished" + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createFinishedEvent(tt.args.source, tt.args.parentEvent, tt.args.eventData) + if (err != nil) != tt.wantErr { + t.Errorf("createFinishedEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.True(t, tt.assertEvent(got)) + }) + } +} + +func Test_createStartedEvent(t *testing.T) { + type args struct { + source string + parentEvent models.KeptnContextExtendedCE + } + tests := []struct { + name string + args args + assertEvent func(*models.KeptnContextExtendedCE) bool + wantErr bool + }{ + { + name: "missing keptn context", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{}, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "non-replacable event type", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abce", + Type: strutils.Stringp("somethin.weird"), + }, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "ok", + args: args{ + source: "source", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return *ce.Type == "sh.keptn.event.eval.started" }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createStartedEvent(tt.args.source, tt.args.parentEvent) + if (err != nil) != tt.wantErr { + t.Errorf("createStartedEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.True(t, tt.assertEvent(got)) + }) + } +} + +func Test_createErrorLogEvent(t *testing.T) { + type args struct { + source string + parentEvent models.KeptnContextExtendedCE + eventData interface{} + errVal *Error + } + tests := []struct { + name string + args args + assertEvent func(*models.KeptnContextExtendedCE) bool + wantErr bool + }{ + { + name: "missing event type", + args: args{ + source: "soure", + parentEvent: models.KeptnContextExtendedCE{}, + eventData: nil, + errVal: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "missing keptn context", + args: args{ + source: "soure", + parentEvent: models.KeptnContextExtendedCE{ + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: nil, + errVal: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "creates finished event for events of type .triggered", + args: args{ + source: "soure", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: nil, + errVal: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return *ce.Type == "sh.keptn.event.eval.finished" }, + wantErr: false, + }, + { + name: "creates error event for events other than .triggered", + args: args{ + source: "soure", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.started"), + }, + eventData: nil, + errVal: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return *ce.Type == v0_2_0.ErrorLogEventName }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createErrorLogEvent(tt.args.source, tt.args.parentEvent, tt.args.eventData, tt.args.errVal) + if (err != nil) != tt.wantErr { + t.Errorf("createErrorLogEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.True(t, tt.assertEvent(got)) + }) + } +} + +func Test_createErrorEvent(t *testing.T) { + type args struct { + source string + parentEvent models.KeptnContextExtendedCE + eventData interface{} + err *Error + } + tests := []struct { + name string + args args + assertEvent func(*models.KeptnContextExtendedCE) bool + wantErr bool + }{ + { + name: "missing keptn context", + args: args{ + source: "", + parentEvent: models.KeptnContextExtendedCE{ + Type: strutils.Stringp("sh.keptn.event.eval.started"), + }, + eventData: nil, + err: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return ce == nil }, + wantErr: true, + }, + { + name: "creates error event for events other than .triggered", + args: args{ + source: "", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.started"), + }, + eventData: nil, + err: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return *ce.Type == v0_2_0.ErrorLogEventName }, + wantErr: false, + }, + { + name: "creates finished event for events of type .triggered", + args: args{ + source: "", + parentEvent: models.KeptnContextExtendedCE{ + Shkeptncontext: "abcde", + Type: strutils.Stringp("sh.keptn.event.eval.triggered"), + }, + eventData: nil, + err: nil, + }, + assertEvent: func(ce *models.KeptnContextExtendedCE) bool { return *ce.Type == "sh.keptn.event.eval.finished" }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createErrorEvent(tt.args.source, tt.args.parentEvent, tt.args.eventData, tt.args.err) + if (err != nil) != tt.wantErr { + t.Errorf("createErrorEvent() error = %v, wantErr %v", err, tt.wantErr) + return + } + require.True(t, tt.assertEvent(got)) + }) + } +} diff --git a/pkg/sdk/example/handler.go b/pkg/sdk/example/handler.go new file mode 100644 index 00000000..270bfa3c --- /dev/null +++ b/pkg/sdk/example/handler.go @@ -0,0 +1,48 @@ +package main + +import ( + "bytes" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/keptn/go-utils/pkg/sdk" + "html/template" +) + +type GreetingsHandler struct { +} + +type GreetingTriggeredData struct { + v0_2_0.EventData + Text string `json:"text"` +} + +type GreetingFinishedData struct { + v0_2_0.EventData + GreetMessage string `json:"greetMessage"` +} + +func NewGreetingsHandler() *GreetingsHandler { + return &GreetingsHandler{} +} + +func (g *GreetingsHandler) Execute(k sdk.IKeptn, event sdk.KeptnEvent) (interface{}, *sdk.Error) { + greetingsTriggeredData := &GreetingTriggeredData{} + if err := v0_2_0.Decode(event.Data, greetingsTriggeredData); err != nil { + return nil, &sdk.Error{Err: err, StatusType: v0_2_0.StatusErrored, ResultType: v0_2_0.ResultFailed, Message: "Could not decode input event data"} + } + name := struct{ Name string }{"Keptn"} + + tmpl, err := template.New("").Parse(greetingsTriggeredData.Text) + if err != nil { + return nil, &sdk.Error{Err: err, StatusType: v0_2_0.StatusErrored, ResultType: v0_2_0.ResultFailed, Message: "Could not parse greeting message"} + } + + var greetMessage bytes.Buffer + if err = tmpl.Execute(&greetMessage, name); err != nil { + return nil, &sdk.Error{Err: err, StatusType: v0_2_0.StatusErrored, ResultType: v0_2_0.ResultFailed, Message: "Could not parse process greeting message"} + } + finishedEventData := GreetingFinishedData{ + EventData: greetingsTriggeredData.EventData, + GreetMessage: greetMessage.String(), + } + return finishedEventData, nil +} diff --git a/pkg/sdk/example/handler_test.go b/pkg/sdk/example/handler_test.go new file mode 100644 index 00000000..e7d437c1 --- /dev/null +++ b/pkg/sdk/example/handler_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "github.com/keptn/go-utils/pkg/api/models" + keptnv2 "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/keptn/go-utils/pkg/sdk" + "io/ioutil" + "log" + "testing" +) + +func Test_Handler(t *testing.T) { + fakeKeptn := sdk.NewFakeKeptn("test-greeting-svc") + fakeKeptn.AddTaskHandler(greetingsTriggeredEventType, NewGreetingsHandler()) + fakeKeptn.NewEvent(newNewGreetingTriggeredEvent("test-assets/events/greeting.triggered-0.json")) + fakeKeptn.AssertNumberOfEventSent(t, 2) + fakeKeptn.AssertSentEventType(t, 0, keptnv2.GetStartedEventType("greeting")) + fakeKeptn.AssertSentEventType(t, 1, keptnv2.GetFinishedEventType("greeting")) + fakeKeptn.AssertSentEvent(t, 1, func(e models.KeptnContextExtendedCE) bool { + greetingFinishedData := GreetingFinishedData{} + e.DataAs(&greetingFinishedData) + return "Hi, my name is Keptn" == greetingFinishedData.GreetMessage + }) +} + +func newNewGreetingTriggeredEvent(filename string) models.KeptnContextExtendedCE { + content, err := ioutil.ReadFile(filename) + if err != nil { + log.Fatal(err) + } + event := models.KeptnContextExtendedCE{} + err = json.Unmarshal(content, &event) + _ = err + return event +} diff --git a/pkg/sdk/example/main.go b/pkg/sdk/example/main.go new file mode 100644 index 00000000..bb5da5e9 --- /dev/null +++ b/pkg/sdk/example/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/keptn/go-utils/pkg/sdk" + "log" +) + +const greetingsTriggeredEventType = "sh.keptn.event.greeting.triggered" +const serviceName = "greetings-service" + +func main() { + log.Fatal(sdk.NewKeptn( + serviceName, + sdk.WithTaskHandler( + greetingsTriggeredEventType, + NewGreetingsHandler()), + ).Start()) +} diff --git a/pkg/sdk/example/test-assets/events/greeting.triggered-0.json b/pkg/sdk/example/test-assets/events/greeting.triggered-0.json new file mode 100644 index 00000000..21922dfb --- /dev/null +++ b/pkg/sdk/example/test-assets/events/greeting.triggered-0.json @@ -0,0 +1,15 @@ +{ + "type": "sh.keptn.event.greeting.triggered", + "specversion": "1.0", + "source": "test", + "id": "f2b878d3-03c0-4e8f-bc3f-454bc1b3d79d", + "time": "2019-06-07T07:02:15.64489Z", + "contenttype": "application/json", + "shkeptncontext": "08735340-6f9e-4b32-97ff-3b6c292bc50f", + "data": { + "project": "my-project", + "service": "my-service", + "stage": "ms-stage", + "text": "Hi, my name is {{ .Name }}" + } +} \ No newline at end of file diff --git a/pkg/sdk/internal/api/client.go b/pkg/sdk/internal/api/client.go new file mode 100644 index 00000000..9a7d1a66 --- /dev/null +++ b/pkg/sdk/internal/api/client.go @@ -0,0 +1,106 @@ +package api + +import ( + "context" + "crypto/tls" + "fmt" + oauthutils "github.com/keptn/go-utils/pkg/common/oauth2" + "github.com/keptn/go-utils/pkg/sdk/internal/config" + logger "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + "net/http" + "time" +) + +// CreateClientGetter returns a HTTPClientGetter implementation based on the values certain properties +// inside the given env configuration +func CreateClientGetter(envConfig config.EnvConfig) HTTPClientGetter { + if envConfig.OAuthEnabled() { + logger.Infof("Using Oauth to connect to Keptn wth client ID %s and scopes %v", envConfig.OAuthClientID, envConfig.OAuthScopes) + return NewOauthClientGetter(envConfig, oauthutils.NewOauthDiscovery(&http.Client{})) + } + return New(envConfig) +} + +// HTTPClientGetter is responsible for creating an HTTP client +type HTTPClientGetter interface { + // Get Creates the HTTP Client + Get() (*http.Client, error) +} + +// OAuthClientGetter creates an HTTP client configured for use with SSO/Oauth +type OAuthClientGetter struct { + *SimpleClientGetter + envConfig config.EnvConfig + oauthDiscovery oauthutils.OauthLocationGetter +} + +// NewOauthClientGetter creates a new instance of a OAuthClientGetter +func NewOauthClientGetter(envConfig config.EnvConfig, oauthDiscovery oauthutils.OauthLocationGetter) *OAuthClientGetter { + return &OAuthClientGetter{ + SimpleClientGetter: &SimpleClientGetter{envConfig: envConfig}, + envConfig: envConfig, + oauthDiscovery: oauthDiscovery, + } +} + +func (g *OAuthClientGetter) Get() (*http.Client, error) { + c, err := g.SimpleClientGetter.Get() + if err != nil { + return nil, err + } + if g.envConfig.OAuthClientID == "" || g.envConfig.OAuthClientSecret == "" || len(g.envConfig.OAuthScopes) == 0 { + return nil, fmt.Errorf("client id or client secret or scopes missing") + } + + if g.envConfig.OauthTokenURL != "" { + logger.Infof("Using Token URL for Oauth flow: %s", g.envConfig.OauthTokenURL) + conf := clientcredentials.Config{ + ClientID: g.envConfig.OAuthClientID, + ClientSecret: g.envConfig.OAuthClientSecret, + Scopes: g.envConfig.OAuthScopes, + TokenURL: g.envConfig.OauthTokenURL, + } + return conf.Client(context.WithValue(context.TODO(), oauth2.HTTPClient, c)), nil + } + + if g.envConfig.OAuthDiscovery != "" { + logger.Infof("Using Discovery URL for Oauth flow: %s", g.envConfig.OAuthDiscovery) + ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10) + defer cancel() + discoveryRes, err := g.oauthDiscovery.Discover(ctx, g.envConfig.OAuthDiscovery) + if err != nil { + return nil, err + } + + conf := clientcredentials.Config{ + ClientID: g.envConfig.OAuthClientID, + ClientSecret: g.envConfig.OAuthClientSecret, + Scopes: g.envConfig.OAuthScopes, + TokenURL: discoveryRes.TokenEndpoint, + } + return conf.Client(context.WithValue(context.TODO(), oauth2.HTTPClient, c)), nil + } + return nil, fmt.Errorf("no discovery or token url is provided") +} + +// SimpleClientGetter creates a basic HTTP client +type SimpleClientGetter struct { + envConfig config.EnvConfig +} + +// New Creates a new instance of a SimpleClientGetter +func New(envConfig config.EnvConfig) *SimpleClientGetter { + return &SimpleClientGetter{envConfig: envConfig} +} + +func (g *SimpleClientGetter) Get() (*http.Client, error) { + c := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: !g.envConfig.VerifySSL}, //nolint:gosec + }, + Timeout: g.envConfig.GetAPIProxyHTTPTimeout(), + } + return c, nil +} diff --git a/pkg/sdk/internal/api/client_test.go b/pkg/sdk/internal/api/client_test.go new file mode 100644 index 00000000..a370c6c7 --- /dev/null +++ b/pkg/sdk/internal/api/client_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "fmt" + oauthutils "github.com/keptn/go-utils/pkg/common/oauth2" + "github.com/keptn/go-utils/pkg/sdk/internal/config" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSimpleClientGetter_Get(t *testing.T) { + cfg := config.EnvConfig{} + getter := New(cfg) + c, err := getter.Get() + assert.NotNil(t, c) + assert.Nil(t, err) +} + +func TestOAuthClientGetter_Get(t *testing.T) { + t.Run("Get - No Discovery, nor Token URL given", func(t *testing.T) { + cfg := config.EnvConfig{ + OAuthClientID: "client-id", + OAuthClientSecret: "client-secret", + OAuthScopes: []string{"scope"}, + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{}} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.Nil(t, c) + assert.NotNil(t, err) + }) + t.Run("Get - With Discovery URL", func(t *testing.T) { + cfg := config.EnvConfig{ + OAuthClientID: "client-id", + OAuthClientSecret: "client-secret", + OAuthScopes: []string{"scope"}, + OAuthDiscovery: "http://some-url.com", + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{}} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.NotNil(t, c) + assert.Nil(t, err) + }) + t.Run("Get - With Token URL", func(t *testing.T) { + cfg := config.EnvConfig{ + OAuthClientID: "client-id", + OAuthClientSecret: "client-secret", + OAuthScopes: []string{"scope"}, + OauthTokenURL: "http://some-url.com", + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{}} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.NotNil(t, c) + assert.Nil(t, err) + }) + t.Run("Get - missing scopes", func(t *testing.T) { + cfg := config.EnvConfig{ + OAuthClientID: "client-id", + OAuthClientSecret: "client-secret", + OAuthDiscovery: "http://some-url.com", + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{}} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.Nil(t, c) + assert.NotNil(t, err) + }) + t.Run("Get - missing client id", func(t *testing.T) { + cfg := config.EnvConfig{ + OAuthClientSecret: "client-secret", + OAuthScopes: []string{"scope"}, + OAuthDiscovery: "http://some-url.com", + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{}} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.Nil(t, c) + assert.NotNil(t, err) + }) + t.Run("Get - missing client secret", func(t *testing.T) { + cfg := config.EnvConfig{ + OAuthClientID: "client-id", + OAuthScopes: []string{"scope"}, + OAuthDiscovery: "http://some-url.com", + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{}} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.Nil(t, c) + assert.NotNil(t, err) + }) +} + +func TestOAuthClientGetter_Get_TokenEndpointIsCalled(t *testing.T) { + tokenURLCalled := false + tokenURLSrv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + tokenURLCalled = true + rw.WriteHeader(http.StatusOK) + })) + defer tokenURLSrv.Close() + + cfg := config.EnvConfig{ + OAuthClientID: "client-id", + OAuthClientSecret: "client-secret", + OAuthScopes: []string{"scope"}, + OAuthDiscovery: "http://some-wellknown-url.com", + } + oauthDiscovery := &oauthutils.StaticOauthDiscovery{DiscoveryValues: &oauthutils.OauthDiscoveryResult{ + TokenEndpoint: tokenURLSrv.URL, + }} + + c, err := NewOauthClientGetter(cfg, oauthDiscovery).Get() + assert.NotNil(t, c) + assert.Nil(t, err) + + // next line will obviously fail, + // but we only want to check whether the token endpoint + // is called + c.Get("localhost") + assert.Eventually(t, func() bool { + fmt.Println(tokenURLCalled) + return tokenURLCalled == true + }, time.Second, 100*time.Millisecond) + +} diff --git a/pkg/sdk/internal/api/initializer.go b/pkg/sdk/internal/api/initializer.go new file mode 100644 index 00000000..8e70f7d7 --- /dev/null +++ b/pkg/sdk/internal/api/initializer.go @@ -0,0 +1,46 @@ +package api + +import ( + "fmt" + keptnapi "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/sdk/internal/config" + "net/http" + "net/url" + "strings" +) + +// Initializer implements both methods of creating a new keptn API with internal or remote execution plane +type Initializer struct { + Remote func(baseURL string, options ...func(*keptnapi.APISet)) (*keptnapi.APISet, error) + Internal func(client *http.Client, apiMappings ...keptnapi.InClusterAPIMappings) (*keptnapi.InternalAPISet, error) +} + +func CreateKeptnAPI(httpClient *http.Client, env config.EnvConfig) (keptnapi.KeptnInterface, error) { + return createAPI(httpClient, env, Initializer{keptnapi.New, keptnapi.NewInternal}) +} + +func createAPI(httpClient *http.Client, env config.EnvConfig, apiInit Initializer) (keptnapi.KeptnInterface, error) { + if httpClient == nil { + httpClient = &http.Client{} + } + if env.PubSubConnectionType() == config.ConnectionTypeHTTP { + scheme := "http" + parsed, err := url.ParseRequestURI(env.KeptnAPIEndpoint) + if err != nil { + return nil, err + } + + // accepts either "" or http + if parsed.Scheme == "" || !strings.HasPrefix(parsed.Scheme, "http") { + return nil, fmt.Errorf("invalid scheme for keptn endpoint, %s is not http or https", env.KeptnAPIEndpoint) + } + + if strings.HasPrefix(parsed.Scheme, "http") { + // if no value is assigned to the endpoint than we keep the default scheme + scheme = parsed.Scheme + } + return apiInit.Remote(env.KeptnAPIEndpoint, keptnapi.WithScheme(scheme), keptnapi.WithHTTPClient(httpClient), keptnapi.WithAuthToken(env.KeptnAPIToken)) + } + + return apiInit.Internal(httpClient) +} diff --git a/pkg/sdk/internal/api/initializer_test.go b/pkg/sdk/internal/api/initializer_test.go new file mode 100644 index 00000000..91a306fc --- /dev/null +++ b/pkg/sdk/internal/api/initializer_test.go @@ -0,0 +1,74 @@ +package api + +import ( + keptnapi "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/sdk/internal/config" + "net/http" + "reflect" + "testing" +) + +func Test_createAPI(t *testing.T) { + + apiInit := Initializer{ + Internal: func(client *http.Client, apiMappings ...keptnapi.InClusterAPIMappings) (*keptnapi.InternalAPISet, error) { + return &keptnapi.InternalAPISet{}, nil + }, + Remote: func(baseURL string, options ...func(*keptnapi.APISet)) (*keptnapi.APISet, error) { + return &keptnapi.APISet{}, nil + }, + } + + tests := []struct { + name string + env config.EnvConfig + wantInternal bool + wantErr bool + }{ + { + name: "test no env internal NATS ", + env: config.EnvConfig{}, + wantInternal: true, + wantErr: false, + }, + { + name: "test FAIL for no http address", + env: config.EnvConfig{ + KeptnAPIEndpoint: "ssh://mynotsogoodendpoint", + }, + wantErr: true, + wantInternal: false, + }, + { + name: "test FAIL for no good address", + env: config.EnvConfig{ + KeptnAPIEndpoint: ":///MALFORMEDendpoint", + }, + wantErr: true, + wantInternal: false, + }, + { + name: "test PASS for http address", + env: config.EnvConfig{ + KeptnAPIEndpoint: "http://endpoint", + }, + wantErr: false, + wantInternal: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createAPI(nil, tt.env, apiInit) + if (err != nil) != tt.wantErr { + t.Errorf("createAPI() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil && tt.wantInternal && !reflect.DeepEqual(got, &keptnapi.InternalAPISet{}) { + t.Errorf("createAPI() got = %v, wanted internal API", got) + } else if err == nil && !tt.wantInternal && !reflect.DeepEqual(got, &keptnapi.APISet{}) { + t.Errorf("createAPI() got = %v, want remote execution plane", got) + } + + }) + } +} diff --git a/pkg/sdk/internal/config/config.go b/pkg/sdk/internal/config/config.go new file mode 100644 index 00000000..c6ec6f0e --- /dev/null +++ b/pkg/sdk/internal/config/config.go @@ -0,0 +1,71 @@ +package config + +import ( + "github.com/kelseyhightower/envconfig" + "log" + "strconv" + "time" +) + +type EnvConfig struct { + APIProxyHTTPTimeout string `envconfig:"API_PROXY_HTTP_TIMEOUT" default:"30"` + ConfigurationServiceURL string `envconfig:"CONFIGURATION_SERVICE" default:"configuration-service:8080"` + EventBrokerURL string `envconfig:"EVENTBROKER" default:"nats://keptn-nats"` + PubSubTopic string `envconfig:"PUBSUB_TOPIC" default:""` + HealthEndpointPort string `envconfig:"HEALTH_ENDPOINT_PORT" default:"8080"` + HealthEndpointEnabled bool `envconfig:"HEALTH_ENDPOINT_ENABLED" default:"true"` + KeptnAPIEndpoint string `envconfig:"KEPTN_API_ENDPOINT" default:""` + KeptnAPIToken string `envconfig:"KEPTN_API_TOKEN" default:""` + Location string `envconfig:"LOCATION" default:"control-plane"` + K8sDeploymentVersion string `envconfig:"K8S_DEPLOYMENT_VERSION" default:""` + K8sDeploymentName string `envconfig:"K8S_DEPLOYMENT_NAME" default:""` + K8sNamespace string `envconfig:"K8S_NAMESPACE" default:""` + K8sPodName string `envconfig:"K8S_POD_NAME" default:""` + K8sNodeName string `envconfig:"K8S_NODE_NAME" default:""` + OAuthClientID string `envconfig:"OAUTH_CLIENT_ID" default:""` + OAuthClientSecret string `envconfig:"OAUTH_CLIENT_SECRET" default:""` + OAuthScopes []string `envconfig:"OAUTH_SCOPES" default:""` + OAuthDiscovery string `envconfig:"OAUTH_DISCOVERY" default:""` + OauthTokenURL string `envconfig:"OAUTH_TOKEN_URL" default:""` + VerifySSL bool `envconfig:"HTTP_SSL_VERIFY" default:"true"` +} + +type ConnectionType string + +const ( + DefaultAPIProxyHTTPTimeout = 30 + ConnectionTypeNATS ConnectionType = "nats" + ConnectionTypeHTTP ConnectionType = "http" +) + +func NewEnvConfig() EnvConfig { + var env EnvConfig + if err := envconfig.Process("", &env); err != nil { + log.Fatalf("failed to process env var: %s", err) + } + return env +} + +func (env *EnvConfig) OAuthEnabled() bool { + clientIDAndSecretSet := env.OAuthClientID != "" && env.OAuthClientSecret != "" + tokenURLOrDiscoverySet := env.OauthTokenURL != "" || env.OAuthDiscovery != "" + scopesSet := len(env.OAuthScopes) > 0 + return clientIDAndSecretSet && tokenURLOrDiscoverySet && scopesSet +} + +func (env *EnvConfig) GetAPIProxyHTTPTimeout() time.Duration { + timeout, err := strconv.ParseInt(env.APIProxyHTTPTimeout, 10, 64) + if err != nil { + timeout = DefaultAPIProxyHTTPTimeout + } + return time.Duration(timeout) * time.Second +} + +func (env *EnvConfig) PubSubConnectionType() ConnectionType { + if env.KeptnAPIEndpoint == "" { + // if no Keptn API URL has been defined, this means that run inside the Keptn cluster -> we can subscribe to events directly via NATS + return ConnectionTypeNATS + } + // if a Keptn API URL has been defined, this means that the distributor runs outside of the Keptn cluster -> therefore no NATS connection is possible + return ConnectionTypeHTTP +} diff --git a/pkg/sdk/keptn.go b/pkg/sdk/keptn.go new file mode 100644 index 00000000..a9b5a1d2 --- /dev/null +++ b/pkg/sdk/keptn.go @@ -0,0 +1,411 @@ +package sdk + +import ( + "context" + eventsource "github.com/keptn/go-utils/pkg/sdk/connector/eventsource/nats" + "github.com/keptn/go-utils/pkg/sdk/connector/logforwarder" + "github.com/keptn/go-utils/pkg/sdk/connector/subscriptionsource" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + sdk "github.com/keptn/go-utils/pkg/sdk/internal/api" + "github.com/keptn/go-utils/pkg/sdk/internal/config" + logger "github.com/sirupsen/logrus" + "log" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/kelseyhightower/envconfig" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + keptnv2 "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/keptn/go-utils/pkg/sdk/connector/controlplane" + "github.com/keptn/go-utils/pkg/sdk/connector/nats" +) + +const ( + shkeptnspecversion = "0.2.4" + cloudeventsversion = "1.0" +) + +type IKeptn interface { + // Start starts the internal event handling logic and needs to be called by the user + // after creating value of IKeptn + Start() error + // GetResourceHandler returns a handler to fetch data from the configuration service + GetResourceHandler() ResourceHandler + // SendStartedEvent sends a started event for the given input event to the Keptn API + SendStartedEvent(event KeptnEvent) error + // SendFinishedEvent sends a finished event for the given input event to the Keptn API + SendFinishedEvent(event KeptnEvent, result interface{}) error + // Logger returns the logger used by the sdk + // Per default DefaultLogger is used which internally just uses the go logging package + // Another logger can be configured using the sdk.WithLogger function + Logger() Logger + // APIV1 returns API utils for all Keptn APIs + APIV1() api.KeptnInterface +} + +type TaskHandler interface { + // Execute is called whenever the actual business-logic of the service shall be executed. + // Thus, the core logic of the service shall be triggered/implemented in this method. + // + // Note, that the contract of the method is to return the payload of the .finished event to be sent out as well as a Error Pointer + // or nil, if there was no error during execution. + Execute(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) +} + +type KeptnEvent models.KeptnContextExtendedCE + +type Error struct { + StatusType keptnv2.StatusType + ResultType keptnv2.ResultType + Message string + Err error +} + +func (e Error) Error() string { + return e.Message +} + +// KeptnOption can be used to configure the keptn sdk +type KeptnOption func(*Keptn) + +type ResourceHandler interface { + GetResource(scope api.ResourceScope, options ...api.URIOption) (*models.Resource, error) +} + +type healthEndpointRunner func(port string, cp *controlplane.ControlPlane) + +// Opaque key type used for graceful shutdown context value +type gracefulShutdownKeyType struct{} + +var gracefulShutdownKey = gracefulShutdownKeyType{} + +type wgInterface interface { + Add(delta int) + Done() + Wait() +} + +type nopWG struct { + // -- +} + +func (w *nopWG) Add(delta int) { + // -- +} +func (w *nopWG) Done() { + // -- +} +func (w *nopWG) Wait() { + // -- +} + +// WithTaskHandler registers a handler which is responsible for processing a .triggered event +func WithTaskHandler(eventType string, handler TaskHandler, filters ...func(keptnHandle IKeptn, event KeptnEvent) bool) KeptnOption { + return func(k *Keptn) { + k.taskRegistry.Add(eventType, taskEntry{taskHandler: handler, eventFilters: filters}) + } +} + +// WithAutomaticResponse sets the option to instruct the sdk to automatically send a .started and .finished event. +// Per default this behavior is turned on and can be disabled with this function +func WithAutomaticResponse(autoResponse bool) KeptnOption { + return func(k *Keptn) { + k.automaticEventResponse = autoResponse + } +} + +// WithGracefulShutdown sets the option to ensure running tasks/handlers will finish in case of interrupt or forced termination +// Per default this behavior is turned on and can be disabled with this function +func WithGracefulShutdown(gracefulShutdown bool) KeptnOption { + return func(k *Keptn) { + k.gracefulShutdown = gracefulShutdown + } +} + +// WithLogger configures keptn to use another logger +func WithLogger(logger Logger) KeptnOption { + return func(k *Keptn) { + k.logger = logger + } +} + +// Keptn is the default implementation of IKeptn +type Keptn struct { + controlPlane *controlplane.ControlPlane + eventSender controlplane.EventSender + resourceHandler ResourceHandler + api api.KeptnInterface + source string + taskRegistry *taskRegistry + syncProcessing bool + automaticEventResponse bool + gracefulShutdown bool + logger Logger + env config.EnvConfig + healthEndpointRunner healthEndpointRunner +} + +// NewKeptn creates a new Keptn +func NewKeptn(source string, opts ...KeptnOption) *Keptn { + env := config.NewEnvConfig() + apiSet, controlPlane, eventSender := newControlPlaneFromEnv() + resourceHandler := newResourceHandlerFromEnv() + taskRegistry := newTaskMap() + logger := newDefaultLogger() + keptn := &Keptn{ + controlPlane: controlPlane, + eventSender: eventSender, + source: source, + taskRegistry: taskRegistry, + resourceHandler: resourceHandler, + api: apiSet, + automaticEventResponse: true, + gracefulShutdown: true, + syncProcessing: false, + logger: logger, + env: env, + healthEndpointRunner: newHealthEndpointRunner, + } + for _, opt := range opts { + opt(keptn) + } + return keptn +} + +func (k *Keptn) OnEvent(ctx context.Context, event models.KeptnContextExtendedCE) error { + k.logger.Debug("Handling event ", event) + eventSender, ok := ctx.Value(types.EventSenderKey).(controlplane.EventSender) + if !ok { + k.logger.Errorf("Unable to get event sender. Skip processing of event %s", event.ID) + return nil + } + + if event.Type == nil { + k.logger.Errorf("Unable to get event type. Skip processing of event %s", event.ID) + return nil + } + + if !keptnv2.IsTaskEventType(*event.Type) { + k.logger.Errorf("Event type %s does not match format for task events. Skip Processing of event %s", *event.Type, event.ID) + return nil + } + wg, ok := ctx.Value(gracefulShutdownKey).(wgInterface) + if !ok { + k.logger.Errorf("Unable to get graceful shutdown wait group. Skip processing of event %s", event.ID) + return nil + } + wg.Add(1) + k.runEventTaskAction(func() { + { + defer wg.Done() + if handler, ok := k.taskRegistry.Contains(*event.Type); ok { + keptnEvent := &KeptnEvent{} + if err := keptnv2.Decode(&event, keptnEvent); err != nil { + errorLogEvent, err := createErrorLogEvent(k.source, event, nil, &Error{Err: err, StatusType: keptnv2.StatusErrored, ResultType: keptnv2.ResultFailed}) + if err != nil { + k.logger.Errorf("Unable to create '.error.log' event from '.triggered' event: %v", err) + return + } + // no started event sent yet, so it only makes sense to Send an error log event at this point + if err := eventSender(*errorLogEvent); err != nil { + k.logger.Errorf("Unable to send '.finished' event: %v", err) + return + } + } + + // execute the filtering functions of the task handler to determine whether the incoming event should be handled + // only if all functions return true, the event will be handled + for _, filterFn := range handler.eventFilters { + if !filterFn(k, *keptnEvent) { + k.logger.Infof("Will not handle incoming %s event", *event.Type) + return + } + } + + // only respond with .started event if the incoming event is a task.triggered event + if keptnv2.IsTaskEventType(*event.Type) && keptnv2.IsTriggeredEventType(*event.Type) && k.automaticEventResponse { + startedEvent, err := createStartedEvent(k.source, event) + if err != nil { + k.logger.Errorf("Unable to create '.started' event from '.triggered' event: %v", err) + return + } + if err := eventSender(*startedEvent); err != nil { + k.logger.Errorf("Unable to send '.started' event: %v", err) + return + } + } + + result, err := handler.taskHandler.Execute(k, *keptnEvent) + if err != nil { + k.logger.Errorf("Error during task execution %v", err.Err) + if k.automaticEventResponse { + errorEvent, err := createErrorEvent(k.source, event, result, err) + if err != nil { + k.logger.Errorf("Unable to create '.error' event: %v", err) + return + } + if err := eventSender(*errorEvent); err != nil { + k.logger.Errorf("Unable to send '.error' event: %v", err) + return + } + } + return + } + if result == nil { + k.logger.Infof("no finished data set by task executor for event %s. Skipping sending finished event", *event.Type) + } else if keptnv2.IsTaskEventType(*event.Type) && keptnv2.IsTriggeredEventType(*event.Type) && k.automaticEventResponse { + finishedEvent, err := createFinishedEvent(k.source, event, result) + if err != nil { + k.logger.Errorf("Unable to create '.finished' event: %v", err) + return + } + if err := eventSender(*finishedEvent); err != nil { + k.logger.Errorf("Unable to send '.finished' event: %v", err) + return + } + } + } + } + }) + return nil +} + +func (k *Keptn) RegistrationData() controlplane.RegistrationData { + subscriptions := []models.EventSubscription{} + subjects := []string{} + if k.env.PubSubTopic != "" { + subjects = strings.Split(k.env.PubSubTopic, ",") + } + + for _, s := range subjects { + subscriptions = append(subscriptions, models.EventSubscription{Event: s}) + } + return controlplane.RegistrationData{ + Name: k.source, + MetaData: models.MetaData{ + Hostname: k.env.K8sNodeName, + IntegrationVersion: k.env.K8sDeploymentVersion, + Location: k.env.Location, + DistributorVersion: "0.15.0", // note: to be deleted when bridge stops requiring this info + KubernetesMetaData: models.KubernetesMetaData{ + Namespace: k.env.K8sNamespace, + PodName: k.env.K8sPodName, + DeploymentName: k.env.K8sDeploymentName, + }, + }, + Subscriptions: subscriptions, + } +} + +func (k *Keptn) Start() error { + if k.env.HealthEndpointEnabled { + k.healthEndpointRunner(k.env.HealthEndpointPort, k.controlPlane) + } + ctx, wg := k.getContext(k.gracefulShutdown) + err := k.controlPlane.Register(ctx, k) + // add additional waiting time to ensure the waitGroup has been increased for all events that have been received between receiving SIGTERM and this point + <-time.After(5 * time.Second) + wg.Wait() + return err +} + +func (k *Keptn) GetResourceHandler() ResourceHandler { + return k.resourceHandler +} + +func (k *Keptn) SendStartedEvent(event KeptnEvent) error { + finishedEvent, err := createStartedEvent(k.source, models.KeptnContextExtendedCE(event)) + if err != nil { + return err + } + return k.eventSender(*finishedEvent) +} + +func (k *Keptn) SendFinishedEvent(event KeptnEvent, result interface{}) error { + finishedEvent, err := createFinishedEvent(k.source, models.KeptnContextExtendedCE(event), result) + if err != nil { + return err + } + return k.eventSender(*finishedEvent) +} + +func (k *Keptn) APIV1() api.KeptnInterface { + return k.api +} + +func (k *Keptn) Logger() Logger { + return k.logger +} + +func (k *Keptn) runEventTaskAction(fn func()) { + if k.syncProcessing { + fn() + } else { + go fn() + } +} + +func (k *Keptn) getContext(graceful bool) (context.Context, wgInterface) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + var wg wgInterface + if graceful { + wg = &sync.WaitGroup{} + } else { + wg = &nopWG{} + } + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), gracefulShutdownKey, wg)) + go func() { + <-ch + cancel() + }() + return ctx, wg +} + +func noOpHealthEndpointRunner(port string, cp *controlplane.ControlPlane) {} + +func newHealthEndpointRunner(port string, cp *controlplane.ControlPlane) { + go func() { + api.RunHealthEndpoint(port, api.WithReadinessConditionFunc(func() bool { + return cp.IsRegistered() + })) + }() +} + +func newResourceHandlerFromEnv() *api.ResourceHandler { + var env config.EnvConfig + if err := envconfig.Process("", &env); err != nil { + log.Fatalf("failed to process env var: %s", err) + } + return api.NewResourceHandler(env.ConfigurationServiceURL) +} + +func newControlPlaneFromEnv() (api.KeptnInterface, *controlplane.ControlPlane, controlplane.EventSender) { + var env config.EnvConfig + if err := envconfig.Process("", &env); err != nil { + log.Fatalf("failed to process env var: %s", err) + } + + httpClient, err := sdk.CreateClientGetter(env).Get() + if err != nil { + logger.WithError(err).Fatal("Could not initialize http client.") + } + + apiSet, err := sdk.CreateKeptnAPI(httpClient, env) + if err != nil { + log.Fatal(err) + } + + natsConnector := nats.New(env.EventBrokerURL) + eventSource := eventsource.New(natsConnector) + eventSender := eventSource.Sender() + subscriptionSource := subscriptionsource.New(apiSet.UniformV1()) + logForwarder := logforwarder.New(apiSet.LogsV1()) + controlPlane := controlplane.New(subscriptionSource, eventSource, logForwarder) + return apiSet, controlPlane, eventSender +} diff --git a/pkg/sdk/keptn_fake.go b/pkg/sdk/keptn_fake.go new file mode 100644 index 00000000..e1d0ca9c --- /dev/null +++ b/pkg/sdk/keptn_fake.go @@ -0,0 +1,219 @@ +package sdk + +import ( + "context" + "errors" + "fmt" + "github.com/keptn/go-utils/pkg/api/models" + api "github.com/keptn/go-utils/pkg/api/utils" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/keptn/go-utils/pkg/sdk/connector/controlplane" + "github.com/keptn/go-utils/pkg/sdk/connector/types" + "github.com/stretchr/testify/require" + "io/ioutil" + "path/filepath" + "testing" +) + +type FakeKeptn struct { + TestResourceHandler ResourceHandler + SentEvents []models.KeptnContextExtendedCE + Keptn *Keptn +} + +func (f *FakeKeptn) GetResourceHandler() ResourceHandler { + if f.TestResourceHandler == nil { + return &TestResourceHandler{} + } + return f.TestResourceHandler +} + +func (f *FakeKeptn) NewEvent(event models.KeptnContextExtendedCE) error { + ctx := context.WithValue(context.TODO(), types.EventSenderKey, controlplane.EventSender(f.fakeSender)) + ctx = context.WithValue(ctx, gracefulShutdownKey, &nopWG{}) + return f.Keptn.OnEvent(ctx, event) +} + +func (f *FakeKeptn) AssertNumberOfEventSent(t *testing.T, numOfEvents int) { + require.Equalf(t, numOfEvents, len(f.SentEvents), "number of events expected: %d got: %d", numOfEvents, len(f.SentEvents)) +} + +func (f *FakeKeptn) AssertSentEvent(t *testing.T, eventIndex int, assertFn func(ce models.KeptnContextExtendedCE) bool) { + if eventIndex >= len(f.SentEvents) { + t.Fatalf("unable to assert sent event with index %d: too less events sent", eventIndex) + } + require.True(t, assertFn(f.SentEvents[eventIndex])) +} + +func (f *FakeKeptn) AssertSentEventType(t *testing.T, eventIndex int, eventType string) { + if eventIndex >= len(f.SentEvents) { + t.Fatalf("unable to assert sent event with index %d: too less events sent", eventIndex) + } + require.Equalf(t, eventType, *f.SentEvents[eventIndex].Type, "event type expected: %s got %s", eventType, *f.SentEvents[eventIndex].Type) +} + +func (f *FakeKeptn) AssertSentEventStatus(t *testing.T, eventIndex int, status v0_2_0.StatusType) { + if eventIndex >= len(f.SentEvents) { + t.Fatalf("unable to assert sent event with index %d: too less events sent", eventIndex) + } + eventData := v0_2_0.EventData{} + v0_2_0.EventDataAs(f.SentEvents[eventIndex], &eventData) + require.Equal(t, status, eventData.Status) +} + +func (f *FakeKeptn) AssertSentEventResult(t *testing.T, eventIndex int, result v0_2_0.ResultType) { + if eventIndex >= len(f.SentEvents) { + t.Fatalf("unable to assert sent event with index %d: too less events sent", eventIndex) + } + eventData := v0_2_0.EventData{} + v0_2_0.EventDataAs(f.SentEvents[eventIndex], &eventData) + require.Equal(t, result, eventData.Result) +} + +func (f *FakeKeptn) SetAutomaticResponse(autoResponse bool) { + f.Keptn.automaticEventResponse = autoResponse +} +func (f *FakeKeptn) SetResourceHandler(handler ResourceHandler) { + f.TestResourceHandler = handler + f.Keptn.resourceHandler = handler +} + +func (f *FakeKeptn) SetAPI(api api.KeptnInterface) { + f.Keptn.api = api +} + +func (f *FakeKeptn) AddTaskHandler(eventType string, handler TaskHandler, filters ...func(keptnHandle IKeptn, event KeptnEvent) bool) { + f.AddTaskHandlerWithSubscriptionID(eventType, handler, "", filters...) +} + +func (f *FakeKeptn) AddTaskHandlerWithSubscriptionID(eventType string, handler TaskHandler, subscriptionID string, filters ...func(keptnHandle IKeptn, event KeptnEvent) bool) { + f.Keptn.taskRegistry.Add(eventType, taskEntry{taskHandler: handler, eventFilters: filters}) +} + +func (f *FakeKeptn) fakeSender(ce models.KeptnContextExtendedCE) error { + f.SentEvents = append(f.SentEvents, ce) + return nil +} + +func NewFakeKeptn(source string) *FakeKeptn { + resourceHandler := &TestResourceHandler{} + var fakeKeptn = &FakeKeptn{ + TestResourceHandler: resourceHandler, + Keptn: &Keptn{ + source: source, + api: panicKeptnInterface{}, + resourceHandler: resourceHandler, + taskRegistry: newTaskMap(), + syncProcessing: true, + automaticEventResponse: true, + gracefulShutdown: false, + logger: newDefaultLogger(), + healthEndpointRunner: noOpHealthEndpointRunner, + }, + } + fakeKeptn.Keptn.eventSender = fakeKeptn.fakeSender + return fakeKeptn +} + +type TestResourceHandler struct { + Resource models.Resource +} + +func (t TestResourceHandler) GetResource(scope api.ResourceScope, options ...api.URIOption) (*models.Resource, error) { + return newResourceFromFile(fmt.Sprintf("test/keptn/resources/%s%s%s%s", scope.GetProjectPath(), scope.GetStagePath(), scope.GetServicePath(), scope.GetResourcePath())), nil +} + +func newResourceFromFile(filename string) *models.Resource { + filename = filepath.Clean(filename) + content, err := ioutil.ReadFile(filename) + if err != nil { + return nil + } + + return &models.Resource{ + Metadata: nil, + ResourceContent: string(content), + ResourceURI: nil, + } +} + +type StringResourceHandler struct { + ResourceContent string +} + +func (s StringResourceHandler) GetResource(scope api.ResourceScope, options ...api.URIOption) (*models.Resource, error) { + return &models.Resource{ + Metadata: &models.Version{Version: "CommitID"}, + ResourceContent: s.ResourceContent, + ResourceURI: nil, + }, nil +} + +type FailingResourceHandler struct { +} + +func (f FailingResourceHandler) GetResource(scope api.ResourceScope, options ...api.URIOption) (*models.Resource, error) { + return nil, errors.New("unable to get resource") +} + +type panicKeptnInterface struct { +} + +func (p panicKeptnInterface) AuthV1() api.AuthV1Interface { + + panic("No implementation of AuthV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) EventsV1() api.EventsV1Interface { + + panic("No implementation of EventsV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) LogsV1() api.LogsV1Interface { + + panic("No implementation of LogsV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) ProjectsV1() api.ProjectsV1Interface { + + panic("No implementation of ProjectsV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) ResourcesV1() api.ResourcesV1Interface { + + panic("No implementation of ResourcesV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) SecretsV1() api.SecretsV1Interface { + + panic("No implementation of SecretsV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) SequencesV1() api.SequencesV1Interface { + + panic("No implementation of SequencesV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) ServicesV1() api.ServicesV1Interface { + + panic("No implementation of ServicesV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) StagesV1() api.StagesV1Interface { + + panic("No implementation of StagesV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) UniformV1() api.UniformV1Interface { + + panic("No implementation of UniformV1found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) ShipyardControlV1() api.ShipyardControlV1Interface { + + panic("No implementation of ShipyardControlV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} + +func (p panicKeptnInterface) APIV1() api.APIV1Interface { + panic("No implementation of APIV1 found. Please Provide a mocked implementation of KeptnInterface for Fake Keptn") +} diff --git a/pkg/sdk/keptn_test.go b/pkg/sdk/keptn_test.go new file mode 100644 index 00000000..7b691bf1 --- /dev/null +++ b/pkg/sdk/keptn_test.go @@ -0,0 +1,201 @@ +package sdk + +import ( + "context" + "fmt" + "github.com/keptn/go-utils/pkg/sdk/internal/config" + "testing" + + "github.com/google/uuid" + "github.com/keptn/go-utils/pkg/api/models" + "github.com/keptn/go-utils/pkg/common/strutils" + "github.com/keptn/go-utils/pkg/lib/v0_2_0" + "github.com/stretchr/testify/require" +) + +func Test_ReceivingEventWithMissingType(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { return FakeTaskData{}, nil } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler) + fakeKeptn.NewEvent(models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{Project: "prj", Stage: "stg", Service: "svc"}, + ID: "id", + Shkeptncontext: "context", + Source: strutils.Stringp("source"), + }) + + fakeKeptn.AssertNumberOfEventSent(t, 0) +} + +func Test_CannotGetEventSenderFromContext(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { return FakeTaskData{}, nil } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler) + fakeKeptn.Keptn.OnEvent(context.TODO(), models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{Project: "prj", Stage: "stg", Service: "svc"}, + ID: "id", + Shkeptncontext: "context", + Source: strutils.Stringp("source"), + Type: strutils.Stringp("sh.keptn.event.faketask.triggered"), + }) + + fakeKeptn.AssertNumberOfEventSent(t, 0) +} + +func Test_WhenReceivingAnEvent_StartedEventAndFinishedEventsAreSent(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { return FakeTaskData{}, nil } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler) + fakeKeptn.NewEvent(models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{Project: "prj", Stage: "stg", Service: "svc"}, + ID: "id", + Shkeptncontext: "context", + Source: strutils.Stringp("source"), + Type: strutils.Stringp("sh.keptn.event.faketask.triggered"), + }) + + fakeKeptn.AssertNumberOfEventSent(t, 2) + fakeKeptn.AssertSentEventType(t, 0, "sh.keptn.event.faketask.started") + fakeKeptn.AssertSentEventType(t, 1, "sh.keptn.event.faketask.finished") +} + +func Test_WhenReceivingAnEvent_TaskHandlerFails(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { + return nil, &Error{ + StatusType: v0_2_0.StatusErrored, + ResultType: v0_2_0.ResultFailed, + Message: "something went wrong", + Err: fmt.Errorf("something went wrong"), + } + } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler) + fakeKeptn.NewEvent(models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{Project: "prj", Stage: "stg", Service: "svc"}, + ID: "id", + Shkeptncontext: "context", + Source: strutils.Stringp("source"), + Type: strutils.Stringp("sh.keptn.event.faketask.triggered"), + }) + + fakeKeptn.AssertNumberOfEventSent(t, 2) + fakeKeptn.AssertSentEventType(t, 0, "sh.keptn.event.faketask.started") + fakeKeptn.AssertSentEventType(t, 1, "sh.keptn.event.faketask.finished") + fakeKeptn.AssertSentEventStatus(t, 1, v0_2_0.StatusErrored) + fakeKeptn.AssertSentEventResult(t, 1, v0_2_0.ResultFailed) +} + +func Test_WhenReceivingBadEvent_NoEventIsSent(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { return FakeTaskData{}, nil } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler) + fakeKeptn.NewEvent(newTestTaskBadTriggeredEvent()) + fakeKeptn.AssertNumberOfEventSent(t, 0) +} + +func Test_WhenReceivingAnEvent_AndNoFilterMatches_NoEventIsSent(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { return FakeTaskData{}, nil } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler, func(keptnHandle IKeptn, event KeptnEvent) bool { return false }) + fakeKeptn.NewEvent(models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{Project: "prj", Stage: "stg", Service: "svc"}, + ID: "id", + Shkeptncontext: "context", + Source: strutils.Stringp("source"), + Type: strutils.Stringp("sh.keptn.event.faketask.triggered"), + }) + + fakeKeptn.AssertNumberOfEventSent(t, 0) +} + +func Test_NoFinishedEventDataProvided(t *testing.T) { + taskHandler := &TaskHandlerMock{} + taskHandler.ExecuteFunc = func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { + return nil, nil + } + fakeKeptn := NewFakeKeptn("fake") + fakeKeptn.AddTaskHandler("sh.keptn.event.faketask.triggered", taskHandler) + fakeKeptn.NewEvent(models.KeptnContextExtendedCE{ + Data: v0_2_0.EventData{Project: "prj", Stage: "stg", Service: "svc"}, + ID: "id", + Shkeptncontext: "context", + Source: strutils.Stringp("source"), + Type: strutils.Stringp("sh.keptn.event.faketask.triggered"), + }) + + fakeKeptn.AssertNumberOfEventSent(t, 1) + fakeKeptn.AssertSentEventType(t, 0, "sh.keptn.event.faketask.started") +} + +func Test_InitialRegistrationData(t *testing.T) { + keptn := Keptn{env: config.EnvConfig{ + PubSubTopic: "sh.keptn.event.task1.triggered,sh.keptn.event.task2.triggered", + Location: "localhost", + K8sDeploymentVersion: "v1", + K8sDeploymentName: "k8s-deployment", + K8sNamespace: "k8s-namespace", + K8sPodName: "k8s-podname", + K8sNodeName: "k8s-nodename", + }} + + regData := keptn.RegistrationData() + require.Equal(t, "v1", regData.MetaData.IntegrationVersion) + require.Equal(t, "localhost", regData.MetaData.Location) + require.Equal(t, "k8s-deployment", regData.MetaData.KubernetesMetaData.DeploymentName) + require.Equal(t, "k8s-namespace", regData.MetaData.KubernetesMetaData.Namespace) + require.Equal(t, "k8s-podname", regData.MetaData.KubernetesMetaData.PodName) + require.Equal(t, "k8s-nodename", regData.MetaData.Hostname) + require.Equal(t, []models.EventSubscription{{Event: "sh.keptn.event.task1.triggered"}, {Event: "sh.keptn.event.task2.triggered"}}, regData.Subscriptions) +} + +func Test_InitialRegistrationData_EmptyPubSubTopics(t *testing.T) { + keptn := Keptn{env: config.EnvConfig{PubSubTopic: ""}} + regData := keptn.RegistrationData() + require.Equal(t, 0, len(regData.Subscriptions)) +} + +func newTestTaskTriggeredEvent() models.KeptnContextExtendedCE { + return models.KeptnContextExtendedCE{ + Contenttype: "application/json", + Data: FakeTaskData{}, + ID: uuid.New().String(), + Shkeptncontext: "keptncontext", + Triggeredid: "ID", + GitCommitID: "mycommitid", + Source: strutils.Stringp("unittest"), + Type: strutils.Stringp("sh.keptn.event.faketask.triggered"), + } +} + +func newTestTaskBadTriggeredEvent() models.KeptnContextExtendedCE { + return models.KeptnContextExtendedCE{ + Contenttype: "application/json", + Data: FakeTaskData{}, + ID: uuid.New().String(), + Shkeptncontext: "keptncontext", + Triggeredid: "ID", + GitCommitID: "mycommitid", + Source: strutils.Stringp("unittest"), + Type: strutils.Stringp("sh.keptn.event.faketask.finished.triggered"), + } +} + +type FakeTaskData struct { +} +type TaskHandlerMock struct { + // ExecuteFunc mocks the Execute method. + ExecuteFunc func(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) +} + +func (mock *TaskHandlerMock) Execute(keptnHandle IKeptn, event KeptnEvent) (interface{}, *Error) { + if mock.ExecuteFunc == nil { + panic("TaskHandlerMock.ExecuteFunc: method is nil but taskHandler.Execute was just called") + } + return mock.ExecuteFunc(keptnHandle, event) +} diff --git a/pkg/sdk/log.go b/pkg/sdk/log.go new file mode 100644 index 00000000..25053fd9 --- /dev/null +++ b/pkg/sdk/log.go @@ -0,0 +1,70 @@ +package sdk + +import ( + "log" + "os" +) + +// Logger interface used by the go sdk +type Logger interface { + Debug(v ...interface{}) + Debugf(format string, v ...interface{}) + Info(v ...interface{}) + Infof(format string, v ...interface{}) + Warn(v ...interface{}) + Warnf(format string, v ...interface{}) + Error(v ...interface{}) + Errorf(format string, v ...interface{}) + Fatal(v ...interface{}) + Fatalf(format string, v ...interface{}) +} + +// DefaultLogger implementation of Logger using the go log package +type DefaultLogger struct { + logger *log.Logger +} + +// newDefaultLogger creates a new Default Logger +func newDefaultLogger() *DefaultLogger { + return &DefaultLogger{logger: log.New(os.Stdout, "", 5)} +} + +func (d DefaultLogger) Debug(v ...interface{}) { + d.logger.Println(v...) +} + +func (d DefaultLogger) Debugf(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Info(v ...interface{}) { + d.logger.Println(v...) +} + +func (d DefaultLogger) Infof(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Warn(v ...interface{}) { + d.logger.Println(v...) +} + +func (d DefaultLogger) Warnf(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Error(v ...interface{}) { + d.logger.Print(v...) +} + +func (d DefaultLogger) Errorf(format string, v ...interface{}) { + d.logger.Printf(format, v...) +} + +func (d DefaultLogger) Fatal(v ...interface{}) { + d.logger.Fatal(v...) +} + +func (d DefaultLogger) Fatalf(format string, v ...interface{}) { + d.logger.Fatalf(format, v...) +} diff --git a/pkg/sdk/taskregistry.go b/pkg/sdk/taskregistry.go new file mode 100644 index 00000000..7e253fb3 --- /dev/null +++ b/pkg/sdk/taskregistry.go @@ -0,0 +1,46 @@ +package sdk + +import ( + "sync" +) + +type taskRegistry struct { + sync.RWMutex + entries map[string]taskEntry +} + +type taskEntry struct { + taskHandler TaskHandler + // eventFilters is a list of functions that are executed before a task is handled by the taskHandler. Only if all functions return 'true', the task will be handled + eventFilters []func(keptnHandle IKeptn, event KeptnEvent) bool +} + +func newTaskMap() *taskRegistry { + return &taskRegistry{ + entries: make(map[string]taskEntry), + } +} + +func (t *taskRegistry) Contains(name string) (*taskEntry, bool) { + t.RLock() + defer t.RUnlock() + if e, ok := t.entries[name]; ok { + return &e, true + } else if e, ok := t.entries["*"]; ok { // check if we have registered a wildcard handler + return &e, true + } + return nil, false +} + +func (t *taskRegistry) Add(name string, entry taskEntry) { + t.Lock() + defer t.Unlock() + t.entries[name] = entry +} + +func (t *taskRegistry) Get(name string) *taskEntry { + t.RLock() + defer t.RUnlock() + entry := t.entries[name] + return &entry +}