From 72882731e342cc26d607d392a8f347321d70e916 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Tue, 17 Sep 2024 14:50:58 +0200 Subject: [PATCH 1/7] Generic execution space provider for ETOS API --- Makefile | 2 +- cmd/executionspace/main.go | 147 +++++++ deploy/etos-executionspace/Dockerfile | 17 + deploy/etos-executionspace/Dockerfile.dev | 8 + deploy/etos-executionspace/docker-compose.yml | 16 + go.mod | 48 ++- go.sum | 57 +++ internal/configs/executionspace/config.go | 164 +++++++ .../configs/executionspace/config_test.go | 87 ++++ internal/executionspace/database/database.go | 27 ++ internal/executionspace/etcd/etcd.go | 125 ++++++ .../eventrepository/eventrepository.go | 147 +++++++ internal/executionspace/executor/executor.go | 32 ++ .../executionspace/executor/kubernetes.go | 260 +++++++++++ internal/executionspace/logging/logging.go | 38 ++ .../executionspace/logging/logging_test.go | 34 ++ .../logging/rabbitmqhook/rabbitmqhook.go | 107 +++++ .../executionspace/provider/kubernetes.go | 44 ++ internal/executionspace/provider/provider.go | 183 ++++++++ internal/executionspace/rabbitmq/rabbitmq.go | 207 +++++++++ .../executionspace/responses/responses.go | 36 ++ .../responses/responses_test.go | 42 ++ manifests/base/executionspace/deployment.yaml | 50 +++ .../base/executionspace/kustomization.yaml | 8 + manifests/base/executionspace/role.yaml | 55 +++ .../base/executionspace/rolebinding.yaml | 15 + .../base/executionspace/service-account.yaml | 8 + manifests/base/executionspace/service.yaml | 18 + manifests/base/kustomization.yaml | 1 + pkg/executionspace/errors/errors.go | 43 ++ .../executionspace/executionspace.go | 87 ++++ pkg/executionspace/executionspace/executor.go | 145 +++++++ pkg/executionspace/v1alpha/executor.go | 233 ++++++++++ pkg/executionspace/v1alpha/provider.go | 406 ++++++++++++++++++ 34 files changed, 2874 insertions(+), 23 deletions(-) create mode 100644 cmd/executionspace/main.go create mode 100644 deploy/etos-executionspace/Dockerfile create mode 100644 deploy/etos-executionspace/Dockerfile.dev create mode 100644 deploy/etos-executionspace/docker-compose.yml create mode 100644 internal/configs/executionspace/config.go create mode 100644 internal/configs/executionspace/config_test.go create mode 100644 internal/executionspace/database/database.go create mode 100644 internal/executionspace/etcd/etcd.go create mode 100644 internal/executionspace/eventrepository/eventrepository.go create mode 100644 internal/executionspace/executor/executor.go create mode 100644 internal/executionspace/executor/kubernetes.go create mode 100644 internal/executionspace/logging/logging.go create mode 100644 internal/executionspace/logging/logging_test.go create mode 100644 internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go create mode 100644 internal/executionspace/provider/kubernetes.go create mode 100644 internal/executionspace/provider/provider.go create mode 100644 internal/executionspace/rabbitmq/rabbitmq.go create mode 100644 internal/executionspace/responses/responses.go create mode 100644 internal/executionspace/responses/responses_test.go create mode 100644 manifests/base/executionspace/deployment.yaml create mode 100644 manifests/base/executionspace/kustomization.yaml create mode 100644 manifests/base/executionspace/role.yaml create mode 100644 manifests/base/executionspace/rolebinding.yaml create mode 100644 manifests/base/executionspace/service-account.yaml create mode 100644 manifests/base/executionspace/service.yaml create mode 100644 pkg/executionspace/errors/errors.go create mode 100644 pkg/executionspace/executionspace/executionspace.go create mode 100644 pkg/executionspace/executionspace/executor.go create mode 100644 pkg/executionspace/v1alpha/executor.go create mode 100644 pkg/executionspace/v1alpha/provider.go diff --git a/Makefile b/Makefile index 341b864..330cd49 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ export DOCKER_REGISTRY ?= registry.nordix.org export DOCKER_NAMESPACE ?= eiffel export DEPLOY ?= etos-sse -PROGRAMS = sse logarea iut +PROGRAMS = sse logarea iut executionspace COMPILEDAEMON = $(GOBIN)/CompileDaemon GIT = git GOLANGCI_LINT = $(GOBIN)/golangci-lint diff --git a/cmd/executionspace/main.go b/cmd/executionspace/main.go new file mode 100644 index 0000000..f1ecafd --- /dev/null +++ b/cmd/executionspace/main.go @@ -0,0 +1,147 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "runtime/debug" + "syscall" + + config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + "github.com/eiffel-community/etos-api/internal/executionspace/etcd" + "github.com/eiffel-community/etos-api/internal/executionspace/provider" + "github.com/eiffel-community/etos-api/internal/logging" + "github.com/eiffel-community/etos-api/internal/server" + "github.com/eiffel-community/etos-api/pkg/application" + "github.com/sirupsen/logrus" + "github.com/snowzach/rotatefilehook" + "github.com/eiffel-community/etos-api/internal/executionspace/logging/rabbitmqhook" + "github.com/eiffel-community/etos-api/internal/executionspace/rabbitmq" + providerservice "github.com/eiffel-community/etos-api/pkg/executionspace/v1alpha" + "go.elastic.co/ecslogrus" +) + +// main sets up logging and starts up the webservice. +func main() { + cfg := config.Get() + ctx := context.Background() + + var hooks []logrus.Hook + if publisher := remoteLogging(cfg); publisher != nil { + defer publisher.Close() + hooks = append(hooks, rabbitmqhook.NewRabbitMQHook(publisher)) + } + if fileHook := fileLogging(cfg); fileHook != nil { + hooks = append(hooks, fileHook) + } + + logger, err := logging.Setup(cfg.LogLevel(), hooks) + if err != nil { + logrus.Fatal(err.Error()) + } + + hostname, err := os.Hostname() + if err != nil { + logrus.Fatal(err.Error()) + } + log := logger.WithFields(logrus.Fields{ + "hostname": hostname, + "application": "ETOS Execution Space Provider Kubernetes", + "version": vcsRevision(), + "name": "ETOS Execution Space Provider", + "user_log": false, + }) + + log.Info("Loading v1alpha routes") + provider := provider.Kubernetes{}.New(etcd.New(cfg, logger), cfg) + providerServiceApp := providerservice.New(cfg, log, provider, ctx) + defer providerServiceApp.Close() + handler := application.New(providerServiceApp) + + srv := server.NewWebService(cfg, log, handler) + + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + go func() { + if err := srv.Start(); err != nil && err != http.ErrServerClosed { + log.Errorf("WebService shutdown: %+v", err) + } + }() + + sig := <-done + log.Infof("%s received", sig.String()) + + ctx, cancel := context.WithTimeout(ctx, cfg.Timeout()) + defer cancel() + + if err := srv.Close(ctx); err != nil { + log.Errorf("WebService shutdown failed: %+v", err) + } + log.Info("Wait for checkout, flash and checkin jobs to complete") +} + +// fileLogging adds a hook into a slice of hooks, if the filepath configuration is set +func fileLogging(cfg config.Config) logrus.Hook { + if filePath := cfg.LogFilePath(); filePath != "" { + // TODO: Make these parameters configurable. + // NewRotateFileHook cannot return an error which is why it's set to '_'. + rotateFileHook, _ := rotatefilehook.NewRotateFileHook(rotatefilehook.RotateFileConfig{ + Filename: filePath, + MaxSize: 10, // megabytes + MaxBackups: 3, + MaxAge: 0, // days + Level: logrus.DebugLevel, + Formatter: &ecslogrus.Formatter{ + DataKey: "labels", + }, + }) + return rotateFileHook + } + return nil +} + +// remoteLogging starts a new rabbitmq publisher if the rabbitmq parameters are set +// Warning: Must call publisher.Close() on the publisher returned from this function +func remoteLogging(cfg config.Config) *rabbitmq.Publisher { + if cfg.RabbitMQHookUrl() != "" { + if cfg.RabbitMQHookExchangeName() == "" { + panic("-rabbitmq_hook_exchange (env:ETOS_RABBITMQ_EXCHANGE) must be set when using -rabbitmq_hook_url (env:ETOS_RABBITMQ_URL)") + } + publisher := rabbitmq.NewPublisher(rabbitmq.PublisherConfig{ + URL: cfg.RabbitMQHookUrl(), + ExchangeName: cfg.RabbitMQHookExchangeName(), + }) + return publisher + } + return nil +} + +func vcsRevision() string { + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return "(unknown)" + } + for _, val := range buildInfo.Settings { + if val.Key == "vcs.revision" { + return val.Value + } + } + return "(unknown)" +} diff --git a/deploy/etos-executionspace/Dockerfile b/deploy/etos-executionspace/Dockerfile new file mode 100644 index 0000000..f2f3a70 --- /dev/null +++ b/deploy/etos-executionspace/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.22-alpine AS build +WORKDIR /tmp/executionspace +COPY . . +RUN apk add --no-cache make=4.4.1-r2 git=2.45.2-r0 && make executionspace + +FROM alpine:3.17.3 +ARG TZ +ENV TZ=$TZ + +LABEL org.opencontainers.image.source=https://github.com/eiffel-community/etos-api +LABEL org.opencontainers.image.authors=etos-maintainers@googlegroups.com +LABEL org.opencontainers.image.licenses=Apache-2.0 + +RUN apk add --no-cache tzdata=2024a-r0 +ENTRYPOINT ["/app/executionspace"] + +COPY --from=build /tmp/executionspace/bin/executionspace /app/executionspace diff --git a/deploy/etos-executionspace/Dockerfile.dev b/deploy/etos-executionspace/Dockerfile.dev new file mode 100644 index 0000000..9c26272 --- /dev/null +++ b/deploy/etos-executionspace/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM golang:1.22 +WORKDIR /app + +COPY ./go.mod ./go.sum ./ +RUN go mod tidy +COPY . . +RUN git config --global --add safe.directory /app +EXPOSE 8080 diff --git a/deploy/etos-executionspace/docker-compose.yml b/deploy/etos-executionspace/docker-compose.yml new file mode 100644 index 0000000..8872d3c --- /dev/null +++ b/deploy/etos-executionspace/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.7" +services: + etos-executionspace: + build: + context: . + dockerfile: ./deploy/etos-executionspace/Dockerfile.dev + args: + http_proxy: "${http_proxy}" + https_proxy: "${https_proxy}" + volumes: + - ./:/app + ports: + - 8080:8080 + env_file: + - ./configs/development.env + entrypoint: ["/app/bin/executionspace"] diff --git a/go.mod b/go.mod index 0c16e4d..e85d504 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/eiffel-community/etos-api -go 1.21 +go 1.22.0 toolchain go1.22.1 @@ -21,8 +21,8 @@ require ( go.etcd.io/etcd/api/v3 v3.5.15 go.etcd.io/etcd/client/v3 v3.5.15 go.etcd.io/etcd/server/v3 v3.5.14 - k8s.io/apimachinery v0.28.2 - k8s.io/client-go v0.28.2 + k8s.io/apimachinery v0.31.1 + k8s.io/client-go v0.31.1 ) require ( @@ -34,14 +34,15 @@ require ( github.com/clarketm/json v1.17.1 // indirect github.com/coreos/go-semver v0.3.0 // indirect github.com/coreos/go-systemd/v22 v22.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.0 // indirect - github.com/emicklei/go-restful/v3 v3.9.0 // indirect - github.com/go-logr/logr v1.3.0 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-openapi/swag v0.22.4 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -49,11 +50,12 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -65,17 +67,19 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.11.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/soheilhy/cmux v0.1.5 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect go.etcd.io/bbolt v1.3.10 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect @@ -93,28 +97,28 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.17.0 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/oauth2 v0.11.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/term v0.18.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.28.2 // indirect - k8s.io/klog/v2 v2.100.1 // indirect - k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect - k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect + k8s.io/api v0.31.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 7b297b3..5f0df3e 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopT cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= @@ -42,12 +43,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eiffel-community/eiffelevents-sdk-go v0.0.0-20240807115026-5ca5c194b7dc h1:yRg84ReJfuVCJ/TMzfCqL12Aoy4vUSrUUgcuE02mBJo= github.com/eiffel-community/eiffelevents-sdk-go v0.0.0-20240807115026-5ca5c194b7dc/go.mod h1:Lt487E8lrDd/5hkCEyKHU/xZrqDjIgRNIDaoK/F3Yk4= github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -56,6 +61,8 @@ github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBF github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM= github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -67,6 +74,8 @@ github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbV github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/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.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= @@ -75,6 +84,8 @@ github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2Kv github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= @@ -111,6 +122,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/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.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -118,11 +130,14 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= @@ -131,6 +146,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -186,6 +203,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE= github.com/onsi/ginkgo/v2 v2.9.4/go.mod h1:gCQYp2Q+kSoIj7ykSVb9nskRSsR6PUj4AiLywzIhbKM= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= @@ -197,6 +216,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= @@ -216,9 +237,12 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -255,6 +279,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -310,6 +336,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -333,11 +361,15 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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= @@ -347,6 +379,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -364,13 +397,19 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -418,6 +457,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -445,19 +486,35 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.28.2 h1:9mpl5mOb6vXZvqbQmankOfPIGiudghwCoLl1EYfUZbw= k8s.io/api v0.28.2/go.mod h1:RVnJBsjU8tcMq7C3iaRSGMeaKt2TWEUXcpIt/90fjEg= +k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= k8s.io/apimachinery v0.28.2 h1:KCOJLrc6gu+wV1BYgwik4AF4vXOlVJPdiqn0yAWWwXQ= k8s.io/apimachinery v0.28.2/go.mod h1:RdzF87y/ngqk9H4z3EL2Rppv5jj95vGS/HaFXrLDApU= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= k8s.io/client-go v0.28.2 h1:DNoYI1vGq0slMBN/SWKMZMw0Rq+0EQW6/AK4v9+3VeY= k8s.io/client-go v0.28.2/go.mod h1:sMkApowspLuc7omj1FOSUxSoqjr+d5Q0Yc0LOFnYFJY= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 h1:LyMgNKD2P8Wn1iAwQU5OhxCKlKJy0sHc+PcDwFB24dQ= k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk= k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/configs/executionspace/config.go b/internal/configs/executionspace/config.go new file mode 100644 index 0000000..28bd34a --- /dev/null +++ b/internal/configs/executionspace/config.go @@ -0,0 +1,164 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package config + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/sirupsen/logrus" +) + +// Config interface for retreiving configuration options. +type Config interface { + ServiceHost() string + ServicePort() string + StripPrefix() string + Hostname() string + LogLevel() string + LogFilePath() string + Timeout() time.Duration + KubernetesNamespace() string + ExecutionSpaceWaitTimeout() time.Duration + RabbitMQHookUrl() string + RabbitMQHookExchangeName() string + DatabaseURI() string + ETOSNamespace() string +} + +// cfg implements the Config interface. +type cfg struct { + serviceHost string + servicePort string + stripPrefix string + hostname string + logLevel string + logFilePath string + timeout time.Duration + databaseHost string + databasePort string + kubernetesNamespace string + executionSpaceWaitTimeout time.Duration + rabbitmqHookURL string + rabbitmqHookExchange string + etosNamespace string +} + +// Get creates a config interface based on input parameters or environment variables. +func Get() Config { + var conf cfg + + defaultTimeout, err := time.ParseDuration(EnvOrDefault("REQUEST_TIMEOUT", "1m")) + if err != nil { + logrus.Panic(err) + } + + executionSpaceWaitTimeout, err := time.ParseDuration(EnvOrDefault("EXECUTION_SPACE_WAIT_TIMEOUT", "1h")) + if err != nil { + logrus.Panic(err) + } + + flag.StringVar(&conf.serviceHost, "address", EnvOrDefault("SERVICE_HOST", "127.0.0.1"), "Address to serve API on") + flag.StringVar(&conf.servicePort, "port", EnvOrDefault("SERVICE_PORT", "8080"), "Port to serve API on") + flag.StringVar(&conf.stripPrefix, "stripprefix", EnvOrDefault("STRIP_PREFIX", ""), "Strip prefix") + flag.StringVar(&conf.hostname, "hostname", EnvOrDefault("PROVIDER_HOSTNAME", "http://localhost"), "Host to supply to ESR for starting executors") + flag.StringVar(&conf.logLevel, "loglevel", EnvOrDefault("LOGLEVEL", "INFO"), "Log level (TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, PANIC).") + flag.StringVar(&conf.logFilePath, "logfilepath", os.Getenv("LOG_FILE_PATH"), "Path, including filename, for the log files to create.") + flag.DurationVar(&conf.timeout, "timeout", defaultTimeout, "Maximum timeout for requests to Execution space provider Service.") + flag.StringVar(&conf.databaseHost, "database_host", EnvOrDefault("ETOS_ETCD_HOST", "etcd-client"), "Host to ETOS database") + flag.StringVar(&conf.databasePort, "database_port", EnvOrDefault("ETOS_ETCD_PORT", "2379"), "Port to ETOS database") + flag.StringVar(&conf.kubernetesNamespace, "kubernetes_namespace", os.Getenv("KUBERNETES_NAMESPACE"), "Namespace to start k8s jobs") + flag.DurationVar(&conf.executionSpaceWaitTimeout, "execution space wait timeout", executionSpaceWaitTimeout, "Timeout duration to wait when trying to checkout execution space(s)") + flag.StringVar(&conf.rabbitmqHookURL, "rabbitmq_hook_url", os.Getenv("ETOS_RABBITMQ_URL"), "URL to the ETOS rabbitmq for logs") + flag.StringVar(&conf.rabbitmqHookExchange, "rabbitmq_hook_exchange", os.Getenv("ETOS_RABBITMQ_EXCHANGE"), "Exchange to use for the ETOS rabbitmq for logs") + flag.Parse() + return &conf +} + +// ServiceHost returns the host of the service. +func (c *cfg) ServiceHost() string { + return c.serviceHost +} + +// ServicePort returns the port of the service. +func (c *cfg) ServicePort() string { + return c.servicePort +} + +// StripPrefix returns a prefix that is supposed to be stripped from URL. +func (c *cfg) StripPrefix() string { + return c.stripPrefix +} + +// Hostname returns the hostname to use for executors +func (c *cfg) Hostname() string { + return c.hostname +} + +// LogLevel returns the log level. +func (c *cfg) LogLevel() string { + return c.logLevel +} + +// LogFilePath returns the path to where log files should be stored, including filename. +func (c *cfg) LogFilePath() string { + return c.logFilePath +} + +// Timeout returns the request timeout for Execution space provider Service API. +func (c *cfg) Timeout() time.Duration { + return c.timeout +} + +// KubernetesNamespace returns the namespace where k8s jobs shall be deployed. +func (c *cfg) KubernetesNamespace() string { + return c.kubernetesNamespace +} + +// ExecutionSpaceWaitTimeout returns the timeout for checking out execution spaces. +func (c *cfg) ExecutionSpaceWaitTimeout() time.Duration { + return c.executionSpaceWaitTimeout +} + +// RabbitMQHookURL returns the rabbitmq url for ETOS logs +func (c *cfg) RabbitMQHookUrl() string { + return c.rabbitmqHookURL +} + +// RabbitMQHookExchangeName returns the rabbitmq exchange name used for ETOS logs +func (c *cfg) RabbitMQHookExchangeName() string { + return c.rabbitmqHookExchange +} + +// DatabaseURI returns the URI to the ETOS database. +func (c *cfg) DatabaseURI() string { + return fmt.Sprintf("%s:%s", c.databaseHost, c.databasePort) +} + +// ETOSNamespace returns the ETOS namespace. +func (c *cfg) ETOSNamespace() string { + return c.etosNamespace +} + +// EnvOrDefault will look up key in environment variables and return if it exists, else return the fallback value. +func EnvOrDefault(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} diff --git a/internal/configs/executionspace/config_test.go b/internal/configs/executionspace/config_test.go new file mode 100644 index 0000000..a8698a1 --- /dev/null +++ b/internal/configs/executionspace/config_test.go @@ -0,0 +1,87 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package config + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// Test that it is possible to get a Cfg from Get with values taken from environment variables. +func TestGet(t *testing.T) { + port := "8080" + serverHost := "127.0.0.1" + logLevel := "DEBUG" + logFilePath := "path/to/a/file" + timeoutStr := "1m" + + os.Setenv("SERVICE_HOST", serverHost) + os.Setenv("SERVICE_PORT", port) + os.Setenv("LOGLEVEL", logLevel) + os.Setenv("LOG_FILE_PATH", logFilePath) + os.Setenv("REQUEST_TIMEOUT", timeoutStr) + + timeout, _ := time.ParseDuration(timeoutStr) + + conf, ok := Get().(*cfg) + assert.Truef(t, ok, "cfg returned from get is not a config interface") + assert.Equal(t, port, conf.servicePort) + assert.Equal(t, serverHost, conf.serviceHost) + assert.Equal(t, logLevel, conf.logLevel) + assert.Equal(t, logFilePath, conf.logFilePath) + assert.Equal(t, timeout, conf.timeout) +} + +type getter func() string + +// Test that the getters in the Cfg struct return the values from the struct. +func TestGetters(t *testing.T) { + conf := &cfg{ + serviceHost: "127.0.0.1", + servicePort: "8080", + logLevel: "TRACE", + logFilePath: "a/file/path.json", + } + tests := []struct { + name string + cfg *cfg + function getter + value string + }{ + {name: "ServiceHost", cfg: conf, function: conf.ServiceHost, value: conf.serviceHost}, + {name: "ServicePort", cfg: conf, function: conf.ServicePort, value: conf.servicePort}, + {name: "LogLevel", cfg: conf, function: conf.LogLevel, value: conf.logLevel}, + {name: "LogFilePath", cfg: conf, function: conf.LogFilePath, value: conf.logFilePath}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.value, testCase.function()) + }) + } +} + +// TestTimeoutGetter tests the getter for Timeout. Similar to TestGetters, but since +// Timeout is not a "func() string" we separate its test. +func TestTimeoutGetter(t *testing.T) { + timeout, _ := time.ParseDuration("1m") + conf := &cfg{ + timeout: timeout, + } + assert.Equal(t, conf.timeout, conf.Timeout()) +} diff --git a/internal/executionspace/database/database.go b/internal/executionspace/database/database.go new file mode 100644 index 0000000..50d428b --- /dev/null +++ b/internal/executionspace/database/database.go @@ -0,0 +1,27 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package database + +import ( + "context" + "io" + + "github.com/google/uuid" +) + +type Opener interface { + Open(context.Context, uuid.UUID) io.ReadWriter +} diff --git a/internal/executionspace/etcd/etcd.go b/internal/executionspace/etcd/etcd.go new file mode 100644 index 0000000..fd23fd6 --- /dev/null +++ b/internal/executionspace/etcd/etcd.go @@ -0,0 +1,125 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package etcd + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + "github.com/eiffel-community/etos-api/internal/executionspace/database" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + clientv3 "go.etcd.io/etcd/client/v3" +) + +const etcdTreePrefix string = "/execution-space" + +// TODO: refactor the client so that it does not store data it fetched. +// However, without it implementing the database.Opener interface would be more complex (methods readByte, read). +type Etcd struct { + cfg config.Config + client *clientv3.Client + ID uuid.UUID + ctx context.Context + data []byte + hasRead bool +} + +// New returns a new Etcd Object/Struct. +func New(cfg config.Config, logger *logrus.Logger) database.Opener { + client, err := clientv3.New(clientv3.Config{ + Endpoints: []string{cfg.DatabaseURI()}, + DialTimeout: 5 * time.Second, + }) + if err != nil { + panic(err) + } + + return Etcd{ + client: client, + cfg: cfg, + } +} + +// Open returns a copy of an Etcd client with ID and context added +func (etcd Etcd) Open(ctx context.Context, id uuid.UUID) io.ReadWriter { + return &Etcd{ + client: etcd.client, + cfg: etcd.cfg, + ID: id, + ctx: ctx, + } +} + +// Write writes data to etcd +func (etcd Etcd) Write(p []byte) (int, error) { + if etcd.ID == uuid.Nil { + return 0, errors.New("please create a new etcd client using Open") + } + key := fmt.Sprintf("%s/%s", etcdTreePrefix, etcd.ID.String()) + _, err := etcd.client.Put(etcd.ctx, key, string(p)) + if err != nil { + return 0, err + } + return len(p), nil +} + +// readByte reads a single byte from etcd.data and reduces the slice afterwards +func (etcd *Etcd) readByte() byte { + b := etcd.data[0] + etcd.data = etcd.data[1:] + return b +} + +// Read reads data from etcd and returns p bytes to user +func (etcd *Etcd) Read(p []byte) (n int, err error) { + if etcd.ID == uuid.Nil { + err = errors.New("please create a new etcd client using NewWithID") + return n, err + } + + key := fmt.Sprintf("%s/%s", etcdTreePrefix, etcd.ID.String()) + + if !etcd.hasRead { + resp, err := etcd.client.Get(etcd.ctx, key) + if err != nil { + return n, err + } + if len(resp.Kvs) == 0 { + return n, io.EOF + } + etcd.data = resp.Kvs[0].Value + etcd.hasRead = true + } + + if len(etcd.data) == 0 { + return n, io.EOF + } + if c := cap(p); c > 0 { + for n < c { + p[n] = etcd.readByte() + n++ + if len(etcd.data) == 0 { + return n, io.EOF + } + } + } + return n, nil +} diff --git a/internal/executionspace/eventrepository/eventrepository.go b/internal/executionspace/eventrepository/eventrepository.go new file mode 100644 index 0000000..a1e1b45 --- /dev/null +++ b/internal/executionspace/eventrepository/eventrepository.go @@ -0,0 +1,147 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package eventrepository + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + + "github.com/eiffel-community/eiffelevents-sdk-go" +) + +type environmentResponse struct { + Items []eiffelevents.EnvironmentDefinedV3 `json:"items"` +} + +type testSuiteResponse struct { + Items []eiffelevents.TestSuiteStartedV3 `json:"items"` +} + +type activityResponse struct { + Items []eiffelevents.ActivityTriggeredV4 `json:"items"` +} + +// eventRepository returns the event repository URL to use. +func eventRepository() string { + return os.Getenv("EVENT_REPOSITORY_HOST") +} + +// ActivityTriggered returns an activity triggered event from the event repository +func ActivityTriggered(ctx context.Context, id string) (*eiffelevents.ActivityTriggeredV4, error) { + query := map[string]string{"meta.id": id, "meta.type": "EiffelActivityTriggeredEvent"} + body, err := getEvents(ctx, query) + if err != nil { + return nil, err + } + var event activityResponse + if err = json.Unmarshal(body, &event); err != nil { + return nil, err + } + if len(event.Items) == 0 { + return nil, errors.New("no sub suite found") + } + return &event.Items[0], nil +} + +// MainSuiteStarted returns a test suite started event from the event repository +func MainSuiteStarted(ctx context.Context, id string) (*eiffelevents.TestSuiteStartedV3, error) { + activity, err := ActivityTriggered(ctx, id) + if err != nil { + return nil, err + } + testSuiteID := activity.Links.FindFirst("CONTEXT") + + query := map[string]string{"meta.id": testSuiteID, "meta.type": "EiffelTestSuiteStartedEvent"} + body, err := getEvents(ctx, query) + if err != nil { + return nil, err + } + var event testSuiteResponse + if err = json.Unmarshal(body, &event); err != nil { + return nil, err + } + if len(event.Items) == 0 { + return nil, errors.New("no sub suite found") + } + return &event.Items[0], nil +} + +// TestSuiteStarted returns a test suite started event from the event repository +func TestSuiteStarted(ctx context.Context, id string, name string) (*eiffelevents.TestSuiteStartedV3, error) { + query := map[string]string{"links.target": id, "meta.type": "EiffelTestSuiteStartedEvent", "data.name": name} + body, err := getEvents(ctx, query) + if err != nil { + return nil, err + } + var event testSuiteResponse + if err = json.Unmarshal(body, &event); err != nil { + return nil, err + } + if len(event.Items) == 0 { + return nil, errors.New("no sub suite found") + } + return &event.Items[0], nil +} + +// EnvironmentDefined returns an environment defined event from the event repository +func EnvironmentDefined(ctx context.Context, id string) (*eiffelevents.EnvironmentDefinedV3, error) { + query := map[string]string{"meta.id": id, "meta.type": "EiffelEnvironmentDefinedEvent"} + body, err := getEvents(ctx, query) + if err != nil { + return nil, err + } + var event environmentResponse + if err = json.Unmarshal(body, &event); err != nil { + return nil, err + } + if len(event.Items) == 0 { + return nil, errors.New("no environment defined found") + } + return &event.Items[0], nil +} + +// getEvents queries the event repository and returns the response for others to parse +func getEvents(ctx context.Context, query map[string]string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", eventRepository(), nil) + if err != nil { + return nil, err + } + q := req.URL.Query() + for key, value := range query { + q.Add(key, value) + } + req.URL.RawQuery = q.Encode() + + client := &http.Client{} + response, err := client.Do(req) + if err != nil { + return nil, err + } + if response.StatusCode == 404 { + return nil, errors.New("event not found in event repository") + } + + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + return body, nil +} diff --git a/internal/executionspace/executor/executor.go b/internal/executionspace/executor/executor.go new file mode 100644 index 0000000..201e75f --- /dev/null +++ b/internal/executionspace/executor/executor.go @@ -0,0 +1,32 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package executor + +import ( + "context" + + "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" + "github.com/sirupsen/logrus" +) + +type Executor interface { + Name() string + Start(context.Context, *logrus.Entry, *executionspace.ExecutorSpec) (string, error) + Wait(context.Context, *logrus.Entry, string, *executionspace.ExecutorSpec) (string, string, error) + Cancel(context.Context, *logrus.Entry, string) error + Stop(context.Context, *logrus.Entry, string) error + Alive(context.Context, *logrus.Entry, string) (bool, error) +} diff --git a/internal/executionspace/executor/kubernetes.go b/internal/executionspace/executor/kubernetes.go new file mode 100644 index 0000000..fff6999 --- /dev/null +++ b/internal/executionspace/executor/kubernetes.go @@ -0,0 +1,260 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package executor + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" + "github.com/sirupsen/logrus" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/net" + watch "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" +) + +var ( + BACKOFFLIMIT int32 = 0 + PARALLELL int32 = 1 + COMPLETIONS int32 = 1 + SECRETMODE int32 = 0600 +) + +type KubernetesExecutor struct { + client *kubernetes.Clientset + namespace string +} + +// Kubernetes returns a new Kubernetes executor +func Kubernetes(namespace string) Executor { + config, err := inCluster() + if err != nil { + config, err = outOfCluster() + } + if err != nil { + panic(err) + } + client, err := kubernetes.NewForConfig(config) + if err != nil { + panic(err) + } + return &KubernetesExecutor{ + client: client, + namespace: namespace, + } +} + +// outOfCluster returns a configuration from $HOME/.kube/config +func outOfCluster() (*rest.Config, error) { + if homedir.HomeDir() == "" { + return nil, errors.New("no home directory for user") + } + kubeconfig := filepath.Join(homedir.HomeDir(), ".kube", "config") + return clientcmd.BuildConfigFromFlags("", kubeconfig) +} + +// inCluster returns a configuration from within a kubernetes cluster. +func inCluster() (*rest.Config, error) { + return rest.InClusterConfig() +} + +// Name returns the name of this executor +func (k KubernetesExecutor) Name() string { + return "kubernetes" +} + +// Start starts a test runner Kubernetes pod. +func (k KubernetesExecutor) Start(ctx context.Context, logger *logrus.Entry, executorSpec *executionspace.ExecutorSpec) (string, error) { + jobName := fmt.Sprintf("etr-%s", executorSpec.ID) + logger.WithField("user_log", true).Infof("Starting up a test runner with id %s on Kubernetes", jobName) + var envs []corev1.EnvVar + for key, value := range executorSpec.Instructions.Environment { + envs = append(envs, corev1.EnvVar{Name: key, Value: value}) + } + var args []string + for key, value := range executorSpec.Instructions.Parameters { + args = append(args, fmt.Sprintf("%s=%s", key, value)) + } + + jobs := k.client.BatchV1().Jobs(k.namespace) + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &BACKOFFLIMIT, + Completions: &COMPLETIONS, + Parallelism: &PARALLELL, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "etos-test-runner", + Image: executorSpec.Instructions.Image, + Args: args, + Env: envs, + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "etos-encryption-key", + }, + }, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "ssh-key-and-config", + ReadOnly: true, + MountPath: "/home/etos/keys", + }, + }, + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + Volumes: []corev1.Volume{ + { + Name: "ssh-key-and-config", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "kubernetes-provider-ssh-key", + DefaultMode: &SECRETMODE, + }, + }, + }, + }, + }, + }, + }, + } + job, err := jobs.Create(ctx, job, metav1.CreateOptions{}) + if err != nil { + logger.WithField("user_log", true).Infof("Create job error: %s", err) + logger.WithField("user_log", true).Infof("Create job error: %s", err.Error()) + + unwrappedErr := errors.Unwrap(err) + if unwrappedErr != nil { + logger.WithField("user_log", true).Infof("Unwrapped Error: %s", unwrappedErr) + } + return "", err + } + return job.ObjectMeta.Name, nil +} + +// isReady returns true if a pod is in the PodReady condition. +func isReady(pod *corev1.Pod) bool { + for _, c := range pod.Status.Conditions { + if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +// podFromJob gets a pod connected to a job. +func (k KubernetesExecutor) podFromJob(ctx context.Context, job *batchv1.Job) (*corev1.Pod, error) { + pods := k.client.CoreV1().Pods(k.namespace) + var pod corev1.Pod + podlist, err := pods.List(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", job.ObjectMeta.Name)}) + if err != nil { + return &pod, err + } + if len(podlist.Items) != 1 { + return &pod, errors.New("no pod yet") + } + pod = podlist.Items[0] + return &pod, nil +} + +// Wait waits for a Kubernetes pod to start +func (k KubernetesExecutor) Wait(ctx context.Context, logger *logrus.Entry, name string, executorSpec *executionspace.ExecutorSpec) (string, string, error) { + logger.WithField("user_log", true).Info("Waiting for a test runner Kubernetes pod to start") + watcher, err := k.client.CoreV1().Pods(k.namespace).Watch(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", name)}) + if err != nil { + return "", "", err + } + defer watcher.Stop() + for { + select { + case <-ctx.Done(): + return "", "", fmt.Errorf("timed out waiting for kubernets job %s to start", name) + case event := <-watcher.ResultChan(): + pod := event.Object.(*corev1.Pod) + if isReady(pod) { + return name, "", nil + } + } + } +} + +// Stop stops a test runner Kubernetes pod +func (k KubernetesExecutor) Stop(ctx context.Context, logger *logrus.Entry, id string) error { + logger.WithField("user_log", true).Info("Stopping test runner Kubernetes pod") + jobs := k.client.BatchV1().Jobs(k.namespace) + propagation := metav1.DeletePropagationForeground + err := jobs.Delete(ctx, id, metav1.DeleteOptions{PropagationPolicy: &propagation}) + if err != nil { + logger.Error(err.Error()) + return err + } + watcher, err := k.client.CoreV1().Pods(k.namespace).Watch(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", id)}) + if err != nil { + if net.IsProbableEOF(err) { + // Assume that there are no more active jobs. + logger.Warningf("Did not find any pods for 'job-name=%s', reason=EOF. Assuming that there are no more active jobs", id) + return nil + } + return err + } + defer watcher.Stop() + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for kubernets job %s to stop", id) + case event := <-watcher.ResultChan(): + if event.Type == watch.Deleted { + return nil + } + } + } +} + +// Cancel stops a Kubernetes job. Since Kubernetes has no queue concept, the cancel function does nothing else. +func (k KubernetesExecutor) Cancel(ctx context.Context, logger *logrus.Entry, id string) error { + return k.Stop(ctx, logger, id) +} + +// Alive checks that a Kubernetes pod running a test runner is still alive +func (k KubernetesExecutor) Alive(ctx context.Context, logger *logrus.Entry, id string) (bool, error) { + jobs := k.client.BatchV1().Jobs(k.namespace) + job, err := jobs.Get(ctx, id, metav1.GetOptions{}) + if err != nil { + return false, err + } + pod, err := k.podFromJob(ctx, job) + if err != nil { + return false, err + } + return isReady(pod), nil +} diff --git a/internal/executionspace/logging/logging.go b/internal/executionspace/logging/logging.go new file mode 100644 index 0000000..07bc8d6 --- /dev/null +++ b/internal/executionspace/logging/logging.go @@ -0,0 +1,38 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package logging + +import ( + "github.com/sirupsen/logrus" +) + +// Setup sets up logging to file with a JSON format and to stdout in text format. +func Setup(loglevel string, hooks []logrus.Hook) (*logrus.Logger, error) { + log := logrus.New() + + logLevel, err := logrus.ParseLevel(loglevel) + if err != nil { + return log, err + } + for _, hook := range hooks { + log.AddHook(hook) + } + + log.SetLevel(logLevel) + log.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) + log.SetReportCaller(true) + return log, nil +} diff --git a/internal/executionspace/logging/logging_test.go b/internal/executionspace/logging/logging_test.go new file mode 100644 index 0000000..ead65c6 --- /dev/null +++ b/internal/executionspace/logging/logging_test.go @@ -0,0 +1,34 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package logging + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestLoggingSetup tests that it is possible to setup logging without a file hook. +func TestLoggingSetup(t *testing.T) { + _, err := Setup("INFO", nil) + assert.Nil(t, err) +} + +// TestLoggingSetupBadLogLevel shall return an error if log level is not parsable. +func TestLoggingSetupBadLogLevel(t *testing.T) { + _, err := Setup("NOTALOGLEVEL", nil) + assert.Error(t, err) +} diff --git a/internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go b/internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go new file mode 100644 index 0000000..547789d --- /dev/null +++ b/internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go @@ -0,0 +1,107 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package rabbitmqhook + +import ( + "errors" + "fmt" + + amqp "github.com/rabbitmq/amqp091-go" + "github.com/sirupsen/logrus" + "github.com/eiffel-community/etos-api/internal/executionspace/rabbitmq" +) + +var fieldMap = logrus.FieldMap{ + logrus.FieldKeyTime: "@timestamp", + logrus.FieldKeyMsg: "message", + logrus.FieldKeyLevel: "levelname", +} + +type RabbitMQHook struct { + formatter logrus.Formatter + publisher *rabbitmq.Publisher +} + +// NewRabbitMQHook creates a new RabbitMQ hook for use in logrus. +func NewRabbitMQHook(publisher *rabbitmq.Publisher) *RabbitMQHook { + return &RabbitMQHook{ + formatter: &logrus.JSONFormatter{ + TimestampFormat: "2006-01-02T15:04:05.000Z", + FieldMap: fieldMap, + }, + publisher: publisher, + } +} + +// Fire formats a logrus entry to json and publishes it to a RabbitMQ. +// Will only fire messages if the 'publish' field and 'identifier' fields are set. +// A context must also be set on the entry else publish won't run. +func (h RabbitMQHook) Fire(entry *logrus.Entry) error { + // Ignore publish to RabbitMQ if user_log or identifier are not set + if entry.Data["user_log"] == false { + return nil + } + if entry.Data["identifier"] == nil { + return errors.New("no identifier set to user log entry") + } + if entry.Context == nil { + return errors.New("no context set to user log entry") + } + + message, err := h.format(entry) + if err != nil { + return err + } + return h.publish(entry, message) +} + +// Levels returns a list of levels that this hook reacts to +func (h RabbitMQHook) Levels() []logrus.Level { + return []logrus.Level{ + logrus.PanicLevel, + logrus.FatalLevel, + logrus.ErrorLevel, + logrus.WarnLevel, + logrus.InfoLevel, + logrus.DebugLevel, + logrus.TraceLevel, + } +} + +// format serializes an entry to a format better suited for ETOS logging. +func (h RabbitMQHook) format(entry *logrus.Entry) ([]byte, error) { + // Changing the timezone to UTC from whatever the host system uses + // but we don't want to change the output from the string formatter that + // prints to terminal, so we store the old Time, change the entry.Time + // and then change it back to what it was before we started screwing + // with it. + originalTime := entry.Time + entry.Time = entry.Time.UTC() + formatted, err := h.formatter.Format(entry) + entry.Time = originalTime + return formatted, err +} + +// publish publishes a log message to RabbitMQ. +func (h RabbitMQHook) publish(entry *logrus.Entry, message []byte) error { + routingKey := fmt.Sprintf("%s.log.%s", entry.Data["identifier"], entry.Logger.Level.String()) + return h.publisher.Publish( + entry.Context, + entry.WithField("user_log", false), + routingKey, + amqp.Publishing{Body: message}, + ) +} diff --git a/internal/executionspace/provider/kubernetes.go b/internal/executionspace/provider/kubernetes.go new file mode 100644 index 0000000..a1b1412 --- /dev/null +++ b/internal/executionspace/provider/kubernetes.go @@ -0,0 +1,44 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package provider + +import ( + "fmt" + "sync" + + config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + "github.com/eiffel-community/etos-api/internal/executionspace/database" + "github.com/eiffel-community/etos-api/internal/executionspace/executor" +) + +type Kubernetes struct { + provider +} + +// New creates a copy of a Kubernetes provider +func (k Kubernetes) New(db database.Opener, cfg config.Config) Provider { + return &Kubernetes{ + provider{ + db: db, + cfg: cfg, + url: fmt.Sprintf("%s/v1alpha/executor/kubernetes", cfg.Hostname()), + executor: executor.Kubernetes( + cfg.KubernetesNamespace(), + ), + active: &sync.WaitGroup{}, + }, + } +} diff --git a/internal/executionspace/provider/provider.go b/internal/executionspace/provider/provider.go new file mode 100644 index 0000000..b35cc0b --- /dev/null +++ b/internal/executionspace/provider/provider.go @@ -0,0 +1,183 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package provider + +import ( + "context" + "sync" + + config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + "github.com/eiffel-community/etos-api/internal/executionspace/database" + "github.com/eiffel-community/etos-api/internal/executionspace/executor" + "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +type Provider interface { + New(database.Opener, config.Config) Provider + Status(*logrus.Entry, context.Context, uuid.UUID) (*executionspace.ExecutionSpace, error) + Checkout(*logrus.Entry, context.Context, ExecutorConfig) + Checkin(*logrus.Entry, context.Context, []executionspace.ExecutorSpec) error + Executor() executor.Executor + SaveExecutor(context.Context, executionspace.ExecutorSpec) error + Job(context.Context, uuid.UUID) (string, error) + ExecutorSpec(context.Context, uuid.UUID) (*executionspace.ExecutorSpec, error) + ExecutionSpace(context.Context, uuid.UUID) (*executionspace.ExecutionSpace, error) + Done() +} + +type ExecutorConfig struct { + Amount int + TestRunner string + CheckoutID uuid.UUID + ETOSIdentifier string + Environment map[string]string +} + +// provider partially implements the Provider interface. To use it it should +// be included into another struct that implements the rest of the interface. +type provider struct { + db database.Opener + cfg config.Config + url string + active *sync.WaitGroup + executor executor.Executor +} + +// Get fetches execution space status from a database +func (e provider) Status(logger *logrus.Entry, ctx context.Context, id uuid.UUID) (*executionspace.ExecutionSpace, error) { + e.active.Add(1) + defer e.active.Done() + + executionSpace, err := e.ExecutionSpace(ctx, id) + if err != nil { + return &executionspace.ExecutionSpace{ + ID: id, + Status: executionspace.Failed, + Description: err.Error(), + }, err + } + + for _, reference := range executionSpace.References { + id, err := uuid.Parse(reference) + if err != nil { + return &executionspace.ExecutionSpace{ + ID: id, + Status: executionspace.Failed, + Description: err.Error(), + }, err + } + + executor, err := e.ExecutorSpec(ctx, id) + if err != nil { + return &executionspace.ExecutionSpace{ + ID: id, + Status: executionspace.Failed, + Description: err.Error(), + }, err + } + executionSpace.Executors = append(executionSpace.Executors, *executor) + } + return executionSpace, nil +} + +// Checkout checks out an execution space and stores it in a database +func (e provider) Checkout(logger *logrus.Entry, ctx context.Context, cfg ExecutorConfig) { + e.active.Add(1) + defer e.active.Done() + + executionSpace := executionspace.New(cfg.CheckoutID) + client := e.db.Open(ctx, cfg.CheckoutID) + if err := executionSpace.Save(client); err != nil { + logger.Errorf("failed to write checkout pending status to RedisDB - %s", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(ctx, e.cfg.ExecutionSpaceWaitTimeout()) + defer cancel() + + for i := 0; i < cfg.Amount; i++ { + executor := executionspace.NewExecutorSpec(e.url, cfg.ETOSIdentifier, cfg.TestRunner, cfg.Environment, ctx) + executionSpace.Add(executor) + if err := e.SaveExecutor(ctx, executor); err != nil { + executionSpace.Fail(client, err) + return + } + } + executionSpace.Status = executionspace.Done + executionSpace.Description = "Execution spaces checked out successfully" + + if err := executionSpace.Save(client); err != nil { + if failErr := executionSpace.Fail(client, err); err != nil { + logger.Errorf("failed to write failure status to RedisDB - Reason: %s", failErr.Error()) + } + } + logger.WithField("user_log", true).Infof("Executor prepared for running tests") +} + +// Checkin checks in an execution space by removing it from database +func (e provider) Checkin(logger *logrus.Entry, ctx context.Context, executors []executionspace.ExecutorSpec) error { + e.active.Add(1) + defer e.active.Done() + for _, executor := range executors { + client := e.db.Open(ctx, executor.ID) + if err := executor.Delete(client); err != nil { + return err + } + } + return nil +} + +// Executor returns the executor of this provider +func (e provider) Executor() executor.Executor { + return e.executor +} + +// SaveExecutor saves an executor specification into a database +func (e provider) SaveExecutor(ctx context.Context, executorSpec executionspace.ExecutorSpec) error { + client := e.db.Open(ctx, executorSpec.ID) + return executorSpec.Save(client) +} + +// Job gets the Build ID of a test runner execution. +func (e provider) Job(ctx context.Context, id uuid.UUID) (string, error) { + executorSpec, err := e.ExecutorSpec(ctx, id) + if err != nil { + return "", err + } + if executorSpec == nil { + return "", nil + } + return executorSpec.BuildID, nil +} + +// ExecutorSpec returns the specification of an executor stored in database +func (e provider) ExecutorSpec(ctx context.Context, id uuid.UUID) (*executionspace.ExecutorSpec, error) { + client := e.db.Open(ctx, id) + return executionspace.LoadExecutorSpec(client) +} + +// ExecutionSPace returns the execution space stored in database +func (e provider) ExecutionSpace(ctx context.Context, id uuid.UUID) (*executionspace.ExecutionSpace, error) { + client := e.db.Open(ctx, id) + return executionspace.Load(client) +} + +// Done waits for all jobs to be done +func (e provider) Done() { + e.active.Wait() +} diff --git a/internal/executionspace/rabbitmq/rabbitmq.go b/internal/executionspace/rabbitmq/rabbitmq.go new file mode 100644 index 0000000..50eabfa --- /dev/null +++ b/internal/executionspace/rabbitmq/rabbitmq.go @@ -0,0 +1,207 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package rabbitmq + +import ( + "context" + "fmt" + "net/url" + "sync" + "time" + + amqp "github.com/rabbitmq/amqp091-go" + "github.com/sethvargo/go-retry" + "github.com/sirupsen/logrus" +) + +// PublisherConfig defines the configuration to use when publishing to an +// exchange. +type PublisherConfig struct { + URL string `yaml:"url"` + ExchangeName string `yaml:"exchange_name"` +} + +// Publisher maintains a persistent AMQP connection that can be used to +// publish messages synchronously. +// +// A publisher can safely be called concurrently from multiple goroutines, +// but the broker communication will be serialized with no more than one +// outstanding message, i.e. you should publish concurrently when it's +// convenient and not because you want to increase the rate of publishing. +// Use multiple Publishers in the latter case. +type Publisher struct { + config PublisherConfig + conn *amqp.Connection + channel *amqp.Channel + chanClosures chan *amqp.Error + connClosures chan *amqp.Error + confirmations chan amqp.Confirmation + hasOutstanding bool // Is there an in-flight message that hasn't been (n)acked? + connMu sync.Mutex // Prevent overlapping connection setup/teardown + publishMu sync.Mutex // Prevent overlapping publishing +} + +func NewPublisher(config PublisherConfig) *Publisher { + return &Publisher{ + config: config, + } +} + +// Close closes any current connection and any channel open within it. +// This will interrupt any ongoing publishing, but only temporarily as +// it'll retry. To permanently interrupt ongoing publishing and force +// a return to the caller, cancel the context passed to Publish. +func (p *Publisher) Close() { + p.connMu.Lock() + if p.conn != nil { + // Closing the connection also closes p.channel and notification channels. + p.conn.Close() + } + p.connMu.Unlock() +} + +// Publish attempts to publish a single message. It'll block until a connection +// is available, sends the message, and then waits for the message to be +// acknowledged by the broker. All kinds of errors except context expirations +// are retried indefinitely with a backoff. +// +// Connections are created lazily upon the first call to this method and will +// be kept alive. Any error will cause the connection to be torn down and +// reestablished upon the next attempt. +func (p *Publisher) Publish(ctx context.Context, logger *logrus.Entry, topic string, message amqp.Publishing) error { + backoff := retry.WithCappedDuration(1*time.Minute, retry.NewExponential(1*time.Second)) + return retry.Do(ctx, backoff, func(ctx context.Context) error { + if err := p.tryPublish(ctx, logger, topic, message); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + logger.Errorf("Could not publish message, will retry: %s", err) + return retry.RetryableError(err) + } + return nil + }) +} + +// awaitConfirmation waits for and returns the confirmation (positive or +// negative) for a single message. An error is returned if the context +// expires or the connection or channel closes. Must only be called while +// the p.publishMu mutex is held. +func (p *Publisher) awaitConfirmation(ctx context.Context) (amqp.Confirmation, error) { + select { + case err := <-p.connClosures: + return amqp.Confirmation{}, fmt.Errorf("connection closed: %w", err) + case err := <-p.chanClosures: + return amqp.Confirmation{}, fmt.Errorf("channel closed: %w", err) + case c := <-p.confirmations: + p.hasOutstanding = false + return c, nil + case <-ctx.Done(): + return amqp.Confirmation{}, ctx.Err() + } +} + +func (p *Publisher) ensureConnection(logger *logrus.Entry) error { + p.connMu.Lock() + defer p.connMu.Unlock() + if p.conn == nil || p.channel == nil || p.conn.IsClosed() { + if p.conn != nil { + p.conn.Close() + } + amqpURL, err := url.Parse(p.config.URL) + if err != nil { + return fmt.Errorf("invalid AMQP URL: %w", err) + } + logger.Infof("Opening AMQP connection to %s", amqpURL.Redacted()) + if p.conn, err = amqp.Dial(amqpURL.String()); err != nil { + return fmt.Errorf("error making AMQP connection: %w", err) + } + p.connClosures = p.conn.NotifyClose(make(chan *amqp.Error, 1)) + + if p.channel, err = p.conn.Channel(); err != nil { + return fmt.Errorf("error creating channel: %w", err) + } + if err = p.channel.Confirm(false); err != nil { + // Force closure of possibly healthy connection to make sure + // we get to set up confirms in the next ensureConnection call. + p.conn.Close() + return fmt.Errorf("error enabling publisher confirms: %w", err) + } + p.hasOutstanding = false + + // Might be overkill to set up notifications for both connection and channel + // closures, but this might save us from getting into weird half-open states. + p.chanClosures = p.channel.NotifyClose(make(chan *amqp.Error, 1)) + p.confirmations = p.channel.NotifyPublish(make(chan amqp.Confirmation, 1)) + } + return nil +} + +// closeOnTimeout closes the connection if the context contains an error (timeout) +func (p *Publisher) CloseOnTimeout(ctx context.Context, logger *logrus.Entry) { + if ctx.Err() != nil { + logger.Info("Forcibly closing RabbitMQ connection due to timeout") + p.Close() + } +} + +func (p *Publisher) tryPublish(ctx context.Context, logger *logrus.Entry, topic string, message amqp.Publishing) error { + if err := p.ensureConnection(logger); err != nil { + return err + } + + // Only one goroutine should be publishing at the same time so we won't + // have to correlate delivery tags to figure out which message was acked. + p.publishMu.Lock() + defer p.publishMu.Unlock() + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + defer p.CloseOnTimeout(ctx, logger) // will always be called, but connection will be closed if ctx contains error + + // If a previous tryPublish call's context expires while it's waiting for + // its confirmation there could be a confirmation already queued up in + // the next tryPublish call. That wouldn't have to be a problem in itself + // (although we'd have to track the delivery tag so we won't claim delivery + // victory over an old confirmation), but if many publishers bail out + // (e.g. because of a retry loop that reuses the same expired context) + // we'll fill up the confirmation channel and eventually block the whole + // AMQP channel and deadlock everything. We mitigate this by only allowing + // one in-flight outbound message and draining the confirmation channel + // prior to each publish operation. + if p.hasOutstanding { + c, err := p.awaitConfirmation(ctx) + if err != nil { + return err + } + if !c.Ack { + logger.Info("A previous message was nacked by the broker") + } + } + + if err := p.channel.Publish(p.config.ExchangeName, topic, false, false, message); err != nil { + return fmt.Errorf("error publishing message: %w", err) + } + p.hasOutstanding = true + + c, err := p.awaitConfirmation(ctx) + if err != nil { + return err + } + if !c.Ack { + return fmt.Errorf("message nacked") + } + return nil +} diff --git a/internal/executionspace/responses/responses.go b/internal/executionspace/responses/responses.go new file mode 100644 index 0000000..5f40b4b --- /dev/null +++ b/internal/executionspace/responses/responses.go @@ -0,0 +1,36 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package responses + +import ( + "encoding/json" + "net/http" +) + +// RespondWithJSON writes a JSON response with a status code to the HTTP ResponseWriter. +func RespondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + response, _ := json.Marshal(payload) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write(response) +} + +// RespondWithError writes a JSON response with an error message and status code to the HTTP ResponseWriter. +func RespondWithError(w http.ResponseWriter, code int, message string) { + RespondWithJSON(w, code, map[string]string{"error": message}) +} diff --git a/internal/executionspace/responses/responses_test.go b/internal/executionspace/responses/responses_test.go new file mode 100644 index 0000000..a819deb --- /dev/null +++ b/internal/executionspace/responses/responses_test.go @@ -0,0 +1,42 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package responses + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Test that RespondWithJSON writes the correct HTTP code, message and adds a content type header. +func TestRespondWithJSON(t *testing.T) { + responseRecorder := httptest.NewRecorder() + RespondWithJSON(responseRecorder, 200, map[string]string{"hello": "world"}) + assert.Equal(t, "application/json", responseRecorder.Header().Get("Content-Type")) + assert.Equal(t, 200, responseRecorder.Result().StatusCode) + assert.JSONEq(t, `{"hello": "world"}`, responseRecorder.Body.String()) +} + +// Test that RespondWithError writes the correct HTTP code, message and adds a content type header. +func TestRespondWithError(t *testing.T) { + responseRecorder := httptest.NewRecorder() + RespondWithError(responseRecorder, 400, "failure") + assert.Equal(t, "application/json", responseRecorder.Header().Get("Content-Type")) + assert.Equal(t, 400, responseRecorder.Result().StatusCode) + assert.JSONEq(t, `{"error": "failure"}`, responseRecorder.Body.String()) +} diff --git a/manifests/base/executionspace/deployment.yaml b/manifests/base/executionspace/deployment.yaml new file mode 100644 index 0000000..4e7a84e --- /dev/null +++ b/manifests/base/executionspace/deployment.yaml @@ -0,0 +1,50 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: etos-executionspace +data: + TZ: "Europe/Stockholm" + PROVIDER_HOSTNAME: http://etos-executionspace +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/part-of: etos + app.kubernetes.io/component: executionspace + name: etos-executionspace +spec: + selector: + matchLabels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/component: executionspace + template: + metadata: + labels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/component: executionspace + spec: + serviceAccountName: etos-executionspace + containers: + - name: etos-executionspace + image: registry.nordix.org/eiffel/etos-executionspace:672f982e + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: etos-executionspace + env: + - name: SERVICE_HOST + value: 0.0.0.0 + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /v1alpha/selftest/ping + port: http + readinessProbe: + httpGet: + path: /v1alpha/selftest/ping + port: http diff --git a/manifests/base/executionspace/kustomization.yaml b/manifests/base/executionspace/kustomization.yaml new file mode 100644 index 0000000..0db2655 --- /dev/null +++ b/manifests/base/executionspace/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - service-account.yaml + - rolebinding.yaml + - service.yaml + - deployment.yaml + - role.yaml \ No newline at end of file diff --git a/manifests/base/executionspace/role.yaml b/manifests/base/executionspace/role.yaml new file mode 100644 index 0000000..49a373d --- /dev/null +++ b/manifests/base/executionspace/role.yaml @@ -0,0 +1,55 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/part-of: etos + app.kubernetes.io/component: executionspace + annotations: + rbac.authorization.kubernetes.io/autoupdate: "true" + name: etos-executionspace:sa:job-creator +rules: +- apiGroups: + - batch + resources: + - jobs + verbs: + - get + - list + - update + - watch + - create + - delete +- apiGroups: + - batch + resources: + - jobs/status + verbs: + - update + - watch + - list + - get +- apiGroups: + - batch + resources: + - jobs/finalizers + verbs: + - update +- apiGroups: + - "" + resources: + - pods + verbs: + - create + - delete + - list + - patch + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + - update \ No newline at end of file diff --git a/manifests/base/executionspace/rolebinding.yaml b/manifests/base/executionspace/rolebinding.yaml new file mode 100644 index 0000000..14b9d8b --- /dev/null +++ b/manifests/base/executionspace/rolebinding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/part-of: etos + app.kubernetes.io/component: executionspace + name: etos-executionspace +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: etos-executionspace:sa:job-creator +subjects: +- kind: ServiceAccount + name: etos-executionspace diff --git a/manifests/base/executionspace/service-account.yaml b/manifests/base/executionspace/service-account.yaml new file mode 100644 index 0000000..00a510f --- /dev/null +++ b/manifests/base/executionspace/service-account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/part-of: etos + app.kubernetes.io/component: executionspace + name: etos-executionspace diff --git a/manifests/base/executionspace/service.yaml b/manifests/base/executionspace/service.yaml new file mode 100644 index 0000000..5f80e51 --- /dev/null +++ b/manifests/base/executionspace/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: etos-api + app.kubernetes.io/part-of: etos + app.kubernetes.io/component: executionspace + name: etos-executionspace +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app.kubernetes.io/name: etos-api + app.kubernetes.io/component: executionspace + type: ClusterIP diff --git a/manifests/base/kustomization.yaml b/manifests/base/kustomization.yaml index dbaa3bd..c598bec 100644 --- a/manifests/base/kustomization.yaml +++ b/manifests/base/kustomization.yaml @@ -10,6 +10,7 @@ resources: - ./sse - ./logarea - ./iut + - ./executionspace # By generating the configmap it will get a unique name on each apply diff --git a/pkg/executionspace/errors/errors.go b/pkg/executionspace/errors/errors.go new file mode 100644 index 0000000..d22f7e3 --- /dev/null +++ b/pkg/executionspace/errors/errors.go @@ -0,0 +1,43 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httperrors + +import ( + "fmt" + "net/http" +) + +// HTTPError is a wrapper around a standard error but also adding HTTP status code. +type HTTPError struct { + Original error + Message string + Code int +} + +// NewHTTPError creates a new HTTPError wrapping another error with it. +func NewHTTPError(e error, httpCode int) *HTTPError { + return &HTTPError{ + Original: e, + Message: e.Error(), + Code: httpCode, + } +} + +// Error is the string representation of HTTPError. +func (e *HTTPError) Error() string { + return fmt.Sprintf("(%d: %s): %s", e.Code, http.StatusText(e.Code), e.Message) +} diff --git a/pkg/executionspace/executionspace/executionspace.go b/pkg/executionspace/executionspace/executionspace.go new file mode 100644 index 0000000..25c7e7d --- /dev/null +++ b/pkg/executionspace/executionspace/executionspace.go @@ -0,0 +1,87 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package executionspace + +import ( + "encoding/json" + "errors" + "io" + + "github.com/google/uuid" +) + +type CheckoutStatus string + +const ( + Pending CheckoutStatus = "PENDING" + Failed CheckoutStatus = "FAILED" + Done CheckoutStatus = "DONE" +) + +type ExecutionSpace struct { + ID uuid.UUID `json:"id"` + Executors []ExecutorSpec `json:"execution_spaces"` + References []string `json:"references"` + Status CheckoutStatus `json:"status"` + Description string `json:"description"` +} + +// New creates a new ExecutionSpace +func New(id uuid.UUID) *ExecutionSpace { + return &ExecutionSpace{ + ID: id, + Status: Pending, + Description: "Checking out execution spaces", + } +} + +// Load loads an ExecutionSpace from an io Reader +func Load(r io.Reader) (*ExecutionSpace, error) { + executionSpace := &ExecutionSpace{} + decoder := json.NewDecoder(r) + if err := decoder.Decode(executionSpace); err != nil { + return nil, err + } + return executionSpace, nil +} + +// Add adds a new executor to an execution space +func (e *ExecutionSpace) Add(executor ExecutorSpec) { + e.Executors = append(e.Executors, executor) + e.References = append(e.References, executor.ID.String()) +} + +// Save saves an execution space to an io Writer +func (e ExecutionSpace) Save(w io.Writer) error { + // Executors should be written separately into their own writers. + e.Executors = nil + + encoder := json.NewEncoder(w) + if err := encoder.Encode(e); err != nil { + return errors.Join(errors.New("failed to write execution space to database"), err) + } + return nil +} + +// Fail writes a failure message to an io Writer +func (e ExecutionSpace) Fail(w io.Writer, err error) error { + fakeExecutionSpace := ExecutionSpace{ + ID: e.ID, + Status: Failed, + Description: err.Error(), + } + return fakeExecutionSpace.Save(w) +} diff --git a/pkg/executionspace/executionspace/executor.go b/pkg/executionspace/executionspace/executor.go new file mode 100644 index 0000000..729003e --- /dev/null +++ b/pkg/executionspace/executionspace/executor.go @@ -0,0 +1,145 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package executionspace + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "strings" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +type Data struct { + ID uuid.UUID `json:"id"` +} + +type Request struct { + URL string `json:"url"` + Method string `json:"method"` + Data Data `json:"json"` + Headers map[string]string `json:"headers"` + Timeout int `json:"timeout"` +} + +type Instructions struct { + Image string `json:"image"` + Environment map[string]string `json:"environment"` + Parameters map[string]string `json:"parameters"` + Identifier uuid.UUID `json:"identifier"` +} + +type ExecutorSpec struct { + Request Request `json:"request"` + Instructions Instructions `json:"instructions"` + ID uuid.UUID `json:"id"` + BuildID string +} + +// NewExecutorSpec creates a new ExecutorSpec +func NewExecutorSpec(url string, etosIdentifier string, testRunner string, environment map[string]string, otelCtx context.Context) ExecutorSpec { + id := uuid.New() + + headers := make(map[string]string) + headers["X-Etos-id"] = etosIdentifier + + carrier := propagation.HeaderCarrier(make(map[string][]string)) + propagators := otel.GetTextMapPropagator() + propagators.Inject(otelCtx, carrier) + + for key, values := range carrier { + headers[key] = strings.Join(values, ",") + } + + e := ExecutorSpec{ + Request: Request{ + URL: url, + Method: "POST", + Timeout: 7200, // 2 hours + Data: Data{ + ID: id, + }, + Headers: headers, + }, + Instructions: Instructions{ + Environment: environment, + Image: testRunner, + Parameters: map[string]string{}, + Identifier: uuid.New(), + }, + ID: id, + } + e.Instructions.Environment["ENVIRONMENT_ID"] = id.String() + if v := os.Getenv("EXECUTOR_HTTPS_PROXY"); v != "" { + e.Instructions.Environment["HTTPS_PROXY"] = v + e.Instructions.Environment["https_proxy"] = v + } + if v := os.Getenv("EXECUTOR_HTTP_PROXY"); v != "" { + e.Instructions.Environment["HTTP_PROXY"] = v + e.Instructions.Environment["http_proxy"] = v + } + if v := os.Getenv("EXECUTOR_NO_PROXY"); v != "" { + e.Instructions.Environment["NO_PROXY"] = v + e.Instructions.Environment["no_proxy"] = v + } + if v := os.Getenv("EXECUTOR_TZ"); v != "" { + e.Instructions.Environment["TZ"] = v + } + return e +} + +// LoadExecutorSpec loads an ExecutorSpec from an io Reader +func LoadExecutorSpec(r io.Reader) (*ExecutorSpec, error) { + executor := &ExecutorSpec{} + decoder := json.NewDecoder(r) + if err := decoder.Decode(executor); err != nil { + return nil, err + } + return executor, nil +} + +// LoadExecutorSpecs loads multiple executors from a single io Reader +func LoadExecutorSpecs(r io.Reader) ([]ExecutorSpec, error) { + var executors []ExecutorSpec + decoder := json.NewDecoder(r) + if err := decoder.Decode(&executors); err != nil { + return nil, err + } + return executors, nil +} + +// Save saves an execution space to an io Writer +func (e ExecutorSpec) Save(w io.Writer) error { + encoder := json.NewEncoder(w) + if err := encoder.Encode(e); err != nil { + return errors.Join(errors.New("failed to write executor to database"), err) + } + return nil +} + +// Delete deletes an executor from an io Writer +func (e ExecutorSpec) Delete(w io.Writer) error { + _, err := w.Write(nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/executionspace/v1alpha/executor.go b/pkg/executionspace/v1alpha/executor.go new file mode 100644 index 0000000..027bc2f --- /dev/null +++ b/pkg/executionspace/v1alpha/executor.go @@ -0,0 +1,233 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package providerservice + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/eiffel-community/eiffelevents-sdk-go" + "github.com/eiffel-community/etos-api/internal/executionspace/eventrepository" + "github.com/eiffel-community/etos-api/internal/executionspace/executor" + "github.com/eiffel-community/etos-api/internal/executionspace/responses" + "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "github.com/sethvargo/go-retry" + "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +type startRequest struct { + ID uuid.UUID `json:"id"` +} + +// Start starts up a testrunner job and waits for it to start completely +func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + h.wg.Add(1) + defer h.wg.Done() + + identifier := r.Header.Get("X-Etos-Id") + // This context is used until we can retrieve the timeout we shall be using from the executorSpec. + ctx, cancelRequest := context.WithCancel(r.Context()) + defer cancelRequest() + logger := h.logger.WithField("identifier", identifier).WithContext(ctx) + + _, span := h.getOtelTracer().Start(h.getOtelContext(ctx, r), "start_executor", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + executorName := h.provider.Executor().Name() + request := startRequest{} + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + msg := fmt.Errorf("There was an error when preparing the %s execution space", executorName) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + responses.RespondWithError(w, http.StatusBadRequest, "could not read ID from post body") + return + } + + executor, err := h.provider.ExecutorSpec(ctx, request.ID) + if err != nil { + msg := fmt.Errorf("Timed out when reading the %s execution space configuration from database", executorName) + if ctx.Err() != nil { + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + return + } + responses.RespondWithError(w, http.StatusBadRequest, msg.Error()) + logger.WithField("user_log", true).Error(msg) + return + } + ctx, cancel := context.WithTimeout(r.Context(), time.Second*time.Duration(executor.Request.Timeout)) + defer cancel() + + id, err := h.provider.Executor().Start(ctx, logger, executor) + if err != nil { + if ctx.Err() != nil { + msg := fmt.Errorf("Timed out when trying to start the test execution job") + responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + msg := fmt.Errorf("Error trying to start the test execution job: %s", err.Error()) + responses.RespondWithError(w, http.StatusInternalServerError, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + + buildID, buildURL, err := h.provider.Executor().Wait(ctx, logger, id, executor) + if err != nil { + if cancelErr := h.provider.Executor().Cancel(context.Background(), logger, id); cancelErr != nil { + msg := fmt.Errorf("cancel failed: %s", cancelErr.Error()) + logger.Error(msg) + h.recordOtelException(span, msg) + } + if ctx.Err() != nil { + msg := fmt.Errorf("Timed out when waiting for the test execution job to start - Error: %s", err.Error()) + responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + msg := fmt.Errorf("Error when waiting for the test execution job to start - Error: %s", err.Error()) + responses.RespondWithError(w, http.StatusInternalServerError, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + executor.BuildID = buildID + + if err := h.provider.SaveExecutor(ctx, *executor); err != nil { + logger.Error(err.Error()) + if cancelErr := h.provider.Executor().Stop(context.Background(), logger, buildID); cancelErr != nil { + msg := fmt.Errorf("cancel failed: %s", cancelErr.Error()) + logger.Error(msg) + h.recordOtelException(span, msg) + } + if ctx.Err() != nil { + msg := fmt.Errorf("Timed out when saving the test execution configuration") + responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + msg := fmt.Errorf("Error when saving the test execution configuration") + responses.RespondWithError(w, http.StatusInternalServerError, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + + subSuiteState := state{ExecutorSpec: executor} + if err = subSuiteState.waitStart(ctx, logger, h.provider.Executor()); err != nil { + if cancelErr := h.provider.Executor().Stop(context.Background(), logger, buildID); cancelErr != nil { + msg := fmt.Errorf("cancel failed: %s", cancelErr.Error()) + logger.Error(msg) + } + if ctx.Err() != nil { + msg := fmt.Errorf("Timed out when waiting for the test execution job to initialize - Error: %s", err.Error()) + responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + msg := fmt.Errorf("Error when waiting for the test execution job to initialize - Error: %s", err.Error()) + responses.RespondWithError(w, http.StatusBadRequest, msg.Error()) + logger.WithField("user_log", true).Error(msg) + h.recordOtelException(span, msg) + return + } + span.SetAttributes(attribute.String("etos.execution_space.build_id", buildID)) + span.SetAttributes(attribute.String("etos.execution_space.build_url", buildURL)) + logger.WithField("user_log", true).Info("Executor has started successfully") + if buildURL != "" { + logger.WithField("user_log", true).Info("Executor build URL: ", buildURL) + } + responses.RespondWithError(w, http.StatusNoContent, "") +} + +type state struct { + ExecutorSpec *executionspace.ExecutorSpec + environment *eiffelevents.EnvironmentDefinedV3 + mainSuite *eiffelevents.TestSuiteStartedV3 +} + +// getSubSuite gets a sub suite from event repository +func (s *state) getSubSuite(logger *logrus.Entry, ctx context.Context) (*eiffelevents.TestSuiteStartedV3, error) { + if s.environment == nil { + event, err := eventrepository.EnvironmentDefined(ctx, s.ExecutorSpec.Instructions.Environment["ENVIRONMENT_ID"]) + if err != nil { + return nil, err + } + s.environment = event + } + if s.environment != nil && s.mainSuite == nil { + event, err := eventrepository.MainSuiteStarted(ctx, s.environment.Links.FindFirst("CONTEXT")) + if err != nil { + return nil, err + } + s.mainSuite = event + } + if s.mainSuite != nil && s.environment != nil { + event, err := eventrepository.TestSuiteStarted(ctx, s.mainSuite.Meta.ID, s.environment.Data.Name) + if err != nil { + return nil, err + } + return event, err + } + return nil, errors.New("sub suite not yet available") +} + +// waitStart waits for a job to start completely +func (s *state) waitStart(ctx context.Context, logger *logrus.Entry, executor executor.Executor) error { + var event *eiffelevents.TestSuiteStartedV3 + var err error + if err = retry.Fibonacci(ctx, 5*time.Second, func(ctx context.Context) error { + alive, err := executor.Alive(ctx, logger, s.ExecutorSpec.BuildID) + if err != nil { + logger.Errorf("Retrying - %s", err.Error()) + // TODO: Verify that this is retryable + return retry.RetryableError(err) + } + if !alive { + return errors.New("test runner did not start properly") + } + if event == nil { + event, err = s.getSubSuite(logger, ctx) + if err != nil { + logger.Errorf("Retrying - %s", err.Error()) + // TODO: Verify that this is always retryable + return retry.RetryableError(err) + } + } + if event == nil { + return retry.RetryableError(errors.New("not yet started")) + } + return nil + }); err != nil { + return err + } + return nil +} diff --git a/pkg/executionspace/v1alpha/provider.go b/pkg/executionspace/v1alpha/provider.go new file mode 100644 index 0000000..33c1e7d --- /dev/null +++ b/pkg/executionspace/v1alpha/provider.go @@ -0,0 +1,406 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package providerservice + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "runtime" + "sync" + + "github.com/eiffel-community/eiffelevents-sdk-go" + config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + "github.com/eiffel-community/etos-api/internal/executionspace/provider" + "github.com/eiffel-community/etos-api/internal/executionspace/responses" + "github.com/eiffel-community/etos-api/pkg/application" + httperrors "github.com/eiffel-community/etos-api/pkg/executionspace/errors" + "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" + "github.com/package-url/packageurl-go" + "github.com/sirupsen/logrus" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.4.0" + "go.opentelemetry.io/otel/trace" +) + +var ( + service_version string + otel_sdk_version string +) + +type ProviderServiceApplication struct { + logger *logrus.Entry + cfg config.Config + provider provider.Provider + wg *sync.WaitGroup +} + +type ProviderServiceHandler struct { + logger *logrus.Entry + cfg config.Config + provider provider.Provider + wg *sync.WaitGroup +} + +type StartRequest struct { + MinimumAmount int `json:"minimum_amount"` + MaximumAmount int `json:"maximum_amount"` + TestRunner string `json:"test_runner"` + Environment map[string]string `json:"environment"` + ArtifactIdentity string `json:"identity"` + ArtifactID string `json:"artifact_id"` + ArtifactCreated eiffelevents.ArtifactCreatedV3 `json:"artifact_created,omitempty"` + ArtifactPublished eiffelevents.ArtifactPublishedV3 `json:"artifact_published,omitempty"` + TERCC eiffelevents.TestExecutionRecipeCollectionCreatedV4 `json:"tercc,omitempty"` + Dataset Dataset `json:"dataset,omitempty"` + Context uuid.UUID `json:"context,omitempty"` +} + +type Dataset struct { + ETRBranch string `json:"ETR_BRANCH"` + ETRRepo string `json:"ETR_REPO"` +} + +type StartResponse struct { + ID uuid.UUID `json:"id"` +} + +type StatusRequest struct { + ID uuid.UUID `json:"id"` +} + +// initTracer initializes the OpenTelemetry instrumentation for trace collection +func (a *ProviderServiceApplication) initTracer() { + _, endpointSet := os.LookupEnv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + if !endpointSet { + a.logger.Infof("No OpenTelemetry collector is set. OpenTelemetry traces will not be available.") + return + } + collector := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + a.logger.Infof("Using OpenTelemetry collector: %s", collector) + + // Create OTLP exporter to export traces + exporter, err := otlptrace.New(context.Background(), otlptracegrpc.NewClient( + otlptracegrpc.WithInsecure(), + otlptracegrpc.WithEndpoint(collector), + )) + if err != nil { + log.Fatal(err) + } + + // Create a resource with service name attribute + res, err := resource.New(context.Background(), + resource.WithAttributes( + semconv.ServiceNameKey.String("execution-space-provider"), + semconv.ServiceNamespaceKey.String(os.Getenv("OTEL_SERVICE_NAMESPACE")), + semconv.ServiceVersionKey.String(service_version), + semconv.TelemetrySDKLanguageGo.Key.String("go"), + semconv.TelemetrySDKNameKey.String("opentelemetry"), + semconv.TelemetrySDKVersionKey.String(otel_sdk_version), + ), + ) + if err != nil { + log.Fatal(err) + } + + // Create a TraceProvider with the exporter and resource + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + + // Set the global TracerProvider + otel.SetTracerProvider(tp) + + // Set the global propagator to TraceContext (W3C Trace Context) + otel.SetTextMapPropagator(propagation.TraceContext{}) +} + +// Close waits for all active jobs to finish +func (a *ProviderServiceApplication) Close() { + a.provider.Done() + a.wg.Wait() +} + +// New returns a new ProviderServiceApplication object/struct +func New(cfg config.Config, log *logrus.Entry, provider provider.Provider, ctx context.Context) application.Application { + return &ProviderServiceApplication{ + logger: log, + cfg: cfg, + provider: provider, + wg: &sync.WaitGroup{}, + } +} + +// LoadRoutes loads all the v1alpha1 routes. +func (a ProviderServiceApplication) LoadRoutes(router *httprouter.Router) { + handler := &ProviderServiceHandler{a.logger, a.cfg, a.provider, a.wg} + router.GET("/v1alpha/selftest/ping", handler.Selftest) + router.POST("/start", handler.panicRecovery(handler.timeoutHandler(handler.Start))) + router.GET("/status", handler.panicRecovery(handler.timeoutHandler(handler.Status))) + router.POST("/stop", handler.panicRecovery(handler.timeoutHandler(handler.Stop))) + + router.POST(fmt.Sprintf("/v1alpha/executor/%s", a.provider.Executor().Name()), handler.panicRecovery(handler.timeoutHandler(handler.ExecutorStart))) + a.initTracer() +} + +// getOtelTracer returns the current OpenTelemetry tracer +func (h ProviderServiceHandler) getOtelTracer() trace.Tracer { + return otel.Tracer("execution-space-provider") +} + +// getOtelContext returns OpenTelemetry context from the given HTTP request object +func (h ProviderServiceHandler) getOtelContext(ctx context.Context, r *http.Request) context.Context { + return otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(r.Header)) +} + +// recordOtelException records an error to the given span +func (h ProviderServiceHandler) recordOtelException(span trace.Span, err error) { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) +} + +// Selftest is a handler to just return 204. +func (h ProviderServiceHandler) Selftest(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + responses.RespondWithError(w, http.StatusNoContent, "") +} + +// Start handles the start request and checks out execution spaces +func (h ProviderServiceHandler) Start(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := context.Background() + identifier := r.Header.Get("X-Etos-Id") + logger := h.logger.WithField("identifier", identifier).WithContext(ctx) + checkoutId := uuid.New() + + ctx = h.getOtelContext(ctx, r) + _, span := h.getOtelTracer().Start(ctx, "start", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + startReq, err := h.verifyStartInput(logger, r) + if err != nil { + msg := fmt.Errorf("start input could not be verified: %s", err.Error()) + logger.Error(msg) + h.recordOtelException(span, msg) + sendError(w, msg) + return + } + if startReq.MaximumAmount == 0 { + startReq.MaximumAmount = startReq.MinimumAmount + } + if startReq.Dataset.ETRBranch != "" { + startReq.Environment["ETR_BRANCH"] = startReq.Dataset.ETRBranch + } + if startReq.Dataset.ETRRepo != "" { + startReq.Environment["ETR_REPOSITORY"] = startReq.Dataset.ETRRepo + } + + go h.provider.Checkout(logger, ctx, provider.ExecutorConfig{ + Amount: startReq.MaximumAmount, + TestRunner: startReq.TestRunner, + Environment: startReq.Environment, + ETOSIdentifier: identifier, + CheckoutID: checkoutId, + }) + span.SetAttributes(attribute.Int("etos.execution_space_provider.checkout.maximum_amount", startReq.MaximumAmount)) + span.SetAttributes(attribute.String("etos.execution_space_provider.checkout.test_runner", startReq.TestRunner)) + span.SetAttributes(attribute.String("etos.execution_space_provider.checkout.environment", fmt.Sprintf("%v", startReq.Environment))) + span.SetAttributes(attribute.String("etos.execution_space_provider.checkout.id", checkoutId.String())) + + responses.RespondWithJSON(w, http.StatusOK, StartResponse{ID: checkoutId}) +} + +// Status handles the status request, gets and returns the execution space checkout status +func (h ProviderServiceHandler) Status(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + identifier := r.Header.Get("X-Etos-Id") + logger := h.logger.WithField("identifier", identifier).WithContext(r.Context()) + + ctx, span := h.getOtelTracer().Start(h.getOtelContext(context.Background(), r), "status", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + id, err := uuid.Parse(r.URL.Query().Get("id")) + if err != nil { + msg := fmt.Errorf("Error parsing id parameter in status request - Reason: %s", err.Error()) + logger.Error(msg) + h.recordOtelException(span, msg) + sendError(w, httperrors.NewHTTPError(msg, http.StatusBadRequest)) + return + } + + executionSpace, err := h.provider.Status(logger, ctx, id) + if err != nil { + msg := fmt.Errorf("Failed to retrieve execution space status (id=%s) - Reason: %s", id, err.Error()) + logger.Error(msg.Error()) + h.recordOtelException(span, msg) + responses.RespondWithJSON(w, http.StatusInternalServerError, executionSpace) + return + } + + for _, executorSpec := range executionSpace.Executors { + span.SetAttributes( + attribute.String("etos.execution_space_provider.status.executorspec", fmt.Sprintf("%v", executorSpec)), + ) + } + responses.RespondWithJSON(w, http.StatusOK, executionSpace) +} + +// Stop handles the stop request, stops the execution space executors and checks in all the provided execution spaces +func (h ProviderServiceHandler) Stop(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + h.wg.Add(1) + defer h.wg.Done() + identifier := r.Header.Get("X-Etos-Id") + logger := h.logger.WithField("identifier", identifier).WithContext(r.Context()) + + ctx := h.getOtelContext(context.Background(), r) + ctx, span := h.getOtelTracer().Start(ctx, "stop", trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + + executors, err := executionspace.LoadExecutorSpecs(r.Body) + if err != nil { + msg := fmt.Errorf("failed to load executor spec: %s. Unable to decode post body: %v", err.Error(), err) + logger.Error(msg) + h.recordOtelException(span, msg) + sendError(w, httperrors.NewHTTPError(msg, http.StatusBadRequest)) + return + } + defer r.Body.Close() + + err = nil + for _, executorSpec := range executors { + id, stopErr := h.provider.Job(r.Context(), executorSpec.ID) + if stopErr != nil { + if errors.Is(stopErr, io.EOF) { + // Already been checked in + continue + } + err = errors.Join(err, stopErr) + continue + } + // If the executorSpec does not exist in the database, we should not + // try to stop the job (because we cannot find the job without the ID) + if id == "" { + continue + } + if stopErr := h.provider.Executor().Stop(r.Context(), logger, id); stopErr != nil { + err = errors.Join(err, stopErr) + } + success := true + if stopErr != nil { + success = false + msg := fmt.Errorf("Failed to stop executor %v - Reason: %s", id, stopErr.Error()) + logger.Error(msg) + h.recordOtelException(span, msg) + } + span.SetAttributes(attribute.Bool(fmt.Sprintf("etos.execution_space_provider.stop.%v", id), success)) + } + if err != nil { + msg := fmt.Errorf("Some of the executors could not be stopped - Reason: %s", err.Error()) + logger.Error(msg) + h.recordOtelException(span, msg) + responses.RespondWithJSON(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := h.provider.Checkin(logger, r.Context(), executors); err != nil { + msg := fmt.Errorf("Failed to check in executors: %v - Reason: %s", executors, err) + logger.Error(msg) + h.recordOtelException(span, msg) + responses.RespondWithJSON(w, http.StatusInternalServerError, msg) + return + } + responses.RespondWithJSON(w, http.StatusNoContent, "") +} + +// sendError sends an error HTTP response depending on which error has been returned. +func sendError(w http.ResponseWriter, err error) { + httpError, ok := err.(*httperrors.HTTPError) + if !ok { + responses.RespondWithError(w, http.StatusInternalServerError, fmt.Sprintf("unknown error %+v", err)) + } else { + responses.RespondWithError(w, httpError.Code, httpError.Message) + } +} + +// verifyStartInput verify input (json body) from a start request +func (h ProviderServiceHandler) verifyStartInput(logger *logrus.Entry, r *http.Request) (StartRequest, error) { + request := StartRequest{} + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + return request, httperrors.NewHTTPError( + fmt.Errorf("unable to decode post body - Reason: %s", err.Error()), + http.StatusBadRequest, + ) + } + _, purlErr := packageurl.FromString(request.ArtifactIdentity) + if purlErr != nil { + return request, httperrors.NewHTTPError(purlErr, http.StatusBadRequest) + } + + return request, nil +} + +// timeoutHandler will change the request context to a timeout context. +func (h ProviderServiceHandler) timeoutHandler( + fn func(http.ResponseWriter, *http.Request, httprouter.Params), +) func(http.ResponseWriter, *http.Request, httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx, cancel := context.WithTimeout(r.Context(), h.cfg.Timeout()) + defer cancel() + newRequest := r.WithContext(ctx) + fn(w, newRequest, ps) + } +} + +// panicRecovery tracks panics from the service, logs them and returns an error response to the user. +func (h ProviderServiceHandler) panicRecovery( + fn func(http.ResponseWriter, *http.Request, httprouter.Params), +) func(http.ResponseWriter, *http.Request, httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + defer func() { + if err := recover(); err != nil { + buf := make([]byte, 2048) + n := runtime.Stack(buf, false) + buf = buf[:n] + h.logger.WithField( + "identifier", ps.ByName("identifier"), + ).WithContext( + r.Context(), + ).Errorf("recovering from err %+v\n %s", err, buf) + identifier := ps.ByName("identifier") + responses.RespondWithError( + w, + http.StatusInternalServerError, + fmt.Sprintf("unknown error: contact server admin with id '%s'", identifier), + ) + } + }() + fn(w, r, ps) + } +} From 8a034fc72610d6d2111c86abb0259b993279e345 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Wed, 25 Sep 2024 13:18:11 +0200 Subject: [PATCH 2/7] Stepped up Go version for etos-sse and etos-logarea --- deploy/etos-logarea/Dockerfile | 2 +- deploy/etos-sse/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/etos-logarea/Dockerfile b/deploy/etos-logarea/Dockerfile index 86d7664..e76bbbf 100644 --- a/deploy/etos-logarea/Dockerfile +++ b/deploy/etos-logarea/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine AS build +FROM golang:1.22-alpine AS build WORKDIR /tmp/logarea COPY . . RUN apk add --no-cache make=4.4.1-r2 git=2.45.2-r0 && make logarea diff --git a/deploy/etos-sse/Dockerfile b/deploy/etos-sse/Dockerfile index 9015f65..cf9dce4 100644 --- a/deploy/etos-sse/Dockerfile +++ b/deploy/etos-sse/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine AS build +FROM golang:1.22-alpine AS build WORKDIR /tmp/sse COPY . . RUN apk add --no-cache make=4.4.1-r2 git=2.45.2-r0 && make sse From a68434e96fccaaffece73d9def68f4393c72de75 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 27 Sep 2024 09:59:51 +0200 Subject: [PATCH 3/7] code review changes --- cmd/executionspace/main.go | 15 ++--- deploy/etos-executionspace/docker-compose.yml | 1 - internal/configs/executionspace/config.go | 12 +++- .../{executionspace => }/database/database.go | 1 + .../{executionspace => database}/etcd/etcd.go | 2 +- .../eventrepository/eventrepository.go | 28 ++++------ internal/executionspace/executor/executor.go | 1 + .../executionspace/executor/kubernetes.go | 40 +++----------- internal/executionspace/logging/logging.go | 38 ------------- .../executionspace/logging/logging_test.go | 34 ------------ .../executionspace/provider/kubernetes.go | 6 +- internal/executionspace/provider/provider.go | 26 ++++----- .../responses/responses_test.go | 42 -------------- .../logging/rabbitmqhook/rabbitmqhook.go | 2 +- .../{executionspace => }/rabbitmq/rabbitmq.go | 0 manifests/base/executionspace/deployment.yaml | 1 - pkg/executionspace/v1alpha/executor.go | 55 +++++++++---------- pkg/executionspace/v1alpha/provider.go | 42 +++++++------- .../executionspace/v1alpha}/responses.go | 2 +- 19 files changed, 106 insertions(+), 242 deletions(-) rename internal/{executionspace => }/database/database.go (93%) rename internal/{executionspace => database}/etcd/etcd.go (97%) rename internal/{executionspace => }/eventrepository/eventrepository.go (79%) delete mode 100644 internal/executionspace/logging/logging.go delete mode 100644 internal/executionspace/logging/logging_test.go delete mode 100644 internal/executionspace/responses/responses_test.go rename internal/{executionspace => }/logging/rabbitmqhook/rabbitmqhook.go (97%) rename internal/{executionspace => }/rabbitmq/rabbitmq.go (100%) rename {internal/executionspace/responses => pkg/executionspace/v1alpha}/responses.go (98%) diff --git a/cmd/executionspace/main.go b/cmd/executionspace/main.go index f1ecafd..2c72854 100644 --- a/cmd/executionspace/main.go +++ b/cmd/executionspace/main.go @@ -24,16 +24,16 @@ import ( "syscall" config "github.com/eiffel-community/etos-api/internal/configs/executionspace" - "github.com/eiffel-community/etos-api/internal/executionspace/etcd" + "github.com/eiffel-community/etos-api/internal/database/etcd" "github.com/eiffel-community/etos-api/internal/executionspace/provider" "github.com/eiffel-community/etos-api/internal/logging" + "github.com/eiffel-community/etos-api/internal/logging/rabbitmqhook" + "github.com/eiffel-community/etos-api/internal/rabbitmq" "github.com/eiffel-community/etos-api/internal/server" "github.com/eiffel-community/etos-api/pkg/application" + providerservice "github.com/eiffel-community/etos-api/pkg/executionspace/v1alpha" "github.com/sirupsen/logrus" "github.com/snowzach/rotatefilehook" - "github.com/eiffel-community/etos-api/internal/executionspace/logging/rabbitmqhook" - "github.com/eiffel-community/etos-api/internal/executionspace/rabbitmq" - providerservice "github.com/eiffel-community/etos-api/pkg/executionspace/v1alpha" "go.elastic.co/ecslogrus" ) @@ -94,7 +94,7 @@ func main() { if err := srv.Close(ctx); err != nil { log.Errorf("WebService shutdown failed: %+v", err) } - log.Info("Wait for checkout, flash and checkin jobs to complete") + log.Info("Wait for checkout and checkin jobs to complete") } // fileLogging adds a hook into a slice of hooks, if the filepath configuration is set @@ -120,12 +120,12 @@ func fileLogging(cfg config.Config) logrus.Hook { // remoteLogging starts a new rabbitmq publisher if the rabbitmq parameters are set // Warning: Must call publisher.Close() on the publisher returned from this function func remoteLogging(cfg config.Config) *rabbitmq.Publisher { - if cfg.RabbitMQHookUrl() != "" { + if cfg.RabbitMQHookURL() != "" { if cfg.RabbitMQHookExchangeName() == "" { panic("-rabbitmq_hook_exchange (env:ETOS_RABBITMQ_EXCHANGE) must be set when using -rabbitmq_hook_url (env:ETOS_RABBITMQ_URL)") } publisher := rabbitmq.NewPublisher(rabbitmq.PublisherConfig{ - URL: cfg.RabbitMQHookUrl(), + URL: cfg.RabbitMQHookURL(), ExchangeName: cfg.RabbitMQHookExchangeName(), }) return publisher @@ -133,6 +133,7 @@ func remoteLogging(cfg config.Config) *rabbitmq.Publisher { return nil } +// vcsRevision returns the current source code revision func vcsRevision() string { buildInfo, ok := debug.ReadBuildInfo() if !ok { diff --git a/deploy/etos-executionspace/docker-compose.yml b/deploy/etos-executionspace/docker-compose.yml index 8872d3c..65f096e 100644 --- a/deploy/etos-executionspace/docker-compose.yml +++ b/deploy/etos-executionspace/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.7" services: etos-executionspace: build: diff --git a/internal/configs/executionspace/config.go b/internal/configs/executionspace/config.go index 28bd34a..04f6f8b 100644 --- a/internal/configs/executionspace/config.go +++ b/internal/configs/executionspace/config.go @@ -35,10 +35,11 @@ type Config interface { Timeout() time.Duration KubernetesNamespace() string ExecutionSpaceWaitTimeout() time.Duration - RabbitMQHookUrl() string + RabbitMQHookURL() string RabbitMQHookExchangeName() string DatabaseURI() string ETOSNamespace() string + EiffelGoerURL() string } // cfg implements the Config interface. @@ -56,6 +57,7 @@ type cfg struct { executionSpaceWaitTimeout time.Duration rabbitmqHookURL string rabbitmqHookExchange string + eiffelGoerURL string etosNamespace string } @@ -86,6 +88,7 @@ func Get() Config { flag.DurationVar(&conf.executionSpaceWaitTimeout, "execution space wait timeout", executionSpaceWaitTimeout, "Timeout duration to wait when trying to checkout execution space(s)") flag.StringVar(&conf.rabbitmqHookURL, "rabbitmq_hook_url", os.Getenv("ETOS_RABBITMQ_URL"), "URL to the ETOS rabbitmq for logs") flag.StringVar(&conf.rabbitmqHookExchange, "rabbitmq_hook_exchange", os.Getenv("ETOS_RABBITMQ_EXCHANGE"), "Exchange to use for the ETOS rabbitmq for logs") + flag.StringVar(&conf.eiffelGoerURL, "event_repository_host", os.Getenv("EIFFEL_GOER_URL"), "Event repository URL used for Eiffel event lookup") flag.Parse() return &conf } @@ -136,10 +139,15 @@ func (c *cfg) ExecutionSpaceWaitTimeout() time.Duration { } // RabbitMQHookURL returns the rabbitmq url for ETOS logs -func (c *cfg) RabbitMQHookUrl() string { +func (c *cfg) RabbitMQHookURL() string { return c.rabbitmqHookURL } +// EventRepositoryURL returns the Eiffel event repository used for event lookups +func (c *cfg) EiffelGoerURL() string { + return c.eiffelGoerURL +} + // RabbitMQHookExchangeName returns the rabbitmq exchange name used for ETOS logs func (c *cfg) RabbitMQHookExchangeName() string { return c.rabbitmqHookExchange diff --git a/internal/executionspace/database/database.go b/internal/database/database.go similarity index 93% rename from internal/executionspace/database/database.go rename to internal/database/database.go index 50d428b..b34e652 100644 --- a/internal/executionspace/database/database.go +++ b/internal/database/database.go @@ -22,6 +22,7 @@ import ( "github.com/google/uuid" ) +// Opener is the common interface for database clients type Opener interface { Open(context.Context, uuid.UUID) io.ReadWriter } diff --git a/internal/executionspace/etcd/etcd.go b/internal/database/etcd/etcd.go similarity index 97% rename from internal/executionspace/etcd/etcd.go rename to internal/database/etcd/etcd.go index fd23fd6..c97b585 100644 --- a/internal/executionspace/etcd/etcd.go +++ b/internal/database/etcd/etcd.go @@ -23,7 +23,7 @@ import ( "time" config "github.com/eiffel-community/etos-api/internal/configs/executionspace" - "github.com/eiffel-community/etos-api/internal/executionspace/database" + "github.com/eiffel-community/etos-api/internal/database" "github.com/google/uuid" "github.com/sirupsen/logrus" clientv3 "go.etcd.io/etcd/client/v3" diff --git a/internal/executionspace/eventrepository/eventrepository.go b/internal/eventrepository/eventrepository.go similarity index 79% rename from internal/executionspace/eventrepository/eventrepository.go rename to internal/eventrepository/eventrepository.go index a1e1b45..23f0fa2 100644 --- a/internal/executionspace/eventrepository/eventrepository.go +++ b/internal/eventrepository/eventrepository.go @@ -21,7 +21,6 @@ import ( "errors" "io" "net/http" - "os" "github.com/eiffel-community/eiffelevents-sdk-go" ) @@ -38,15 +37,10 @@ type activityResponse struct { Items []eiffelevents.ActivityTriggeredV4 `json:"items"` } -// eventRepository returns the event repository URL to use. -func eventRepository() string { - return os.Getenv("EVENT_REPOSITORY_HOST") -} - // ActivityTriggered returns an activity triggered event from the event repository -func ActivityTriggered(ctx context.Context, id string) (*eiffelevents.ActivityTriggeredV4, error) { +func ActivityTriggered(ctx context.Context, eventRepositoryURL string, id string) (*eiffelevents.ActivityTriggeredV4, error) { query := map[string]string{"meta.id": id, "meta.type": "EiffelActivityTriggeredEvent"} - body, err := getEvents(ctx, query) + body, err := getEvents(ctx, eventRepositoryURL, query) if err != nil { return nil, err } @@ -61,15 +55,15 @@ func ActivityTriggered(ctx context.Context, id string) (*eiffelevents.ActivityTr } // MainSuiteStarted returns a test suite started event from the event repository -func MainSuiteStarted(ctx context.Context, id string) (*eiffelevents.TestSuiteStartedV3, error) { - activity, err := ActivityTriggered(ctx, id) +func MainSuiteStarted(ctx context.Context, eventRepositoryURL string, id string) (*eiffelevents.TestSuiteStartedV3, error) { + activity, err := ActivityTriggered(ctx, eventRepositoryURL, id) if err != nil { return nil, err } testSuiteID := activity.Links.FindFirst("CONTEXT") query := map[string]string{"meta.id": testSuiteID, "meta.type": "EiffelTestSuiteStartedEvent"} - body, err := getEvents(ctx, query) + body, err := getEvents(ctx, eventRepositoryURL, query) if err != nil { return nil, err } @@ -84,9 +78,9 @@ func MainSuiteStarted(ctx context.Context, id string) (*eiffelevents.TestSuiteSt } // TestSuiteStarted returns a test suite started event from the event repository -func TestSuiteStarted(ctx context.Context, id string, name string) (*eiffelevents.TestSuiteStartedV3, error) { +func TestSuiteStarted(ctx context.Context, eventRepositoryURL string, id string, name string) (*eiffelevents.TestSuiteStartedV3, error) { query := map[string]string{"links.target": id, "meta.type": "EiffelTestSuiteStartedEvent", "data.name": name} - body, err := getEvents(ctx, query) + body, err := getEvents(ctx, eventRepositoryURL, query) if err != nil { return nil, err } @@ -101,9 +95,9 @@ func TestSuiteStarted(ctx context.Context, id string, name string) (*eiffelevent } // EnvironmentDefined returns an environment defined event from the event repository -func EnvironmentDefined(ctx context.Context, id string) (*eiffelevents.EnvironmentDefinedV3, error) { +func EnvironmentDefined(ctx context.Context, eventRepositoryURL string, id string) (*eiffelevents.EnvironmentDefinedV3, error) { query := map[string]string{"meta.id": id, "meta.type": "EiffelEnvironmentDefinedEvent"} - body, err := getEvents(ctx, query) + body, err := getEvents(ctx, eventRepositoryURL, query) if err != nil { return nil, err } @@ -118,8 +112,8 @@ func EnvironmentDefined(ctx context.Context, id string) (*eiffelevents.Environme } // getEvents queries the event repository and returns the response for others to parse -func getEvents(ctx context.Context, query map[string]string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, "GET", eventRepository(), nil) +func getEvents(ctx context.Context, eventRepositoryURL string, query map[string]string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", eventRepositoryURL, nil) if err != nil { return nil, err } diff --git a/internal/executionspace/executor/executor.go b/internal/executionspace/executor/executor.go index 201e75f..1b2b21b 100644 --- a/internal/executionspace/executor/executor.go +++ b/internal/executionspace/executor/executor.go @@ -22,6 +22,7 @@ import ( "github.com/sirupsen/logrus" ) +// Executor is the common interface for test executor instances type Executor interface { Name() string Start(context.Context, *logrus.Entry, *executionspace.ExecutorSpec) (string, error) diff --git a/internal/executionspace/executor/kubernetes.go b/internal/executionspace/executor/kubernetes.go index fff6999..625177b 100644 --- a/internal/executionspace/executor/kubernetes.go +++ b/internal/executionspace/executor/kubernetes.go @@ -36,7 +36,7 @@ import ( var ( BACKOFFLIMIT int32 = 0 - PARALLELL int32 = 1 + PARALLEL int32 = 1 COMPLETIONS int32 = 1 SECRETMODE int32 = 0600 ) @@ -105,7 +105,7 @@ func (k KubernetesExecutor) Start(ctx context.Context, logger *logrus.Entry, exe Spec: batchv1.JobSpec{ BackoffLimit: &BACKOFFLIMIT, Completions: &COMPLETIONS, - Parallelism: &PARALLELL, + Parallelism: &PARALLEL, Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ Containers: []corev1.Container{ @@ -114,22 +114,6 @@ func (k KubernetesExecutor) Start(ctx context.Context, logger *logrus.Entry, exe Image: executorSpec.Instructions.Image, Args: args, Env: envs, - EnvFrom: []corev1.EnvFromSource{ - { - SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "etos-encryption-key", - }, - }, - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "ssh-key-and-config", - ReadOnly: true, - MountPath: "/home/etos/keys", - }, - }, }, }, RestartPolicy: corev1.RestartPolicyNever, @@ -150,13 +134,7 @@ func (k KubernetesExecutor) Start(ctx context.Context, logger *logrus.Entry, exe } job, err := jobs.Create(ctx, job, metav1.CreateOptions{}) if err != nil { - logger.WithField("user_log", true).Infof("Create job error: %s", err) - logger.WithField("user_log", true).Infof("Create job error: %s", err.Error()) - - unwrappedErr := errors.Unwrap(err) - if unwrappedErr != nil { - logger.WithField("user_log", true).Infof("Unwrapped Error: %s", unwrappedErr) - } + logger.WithField("user_log", true).Errorf("Create job error: %s", err) return "", err } return job.ObjectMeta.Name, nil @@ -198,7 +176,7 @@ func (k KubernetesExecutor) Wait(ctx context.Context, logger *logrus.Entry, name for { select { case <-ctx.Done(): - return "", "", fmt.Errorf("timed out waiting for kubernets job %s to start", name) + return "", "", fmt.Errorf("timed out waiting for Kubernetes job %s to start", name) case event := <-watcher.ResultChan(): pod := event.Object.(*corev1.Pod) if isReady(pod) { @@ -209,20 +187,20 @@ func (k KubernetesExecutor) Wait(ctx context.Context, logger *logrus.Entry, name } // Stop stops a test runner Kubernetes pod -func (k KubernetesExecutor) Stop(ctx context.Context, logger *logrus.Entry, id string) error { +func (k KubernetesExecutor) Stop(ctx context.Context, logger *logrus.Entry, name string) error { logger.WithField("user_log", true).Info("Stopping test runner Kubernetes pod") jobs := k.client.BatchV1().Jobs(k.namespace) propagation := metav1.DeletePropagationForeground - err := jobs.Delete(ctx, id, metav1.DeleteOptions{PropagationPolicy: &propagation}) + err := jobs.Delete(ctx, name, metav1.DeleteOptions{PropagationPolicy: &propagation}) if err != nil { logger.Error(err.Error()) return err } - watcher, err := k.client.CoreV1().Pods(k.namespace).Watch(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", id)}) + watcher, err := k.client.CoreV1().Pods(k.namespace).Watch(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", name)}) if err != nil { if net.IsProbableEOF(err) { // Assume that there are no more active jobs. - logger.Warningf("Did not find any pods for 'job-name=%s', reason=EOF. Assuming that there are no more active jobs", id) + logger.Warningf("Did not find any pods for 'job-name=%s', reason=EOF. Assuming that there are no more active jobs", name) return nil } return err @@ -231,7 +209,7 @@ func (k KubernetesExecutor) Stop(ctx context.Context, logger *logrus.Entry, id s for { select { case <-ctx.Done(): - return fmt.Errorf("timed out waiting for kubernets job %s to stop", id) + return fmt.Errorf("timed out waiting for Kubernetes job %s to stop", name) case event := <-watcher.ResultChan(): if event.Type == watch.Deleted { return nil diff --git a/internal/executionspace/logging/logging.go b/internal/executionspace/logging/logging.go deleted file mode 100644 index 07bc8d6..0000000 --- a/internal/executionspace/logging/logging.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright Axis Communications AB. -// -// For a full list of individual contributors, please see the commit history. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package logging - -import ( - "github.com/sirupsen/logrus" -) - -// Setup sets up logging to file with a JSON format and to stdout in text format. -func Setup(loglevel string, hooks []logrus.Hook) (*logrus.Logger, error) { - log := logrus.New() - - logLevel, err := logrus.ParseLevel(loglevel) - if err != nil { - return log, err - } - for _, hook := range hooks { - log.AddHook(hook) - } - - log.SetLevel(logLevel) - log.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) - log.SetReportCaller(true) - return log, nil -} diff --git a/internal/executionspace/logging/logging_test.go b/internal/executionspace/logging/logging_test.go deleted file mode 100644 index ead65c6..0000000 --- a/internal/executionspace/logging/logging_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright Axis Communications AB. -// -// For a full list of individual contributors, please see the commit history. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package logging - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestLoggingSetup tests that it is possible to setup logging without a file hook. -func TestLoggingSetup(t *testing.T) { - _, err := Setup("INFO", nil) - assert.Nil(t, err) -} - -// TestLoggingSetupBadLogLevel shall return an error if log level is not parsable. -func TestLoggingSetupBadLogLevel(t *testing.T) { - _, err := Setup("NOTALOGLEVEL", nil) - assert.Error(t, err) -} diff --git a/internal/executionspace/provider/kubernetes.go b/internal/executionspace/provider/kubernetes.go index a1b1412..8df0e09 100644 --- a/internal/executionspace/provider/kubernetes.go +++ b/internal/executionspace/provider/kubernetes.go @@ -20,18 +20,18 @@ import ( "sync" config "github.com/eiffel-community/etos-api/internal/configs/executionspace" - "github.com/eiffel-community/etos-api/internal/executionspace/database" + "github.com/eiffel-community/etos-api/internal/database" "github.com/eiffel-community/etos-api/internal/executionspace/executor" ) type Kubernetes struct { - provider + providerCore } // New creates a copy of a Kubernetes provider func (k Kubernetes) New(db database.Opener, cfg config.Config) Provider { return &Kubernetes{ - provider{ + providerCore{ db: db, cfg: cfg, url: fmt.Sprintf("%s/v1alpha/executor/kubernetes", cfg.Hostname()), diff --git a/internal/executionspace/provider/provider.go b/internal/executionspace/provider/provider.go index b35cc0b..a73a4ff 100644 --- a/internal/executionspace/provider/provider.go +++ b/internal/executionspace/provider/provider.go @@ -20,7 +20,7 @@ import ( "sync" config "github.com/eiffel-community/etos-api/internal/configs/executionspace" - "github.com/eiffel-community/etos-api/internal/executionspace/database" + "github.com/eiffel-community/etos-api/internal/database" "github.com/eiffel-community/etos-api/internal/executionspace/executor" "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" "github.com/google/uuid" @@ -48,9 +48,9 @@ type ExecutorConfig struct { Environment map[string]string } -// provider partially implements the Provider interface. To use it it should +// providerCore partially implements the Provider interface. To use it it should // be included into another struct that implements the rest of the interface. -type provider struct { +type providerCore struct { db database.Opener cfg config.Config url string @@ -58,8 +58,8 @@ type provider struct { executor executor.Executor } -// Get fetches execution space status from a database -func (e provider) Status(logger *logrus.Entry, ctx context.Context, id uuid.UUID) (*executionspace.ExecutionSpace, error) { +// Status fetches execution space status from a database +func (e providerCore) Status(logger *logrus.Entry, ctx context.Context, id uuid.UUID) (*executionspace.ExecutionSpace, error) { e.active.Add(1) defer e.active.Done() @@ -96,7 +96,7 @@ func (e provider) Status(logger *logrus.Entry, ctx context.Context, id uuid.UUID } // Checkout checks out an execution space and stores it in a database -func (e provider) Checkout(logger *logrus.Entry, ctx context.Context, cfg ExecutorConfig) { +func (e providerCore) Checkout(logger *logrus.Entry, ctx context.Context, cfg ExecutorConfig) { e.active.Add(1) defer e.active.Done() @@ -130,7 +130,7 @@ func (e provider) Checkout(logger *logrus.Entry, ctx context.Context, cfg Execut } // Checkin checks in an execution space by removing it from database -func (e provider) Checkin(logger *logrus.Entry, ctx context.Context, executors []executionspace.ExecutorSpec) error { +func (e providerCore) Checkin(logger *logrus.Entry, ctx context.Context, executors []executionspace.ExecutorSpec) error { e.active.Add(1) defer e.active.Done() for _, executor := range executors { @@ -143,18 +143,18 @@ func (e provider) Checkin(logger *logrus.Entry, ctx context.Context, executors [ } // Executor returns the executor of this provider -func (e provider) Executor() executor.Executor { +func (e providerCore) Executor() executor.Executor { return e.executor } // SaveExecutor saves an executor specification into a database -func (e provider) SaveExecutor(ctx context.Context, executorSpec executionspace.ExecutorSpec) error { +func (e providerCore) SaveExecutor(ctx context.Context, executorSpec executionspace.ExecutorSpec) error { client := e.db.Open(ctx, executorSpec.ID) return executorSpec.Save(client) } // Job gets the Build ID of a test runner execution. -func (e provider) Job(ctx context.Context, id uuid.UUID) (string, error) { +func (e providerCore) Job(ctx context.Context, id uuid.UUID) (string, error) { executorSpec, err := e.ExecutorSpec(ctx, id) if err != nil { return "", err @@ -166,18 +166,18 @@ func (e provider) Job(ctx context.Context, id uuid.UUID) (string, error) { } // ExecutorSpec returns the specification of an executor stored in database -func (e provider) ExecutorSpec(ctx context.Context, id uuid.UUID) (*executionspace.ExecutorSpec, error) { +func (e providerCore) ExecutorSpec(ctx context.Context, id uuid.UUID) (*executionspace.ExecutorSpec, error) { client := e.db.Open(ctx, id) return executionspace.LoadExecutorSpec(client) } // ExecutionSPace returns the execution space stored in database -func (e provider) ExecutionSpace(ctx context.Context, id uuid.UUID) (*executionspace.ExecutionSpace, error) { +func (e providerCore) ExecutionSpace(ctx context.Context, id uuid.UUID) (*executionspace.ExecutionSpace, error) { client := e.db.Open(ctx, id) return executionspace.Load(client) } // Done waits for all jobs to be done -func (e provider) Done() { +func (e providerCore) Done() { e.active.Wait() } diff --git a/internal/executionspace/responses/responses_test.go b/internal/executionspace/responses/responses_test.go deleted file mode 100644 index a819deb..0000000 --- a/internal/executionspace/responses/responses_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright Axis Communications AB. -// -// For a full list of individual contributors, please see the commit history. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package responses - -import ( - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -// Test that RespondWithJSON writes the correct HTTP code, message and adds a content type header. -func TestRespondWithJSON(t *testing.T) { - responseRecorder := httptest.NewRecorder() - RespondWithJSON(responseRecorder, 200, map[string]string{"hello": "world"}) - assert.Equal(t, "application/json", responseRecorder.Header().Get("Content-Type")) - assert.Equal(t, 200, responseRecorder.Result().StatusCode) - assert.JSONEq(t, `{"hello": "world"}`, responseRecorder.Body.String()) -} - -// Test that RespondWithError writes the correct HTTP code, message and adds a content type header. -func TestRespondWithError(t *testing.T) { - responseRecorder := httptest.NewRecorder() - RespondWithError(responseRecorder, 400, "failure") - assert.Equal(t, "application/json", responseRecorder.Header().Get("Content-Type")) - assert.Equal(t, 400, responseRecorder.Result().StatusCode) - assert.JSONEq(t, `{"error": "failure"}`, responseRecorder.Body.String()) -} diff --git a/internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go b/internal/logging/rabbitmqhook/rabbitmqhook.go similarity index 97% rename from internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go rename to internal/logging/rabbitmqhook/rabbitmqhook.go index 547789d..bb11f72 100644 --- a/internal/executionspace/logging/rabbitmqhook/rabbitmqhook.go +++ b/internal/logging/rabbitmqhook/rabbitmqhook.go @@ -19,9 +19,9 @@ import ( "errors" "fmt" + "github.com/eiffel-community/etos-api/internal/rabbitmq" amqp "github.com/rabbitmq/amqp091-go" "github.com/sirupsen/logrus" - "github.com/eiffel-community/etos-api/internal/executionspace/rabbitmq" ) var fieldMap = logrus.FieldMap{ diff --git a/internal/executionspace/rabbitmq/rabbitmq.go b/internal/rabbitmq/rabbitmq.go similarity index 100% rename from internal/executionspace/rabbitmq/rabbitmq.go rename to internal/rabbitmq/rabbitmq.go diff --git a/manifests/base/executionspace/deployment.yaml b/manifests/base/executionspace/deployment.yaml index 4e7a84e..beb68ee 100644 --- a/manifests/base/executionspace/deployment.yaml +++ b/manifests/base/executionspace/deployment.yaml @@ -3,7 +3,6 @@ kind: ConfigMap metadata: name: etos-executionspace data: - TZ: "Europe/Stockholm" PROVIDER_HOSTNAME: http://etos-executionspace --- apiVersion: apps/v1 diff --git a/pkg/executionspace/v1alpha/executor.go b/pkg/executionspace/v1alpha/executor.go index 027bc2f..e98f4e1 100644 --- a/pkg/executionspace/v1alpha/executor.go +++ b/pkg/executionspace/v1alpha/executor.go @@ -24,9 +24,9 @@ import ( "time" "github.com/eiffel-community/eiffelevents-sdk-go" - "github.com/eiffel-community/etos-api/internal/executionspace/eventrepository" + config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + "github.com/eiffel-community/etos-api/internal/eventrepository" "github.com/eiffel-community/etos-api/internal/executionspace/executor" - "github.com/eiffel-community/etos-api/internal/executionspace/responses" "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" "github.com/google/uuid" "github.com/julienschmidt/httprouter" @@ -61,7 +61,7 @@ func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Req msg := fmt.Errorf("There was an error when preparing the %s execution space", executorName) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) - responses.RespondWithError(w, http.StatusBadRequest, "could not read ID from post body") + RespondWithError(w, http.StatusBadRequest, "could not read ID from post body") return } @@ -71,10 +71,10 @@ func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Req if ctx.Err() != nil { logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) - responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + RespondWithError(w, http.StatusRequestTimeout, msg.Error()) return } - responses.RespondWithError(w, http.StatusBadRequest, msg.Error()) + RespondWithError(w, http.StatusBadRequest, msg.Error()) logger.WithField("user_log", true).Error(msg) return } @@ -85,13 +85,13 @@ func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Req if err != nil { if ctx.Err() != nil { msg := fmt.Errorf("Timed out when trying to start the test execution job") - responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + RespondWithError(w, http.StatusRequestTimeout, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return } msg := fmt.Errorf("Error trying to start the test execution job: %s", err.Error()) - responses.RespondWithError(w, http.StatusInternalServerError, msg.Error()) + RespondWithError(w, http.StatusInternalServerError, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return @@ -106,13 +106,13 @@ func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Req } if ctx.Err() != nil { msg := fmt.Errorf("Timed out when waiting for the test execution job to start - Error: %s", err.Error()) - responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + RespondWithError(w, http.StatusRequestTimeout, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return } msg := fmt.Errorf("Error when waiting for the test execution job to start - Error: %s", err.Error()) - responses.RespondWithError(w, http.StatusInternalServerError, msg.Error()) + RespondWithError(w, http.StatusInternalServerError, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return @@ -128,33 +128,33 @@ func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Req } if ctx.Err() != nil { msg := fmt.Errorf("Timed out when saving the test execution configuration") - responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + RespondWithError(w, http.StatusRequestTimeout, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return } msg := fmt.Errorf("Error when saving the test execution configuration") - responses.RespondWithError(w, http.StatusInternalServerError, msg.Error()) + RespondWithError(w, http.StatusInternalServerError, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return } subSuiteState := state{ExecutorSpec: executor} - if err = subSuiteState.waitStart(ctx, logger, h.provider.Executor()); err != nil { + if err = subSuiteState.waitStart(ctx, h.cfg, logger, h.provider.Executor()); err != nil { if cancelErr := h.provider.Executor().Stop(context.Background(), logger, buildID); cancelErr != nil { msg := fmt.Errorf("cancel failed: %s", cancelErr.Error()) logger.Error(msg) } if ctx.Err() != nil { msg := fmt.Errorf("Timed out when waiting for the test execution job to initialize - Error: %s", err.Error()) - responses.RespondWithError(w, http.StatusRequestTimeout, msg.Error()) + RespondWithError(w, http.StatusRequestTimeout, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return } msg := fmt.Errorf("Error when waiting for the test execution job to initialize - Error: %s", err.Error()) - responses.RespondWithError(w, http.StatusBadRequest, msg.Error()) + RespondWithError(w, http.StatusBadRequest, msg.Error()) logger.WithField("user_log", true).Error(msg) h.recordOtelException(span, msg) return @@ -165,7 +165,8 @@ func (h ProviderServiceHandler) ExecutorStart(w http.ResponseWriter, r *http.Req if buildURL != "" { logger.WithField("user_log", true).Info("Executor build URL: ", buildURL) } - responses.RespondWithError(w, http.StatusNoContent, "") + w.WriteHeader(http.StatusNoContent) + _, _ = w.Write([]byte("")) } type state struct { @@ -175,23 +176,23 @@ type state struct { } // getSubSuite gets a sub suite from event repository -func (s *state) getSubSuite(logger *logrus.Entry, ctx context.Context) (*eiffelevents.TestSuiteStartedV3, error) { +func (s *state) getSubSuite(ctx context.Context, cfg config.Config) (*eiffelevents.TestSuiteStartedV3, error) { if s.environment == nil { - event, err := eventrepository.EnvironmentDefined(ctx, s.ExecutorSpec.Instructions.Environment["ENVIRONMENT_ID"]) + event, err := eventrepository.EnvironmentDefined(ctx, cfg.EiffelGoerURL(), s.ExecutorSpec.Instructions.Environment["ENVIRONMENT_ID"]) if err != nil { return nil, err } s.environment = event } if s.environment != nil && s.mainSuite == nil { - event, err := eventrepository.MainSuiteStarted(ctx, s.environment.Links.FindFirst("CONTEXT")) + event, err := eventrepository.MainSuiteStarted(ctx, cfg.EiffelGoerURL(), s.environment.Links.FindFirst("CONTEXT")) if err != nil { return nil, err } s.mainSuite = event } if s.mainSuite != nil && s.environment != nil { - event, err := eventrepository.TestSuiteStarted(ctx, s.mainSuite.Meta.ID, s.environment.Data.Name) + event, err := eventrepository.TestSuiteStarted(ctx, cfg.EiffelGoerURL(), s.mainSuite.Meta.ID, s.environment.Data.Name) if err != nil { return nil, err } @@ -201,10 +202,10 @@ func (s *state) getSubSuite(logger *logrus.Entry, ctx context.Context) (*eiffele } // waitStart waits for a job to start completely -func (s *state) waitStart(ctx context.Context, logger *logrus.Entry, executor executor.Executor) error { +func (s *state) waitStart(ctx context.Context, cfg config.Config, logger *logrus.Entry, executor executor.Executor) error { var event *eiffelevents.TestSuiteStartedV3 var err error - if err = retry.Fibonacci(ctx, 5*time.Second, func(ctx context.Context) error { + if err = retry.Constant(ctx, 5*time.Second, func(ctx context.Context) error { alive, err := executor.Alive(ctx, logger, s.ExecutorSpec.BuildID) if err != nil { logger.Errorf("Retrying - %s", err.Error()) @@ -214,13 +215,11 @@ func (s *state) waitStart(ctx context.Context, logger *logrus.Entry, executor ex if !alive { return errors.New("test runner did not start properly") } - if event == nil { - event, err = s.getSubSuite(logger, ctx) - if err != nil { - logger.Errorf("Retrying - %s", err.Error()) - // TODO: Verify that this is always retryable - return retry.RetryableError(err) - } + event, err = s.getSubSuite(ctx, cfg) + if err != nil { + logger.Errorf("Retrying - %s", err.Error()) + // TODO: Verify that this is always retryable + return retry.RetryableError(err) } if event == nil { return retry.RetryableError(errors.New("not yet started")) diff --git a/pkg/executionspace/v1alpha/provider.go b/pkg/executionspace/v1alpha/provider.go index 33c1e7d..0bbfd69 100644 --- a/pkg/executionspace/v1alpha/provider.go +++ b/pkg/executionspace/v1alpha/provider.go @@ -30,7 +30,6 @@ import ( "github.com/eiffel-community/eiffelevents-sdk-go" config "github.com/eiffel-community/etos-api/internal/configs/executionspace" "github.com/eiffel-community/etos-api/internal/executionspace/provider" - "github.com/eiffel-community/etos-api/internal/executionspace/responses" "github.com/eiffel-community/etos-api/pkg/application" httperrors "github.com/eiffel-community/etos-api/pkg/executionspace/errors" "github.com/eiffel-community/etos-api/pkg/executionspace/executionspace" @@ -190,7 +189,7 @@ func (h ProviderServiceHandler) recordOtelException(span trace.Span, err error) // Selftest is a handler to just return 204. func (h ProviderServiceHandler) Selftest(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - responses.RespondWithError(w, http.StatusNoContent, "") + RespondWithError(w, http.StatusNoContent, "") } // Start handles the start request and checks out execution spaces @@ -204,7 +203,7 @@ func (h ProviderServiceHandler) Start(w http.ResponseWriter, r *http.Request, ps _, span := h.getOtelTracer().Start(ctx, "start", trace.WithSpanKind(trace.SpanKindServer)) defer span.End() - startReq, err := h.verifyStartInput(logger, r) + startReq, err := h.verifyStartInput(r) if err != nil { msg := fmt.Errorf("start input could not be verified: %s", err.Error()) logger.Error(msg) @@ -234,7 +233,7 @@ func (h ProviderServiceHandler) Start(w http.ResponseWriter, r *http.Request, ps span.SetAttributes(attribute.String("etos.execution_space_provider.checkout.environment", fmt.Sprintf("%v", startReq.Environment))) span.SetAttributes(attribute.String("etos.execution_space_provider.checkout.id", checkoutId.String())) - responses.RespondWithJSON(w, http.StatusOK, StartResponse{ID: checkoutId}) + RespondWithJSON(w, http.StatusOK, StartResponse{ID: checkoutId}) } // Status handles the status request, gets and returns the execution space checkout status @@ -259,7 +258,7 @@ func (h ProviderServiceHandler) Status(w http.ResponseWriter, r *http.Request, p msg := fmt.Errorf("Failed to retrieve execution space status (id=%s) - Reason: %s", id, err.Error()) logger.Error(msg.Error()) h.recordOtelException(span, msg) - responses.RespondWithJSON(w, http.StatusInternalServerError, executionSpace) + RespondWithJSON(w, http.StatusInternalServerError, executionSpace) return } @@ -268,7 +267,7 @@ func (h ProviderServiceHandler) Status(w http.ResponseWriter, r *http.Request, p attribute.String("etos.execution_space_provider.status.executorspec", fmt.Sprintf("%v", executorSpec)), ) } - responses.RespondWithJSON(w, http.StatusOK, executionSpace) + RespondWithJSON(w, http.StatusOK, executionSpace) } // Stop handles the stop request, stops the execution space executors and checks in all the provided execution spaces @@ -293,14 +292,15 @@ func (h ProviderServiceHandler) Stop(w http.ResponseWriter, r *http.Request, ps defer r.Body.Close() err = nil + for _, executorSpec := range executors { - id, stopErr := h.provider.Job(r.Context(), executorSpec.ID) - if stopErr != nil { - if errors.Is(stopErr, io.EOF) { + id, jobInitErr := h.provider.Job(r.Context(), executorSpec.ID) + if jobInitErr != nil { + if errors.Is(jobInitErr, io.EOF) { // Already been checked in continue } - err = errors.Join(err, stopErr) + err = errors.Join(err, jobInitErr) continue } // If the executorSpec does not exist in the database, we should not @@ -308,13 +308,11 @@ func (h ProviderServiceHandler) Stop(w http.ResponseWriter, r *http.Request, ps if id == "" { continue } - if stopErr := h.provider.Executor().Stop(r.Context(), logger, id); stopErr != nil { - err = errors.Join(err, stopErr) - } success := true - if stopErr != nil { + if stopErr := h.provider.Executor().Stop(r.Context(), logger, id); stopErr != nil { success = false - msg := fmt.Errorf("Failed to stop executor %v - Reason: %s", id, stopErr.Error()) + err = errors.Join(err, stopErr) + msg := fmt.Errorf("Failed to stop executor %v - Reason: %s", id, err.Error()) logger.Error(msg) h.recordOtelException(span, msg) } @@ -324,7 +322,7 @@ func (h ProviderServiceHandler) Stop(w http.ResponseWriter, r *http.Request, ps msg := fmt.Errorf("Some of the executors could not be stopped - Reason: %s", err.Error()) logger.Error(msg) h.recordOtelException(span, msg) - responses.RespondWithJSON(w, http.StatusInternalServerError, err.Error()) + RespondWithJSON(w, http.StatusInternalServerError, err.Error()) return } @@ -332,24 +330,24 @@ func (h ProviderServiceHandler) Stop(w http.ResponseWriter, r *http.Request, ps msg := fmt.Errorf("Failed to check in executors: %v - Reason: %s", executors, err) logger.Error(msg) h.recordOtelException(span, msg) - responses.RespondWithJSON(w, http.StatusInternalServerError, msg) + RespondWithJSON(w, http.StatusInternalServerError, msg) return } - responses.RespondWithJSON(w, http.StatusNoContent, "") + RespondWithJSON(w, http.StatusNoContent, "") } // sendError sends an error HTTP response depending on which error has been returned. func sendError(w http.ResponseWriter, err error) { httpError, ok := err.(*httperrors.HTTPError) if !ok { - responses.RespondWithError(w, http.StatusInternalServerError, fmt.Sprintf("unknown error %+v", err)) + RespondWithError(w, http.StatusInternalServerError, fmt.Sprintf("unknown error %+v", err)) } else { - responses.RespondWithError(w, httpError.Code, httpError.Message) + RespondWithError(w, httpError.Code, httpError.Message) } } // verifyStartInput verify input (json body) from a start request -func (h ProviderServiceHandler) verifyStartInput(logger *logrus.Entry, r *http.Request) (StartRequest, error) { +func (h ProviderServiceHandler) verifyStartInput(r *http.Request) (StartRequest, error) { request := StartRequest{} defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -394,7 +392,7 @@ func (h ProviderServiceHandler) panicRecovery( r.Context(), ).Errorf("recovering from err %+v\n %s", err, buf) identifier := ps.ByName("identifier") - responses.RespondWithError( + RespondWithError( w, http.StatusInternalServerError, fmt.Sprintf("unknown error: contact server admin with id '%s'", identifier), diff --git a/internal/executionspace/responses/responses.go b/pkg/executionspace/v1alpha/responses.go similarity index 98% rename from internal/executionspace/responses/responses.go rename to pkg/executionspace/v1alpha/responses.go index 5f40b4b..964043e 100644 --- a/internal/executionspace/responses/responses.go +++ b/pkg/executionspace/v1alpha/responses.go @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package responses +package providerservice import ( "encoding/json" From 7be8c577a4532ce2129b1a3eee00db1a51dcfe1e Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Fri, 27 Sep 2024 10:17:03 +0200 Subject: [PATCH 4/7] restored secret ref source for Kubernetes executor --- .../executionspace/executor/kubernetes.go | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/internal/executionspace/executor/kubernetes.go b/internal/executionspace/executor/kubernetes.go index 625177b..a56d2b7 100644 --- a/internal/executionspace/executor/kubernetes.go +++ b/internal/executionspace/executor/kubernetes.go @@ -114,20 +114,17 @@ func (k KubernetesExecutor) Start(ctx context.Context, logger *logrus.Entry, exe Image: executorSpec.Instructions.Image, Args: args, Env: envs, - }, - }, - RestartPolicy: corev1.RestartPolicyNever, - Volumes: []corev1.Volume{ - { - Name: "ssh-key-and-config", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: "kubernetes-provider-ssh-key", - DefaultMode: &SECRETMODE, + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "etos-encryption-key", + }, + }, }, - }, - }, + }}, }, + RestartPolicy: corev1.RestartPolicyNever, }, }, }, From efb9e57656d06a03d822a4b2c72e3ebf8aa41e9b Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Mon, 30 Sep 2024 09:50:16 +0200 Subject: [PATCH 5/7] Make etcd tree prefix configurable in etcd.New() --- cmd/executionspace/main.go | 3 ++- internal/database/etcd/etcd.go | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cmd/executionspace/main.go b/cmd/executionspace/main.go index 2c72854..f52d5b4 100644 --- a/cmd/executionspace/main.go +++ b/cmd/executionspace/main.go @@ -69,7 +69,8 @@ func main() { }) log.Info("Loading v1alpha routes") - provider := provider.Kubernetes{}.New(etcd.New(cfg, logger), cfg) + executionSpaceEtcdTreePrefix := "/execution-space" + provider := provider.Kubernetes{}.New(etcd.New(cfg, logger, executionSpaceEtcdTreePrefix), cfg) providerServiceApp := providerservice.New(cfg, log, provider, ctx) defer providerServiceApp.Close() handler := application.New(providerServiceApp) diff --git a/internal/database/etcd/etcd.go b/internal/database/etcd/etcd.go index c97b585..363e37c 100644 --- a/internal/database/etcd/etcd.go +++ b/internal/database/etcd/etcd.go @@ -29,21 +29,20 @@ import ( clientv3 "go.etcd.io/etcd/client/v3" ) -const etcdTreePrefix string = "/execution-space" - // TODO: refactor the client so that it does not store data it fetched. // However, without it implementing the database.Opener interface would be more complex (methods readByte, read). type Etcd struct { - cfg config.Config - client *clientv3.Client - ID uuid.UUID - ctx context.Context - data []byte - hasRead bool + cfg config.Config + client *clientv3.Client + ID uuid.UUID + ctx context.Context + treePrefix string + data []byte + hasRead bool } // New returns a new Etcd Object/Struct. -func New(cfg config.Config, logger *logrus.Logger) database.Opener { +func New(cfg config.Config, logger *logrus.Logger, treePrefix string) database.Opener { client, err := clientv3.New(clientv3.Config{ Endpoints: []string{cfg.DatabaseURI()}, DialTimeout: 5 * time.Second, @@ -53,8 +52,9 @@ func New(cfg config.Config, logger *logrus.Logger) database.Opener { } return Etcd{ - client: client, - cfg: cfg, + client: client, + cfg: cfg, + treePrefix: treePrefix, } } @@ -73,7 +73,7 @@ func (etcd Etcd) Write(p []byte) (int, error) { if etcd.ID == uuid.Nil { return 0, errors.New("please create a new etcd client using Open") } - key := fmt.Sprintf("%s/%s", etcdTreePrefix, etcd.ID.String()) + key := fmt.Sprintf("%s/%s", etcd.treePrefix, etcd.ID.String()) _, err := etcd.client.Put(etcd.ctx, key, string(p)) if err != nil { return 0, err @@ -95,7 +95,7 @@ func (etcd *Etcd) Read(p []byte) (n int, err error) { return n, err } - key := fmt.Sprintf("%s/%s", etcdTreePrefix, etcd.ID.String()) + key := fmt.Sprintf("%s/%s", etcd.treePrefix, etcd.ID.String()) if !etcd.hasRead { resp, err := etcd.client.Get(etcd.ctx, key) From 04118d9ae95fbcaacd8ca57a134ab38fc2e01a14 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Mon, 30 Sep 2024 09:56:53 +0200 Subject: [PATCH 6/7] Use base config in shared etcd client --- internal/database/etcd/etcd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/database/etcd/etcd.go b/internal/database/etcd/etcd.go index 363e37c..c57fdaa 100644 --- a/internal/database/etcd/etcd.go +++ b/internal/database/etcd/etcd.go @@ -22,7 +22,7 @@ import ( "io" "time" - config "github.com/eiffel-community/etos-api/internal/configs/executionspace" + config "github.com/eiffel-community/etos-api/internal/configs/base" "github.com/eiffel-community/etos-api/internal/database" "github.com/google/uuid" "github.com/sirupsen/logrus" From daf50394fc184652edaeb55d1886e666aeed29c4 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Mon, 30 Sep 2024 09:45:48 +0200 Subject: [PATCH 7/7] Use shared etcd client in IUT Provider --- cmd/iut/main.go | 22 ++++++++++++---------- pkg/iut/v1alpha1/v1alpha1.go | 12 +++++++----- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/cmd/iut/main.go b/cmd/iut/main.go index 19cb0bd..1189ad7 100644 --- a/cmd/iut/main.go +++ b/cmd/iut/main.go @@ -25,6 +25,7 @@ import ( "time" config "github.com/eiffel-community/etos-api/internal/configs/iut" + "github.com/eiffel-community/etos-api/internal/database/etcd" "github.com/eiffel-community/etos-api/internal/logging" server "github.com/eiffel-community/etos-api/internal/server" "github.com/eiffel-community/etos-api/pkg/application" @@ -32,7 +33,6 @@ import ( "github.com/sirupsen/logrus" "github.com/snowzach/rotatefilehook" "go.elastic.co/ecslogrus" - clientv3 "go.etcd.io/etcd/client/v3" ) // main sets up logging and starts up the webserver. @@ -63,16 +63,18 @@ func main() { }) // Database connection test - cli, err := clientv3.New(clientv3.Config{ - Endpoints: []string{cfg.DatabaseURI()}, - DialTimeout: 5 * time.Second, - }) - if err != nil { - log.WithError(err).Fatal("failed to create etcd connection") - } - + // cli, err := clientv3.New(clientv3.Config{ + // Endpoints: []string{cfg.DatabaseURI()}, + // DialTimeout: 5 * time.Second, + // }) + // if err != nil { + // log.WithError(err).Fatal("failed to create etcd connection") + // } + + iutEtcdTreePrefix := "/iut" + db := etcd.New(cfg, logger, iutEtcdTreePrefix) log.Info("Loading v1alpha1 routes") - v1alpha1App := v1alpha1.New(cfg, log, ctx, cli) + v1alpha1App := v1alpha1.New(cfg, log, ctx, db) defer v1alpha1App.Close() router := application.New(v1alpha1App) diff --git a/pkg/iut/v1alpha1/v1alpha1.go b/pkg/iut/v1alpha1/v1alpha1.go index 53f356c..cabcbc8 100644 --- a/pkg/iut/v1alpha1/v1alpha1.go +++ b/pkg/iut/v1alpha1/v1alpha1.go @@ -26,9 +26,9 @@ import ( eiffelevents "github.com/eiffel-community/eiffelevents-sdk-go" config "github.com/eiffel-community/etos-api/internal/configs/iut" + "github.com/eiffel-community/etos-api/internal/database" "github.com/eiffel-community/etos-api/pkg/application" packageurl "github.com/package-url/packageurl-go" - clientv3 "go.etcd.io/etcd/client/v3" "github.com/google/uuid" "github.com/julienschmidt/httprouter" @@ -38,14 +38,14 @@ import ( type V1Alpha1Application struct { logger *logrus.Entry cfg config.Config - database *clientv3.Client + database database.Opener wg *sync.WaitGroup } type V1Alpha1Handler struct { logger *logrus.Entry cfg config.Config - database *clientv3.Client + database database.Opener wg *sync.WaitGroup } @@ -72,11 +72,11 @@ func (a *V1Alpha1Application) Close() { } // New returns a new V1Alpha1Application object/struct -func New(cfg config.Config, log *logrus.Entry, ctx context.Context, cli *clientv3.Client) application.Application { +func New(cfg config.Config, log *logrus.Entry, ctx context.Context, db database.Opener) application.Application { return &V1Alpha1Application{ logger: log, cfg: cfg, - database: cli, + database: db, wg: &sync.WaitGroup{}, } } @@ -153,6 +153,8 @@ func (h V1Alpha1Handler) Start(w http.ResponseWriter, r *http.Request, ps httpro RespondWithError(w, http.StatusInternalServerError, err.Error()) return } + client := h.database.Open(r.Context(), checkOutID) + client.Write() _, err = h.database.Put(r.Context(), fmt.Sprintf("/iut/%s", checkOutID.String()), string(iuts)) if err != nil { RespondWithError(w, http.StatusInternalServerError, err.Error())