From d6432aed96a4374442167cdd6b77416f2f6a7909 Mon Sep 17 00:00:00 2001 From: nicufk Date: Mon, 15 Jan 2024 12:21:06 +0200 Subject: [PATCH 001/234] fix: complete repository empty check (#4889) * fix: complete repository empty check * fix: extract method for empty check --- internal/app/api/v1/tests.go | 13 ++++++++++++- pkg/api/v1/testkube/model_repository_extended.go | 8 +++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/app/api/v1/tests.go b/internal/app/api/v1/tests.go index aa7df7d6beb..6eefb5644d7 100644 --- a/internal/app/api/v1/tests.go +++ b/internal/app/api/v1/tests.go @@ -449,7 +449,7 @@ func (s TestkubeAPI) UpdateTestHandler() fiber.Handler { } } - if testSpec.Spec.Content != nil && testSpec.Spec.Content.Repository != nil && testSpec.Spec.Content.Repository.Uri == "" { + if isRepositoryEmpty(testSpec.Spec) { testSpec.Spec.Content.Repository = nil } @@ -470,6 +470,17 @@ func (s TestkubeAPI) UpdateTestHandler() fiber.Handler { } } +func isRepositoryEmpty(s testsv3.TestSpec) bool { + return s.Content != nil && + s.Content.Repository != nil && + s.Content.Repository.Type_ == "" && + s.Content.Repository.Uri == "" && + s.Content.Repository.Branch == "" && + s.Content.Repository.Path == "" && + s.Content.Repository.Commit == "" && + s.Content.Repository.WorkingDir == "" +} + // DeleteTestHandler is a method for deleting a test with id func (s TestkubeAPI) DeleteTestHandler() fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/pkg/api/v1/testkube/model_repository_extended.go b/pkg/api/v1/testkube/model_repository_extended.go index 5bfef92f3b3..42891051506 100644 --- a/pkg/api/v1/testkube/model_repository_extended.go +++ b/pkg/api/v1/testkube/model_repository_extended.go @@ -41,5 +41,11 @@ func (r *Repository) WithAuthType(authType GitAuthType) *Repository { // IsEmpty returns true if repository is empty func (r *Repository) IsEmpty() bool { - return r == nil || r.Uri == "" + return r == nil || + (r.Type_ == "" && + r.Uri == "" && + r.Branch == "" && + r.Path == "" && + r.Commit == "" && + r.WorkingDir == "") } From 021a0e8ccd9b8985a1be400981cbafecf3211731 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Mon, 15 Jan 2024 12:44:22 +0100 Subject: [PATCH 002/234] feat: Executor tests - JMeter/JMeterd - extended other cases, special cases (#4894) * executor tests - jmeter-executor-smoke-2.jmx * executor tests - jmeter - other and special cases extended * end - empty line * jmeter executor smoke - suite updated * jmeter executor tests - special cases * jmeter executor tests - special cases - testsuite * executor tests - run script updated * executor tests - branch name updated before merge * end - empty line --- test/jmeter/executor-tests/crd/other.yaml | 59 +++++++ test/jmeter/executor-tests/crd/smoke.yaml | 28 ++- .../executor-tests/crd/special-cases.yaml | 166 ++++++++++++++++++ .../jmeter-executor-smoke-2.jmx | 94 ++++++++++ test/scripts/executor-tests/run.sh | 12 +- test/suites/executor-jmeter-other-tests.yaml | 8 +- test/suites/executor-jmeter-smoke-tests.yaml | 3 + .../special-cases/jmeter-special-cases.yaml | 24 +++ 8 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 test/jmeter/executor-tests/crd/special-cases.yaml create mode 100644 test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx create mode 100644 test/suites/special-cases/jmeter-special-cases.yaml diff --git a/test/jmeter/executor-tests/crd/other.yaml b/test/jmeter/executor-tests/crd/other.yaml index 845797cdf83..03eaf3ca9fd 100644 --- a/test/jmeter/executor-tests/crd/other.yaml +++ b/test/jmeter/executor-tests/crd/other.yaml @@ -151,3 +151,62 @@ spec: limits: cpu: 500m memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-slave-0 # standalone mode + labels: + core-tests: executors +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + executionRequest: + variables: + SLAVES_COUNT: + name: SLAVES_COUNT + value: "0" + type: basic + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-slave-not-set # standalone mode + labels: + core-tests: executors +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + executionRequest: + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/test/jmeter/executor-tests/crd/smoke.yaml b/test/jmeter/executor-tests/crd/smoke.yaml index 67b174a63f4..525a2501328 100644 --- a/test/jmeter/executor-tests/crd/smoke.yaml +++ b/test/jmeter/executor-tests/crd/smoke.yaml @@ -114,7 +114,7 @@ spec: apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-slave-1 + name: jmeterd-executor-smoke-slave-1 # standalone mode labels: core-tests: executors spec: @@ -174,3 +174,29 @@ spec: limits: cpu: 500m memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-env-and-property-values + labels: + core-tests: executors +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke-env-and-property.jmx + executionRequest: + variables: + URL_ENV: + name: URL_ENV + value: "testkube.io" + type: basic + args: + - "-JURL_PROPERTY=testkube.io" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml new file mode 100644 index 00000000000..a073e6e9c97 --- /dev/null +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -0,0 +1,166 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-custom-envs-replication # TODO: validation on the test side + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + executionRequest: + variables: + SLAVES_COUNT: + name: SLAVES_COUNT + value: "2" + type: basic + CUSTOM_ENV_VARIABLE: + name: CUSTOM_ENV_VARIABLE + value: CUSTOM_ENV_VARIABLE_value + type: basic + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-env-value-in-args + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + variables: + JMETER_SCRIPT: + name: JMETER_SCRIPT + value: jmeter-executor-smoke.jmx + type: basic + args: + - "${JMETER_SCRIPT}" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-directory-1 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + args: + - "jmeter-executor-smoke.jmx" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-directory-2 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + args: + - "jmeter-executor-smoke-2.jmx" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-slaves-sharedbetweenpods # can be run only at cluster with storageClassName (NFS volume) + labels: + core-tests: executors +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + executionRequest: + executePostRunScriptBeforeScraping: true + postRunScript: "echo \"postrun script\" && echo \"artifact file - contents\" > /data/output/artifact-`uuidgen`.txt" + artifactRequest: + storageClassName: standard-rwx + masks: + - .*\.txt + sharedBetweenPods: true + variables: + SLAVES_COUNT: + name: SLAVES_COUNT + value: "2" + type: basic + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + # activeDeadlineSeconds: 180 TODO: increase - too low to create volume + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx b/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx new file mode 100644 index 00000000000..6dc469a6d51 --- /dev/null +++ b/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx @@ -0,0 +1,94 @@ + + + + + + false + false + + + + + + + + continue + + false + 1 + + 1 + 1 + 1668426657000 + 1668426657000 + false + + + + + + + + + testkube.kubeshop.io + + + + + + + GET + true + false + true + false + false + + + + + + 200 + + Assertion.response_code + false + 8 + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + false + false + false + false + false + 0 + true + true + + + + + + + + + diff --git a/test/scripts/executor-tests/run.sh b/test/scripts/executor-tests/run.sh index a5919a28e9e..4d471a7e7da 100755 --- a/test/scripts/executor-tests/run.sh +++ b/test/scripts/executor-tests/run.sh @@ -329,6 +329,15 @@ special-cases-large-artifacts() { common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" } +special-cases-jmeter() { + name="Special Cases - JMeter/JMeterd" + test_crd_file="test/jmeter/executor-tests/crd/special-cases.yaml" + testsuite_name="jmeter-special-cases" + testsuite_file="test/suites/special-cases/jmeter-special-cases.yaml" + + common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" +} + main() { case $executor_type in all) @@ -375,6 +384,7 @@ main() { special-cases-failures special-cases-large-logs special-cases-large-artifacts + special-cases-jmeter ;; *) $executor_type @@ -390,4 +400,4 @@ main() { fi } -main \ No newline at end of file +main diff --git a/test/suites/executor-jmeter-other-tests.yaml b/test/suites/executor-jmeter-other-tests.yaml index 6ed16725314..9d99b59c4f4 100644 --- a/test/suites/executor-jmeter-other-tests.yaml +++ b/test/suites/executor-jmeter-other-tests.yaml @@ -5,7 +5,7 @@ metadata: labels: core-tests: executors spec: - description: "jmeter and jmeterd executor - other tests and edge-cases" + description: "jmeter and jmeterd executor - other tests" steps: - stopOnFailure: false execute: @@ -25,3 +25,9 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-failure-exit-code-0-negative + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-slave-0 + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-slave-not-set diff --git a/test/suites/executor-jmeter-smoke-tests.yaml b/test/suites/executor-jmeter-smoke-tests.yaml index f71f53907a5..df0a0fad3af 100644 --- a/test/suites/executor-jmeter-smoke-tests.yaml +++ b/test/suites/executor-jmeter-smoke-tests.yaml @@ -28,3 +28,6 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-slaves + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-env-and-property-values diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml new file mode 100644 index 00000000000..afac7662614 --- /dev/null +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -0,0 +1,24 @@ +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: jmeter-special-cases + labels: + core-tests: special-cases +spec: + description: "jmeter and jmeterd executor - special-cases" + steps: + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-custom-envs-replication + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-env-value-in-args + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-directory-1 + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-directory-2 + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-slaves-sharedbetweenpods From 3d7fdca24db183ad7670476840125462a822c5ea Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 15 Jan 2024 18:57:11 +0300 Subject: [PATCH 003/234] fix: pvc name --- config/slave-pod-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/slave-pod-template.yml b/config/slave-pod-template.yml index 0530fd864ac..33d6fd9f8fb 100644 --- a/config/slave-pod-template.yml +++ b/config/slave-pod-template.yml @@ -139,7 +139,7 @@ spec: {{- if and .ArtifactRequest.VolumeMountPath .ArtifactRequest.StorageClassName }} - name: artifact-volume persistentVolumeClaim: - claimName: {{ .Name }}-pvc + claimName: {{ .JobName }}-pvc {{- end }} {{- end }} {{- range $configmap := .EnvConfigMaps }} From f9ec6deea14438bcdf2c5e5a5c9e310ed016726b Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 16 Jan 2024 10:38:55 +0100 Subject: [PATCH 004/234] =?UTF-8?q?feat:=20refactored=20logs=20stream=20to?= =?UTF-8?q?=20allow=20to=20be=20passed=20and=20initialized=20la=E2=80=A6?= =?UTF-8?q?=20(#4892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: refactored logs stream to allow to be passed and initialized later with id * feat: added logs for test scheduler errors * fix: renamed repos * fix: initialize logs stream only if logs v2 enabled * Update pkg/logs/events/events.go Co-authored-by: Lilla Vass * fix: comments * fix: added logs * fix: logs metghod --------- Co-authored-by: Lilla Vass --- cmd/api-server/main.go | 11 ++ cmd/sidecar/main.go | 2 +- pkg/logs/client/interface.go | 26 +++- pkg/logs/client/mock_client.go | 50 +++++++ .../client/mock_initializedstreamgetter.go | 66 ++++++++++ .../client/mock_initializedstreampusher.go | 79 +++++++++++ pkg/logs/client/mock_stream.go | 124 ++++++++++++++++++ pkg/logs/client/stream.go | 63 +++++---- pkg/logs/client/stream_test.go | 17 ++- pkg/logs/events/events.go | 43 ++++-- pkg/logs/events_test.go | 28 ++-- pkg/logs/sidecar/proxy.go | 13 +- pkg/scheduler/service.go | 12 +- pkg/scheduler/test_scheduler.go | 52 +++++--- pkg/scheduler/testsuite_scheduler.go | 16 +-- pkg/triggers/executor_test.go | 4 + pkg/triggers/service_test.go | 19 +-- pkg/triggers/watcher.go | 3 +- 18 files changed, 509 insertions(+), 119 deletions(-) create mode 100644 pkg/logs/client/mock_client.go create mode 100644 pkg/logs/client/mock_initializedstreamgetter.go create mode 100644 pkg/logs/client/mock_initializedstreampusher.go create mode 100644 pkg/logs/client/mock_stream.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index f25259182b8..86cd6ff8eb1 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -58,6 +58,7 @@ import ( kubeexecutor "github.com/kubeshop/testkube/pkg/executor" "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/executor/containerexecutor" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/scheduler" testkubeclientset "github.com/kubeshop/testkube-operator/pkg/clientset/versioned" @@ -348,6 +349,15 @@ func main() { eventBus := bus.NewNATSBus(nc) eventsEmitter := event.NewEmitter(eventBus, cfg.TestkubeClusterName, envs) + var logsStream logsclient.Stream + + if ff.LogsV2 { + logsStream, err = logsclient.NewNatsLogStream(nc.Conn) + if err != nil { + ui.ExitOnError("Creating logs streaming client", err) + } + } + metrics := metrics.NewMetrics() defaultExecutors, err := parseDefaultExecutors(cfg) @@ -439,6 +449,7 @@ func main() { eventBus, cfg.TestkubeDashboardURI, ff, + logsStream, ) slackLoader, err := newSlackLoader(cfg, envs) diff --git a/cmd/sidecar/main.go b/cmd/sidecar/main.go index d2a54bb19e7..fab7ec5f0ff 100644 --- a/cmd/sidecar/main.go +++ b/cmd/sidecar/main.go @@ -39,7 +39,7 @@ func main() { podsClient := clientset.CoreV1().Pods(cfg.Namespace) - logsStream, err := client.NewNatsLogStream(nc, cfg.ExecutionId) + logsStream, err := client.NewNatsLogStream(nc) if err != nil { ui.ExitOnError("error creating logs stream", err) return diff --git a/pkg/logs/client/interface.go b/pkg/logs/client/interface.go index 6f85f45fb68..595dffa93db 100644 --- a/pkg/logs/client/interface.go +++ b/pkg/logs/client/interface.go @@ -13,10 +13,12 @@ const ( StopSubject = "events.logs.stop" ) +//go:generate mockgen -destination=./mock_client.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" Client type Client interface { Get(ctx context.Context, id string) chan events.LogResponse } +//go:generate mockgen -destination=./mock_stream.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" Stream type Stream interface { StreamInitializer StreamPusher @@ -24,26 +26,38 @@ type Stream interface { StreamGetter } +//go:generate mockgen -destination=./mock_initializedstreampusher.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" InitializedStreamPusher +type InitializedStreamPusher interface { + StreamInitializer + StreamPusher +} + +//go:generate mockgen -destination=./mock_initializedstreamgetter.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" InitializedStreamGetter +type InitializedStreamGetter interface { + StreamInitializer + StreamGetter +} + type StreamMetadata struct { Name string } type StreamInitializer interface { // Init creates or updates stream on demand - Init(ctx context.Context) (meta StreamMetadata, err error) + Init(ctx context.Context, id string) (meta StreamMetadata, err error) } type StreamPusher interface { // Push sends logs to log stream - Push(ctx context.Context, chunk events.Log) error + Push(ctx context.Context, id string, chunk events.Log) error // PushBytes sends RAW bytes to log stream, developer is responsible for marshaling valid data - PushBytes(ctx context.Context, chunk []byte) error + PushBytes(ctx context.Context, id string, chunk []byte) error } // LogStream is a single log stream chunk with possible errors type StreamGetter interface { // Init creates or updates stream on demand - Get(ctx context.Context) (chan events.LogResponse, error) + Get(ctx context.Context, id string) (chan events.LogResponse, error) } type StreamConfigurer interface { @@ -63,7 +77,7 @@ type StreamResponse struct { type StreamTrigger interface { // Trigger start event - Start(ctx context.Context) (StreamResponse, error) + Start(ctx context.Context, id string) (StreamResponse, error) // Trigger stop event - Stop(ctx context.Context) (StreamResponse, error) + Stop(ctx context.Context, id string) (StreamResponse, error) } diff --git a/pkg/logs/client/mock_client.go b/pkg/logs/client/mock_client.go new file mode 100644 index 00000000000..a87031ea1af --- /dev/null +++ b/pkg/logs/client/mock_client.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: Client) + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockClient) Get(arg0 context.Context, arg1 string) chan events.LogResponse { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(chan events.LogResponse) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockClientMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), arg0, arg1) +} diff --git a/pkg/logs/client/mock_initializedstreamgetter.go b/pkg/logs/client/mock_initializedstreamgetter.go new file mode 100644 index 00000000000..911f3f3f5cd --- /dev/null +++ b/pkg/logs/client/mock_initializedstreamgetter.go @@ -0,0 +1,66 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: InitializedStreamGetter) + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" +) + +// MockInitializedStreamGetter is a mock of InitializedStreamGetter interface. +type MockInitializedStreamGetter struct { + ctrl *gomock.Controller + recorder *MockInitializedStreamGetterMockRecorder +} + +// MockInitializedStreamGetterMockRecorder is the mock recorder for MockInitializedStreamGetter. +type MockInitializedStreamGetterMockRecorder struct { + mock *MockInitializedStreamGetter +} + +// NewMockInitializedStreamGetter creates a new mock instance. +func NewMockInitializedStreamGetter(ctrl *gomock.Controller) *MockInitializedStreamGetter { + mock := &MockInitializedStreamGetter{ctrl: ctrl} + mock.recorder = &MockInitializedStreamGetterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInitializedStreamGetter) EXPECT() *MockInitializedStreamGetterMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockInitializedStreamGetter) Get(arg0 context.Context, arg1 string) (chan events.LogResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(chan events.LogResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockInitializedStreamGetterMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInitializedStreamGetter)(nil).Get), arg0, arg1) +} + +// Init mocks base method. +func (m *MockInitializedStreamGetter) Init(arg0 context.Context, arg1 string) (StreamMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Init", arg0, arg1) + ret0, _ := ret[0].(StreamMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Init indicates an expected call of Init. +func (mr *MockInitializedStreamGetterMockRecorder) Init(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockInitializedStreamGetter)(nil).Init), arg0, arg1) +} diff --git a/pkg/logs/client/mock_initializedstreampusher.go b/pkg/logs/client/mock_initializedstreampusher.go new file mode 100644 index 00000000000..9892c9b9ef2 --- /dev/null +++ b/pkg/logs/client/mock_initializedstreampusher.go @@ -0,0 +1,79 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: InitializedStreamPusher) + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" +) + +// MockInitializedStreamPusher is a mock of InitializedStreamPusher interface. +type MockInitializedStreamPusher struct { + ctrl *gomock.Controller + recorder *MockInitializedStreamPusherMockRecorder +} + +// MockInitializedStreamPusherMockRecorder is the mock recorder for MockInitializedStreamPusher. +type MockInitializedStreamPusherMockRecorder struct { + mock *MockInitializedStreamPusher +} + +// NewMockInitializedStreamPusher creates a new mock instance. +func NewMockInitializedStreamPusher(ctrl *gomock.Controller) *MockInitializedStreamPusher { + mock := &MockInitializedStreamPusher{ctrl: ctrl} + mock.recorder = &MockInitializedStreamPusherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInitializedStreamPusher) EXPECT() *MockInitializedStreamPusherMockRecorder { + return m.recorder +} + +// Init mocks base method. +func (m *MockInitializedStreamPusher) Init(arg0 context.Context, arg1 string) (StreamMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Init", arg0, arg1) + ret0, _ := ret[0].(StreamMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Init indicates an expected call of Init. +func (mr *MockInitializedStreamPusherMockRecorder) Init(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockInitializedStreamPusher)(nil).Init), arg0, arg1) +} + +// Push mocks base method. +func (m *MockInitializedStreamPusher) Push(arg0 context.Context, arg1 string, arg2 events.Log) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Push", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Push indicates an expected call of Push. +func (mr *MockInitializedStreamPusherMockRecorder) Push(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockInitializedStreamPusher)(nil).Push), arg0, arg1, arg2) +} + +// PushBytes mocks base method. +func (m *MockInitializedStreamPusher) PushBytes(arg0 context.Context, arg1 string, arg2 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushBytes", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PushBytes indicates an expected call of PushBytes. +func (mr *MockInitializedStreamPusherMockRecorder) PushBytes(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushBytes", reflect.TypeOf((*MockInitializedStreamPusher)(nil).PushBytes), arg0, arg1, arg2) +} diff --git a/pkg/logs/client/mock_stream.go b/pkg/logs/client/mock_stream.go new file mode 100644 index 00000000000..4b605daacc9 --- /dev/null +++ b/pkg/logs/client/mock_stream.go @@ -0,0 +1,124 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: Stream) + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" +) + +// MockStream is a mock of Stream interface. +type MockStream struct { + ctrl *gomock.Controller + recorder *MockStreamMockRecorder +} + +// MockStreamMockRecorder is the mock recorder for MockStream. +type MockStreamMockRecorder struct { + mock *MockStream +} + +// NewMockStream creates a new mock instance. +func NewMockStream(ctrl *gomock.Controller) *MockStream { + mock := &MockStream{ctrl: ctrl} + mock.recorder = &MockStreamMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStream) EXPECT() *MockStreamMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockStream) Get(arg0 context.Context, arg1 string) (chan events.LogResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(chan events.LogResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStreamMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStream)(nil).Get), arg0, arg1) +} + +// Init mocks base method. +func (m *MockStream) Init(arg0 context.Context, arg1 string) (StreamMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Init", arg0, arg1) + ret0, _ := ret[0].(StreamMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Init indicates an expected call of Init. +func (mr *MockStreamMockRecorder) Init(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockStream)(nil).Init), arg0, arg1) +} + +// Push mocks base method. +func (m *MockStream) Push(arg0 context.Context, arg1 string, arg2 events.Log) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Push", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Push indicates an expected call of Push. +func (mr *MockStreamMockRecorder) Push(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockStream)(nil).Push), arg0, arg1, arg2) +} + +// PushBytes mocks base method. +func (m *MockStream) PushBytes(arg0 context.Context, arg1 string, arg2 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushBytes", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PushBytes indicates an expected call of PushBytes. +func (mr *MockStreamMockRecorder) PushBytes(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushBytes", reflect.TypeOf((*MockStream)(nil).PushBytes), arg0, arg1, arg2) +} + +// Start mocks base method. +func (m *MockStream) Start(arg0 context.Context, arg1 string) (StreamResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", arg0, arg1) + ret0, _ := ret[0].(StreamResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Start indicates an expected call of Start. +func (mr *MockStreamMockRecorder) Start(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockStream)(nil).Start), arg0, arg1) +} + +// Stop mocks base method. +func (m *MockStream) Stop(arg0 context.Context, arg1 string) (StreamResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Stop", arg0, arg1) + ret0, _ := ret[0].(StreamResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Stop indicates an expected call of Stop. +func (mr *MockStreamMockRecorder) Stop(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockStream)(nil).Stop), arg0, arg1) +} diff --git a/pkg/logs/client/stream.go b/pkg/logs/client/stream.go index 46cbd89ab74..e8bccf966d6 100644 --- a/pkg/logs/client/stream.go +++ b/pkg/logs/client/stream.go @@ -15,32 +15,30 @@ import ( "github.com/kubeshop/testkube/pkg/utils" ) -func NewNatsLogStream(nc *nats.Conn, id string) (Stream, error) { +const ConsumerPrefix = "lc" + +func NewNatsLogStream(nc *nats.Conn) (s Stream, err error) { js, err := jetstream.New(nc) if err != nil { - return &NatsLogStream{}, err + return s, err } return &NatsLogStream{ - nc: nc, - js: js, - log: log.DefaultLogger, - id: id, - streamName: StreamPrefix + id, + nc: nc, + js: js, + log: log.DefaultLogger, }, nil } type NatsLogStream struct { - nc *nats.Conn - js jetstream.JetStream - log *zap.SugaredLogger - streamName string - id string + nc *nats.Conn + js jetstream.JetStream + log *zap.SugaredLogger } -func (c NatsLogStream) Init(ctx context.Context) (StreamMetadata, error) { +func (c NatsLogStream) Init(ctx context.Context, id string) (StreamMetadata, error) { s, err := c.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{ - Name: c.streamName, + Name: c.streamName(id), Storage: jetstream.FileStorage, // durable stream }) @@ -48,42 +46,42 @@ func (c NatsLogStream) Init(ctx context.Context) (StreamMetadata, error) { c.log.Debugw("stream upserted", "info", s.CachedInfo()) } - return StreamMetadata{Name: c.streamName}, err + return StreamMetadata{Name: c.streamName(id)}, err } // Push log chunk to NATS stream -func (c NatsLogStream) Push(ctx context.Context, chunk events.Log) error { +func (c NatsLogStream) Push(ctx context.Context, id string, chunk events.Log) error { b, err := json.Marshal(chunk) if err != nil { return err } - return c.PushBytes(ctx, b) + return c.PushBytes(ctx, id, b) } // Push log chunk to NATS stream // TODO handle message repeat with backoff strategy on error -func (c NatsLogStream) PushBytes(ctx context.Context, chunk []byte) error { - _, err := c.js.Publish(ctx, c.streamName, chunk) +func (c NatsLogStream) PushBytes(ctx context.Context, id string, chunk []byte) error { + _, err := c.js.Publish(ctx, c.streamName(id), chunk) return err } // Start emits start event to the stream - logs service will handle start and create new stream -func (c NatsLogStream) Start(ctx context.Context) (resp StreamResponse, err error) { - return c.syncCall(ctx, StartSubject) +func (c NatsLogStream) Start(ctx context.Context, id string) (resp StreamResponse, err error) { + return c.syncCall(ctx, StartSubject, id) } // Stop emits stop event to the stream and waits for given stream to be stopped fully - logs service will handle stop and close stream and all subscribers -func (c NatsLogStream) Stop(ctx context.Context) (resp StreamResponse, err error) { - return c.syncCall(ctx, StopSubject) +func (c NatsLogStream) Stop(ctx context.Context, id string) (resp StreamResponse, err error) { + return c.syncCall(ctx, StopSubject, id) } // Get returns channel with log stream chunks for given execution id connects through GRPC to log service -func (c NatsLogStream) Get(ctx context.Context) (chan events.LogResponse, error) { +func (c NatsLogStream) Get(ctx context.Context, id string) (chan events.LogResponse, error) { ch := make(chan events.LogResponse) - name := fmt.Sprintf("lc%s%s", c.id, utils.RandAlphanum(6)) - cons, err := c.js.CreateOrUpdateConsumer(ctx, c.streamName, jetstream.ConsumerConfig{ + name := fmt.Sprintf("%s%s%s", ConsumerPrefix, id, utils.RandAlphanum(6)) + cons, err := c.js.CreateOrUpdateConsumer(ctx, c.streamName(id), jetstream.ConsumerConfig{ Name: name, Durable: name, DeliverPolicy: jetstream.DeliverAllPolicy, @@ -93,7 +91,7 @@ func (c NatsLogStream) Get(ctx context.Context) (chan events.LogResponse, error) return ch, err } - log := c.log.With("id", c.id) + log := c.log.With("id", id) cons.Consume(func(msg jetstream.Msg) { log.Debugw("got message", "data", string(msg.Data())) @@ -123,8 +121,11 @@ func (c NatsLogStream) Get(ctx context.Context) (chan events.LogResponse, error) } // syncCall sends request to given subject and waits for response -func (c NatsLogStream) syncCall(ctx context.Context, subject string) (resp StreamResponse, err error) { - b, _ := json.Marshal(events.Trigger{Id: c.id}) +func (c NatsLogStream) syncCall(ctx context.Context, subject, id string) (resp StreamResponse, err error) { + b, err := json.Marshal(events.Trigger{Id: id}) + if err != nil { + return resp, err + } m, err := c.nc.Request(subject, b, time.Minute) if err != nil { return resp, err @@ -132,3 +133,7 @@ func (c NatsLogStream) syncCall(ctx context.Context, subject string) (resp Strea return StreamResponse{Message: m.Data}, nil } + +func (c NatsLogStream) streamName(id string) string { + return StreamPrefix + id +} diff --git a/pkg/logs/client/stream_test.go b/pkg/logs/client/stream_test.go index e5390da6b58..7d3ed1a8845 100644 --- a/pkg/logs/client/stream_test.go +++ b/pkg/logs/client/stream_test.go @@ -2,7 +2,6 @@ package client import ( "context" - "fmt" "testing" "github.com/nats-io/nats.go" @@ -15,39 +14,39 @@ func TestStream_StartStop(t *testing.T) { ns, nc := bus.TestServerWithConnection() defer ns.Shutdown() + id := "111" + ctx := context.Background() - client, err := NewNatsLogStream(nc, "111") + client, err := NewNatsLogStream(nc) assert.NoError(t, err) - meta, err := client.Init(ctx) + meta, err := client.Init(ctx, id) assert.NoError(t, err) - assert.Equal(t, StreamPrefix+"111", meta.Name) + assert.Equal(t, StreamPrefix+id, meta.Name) - err = client.PushBytes(ctx, []byte(`{"content":"hello 1"}`)) + err = client.PushBytes(ctx, id, []byte(`{"content":"hello 1"}`)) assert.NoError(t, err) var startReceived, stopReceived bool _, err = nc.Subscribe(StartSubject, func(m *nats.Msg) { - fmt.Printf("%s\n", m.Data) m.Respond([]byte("ok")) startReceived = true }) assert.NoError(t, err) _, err = nc.Subscribe(StopSubject, func(m *nats.Msg) { - fmt.Printf("%s\n", m.Data) m.Respond([]byte("ok")) stopReceived = true }) assert.NoError(t, err) - d, err := client.Start(ctx) + d, err := client.Start(ctx, id) assert.NoError(t, err) assert.Equal(t, "ok", string(d.Message)) - d, err = client.Stop(ctx) + d, err = client.Stop(ctx, id) assert.NoError(t, err) assert.Equal(t, "ok", string(d.Message)) diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index f2548ba369c..032299405d2 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -39,13 +39,21 @@ type Log struct { Error bool `json:"error,omitempty"` Version LogVersion `json:"version,omitempty"` - // Old output - for backwards compatibility - will be removed + // Old output - for backwards compatibility - will be removed for non-structured logs V1 *LogOutputV1 `json:"v1,omitempty"` } type LogOutputV1 struct { Result *testkube.ExecutionResult } +func NewLog(content string) *Log { + return &Log{ + Time: time.Now(), + Content: string(content), + Metadata: map[string]string{}, + } +} + func NewLogResponse(ts time.Time, content []byte) Log { return Log{ Time: ts, @@ -54,23 +62,32 @@ func NewLogResponse(ts time.Time, content []byte) Log { } } -// log line/chunk data -func (c *Log) WithMetadataEntry(key, value string) *Log { - if c.Metadata == nil { - c.Metadata = map[string]string{} +func (l *Log) WithMetadataEntry(key, value string) *Log { + if l.Metadata == nil { + l.Metadata = map[string]string{} } - c.Metadata[key] = value - return c + l.Metadata[key] = value + return l +} + +func (l *Log) WithType(t string) *Log { + l.Type = t + return l +} + +func (l *Log) WithSource(s string) *Log { + l.Source = s + return l } -func (c *Log) WithVersion(version LogVersion) *Log { - c.Version = version - return c +func (l *Log) WithVersion(version LogVersion) *Log { + l.Version = version + return l } -func (c *Log) WithV1Result(result *testkube.ExecutionResult) *Log { - c.V1.Result = result - return c +func (l *Log) WithV1Result(result *testkube.ExecutionResult) *Log { + l.V1.Result = result + return l } var timestampRegexp = regexp.MustCompile("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*") diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 288aea12788..879dcb06c87 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -62,23 +62,23 @@ func TestLogs_EventsFlow(t *testing.T) { <-log.Ready // and logs stream client - stream, err := client.NewNatsLogStream(nc, "stop-test") + stream, err := client.NewNatsLogStream(nc) assert.NoError(t, err) // and initialized log stream for given ID - meta, err := stream.Init(ctx) + meta, err := stream.Init(ctx, "stop-test") assert.NotEmpty(t, meta.Name) assert.NoError(t, err) // when start event triggered - _, err = stream.Start(ctx) + _, err = stream.Start(ctx, "stop-test") assert.NoError(t, err) // and when data pushed to the log stream - stream.Push(ctx, events.NewLogResponse(time.Now(), []byte("hello 1"))) + stream.Push(ctx, "stop-test", events.NewLogResponse(time.Now(), []byte("hello 1"))) // and stop event triggered - _, err = stream.Stop(ctx) + _, err = stream.Stop(ctx, "stop-test") assert.NoError(t, err) // then all adapters should be gracefully stopped @@ -130,26 +130,26 @@ func TestLogs_EventsFlow(t *testing.T) { <-log.Ready // and stream client - stream, err := client.NewNatsLogStream(nc, "messages-test") + stream, err := client.NewNatsLogStream(nc) assert.NoError(t, err) // and initialized log stream for given ID - meta, err := stream.Init(ctx) + meta, err := stream.Init(ctx, "messages-test") assert.NotEmpty(t, meta.Name) assert.NoError(t, err) // when start event triggered - _, err = stream.Start(ctx) + _, err = stream.Start(ctx, "messages-test") assert.NoError(t, err) for i := 0; i < messagesCount; i++ { // and when data pushed to the log stream - err = stream.Push(ctx, events.NewLogResponse(time.Now(), []byte("hello"))) + err = stream.Push(ctx, "messages-test", events.NewLogResponse(time.Now(), []byte("hello"))) assert.NoError(t, err) } // and wait for message to be propagated - _, err = stream.Stop(ctx) + _, err = stream.Stop(ctx, "messages-test") assert.NoError(t, err) assertMessagesCount(t, a, 4*messagesCount) @@ -198,16 +198,16 @@ func TestLogs_EventsFlow(t *testing.T) { <-log.Ready // and logs stream client - stream, err := client.NewNatsLogStream(nc, "stop-test") + stream, err := client.NewNatsLogStream(nc) assert.NoError(t, err) // and initialized log stream for given ID - meta, err := stream.Init(ctx) + meta, err := stream.Init(ctx, "consumer-stats") assert.NotEmpty(t, meta.Name) assert.NoError(t, err) // when start event triggered - _, err = stream.Start(ctx) + _, err = stream.Start(ctx, "consumer-stats") assert.NoError(t, err) // then we should have 2 consumers @@ -215,7 +215,7 @@ func TestLogs_EventsFlow(t *testing.T) { assert.Equal(t, 2, stats.Count) // when stop event triggered - _, err = stream.Stop(ctx) + _, err = stream.Stop(ctx, "consumer-stats") assert.NoError(t, err) // then all adapters should be gracefully stopped diff --git a/pkg/logs/sidecar/proxy.go b/pkg/logs/sidecar/proxy.go index ad99a2388c9..d7c9aad161d 100644 --- a/pkg/logs/sidecar/proxy.go +++ b/pkg/logs/sidecar/proxy.go @@ -38,7 +38,7 @@ const ( func NewProxy(clientset kubernetes.Interface, podsClient tcorev1.PodInterface, logsStream client.Stream, js jetstream.JetStream, log *zap.SugaredLogger, namespace, executionId string) *Proxy { return &Proxy{ - log: log.With("namespace", namespace, "executionId", executionId), + log: log.With("service", "logs-proxy", "namespace", namespace, "executionId", executionId), js: js, clientset: clientset, namespace: namespace, @@ -55,7 +55,7 @@ type Proxy struct { namespace string executionId string podsClient tcorev1.PodInterface - logsStream client.Stream + logsStream client.InitializedStreamPusher } func (p *Proxy) Run(ctx context.Context) error { @@ -66,8 +66,7 @@ func (p *Proxy) Run(ctx context.Context) error { logs := make(chan events.Log, logsBuffer) // create stream for incoming logs - - _, err := p.logsStream.Init(ctx) + _, err := p.logsStream.Init(ctx, p.executionId) if err != nil { return err } @@ -76,7 +75,7 @@ func (p *Proxy) Run(ctx context.Context) error { p.log.Debugw("logs proxy stream started") err := p.streamLogs(ctx, logs) if err != nil { - p.handleError(err, "proxy stream logs error") + p.handleError(err, "logs proxy stream error") } }() @@ -89,7 +88,7 @@ func (p *Proxy) Run(ctx context.Context) error { p.log.Warn("logs proxy context cancelled, exiting") return nil default: - err = p.logsStream.Push(ctx, l) + err = p.logsStream.Push(ctx, p.executionId, l) if err != nil { p.handleError(err, "error pushing logs to stream") return err @@ -248,7 +247,7 @@ func (p *Proxy) handleError(err error, title string) { p.log.Errorw(title, "error", err) if err == nil { - p.logsStream.Push(context.Background(), ch) + p.logsStream.Push(context.Background(), p.executionId, ch) } else { p.log.Errorw("error pushing error to stream", "title", title, "error", err) } diff --git a/pkg/scheduler/service.go b/pkg/scheduler/service.go index 6e1a6656ed6..349c1d8c688 100644 --- a/pkg/scheduler/service.go +++ b/pkg/scheduler/service.go @@ -16,6 +16,7 @@ import ( "github.com/kubeshop/testkube/pkg/configmap" "github.com/kubeshop/testkube/pkg/event" "github.com/kubeshop/testkube/pkg/executor/client" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/kubeshop/testkube/pkg/repository/testresult" "github.com/kubeshop/testkube/pkg/secret" @@ -25,8 +26,8 @@ type Scheduler struct { metrics v1.Metrics executor client.Executor containerExecutor client.Executor - executionResults result.Repository - testExecutionResults testresult.Repository + testResults result.Repository + testsuiteResults testresult.Repository executorsClient executorsv1.Interface testsClient testsv3.Interface testSuitesClient testsuitesv3.Interface @@ -40,6 +41,7 @@ type Scheduler struct { eventsBus bus.Bus dashboardURI string featureFlags featureflags.FeatureFlags + logsStream logsclient.InitializedStreamPusher } func NewScheduler( @@ -61,14 +63,15 @@ func NewScheduler( eventsBus bus.Bus, dashboardURI string, featureFlags featureflags.FeatureFlags, + logsStream logsclient.InitializedStreamPusher, ) *Scheduler { return &Scheduler{ metrics: metrics, executor: executor, containerExecutor: containerExecutor, secretClient: secretClient, - executionResults: executionResults, - testExecutionResults: testExecutionResults, + testResults: executionResults, + testsuiteResults: testExecutionResults, executorsClient: executorsClient, testsClient: testsClient, testSuitesClient: testSuitesClient, @@ -81,5 +84,6 @@ func NewScheduler( eventsBus: eventsBus, dashboardURI: dashboardURI, featureFlags: featureFlags, + logsStream: logsStream, } } diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 4bfbcd97b92..11aaa82bbf8 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -14,6 +14,7 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/logs/events" testsmapper "github.com/kubeshop/testkube/pkg/mapper/tests" "github.com/kubeshop/testkube/pkg/workerpool" ) @@ -53,22 +54,25 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request request.Name = fmt.Sprintf("%s-%d", request.Name, request.Number) } + s.events.Notify(testkube.NewEventStartTest(&execution)) + // test name + test execution name should be unique - execution, _ = s.executionResults.GetByNameAndTest(ctx, request.Name, test.Name) + execution, _ = s.testResults.GetByNameAndTest(ctx, request.Name, test.Name) if execution.Name == request.Name { - return execution.Err(errors.Errorf("test execution with name %s already exists", request.Name)), nil + err := errors.Errorf("test execution with name %s already exists", request.Name) + return s.handleExecutionError(ctx, execution, "duplicate execution: %w", err) } secretUUID, err := s.testsClient.GetCurrentSecretUUID(test.Name) if err != nil { - return execution.Errw(request.Id, "can't get current secret uuid: %w", err), nil + return s.handleExecutionError(ctx, execution, "can't get current secret uuid: %w", err) } request.TestSecretUUID = secretUUID // merge available data into execution options test spec, executor spec, request, test id options, err := s.getExecuteOptions(test.Namespace, test.Name, request) if err != nil { - return execution.Errw(request.Id, "can't create valid execution options: %w", err), nil + return s.handleExecutionError(ctx, execution, "can't get current secret uuid: %w", err) } // store execution in storage, can be fetched from API now @@ -76,25 +80,22 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request options.ID = execution.Id if err := s.createSecretsReferences(&execution); err != nil { - return execution.Errw(execution.Id, "can't create secret variables `Secret` references: %w", err), nil + return s.handleExecutionError(ctx, execution, "can't create secret variables `Secret` references: %w", err) } - err = s.executionResults.Insert(ctx, execution) + err = s.testResults.Insert(ctx, execution) if err != nil { - return execution.Errw(execution.Id, "can't create new test execution, can't insert into storage: %w", err), nil + return s.handleExecutionError(ctx, execution, "can't create new test execution, can't insert into storage: %w", err) } s.logger.Infow("calling executor with options", "options", options.Request) execution.Start() - s.events.Notify(testkube.NewEventStartTest(&execution)) - // update storage with current execution status - err = s.executionResults.StartExecution(ctx, execution.Id, execution.StartTime) + err = s.testResults.StartExecution(ctx, execution.Id, execution.StartTime) if err != nil { - s.events.Notify(testkube.NewEventEndTestFailed(&execution)) - return execution.Errw(execution.Id, "can't execute test, can't insert into storage error: %w", err), nil + return s.handleExecutionError(ctx, execution, "can't execute test, can't insert into storage error: %w", err) } // sync/async test execution @@ -104,14 +105,12 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request execution.ExecutionResult = result // update storage with current execution status - if uerr := s.executionResults.UpdateResult(ctx, execution.Id, execution); uerr != nil { - s.events.Notify(testkube.NewEventEndTestFailed(&execution)) - return execution.Errw(execution.Id, "update execution error: %w", uerr), nil + if uerr := s.testResults.UpdateResult(ctx, execution.Id, execution); uerr != nil { + return s.handleExecutionError(ctx, execution, "update execution error: %w", err) } if err != nil { - s.events.Notify(testkube.NewEventEndTestFailed(&execution)) - return execution.Errw(execution.Id, "test execution failed: %w", err), nil + return s.handleExecutionError(ctx, execution, "test execution failed: %w", err) } s.logger.Infow("test started", "executionId", execution.Id, "status", execution.ExecutionResult.Status) @@ -119,6 +118,23 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request return execution, nil } +func (s *Scheduler) handleExecutionError(ctx context.Context, execution testkube.Execution, msgTpl string, err error) (testkube.Execution, error) { + // push error log to the log stream if logs v2 enabled + if s.featureFlags.LogsV2 { + l := events.NewLog(fmt.Sprintf(msgTpl, err)). + WithType("error"). + WithVersion(events.LogVersionV2). + WithSource("test-scheduler") + + s.logsStream.Push(ctx, execution.Id, *l) + } + + // notify events that execution failed + s.events.Notify(testkube.NewEventEndTestFailed(&execution)) + + return execution.Errw(execution.Id, msgTpl, err), nil +} + func (s *Scheduler) startTestExecution(ctx context.Context, options client.ExecuteOptions, execution *testkube.Execution) (result *testkube.ExecutionResult, err error) { executor := s.getExecutor(options.TestName) return executor.Execute(ctx, execution, options) @@ -146,7 +162,7 @@ func (s *Scheduler) getExecutor(testName string) client.Executor { } func (s *Scheduler) getNextExecutionNumber(testName string) int32 { - number, err := s.executionResults.GetNextExecutionNumber(context.Background(), testName) + number, err := s.testResults.GetNextExecutionNumber(context.Background(), testName) if err != nil { s.logger.Errorw("retrieving latest execution", "error", err) return number diff --git a/pkg/scheduler/testsuite_scheduler.go b/pkg/scheduler/testsuite_scheduler.go index ba564919051..d8aa33189ea 100644 --- a/pkg/scheduler/testsuite_scheduler.go +++ b/pkg/scheduler/testsuite_scheduler.go @@ -117,7 +117,7 @@ func (s *Scheduler) executeTestSuite(ctx context.Context, testSuite testkube.Tes } testsuiteExecution = testkube.NewStartedTestSuiteExecution(testSuite, request) - err = s.testExecutionResults.Insert(ctx, testsuiteExecution) + err = s.testsuiteResults.Insert(ctx, testsuiteExecution) if err != nil { s.logger.Infow("Inserting test execution", "error", err) } @@ -208,7 +208,7 @@ func (s *Scheduler) runSteps(ctx context.Context, wg *sync.WaitGroup, testsuiteE } } - err := s.testExecutionResults.Update(ctx, *testsuiteExecution) + err := s.testsuiteResults.Update(ctx, *testsuiteExecution) if err != nil { s.logger.Infow("Updating test execution", "error", err) } @@ -224,7 +224,7 @@ func (s *Scheduler) runSteps(ctx context.Context, wg *sync.WaitGroup, testsuiteE s.logger.Debugw("Batch step execution result", "step", batchStepResult.Execute, "results", results) - err = s.testExecutionResults.Update(ctx, *testsuiteExecution) + err = s.testsuiteResults.Update(ctx, *testsuiteExecution) if err != nil { s.logger.Errorw("saving test suite execution results error", "error", err) @@ -262,7 +262,7 @@ func (s *Scheduler) runSteps(ctx context.Context, wg *sync.WaitGroup, testsuiteE s.metrics.IncExecuteTestSuite(*testsuiteExecution, s.dashboardURI) - err = s.testExecutionResults.Update(ctx, *testsuiteExecution) + err = s.testsuiteResults.Update(ctx, *testsuiteExecution) if err != nil { s.logger.Errorw("saving final test suite execution result error", "error", err) } @@ -272,7 +272,7 @@ func (s *Scheduler) runSteps(ctx context.Context, wg *sync.WaitGroup, testsuiteE func (s *Scheduler) runAfterEachStep(ctx context.Context, execution *testkube.TestSuiteExecution, wg *sync.WaitGroup) { execution.Stop() - err := s.testExecutionResults.EndExecution(ctx, *execution) + err := s.testsuiteResults.EndExecution(ctx, *execution) if err != nil { s.logger.Errorw("error setting end time", "error", err.Error()) } @@ -518,7 +518,7 @@ func (s *Scheduler) executeTestStep(ctx context.Context, testsuiteExecution test } result.Start() - if err := s.testExecutionResults.Update(ctx, testsuiteExecution); err != nil { + if err := s.testsuiteResults.Update(ctx, testsuiteExecution); err != nil { s.logger.Errorw("saving test suite execution start time error", "error", err) } @@ -543,7 +543,7 @@ func (s *Scheduler) executeTestStep(ctx context.Context, testsuiteExecution test if result.Execute[i].Execution.Id == r.Result.Id { result.Execute[i].Execution = &value - if err := s.testExecutionResults.Update(ctx, testsuiteExecution); err != nil { + if err := s.testsuiteResults.Update(ctx, testsuiteExecution); err != nil { s.logger.Errorw("saving test suite execution results error", "error", err) } } @@ -552,7 +552,7 @@ func (s *Scheduler) executeTestStep(ctx context.Context, testsuiteExecution test } result.Stop() - if err := s.testExecutionResults.Update(ctx, testsuiteExecution); err != nil { + if err := s.testsuiteResults.Update(ctx, testsuiteExecution); err != nil { s.logger.Errorw("saving test suite execution end time error", "error", err) } } diff --git a/pkg/triggers/executor_test.go b/pkg/triggers/executor_test.go index 371981fdcd7..dce098cbd69 100644 --- a/pkg/triggers/executor_test.go +++ b/pkg/triggers/executor_test.go @@ -24,6 +24,7 @@ import ( "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/log" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/repository/config" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/kubeshop/testkube/pkg/repository/testresult" @@ -103,6 +104,8 @@ func TestExecute(t *testing.T) { mockExecutor.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return(&mockExecutionResult, nil) mockResultRepository.EXPECT().UpdateResult(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + mockLogsStream := logsclient.NewMockInitializedStreamPusher(mockCtrl) + sched := scheduler.NewScheduler( metricsHandle, mockExecutor, @@ -122,6 +125,7 @@ func TestExecute(t *testing.T) { mockBus, "", featureflags.FeatureFlags{}, + mockLogsStream, ) s := &Service{ triggerStatus: make(map[statusKey]*triggerStatus), diff --git a/pkg/triggers/service_test.go b/pkg/triggers/service_test.go index e2cf5a43e10..7c847f32e4d 100644 --- a/pkg/triggers/service_test.go +++ b/pkg/triggers/service_test.go @@ -14,7 +14,6 @@ import ( executorv1 "github.com/kubeshop/testkube-operator/api/executor/v1" testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" testtriggersv1 "github.com/kubeshop/testkube-operator/api/testtriggers/v1" - v1 "github.com/kubeshop/testkube-operator/api/testtriggers/v1" executorsclientv1 "github.com/kubeshop/testkube-operator/pkg/client/executors/v1" testsclientv3 "github.com/kubeshop/testkube-operator/pkg/client/tests/v3" testsourcesv1 "github.com/kubeshop/testkube-operator/pkg/client/testsources/v1" @@ -29,6 +28,7 @@ import ( "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/log" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/repository/config" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/kubeshop/testkube/pkg/repository/testresult" @@ -117,6 +117,8 @@ func TestService_Run(t *testing.T) { testLogger := log.DefaultLogger + mockLogsStream := logsclient.NewMockInitializedStreamPusher(mockCtrl) + sched := scheduler.NewScheduler( testMetrics, mockExecutor, @@ -136,6 +138,7 @@ func TestService_Run(t *testing.T) { mockBus, "", featureflags.FeatureFlags{}, + mockLogsStream, ) mockLeaseBackend := NewMockLeaseBackend(mockCtrl) @@ -207,7 +210,7 @@ func TestService_addTrigger(t *testing.T) { s := Service{triggerStatus: make(map[statusKey]*triggerStatus)} - testTrigger := v1.TestTrigger{ + testTrigger := testtriggersv1.TestTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "test-trigger-1", Namespace: "testkube"}, } s.addTrigger(&testTrigger) @@ -222,10 +225,10 @@ func TestService_removeTrigger(t *testing.T) { s := Service{triggerStatus: make(map[statusKey]*triggerStatus)} - testTrigger1 := v1.TestTrigger{ + testTrigger1 := testtriggersv1.TestTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "test-trigger-1", Namespace: "testkube"}, } - testTrigger2 := v1.TestTrigger{ + testTrigger2 := testtriggersv1.TestTrigger{ ObjectMeta: metav1.ObjectMeta{Name: "test-trigger-2", Namespace: "testkube"}, } s.addTrigger(&testTrigger1) @@ -247,15 +250,15 @@ func TestService_updateTrigger(t *testing.T) { s := Service{triggerStatus: make(map[statusKey]*triggerStatus)} - oldTestTrigger := v1.TestTrigger{ + oldTestTrigger := testtriggersv1.TestTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: "testkube", Name: "test-trigger-1"}, - Spec: v1.TestTriggerSpec{Event: "created"}, + Spec: testtriggersv1.TestTriggerSpec{Event: "created"}, } s.addTrigger(&oldTestTrigger) - newTestTrigger := v1.TestTrigger{ + newTestTrigger := testtriggersv1.TestTrigger{ ObjectMeta: metav1.ObjectMeta{Namespace: "testkube", Name: "test-trigger-1"}, - Spec: v1.TestTriggerSpec{Event: "modified"}, + Spec: testtriggersv1.TestTriggerSpec{Event: "modified"}, } s.updateTrigger(&newTestTrigger) diff --git a/pkg/triggers/watcher.go b/pkg/triggers/watcher.go index 7413671c264..cb85ee7b37b 100644 --- a/pkg/triggers/watcher.go +++ b/pkg/triggers/watcher.go @@ -9,7 +9,6 @@ import ( corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/informers" appsinformerv1 "k8s.io/client-go/informers/apps/v1" coreinformerv1 "k8s.io/client-go/informers/core/v1" @@ -57,7 +56,7 @@ func newK8sInformers(clientset kubernetes.Interface, testKubeClientset versioned testkubeNamespace string, watcherNamespaces []string) *k8sInformers { var k8sInformers k8sInformers if len(watcherNamespaces) == 0 { - watcherNamespaces = append(watcherNamespaces, v1.NamespaceAll) + watcherNamespaces = append(watcherNamespaces, metav1.NamespaceAll) } for _, namespace := range watcherNamespaces { From 5e416091cb34bb1c86b05811fe8265ce63e67bce Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 16 Jan 2024 20:47:13 +0300 Subject: [PATCH 005/234] fix: remove duplicates --- contrib/executor/jmeterd/pkg/runner/runner.go | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index ea0c96d4004..1472e89ce44 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -132,7 +132,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( reportPath := filepath.Join(outputDir, "report") jmeterLogPath := filepath.Join(outputDir, "jmeter.log") args := execution.Args - hasJunit, hasReport := replacePlaceholderArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) + hasJunit, hasReport := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) if mode == jmeterModeDistributed { clientSet, err := k8sclient.ConnectToK8s() @@ -227,7 +227,37 @@ func initSlaves( } -func replacePlaceholderArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool) { +func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool) { + duplicates := make(map[string]int) + removals := make(map[string]string) + for _, arg := range args { + duplicates[arg] += 1 + if duplicates[arg] > 1 { + switch arg { + case "-t": + removals[""] = arg + case "-l": + removals[""] = arg + case "-o": + removals[""] = arg + case "-j": + removals[""] = arg + } + } + } + + for i := len(args) - 1; i >= 0; i-- { + if arg, ok := removals[args[i]]; ok { + args = append(args[:i], args[i+1:]...) + if i > 0 { + i-- + if args[i] == arg { + args = append(args[:i], args[i+1:]...) + } + } + } + } + for i, arg := range args { switch arg { case "": From ea557920c610fd90db0842bcfec5937ed6405772 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 16 Jan 2024 20:55:25 +0300 Subject: [PATCH 006/234] fix: unit tests --- contrib/executor/jmeterd/pkg/runner/runner_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index ee75d8d0f30..e9aa1b92604 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -57,7 +57,7 @@ func TestReplaceArgs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - hasJunit, hasReport := replacePlaceholderArgs(tt.args, tt.path, tt.jtlPath, tt.reportPath, tt.jmeterLogPath) + hasJunit, hasReport := prepareArgs(tt.args, tt.path, tt.jtlPath, tt.reportPath, tt.jmeterLogPath) for i, arg := range tt.args { assert.Equal(t, tt.expectedArgs[i], arg) From 2d1c7d703d89254d5e29b1148d40a1f08c45c89c Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 16 Jan 2024 21:03:13 +0300 Subject: [PATCH 007/234] fix: add OUTPUT_DIR env var --- contrib/executor/jmeterd/pkg/runner/runner.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index 1472e89ce44..a9ae104b235 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -122,7 +122,14 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( if workingDir != "" { runPath = workingDir } + outputDir := filepath.Join(runPath, "output") + err = os.Setenv("OUTPUT_DIR", outputDir) + if err != nil { + output.PrintLogf("%s Failed to set output directory %s", ui.IconWarning, outputDir) + } + slavesEnvVariables["OUTPUT_DIR"] = testkube.NewBasicVariable("OUTPUT_DIR", outputDir) + // recreate output directory with wide permissions so JMeter can create report files if err = os.Mkdir(outputDir, 0777); err != nil { return *result.Err(errors.Wrapf(err, "error creating directory %s", outputDir)), nil From 64ffb5f3c9a31ae5379a2f85534db65ff2100ada Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 16 Jan 2024 23:14:03 +0300 Subject: [PATCH 008/234] fix: add unit tests --- contrib/executor/jmeterd/pkg/runner/runner.go | 24 +++++----- .../jmeterd/pkg/runner/runner_test.go | 48 +++++++++++++++++-- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index a9ae104b235..b1dfa26438a 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -139,7 +139,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( reportPath := filepath.Join(outputDir, "report") jmeterLogPath := filepath.Join(outputDir, "jmeter.log") args := execution.Args - hasJunit, hasReport := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) + hasJunit, hasReport, args := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) if mode == jmeterModeDistributed { clientSet, err := k8sclient.ConnectToK8s() @@ -234,27 +234,27 @@ func initSlaves( } -func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool) { - duplicates := make(map[string]int) - removals := make(map[string]string) +func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool, result []string) { + counters := make(map[string]int) + duplicates := make(map[string]string) for _, arg := range args { - duplicates[arg] += 1 - if duplicates[arg] > 1 { + counters[arg] += 1 + if counters[arg] > 1 { switch arg { case "-t": - removals[""] = arg + duplicates[""] = arg case "-l": - removals[""] = arg + duplicates[""] = arg case "-o": - removals[""] = arg + duplicates[""] = arg case "-j": - removals[""] = arg + duplicates[""] = arg } } } for i := len(args) - 1; i >= 0; i-- { - if arg, ok := removals[args[i]]; ok { + if arg, ok := duplicates[args[i]]; ok { args = append(args[:i], args[i+1:]...) if i > 0 { i-- @@ -280,7 +280,7 @@ func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) hasJunit = true } } - return + return hasJunit, hasReport, args } func getEntryPoint() (entrypoint string) { diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index e9aa1b92604..6575ab9a5a3 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -14,7 +14,7 @@ import ( "github.com/kubeshop/testkube/pkg/envs" ) -func TestReplaceArgs(t *testing.T) { +func TestPrepareArgsReplacements(t *testing.T) { t.Parallel() tests := []struct { @@ -57,9 +57,51 @@ func TestReplaceArgs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - hasJunit, hasReport := prepareArgs(tt.args, tt.path, tt.jtlPath, tt.reportPath, tt.jmeterLogPath) + hasJunit, hasReport, args := prepareArgs(tt.args, tt.path, tt.jtlPath, tt.reportPath, tt.jmeterLogPath) - for i, arg := range tt.args { + for i, arg := range args { + assert.Equal(t, tt.expectedArgs[i], arg) + } + assert.Equal(t, tt.expectedJunit, hasJunit) + assert.Equal(t, tt.expectedReport, hasReport) + }) + } +} + +func TestPrepareArgsDuplication(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + expectedArgs []string + expectedJunit bool + expectedReport bool + }{ + { + name: "Duplicated args", + args: []string{"-t", "", "-t", "path", "-l"}, + expectedArgs: []string{"-t", "path", "-l"}, + expectedJunit: true, + expectedReport: false, + }, + { + name: "Non duplicated args", + args: []string{"-t", "path", "-l"}, + expectedArgs: []string{"-t", "path", "-l"}, + expectedJunit: true, + expectedReport: false, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hasJunit, hasReport, args := prepareArgs(tt.args, "", "", "", "") + + for i, arg := range args { assert.Equal(t, tt.expectedArgs[i], arg) } assert.Equal(t, tt.expectedJunit, hasJunit) From 1e90ad7d03eaa8229c57df096e20e3a16dd5bc18 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 17 Jan 2024 13:35:22 +0300 Subject: [PATCH 009/234] fix: add more test cases --- .../jmeterd/pkg/runner/runner_test.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index 6575ab9a5a3..b9195ecbc98 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -85,6 +85,13 @@ func TestPrepareArgsDuplication(t *testing.T) { expectedJunit: true, expectedReport: false, }, + { + name: "Multiple duplicated args", + args: []string{"-t", "", "-o", "", "-t", "path", "-o", "output", "-l"}, + expectedArgs: []string{"-t", "path", "-o", "output", "-l"}, + expectedJunit: true, + expectedReport: false, + }, { name: "Non duplicated args", args: []string{"-t", "path", "-l"}, @@ -92,6 +99,27 @@ func TestPrepareArgsDuplication(t *testing.T) { expectedJunit: true, expectedReport: false, }, + { + name: "Wrong arg order", + args: []string{"", "-t", "-t", "path", "-l"}, + expectedArgs: []string{"-t", "-t", "path", "-l"}, + expectedJunit: true, + expectedReport: false, + }, + { + name: "Missed template arg", + args: []string{"-t", "-t", "path", "-l"}, + expectedArgs: []string{"-t", "-t", "path", "-l"}, + expectedJunit: true, + expectedReport: false, + }, + { + name: "Wrong arg before template", + args: []string{"-d", "-o", "", "-t", "-t", "path", "-l"}, + expectedArgs: []string{"-d", "-o", "-t", "-t", "path", "-l"}, + expectedJunit: true, + expectedReport: false, + }, } for i := range tests { From 87655eb3958c750d23f5275a82cb9e100555ff48 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 17 Jan 2024 13:38:57 +0300 Subject: [PATCH 010/234] fix: one more test case --- contrib/executor/jmeterd/pkg/runner/runner_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index b9195ecbc98..dd3755dd61b 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -120,6 +120,13 @@ func TestPrepareArgsDuplication(t *testing.T) { expectedJunit: true, expectedReport: false, }, + { + name: "Duplicated not template args", + args: []string{"-t", "first", "-t", "second", "-l"}, + expectedArgs: []string{"-t", "first", "-t", "second", "-l"}, + expectedJunit: true, + expectedReport: false, + }, } for i := range tests { From 110290b2b8155649ffb4a5da2f40338b6c10a43c Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 17 Jan 2024 12:42:10 +0100 Subject: [PATCH 011/234] fix: slack token must be set to initialize Slack Listener (#4902) --- pkg/slack/slack.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/slack/slack.go b/pkg/slack/slack.go index b703ceaae0b..c6ab0f2734d 100644 --- a/pkg/slack/slack.go +++ b/pkg/slack/slack.go @@ -47,7 +47,7 @@ func NewNotifier(template, clusterName, dashboardURI string, config []Notificati notifier := Notifier{messageTemplate: template, clusterName: clusterName, dashboardURI: dashboardURI, config: NewConfig(config), envs: envs} notifier.timestamps = make(map[string]string) - if token, ok := os.LookupEnv("SLACK_TOKEN"); ok { + if token, ok := os.LookupEnv("SLACK_TOKEN"); ok && token != "" { log.DefaultLogger.Infow("initializing slack client", "SLACK_TOKEN", text.Obfuscate(token)) notifier.client = slack.New(token, slack.OptionDebug(true)) notifier.Ready = true From d39a8aef917b68ea8e6097674081c34cad125ffd Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 17 Jan 2024 17:20:22 +0200 Subject: [PATCH 012/234] test cli image --- .builds-linux.goreleaser.yml | 10 ---------- .github/workflows/release-dev.yaml | 19 ++++++++++++++++--- .github/workflows/release.yaml | 26 ++++++++++++++++---------- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.builds-linux.goreleaser.yml b/.builds-linux.goreleaser.yml index 512281a9b20..f733a63b880 100644 --- a/.builds-linux.goreleaser.yml +++ b/.builds-linux.goreleaser.yml @@ -70,16 +70,6 @@ dockers: - "--cache-from={{ .Env.DOCKER_BUILDX_CACHE_FROM }}" - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" -docker_manifests: - - name_template: kubeshop/testkube-cli:{{ .Env.DOCKER_IMAGE_TAG }} - image_templates: - - kubeshop/testkube-cli:{{ .Env.DOCKER_IMAGE_TAG }}-amd64 - - kubeshop/testkube-cli:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8 - - name_template: kubeshop/testkube-cli:latest - image_templates: - - kubeshop/testkube-cli:{{ .Env.DOCKER_IMAGE_TAG }}-amd64 - - kubeshop/testkube-cli:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8 - docker_signs: - cmd: cosign artifacts: all diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml index 656fec54f57..be9e463ce71 100644 --- a/.github/workflows/release-dev.yaml +++ b/.github/workflows/release-dev.yaml @@ -66,6 +66,12 @@ jobs: id: github_sha run: echo "::set-output name=sha_short::${GITHUB_SHA::7}" + - name: Get tag + id: tag + uses: dawidd6/action-get-tag@v1 + with: + strip_v: true + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 with: @@ -82,13 +88,20 @@ jobs: DOCKER_BUILDX_CACHE_FROM: "type=gha" DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} - DOCKER_IMAGE_TAG: ${{ steps.github_sha.outputs.sha_short }} + DOCKER_IMAGE_TAG: ${{steps.tag.outputs.tag}} - name: Push Docker images if: matrix.name == 'linux' run: | - docker push kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker push kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker push kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-arm64v8 + docker push kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-amd64 + + # adding the docker manifest for the latest image tag + docker manifest create kubeshop/testkube-cli:latest --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-amd64 --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-arm64v8 + docker manifest push -p kubeshop/testkube-cli:latest + + docker manifest create kubeshop/testkube-cli:${{steps.tag.outputs.tag}} --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-amd64 --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-arm64v8 + docker manifest push -p kubeshop/testkube-cli:${{steps.tag.outputs.tag}} - name: Push README to Dockerhub if: matrix.name == 'linux' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f0e0cd5d7d5..3ecb2129c75 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -67,8 +67,14 @@ jobs: id: github_sha run: echo "::set-output name=sha_short::${GITHUB_SHA::7}" + - name: Get tag + id: tag + uses: dawidd6/action-get-tag@v1 + with: + strip_v: true + - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser-pro version: latest @@ -83,20 +89,20 @@ jobs: DOCKER_BUILDX_CACHE_FROM: "type=gha" DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} - DOCKER_IMAGE_TAG: ${{ steps.github_sha.outputs.sha_short }} + DOCKER_IMAGE_TAG: ${{steps.tag.outputs.tag}} - name: Push Docker images if: matrix.name == 'linux' run: | - docker push kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker push kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker push kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-arm64v8 + docker push kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-amd64 + # adding the docker manifest for the latest image tag - docker manifest create kubeshop/testkube-cli:latest \ - kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-amd64 \ - kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker manifest annotate kubeshop/testkube-cli:latest kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 - docker manifest annotate kubeshop/testkube-cli:latest kubeshop/testkube-cli:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 - docker manifest push kubeshop/testkube-cli:latest + docker manifest create kubeshop/testkube-cli:latest --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-amd64 --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-arm64v8 + docker manifest push -p kubeshop/testkube-cli:latest + + docker manifest create kubeshop/testkube-cli:${{steps.tag.outputs.tag}} --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-amd64 --amend kubeshop/testkube-cli:${{steps.tag.outputs.tag}}-arm64v8 + docker manifest push -p kubeshop/testkube-cli:${{steps.tag.outputs.tag}} - name: Upload Artifacts uses: actions/upload-artifact@master From 9fd606d13b38f2a68f3c1d77b63f5ed72f4f608d Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 17 Jan 2024 17:27:48 +0200 Subject: [PATCH 013/234] delete sha output --- .github/workflows/release-dev.yaml | 4 ---- .github/workflows/release.yaml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml index be9e463ce71..c2311de10d3 100644 --- a/.github/workflows/release-dev.yaml +++ b/.github/workflows/release-dev.yaml @@ -62,10 +62,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Get github sha - id: github_sha - run: echo "::set-output name=sha_short::${GITHUB_SHA::7}" - - name: Get tag id: tag uses: dawidd6/action-get-tag@v1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3ecb2129c75..43e4cb75938 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -63,10 +63,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Get github sha - id: github_sha - run: echo "::set-output name=sha_short::${GITHUB_SHA::7}" - - name: Get tag id: tag uses: dawidd6/action-get-tag@v1 From 51caafa01f045be900b65a0e759db2cb193877d5 Mon Sep 17 00:00:00 2001 From: Povilas Versockas Date: Thu, 18 Jan 2024 10:43:48 +0200 Subject: [PATCH 014/234] feat: add status field to artifacts (#4908) --- api/v1/testkube.yaml | 8 +++++++- pkg/api/v1/testkube/model_artifact.go | 1 + pkg/executor/scraper/extractor.go | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index ba7b5601660..caad2352fae 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -4306,6 +4306,12 @@ components: type: string description: execution name that produced the artifact example: "test-1" + status: + type: string + enum: + - ready + - processing + - failed ExecutionsResult: description: the result for a page of executions @@ -6133,4 +6139,4 @@ components: - execution filePath: type: string - example: folder/file.txt \ No newline at end of file + example: folder/file.txt diff --git a/pkg/api/v1/testkube/model_artifact.go b/pkg/api/v1/testkube/model_artifact.go index 41111f66d2f..062b341e8a3 100644 --- a/pkg/api/v1/testkube/model_artifact.go +++ b/pkg/api/v1/testkube/model_artifact.go @@ -17,4 +17,5 @@ type Artifact struct { Size int32 `json:"size,omitempty"` // execution name that produced the artifact ExecutionName string `json:"executionName,omitempty"` + Status string `json:"status,omitempty"` } diff --git a/pkg/executor/scraper/extractor.go b/pkg/executor/scraper/extractor.go index 63ce3b5ed04..2acf4e81fa3 100644 --- a/pkg/executor/scraper/extractor.go +++ b/pkg/executor/scraper/extractor.go @@ -41,4 +41,7 @@ type FilesMeta struct { type FileStat struct { Name string `json:"name"` Size int64 `json:"size"` + // Status shows if file is ready to be downloaded + // One of: ready, processing, error + Status string `json:"status,omitempty"` } From 0a390e70152595f111213db3b103caafbbd6f8cc Mon Sep 17 00:00:00 2001 From: nicufk Date: Thu, 18 Jan 2024 13:26:41 +0200 Subject: [PATCH 015/234] feat: add minio log consumer with opts (#4867) * feat: add minio log consumer with opts * fix: tests * fix: skip test * fix: improve code and error handling * fix: handle chunk too big * fix: execute but return errors * fix: minor improvements * fix: contexts --- internal/app/api/v1/handlers.go | 3 +- pkg/logs/adapter/minio.go | 236 +++++++++++++++++++++++++++ pkg/logs/adapter/minio_test.go | 184 +++++++++++++++++++++ pkg/storage/minio/minio.go | 182 +++++---------------- pkg/storage/minio/minio_connecter.go | 147 +++++++++++++++++ 5 files changed, 606 insertions(+), 146 deletions(-) create mode 100644 pkg/logs/adapter/minio.go create mode 100644 pkg/logs/adapter/minio_test.go create mode 100644 pkg/storage/minio/minio_connecter.go diff --git a/internal/app/api/v1/handlers.go b/internal/app/api/v1/handlers.go index 3db757e5d1c..ee3394b0018 100644 --- a/internal/app/api/v1/handlers.go +++ b/internal/app/api/v1/handlers.go @@ -6,13 +6,12 @@ import ( "os" "strings" - "github.com/kubeshop/testkube/pkg/version" - "github.com/gofiber/fiber/v2" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/k8sclient" "github.com/kubeshop/testkube/pkg/oauth" + "github.com/kubeshop/testkube/pkg/version" ) const ( diff --git a/pkg/logs/adapter/minio.go b/pkg/logs/adapter/minio.go new file mode 100644 index 00000000000..25d8f15a3f2 --- /dev/null +++ b/pkg/logs/adapter/minio.go @@ -0,0 +1,236 @@ +package adapter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strconv" + "sync" + + "github.com/minio/minio-go/v7" + "go.uber.org/zap" + + "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/logs/events" + minioconnecter "github.com/kubeshop/testkube/pkg/storage/minio" +) + +const ( + defaultBufferSize = 1024 * 100 // 100KB + defaultWriteSize = 1024 * 80 // 80KB +) + +var _ Adapter = &MinioConsumer{} + +type ErrMinioConsumerDisconnected struct { +} + +func (e ErrMinioConsumerDisconnected) Error() string { + return "minio consumer disconnected" +} + +type ErrIdNotFound struct { + Id string +} + +func (e ErrIdNotFound) Error() string { + return fmt.Sprintf("id %s not found", e.Id) +} + +type ErrChucnkTooBig struct { + Length int +} + +func (e ErrChucnkTooBig) Error() string { + return fmt.Sprintf("chunk too big: %d", e.Length) +} + +type BufferInfo struct { + Buffer *bytes.Buffer + Part int +} + +// MinioConsumer creates new MinioSubscriber which will send data to local MinIO bucket +func NewMinioConsumer(endpoint, accessKeyID, secretAccessKey, region, token, bucket string, opts ...minioconnecter.Option) (*MinioConsumer, error) { + ctx := context.TODO() + c := &MinioConsumer{ + minioConnecter: minioconnecter.NewConnecter(endpoint, accessKeyID, secretAccessKey, region, token, bucket, log.DefaultLogger, opts...), + Log: log.DefaultLogger, + bucket: bucket, + region: region, + disconnected: false, + buffInfos: make(map[string]BufferInfo), + } + minioClient, err := c.minioConnecter.GetClient() + if err != nil { + c.Log.Errorw("error connecting to minio", "err", err) + return c, err + } + + c.minioClient = minioClient + exists, err := c.minioClient.BucketExists(ctx, c.bucket) + if err != nil { + c.Log.Errorw("error checking if bucket exists", "err", err) + return c, err + } + + if !exists { + err = c.minioClient.MakeBucket(ctx, c.bucket, + minio.MakeBucketOptions{Region: c.region}) + if err != nil { + c.Log.Errorw("error creating bucket", "err", err) + return c, err + } + } + return c, nil +} + +type MinioConsumer struct { + minioConnecter *minioconnecter.Connecter + minioClient *minio.Client + bucket string + region string + Log *zap.SugaredLogger + disconnected bool + buffInfos map[string]BufferInfo + mapLock sync.RWMutex +} + +func (s *MinioConsumer) Notify(id string, e events.Log) error { + if s.disconnected { + s.Log.Debugw("minio consumer disconnected", "id", id) + return ErrMinioConsumerDisconnected{} + } + + buffInfo, ok := s.GetBuffInfo(id) + if !ok { + buffInfo = BufferInfo{Buffer: bytes.NewBuffer(make([]byte, 0, defaultBufferSize)), Part: 0} + s.UpdateBuffInfo(id, buffInfo) + } + + chunckToAdd, err := json.Marshal(e) + if err != nil { + return err + } + + if len(chunckToAdd) > defaultWriteSize { + s.Log.Warnw("chunck too big", "length", len(chunckToAdd)) + return ErrChucnkTooBig{len(chunckToAdd)} + } + + chunckToAdd = append(chunckToAdd, []byte("\n")...) + + writer := buffInfo.Buffer + _, err = writer.Write(chunckToAdd) + if err != nil { + return err + } + + if writer.Len() > defaultWriteSize { + buffInfo.Buffer = bytes.NewBuffer(make([]byte, 0, defaultBufferSize)) + name := id + "-" + strconv.Itoa(buffInfo.Part) + buffInfo.Part++ + s.UpdateBuffInfo(id, buffInfo) + go s.putData(context.TODO(), name, writer) + } + + return nil +} + +func (s *MinioConsumer) putData(ctx context.Context, name string, buffer *bytes.Buffer) { + if buffer != nil && buffer.Len() != 0 { + _, err := s.minioClient.PutObject(ctx, s.bucket, name, buffer, int64(buffer.Len()), minio.PutObjectOptions{ContentType: "application/octet-stream"}) + if err != nil { + s.Log.Errorw("error putting object", "err", err) + } + } else { + s.Log.Warn("empty buffer for name: ", name) + } + +} + +func (s *MinioConsumer) combineData(ctxt context.Context, minioClient *minio.Client, id string, parts int, deleteIntermediaryData bool) error { + var returnedError []error + returnedError = nil + buffer := bytes.NewBuffer(make([]byte, 0, parts*defaultBufferSize)) + for i := 0; i < parts; i++ { + objectName := fmt.Sprintf("%s-%d", id, i) + if s.objectExists(objectName) { + objInfo, err := minioClient.GetObject(ctxt, s.bucket, objectName, minio.GetObjectOptions{}) + if err != nil { + s.Log.Errorw("error getting object", "err", err) + returnedError = append(returnedError, err) + } + _, err = buffer.ReadFrom(objInfo) + if err != nil { + s.Log.Errorw("error reading object", "err", err) + returnedError = append(returnedError, err) + } + } + } + _, err := minioClient.PutObject(ctxt, s.bucket, id, buffer, int64(buffer.Len()), minio.PutObjectOptions{ContentType: "application/octet-stream"}) + if err != nil { + s.Log.Errorw("error putting object", "err", err) + return err + } + + if deleteIntermediaryData { + for i := 0; i < parts; i++ { + objectName := fmt.Sprintf("%s-%d", id, i) + if s.objectExists(objectName) { + err = minioClient.RemoveObject(ctxt, s.bucket, objectName, minio.RemoveObjectOptions{}) + if err != nil { + s.Log.Errorw("error removing object", "err", err) + returnedError = append(returnedError, err) + } + } + } + } + buffer.Reset() + if len(returnedError) == 0 { + return nil + } + return fmt.Errorf("executed with errors: %v", returnedError) +} + +func (s *MinioConsumer) objectExists(objectName string) bool { + _, err := s.minioClient.StatObject(context.Background(), s.bucket, objectName, minio.StatObjectOptions{}) + return err == nil +} + +func (s *MinioConsumer) Stop(id string) error { + ctx := context.TODO() + buffInfo, ok := s.GetBuffInfo(id) + if !ok { + return ErrIdNotFound{id} + } + name := id + "-" + strconv.Itoa(buffInfo.Part) + s.putData(ctx, name, buffInfo.Buffer) + parts := buffInfo.Part + 1 + s.DeleteBuffInfo(id) + return s.combineData(ctx, s.minioClient, id, parts, true) +} + +func (s *MinioConsumer) Name() string { + return "minio" +} + +func (s *MinioConsumer) GetBuffInfo(id string) (BufferInfo, bool) { + s.mapLock.RLock() + defer s.mapLock.RUnlock() + buffInfo, ok := s.buffInfos[id] + return buffInfo, ok +} + +func (s *MinioConsumer) UpdateBuffInfo(id string, buffInfo BufferInfo) { + s.mapLock.Lock() + defer s.mapLock.Unlock() + s.buffInfos[id] = buffInfo +} + +func (s *MinioConsumer) DeleteBuffInfo(id string) { + s.mapLock.Lock() + defer s.mapLock.Unlock() + delete(s.buffInfos, id) +} diff --git a/pkg/logs/adapter/minio_test.go b/pkg/logs/adapter/minio_test.go new file mode 100644 index 00000000000..307e590b5f7 --- /dev/null +++ b/pkg/logs/adapter/minio_test.go @@ -0,0 +1,184 @@ +package adapter + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/minio/minio-go/v7" + "github.com/stretchr/testify/assert" + + "github.com/kubeshop/testkube/pkg/logs/events" + minioconnecter "github.com/kubeshop/testkube/pkg/storage/minio" + "github.com/kubeshop/testkube/pkg/utils" +) + +const hugeString = "82vbUcyQ0chpR665zbXY2mySOk7DGDFQCF1iLFjDNUYtNV8oQNaX3IYgJR30zBVhmDVjoZDJXO479tGSirHilZWEbzhjKJOdUwGb2HWOSOOjGh5r5wH0EHxRiOp8mBJv2rwdB2SoKF7JTBFgRt9M8F0JKp2Zx5kqh8eOGB1DGj64NLmwIpfuevJSv0wbDLrls5kEL5hHkszXPsuufVjJBsjNrxCoafuk93L2jE3ivVrMlkmLd9XAWKdop0oo0yRMJ9Vs1T5SZTkM6KXJB5hY3c14NsoPiG9Ay4EZmXrGpzGWI3RLAU6snXL8kV9sVLCG5DuRDnW047VR8eb78fpVj8YY3o9xpZd7xYPAhsmK0SwznHfrb0etAqdjQO6LFS9Blwre3G94DG5scVFH8RfteVNgKJXa8lTp8kKjtQLKNNA9mqyWfJ7uy8yjnVKwl7rodKqdtU6wjH2hf597MXA3roIS2xVhFpsCAVDybo9TVvZpoGfE9povhApoUR6Rmae9zvXPRoDbClOrvDElFkfgkJFzuoY2rPoV3dKuiTNwhYgPm36WPRk3SeFf2NiBQnWJBvjbRMIk5DsGfxcEiXQBfDvY4hgFctjwZ3USvWGriqT1cPsJ90LMLxbp38TRD1KVJ8ZgpqdvKTTi8dBqgEtob7okhdrkOahHJ3EKPtqV4PmaHvXSaIJvDG9c8jza64wxYBwMkHGt22i3HhCcIi8KmmfVo1ruqQLqKvINJg8eD5rKGV1mX9IipQcnrqADYnAj1wls7NSxsL0VZZm2pxRaGN494o2LCicHGEcOYkVLHufXY4Gv3friOIZSrT1r3NUgDBufpXWiG2b02TrRyFhgwRSS1a2OyMjHkT9tALmlIwFGF5HdaZphN6Mo5TFGdJyp65YU1scnlSGAVXzVdhsoD0RDZPSetdK2fzJC20kncaujAujHtSKnXrJNIhObnOjgMhCkx5E4z0oIH26DlfrbxS7k5SBQb1Zo3papQOk4uTNIdMBW4cE3V7AB8r6v4en3" + +func init() { + rand.New(rand.NewSource(time.Now().UnixNano())) +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func RandString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func TestLogs(t *testing.T) { + t.Skip("skipping test") + consumer, _ := NewMinioConsumer("localhost:9000", "minio", "minio123", "", "", "test-1", minioconnecter.Insecure()) + id := "test-bla" + for i := 0; i < 1000; i++ { + fmt.Println("sending", i) + consumer.Notify(id, events.Log{Time: time.Now(), + Content: fmt.Sprintf("Test %d: %s", i, hugeString), + Type: "test", Source: strconv.Itoa(i)}) + time.Sleep(100 * time.Millisecond) + } + err := consumer.Stop(id) + assert.NoError(t, err) +} + +func BenchmarkLogs(b *testing.B) { + randomString := RandString(5) + bucket := "test-bench" + consumer, _ := NewMinioConsumer("localhost:9000", "minio", "minio123", "", "", bucket, minioconnecter.Insecure()) + id := "test-bench" + "-" + randomString + "-" + strconv.Itoa(b.N) + totalSize := 0 + for i := 0; i < b.N; i++ { + consumer.Notify(id, events.Log{Time: time.Now(), + Content: fmt.Sprintf("Test %d: %s", i, hugeString), + Type: "test", Source: strconv.Itoa(i)}) + totalSize += len(hugeString) + } + sizeInMB := float64(totalSize) / 1024 / 1024 + err := consumer.Stop(id) + assert.NoError(b, err) + b.Logf("Total size for %s logs is %f MB", id, sizeInMB) +} + +func BenchmarkLogs2(b *testing.B) { + bucket := "test-bench" + consumer, _ := NewMinioConsumer("localhost:9000", "minio", "minio123", "", "", bucket, minioconnecter.Insecure()) + idChan := make(chan string, 100) + go verifyConsumer(idChan, bucket, consumer.minioClient) + var counter atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + randomString := strconv.Itoa(int(counter.Add(1))) + id := "test-bench" + "-" + randomString + testOneConsumer(consumer, id) + idChan <- id + }() + } + wg.Wait() +} + +func testOneConsumer(consumer *MinioConsumer, id string) { + fmt.Println("#####starting", id) + totalSize := 0 + numberOFLogs := rand.Intn(100000) + for i := 0; i < numberOFLogs; i++ { + consumer.Notify(id, events.Log{Time: time.Now(), + Content: fmt.Sprintf("Test %d: %s", i, hugeString), + Type: "test", Source: strconv.Itoa(i)}) + totalSize += len(hugeString) + time.Sleep(time.Duration(rand.Intn(10)) * time.Millisecond) + } + sizeInMB := float64(totalSize) / 1024 / 1024 + err := consumer.Stop(id) + if err != nil { + fmt.Println("#####error stopping", err) + } + fmt.Printf("#####Total size for %s logs is %f MB\n\n\n", id, sizeInMB) +} + +func verifyConsumer(idChan chan string, bucket string, minioClient *minio.Client) { + okSlice := make([]string, 0) + notOkSlice := make([]string, 0) + for id := range idChan { + reader, err := minioClient.GetObject(context.Background(), bucket, id, minio.GetObjectOptions{}) + if err != nil { + fmt.Println("######error getting object", err) + } + count := 0 + + r := bufio.NewReader(reader) + isOk := true + for { + line, err := utils.ReadLongLine(r) + if err != nil { + if err == io.EOF { + err = nil + } + break + } + var LogChunk events.Log + err = json.Unmarshal(line, &LogChunk) + if err != nil { + fmt.Printf("for id %s error %v unmarshalling %s\n\n\n", id, err, string(line)) + isOk = false + break + } + if LogChunk.Source == "" || LogChunk.Source != strconv.Itoa(count) { + fmt.Printf("for id %s not equal for count %d line %s \n logChunk %+v\n\n\n", id, count, string(line), LogChunk) + isOk = false + break + } + count++ + } + if isOk { + okSlice = append(okSlice, id) + } else { + notOkSlice = append(notOkSlice, id) + } + } + fmt.Println("##### number of ok", len(okSlice)) + fmt.Println("#####verified ok", okSlice) + fmt.Println("##### number of not ok", len(notOkSlice)) + fmt.Println("#####verified not ok", notOkSlice) +} + +func DoRunBenchmark() { + numberOfConsumers := 100 + bucket := "test-bench" + consumer, _ := NewMinioConsumer("testkube-minio-service-testkube:9000", "minio", "minio123", "", "", bucket, minioconnecter.Insecure()) + + idChan := make(chan string, numberOfConsumers) + DoRunBenchmark2(idChan, numberOfConsumers, consumer) + verifyConsumer(idChan, bucket, consumer.minioClient) +} + +func DoRunBenchmark2(idChan chan string, numberOfConsumers int, consumer *MinioConsumer) { + var counter atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < numberOfConsumers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + randomString := strconv.Itoa(int(counter.Add(1))) + id := "test-bench" + "-" + randomString + testOneConsumer(consumer, id) + idChan <- id + }() + } + wg.Wait() + close(idChan) + fmt.Printf("#####Done buffInfo is %+v\n\n\n", consumer.buffInfos) +} diff --git a/pkg/storage/minio/minio.go b/pkg/storage/minio/minio.go index 8078d90857e..7a7ea70bb22 100644 --- a/pkg/storage/minio/minio.go +++ b/pkg/storage/minio/minio.go @@ -3,8 +3,6 @@ package minio import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "fmt" "hash/fnv" "io" @@ -16,7 +14,6 @@ import ( "github.com/pkg/errors" "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/lifecycle" "go.uber.org/zap" @@ -35,89 +32,20 @@ var ErrArtifactsNotFound = errors.New("Execution doesn't have any artifacts asso // Client for managing MinIO storage server type Client struct { - Endpoint string - accessKeyID string - secretAccessKey string - ssl bool - region string - token string - bucket string - opts []Option - minioclient *minio.Client - tlsConfig *tls.Config - Log *zap.SugaredLogger -} - -type Option func(*Client) error - -// Insecure is an Option to enable TLS secure connections that skip server verification. -func Insecure() Option { - return func(o *Client) error { - if o.tlsConfig == nil { - o.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} - } - o.tlsConfig.InsecureSkipVerify = true - o.ssl = true - return nil - } -} - -// RootCAs is a helper option to provide the RootCAs pool from a list of filenames. -// If Secure is not already set this will set it as well. -func RootCAs(file ...string) Option { - return func(o *Client) error { - pool := x509.NewCertPool() - for _, f := range file { - rootPEM, err := os.ReadFile(f) - if err != nil || rootPEM == nil { - return fmt.Errorf("nats: error loading or parsing rootCA file: %v", err) - } - ok := pool.AppendCertsFromPEM(rootPEM) - if !ok { - return fmt.Errorf("nats: failed to parse root certificate from %q", f) - } - } - if o.tlsConfig == nil { - o.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} - } - o.tlsConfig.RootCAs = pool - o.ssl = true - return nil - } -} - -// ClientCert is a helper option to provide the client certificate from a file. -// If Secure is not already set this will set it as well. -func ClientCert(certFile, keyFile string) Option { - return func(o *Client) error { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return fmt.Errorf("nats: error loading client certificate: %v", err) - } - cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return fmt.Errorf("nats: error parsing client certificate: %v", err) - } - if o.tlsConfig == nil { - o.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} - } - o.tlsConfig.Certificates = []tls.Certificate{cert} - o.ssl = true - return nil - } + region string + bucket string + minioClient *minio.Client + Log *zap.SugaredLogger + minioConnecter *Connecter } // NewClient returns new MinIO client func NewClient(endpoint, accessKeyID, secretAccessKey, region, token, bucket string, opts ...Option) *Client { c := &Client{ - region: region, - accessKeyID: accessKeyID, - secretAccessKey: secretAccessKey, - token: token, - bucket: bucket, - Endpoint: endpoint, - opts: opts, - Log: log.DefaultLogger, + minioConnecter: NewConnecter(endpoint, accessKeyID, secretAccessKey, region, token, bucket, log.DefaultLogger, opts...), + region: region, + bucket: bucket, + Log: log.DefaultLogger, } return c @@ -125,47 +53,13 @@ func NewClient(endpoint, accessKeyID, secretAccessKey, region, token, bucket str // Connect connects to MinIO server func (c *Client) Connect() error { - for _, opt := range c.opts { - if err := opt(c); err != nil { - return errors.Wrapf(err, "error connecting to server") - } - } - creds := credentials.NewIAM("") - c.Log.Debugw("connecting to server", - "endpoint", c.Endpoint, - "accessKeyID", c.accessKeyID, - "region", c.region, - "token", c.token, - "bucket", c.bucket, - "ssl", c.ssl) - if c.accessKeyID != "" && c.secretAccessKey != "" { - creds = credentials.NewStaticV4(c.accessKeyID, c.secretAccessKey, c.token) - } - transport, err := minio.DefaultTransport(c.ssl) - if err != nil { - c.Log.Errorw("error creating minio transport", "error", err) - return err - } - transport.TLSClientConfig = c.tlsConfig - opts := &minio.Options{ - Creds: creds, - Secure: c.ssl, - Transport: transport, - } - if c.region != "" { - opts.Region = c.region - } - mclient, err := minio.New(c.Endpoint, opts) - if err != nil { - c.Log.Errorw("error connecting to minio", "error", err) - return err - } - c.minioclient = mclient + var err error + c.minioClient, err = c.minioConnecter.GetClient() return err } func (c *Client) SetExpirationPolicy(expirationDays int) error { - if expirationDays != 0 && c.minioclient != nil { + if expirationDays != 0 && c.minioClient != nil { lifecycleConfig := &lifecycle.Configuration{ Rules: []lifecycle.Rule{ { @@ -177,7 +71,7 @@ func (c *Client) SetExpirationPolicy(expirationDays int) error { }, }, } - return c.minioclient.SetBucketLifecycle(context.TODO(), c.bucket, lifecycleConfig) + return c.minioClient.SetBucketLifecycle(context.TODO(), c.bucket, lifecycleConfig) } return nil } @@ -187,11 +81,11 @@ func (c *Client) CreateBucket(ctx context.Context, bucket string) error { if err := c.Connect(); err != nil { return err } - err := c.minioclient.MakeBucket(ctx, bucket, minio.MakeBucketOptions{Region: c.region}) + err := c.minioClient.MakeBucket(ctx, bucket, minio.MakeBucketOptions{Region: c.region}) if err != nil { c.Log.Errorw("error creating bucket", "error", err) // Check to see if we already own this bucket (which happens if you run this twice) - exists, errBucketExists := c.minioclient.BucketExists(ctx, bucket) + exists, errBucketExists := c.minioClient.BucketExists(ctx, bucket) if errBucketExists == nil && exists { return fmt.Errorf("bucket %q already exists", bucket) } else { @@ -206,7 +100,7 @@ func (c *Client) DeleteBucket(ctx context.Context, bucket string, force bool) er if err := c.Connect(); err != nil { return err } - return c.minioclient.RemoveBucketWithOptions(ctx, bucket, minio.RemoveBucketOptions{ForceDelete: force}) + return c.minioClient.RemoveBucketWithOptions(ctx, bucket, minio.RemoveBucketOptions{ForceDelete: force}) } // ListBuckets lists available buckets @@ -215,7 +109,7 @@ func (c *Client) ListBuckets(ctx context.Context) ([]string, error) { return nil, err } var toReturn []string - if buckets, err := c.minioclient.ListBuckets(ctx); err != nil { + if buckets, err := c.minioClient.ListBuckets(ctx); err != nil { return nil, err } else { for _, bucket := range buckets { @@ -232,7 +126,7 @@ func (c *Client) listFiles(ctx context.Context, bucket, bucketFolder string) ([] } var toReturn []testkube.Artifact - exists, err := c.minioclient.BucketExists(ctx, bucket) + exists, err := c.minioClient.BucketExists(ctx, bucket) if err != nil { return nil, err } @@ -246,7 +140,7 @@ func (c *Client) listFiles(ctx context.Context, bucket, bucketFolder string) ([] listOptions.Prefix = bucketFolder } - for obj := range c.minioclient.ListObjects(ctx, bucket, listOptions) { + for obj := range c.minioClient.ListObjects(ctx, bucket, listOptions) { if obj.Err != nil { return nil, obj.Err } @@ -264,7 +158,7 @@ func (c *Client) ListFiles(ctx context.Context, bucketFolder string) ([]testkube c.Log.Infow("listing files", "bucket", c.bucket, "bucketFolder", bucketFolder) // TODO: this is for back compatibility, remove it sometime in the future if bucketFolder != "" { - if exist, err := c.minioclient.BucketExists(ctx, bucketFolder); err == nil && exist { + if exist, err := c.minioClient.BucketExists(ctx, bucketFolder); err == nil && exist { formerResult, err := c.listFiles(ctx, bucketFolder, "") if err == nil && len(formerResult) > 0 { return formerResult, nil @@ -291,7 +185,7 @@ func (c *Client) saveFile(ctx context.Context, bucket, bucketFolder, filePath st return fmt.Errorf("minio object stat (file:%s) error: %w", filePath, err) } - exists, err := c.minioclient.BucketExists(ctx, bucket) + exists, err := c.minioClient.BucketExists(ctx, bucket) if err != nil || !exists { err := c.CreateBucket(ctx, bucket) if err != nil { @@ -305,7 +199,7 @@ func (c *Client) saveFile(ctx context.Context, bucket, bucketFolder, filePath st } c.Log.Debugw("saving object in minio", "filePath", filePath, "fileName", fileName, "bucket", bucket, "size", objectStat.Size()) - _, err = c.minioclient.PutObject(ctx, bucket, fileName, object, objectStat.Size(), minio.PutObjectOptions{ContentType: "application/octet-stream"}) + _, err = c.minioClient.PutObject(ctx, bucket, fileName, object, objectStat.Size(), minio.PutObjectOptions{ContentType: "application/octet-stream"}) if err != nil { return fmt.Errorf("minio saving file (%s) put object error: %w", fileName, err) } @@ -314,7 +208,7 @@ func (c *Client) saveFile(ctx context.Context, bucket, bucketFolder, filePath st } func (c *Client) SaveFileDirect(ctx context.Context, folder, file string, data io.Reader, size int64, opts minio.PutObjectOptions) error { - exists, err := c.minioclient.BucketExists(ctx, c.bucket) + exists, err := c.minioClient.BucketExists(ctx, c.bucket) if err != nil { return errors.Wrapf(err, "error checking does bucket %s exists", c.bucket) } @@ -333,7 +227,7 @@ func (c *Client) SaveFileDirect(ctx context.Context, folder, file string, data i opts.ContentType = "application/octet-stream" } c.Log.Debugw("saving object in minio", "filename", filename, "bucket", c.bucket, "size", size) - _, err = c.minioclient.PutObject(ctx, c.bucket, filename, data, size, opts) + _, err = c.minioClient.PutObject(ctx, c.bucket, filename, data, size, opts) if err != nil { return errors.Wrapf(err, "minio saving file (%s) put object error", filename) } @@ -354,7 +248,7 @@ func (c *Client) downloadFile(ctx context.Context, bucket, bucketFolder, file st return nil, fmt.Errorf("minio DownloadFile .Connect error: %w", err) } - exists, err := c.minioclient.BucketExists(ctx, bucket) + exists, err := c.minioClient.BucketExists(ctx, bucket) if err != nil { return nil, err } @@ -368,7 +262,7 @@ func (c *Client) downloadFile(ctx context.Context, bucket, bucketFolder, file st file = strings.Trim(bucketFolder, "/") + "/" + file } - reader, err := c.minioclient.GetObject(ctx, bucket, file, minio.GetObjectOptions{}) + reader, err := c.minioClient.GetObject(ctx, bucket, file, minio.GetObjectOptions{}) if err != nil { return nil, fmt.Errorf("minio DownloadFile GetObject error: %w", err) } @@ -388,7 +282,7 @@ func (c *Client) DownloadFile(ctx context.Context, bucketFolder, file string) (* var objFirst *minio.Object var errFirst error if bucketFolder != "" { - exists, err := c.minioclient.BucketExists(ctx, bucketFolder) + exists, err := c.minioClient.BucketExists(ctx, bucketFolder) c.Log.Debugw("Checking if bucket exists", exists, err) if err == nil && exists { c.Log.Infow("Bucket exists, trying to get files from former bucket per execution", exists, err) @@ -412,7 +306,7 @@ func (c *Client) downloadArchive(ctx context.Context, bucket, bucketFolder strin return nil, fmt.Errorf("minio DownloadArchive .Connect error: %w", err) } - exists, err := c.minioclient.BucketExists(ctx, bucket) + exists, err := c.minioClient.BucketExists(ctx, bucket) if err != nil { return nil, err } @@ -441,7 +335,7 @@ func (c *Client) downloadArchive(ctx context.Context, bucket, bucketFolder strin } var files []*archive.File - for obj := range c.minioclient.ListObjects(ctx, bucket, listOptions) { + for obj := range c.minioClient.ListObjects(ctx, bucket, listOptions) { if obj.Err != nil { return nil, fmt.Errorf("minio DownloadArchive ListObjects error: %w", obj.Err) } @@ -466,7 +360,7 @@ func (c *Client) downloadArchive(ctx context.Context, bucket, bucketFolder strin } for i := range files { - reader, err := c.minioclient.GetObject(ctx, bucket, files[i].Name, minio.GetObjectOptions{}) + reader, err := c.minioClient.GetObject(ctx, bucket, files[i].Name, minio.GetObjectOptions{}) if err != nil { return nil, fmt.Errorf("minio DownloadArchive GetObject error: %w", err) } @@ -497,7 +391,7 @@ func (c *Client) DownloadArchive(ctx context.Context, bucketFolder string, masks var objFirst io.Reader var errFirst error if bucketFolder != "" { - exists, err := c.minioclient.BucketExists(ctx, bucketFolder) + exists, err := c.minioClient.BucketExists(ctx, bucketFolder) c.Log.Debugw("Checking if bucket exists", exists, err) if err == nil && exists { c.Log.Infow("Bucket exists, trying to get archive from former bucket per execution", exists, err) @@ -568,7 +462,7 @@ func (c *Client) uploadFile(ctx context.Context, bucket, bucketFolder, filePath return fmt.Errorf("minio UploadFile connection error: %w", err) } - exists, err := c.minioclient.BucketExists(ctx, bucket) + exists, err := c.minioClient.BucketExists(ctx, bucket) if err != nil { return fmt.Errorf("could not check if bucket already exists for copy files: %w", err) } @@ -586,7 +480,7 @@ func (c *Client) uploadFile(ctx context.Context, bucket, bucketFolder, filePath } c.Log.Debugw("saving object in minio", "file", filePath, "bucket", bucket) - _, err = c.minioclient.PutObject(ctx, bucket, filePath, reader, objectSize, minio.PutObjectOptions{ContentType: "application/octet-stream"}) + _, err = c.minioClient.PutObject(ctx, bucket, filePath, reader, objectSize, minio.PutObjectOptions{ContentType: "application/octet-stream"}) if err != nil { return fmt.Errorf("minio saving file (%s) put object error: %w", filePath, err) } @@ -611,7 +505,7 @@ func (c *Client) PlaceFiles(ctx context.Context, bucketFolders []string, prefix output.PrintLog(fmt.Sprintf("%s Minio PlaceFiles connection error: %s", ui.IconWarning, err.Error())) return fmt.Errorf("minio PlaceFiles connection error: %w", err) } - exists, err := c.minioclient.BucketExists(ctx, c.bucket) + exists, err := c.minioClient.BucketExists(ctx, c.bucket) if err != nil { output.PrintLog(fmt.Sprintf("%s Could not check if bucket already exists for files %s", ui.IconWarning, err.Error())) return fmt.Errorf("could not check if bucket already exists for files: %w", err) @@ -644,7 +538,7 @@ func (c *Client) PlaceFiles(ctx context.Context, bucketFolders []string, prefix } path := filepath.Join(prefix, f.Name) - err = c.minioclient.FGetObject(ctx, c.bucket, objectName, path, minio.GetObjectOptions{}) + err = c.minioClient.FGetObject(ctx, c.bucket, objectName, path, minio.GetObjectOptions{}) if err != nil { output.PrintEvent(fmt.Sprintf("%s Could not download file %s", ui.IconCross, f.Name)) return fmt.Errorf("could not persist file %s from bucket %s, folder %s: %w", f.Name, c.bucket, folder, err) @@ -673,7 +567,7 @@ func (c *Client) deleteFile(ctx context.Context, bucket, bucketFolder, file stri return fmt.Errorf("minio DeleteFile connection error: %w", err) } - exists, err := c.minioclient.BucketExists(ctx, bucket) + exists, err := c.minioClient.BucketExists(ctx, bucket) if err != nil { return fmt.Errorf("could not check if bucket already exists for delete file: %w", err) } @@ -687,7 +581,7 @@ func (c *Client) deleteFile(ctx context.Context, bucket, bucketFolder, file stri file = strings.Trim(bucketFolder, "/") + "/" + file } - err = c.minioclient.RemoveObject(ctx, bucket, file, minio.RemoveObjectOptions{ForceDelete: true}) + err = c.minioClient.RemoveObject(ctx, bucket, file, minio.RemoveObjectOptions{ForceDelete: true}) if err != nil { return fmt.Errorf("minio DeleteFile RemoveObject error: %w", err) } @@ -700,7 +594,7 @@ func (c *Client) DeleteFile(ctx context.Context, bucketFolder, file string) erro // TODO: this is for back compatibility, remove it sometime in the future var errFirst error if bucketFolder != "" { - if exist, err := c.minioclient.BucketExists(ctx, bucketFolder); err != nil || !exist { + if exist, err := c.minioClient.BucketExists(ctx, bucketFolder); err != nil || !exist { errFirst = c.DeleteFileFromBucket(ctx, bucketFolder, "", file) if err == nil { return nil diff --git a/pkg/storage/minio/minio_connecter.go b/pkg/storage/minio/minio_connecter.go new file mode 100644 index 00000000000..309c375b715 --- /dev/null +++ b/pkg/storage/minio/minio_connecter.go @@ -0,0 +1,147 @@ +package minio + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +type Option func(*Connecter) error + +// Insecure is an Option to enable TLS secure connections that skip server verification. +func Insecure() Option { + return func(o *Connecter) error { + if o.TlsConfig == nil { + o.TlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + o.TlsConfig.InsecureSkipVerify = true + o.Ssl = true + return nil + } +} + +// RootCAs is a helper option to provide the RootCAs pool from a list of filenames. +// If Secure is not already set this will set it as well. +func RootCAs(file ...string) Option { + return func(o *Connecter) error { + pool := x509.NewCertPool() + for _, f := range file { + rootPEM, err := os.ReadFile(f) + if err != nil || rootPEM == nil { + return fmt.Errorf("nats: error loading or parsing rootCA file: %v", err) + } + ok := pool.AppendCertsFromPEM(rootPEM) + if !ok { + return fmt.Errorf("nats: failed to parse root certificate from %q", f) + } + } + if o.TlsConfig == nil { + o.TlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + o.TlsConfig.RootCAs = pool + o.Ssl = true + return nil + } +} + +// ClientCert is a helper option to provide the client certificate from a file. +// If Secure is not already set this will set it as well. +func ClientCert(certFile, keyFile string) Option { + return func(o *Connecter) error { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return fmt.Errorf("nats: error loading client certificate: %v", err) + } + cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return fmt.Errorf("nats: error parsing client certificate: %v", err) + } + if o.TlsConfig == nil { + o.TlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + o.TlsConfig.Certificates = []tls.Certificate{cert} + o.Ssl = true + return nil + } +} + +type Connecter struct { + Endpoint string + AccessKeyID string + SecretAccessKey string + Region string + Token string + Bucket string + Ssl bool + TlsConfig *tls.Config + Opts []Option + Log *zap.SugaredLogger + client *minio.Client +} + +// NewConnecter creates a new Connecter +func NewConnecter(endpoint, accessKeyID, secretAccessKey, region, token, bucket string, log *zap.SugaredLogger, opts ...Option) *Connecter { + c := &Connecter{ + Endpoint: endpoint, + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + Region: region, + Token: token, + Bucket: bucket, + Opts: opts, + Log: log, + } + return c +} + +// GetClient() connects to MinIO +func (c *Connecter) GetClient() (*minio.Client, error) { + if c.client != nil { + return c.client, nil + } + + for _, opt := range c.Opts { + if err := opt(c); err != nil { + return nil, errors.Wrapf(err, "error connecting to server") + } + } + creds := credentials.NewIAM("") + c.Log.Debugw("connecting to server", + "endpoint", c.Endpoint, + "accessKeyID", c.AccessKeyID, + "region", c.Region, + "token", c.Token, + "bucket", c.Bucket, + "ssl", c.Ssl) + if c.AccessKeyID != "" && c.SecretAccessKey != "" { + creds = credentials.NewStaticV4(c.AccessKeyID, c.SecretAccessKey, c.Token) + } + transport, err := minio.DefaultTransport(c.Ssl) + if err != nil { + c.Log.Errorw("error creating minio transport", "error", err) + return nil, err + } + transport.TLSClientConfig = c.TlsConfig + opts := &minio.Options{ + Creds: creds, + Secure: c.Ssl, + Transport: transport, + } + if c.Region != "" { + opts.Region = c.Region + } + mclient, err := minio.New(c.Endpoint, opts) + if err != nil { + c.Log.Errorw("error connecting to minio", "error", err) + return nil, err + } + + c.client = mclient + return mclient, nil +} From cc25be898cdbcd95331d15587c7e0921fe6423e0 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 18 Jan 2024 14:39:06 +0300 Subject: [PATCH 016/234] fix: fiber doesn't allow to use query string outside handler --- internal/app/api/v1/executions.go | 3 ++- internal/app/api/v1/testsuites.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/app/api/v1/executions.go b/internal/app/api/v1/executions.go index 16a029bee9c..97d11fb2ca5 100644 --- a/internal/app/api/v1/executions.go +++ b/internal/app/api/v1/executions.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "strconv" + "strings" "k8s.io/apimachinery/pkg/api/errors" @@ -86,7 +87,7 @@ func (s *TestkubeAPI) ExecuteTestsHandler() fiber.Handler { } var results []testkube.Execution if len(tests) != 0 { - request.TestExecutionName = c.Query("testExecutionName") + request.TestExecutionName = strings.Clone(c.Query("testExecutionName")) concurrencyLevel, err := strconv.Atoi(c.Query("concurrency", strconv.Itoa(scheduler.DefaultConcurrencyLevel))) if err != nil { return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: can't detect concurrency level: %w", errPrefix, err)) diff --git a/internal/app/api/v1/testsuites.go b/internal/app/api/v1/testsuites.go index 566d7f2cdea..34e635bf099 100644 --- a/internal/app/api/v1/testsuites.go +++ b/internal/app/api/v1/testsuites.go @@ -577,7 +577,7 @@ func (s TestkubeAPI) ExecuteTestSuitesHandler() fiber.Handler { var results []testkube.TestSuiteExecution if len(testSuites) != 0 { - request.TestSuiteExecutionName = c.Query("testSuiteExecutionName") + request.TestSuiteExecutionName = strings.Clone(c.Query("testSuiteExecutionName")) concurrencyLevel, err := strconv.Atoi(c.Query("concurrency", strconv.Itoa(scheduler.DefaultConcurrencyLevel))) if err != nil { return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: can't detect concurrency level: %w", errPrefix, err)) From 600f1a8e3fbde1a1cd0d9bb4d2f63b1346d35af4 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Thu, 18 Jan 2024 13:30:32 +0100 Subject: [PATCH 017/234] feat: Executor tests - container executor - Gradle and Maven (#4898) * executor tests - container executor - maven and gradle * empty lines added * empty lines added --- .../executor-smoke/crd/gradle.yaml | 22 ++++++++++++++++ .../executor-smoke/crd/maven.yaml | 22 ++++++++++++++++ test/executors/container-executor-gradle.yaml | 9 +++++++ test/executors/container-executor-maven.yaml | 10 +++++++ test/scripts/executor-tests/run.sh | 26 +++++++++++++++++++ ...executor-container-gradle-smoke-tests.yaml | 12 +++++++++ .../executor-container-maven-smoke-tests.yaml | 12 +++++++++ 7 files changed, 113 insertions(+) create mode 100644 test/container-executor/executor-smoke/crd/gradle.yaml create mode 100644 test/container-executor/executor-smoke/crd/maven.yaml create mode 100644 test/executors/container-executor-gradle.yaml create mode 100644 test/executors/container-executor-maven.yaml create mode 100644 test/suites/executor-container-gradle-smoke-tests.yaml create mode 100644 test/suites/executor-container-maven-smoke-tests.yaml diff --git a/test/container-executor/executor-smoke/crd/gradle.yaml b/test/container-executor/executor-smoke/crd/gradle.yaml new file mode 100644 index 00000000000..a01732880ba --- /dev/null +++ b/test/container-executor/executor-smoke/crd/gradle.yaml @@ -0,0 +1,22 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: container-executor-gradle-jdk-11 + labels: + core-tests: executors +spec: + type: container-executor-gradle-8.5-jdk11/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube + branch: main + path: contrib/executor/gradle/examples/hello-gradle + workingDir: contrib/executor/gradle/examples/hello-gradle + executionRequest: + variables: + TESTKUBE_GRADLE: + name: TESTKUBE_GRADLE + value: "true" + type: basic diff --git a/test/container-executor/executor-smoke/crd/maven.yaml b/test/container-executor/executor-smoke/crd/maven.yaml new file mode 100644 index 00000000000..2eb4ff0ea62 --- /dev/null +++ b/test/container-executor/executor-smoke/crd/maven.yaml @@ -0,0 +1,22 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: container-executor-maven-jdk-11 + labels: + core-tests: executors +spec: + type: container-executor-maven-3.9-jdk11/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube + branch: main + path: contrib/executor/maven/examples/hello-maven + workingDir: contrib/executor/maven/examples/hello-maven + executionRequest: + variables: + TESTKUBE_MAVEN: + name: TESTKUBE_MAVEN + value: "true" + type: basic diff --git a/test/executors/container-executor-gradle.yaml b/test/executors/container-executor-gradle.yaml new file mode 100644 index 00000000000..91249ff6251 --- /dev/null +++ b/test/executors/container-executor-gradle.yaml @@ -0,0 +1,9 @@ +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: container-executor-gradle-8.5-jdk11 +spec: + image: gradle:8.5.0-jdk11 + executor_type: container + types: + - container-executor-gradle-8.5-jdk11/test diff --git a/test/executors/container-executor-maven.yaml b/test/executors/container-executor-maven.yaml new file mode 100644 index 00000000000..1b3f246749a --- /dev/null +++ b/test/executors/container-executor-maven.yaml @@ -0,0 +1,10 @@ +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: container-executor-maven-3.9-jdk11 +spec: + image: maven:3.9.6-eclipse-temurin-11-focal + executor_type: container + types: + - container-executor-maven-3.9-jdk11/test + command: ["mvn", "test"] diff --git a/test/scripts/executor-tests/run.sh b/test/scripts/executor-tests/run.sh index 4d471a7e7da..740c23a04e2 100755 --- a/test/scripts/executor-tests/run.sh +++ b/test/scripts/executor-tests/run.sh @@ -142,6 +142,17 @@ container-cypress-smoke() { common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" } +container-gradle-smoke() { + name="Container executor - Gradle" + test_crd_file="test/container-executor/executor-smoke/crd/gradle.yaml" + testsuite_name="executor-container-gradle-smoke-tests" + testsuite_file="test/suites/executor-container-gradle-smoke-tests.yaml" + + custom_executor_crd_file="test/executors/container-executor-gradle.yaml" + + common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" +} + container-k6-smoke() { name="Container executor - K6" test_crd_file="test/container-executor/executor-smoke/crd/k6.yaml" @@ -153,6 +164,17 @@ container-k6-smoke() { common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" } +container-maven-smoke() { + name="Container executor - Maven" + test_crd_file="test/container-executor/executor-smoke/crd/maven.yaml" + testsuite_name="executor-container-maven-smoke-tests" + testsuite_file="test/suites/executor-container-maven-smoke-tests.yaml" + + custom_executor_crd_file="test/executors/container-executor-maven.yaml" + + common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" +} + container-playwright-smoke() { name="Container executor - Playwright" test_crd_file="test/container-executor/executor-smoke/crd/playwright.yaml" @@ -344,7 +366,9 @@ main() { artillery-smoke container-curl-smoke container-cypress-smoke + container-gradle-smoke container-k6-smoke + container-maven-smoke container-postman-smoke container-playwright-smoke curl-smoke @@ -365,7 +389,9 @@ main() { artillery-smoke container-curl-smoke container-cypress-smoke + container-gradle-smoke container-k6-smoke + container-maven-smoke container-postman-smoke container-playwright-smoke curl-smoke diff --git a/test/suites/executor-container-gradle-smoke-tests.yaml b/test/suites/executor-container-gradle-smoke-tests.yaml new file mode 100644 index 00000000000..c642decd13e --- /dev/null +++ b/test/suites/executor-container-gradle-smoke-tests.yaml @@ -0,0 +1,12 @@ +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: executor-container-gradle-smoke-tests + labels: + core-tests: executors +spec: + description: "container executor gradle smoke tests" + steps: + - stopOnFailure: false + execute: + - test: container-executor-gradle-jdk-11 diff --git a/test/suites/executor-container-maven-smoke-tests.yaml b/test/suites/executor-container-maven-smoke-tests.yaml new file mode 100644 index 00000000000..34b944eb83d --- /dev/null +++ b/test/suites/executor-container-maven-smoke-tests.yaml @@ -0,0 +1,12 @@ +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: executor-container-maven-smoke-tests + labels: + core-tests: executors +spec: + description: "container executor maven smoke tests" + steps: + - stopOnFailure: false + execute: + - test: container-executor-maven-jdk-11 From 740c95250aac2fe2aa2a791afa3cd3d2a951c270 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 18 Jan 2024 16:09:42 +0300 Subject: [PATCH 018/234] fix: dep update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c1e3f72a865..e3c75806c68 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/joshdk/go-junit v1.0.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20231214095624-483fef2d8731 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118124335-9424636d3456 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index abb930584df..e2d3038d64f 100644 --- a/go.sum +++ b/go.sum @@ -354,8 +354,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20231214095624-483fef2d8731 h1:otwyKLt+5Trz5c2EMS+yjMV33O26sdNlcJJkRSiu2tU= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20231214095624-483fef2d8731/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118124335-9424636d3456 h1:rBJgy8PnkY6mQDUSKl11DJOKTekwwFAKg2CtXC1UIPo= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118124335-9424636d3456/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= From d7bec40e860457ce132143f42328f17d5c7f9823 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 18 Jan 2024 16:22:25 +0300 Subject: [PATCH 019/234] fix: dep update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e3c75806c68..391c1805158 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/joshdk/go-junit v1.0.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118124335-9424636d3456 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index e2d3038d64f..98d42309225 100644 --- a/go.sum +++ b/go.sum @@ -354,8 +354,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118124335-9424636d3456 h1:rBJgy8PnkY6mQDUSKl11DJOKTekwwFAKg2CtXC1UIPo= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118124335-9424636d3456/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3 h1:R6xdH//ctWpE18U1GYwzNvq1HLiT9LUJogXkfyKDDGo= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= From 94ddfe5ba4abf06f958816c1c6e4186e2f3400a9 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 18 Jan 2024 19:01:55 +0300 Subject: [PATCH 020/234] fix: add gradle home --- contrib/executor/gradle/build/agent/Dockerfile.jdk11 | 3 +++ contrib/executor/gradle/build/agent/Dockerfile.jdk17 | 3 +++ contrib/executor/gradle/build/agent/Dockerfile.jdk18 | 3 +++ contrib/executor/gradle/build/agent/Dockerfile.jdk8 | 3 +++ 4 files changed, 12 insertions(+) diff --git a/contrib/executor/gradle/build/agent/Dockerfile.jdk11 b/contrib/executor/gradle/build/agent/Dockerfile.jdk11 index ed08d550d1f..8111390e8df 100644 --- a/contrib/executor/gradle/build/agent/Dockerfile.jdk11 +++ b/contrib/executor/gradle/build/agent/Dockerfile.jdk11 @@ -2,6 +2,9 @@ FROM gradle:8.5.0-jdk11 COPY gradle /bin/runner +RUN chown -R 1001:1001 /home/gradle +ENV GRADLE_USER_HOME /home/gradle + USER 1001 ENTRYPOINT ["/bin/runner"] diff --git a/contrib/executor/gradle/build/agent/Dockerfile.jdk17 b/contrib/executor/gradle/build/agent/Dockerfile.jdk17 index 074ce893636..41353e9b66b 100644 --- a/contrib/executor/gradle/build/agent/Dockerfile.jdk17 +++ b/contrib/executor/gradle/build/agent/Dockerfile.jdk17 @@ -2,6 +2,9 @@ FROM gradle:8.5.0-jdk17 COPY gradle /bin/runner +RUN chown -R 1001:1001 /home/gradle +ENV GRADLE_USER_HOME /home/gradle + USER 1001 ENTRYPOINT ["/bin/runner"] diff --git a/contrib/executor/gradle/build/agent/Dockerfile.jdk18 b/contrib/executor/gradle/build/agent/Dockerfile.jdk18 index 0a21cb34b13..e365ec77b03 100644 --- a/contrib/executor/gradle/build/agent/Dockerfile.jdk18 +++ b/contrib/executor/gradle/build/agent/Dockerfile.jdk18 @@ -2,6 +2,9 @@ FROM gradle:8.5.0-jdk18 COPY gradle /bin/runner +RUN chown -R 1001:1001 /home/gradle +ENV GRADLE_USER_HOME /home/gradle + USER 1001 ENTRYPOINT ["/bin/runner"] diff --git a/contrib/executor/gradle/build/agent/Dockerfile.jdk8 b/contrib/executor/gradle/build/agent/Dockerfile.jdk8 index 463101d6afa..546000c545f 100644 --- a/contrib/executor/gradle/build/agent/Dockerfile.jdk8 +++ b/contrib/executor/gradle/build/agent/Dockerfile.jdk8 @@ -2,6 +2,9 @@ FROM gradle:8.5.0-jdk8 COPY gradle /bin/runner +RUN chown -R 1001:1001 /home/gradle +ENV GRADLE_USER_HOME /home/gradle + USER 1001 ENTRYPOINT ["/bin/runner"] From f4ffbcf9c07926417c04c00da0592d72f4ab68bc Mon Sep 17 00:00:00 2001 From: Dejan Zele Pejchev Date: Fri, 19 Jan 2024 13:02:44 +0100 Subject: [PATCH 021/234] add support for skipping cert verification of presigned put urls (#4915) --- pkg/cloud/data/artifact/scraper_integration_test.go | 4 ++-- pkg/cloud/data/artifact/uploader.go | 12 +++++++++--- pkg/cloud/data/artifact/uploader_test.go | 4 ++-- pkg/executor/scraper/factory/factory.go | 6 +++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pkg/cloud/data/artifact/scraper_integration_test.go b/pkg/cloud/data/artifact/scraper_integration_test.go index 0eda10921bb..8c132aaddef 100644 --- a/pkg/cloud/data/artifact/scraper_integration_test.go +++ b/pkg/cloud/data/artifact/scraper_integration_test.go @@ -61,7 +61,7 @@ func TestCloudScraper_ArchiveFilesystemExtractor_Integration(t *testing.T) { defer testServer.Close() mockExecutor := executor.NewMockExecutor(mockCtrl) - cloudLoader := cloudscraper.NewCloudUploader(mockExecutor) + cloudLoader := cloudscraper.NewCloudUploader(mockExecutor, false) req := &cloudscraper.PutObjectSignedURLRequest{ Object: "artifacts.tar.gz", ExecutionID: "my-execution-id", @@ -148,7 +148,7 @@ func TestCloudScraper_RecursiveFilesystemExtractor_Integration(t *testing.T) { defer testServer.Close() mockExecutor := executor.NewMockExecutor(mockCtrl) - cloudLoader := cloudscraper.NewCloudUploader(mockExecutor) + cloudLoader := cloudscraper.NewCloudUploader(mockExecutor, false) req1 := &cloudscraper.PutObjectSignedURLRequest{ Object: "file1.txt", ExecutionID: "my-execution-id", diff --git a/pkg/cloud/data/artifact/uploader.go b/pkg/cloud/data/artifact/uploader.go index fa1f522de1c..b7a34c70805 100644 --- a/pkg/cloud/data/artifact/uploader.go +++ b/pkg/cloud/data/artifact/uploader.go @@ -2,6 +2,7 @@ package artifact import ( "context" + "crypto/tls" "encoding/json" "io" "net/http" @@ -18,10 +19,12 @@ import ( type CloudUploader struct { executor executor.Executor + // skipVerify is used to skip TLS verification when artifacts + skipVerify bool } -func NewCloudUploader(executor executor.Executor) *CloudUploader { - return &CloudUploader{executor: executor} +func NewCloudUploader(executor executor.Executor, skipVerify bool) *CloudUploader { + return &CloudUploader{executor: executor, skipVerify: skipVerify} } func (u *CloudUploader) Upload(ctx context.Context, object *scraper.Object, execution testkube.Execution) error { @@ -63,7 +66,10 @@ func (u *CloudUploader) putObject(ctx context.Context, url string, data io.Reade return err } req.Header.Set("Content-Type", "application/octet-stream") - rsp, err := http.DefaultClient.Do(req) + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: u.skipVerify} + client := &http.Client{Transport: tr} + rsp, err := client.Do(req) if err != nil { return errors.Wrap(err, "failed to send file to cloud") } diff --git a/pkg/cloud/data/artifact/uploader_test.go b/pkg/cloud/data/artifact/uploader_test.go index 3b10730ba6f..2a78448920d 100644 --- a/pkg/cloud/data/artifact/uploader_test.go +++ b/pkg/cloud/data/artifact/uploader_test.go @@ -60,7 +60,7 @@ func TestCloudLoader_Load(t *testing.T) { } mockExecutor.EXPECT().Execute(gomock.Any(), cloudscraper.CmdScraperPutObjectSignedURL, gomock.Eq(req)).Return([]byte(`{"URL":"`+testServer.URL+`/dummy"}`), nil).Times(1) - return cloudscraper.NewCloudUploader(mockExecutor) + return cloudscraper.NewCloudUploader(mockExecutor, false) }, putErr: nil, wantErr: false, @@ -82,7 +82,7 @@ func TestCloudLoader_Load(t *testing.T) { } mockExecutor.EXPECT().Execute(gomock.Any(), cloudscraper.CmdScraperPutObjectSignedURL, gomock.Eq(req)).Return(nil, errors.New("connection error")).Times(1) - return cloudscraper.NewCloudUploader(mockExecutor) + return cloudscraper.NewCloudUploader(mockExecutor, false) }, wantErr: true, errContains: "failed to get signed URL for object [my-object]: connection error", diff --git a/pkg/executor/scraper/factory/factory.go b/pkg/executor/scraper/factory/factory.go index e9b95968a7e..dc402468e2b 100644 --- a/pkg/executor/scraper/factory/factory.go +++ b/pkg/executor/scraper/factory/factory.go @@ -100,8 +100,8 @@ func getRemoteStorageUploader(ctx context.Context, params envs.Params) (uploader defer cancel() output.PrintLogf( - "%s Uploading artifacts using Remote Storage Uploader (timeout:%ds, insecure:%v, skipVerify: %v, url: %s)", - ui.IconCheckMark, params.CloudConnectionTimeoutSec, params.CloudAPITLSInsecure, params.CloudAPISkipVerify, params.CloudAPIURL) + "%s Uploading artifacts using Remote Storage Uploader (timeout:%ds, agentInsecure:%v, agentSkipVerify: %v, url: %s, scraperSkipVerify: %v)", + ui.IconCheckMark, params.CloudConnectionTimeoutSec, params.CloudAPITLSInsecure, params.CloudAPISkipVerify, params.CloudAPIURL, params.SkipVerify) grpcConn, err := agent.NewGRPCConnection(ctxTimeout, params.CloudAPITLSInsecure, params.CloudAPISkipVerify, params.CloudAPIURL, log.DefaultLogger) if err != nil { return nil, err @@ -110,7 +110,7 @@ func getRemoteStorageUploader(ctx context.Context, params envs.Params) (uploader grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) cloudExecutor := cloudexecutor.NewCloudGRPCExecutor(grpcClient, grpcConn, params.CloudAPIKey) - return cloudscraper.NewCloudUploader(cloudExecutor), nil + return cloudscraper.NewCloudUploader(cloudExecutor, params.SkipVerify), nil } func getMinIOUploader(params envs.Params) (*scraper.MinIOUploader, error) { From 9f5ccc5888e83a9f0ebf44cf5b8748e93f864587 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 22 Jan 2024 08:31:39 +0100 Subject: [PATCH 022/234] fix: trigger event for logs start (#4916) * feat: trigger logs startstop events * fix: single NATS construct func --- cmd/api-server/main.go | 26 +++++-------- cmd/logs/main.go | 11 +++++- cmd/sidecar/main.go | 10 ++++- internal/config/config.go | 1 + pkg/event/bus/nats.go | 56 +++++++++++++++++++++++---- pkg/event/emitter_integration_test.go | 5 ++- pkg/logs/config/logs_config.go | 20 +++++++--- pkg/scheduler/service.go | 4 +- pkg/scheduler/test_scheduler.go | 46 ++++++++++++++++++++++ pkg/triggers/executor_test.go | 2 +- pkg/triggers/service_test.go | 2 +- 11 files changed, 145 insertions(+), 38 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 86cd6ff8eb1..b7aa3fc7402 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/tls" "encoding/json" "flag" "fmt" @@ -634,22 +633,15 @@ func parseDefaultExecutors(cfg *config.Config) (executors []testkube.ExecutorDet } func newNATSConnection(cfg *config.Config) (*nats.EncodedConn, error) { - var opts []nats.Option - if cfg.NatsSecure { - if cfg.NatsSkipVerify { - opts = append(opts, nats.Secure(&tls.Config{InsecureSkipVerify: true})) - } else { - opts = append(opts, nats.ClientCert(cfg.NatsCertFile, cfg.NatsKeyFile)) - if cfg.NatsCAFile != "" { - opts = append(opts, nats.RootCAs(cfg.NatsCAFile)) - } - } - } - nc, err := bus.NewNATSEncoddedConnection(cfg.NatsURI, opts...) - if err != nil { - log.DefaultLogger.Errorw("error creating NATS connection", "error", err) - } - return nc, nil + return bus.NewNATSEncodedConnection(bus.ConnectionConfig{ + NatsURI: cfg.NatsURI, + NatsSecure: cfg.NatsSecure, + NatsSkipVerify: cfg.NatsSkipVerify, + NatsCertFile: cfg.NatsCertFile, + NatsKeyFile: cfg.NatsKeyFile, + NatsCAFile: cfg.NatsCAFile, + NatsConnectTimeout: cfg.NatsConnectTimeout, + }) } func newStorageClient(cfg *config.Config) *minio.Client { diff --git a/cmd/logs/main.go b/cmd/logs/main.go index 3f23ba3a2e4..c642ad5d467 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "os" "os/signal" "syscall" @@ -30,7 +31,15 @@ func main() { cfg := Must(config.Get()) // Event bus - nc := Must(bus.NewNATSConnection(cfg.NatsURI)) + nc := Must(bus.NewNATSConnection(bus.ConnectionConfig{ + NatsURI: cfg.NatsURI, + NatsSecure: cfg.NatsSecure, + NatsSkipVerify: cfg.NatsSkipVerify, + NatsCertFile: cfg.NatsCertFile, + NatsKeyFile: cfg.NatsKeyFile, + NatsCAFile: cfg.NatsCAFile, + NatsConnectTimeout: cfg.NatsConnectTimeout, + })) defer func() { log.Infof("closing nats connection") nc.Close() diff --git a/cmd/sidecar/main.go b/cmd/sidecar/main.go index fab7ec5f0ff..6f069b8c20b 100644 --- a/cmd/sidecar/main.go +++ b/cmd/sidecar/main.go @@ -23,7 +23,15 @@ func main() { cfg := Must(config.Get()) // Event bus - nc := Must(bus.NewNATSConnection(cfg.NatsURI)) + nc := Must(bus.NewNATSConnection(bus.ConnectionConfig{ + NatsURI: cfg.NatsURI, + NatsSecure: cfg.NatsSecure, + NatsSkipVerify: cfg.NatsSkipVerify, + NatsCertFile: cfg.NatsCertFile, + NatsKeyFile: cfg.NatsKeyFile, + NatsCAFile: cfg.NatsCAFile, + NatsConnectTimeout: cfg.NatsConnectTimeout, + })) defer func() { log.Infof("closing nats connection") nc.Close() diff --git a/internal/config/config.go b/internal/config/config.go index 01d1f9c9bb7..910796c3755 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ type Config struct { NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` + NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` JobServiceAccountName string `envconfig:"JOB_SERVICE_ACCOUNT_NAME" default:""` JobTemplateFile string `envconfig:"JOB_TEMPLATE_FILE" default:""` DisableTestTriggers bool `envconfig:"DISABLE_TEST_TRIGGERS" default:"false"` diff --git a/pkg/event/bus/nats.go b/pkg/event/bus/nats.go index 87e8dbd852a..e1ff85cdbcf 100644 --- a/pkg/event/bus/nats.go +++ b/pkg/event/bus/nats.go @@ -1,8 +1,10 @@ package bus import ( + "crypto/tls" "fmt" "sync" + "time" "github.com/nats-io/nats.go" @@ -22,18 +24,40 @@ const ( InternalSubscribeTopic = "internal.>" ) -func NewNATSConnection(uri string, opts ...nats.Option) (*nats.Conn, error) { - nc, err := nats.Connect(uri, opts...) - if err != nil { - log.DefaultLogger.Fatalw("error connecting to nats", "error", err) - return nil, err +type ConnectionConfig struct { + NatsURI string + NatsSecure bool + NatsSkipVerify bool + NatsCertFile string + NatsKeyFile string + NatsCAFile string + NatsConnectTimeout time.Duration +} + +func optsFromConfig(cfg ConnectionConfig) (opts []nats.Option) { + opts = []nats.Option{} + if cfg.NatsSecure { + if cfg.NatsSkipVerify { + opts = append(opts, nats.Secure(&tls.Config{InsecureSkipVerify: true})) + } else { + opts = append(opts, nats.ClientCert(cfg.NatsCertFile, cfg.NatsKeyFile)) + if cfg.NatsCAFile != "" { + opts = append(opts, nats.RootCAs(cfg.NatsCAFile)) + } + } } - return nc, nil + if cfg.NatsConnectTimeout > 0 { + opts = append(opts, nats.Timeout(cfg.NatsConnectTimeout)) + } + + return opts } -func NewNATSEncoddedConnection(uri string, opts ...nats.Option) (*nats.EncodedConn, error) { - nc, err := nats.Connect(uri, opts...) +func NewNATSEncodedConnection(cfg ConnectionConfig, opts ...nats.Option) (*nats.EncodedConn, error) { + opts = append(opts, optsFromConfig(cfg)...) + + nc, err := nats.Connect(cfg.NatsURI, opts...) if err != nil { log.DefaultLogger.Fatalw("error connecting to nats", "error", err) return nil, err @@ -46,9 +70,25 @@ func NewNATSEncoddedConnection(uri string, opts ...nats.Option) (*nats.EncodedCo return nil, err } + if err != nil { + log.DefaultLogger.Errorw("error creating NATS connection", "error", err) + } + return ec, nil } +func NewNATSConnection(cfg ConnectionConfig, opts ...nats.Option) (*nats.Conn, error) { + opts = append(opts, optsFromConfig(cfg)...) + + nc, err := nats.Connect(cfg.NatsURI, opts...) + if err != nil { + log.DefaultLogger.Fatalw("error connecting to nats", "error", err) + return nil, err + } + + return nc, nil +} + func NewNATSBus(nc *nats.EncodedConn) *NATSBus { return &NATSBus{ nc: nc, diff --git a/pkg/event/emitter_integration_test.go b/pkg/event/emitter_integration_test.go index a15a5e1c23d..b699e5ede6e 100644 --- a/pkg/event/emitter_integration_test.go +++ b/pkg/event/emitter_integration_test.go @@ -20,7 +20,10 @@ import ( func GetTestNATSEmitter() *Emitter { os.Setenv("DEBUG", "true") // configure NATS event bus - nc, err := bus.NewNATSEncoddedConnection("http://localhost:4222") + nc, err := bus.NewNATSEncodedConnection(bus.ConnectionConfig{ + NatsURI: "http://localhost:4222", + }) + if err != nil { panic(err) } diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index 3296297d520..d776cdf7181 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -1,16 +1,24 @@ package config import ( + "time" + "github.com/kelseyhightower/envconfig" ) type Config struct { - NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` - Namespace string `envconfig:"NAMESPACE" default:"testkube"` - ExecutionId string `envconfig:"ID" default:""` - HttpAddress string `envconfig:"HTTP_ADDRESS" default:":8080"` - GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` - KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` + NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` + NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` + NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` + NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` + NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` + NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` + NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` + Namespace string `envconfig:"NAMESPACE" default:"testkube"` + ExecutionId string `envconfig:"ID" default:""` + HttpAddress string `envconfig:"HTTP_ADDRESS" default:":8080"` + GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` + KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` } func Get() (*Config, error) { diff --git a/pkg/scheduler/service.go b/pkg/scheduler/service.go index 349c1d8c688..69818463630 100644 --- a/pkg/scheduler/service.go +++ b/pkg/scheduler/service.go @@ -41,7 +41,7 @@ type Scheduler struct { eventsBus bus.Bus dashboardURI string featureFlags featureflags.FeatureFlags - logsStream logsclient.InitializedStreamPusher + logsStream logsclient.Stream } func NewScheduler( @@ -63,7 +63,7 @@ func NewScheduler( eventsBus bus.Bus, dashboardURI string, featureFlags featureflags.FeatureFlags, - logsStream logsclient.InitializedStreamPusher, + logsStream logsclient.Stream, ) *Scheduler { return &Scheduler{ metrics: metrics, diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 11aaa82bbf8..630388b3409 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -58,6 +58,16 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request // test name + test execution name should be unique execution, _ = s.testResults.GetByNameAndTest(ctx, request.Name, test.Name) + + // for logs.v2 service trigger start / stop events + if s.featureFlags.LogsV2 { + err := s.triggerLogsStartEvent(ctx, execution.Id) + if err != nil { + return execution, err + } + defer s.triggerLogsStopEvent(ctx, execution.Id) + } + if execution.Name == request.Name { err := errors.Errorf("test execution with name %s already exists", request.Name) return s.handleExecutionError(ctx, execution, "duplicate execution: %w", err) @@ -127,6 +137,7 @@ func (s *Scheduler) handleExecutionError(ctx context.Context, execution testkube WithSource("test-scheduler") s.logsStream.Push(ctx, execution.Id, *l) + } // notify events that execution failed @@ -808,3 +819,38 @@ func mergeSlavePodRequests(podBase *testkube.PodRequest, podAdjust *testkube.Pod return podBase } + +func (s *Scheduler) triggerLogsStartEvent(ctx context.Context, id string) error { + if s.featureFlags.LogsV2 { + r, err := s.logsStream.Start(ctx, id) + if err != nil { + return err + } + + if r.Error { + return errors.New(string(r.Message)) + } + + s.logger.Infow("triggering logs start event", "id", id) + } + + return nil +} + +func (s *Scheduler) triggerLogsStopEvent(ctx context.Context, id string) error { + if s.featureFlags.LogsV2 { + r, err := s.logsStream.Stop(ctx, id) + if err != nil { + s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) + return err + } + + if r.Error { + s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) + return err + } + + s.logger.Infow("triggering logs stop event", "id", id) + } + return nil +} diff --git a/pkg/triggers/executor_test.go b/pkg/triggers/executor_test.go index dce098cbd69..3c136a7e615 100644 --- a/pkg/triggers/executor_test.go +++ b/pkg/triggers/executor_test.go @@ -104,7 +104,7 @@ func TestExecute(t *testing.T) { mockExecutor.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return(&mockExecutionResult, nil) mockResultRepository.EXPECT().UpdateResult(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) - mockLogsStream := logsclient.NewMockInitializedStreamPusher(mockCtrl) + mockLogsStream := logsclient.NewMockStream(mockCtrl) sched := scheduler.NewScheduler( metricsHandle, diff --git a/pkg/triggers/service_test.go b/pkg/triggers/service_test.go index 7c847f32e4d..b0b65fa04b6 100644 --- a/pkg/triggers/service_test.go +++ b/pkg/triggers/service_test.go @@ -117,7 +117,7 @@ func TestService_Run(t *testing.T) { testLogger := log.DefaultLogger - mockLogsStream := logsclient.NewMockInitializedStreamPusher(mockCtrl) + mockLogsStream := logsclient.NewMockStream(mockCtrl) sched := scheduler.NewScheduler( testMetrics, From f0df49fcce5c2b9f9876f860ed273462dcef0e50 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 22 Jan 2024 09:44:02 +0100 Subject: [PATCH 023/234] fix: use parametrized nats connection when creating encoded one (#4918) * feat: trigger logs startstop events * fix: single NATS construct func * fix: added nats conn with valid opts --- pkg/event/bus/nats.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/event/bus/nats.go b/pkg/event/bus/nats.go index e1ff85cdbcf..484b9b4a14c 100644 --- a/pkg/event/bus/nats.go +++ b/pkg/event/bus/nats.go @@ -57,7 +57,7 @@ func optsFromConfig(cfg ConnectionConfig) (opts []nats.Option) { func NewNATSEncodedConnection(cfg ConnectionConfig, opts ...nats.Option) (*nats.EncodedConn, error) { opts = append(opts, optsFromConfig(cfg)...) - nc, err := nats.Connect(cfg.NatsURI, opts...) + nc, err := NewNATSConnection(cfg, opts...) if err != nil { log.DefaultLogger.Fatalw("error connecting to nats", "error", err) return nil, err From d9c77cd7b281c8a784ef1925c6c18c3cd38ace17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:43:52 +0100 Subject: [PATCH 024/234] build: bump follow-redirects from 1.15.1 to 1.15.4 in /docs (#4868) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.1 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.1...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 7c5ed49d3d0..5e36284d6ba 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6712,16 +6712,15 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", - "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -18513,9 +18512,9 @@ } }, "follow-redirects": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", - "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "foreach": { "version": "2.0.6", From 59253612df15c1e20254ab5af413583bf192515d Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 19 Jan 2024 19:25:53 +0300 Subject: [PATCH 025/234] fix: support missing run path --- contrib/executor/k6/pkg/runner/runner.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/contrib/executor/k6/pkg/runner/runner.go b/contrib/executor/k6/pkg/runner/runner.go index e80b203bb93..854f91671a0 100644 --- a/contrib/executor/k6/pkg/runner/runner.go +++ b/contrib/executor/k6/pkg/runner/runner.go @@ -110,6 +110,7 @@ func (r *K6Runner) Run(ctx context.Context, execution testkube.Execution) (resul // in case of Git directory we will run k6 here and // use the last argument as test file + changedArgs := false if execution.Content.Type_ == string(testkube.TestContentTypeGitFile) || execution.Content.Type_ == string(testkube.TestContentTypeGitDir) || execution.Content.Type_ == string(testkube.TestContentTypeGit) { @@ -130,7 +131,7 @@ func (r *K6Runner) Run(ctx context.Context, execution testkube.Execution) (resul if fileInfo.IsDir() { testPath = filepath.Join(path, args[len(args)-1]) args = append(args[:len(args)-1], args[len(args):]...) - + changedArgs = true } else { testPath = path } @@ -144,6 +145,7 @@ func (r *K6Runner) Run(ctx context.Context, execution testkube.Execution) (resul } } + hasRunPath := false for i := range args { if args[i] == "" { args[i] = k6Command @@ -151,9 +153,14 @@ func (r *K6Runner) Run(ctx context.Context, execution testkube.Execution) (resul if args[i] == "" { args[i] = testPath + hasRunPath = true } } + if changedArgs && !hasRunPath { + args = append(args, testPath) + } + for i := range args { if args[i] == "" { newArgs := make([]string, len(args)+len(envVars)-1) From 21b498f162d00d30cc625a55eef1da3058ad776c Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 22 Jan 2024 15:18:22 +0100 Subject: [PATCH 026/234] fix: execution id not passed to events (#4920) --- pkg/logs/events.go | 2 +- pkg/scheduler/test_scheduler.go | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/pkg/logs/events.go b/pkg/logs/events.go index 18278bfa1ce..33f2719a5a9 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -199,7 +199,7 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { if len(toDelete) == 0 { ls.state.Put(ctx, event.Id, state.LogStateFinished) - l.Infow("all logs consumers stopped", "id", event.Id) + l.Infow("execution logs consumers stopped", "id", event.Id) err = msg.Respond([]byte("stopped")) if err != nil { diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 630388b3409..ee37d7cff00 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -54,20 +54,9 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request request.Name = fmt.Sprintf("%s-%d", request.Name, request.Number) } - s.events.Notify(testkube.NewEventStartTest(&execution)) - // test name + test execution name should be unique execution, _ = s.testResults.GetByNameAndTest(ctx, request.Name, test.Name) - // for logs.v2 service trigger start / stop events - if s.featureFlags.LogsV2 { - err := s.triggerLogsStartEvent(ctx, execution.Id) - if err != nil { - return execution, err - } - defer s.triggerLogsStopEvent(ctx, execution.Id) - } - if execution.Name == request.Name { err := errors.Errorf("test execution with name %s already exists", request.Name) return s.handleExecutionError(ctx, execution, "duplicate execution: %w", err) @@ -89,6 +78,18 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request execution = newExecutionFromExecutionOptions(options) options.ID = execution.Id + // TODO consider using single event for test start and logs + s.events.Notify(testkube.NewEventStartTest(&execution)) + + // for logs.v2 service trigger start / stop events + if s.featureFlags.LogsV2 { + err := s.triggerLogsStartEvent(ctx, execution.Id) + if err != nil { + return execution, err + } + defer s.triggerLogsStopEvent(ctx, execution.Id) + } + if err := s.createSecretsReferences(&execution); err != nil { return s.handleExecutionError(ctx, execution, "can't create secret variables `Secret` references: %w", err) } @@ -98,7 +99,7 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request return s.handleExecutionError(ctx, execution, "can't create new test execution, can't insert into storage: %w", err) } - s.logger.Infow("calling executor with options", "options", options.Request) + s.logger.Infow("calling executor with options", "executionId", execution.Id, "options", options.Request) execution.Start() From 80c9807e143f5ac524efbf1377d5f7e84d9bd657 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 23 Jan 2024 08:22:07 +0100 Subject: [PATCH 027/234] fix: added cooldown time to not stop events too early (#4921) * fix: execution id not passed to events * fix: async stop * fix: rollback to debug on message * fix: context aware stop wait time * fix: tests fixed with stop wait time --- pkg/logs/events.go | 20 ++++++++++++++++---- pkg/logs/events_test.go | 12 +++++++++--- pkg/logs/service.go | 12 ++++++++++++ pkg/scheduler/test_scheduler.go | 23 +++++++++++++---------- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/pkg/logs/events.go b/pkg/logs/events.go index 33f2719a5a9..2e92aae3da1 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -147,6 +147,13 @@ func (ls *LogsService) handleStart(ctx context.Context) func(msg *nats.Msg) { // handleStop will handle stop event and stop logs consumers, also clean consumers state func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { return func(msg *nats.Msg) { + ls.log.Debugw("got stop event") + + t := time.NewTicker(ls.stopWaitTime) + select { + case <-t.C: + case <-ctx.Done(): + } event := events.Trigger{} err := json.Unmarshal(msg.Data, &event) @@ -161,6 +168,7 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { repeated := 0 toDelete := []string{} + deleted := false for _, adapter := range ls.adapters { toDelete = append(toDelete, event.Id+"_"+adapter.Name()) } @@ -174,7 +182,7 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { // load consumer and check if has pending messages c, found := ls.consumerInstances.Load(name) if !found { - l.Warnw("consumer not found", "found", found, "name", name) + l.Debugw("consumer not found on this pod", "found", found, "name", name) toDelete = append(toDelete[:i], toDelete[i+1:]...) goto loop // rewrite toDelete and start again } @@ -189,6 +197,9 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { // finally delete consumer if info.NumPending == 0 { + if !deleted { + deleted = true + } consumer.Context.Stop() ls.consumerInstances.Delete(name) toDelete = append(toDelete[:i], toDelete[i+1:]...) @@ -197,16 +208,17 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { } } - if len(toDelete) == 0 { + if len(toDelete) == 0 && !deleted { + l.Debugw("no consumers on this pod registered for id", "id", event.Id) + return + } else if len(toDelete) == 0 { ls.state.Put(ctx, event.Id, state.LogStateFinished) l.Infow("execution logs consumers stopped", "id", event.Id) - err = msg.Respond([]byte("stopped")) if err != nil { l.Errorw("error responding to stop event", "error", err) return } - return } diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 879dcb06c87..68c084d644a 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -17,6 +17,8 @@ import ( "github.com/kubeshop/testkube/pkg/logs/state" ) +var waitTime = time.Millisecond * 100 + func TestLogs_EventsFlow(t *testing.T) { t.Parallel() @@ -43,7 +45,8 @@ func TestLogs_EventsFlow(t *testing.T) { // and initialized log service log := NewLogsService(nc, js, state). - WithRandomPort() + WithRandomPort(). + WithStopWaitTime(waitTime) // given example adapters a := NewMockAdapter("aaa") @@ -76,6 +79,7 @@ func TestLogs_EventsFlow(t *testing.T) { // and when data pushed to the log stream stream.Push(ctx, "stop-test", events.NewLogResponse(time.Now(), []byte("hello 1"))) + stream.Push(ctx, "stop-test", events.NewLogResponse(time.Now(), []byte("hello 2"))) // and stop event triggered _, err = stream.Stop(ctx, "stop-test") @@ -108,7 +112,8 @@ func TestLogs_EventsFlow(t *testing.T) { // and initialized log service log := NewLogsService(nc, js, state). - WithRandomPort() + WithRandomPort(). + WithStopWaitTime(waitTime) // given example adapter a := NewMockAdapter() @@ -179,7 +184,8 @@ func TestLogs_EventsFlow(t *testing.T) { // and initialized log service log := NewLogsService(nc, js, state). - WithRandomPort() + WithRandomPort(). + WithStopWaitTime(waitTime) // given example adapters a := NewMockAdapter("aaa") diff --git a/pkg/logs/service.go b/pkg/logs/service.go index 98013158067..b710d4f9b9a 100644 --- a/pkg/logs/service.go +++ b/pkg/logs/service.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "sync" + "time" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -27,6 +28,8 @@ import ( const ( DefaultHttpAddress = ":8080" DefaultGrpcAddress = ":9090" + + DefaultStopWaitTime = 60 * time.Second // when stop event is faster than first message arrived ) func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interface) *LogsService { @@ -40,6 +43,7 @@ func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interfa grpcAddress: DefaultGrpcAddress, consumerInstances: sync.Map{}, state: state, + stopWaitTime: DefaultStopWaitTime, } } @@ -70,6 +74,9 @@ type LogsService struct { // will allow to distiguish from where load data from in OSS // cloud will be loading always them locally state state.Interface + + // stop wait time for messages cool down + stopWaitTime time.Duration } // AddAdapter adds new adapter to logs service adapters will be configred based on given mode @@ -142,6 +149,11 @@ func (ls *LogsService) WithGrpcAddress(address string) *LogsService { return ls } +func (ls *LogsService) WithStopWaitTime(duration time.Duration) *LogsService { + ls.stopWaitTime = duration + return ls +} + func (ls *LogsService) WithRandomPort() *LogsService { port := rand.Intn(1000) + 17000 ls.httpAddress = fmt.Sprintf("127.0.0.1:%d", port) diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index ee37d7cff00..43ca934cccc 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -840,18 +840,21 @@ func (s *Scheduler) triggerLogsStartEvent(ctx context.Context, id string) error func (s *Scheduler) triggerLogsStopEvent(ctx context.Context, id string) error { if s.featureFlags.LogsV2 { - r, err := s.logsStream.Stop(ctx, id) - if err != nil { - s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) - return err - } + // as Stop is synchro + go func() { + r, err := s.logsStream.Stop(ctx, id) + if err != nil { + s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) + return + } - if r.Error { - s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) - return err - } + if r.Error { + s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) + return + } - s.logger.Infow("triggering logs stop event", "id", id) + s.logger.Infow("triggering logs stop event", "id", id) + }() } return nil } From b56be732cd6865eb7ba471b3fce831b8c31f9bc9 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 23 Jan 2024 08:22:23 +0100 Subject: [PATCH 028/234] fix: smaller docker file (#4919) --- build/sidecar/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/sidecar/Dockerfile b/build/sidecar/Dockerfile index 8642df1803b..a2618e91bb7 100644 --- a/build/sidecar/Dockerfile +++ b/build/sidecar/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 ARG ALPINE_IMAGE FROM ${ALPINE_IMAGE} -RUN apk --no-cache add ca-certificates libssl1.1 git skopeo +RUN apk --no-cache add ca-certificates libssl1.1 WORKDIR /root/ COPY testkube-logs-sidecar /bin/app USER 1001 From e6286c278227cb06fa661daad5895eb2d1b2d029 Mon Sep 17 00:00:00 2001 From: Catalin <20538711+devcatalin@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:41:46 +0200 Subject: [PATCH 029/234] docs: add CircleCI article (#4885) * docs: add CircleCI article * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/circleci.md Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --- docs/docs/articles/cicd-overview.md | 1 + docs/docs/articles/circleci.md | 197 ++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 docs/docs/articles/circleci.md diff --git a/docs/docs/articles/cicd-overview.md b/docs/docs/articles/cicd-overview.md index 32aa3d1a3aa..45142cbd97d 100644 --- a/docs/docs/articles/cicd-overview.md +++ b/docs/docs/articles/cicd-overview.md @@ -8,6 +8,7 @@ We have different tutorials for the options of being CI driven or using GitOps a - [Github Actions - running Testkube CLI commands with setup-testkube-action](./github-actions.md) - [Testkube Docker CLI](./testkube-cli-docker.md) - [Gitlab CI](./gitlab.md) +- [CircleCI](./circleci.md) - [GitOps Testing](./gitops-overview.md) - [Flux](./flux-integration.md) - [ArgoCD](./argocd-integration.md) diff --git a/docs/docs/articles/circleci.md b/docs/docs/articles/circleci.md new file mode 100644 index 00000000000..02236401c71 --- /dev/null +++ b/docs/docs/articles/circleci.md @@ -0,0 +1,197 @@ +# Testkube CircleCI + +The Testkube CircleCI integration facilitates the installation of Testkube and allows the execution of any [Testkube CLI](https://docs.testkube.io/cli/testkube) command within a CircleCI pipeline. This integration can be seamlessly incorporated into your CircleCI repositories to enhance your CI/CD workflows. +The integration offers a versatile approach to align with your pipeline requirements and is compatible with Testkube Pro, Testkube Enterprise, and the open-source Testkube platform. It enables CircleCI users to leverage the powerful features of Testkube directly within their CI/CD pipelines, ensuring efficient and flexible test execution. + +## Testkube Pro + +### How to configure Testkube CLI action for Testkube Pro and run a test + +To use CircleCI for [Testkube Pro](https://app.testkube.io/), you need to create an [API token](https://docs.testkube.io/testkube-pro/articles/organization-management/#api-tokens). +Then, pass the **organization** and **environment** IDs, along with the **token** and other parameters specific for your use case. + +If a test is already created, you can run it using the command `testkube run test test-name -f` . However, if you need to create a test in this workflow, please add a creation command, e.g.: `testkube create test --name test-name --file path_to_file.json`. + +```yaml +version: 2.1 + +jobs: + run-tests: + docker: + - image: kubeshop/testkube-cli + working_directory: /.testkube + environment: + TESTKUBE_API_KEY: tkcapi_0123456789abcdef0123456789abcd + TESTKUBE_ORG_ID: tkcorg_0123456789abcdef + TESTKUBE_ENV_ID: tkcenv_fedcba9876543210 + steps: + - run: + name: "Set Testkube Context" + command: "testkube set context --api-key $TESTKUBE_API_KEY --org $TESTKUBE_ORG_ID --env $TESTKUBE_ENV_ID --cloud-root-domain testkube.dev" + - run: + name: "Trigger testkube test" + command: "testkube run test test-name -f" + +workflows: + run-tests-workflow: + jobs: + - run-tests +``` + +It is recommended that sensitive values should never be stored as plaintext in workflow files, but rather as [project variables](https://circleci.com/docs/set-environment-variable/#set-an-environment-variable-in-a-project). Secrets can be configured at the organization or project level and allow you to store sensitive information in CircleCI. + +```yaml +version: 2.1 + +jobs: + run-tests: + docker: + - image: kubeshop/testkube-cli + working_directory: /.testkube + steps: + - run: + name: "Set Testkube Context" + command: "testkube set context --api-key $TESTKUBE_API_KEY --org $TESTKUBE_ORG_ID --env $TESTKUBE_ENV_ID --cloud-root-domain testkube.dev" + - run: + name: "Trigger testkube test" + command: "testkube run test test-name -f" + +workflows: + run-tests-workflow: + jobs: + - run-tests +``` +## Testkube OSS + +### How to configure Testkube CLI action for TK OSS and run a test + +To connect to the self-hosted instance, you need to have **kubectl** configured for accessing your Kubernetes cluster and pass an optional namespace, if Testkube is not deployed in the default **testkube** namespace. + +If a test is already created, you can run it using the command `testkube run test test-name -f` . However, if you need to create a test in this workflow, please add a creation command, e.g.: `testkube create test --name test-name --file path_to_file.json`. + +In order to connect to your own cluster, you can put your kubeconfig file into CircleCI variable named KUBECONFIGFILE. + +```yaml +version: 2.1 + +jobs: + run-tests: + docker: + - image: kubeshop/testkube-cli + working_directory: /.testkube + steps: + - run: + name: "Export kubeconfig" + command: | + echo $KUBECONFIGFILE > /.testkube/tmp/kubeconfig/config + export KUBECONFIG=/.testkube/tmp/kubeconfig/config + - run: + name: "Set Testkube Context" + command: "testkube set context --api-key $TESTKUBE_API_KEY --org $TESTKUBE_ORG_ID --env $TESTKUBE_ENV_ID --cloud-root-domain testkube.dev" + - run: + name: "Trigger testkube test" + command: "testkube run test test-name -f" + +workflows: + run-tests-workflow: + jobs: + - run-tests +``` + +The steps to connect to your Kubernetes cluster differ for each provider. You should check the docs of your Cloud provider for how to connect to the Kubernetes cluster from CircleCI. + +### How to configure Testkube CLI action for TK OSS and run a test + +This workflow establishes a connection to the EKS cluster and creates and runs a test using TK CLI. In this example we also use CircleCI variables not to reveal sensitive data. Please make sure that the following points are satisfied: +- The **_AwsAccessKeyId_**, **_AwsSecretAccessKeyId_** secrets should contain your AWS IAM keys with proper permissions to connect to EKS cluster. +- The **_AwsRegion_** secret should contain the AWS region where EKS is. +- Tke **EksClusterName** secret points to the name of the EKS cluster you want to connect. + +```yaml +version: 2.1 + +jobs: + setup-aws: + docker: + - image: amazon/aws-cli + steps: + - run: + name: "Configure AWS CLI" + command: | + mkdir -p /.testkube/tmp/kubeconfig/config + aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID + aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY + aws configure set region $AWS_REGION + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --region $AWS_REGION --kubeconfig /.testkube/tmp/kubeconfig/config + + run-testkube-on-aws: + docker: + - image: kubeshop/testkube-cli + working_directory: /.testkube + environment: + NAMESPACE: custom-testkube + steps: + - run: + name: "Run Testkube Test on EKS" + command: | + export KUBECONFIG=/.testkube/tmp/kubeconfig/config + testkube set context --kubeconfig --namespace $NAMESPACE + echo "Running Testkube test..." + testkube run test test-name -f + +workflows: + aws-testkube-workflow: + jobs: + - setup-aws + - run-testkube-on-aws: + requires: + - setup-aws +``` + +### How to connect to GKE (Google Kubernetes Engine) cluster and run a test + +This example connects to a k8s cluster in Google Cloud then creates and runs a test using Testkube CircleCI. Please make sure that the following points are satisfied: +- The **_GKE Sevice Account_** should already be created in Google Cloud and added to CircleCI variables along with **_GKE Project_** value. +- The **_GKE Cluster Name_** and **_GKE Zone_** can be added as environment variables in the workflow. + + +```yaml +version: 2.1 + +jobs: + setup-gcp: + docker: + - image: google/cloud-sdk:latest + working_directory: /.testkube + steps: + - run: + name: "Setup GCP" + command: | + mkdir -p /.testkube/tmp/kubeconfig/config + export KUBECONFIG=$CI_PROJECT_DIR/tmp/kubeconfig/config + echo $GKE_SA_KEY | base64 -d > gke-sa-key.json + gcloud auth activate-service-account --key-file=gke-sa-key.json + gcloud config set project $GKE_PROJECT + gcloud --quiet auth configure-docker + gcloud container clusters get-credentials $GKE_CLUSTER_NAME --zone $GKE_ZONE + + run-testkube-on-gcp: + docker: + - image: kubeshop/testkube-cli + working_directory: /.testkube + steps: + - run: + name: "Run Testkube Test on GKE" + command: | + export KUBECONFIG=/.testkube/tmp/kubeconfig/config + testkube set context --kubeconfig --namespace $NAMESPACE + testkube run test test-name -f + +workflows: + gke-testkube-workflow: + jobs: + - setup-gcp + - run-testkube-on-gcp: + requires: + - setup-gcp +``` From 6ef90d90a0e348a8af42ede35efd2efab6c43054 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Tue, 23 Jan 2024 21:20:09 +0100 Subject: [PATCH 030/234] feat: Executor tests jmeterd special cases extended, run script fixed (labels) (#4929) * jmeterd executor tests - special cases extended * tests - run script fixed (labels) * empty lines added * empty lines added --- .../executor-tests/crd/special-cases.yaml | 113 ++++++++++++++++++ test/scripts/executor-tests/run.sh | 6 +- .../special-cases/jmeter-special-cases.yaml | 6 + 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml index a073e6e9c97..98fa4a7ee03 100644 --- a/test/jmeter/executor-tests/crd/special-cases.yaml +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -164,3 +164,116 @@ spec: limits: cpu: 500m memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-directory-t-o + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: develop + path: test/jmeter/executor-tests + executionRequest: + args: + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx" + - "-o" + - "/data/output/custom-report-directory" + - "-l" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-directory-t-o-slaves-2 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: develop + path: test/jmeter/executor-tests + executionRequest: + args: + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx" + - "-o" + - "/data/output/custom-report-directory" + - "-l" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + variables: + SLAVES_COUNT: + name: SLAVES_COUNT + value: "2" + type: basic + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-directory-wdir-t-o-slaves-2 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: develop + path: test/jmeter/executor-tests + workingDir: test/jmeter/executor-tests + executionRequest: + args: + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx" + - "-o" + - "/data/output/custom-report-directory" + - "-l" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + variables: + SLAVES_COUNT: + name: SLAVES_COUNT + value: "2" + type: basic + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/test/scripts/executor-tests/run.sh b/test/scripts/executor-tests/run.sh index 740c23a04e2..ed5a080d5e6 100755 --- a/test/scripts/executor-tests/run.sh +++ b/test/scripts/executor-tests/run.sh @@ -47,9 +47,9 @@ create_update_testsuite_json() { # testsuite_name testsuite_path if [ "$schedule" = true ] ; then # workaround for appending schedule random_minute="$(($RANDOM % 59))" - cat $2 | kubectl testkube --namespace $namespace $type testsuite --name $1 --label app=testkube --schedule "$random_minute */4 * * *" + cat $2 | kubectl testkube --namespace $namespace $type testsuite --name $1 --schedule "$random_minute */4 * * *" else - cat $2 | kubectl testkube --namespace $namespace $type testsuite --name $1 --label app=testkube + cat $2 | kubectl testkube --namespace $namespace $type testsuite --name $1 fi } @@ -58,7 +58,7 @@ create_update_testsuite() { # testsuite_name testsuite_path if [ "$schedule" = true ] ; then # workaround for appending schedule random_minute="$(($RANDOM % 59))" - kubectl testkube --namespace $namespace update testsuite --name $1 --label app=testkube --schedule "$random_minute */4 * * *" + kubectl testkube --namespace $namespace update testsuite --name $1 --schedule "$random_minute */4 * * *" fi } diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml index afac7662614..6e8b7c0d1b3 100644 --- a/test/suites/special-cases/jmeter-special-cases.yaml +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -22,3 +22,9 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-slaves-sharedbetweenpods + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-directory-t-o + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-directory-t-o-slaves-2 From 79e38f341954212fc1cb3cfd69662e84d784c039 Mon Sep 17 00:00:00 2001 From: Julianne Fermi Date: Tue, 23 Jan 2024 13:46:10 -0800 Subject: [PATCH 031/234] Discord-Slack Migration (#4928) Update docs to contain link to Slack channel. --- DESIGN.md | 19 +++++++++---------- README.md | 4 ++-- contrib/executor/artillery/README.md | 4 ++-- contrib/executor/gradle/README.md | 4 ++-- contrib/executor/k6/README.md | 4 ++-- contrib/executor/kubepug/README.md | 4 ++-- contrib/executor/maven/README.md | 4 ++-- contrib/executor/template/README.md | 4 ++-- contrib/executor/tracetest/README.md | 4 ++-- docs/docs/articles/argocd-integration.md | 8 ++++---- docs/docs/articles/common-issues.md | 4 ++-- docs/docs/articles/deploying-in-aws.md | 2 +- docs/docs/articles/getting-started.md | 2 +- .../testkube-pro/articles/AI-test-insights.md | 2 +- .../testkube-pro/articles/status-pages.md | 2 +- docs/docusaurus.config.js | 4 ++-- 16 files changed, 37 insertions(+), 38 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8bc838e663c..cc5e6a5a1ad 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -19,24 +19,24 @@ Testkube consists of 3 different parts. ## 🚢 How to contribute design -1. Check out open [issues](https://github.com/kubeshop/testkube/issues) here on GitHub (we tend to label them with `🚨 needs-ux`) +1. Check out open [issues](https://github.com/kubeshop/testkube/issues) here on GitHub (we tend to label them with `🚨 needs-ux`). 2. Feel free to open an issue on your own if you find something you would like to contribute to the project and use the `idea 💡` label for it. -3. Clone the public Figma files or create new ones and share them publicly -4. Add your contributions to an issue and we promise we will review your contribution carefully and foster discussions around it +3. Clone the public Figma files or create new ones and share them publicly. +4. Add your contributions to an issue and we promise we will review your contribution carefully and foster discussions around it. **We encourage you to:** -- Get in touch with the team by starting a discussion on [GitHub](https://github.com/kubeshop/testkube/issues) or on our [Discord Server](https://discord.gg/hfq44wtR6Q). +- Get in touch with the team by starting a discussion on [GitHub](https://github.com/kubeshop/testkube/issues) or on our [Slack Channel](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email). - Check out our [Contributor Guide](https://github.com/kubeshop/testkube/blob/main/CONTRIBUTING.md) and - [Code of Conduct](https://github.com/kubeshop/testkube/blob/main/CODE_OF_CONDUCT.md) + [Code of Conduct](https://github.com/kubeshop/testkube/blob/main/CODE_OF_CONDUCT.md). -## 🎭 Target audience +## 🎭 Target Audience Since we are creating a product for Testers and Developers our target audience is pretty straight forward. Sometimes wo do also like to include DevOps people into our considerations. -## 💅 Design relevant materials +## 💅 Design Relevant Materials -We currently aim to to build a more comprehensive Design System which will also include some guidance on Component usage, Wording, and patterns. +We currently aim to to build a more comprehensive Design System which will also include some guidance on Component usage, Wording, and Patterns. For now – here is a list of design relevant information and materials: @@ -56,7 +56,6 @@ https://www.figma.com/file/59vZTaJ6O2wTk0Qyqh2IJJ/Testkube-CLI?t=CBjcXzIKoEcG2AG ## 🎓 License -All design work is licensed under the -[MIT](https://mit-license.org/) +All design work is licensed under [MIT](https://mit-license.org/). [(Back to top)](#-table-of-contents) \ No newline at end of file diff --git a/README.md b/README.md index 5c83dc888cc..0e4ae73d365 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Website |  Documentation |  Twitter |  - Discord |  + Slack |  Blog

@@ -112,4 +112,4 @@ Go to [contribution document](CONTRIBUTING.md) to read more how can you help us # Feedback Whether it helps you or not - we'd LOVE to hear from you. Please let us know what you think and of course, how we can make it better. -Please join our growing community on [Discord](https://discord.com/invite/6zupCZFQbe) +Please join our growing community on [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) diff --git a/contrib/executor/artillery/README.md b/contrib/executor/artillery/README.md index 0784b5d01ec..ad724060a8a 100644 --- a/contrib/executor/artillery/README.md +++ b/contrib/executor/artillery/README.md @@ -63,5 +63,5 @@ For more info go to [main testkube repo](https://github.com/kubeshop/testkube) ![Docker builds](https://img.shields.io/docker/automated/kubeshop/testkube-api-server) ![Code build](https://img.shields.io/github/workflow/status/kubeshop/testkube/Code%20build%20and%20checks) ![Release date](https://img.shields.io/github/release-date/kubeshop/testkube) -![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Discord](https://img.shields.io/discord/884464549347074049) - #### [Documentation](https://docs.testkube.io) | [Discord](https://discord.gg/hfq44wtR6Q) \ No newline at end of file +![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) + #### [Documentation](https://docs.testkube.io) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) \ No newline at end of file diff --git a/contrib/executor/gradle/README.md b/contrib/executor/gradle/README.md index 194b76818de..7a98d3f3d01 100644 --- a/contrib/executor/gradle/README.md +++ b/contrib/executor/gradle/README.md @@ -31,5 +31,5 @@ For more info go to [main testkube repo](https://github.com/kubeshop/testkube) ![Docker builds](https://img.shields.io/docker/automated/kubeshop/testkube-api-server) ![Code build](https://img.shields.io/github/workflow/status/kubeshop/testkube/Code%20build%20and%20checks) ![Release date](https://img.shields.io/github/release-date/kubeshop/testkube) -![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Discord](https://img.shields.io/discord/884464549347074049) - #### [Documentation](https://docs.testkube.io) | [Discord](https://discord.gg/hfq44wtR6Q) +![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) + #### [Documentation](https://docs.testkube.io) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) diff --git a/contrib/executor/k6/README.md b/contrib/executor/k6/README.md index 15c9b6be3a7..6c352b38cee 100644 --- a/contrib/executor/k6/README.md +++ b/contrib/executor/k6/README.md @@ -60,5 +60,5 @@ For more info go to [main testkube repo](https://github.com/kubeshop/testkube) ![Docker builds](https://img.shields.io/docker/automated/kubeshop/testkube-api-server) ![Code build](https://img.shields.io/github/workflow/status/kubeshop/testkube/Code%20build%20and%20checks) ![Release date](https://img.shields.io/github/release-date/kubeshop/testkube) -![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Discord](https://img.shields.io/discord/884464549347074049) - #### [Documentation](https://docs.testkube.io) | [Discord](https://discord.gg/hfq44wtR6Q) \ No newline at end of file +![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) + #### [Documentation](https://docs.testkube.io) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) \ No newline at end of file diff --git a/contrib/executor/kubepug/README.md b/contrib/executor/kubepug/README.md index 85d1182ba66..cb74c5fddd4 100644 --- a/contrib/executor/kubepug/README.md +++ b/contrib/executor/kubepug/README.md @@ -120,6 +120,6 @@ For more info go to [main Testkube repo](https://github.com/kubeshop/testkube) ![Docker builds](https://img.shields.io/docker/automated/kubeshop/testkube-api-server) ![Code build](https://img.shields.io/github/workflow/status/kubeshop/testkube/Code%20build%20and%20checks) ![Release date](https://img.shields.io/github/release-date/kubeshop/testkube) -![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Discord](https://img.shields.io/discord/884464549347074049) +![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) -#### [Documentation](https://docs.testkube.io) | [Discord](https://discord.gg/hfq44wtR6Q) +#### [Documentation](https://docs.testkube.io) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) diff --git a/contrib/executor/maven/README.md b/contrib/executor/maven/README.md index 3d1328f0828..92b0a10691d 100644 --- a/contrib/executor/maven/README.md +++ b/contrib/executor/maven/README.md @@ -30,5 +30,5 @@ For more info go to [main testkube repo](https://github.com/kubeshop/testkube) ![Docker builds](https://img.shields.io/docker/automated/kubeshop/testkube-api-server) ![Code build](https://img.shields.io/github/workflow/status/kubeshop/testkube/Code%20build%20and%20checks) ![Release date](https://img.shields.io/github/release-date/kubeshop/testkube) -![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Discord](https://img.shields.io/discord/884464549347074049) - #### [Documentation](https://docs.testkube.io) | [Discord](https://discord.gg/hfq44wtR6Q) +![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) + #### [Documentation](https://docs.testkube.io) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) diff --git a/contrib/executor/template/README.md b/contrib/executor/template/README.md index dc5135d4aea..1af1e5ea708 100644 --- a/contrib/executor/template/README.md +++ b/contrib/executor/template/README.md @@ -73,5 +73,5 @@ For more info go to [main testkube repo](https://github.com/kubeshop/testkube) ![Docker builds](https://img.shields.io/docker/automated/kubeshop/testkube-api-server) ![Code build](https://img.shields.io/github/workflow/status/kubeshop/testkube/Code%20build%20and%20checks) ![Release date](https://img.shields.io/github/release-date/kubeshop/testkube) -![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Discord](https://img.shields.io/discord/884464549347074049) - #### [Documentation](https://docs.testkube.io/openapi) | [Discord](https://discord.gg/hfq44wtR6Q) \ No newline at end of file +![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) ![Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) + #### [Documentation](https://docs.testkube.io/openapi) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) \ No newline at end of file diff --git a/contrib/executor/tracetest/README.md b/contrib/executor/tracetest/README.md index 9186508d672..14623b38ea9 100644 --- a/contrib/executor/tracetest/README.md +++ b/contrib/executor/tracetest/README.md @@ -125,7 +125,7 @@ For more info go to [main testkube repo](https://github.com/kubeshop/testkube) ![Twitter](https://img.shields.io/twitter/follow/thekubeshop?style=social) -#### [Documentation](https://kubeshop.github.io/testkube) | [Discord](https://discord.gg/hfq44wtR6Q) +#### [Documentation](https://kubeshop.github.io/testkube) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) # Tracetest @@ -133,4 +133,4 @@ For more info go to [main tracetest repo](https://github.com/kubeshop/tracetest) ![Twitter](https://img.shields.io/twitter/follow/tracetest_io?style=social) -#### [Documentation](https://docs.tracetest.io/) | [Discord](https://discord.gg/6zupCZFQbe) \ No newline at end of file +#### [Documentation](https://docs.tracetest.io/) | [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) \ No newline at end of file diff --git a/docs/docs/articles/argocd-integration.md b/docs/docs/articles/argocd-integration.md index 0cffd2be9c5..6dca98a886a 100644 --- a/docs/docs/articles/argocd-integration.md +++ b/docs/docs/articles/argocd-integration.md @@ -219,7 +219,7 @@ spec: namespace: testkube ``` -Notice that we have defined path `postman-collections` which is the test folder with our Postman collections from the steps earlier. With Testkube you can use multiple test executors like `curl`, for example, so it is convenient to have a folder for each. We have also defined the `.destination.namespace` to be `testkube`, which is where the tests should be deployed in our cluster. +Notice that we have defined the path `postman-collections` which is the test folder with our Postman collections from the steps earlier. With Testkube you can use multiple test executors like `curl`, for example, so it is convenient to have a folder for each. We have also defined the `.destination.namespace` to be `testkube`, which is where the tests should be deployed in our cluster. ‍ Now let’s create the application with: @@ -297,12 +297,12 @@ And you will be able to see the results of the execution in the Executions tab a We now have an automated test deployment and execution pipeline based on GitOps principles! -### 11. Allow to add ownerReferences to CronJobs metadata for Tests and Test Suites +### 11. Allow adding ownerReferences to CronJobs metadata for Tests and Test Suites -You will need to enable helm chart variable `useArgoCDSync = true` in order to make CronJobs created for Tests and Test Suites syncronized in ArgoCD. +You will need to enable the Helm chart variable `useArgoCDSync = true` in order to make CronJobs created for Tests and Test Suites syncronized in ArgoCD. ## GitOps Takeaways Once fully realized - using GitOps for testing of Kubernetes applications as described above provides a powerful alternative to a more traditional approach where orchestration is tied to your current CI/CD tooling and not closely aligned with the lifecycle of Kubernetes applications. -We would love to get your thoughts on the above approach - over-engineering done right? Waste of time? Let us know on [our Discord server](https://discord.com/channels/884464549347074049/885185660808474664)! +We would love to get your thoughts on the above approach - over-engineering done right? Waste of time? Let us know on [our Slack Channel](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email)! diff --git a/docs/docs/articles/common-issues.md b/docs/docs/articles/common-issues.md index 3bf32777f85..2934b790069 100644 --- a/docs/docs/articles/common-issues.md +++ b/docs/docs/articles/common-issues.md @@ -83,11 +83,11 @@ Please stop the application that listens on 8080, 8088 ports. ## If You're Still Having Issues -If these guides do not solve the issue that you encountered or you have other questions or comments, please contact us on [Discord](https://discord.com/invite/6zupCZFQbe). +If these guides do not solve the issue that you encountered or you have other questions or comments, please contact us on [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email). ## Other Installation Methods -### Installation on OpenShift deployed on GCP +### Installation on OpenShift Deployed on GCP To install Testkube you need an empty OpenShift cluster. Once the cluster is up and running update `values.yaml` file, including the configuration below. diff --git a/docs/docs/articles/deploying-in-aws.md b/docs/docs/articles/deploying-in-aws.md index bf07bd96635..d6c6cb4c960 100644 --- a/docs/docs/articles/deploying-in-aws.md +++ b/docs/docs/articles/deploying-in-aws.md @@ -250,4 +250,4 @@ data "aws_iam_policy_document" "testkube" { With just a few changes you can deploy Testkube into an EKS cluster and expose it to the outside world while all the necessary resources are created automatically. -If you have any questions you can [join our Discord community](https://discord.com/invite/6zupCZFQbe) or, if you have any ideas for other useful features, you can create feature requests at our [GitHub Issues](https://github.com/kubeshop/testkube) page. +If you have any questions you can [join our Slack Channel](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) or, if you have any ideas for other useful features, you can create feature requests at our [GitHub Issues](https://github.com/kubeshop/testkube) page. diff --git a/docs/docs/articles/getting-started.md b/docs/docs/articles/getting-started.md index 2e57896ed44..f05c2869b22 100644 --- a/docs/docs/articles/getting-started.md +++ b/docs/docs/articles/getting-started.md @@ -51,7 +51,7 @@ By default, Testkube is installed in the `testkube` namespace. ## Need Help? -- Join our community on [Discord](https://discord.com/invite/6zupCZFQbe). +- Join our community on [Slack](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email). - [Schedule a call](https://calendly.com/bryan-3pu/support-product-feedback-call?month=2023-10) with one of our experts. - Check out our guides. - [Integrating Testkube with your CI/CD](https://docs.testkube.io/articles/cicd-overview/). diff --git a/docs/docs/testkube-pro/articles/AI-test-insights.md b/docs/docs/testkube-pro/articles/AI-test-insights.md index 788f6ad817f..6160d6395f6 100644 --- a/docs/docs/testkube-pro/articles/AI-test-insights.md +++ b/docs/docs/testkube-pro/articles/AI-test-insights.md @@ -82,7 +82,7 @@ Now if you execute the test again, it passes. Note that the AI Analysis tab is n This was a simple demo to show you how to use Testkube’s AI Analysis feature to analyze logs and fix failing tests quickly. You can create complex tests to test your applications and infrastructure. -If you have feedback or concerns using the AI analysis feature, do share them on our [Discord channel](https://discord.com/invite/6zupCZFQbe) for faster resolution. +If you have feedback or concerns using the AI analysis feature, do share them on our [Slack Channel](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email) for faster resolution. diff --git a/docs/docs/testkube-pro/articles/status-pages.md b/docs/docs/testkube-pro/articles/status-pages.md index b5ae4bf4677..19d69913780 100644 --- a/docs/docs/testkube-pro/articles/status-pages.md +++ b/docs/docs/testkube-pro/articles/status-pages.md @@ -222,4 +222,4 @@ Custom Slugs: If applicable, configure custom slugs for your status pages to mat These best practices will help you maximize the effectiveness of Testkube Status Pages, ensuring that it serves as a valuable communication tool for both technical and non-technical stakeholders. By following these guidelines, you can maintain transparency, respond efficiently to incidents, and provide a reliable source of information about the status of your software projects. -If you have any questions or need assistance, our team is ready to assist you in our [Discord channel](https://discord.com/invite/6zupCZFQbe). +If you have any questions or need assistance, our team is ready to assist you in our [Slack Channel](https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email). diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 93e6d0cca30..2a78b293129 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -114,8 +114,8 @@ const config = { title: "Community", items: [ { - label: "Discord", - href: "https://discord.com/invite/6zupCZFQbe", + label: "Slack", + href: "https://testkubeworkspace.slack.com/join/shared_invite/zt-2arhz5vmu-U2r3WZ69iPya5Fw0hMhRDg#/shared-invite/email", }, { label: "Twitter", From e1127f063b9f83f7222c6e8eee2167649981e673 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 24 Jan 2024 11:07:18 +0200 Subject: [PATCH 032/234] update cimfor logs service --- .github/workflows/release-dev-log-server.yaml | 4 ++-- .github/workflows/release-dev-log-sidecar.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-dev-log-server.yaml b/.github/workflows/release-dev-log-server.yaml index 8a7d3d2f794..cc6da49741e 100644 --- a/.github/workflows/release-dev-log-server.yaml +++ b/.github/workflows/release-dev-log-server.yaml @@ -2,8 +2,8 @@ name: Release logs server dev on: push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+-*" + branches: + - develop permissions: id-token: write diff --git a/.github/workflows/release-dev-log-sidecar.yaml b/.github/workflows/release-dev-log-sidecar.yaml index 9b0c186dd2e..6b52fbf04c1 100644 --- a/.github/workflows/release-dev-log-sidecar.yaml +++ b/.github/workflows/release-dev-log-sidecar.yaml @@ -2,8 +2,8 @@ name: Release logs sidecar dev on: push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+-*" + branches: + - develop permissions: id-token: write From 8efb2690b70cb7b4402e5bdd3e088c3040745dea Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 24 Jan 2024 11:23:51 +0200 Subject: [PATCH 033/234] add manifest for logs service --- .github/workflows/release-dev-log-server.yaml | 4 ++-- .github/workflows/release-dev-log-sidecar.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-dev-log-server.yaml b/.github/workflows/release-dev-log-server.yaml index cc6da49741e..da47184e43a 100644 --- a/.github/workflows/release-dev-log-server.yaml +++ b/.github/workflows/release-dev-log-server.yaml @@ -72,8 +72,8 @@ jobs: - name: Push Docker images run: | - docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker manifest create kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }} --amend kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --amend kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker manifest push -p kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }} - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@v1 diff --git a/.github/workflows/release-dev-log-sidecar.yaml b/.github/workflows/release-dev-log-sidecar.yaml index 6b52fbf04c1..44412d05b07 100644 --- a/.github/workflows/release-dev-log-sidecar.yaml +++ b/.github/workflows/release-dev-log-sidecar.yaml @@ -72,8 +72,8 @@ jobs: - name: Push Docker images run: | - docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker manifest create kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }} --amend kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --amend kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker manifest push -p kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }} - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@v1 From ea652d2758819125ba5972877112c1f919bc8408 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 24 Jan 2024 11:53:52 +0200 Subject: [PATCH 034/234] fix bug in manifest creation for logs services --- .github/workflows/release-dev-log-server.yaml | 3 +++ .github/workflows/release-dev-log-sidecar.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/release-dev-log-server.yaml b/.github/workflows/release-dev-log-server.yaml index da47184e43a..9192c4b7e1b 100644 --- a/.github/workflows/release-dev-log-server.yaml +++ b/.github/workflows/release-dev-log-server.yaml @@ -72,6 +72,9 @@ jobs: - name: Push Docker images run: | + docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 + docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker manifest create kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }} --amend kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --amend kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 docker manifest push -p kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }} diff --git a/.github/workflows/release-dev-log-sidecar.yaml b/.github/workflows/release-dev-log-sidecar.yaml index 44412d05b07..474987cd81e 100644 --- a/.github/workflows/release-dev-log-sidecar.yaml +++ b/.github/workflows/release-dev-log-sidecar.yaml @@ -72,6 +72,9 @@ jobs: - name: Push Docker images run: | + docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 + docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 + docker manifest create kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }} --amend kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --amend kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 docker manifest push -p kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }} From cad17745e0e86acb7c6f5cf6db8e258f5070e367 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Wed, 24 Jan 2024 13:32:17 +0100 Subject: [PATCH 035/234] feat: executor tests - gradle and maven - tests extended, non-default executor tests fixed (#4930) * executor tests - gradle and maven - tests extended, non-default executor tests - fixed * empty lines added * maven testsuite fixed --- test/executors/gradle.yaml | 36 +++++++++++++-- test/executors/maven.yaml | 30 +++++++++++-- test/gradle/executor-smoke/crd/crd.yaml | 46 +++++++++++++++----- test/maven/executor-smoke/crd/crd.yaml | 26 ++++++++++- test/suites/executor-gradle-smoke-tests.yaml | 3 ++ test/suites/executor-maven-smoke-tests.yaml | 3 ++ 6 files changed, 123 insertions(+), 21 deletions(-) diff --git a/test/executors/gradle.yaml b/test/executors/gradle.yaml index 9c563477f6f..b8694a6aafa 100644 --- a/test/executors/gradle.yaml +++ b/test/executors/gradle.yaml @@ -7,7 +7,14 @@ spec: types: - gradle:jdk18/project - gradle:jdk18/test - - gradle:jdk18/integrationTest + - gradle:jdk18/integrationTest + command: ["gradle"] + args: [ + "--no-daemon", + "", + "-p", + "" + ] --- apiVersion: executor.testkube.io/v1 kind: Executor @@ -18,7 +25,14 @@ spec: types: - gradle:jdk17/project - gradle:jdk17/test - - gradle:jdk17/integrationTest + - gradle:jdk17/integrationTest + command: ["gradle"] + args: [ + "--no-daemon", + "", + "-p", + "" + ] --- apiVersion: executor.testkube.io/v1 kind: Executor @@ -29,7 +43,14 @@ spec: types: - gradle:jdk11/project - gradle:jdk11/test - - gradle:jdk11/integrationTest + - gradle:jdk11/integrationTest + command: ["gradle"] + args: [ + "--no-daemon", + "", + "-p", + "" + ] --- apiVersion: executor.testkube.io/v1 kind: Executor @@ -40,4 +61,11 @@ spec: types: - gradle:jdk8/project - gradle:jdk8/test - - gradle:jdk8/integrationTest \ No newline at end of file + - gradle:jdk8/integrationTest + command: ["gradle"] + args: [ + "--no-daemon", + "", + "-p", + "" + ] diff --git a/test/executors/maven.yaml b/test/executors/maven.yaml index 1f106339b19..c0ae589a9f0 100644 --- a/test/executors/maven.yaml +++ b/test/executors/maven.yaml @@ -7,7 +7,15 @@ spec: types: - maven:jdk18/project - maven:jdk18/test - - maven:jdk18/integration-test + - maven:jdk18/integration-test + command: ["mvn"] + args: [ + "--settings", + "", + "", + "-Duser.home", + "" + ] --- apiVersion: executor.testkube.io/v1 kind: Executor @@ -18,7 +26,15 @@ spec: types: - maven:jdk11/project - maven:jdk11/test - - maven:jdk11/integration-test + - maven:jdk11/integration-test + command: ["mvn"] + args: [ + "--settings", + "", + "", + "-Duser.home", + "" + ] --- apiVersion: executor.testkube.io/v1 kind: Executor @@ -29,4 +45,12 @@ spec: types: - maven:jdk8/project - maven:jdk8/test - - maven:jdk8/integration-test \ No newline at end of file + - maven:jdk8/integration-test + command: ["mvn"] + args: [ + "--settings", + "", + "", + "-Duser.home", + "" + ] diff --git a/test/gradle/executor-smoke/crd/crd.yaml b/test/gradle/executor-smoke/crd/crd.yaml index dd29941beff..185b85428d3 100644 --- a/test/gradle/executor-smoke/crd/crd.yaml +++ b/test/gradle/executor-smoke/crd/crd.yaml @@ -1,5 +1,27 @@ -# https://github.com/kubeshop/testkube-executor-gradle/tree/main/examples - +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: gradle-executor-smoke + labels: + core-tests: executors +spec: + type: gradle/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: contrib/executor/gradle/examples/hello-gradle-jdk18 + executionRequest: + variables: + TESTKUBE_GRADLE: + name: TESTKUBE_GRADLE + value: "true" + type: basic + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 +--- apiVersion: tests.testkube.io/v3 kind: Test metadata: @@ -12,9 +34,9 @@ spec: type: git repository: type: git - uri: https://github.com/kubeshop/testkube-executor-gradle.git + uri: https://github.com/kubeshop/testkube.git branch: main - path: examples/hello-gradle-jdk18 + path: contrib/executor/gradle/examples/hello-gradle-jdk18 executionRequest: variables: TESTKUBE_GRADLE: @@ -36,9 +58,9 @@ spec: type: git repository: type: git - uri: https://github.com/kubeshop/testkube-executor-gradle.git + uri: https://github.com/kubeshop/testkube.git branch: main - path: examples/hello-gradle + path: contrib/executor/gradle/examples/hello-gradle executionRequest: variables: TESTKUBE_GRADLE: @@ -60,9 +82,9 @@ spec: type: git repository: type: git - uri: https://github.com/kubeshop/testkube-executor-gradle.git + uri: https://github.com/kubeshop/testkube.git branch: main - path: examples/hello-gradle + path: contrib/executor/gradle/examples/hello-gradle executionRequest: variables: TESTKUBE_GRADLE: @@ -84,9 +106,9 @@ spec: type: git repository: type: git - uri: https://github.com/kubeshop/testkube-executor-gradle.git + uri: https://github.com/kubeshop/testkube.git branch: main - path: examples/hello-gradle + path: contrib/executor/gradle/examples/hello-gradle executionRequest: variables: TESTKUBE_GRADLE: @@ -108,9 +130,9 @@ spec: type: git repository: type: git - uri: https://github.com/kubeshop/testkube-executor-gradle.git + uri: https://github.com/kubeshop/testkube.git branch: main - path: examples/hello-gradle-jdk18 + path: contrib/executor/gradle/examples/hello-gradle-jdk18 executionRequest: negativeTest: true jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" diff --git a/test/maven/executor-smoke/crd/crd.yaml b/test/maven/executor-smoke/crd/crd.yaml index 69da5c03cb2..37ca9ae400c 100644 --- a/test/maven/executor-smoke/crd/crd.yaml +++ b/test/maven/executor-smoke/crd/crd.yaml @@ -1,5 +1,27 @@ -# https://github.com/kubeshop/testkube-executor-maven/tree/main/examples - +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: maven-executor-smoke + labels: + core-tests: executors +spec: + type: maven/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: contrib/executor/maven/examples/hello-maven-jdk18 + executionRequest: + variables: + TESTKUBE_MAVEN: + name: TESTKUBE_MAVEN + value: "true" + type: basic + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 256Mi\n cpu: 256m\n" + activeDeadlineSeconds: 180 +--- apiVersion: tests.testkube.io/v3 kind: Test metadata: diff --git a/test/suites/executor-gradle-smoke-tests.yaml b/test/suites/executor-gradle-smoke-tests.yaml index 592d3875bdf..cbc683cf255 100644 --- a/test/suites/executor-gradle-smoke-tests.yaml +++ b/test/suites/executor-gradle-smoke-tests.yaml @@ -7,6 +7,9 @@ metadata: spec: description: "gradle executor smoke tests" steps: + - stopOnFailure: false + execute: + - test: gradle-executor-smoke - stopOnFailure: false execute: - test: gradle-executor-smoke-jdk18 diff --git a/test/suites/executor-maven-smoke-tests.yaml b/test/suites/executor-maven-smoke-tests.yaml index 84dc640ca9a..15398f9dc0e 100644 --- a/test/suites/executor-maven-smoke-tests.yaml +++ b/test/suites/executor-maven-smoke-tests.yaml @@ -7,6 +7,9 @@ metadata: spec: description: "maven executor smoke tests" steps: + - stopOnFailure: false + execute: + - test: maven-executor-smoke - stopOnFailure: false execute: - test: maven-executor-smoke-jdk18 From 6af915268cb4304f66ba7f75578067f0e00788a8 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 24 Jan 2024 13:42:59 +0100 Subject: [PATCH 036/234] chore: refactor of client.Client into client.StreamGetter (#4933) --- pkg/logs/client/client.go | 7 ++-- pkg/logs/client/interface.go | 9 ++--- pkg/logs/client/mock_client.go | 50 --------------------------- pkg/logs/client/mock_streamgetter.go | 51 ++++++++++++++++++++++++++++ pkg/logs/logsserver.go | 6 +++- pkg/logs/logsserver_test.go | 7 ++-- pkg/logs/repository/factory.go | 2 +- pkg/logs/repository/interface.go | 2 +- pkg/logs/repository/jetstream.go | 6 ++-- pkg/logs/repository/minio.go | 4 +-- 10 files changed, 74 insertions(+), 70 deletions(-) delete mode 100644 pkg/logs/client/mock_client.go create mode 100644 pkg/logs/client/mock_streamgetter.go diff --git a/pkg/logs/client/client.go b/pkg/logs/client/client.go index a955ffd15bd..189c9ae79c6 100644 --- a/pkg/logs/client/client.go +++ b/pkg/logs/client/client.go @@ -18,7 +18,8 @@ const ( buffer = 100 ) -func NewGrpcClient(address string) Client { +// NewGrpcClient imlpements getter interface for log stream for given ID +func NewGrpcClient(address string) StreamGetter { return &GrpcClient{ log: log.DefaultLogger.With("service", "logs-grpc-client"), address: address, @@ -31,7 +32,7 @@ type GrpcClient struct { } // Get returns channel with log stream chunks for given execution id connects through GRPC to log service -func (c GrpcClient) Get(ctx context.Context, id string) chan events.LogResponse { +func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse, error) { ch := make(chan events.LogResponse, buffer) log := c.log.With("id", id) @@ -78,5 +79,5 @@ func (c GrpcClient) Get(ctx context.Context, id string) chan events.LogResponse } }() - return ch + return ch, nil } diff --git a/pkg/logs/client/interface.go b/pkg/logs/client/interface.go index 595dffa93db..58458f23cd3 100644 --- a/pkg/logs/client/interface.go +++ b/pkg/logs/client/interface.go @@ -13,11 +13,6 @@ const ( StopSubject = "events.logs.stop" ) -//go:generate mockgen -destination=./mock_client.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" Client -type Client interface { - Get(ctx context.Context, id string) chan events.LogResponse -} - //go:generate mockgen -destination=./mock_stream.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" Stream type Stream interface { StreamInitializer @@ -54,7 +49,9 @@ type StreamPusher interface { PushBytes(ctx context.Context, id string, chunk []byte) error } -// LogStream is a single log stream chunk with possible errors +// StreamGetter interface for getting logs stream channel +// +//go:generate mockgen -destination=./mock_streamgetter.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" StreamGetter type StreamGetter interface { // Init creates or updates stream on demand Get(ctx context.Context, id string) (chan events.LogResponse, error) diff --git a/pkg/logs/client/mock_client.go b/pkg/logs/client/mock_client.go deleted file mode 100644 index a87031ea1af..00000000000 --- a/pkg/logs/client/mock_client.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: Client) - -// Package client is a generated GoMock package. -package client - -import ( - context "context" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - events "github.com/kubeshop/testkube/pkg/logs/events" -) - -// MockClient is a mock of Client interface. -type MockClient struct { - ctrl *gomock.Controller - recorder *MockClientMockRecorder -} - -// MockClientMockRecorder is the mock recorder for MockClient. -type MockClientMockRecorder struct { - mock *MockClient -} - -// NewMockClient creates a new mock instance. -func NewMockClient(ctrl *gomock.Controller) *MockClient { - mock := &MockClient{ctrl: ctrl} - mock.recorder = &MockClientMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockClient) EXPECT() *MockClientMockRecorder { - return m.recorder -} - -// Get mocks base method. -func (m *MockClient) Get(arg0 context.Context, arg1 string) chan events.LogResponse { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1) - ret0, _ := ret[0].(chan events.LogResponse) - return ret0 -} - -// Get indicates an expected call of Get. -func (mr *MockClientMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), arg0, arg1) -} diff --git a/pkg/logs/client/mock_streamgetter.go b/pkg/logs/client/mock_streamgetter.go new file mode 100644 index 00000000000..c319a0ebeb6 --- /dev/null +++ b/pkg/logs/client/mock_streamgetter.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: StreamGetter) + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" +) + +// MockStreamGetter is a mock of StreamGetter interface. +type MockStreamGetter struct { + ctrl *gomock.Controller + recorder *MockStreamGetterMockRecorder +} + +// MockStreamGetterMockRecorder is the mock recorder for MockStreamGetter. +type MockStreamGetterMockRecorder struct { + mock *MockStreamGetter +} + +// NewMockStreamGetter creates a new mock instance. +func NewMockStreamGetter(ctrl *gomock.Controller) *MockStreamGetter { + mock := &MockStreamGetter{ctrl: ctrl} + mock.recorder = &MockStreamGetterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStreamGetter) EXPECT() *MockStreamGetterMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockStreamGetter) Get(arg0 context.Context, arg1 string) (chan events.LogResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(chan events.LogResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStreamGetterMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStreamGetter)(nil).Get), arg0, arg1) +} diff --git a/pkg/logs/logsserver.go b/pkg/logs/logsserver.go index c5d2c55eae7..70810c48bd3 100644 --- a/pkg/logs/logsserver.go +++ b/pkg/logs/logsserver.go @@ -44,8 +44,12 @@ func (s LogsServer) Logs(req *pb.LogRequest, stream pb.LogsService_LogsServer) e s.log.Debugw("starting sending stream", "repo", repo) // stream logs from repository through GRPC channel - for l := range repo.Get(ctx, req.ExecutionId) { + ch, err := repo.Get(ctx, req.ExecutionId) + if err != nil { + return err + } + for l := range ch { s.log.Debug("sending log chunk", "log", l) if err := stream.Send(pb.MapResponseToPB(l)); err != nil { return err diff --git a/pkg/logs/logsserver_test.go b/pkg/logs/logsserver_test.go index 31a0a254836..aa21cc9ef47 100644 --- a/pkg/logs/logsserver_test.go +++ b/pkg/logs/logsserver_test.go @@ -35,7 +35,8 @@ func TestGRPC_Server(t *testing.T) { expectedCount := 0 stream := client.NewGrpcClient(ls.grpcAddress) - ch := stream.Get(ctx, "id1") + ch, err := stream.Get(ctx, "id1") + assert.NoError(t, err) t.Log("waiting for logs") @@ -68,12 +69,12 @@ func (l LogsFactoryMock) GetRepository(state state.LogState) (repository.LogsRep type LogsRepositoryMock struct{} -func (l LogsRepositoryMock) Get(ctx context.Context, id string) chan events.LogResponse { +func (l LogsRepositoryMock) Get(ctx context.Context, id string) (chan events.LogResponse, error) { ch := make(chan events.LogResponse, 10) defer close(ch) for i := 0; i < count; i++ { ch <- events.LogResponse{Log: events.Log{Time: time.Now(), Content: fmt.Sprintf("test %d", i), Error: false, Type: "test", Source: "test", Metadata: map[string]string{"test": "test"}}} } - return ch + return ch, nil } diff --git a/pkg/logs/repository/factory.go b/pkg/logs/repository/factory.go index b6b0f2d853f..5ac80512910 100644 --- a/pkg/logs/repository/factory.go +++ b/pkg/logs/repository/factory.go @@ -16,7 +16,7 @@ type Factory interface { type JsMinioFactory struct { minio *minio.Client - js client.Client + js client.StreamGetter } func (b JsMinioFactory) GetRepository(s state.LogState) (LogsRepository, error) { diff --git a/pkg/logs/repository/interface.go b/pkg/logs/repository/interface.go index 376b6050097..845ad7feef3 100644 --- a/pkg/logs/repository/interface.go +++ b/pkg/logs/repository/interface.go @@ -15,5 +15,5 @@ type RepositoryBuilder interface { // LogsRepository is the repository primitive to get logs from type LogsRepository interface { - Get(ctx context.Context, id string) chan events.LogResponse + Get(ctx context.Context, id string) (chan events.LogResponse, error) } diff --git a/pkg/logs/repository/jetstream.go b/pkg/logs/repository/jetstream.go index a538fa57c04..b11305e93c4 100644 --- a/pkg/logs/repository/jetstream.go +++ b/pkg/logs/repository/jetstream.go @@ -9,15 +9,15 @@ import ( var _ LogsRepository = &JetstreamLogsRepository{} -func NewJetstreamRepository(client client.Client) LogsRepository { +func NewJetstreamRepository(client client.StreamGetter) LogsRepository { return JetstreamLogsRepository{c: client} } // Jet type JetstreamLogsRepository struct { - c client.Client + c client.StreamGetter } -func (r JetstreamLogsRepository) Get(ctx context.Context, id string) chan events.LogResponse { +func (r JetstreamLogsRepository) Get(ctx context.Context, id string) (chan events.LogResponse, error) { return r.c.Get(ctx, id) } diff --git a/pkg/logs/repository/minio.go b/pkg/logs/repository/minio.go index 7adfe5c55ff..c9a26ffb8e2 100644 --- a/pkg/logs/repository/minio.go +++ b/pkg/logs/repository/minio.go @@ -14,7 +14,7 @@ func NewMinioRepository(minio *minio.Client) LogsRepository { type MinioLogsRepository struct { } -func (r MinioLogsRepository) Get(ctx context.Context, id string) chan events.LogResponse { +func (r MinioLogsRepository) Get(ctx context.Context, id string) (chan events.LogResponse, error) { ch := make(chan events.LogResponse, 100) - return ch + return ch, nil } From 86ced8b3cedc55f98a51ece08b7884450c075211 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Wed, 24 Jan 2024 14:40:46 +0100 Subject: [PATCH 037/234] feat: executor tests - JMeterd special cases - incorrect filename (#4935) * executor tests - jmeter special cases extended - incorrect file name * empty lines added --- .../executor-tests/crd/special-cases.yaml | 33 +++++++++++++++++++ .../special-cases/jmeter-special-cases.yaml | 3 ++ 2 files changed, 36 insertions(+) diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml index 98fa4a7ee03..8a9eacf13e4 100644 --- a/test/jmeter/executor-tests/crd/special-cases.yaml +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -277,3 +277,36 @@ spec: limits: cpu: 500m memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-incorrect-file-path-negative + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + negativeTest: true + args: + - "-t" + - "/data/repo/test/jmeter/executor-tests/some-incorrect-file-name.jmx" + - "-o" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml index 6e8b7c0d1b3..1ae0bf4d2bd 100644 --- a/test/suites/special-cases/jmeter-special-cases.yaml +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -28,3 +28,6 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-directory-t-o-slaves-2 + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-incorrect-file-path-negative From 2d829dc91ea8bfb050716ec46b391ea33778787e Mon Sep 17 00:00:00 2001 From: Ale <93217218+alelthomas@users.noreply.github.com> Date: Wed, 24 Jan 2024 13:14:27 -0500 Subject: [PATCH 038/234] docs: add demo to docs overview (#4938) * add demo to docs overview * add demo to docs overview --- docs/docs/index.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 519e605ea00..70787ce2844 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -10,6 +10,11 @@ This is the place where you'll find everything you need to get ramped up and sta Testkube is a Kubernetes-native testing framework for Testers, Developers, and DevOps practitioners that allows you to automate the executions of your existing testing tools inside your Kubernetes cluster, removing all the complexity from your CI/CD pipelines. + + ## Try It Out! export const DocCardList = (input) => ( From e51705909bbd304ab713d6be6e2751a9c54b3432 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 25 Jan 2024 09:50:27 +0100 Subject: [PATCH 039/234] chore: refactor of stop function (#4934) * feat: pass execution config to the logs * fix: there is no map string any in grpc * fix: rollback to map string string * fix: improved logs message * fix: stop response async * fix: tests * fix: added test source to the log output * fix: refactored stop function with retries and stream and consumer comparison * fix: refactor of stop function with messages occurence handling --- pkg/logs/events.go | 152 ++++++++++++++++++-------------- pkg/logs/events/events.go | 4 + pkg/logs/events_test.go | 53 +++++++---- pkg/logs/service.go | 10 +-- pkg/scheduler/test_scheduler.go | 27 +++++- 5 files changed, 152 insertions(+), 94 deletions(-) diff --git a/pkg/logs/events.go b/pkg/logs/events.go index 2e92aae3da1..91bb1c59fe4 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sync" "time" "github.com/nats-io/nats.go" @@ -28,6 +29,8 @@ const ( ) type Consumer struct { + // Name of the consumer + Name string // Context is a consumer context you can call Stop() method on it when no more messages are expected Context jetstream.ConsumeContext // Instance is a NATS consumer instance @@ -37,8 +40,8 @@ type Consumer struct { func (ls *LogsService) initConsumer(ctx context.Context, a adapter.Adapter, streamName, id string, i int) (jetstream.Consumer, error) { name := fmt.Sprintf("lc%s%s%d", id, a.Name(), i) return ls.js.CreateOrUpdateConsumer(ctx, streamName, jetstream.ConsumerConfig{ - Name: name, - Durable: name, + Name: name, + // Durable: name, // FilterSubject: streamName, DeliverPolicy: jetstream.DeliverAllPolicy, }) @@ -127,6 +130,7 @@ func (ls *LogsService) handleStart(ctx context.Context) func(msg *nats.Msg) { // store consumer instance so we can stop it later in StopSubject handler ls.consumerInstances.Store(event.Id+"_"+adapter.Name(), Consumer{ + Name: event.Id + "_" + adapter.Name(), Context: cons, Instance: c, }) @@ -147,15 +151,14 @@ func (ls *LogsService) handleStart(ctx context.Context) func(msg *nats.Msg) { // handleStop will handle stop event and stop logs consumers, also clean consumers state func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { return func(msg *nats.Msg) { - ls.log.Debugw("got stop event") + var ( + wg sync.WaitGroup + stopped = 0 + event = events.Trigger{} + ) - t := time.NewTicker(ls.stopWaitTime) - select { - case <-t.C: - case <-ctx.Done(): - } + ls.log.Debugw("got stop event") - event := events.Trigger{} err := json.Unmarshal(msg.Data, &event) if err != nil { ls.log.Errorw("can't handle stop event", "error", err) @@ -164,73 +167,86 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { l := ls.log.With("id", event.Id, "event", "stop") - maxTries := 10 - repeated := 0 + err = msg.Respond([]byte("stop-queued")) + if err != nil { + l.Errorw("error responding to stop event", "error", err) + } - toDelete := []string{} - deleted := false for _, adapter := range ls.adapters { - toDelete = append(toDelete, event.Id+"_"+adapter.Name()) - } + consumerName := event.Id + "_" + adapter.Name() - consumerDeleteWaitInterval := 5 * time.Second - - for { - loop: - // Delete each consumer for given execution id - for i, name := range toDelete { - // load consumer and check if has pending messages - c, found := ls.consumerInstances.Load(name) - if !found { - l.Debugw("consumer not found on this pod", "found", found, "name", name) - toDelete = append(toDelete[:i], toDelete[i+1:]...) - goto loop // rewrite toDelete and start again - } - - consumer := c.(Consumer) - - info, err := consumer.Instance.Info(ctx) - if err != nil { - l.Errorw("error getting consumer info", "error", err, "id", event.Id) - continue - } - - // finally delete consumer - if info.NumPending == 0 { - if !deleted { - deleted = true - } - consumer.Context.Stop() - ls.consumerInstances.Delete(name) - toDelete = append(toDelete[:i], toDelete[i+1:]...) - l.Infow("stopping consumer", "id", name) - goto loop // rewrite toDelete and start again - } + // locate consumer on this pod + c, found := ls.consumerInstances.Load(consumerName) + l.Debugw("consumer instance", "c", c, "found", found, "name", consumerName) + if !found { + l.Debugw("consumer not found on this pod", "found", found, "name", consumerName) + continue } - if len(toDelete) == 0 && !deleted { - l.Debugw("no consumers on this pod registered for id", "id", event.Id) - return - } else if len(toDelete) == 0 { - ls.state.Put(ctx, event.Id, state.LogStateFinished) - l.Infow("execution logs consumers stopped", "id", event.Id) - err = msg.Respond([]byte("stopped")) - if err != nil { - l.Errorw("error responding to stop event", "error", err) - return - } - return - } + // stop consumer + wg.Add(1) + stopped++ + consumer := c.(Consumer) - // handle max tries of cleaning executors - repeated++ - if repeated >= maxTries { - l.Errorw("error cleaning consumeres after max tries", "toDeleteLeft", toDelete, "tries", repeated) - return - } + go ls.stopConsumer(ctx, &wg, consumer) + } + + wg.Wait() + l.Debugw("wait completed") + + if stopped > 0 { + ls.state.Put(ctx, event.Id, state.LogStateFinished) + l.Infow("execution logs consumers stopped", "id", event.Id, "stopped", stopped) + } else { + l.Debugw("no consumers found on this pod to stop") + } - time.Sleep(consumerDeleteWaitInterval) + } +} + +func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, consumer Consumer) { + defer wg.Done() + + var ( + info *jetstream.ConsumerInfo + err error + l = ls.log + retries = 0 + maxRetries = 50 + ) + + l.Debugw("stopping consumer", "name", consumer.Name) + + for { + info, err = consumer.Instance.Info(ctx) + if err != nil { + l.Errorw("error getting consumer info", "error", err, "name", consumer.Name) + return } + + nothingToProcess := info.NumAckPending == 0 && info.NumPending == 0 + messagesDelivered := info.Delivered.Consumer > 0 && info.Delivered.Stream > 0 + + l.Debugw("consumer info", "nothingToProcess", nothingToProcess, "messagesDelivered", messagesDelivered, "info", info) + + // check if there was some messages processed + if nothingToProcess && messagesDelivered { + consumer.Context.Stop() + ls.consumerInstances.Delete(consumer.Name) + l.Infow("stopping and removing consumer", "name", consumer.Name, "consumerSeq", info.Delivered.Consumer, "streamSeq", info.Delivered.Stream, "last", info.Delivered.Last) + return + } + + // retry if there is no messages processed as there could be slower logs + retries++ + if retries >= maxRetries { + l.Errorw("error stopping consumer", "error", err, "name", consumer.Name, "consumerSeq", info.Delivered.Consumer, "streamSeq", info.Delivered.Stream, "last", info.Delivered.Last) + return + } + + // pause a little bit + l.Debugw("waiting for consumer to finish", "name", consumer.Name, "retries", retries, "consumerSeq", info.Delivered.Consumer, "streamSeq", info.Delivered.Stream, "last", info.Delivered.Last) + time.Sleep(ls.stopPauseInterval) } } diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 032299405d2..6697c978d0a 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -23,6 +23,8 @@ const ( LogVersionV1 LogVersion = "v1" // v2 - raw binary format, timestamps are based on Kubernetes logs, line is raw log line LogVersionV2 LogVersion = "v2" + + JobPodLogSource = "job-pod" ) type LogResponse struct { @@ -142,6 +144,7 @@ func NewLogResponseFromBytes(b []byte) Log { Type: o.Type_, Error: true, Version: LogVersionV1, + Source: JobPodLogSource, } } @@ -171,5 +174,6 @@ func NewLogResponseFromBytes(b []byte) Log { Time: ts, Content: string(b), Version: LogVersionV2, + Source: JobPodLogSource, } } diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 68c084d644a..6986c0154d6 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -17,7 +17,7 @@ import ( "github.com/kubeshop/testkube/pkg/logs/state" ) -var waitTime = time.Millisecond * 100 +var waitTime = time.Second func TestLogs_EventsFlow(t *testing.T) { t.Parallel() @@ -31,6 +31,8 @@ func TestLogs_EventsFlow(t *testing.T) { ns, nc := bus.TestServerWithConnection() defer ns.Shutdown() + id := "stop-test" + // and jetstream configured js, err := jetstream.New(nc) assert.NoError(t, err) @@ -45,8 +47,7 @@ func TestLogs_EventsFlow(t *testing.T) { // and initialized log service log := NewLogsService(nc, js, state). - WithRandomPort(). - WithStopWaitTime(waitTime) + WithRandomPort() // given example adapters a := NewMockAdapter("aaa") @@ -69,22 +70,25 @@ func TestLogs_EventsFlow(t *testing.T) { assert.NoError(t, err) // and initialized log stream for given ID - meta, err := stream.Init(ctx, "stop-test") + meta, err := stream.Init(ctx, id) assert.NotEmpty(t, meta.Name) assert.NoError(t, err) // when start event triggered - _, err = stream.Start(ctx, "stop-test") + _, err = stream.Start(ctx, id) assert.NoError(t, err) // and when data pushed to the log stream - stream.Push(ctx, "stop-test", events.NewLogResponse(time.Now(), []byte("hello 1"))) - stream.Push(ctx, "stop-test", events.NewLogResponse(time.Now(), []byte("hello 2"))) + stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) + stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 2"))) // and stop event triggered - _, err = stream.Stop(ctx, "stop-test") + _, err = stream.Stop(ctx, id) assert.NoError(t, err) + // cooldown stop time + time.Sleep(waitTime * 2) + // then all adapters should be gracefully stopped assert.Equal(t, 0, log.GetConsumersStats(ctx).Count) }) @@ -98,6 +102,8 @@ func TestLogs_EventsFlow(t *testing.T) { ns, nc := bus.TestServerWithConnection() defer ns.Shutdown() + id := "messages-test" + // and jetstream configured js, err := jetstream.New(nc) assert.NoError(t, err) @@ -112,8 +118,7 @@ func TestLogs_EventsFlow(t *testing.T) { // and initialized log service log := NewLogsService(nc, js, state). - WithRandomPort(). - WithStopWaitTime(waitTime) + WithRandomPort() // given example adapter a := NewMockAdapter() @@ -139,22 +144,22 @@ func TestLogs_EventsFlow(t *testing.T) { assert.NoError(t, err) // and initialized log stream for given ID - meta, err := stream.Init(ctx, "messages-test") + meta, err := stream.Init(ctx, id) assert.NotEmpty(t, meta.Name) assert.NoError(t, err) // when start event triggered - _, err = stream.Start(ctx, "messages-test") + _, err = stream.Start(ctx, id) assert.NoError(t, err) for i := 0; i < messagesCount; i++ { // and when data pushed to the log stream - err = stream.Push(ctx, "messages-test", events.NewLogResponse(time.Now(), []byte("hello"))) + err = stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello"))) assert.NoError(t, err) } // and wait for message to be propagated - _, err = stream.Stop(ctx, "messages-test") + _, err = stream.Stop(ctx, id) assert.NoError(t, err) assertMessagesCount(t, a, 4*messagesCount) @@ -174,6 +179,8 @@ func TestLogs_EventsFlow(t *testing.T) { js, err := jetstream.New(nc) assert.NoError(t, err) + id := "executionid1" + // and KV store kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "state-test"}) assert.NoError(t, err) @@ -184,8 +191,7 @@ func TestLogs_EventsFlow(t *testing.T) { // and initialized log service log := NewLogsService(nc, js, state). - WithRandomPort(). - WithStopWaitTime(waitTime) + WithRandomPort() // given example adapters a := NewMockAdapter("aaa") @@ -208,21 +214,30 @@ func TestLogs_EventsFlow(t *testing.T) { assert.NoError(t, err) // and initialized log stream for given ID - meta, err := stream.Init(ctx, "consumer-stats") + meta, err := stream.Init(ctx, id) assert.NotEmpty(t, meta.Name) assert.NoError(t, err) // when start event triggered - _, err = stream.Start(ctx, "consumer-stats") + _, err = stream.Start(ctx, id) assert.NoError(t, err) // then we should have 2 consumers stats := log.GetConsumersStats(ctx) assert.Equal(t, 2, stats.Count) + stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) + stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) + stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) + // when stop event triggered - _, err = stream.Stop(ctx, "consumer-stats") + r, err := stream.Stop(ctx, id) assert.NoError(t, err) + assert.False(t, r.Error) + assert.Equal(t, "stop-queued", string(r.Message)) + + // there will be wait for mess + time.Sleep(waitTime * 2) // then all adapters should be gracefully stopped assert.Equal(t, 0, log.GetConsumersStats(ctx).Count) diff --git a/pkg/logs/service.go b/pkg/logs/service.go index b710d4f9b9a..12eff7a0dff 100644 --- a/pkg/logs/service.go +++ b/pkg/logs/service.go @@ -29,7 +29,7 @@ const ( DefaultHttpAddress = ":8080" DefaultGrpcAddress = ":9090" - DefaultStopWaitTime = 60 * time.Second // when stop event is faster than first message arrived + defaultStopPauseInterval = 200 * time.Millisecond ) func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interface) *LogsService { @@ -43,7 +43,7 @@ func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interfa grpcAddress: DefaultGrpcAddress, consumerInstances: sync.Map{}, state: state, - stopWaitTime: DefaultStopWaitTime, + stopPauseInterval: defaultStopPauseInterval, } } @@ -76,7 +76,7 @@ type LogsService struct { state state.Interface // stop wait time for messages cool down - stopWaitTime time.Duration + stopPauseInterval time.Duration } // AddAdapter adds new adapter to logs service adapters will be configred based on given mode @@ -149,8 +149,8 @@ func (ls *LogsService) WithGrpcAddress(address string) *LogsService { return ls } -func (ls *LogsService) WithStopWaitTime(duration time.Duration) *LogsService { - ls.stopWaitTime = duration +func (ls *LogsService) WithPauseInterval(duration time.Duration) *LogsService { + ls.stopPauseInterval = duration return ls } diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 43ca934cccc..b64a58ee678 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "github.com/pkg/errors" v1 "k8s.io/api/core/v1" @@ -126,9 +127,31 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request s.logger.Infow("test started", "executionId", execution.Id, "status", execution.ExecutionResult.Status) + s.handleExecutionStart(ctx, execution) + return execution, nil } +func (s *Scheduler) handleExecutionStart(ctx context.Context, execution testkube.Execution) { + // pass here all needed execution data to the log + if s.featureFlags.LogsV2 { + + l := events.NewLog(fmt.Sprintf("starting execution %s (%s)", execution.Name, execution.Id)). + WithType("execution-config"). + WithVersion(events.LogVersionV2). + WithSource("test-scheduler") + + // TODO try to store map[strin]any through protobuf for now it'll be map[string]string + l.WithMetadataEntry("command", strings.Join(execution.Command, " ")) + l.WithMetadataEntry("argsmode", execution.ArgsMode) + l.WithMetadataEntry("args", strings.Join(execution.Args, " ")) + l.WithMetadataEntry("pre-run", execution.PreRunScript) + l.WithMetadataEntry("post-run", execution.PostRunScript) + + s.logsStream.Push(ctx, execution.Id, *l) + } +} + func (s *Scheduler) handleExecutionError(ctx context.Context, execution testkube.Execution, msgTpl string, err error) (testkube.Execution, error) { // push error log to the log stream if logs v2 enabled if s.featureFlags.LogsV2 { @@ -849,11 +872,11 @@ func (s *Scheduler) triggerLogsStopEvent(ctx context.Context, id string) error { } if r.Error { - s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) + s.logger.Errorw("received invalid response from log stream on stop event", "id", id, "response", r) return } - s.logger.Infow("triggering logs stop event", "id", id) + s.logger.Infow("triggering logs stop event", "id", id, "response", string(r.Message)) }() } return nil From 8ef2ccdafc902eaea16e8b9c3124c73437ec4666 Mon Sep 17 00:00:00 2001 From: Dejan Zele Pejchev Date: Thu, 25 Jan 2024 13:50:05 +0100 Subject: [PATCH 040/234] jmeterd: add sanity checking for test file (#4947) --- .../executor/jmeterd/pkg/runner/helpers.go | 23 ++++++- .../jmeterd/pkg/runner/helpers_test.go | 31 +++++++++ contrib/executor/jmeterd/pkg/runner/runner.go | 33 +++++++-- .../jmeterd/pkg/runner/runner_test.go | 67 +++++++++++++++++++ 4 files changed, 147 insertions(+), 7 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/helpers.go b/contrib/executor/jmeterd/pkg/runner/helpers.go index 6dfa6b85fc7..e83947748d1 100644 --- a/contrib/executor/jmeterd/pkg/runner/helpers.go +++ b/contrib/executor/jmeterd/pkg/runner/helpers.go @@ -18,6 +18,11 @@ const ( envVarPrefix = "$" ) +var ( + ErrParamMissingValue = errors.New("no value found for parameter") + ErrMissingParam = errors.New("parameter not found") +) + func getTestPathAndWorkingDir(fs filesystem.FileSystem, execution *testkube.Execution, dataDir string) (testPath string, workingDir, testFile string, err error) { testPath, workingDir, err = content.GetPathAndWorkingDir(execution.Content, dataDir) if err != nil { @@ -41,7 +46,6 @@ func getTestPathAndWorkingDir(fs filesystem.FileSystem, execution *testkube.Exec if err != nil || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, testFile, err) return "", "", "", errors.Wrapf(err, "could not find file %s in the directory", testFile) - } } return @@ -71,7 +75,7 @@ func findTestFile(fs filesystem.FileSystem, execution *testkube.Execution, testP } } if testFile == "" { - output.PrintLogf("%s %s file not found in args or test path!", ui.IconCross, testExtension) + output.PrintLogf("%s %s file not found in args or test path!", ui.IconCross, testExtension) return "", errors.Errorf("no %s file found", testExtension) } return testFile, nil @@ -114,3 +118,18 @@ func injectAndExpandEnvVars(args []string, params []string) []string { return copied } + +// getParamValue searches for a parameter in the args slice and returns its value. +// It returns an error if the parameter is not found or if it does not have an associated value. +func getParamValue(args []string, param string) (string, error) { + for i, arg := range args { + if arg == param { + // Check if the next element exists + if i+1 < len(args) { + return args[i+1], nil + } + return "", errors.WithStack(ErrParamMissingValue) + } + } + return "", errors.WithStack(ErrMissingParam) +} diff --git a/contrib/executor/jmeterd/pkg/runner/helpers_test.go b/contrib/executor/jmeterd/pkg/runner/helpers_test.go index 5625effcc0c..c91d3f7d7bf 100644 --- a/contrib/executor/jmeterd/pkg/runner/helpers_test.go +++ b/contrib/executor/jmeterd/pkg/runner/helpers_test.go @@ -281,3 +281,34 @@ func TestInjectAndExpandEnvVars(t *testing.T) { }) } } + +func TestGetParamValue(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + args []string + param string + expected string + wantErr error + }{ + {name: "get last param successfully", args: []string{"-n", "-o", "/data", "-t", "/data/repo"}, param: "-t", expected: "/data/repo", wantErr: nil}, + {name: "get middle param successfully", args: []string{"-n", "-o", "/data", "-t", "/data/repo"}, param: "-o", expected: "/data", wantErr: nil}, + {name: "param missing value returns error", args: []string{"-n", "-o", "/data", "-t"}, param: "-t", expected: "", wantErr: ErrParamMissingValue}, + {name: "param missing", args: []string{"-n", "-o", "/data", "-t", "/data/repo"}, param: "-x", expected: "", wantErr: ErrMissingParam}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + value, err := getParamValue(tc.args, tc.param) + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + assert.Empty(t, value) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, value) + } + }) + } +} diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index b1dfa26438a..a871d59c90b 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -32,11 +32,10 @@ import ( type JMeterMode string const ( - jmeterModeStandalone JMeterMode = "standalone" - jmeterModeDistributed JMeterMode = "distributed" - globalJMeterParamPrefix = "-G" - standaloneJMeterParamPrefix = "-J" - jmxExtension = "jmx" + jmeterModeStandalone JMeterMode = "standalone" + jmeterModeDistributed JMeterMode = "distributed" + jmxExtension = "jmx" + jmeterTestFileFlag = "-t" ) // JMeterDRunner runner @@ -158,6 +157,12 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( args = injectAndExpandEnvVars(args, nil) output.PrintLogf("%s Using arguments: %v", ui.IconWorld, envManager.ObfuscateStringSlice(args)) + // TODO: this is a workaround, the check should be ideally performed in the getTestPathAndWorkingDir function + if err := checkIfTestFileExists(r.fs, args); err != nil { + output.PrintLogf("%s Error validating test file exists: %v", ui.IconCross, err.Error()) + return result, errors.WithStack(err) + } + entryPoint := getEntryPoint() for i := range execution.Command { if execution.Command[i] == "" { @@ -231,7 +236,25 @@ func initSlaves( return slaveClient.DeleteSlaves(ctx, slaveMeta) } return slaveMeta, cleanupFunc, nil +} + +func checkIfTestFileExists(fs filesystem.FileSystem, args []string) error { + if len(args) == 0 { + return errors.New("no arguments provided") + } + testParamValue, err := getParamValue(args, jmeterTestFileFlag) + if err != nil { + return errors.Wrapf(err, "error extracting value for %s flag", jmeterTestFileFlag) + } + info, err := fs.Stat(testParamValue) + if err != nil { + return errors.WithStack(err) + } + if info.IsDir() { + return errors.Errorf("test file %s is a directory", testParamValue) + } + return nil } func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool, result []string) { diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index dd3755dd61b..190b4eb9a35 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -6,6 +6,11 @@ import ( "path/filepath" "testing" + "github.com/golang/mock/gomock" + "github.com/pkg/errors" + + "github.com/kubeshop/testkube/pkg/filesystem" + "github.com/kubeshop/testkube/pkg/utils/test" "github.com/stretchr/testify/assert" @@ -14,6 +19,68 @@ import ( "github.com/kubeshop/testkube/pkg/envs" ) +func TestCheckIfTestFileExists(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + testCases := []struct { + name string + args []string + setupMock func(*filesystem.MockFileSystem) + expectError bool + }{ + { + name: "no arguments", + args: []string{}, + setupMock: func(mockFS *filesystem.MockFileSystem) {}, + expectError: true, + }, + { + name: "file does not exist", + args: []string{"-t", "test.txt"}, + setupMock: func(mockFS *filesystem.MockFileSystem) { + mockFS.EXPECT().Stat("test.txt").Return(nil, errors.New("file not found")) + }, + expectError: true, + }, + { + name: "file is a directory", + args: []string{"-t", "testdir"}, + setupMock: func(mockFS *filesystem.MockFileSystem) { + mockFileInfo := filesystem.MockFileInfo{FIsDir: true} + mockFS.EXPECT().Stat("testdir").Return(&mockFileInfo, nil) + }, + expectError: true, + }, + { + name: "file exists", + args: []string{"-t", "test.txt"}, + setupMock: func(mockFS *filesystem.MockFileSystem) { + mockFileInfo := filesystem.MockFileInfo{FName: "test.txt", FSize: 100} + mockFS.EXPECT().Stat("test.txt").Return(&mockFileInfo, nil) + }, + expectError: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + mockFS := filesystem.NewMockFileSystem(mockCtrl) + tc.setupMock(mockFS) + err := checkIfTestFileExists(mockFS, tc.args) + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestPrepareArgsReplacements(t *testing.T) { t.Parallel() From 6306ad99be0ad65f6f326dc8987a882e18070dd8 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 25 Jan 2024 14:14:48 +0100 Subject: [PATCH 041/234] fix: use pointer everywhere when passing log chunk (#4943) * fix: use pointer everywhere when passing log chunk * chore: refactor method name - no response prefix anymore * fix: added source to old logs * fix: cleaning how source is set for logs in proxy * fix: pass feature flags to the executor * chore: rename * fix: passed features to constructors * fix: streamLogs method in job executor --- cmd/api-server/main.go | 15 +++-- internal/app/api/v1/server.go | 4 ++ pkg/executor/client/job.go | 52 +++++++++++++++-- .../containerexecutor/containerexecutor.go | 7 +++ pkg/executor/output/parser.go | 13 +++-- pkg/executor/output/parser_test.go | 18 +++--- pkg/logs/client/interface.go | 4 +- .../client/mock_initializedstreampusher.go | 2 +- pkg/logs/client/mock_stream.go | 2 +- pkg/logs/client/stream.go | 8 +-- pkg/logs/events/events.go | 56 +++++++++++++------ pkg/logs/events_test.go | 12 ++-- pkg/logs/sidecar/proxy.go | 21 +++---- pkg/scheduler/test_scheduler.go | 18 +++--- 14 files changed, 156 insertions(+), 76 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index b7aa3fc7402..d9978508a21 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -133,10 +133,10 @@ func main() { cfg.CleanLegacyVars() ui.ExitOnError("error getting application config", err) - ff, err := featureflags.Get() + features, err := featureflags.Get() ui.ExitOnError("error getting application feature flags", err) - log.DefaultLogger.Infow("Feature flags configured", "ff", ff) + log.DefaultLogger.Infow("Feature flags configured", "ff", features) // Run services within an errgroup to propagate errors between services. g, ctx := errgroup.WithContext(context.Background()) @@ -350,7 +350,7 @@ func main() { var logsStream logsclient.Stream - if ff.LogsV2 { + if features.LogsV2 { logsStream, err = logsclient.NewNatsLogStream(nc.Conn) if err != nil { ui.ExitOnError("Creating logs streaming client", err) @@ -394,6 +394,8 @@ func main() { "http://"+cfg.APIServerFullname+":"+cfg.APIServerPort, cfg.NatsURI, cfg.Debug, + logsStream, + features, ) if err != nil { ui.ExitOnError("Creating executor client", err) @@ -424,6 +426,8 @@ func main() { "http://"+cfg.APIServerFullname+":"+cfg.APIServerPort, cfg.NatsURI, cfg.Debug, + logsStream, + features, ) if err != nil { ui.ExitOnError("Creating container executor", err) @@ -447,7 +451,7 @@ func main() { testsuiteExecutionsClient, eventBus, cfg.TestkubeDashboardURI, - ff, + features, logsStream, ) @@ -486,7 +490,8 @@ func main() { mode, eventBus, cfg.EnableSecretsEndpoint, - ff, + features, + logsStream, ) if mode == common.ModeAgent { diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 6204fbdbb19..f4d96d26dfb 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -44,6 +44,7 @@ import ( "github.com/kubeshop/testkube/pkg/event/kind/webhook" ws "github.com/kubeshop/testkube/pkg/event/kind/websocket" "github.com/kubeshop/testkube/pkg/executor/client" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/oauth" "github.com/kubeshop/testkube/pkg/scheduler" "github.com/kubeshop/testkube/pkg/secret" @@ -89,6 +90,7 @@ func NewTestkubeAPI( eventsBus bus.Bus, enableSecretsEndpoint bool, ff featureflags.FeatureFlags, + logsStream logsclient.Stream, ) TestkubeAPI { var httpConfig server.Config @@ -134,6 +136,7 @@ func NewTestkubeAPI( eventsBus: eventsBus, enableSecretsEndpoint: enableSecretsEndpoint, featureFlags: ff, + logsStream: logsStream, } // will be reused in websockets handler @@ -191,6 +194,7 @@ type TestkubeAPI struct { eventsBus bus.Bus enableSecretsEndpoint bool featureFlags featureflags.FeatureFlags + logsStream logsclient.Stream } type storageParams struct { diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index 26f1813f96a..dc9a49739e5 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -44,6 +44,8 @@ import ( "github.com/kubeshop/testkube/pkg/executor/env" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/log" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" + "github.com/kubeshop/testkube/pkg/logs/events" testexecutionsmapper "github.com/kubeshop/testkube/pkg/mapper/testexecutions" testsmapper "github.com/kubeshop/testkube/pkg/mapper/tests" "github.com/kubeshop/testkube/pkg/telemetry" @@ -93,6 +95,8 @@ func NewJobExecutor( apiURI string, natsURI string, debug bool, + logsStream logsclient.Stream, + features featureflags.FeatureFlags, ) (client *JobExecutor, err error) { return &JobExecutor{ ClientSet: clientset, @@ -115,6 +119,8 @@ func NewJobExecutor( apiURI: apiURI, natsURI: natsURI, debug: debug, + logsStream: logsStream, + features: features, }, nil } @@ -145,6 +151,8 @@ type JobExecutor struct { apiURI string natsURI string debug bool + logsStream logsclient.Stream + features featureflags.FeatureFlags } type JobOptions struct { @@ -225,6 +233,9 @@ func (c *JobExecutor) Execute(ctx context.Context, execution *testkube.Execution if err != nil { return result.Err(err), err } + + c.streamLog(ctx, execution.Id, events.NewLog("created kubernetes job").WithSource(events.SourceJobExecutor)) + if !options.Sync { go c.MonitorJobForTimeout(ctx, execution.Id) } @@ -237,6 +248,8 @@ func (c *JobExecutor) Execute(ctx context.Context, execution *testkube.Execution l := c.Log.With("executionID", execution.Id, "type", "async") + c.streamLog(ctx, execution.Id, events.NewLog("waiting for pod to spin up").WithSource(events.SourceJobExecutor)) + for _, pod := range pods.Items { if pod.Status.Phase != corev1.PodRunning && pod.Labels["job-name"] == execution.Id { // for sync block and complete @@ -258,7 +271,7 @@ func (c *JobExecutor) Execute(ctx context.Context, execution *testkube.Execution l.Debugw("no pods was found", "totalPodsCount", len(pods.Items)) - return testkube.NewRunningExecutionResult(), nil + return result, nil } func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName string) { @@ -282,7 +295,7 @@ func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName string) job := jobs.Items[0] if job.Status.Succeeded > 0 { - l.Debugw("job succeeded", "status") + l.Debugw("job succeeded", "status", "succeded") return } @@ -347,12 +360,14 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, // save stop time and final state defer func() { if err := c.stopExecution(ctx, l, execution, execution.ExecutionResult, isNegativeTest, err); err != nil { + c.streamLog(ctx, execution.Id, events.NewErrorLog(err)) l.Errorw("error stopping execution after updating results from pod", "error", err) } }() // wait for pod to be loggable if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.ClientSet, pod.Name, c.Namespace)); err != nil { + c.streamLog(ctx, execution.Id, events.NewErrorLog(errors.Wrap(err, "can't start test job pod"))) l.Errorw("waiting for pod started error", "error", err) } @@ -360,13 +375,16 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, // wait for pod if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.ClientSet, pod.Name, c.Namespace)); err != nil { // continue on poll err and try to get logs later + c.streamLog(ctx, execution.Id, events.NewErrorLog(errors.Wrap(err, "can't read data from pod, pod was not completed"))) l.Errorw("waiting for pod complete error", "error", err) } + if err != nil { execution.ExecutionResult.Err(err) } l.Debug("poll immediate end") + c.streamLog(ctx, execution.Id, events.NewLog("analyzing test results and artfacts")) if execution.ArtifactRequest != nil && execution.ArtifactRequest.StorageClassName != "" { pvcsClient := c.ClientSet.CoreV1().PersistentVolumeClaims(c.Namespace) @@ -380,13 +398,17 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, logs, err = executor.GetPodLogs(ctx, c.ClientSet, c.Namespace, pod) if err != nil { l.Errorw("get pod logs error", "error", err) + c.streamLog(ctx, execution.Id, events.NewErrorLog(err)) return execution.ExecutionResult, err } + // attachLogs only for previous version of logs, they are not needed here as will be passed from other sources + attachLogs := !c.features.LogsV2 // parse job output log (JSON stream) - execution.ExecutionResult, err = output.ParseRunnerOutput(logs) + execution.ExecutionResult, err = output.ParseRunnerOutput(logs, attachLogs) if err != nil { l.Errorw("parse output error", "error", err) + c.streamLog(ctx, execution.Id, events.NewErrorLog(errors.Wrap(err, "can't get test execution job output"))) return execution.ExecutionResult, err } @@ -397,6 +419,10 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, } execution.ExecutionResult.ErrorMessage = errorMessage + + c.streamLog(ctx, execution.Id, events.NewErrorLog(errors.Wrap(err, "test execution finished with failed state"))) + } else { + c.streamLog(ctx, execution.Id, events.NewLog("test execution finshed").WithMetadataEntry("status", string(*execution.ExecutionResult.Status))) } // saving result in the defer function @@ -409,21 +435,28 @@ func (c *JobExecutor) stopExecution(ctx context.Context, l *zap.SugaredLogger, e l.Errorw("get execution error", "error", err) return err } + + logEvent := events.NewLog().WithSource(events.SourceJobExecutor) + l.Debugw("stopping execution", "executionId", execution.Id, "status", result.Status, "executionStatus", execution.ExecutionResult.Status, "passedError", passedErr, "savedExecutionStatus", savedExecution.ExecutionResult.Status) + c.streamLog(ctx, execution.Id, logEvent.WithContent("stopping execution")) + defer c.streamLog(ctx, execution.Id, logEvent.WithContent("execution stopped")) + if savedExecution.IsCanceled() || savedExecution.IsTimeout() { + c.streamLog(ctx, execution.Id, logEvent.WithContent("execution is cancelled")) return nil } execution.Stop() if isNegativeTest { if result.IsFailed() { - l.Infow("test run was expected to fail, and it failed as expected", "test", execution.TestName) + l.Debugw("test run was expected to fail, and it failed as expected", "test", execution.TestName) execution.ExecutionResult.Status = testkube.ExecutionStatusPassed result.Status = testkube.ExecutionStatusPassed result.Output = result.Output + "\nTest run was expected to fail, and it failed as expected" } else { - l.Infow("test run was expected to fail - the result will be reversed", "test", execution.TestName) + l.Debugw("test run was expected to fail - the result will be reversed", "test", execution.TestName) execution.ExecutionResult.Status = testkube.ExecutionStatusFailed result.Status = testkube.ExecutionStatusFailed result.Output = result.Output + "\nTest run was expected to fail, the result will be reversed" @@ -738,6 +771,9 @@ func (c *JobExecutor) Timeout(ctx context.Context, jobName string) (result *test l.Errorw("error getting execution", "error", err) return } + + c.streamLog(ctx, execution.Id, events.NewLog("execution took too long, pod deadline exceeded")) + result = &testkube.ExecutionResult{ Status: testkube.ExecutionStatusTimeout, } @@ -748,6 +784,12 @@ func (c *JobExecutor) Timeout(ctx context.Context, jobName string) (result *test return } +func (c *JobExecutor) streamLog(ctx context.Context, id string, log *events.Log) { + if c.features.LogsV2 { + c.logsStream.Push(ctx, id, log) + } +} + // NewJobSpec is a method to create new job spec func NewJobSpec(log *zap.SugaredLogger, options JobOptions) (*batchv1.Job, error) { envManager := env.NewManager() diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index 875825fed1e..00f5f12e7ac 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -31,6 +31,7 @@ import ( "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/k8sclient" "github.com/kubeshop/testkube/pkg/log" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" testexecutionsmapper "github.com/kubeshop/testkube/pkg/mapper/testexecutions" testsmapper "github.com/kubeshop/testkube/pkg/mapper/tests" "github.com/kubeshop/testkube/pkg/telemetry" @@ -71,6 +72,8 @@ func NewContainerExecutor( apiURI string, natsUri string, debug bool, + logsStream logsclient.Stream, + features featureflags.FeatureFlags, ) (client *ContainerExecutor, err error) { clientSet, err := k8sclient.ConnectToK8s() if err != nil { @@ -99,6 +102,8 @@ func NewContainerExecutor( apiURI: apiURI, natsURI: natsUri, debug: debug, + logsStream: logsStream, + features: features, }, nil } @@ -129,6 +134,8 @@ type ContainerExecutor struct { apiURI string natsURI string debug bool + logsStream logsclient.Stream + features featureflags.FeatureFlags } type JobOptions struct { diff --git a/pkg/executor/output/parser.go b/pkg/executor/output/parser.go index c3445334e8f..8372d09dd94 100644 --- a/pkg/executor/output/parser.go +++ b/pkg/executor/output/parser.go @@ -36,11 +36,13 @@ func GetLogEntry(b []byte) (out Output, err error) { // {"type": "line", "message": "runner execution started ------------", "time": "..."} // {"type": "line", "message": "GET /results", "time": "..."} // {"type": "result", "result": {"id": "2323", "output": "-----"}, "time": "..."} -func ParseRunnerOutput(b []byte) (*testkube.ExecutionResult, error) { +func ParseRunnerOutput(b []byte, attachLogs bool) (*testkube.ExecutionResult, error) { result := &testkube.ExecutionResult{} if len(b) == 0 { errMessage := "no logs found" - result.Output = errMessage + if attachLogs { + result.Output = errMessage + } return result.Err(errors.New(errMessage)), nil } logs, err := parseLogs(b) @@ -69,7 +71,10 @@ func ParseRunnerOutput(b []byte) (*testkube.ExecutionResult, error) { default: result.Err(fmt.Errorf("wrong log type was found as last log: %v", log)) } - result.Output = sanitizeLogs(logs) + + if attachLogs { + result.Output = sanitizeLogs(logs) + } return result, nil } @@ -308,7 +313,7 @@ func getResultMessage(result testkube.ExecutionResult) string { return result.Output } - return fmt.Sprintf("%s", *result.Status) + return string(*result.Status) } // sameSeverity decides if a and b are of the same severity type diff --git a/pkg/executor/output/parser_test.go b/pkg/executor/output/parser_test.go index 045d3f033b3..cb842bb1f67 100644 --- a/pkg/executor/output/parser_test.go +++ b/pkg/executor/output/parser_test.go @@ -64,7 +64,7 @@ func TestParseRunnerOutput(t *testing.T) { t.Run("Empty runner output", func(t *testing.T) { t.Parallel() - result, err := ParseRunnerOutput([]byte{}) + result, err := ParseRunnerOutput([]byte{}, true) assert.Equal(t, "no logs found", result.Output) assert.NoError(t, err) @@ -75,7 +75,7 @@ func TestParseRunnerOutput(t *testing.T) { t.Parallel() invalidOutput := []byte(`{not a json}`) - result, err := ParseRunnerOutput(invalidOutput) + result, err := ParseRunnerOutput(invalidOutput, true) expectedErrMessage := "ERROR can't get log entry: invalid character 'n' looking for beginning of object key string, ((({not a json})))" assert.Equal(t, expectedErrMessage+"\n", result.Output) @@ -100,7 +100,7 @@ func TestParseRunnerOutput(t *testing.T) { {"type":"line","content":"\n # failure detail \n \n 1. Error \n connect ECONNREFUSED 127.0.0.1:8088 \n at request \n inside \"Health\" \n \n 2. AssertionError Status code is 200 \n expected { Object (id, _details, ...) } to have property 'code' \n at assertion:0 in test-script \n inside \"Health\" \n"} {"type":"result","result":{"status":"failed","startTime":"2021-10-29T11:35:35.759Z","endTime":"2021-10-29T11:35:36.771Z","output":"newman\n\nLocal-API-Health\n\n→ Health\n GET http://localhost:8088/health [errored]\n connect ECONNREFUSED 127.0.0.1:8088\n 2. Status code is 200\n\n┌─────────────────────────┬──────────┬──────────┐\n│ │ executed │ failed │\n├─────────────────────────┼──────────┼──────────┤\n│ iterations │ 1 │ 0 │\n├─────────────────────────┼──────────┼──────────┤\n│ requests │ 1 │ 1 │\n├─────────────────────────┼──────────┼──────────┤\n│ test-scripts │ 1 │ 0 │\n├─────────────────────────┼──────────┼──────────┤\n│ prerequest-scripts │ 0 │ 0 │\n├─────────────────────────┼──────────┼──────────┤\n│ assertions │ 1 │ 1 │\n├─────────────────────────┴──────────┴──────────┤\n│ total run duration: 1012ms │\n├───────────────────────────────────────────────┤\n│ total data received: 0B (approx) │\n└───────────────────────────────────────────────┘\n\n # failure detail \n \n 1. Error \n connect ECONNREFUSED 127.0.0.1:8088 \n at request \n inside \"Health\" \n \n 2. AssertionError Status code is 200 \n expected { Object (id, _details, ...) } to have property 'code' \n at assertion:0 in test-script \n inside \"Health\" \n","outputType":"text/plain","errorMessage":"process error: exit status 1","steps":[{"name":"Health","duration":"0s","status":"failed","assertionResults":[{"name":"Status code is 200","status":"failed","errorMessage":"expected { Object (id, _details, ...) } to have property 'code'"}]}]}} `) - result, err := ParseRunnerOutput(exampleOutput) + result, err := ParseRunnerOutput(exampleOutput, true) assert.Len(t, result.Output, 4624) assert.NoError(t, err) @@ -150,7 +150,7 @@ func TestParseRunnerOutput(t *testing.T) { {"type":"line","content":"✅ Got Newman result successfully","time":"2023-07-18T19:12:46.126116248Z"} {"type":"line","content":"✅ Mapped Newman result successfully","time":"2023-07-18T19:12:46.126152021Z"} {"type":"result","result":{"status":"passed","output":"newman\n\nCore App Tests - WebPlayer\n\n→ core-eks-test.poppcore.co client=testdb sign=testct1 company=41574150-b952-413b-898b-dc5336b4bd12\n GET https://na.com/v6-wplt/?client=testdb\u0026sign=testct1\u0026company=41574150-b952-413b-898b-dc5336b4bd12 [200 OK, 33.9kB, 326ms]\n ✓ Status code is 200\n\n┌─────────────────────────┬────────────────────┬───────────────────┐\n│ │ executed │ failed │\n├─────────────────────────┼────────────────────┼───────────────────┤\n│ iterations │ 1 │ 0 │\n├─────────────────────────┼────────────────────┼───────────────────┤\n│ requests │ 1 │ 0 │\n├─────────────────────────┼────────────────────┼───────────────────┤\n│ test-scripts │ 1 │ 0 │\n├─────────────────────────┼────────────────────┼───────────────────┤\n│ prerequest-scripts │ 0 │ 0 │\n├─────────────────────────┼────────────────────┼───────────────────┤\n│ assertions │ 1 │ 0 │\n├─────────────────────────┴────────────────────┴───────────────────┤\n│ total run duration: 429ms │\n├──────────────────────────────────────────────────────────────────┤\n│ total data received: 33.45kB (approx) │\n├──────────────────────────────────────────────────────────────────┤\n│ average response time: 326ms [min: 326ms, max: 326ms, s.d.: 0µs] │\n└──────────────────────────────────────────────────────────────────┘\n","outputType":"text/plain","steps":[{"name":"na.com client=testdb sign=testct1 company=41574150-b952-413b-898b-dc5336b4bd12","duration":"326ms","status":"passed","assertionResults":[{"name":"Status code is 200","status":"passed"}]}]},"time":"2023-07-18T19:12:46.12615853Z"}`) - result, err := ParseRunnerOutput(exampleOutput) + result, err := ParseRunnerOutput(exampleOutput, true) assert.Len(t, result.Output, 7304) assert.NoError(t, err) @@ -203,7 +203,7 @@ can't find branch or commit in params, repo:&{Type_:git-file Uri:https://github. running test [63c6bec1790802b7e3e57048] 🚚 Preparing for test run ` - result, err := ParseRunnerOutput(unorderedOutput) + result, err := ParseRunnerOutput(unorderedOutput, true) assert.Equal(t, expectedOutput, result.Output) assert.NoError(t, err) @@ -221,7 +221,7 @@ running test [63c6bec1790802b7e3e57048] Running [ ./zap-api-scan.py [-t https://www.example.com/openapi.json -f openapi -c examples/zap-api.conf -d -D 5 -I -l INFO -n examples/context.config -S -T 60 -U anonymous -O https://www.example.com -z -config aaa=bbb -r api-test-report.html]] could not start process: fork/exec ./zap-api-scan.py: no such file or directory ` - result, err := ParseRunnerOutput(output) + result, err := ParseRunnerOutput(output, true) assert.Equal(t, expectedOutput, result.Output) assert.NoError(t, err) @@ -276,7 +276,7 @@ running test [63c960287104b0fa0b7a45ef] can't find branch or commit in params, repo:&{Type_:git-file Uri:https://github.com/kubeshop/testkube.git Branch: Commit: Path:test/cypress/executor-smoke/cypress-11 Username: Token: UsernameSecret: TokenSecret: WorkingDir:} ` - result, err := ParseRunnerOutput(output) + result, err := ParseRunnerOutput(output, true) assert.Equal(t, expectedOutput, result.Output) assert.NoError(t, err) @@ -337,7 +337,7 @@ running test [63c960287104b0fa0b7a45ef] can't find branch or commit in params, repo:&{Type_:git-file Uri:https://github.com/kubeshop/testkube.git Branch: Commit: Path:test/cypress/executor-smoke/cypress-11 Username: Token: UsernameSecret: TokenSecret: WorkingDir:} ` - result, err := ParseRunnerOutput(output) + result, err := ParseRunnerOutput(output, true) assert.Equal(t, expectedOutput, result.Output) assert.NoError(t, err) @@ -392,7 +392,7 @@ running test [63ca8c8988564860327a16b5] ❌ can't find branch or commit in params, repo:&{Type_:git-file Uri:https://github.com/kubeshop/testkube.git Branch: Commit: Path:test/cypress/executor-smoke/cypress-11 Username: Token: UsernameSecret: TokenSecret: WorkingDir:} ` - result, err := ParseRunnerOutput(output) + result, err := ParseRunnerOutput(output, true) assert.Equal(t, expectedOutput, result.Output) assert.NoError(t, err) diff --git a/pkg/logs/client/interface.go b/pkg/logs/client/interface.go index 58458f23cd3..edeed1bc248 100644 --- a/pkg/logs/client/interface.go +++ b/pkg/logs/client/interface.go @@ -44,9 +44,9 @@ type StreamInitializer interface { type StreamPusher interface { // Push sends logs to log stream - Push(ctx context.Context, id string, chunk events.Log) error + Push(ctx context.Context, id string, log *events.Log) error // PushBytes sends RAW bytes to log stream, developer is responsible for marshaling valid data - PushBytes(ctx context.Context, id string, chunk []byte) error + PushBytes(ctx context.Context, id string, bytes []byte) error } // StreamGetter interface for getting logs stream channel diff --git a/pkg/logs/client/mock_initializedstreampusher.go b/pkg/logs/client/mock_initializedstreampusher.go index 9892c9b9ef2..8d67710a751 100644 --- a/pkg/logs/client/mock_initializedstreampusher.go +++ b/pkg/logs/client/mock_initializedstreampusher.go @@ -51,7 +51,7 @@ func (mr *MockInitializedStreamPusherMockRecorder) Init(arg0, arg1 interface{}) } // Push mocks base method. -func (m *MockInitializedStreamPusher) Push(arg0 context.Context, arg1 string, arg2 events.Log) error { +func (m *MockInitializedStreamPusher) Push(arg0 context.Context, arg1 string, arg2 *events.Log) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Push", arg0, arg1, arg2) ret0, _ := ret[0].(error) diff --git a/pkg/logs/client/mock_stream.go b/pkg/logs/client/mock_stream.go index 4b605daacc9..eb0cb02cbe2 100644 --- a/pkg/logs/client/mock_stream.go +++ b/pkg/logs/client/mock_stream.go @@ -66,7 +66,7 @@ func (mr *MockStreamMockRecorder) Init(arg0, arg1 interface{}) *gomock.Call { } // Push mocks base method. -func (m *MockStream) Push(arg0 context.Context, arg1 string, arg2 events.Log) error { +func (m *MockStream) Push(arg0 context.Context, arg1 string, arg2 *events.Log) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Push", arg0, arg1, arg2) ret0, _ := ret[0].(error) diff --git a/pkg/logs/client/stream.go b/pkg/logs/client/stream.go index e8bccf966d6..9e20732d78f 100644 --- a/pkg/logs/client/stream.go +++ b/pkg/logs/client/stream.go @@ -51,8 +51,8 @@ func (c NatsLogStream) Init(ctx context.Context, id string) (StreamMetadata, err } // Push log chunk to NATS stream -func (c NatsLogStream) Push(ctx context.Context, id string, chunk events.Log) error { - b, err := json.Marshal(chunk) +func (c NatsLogStream) Push(ctx context.Context, id string, log *events.Log) error { + b, err := json.Marshal(log) if err != nil { return err } @@ -61,8 +61,8 @@ func (c NatsLogStream) Push(ctx context.Context, id string, chunk events.Log) er // Push log chunk to NATS stream // TODO handle message repeat with backoff strategy on error -func (c NatsLogStream) PushBytes(ctx context.Context, id string, chunk []byte) error { - _, err := c.js.Publish(ctx, c.streamName(id), chunk) +func (c NatsLogStream) PushBytes(ctx context.Context, id string, bytes []byte) error { + _, err := c.js.Publish(ctx, c.streamName(id), bytes) return err } diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 6697c978d0a..0febcf09c4c 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -24,7 +24,10 @@ const ( // v2 - raw binary format, timestamps are based on Kubernetes logs, line is raw log line LogVersionV2 LogVersion = "v2" - JobPodLogSource = "job-pod" + SourceJobPod = "job-pod" + SourceScheduler = "test-scheduler" + SourceContainerExecutor = "container-executor" + SourceJobExecutor = "job-executor" ) type LogResponse struct { @@ -48,20 +51,43 @@ type LogOutputV1 struct { Result *testkube.ExecutionResult } -func NewLog(content string) *Log { +func NewErrorLog(err error) *Log { + var msg string + if err != nil { + msg = err.Error() + } return &Log{ + Error: true, + Content: msg, + } +} + +func NewLog(content ...string) *Log { + log := &Log{ Time: time.Now(), - Content: string(content), Metadata: map[string]string{}, } + + if len(content) > 0 { + log.WithContent(content[0]) + } + + return log } -func NewLogResponse(ts time.Time, content []byte) Log { - return Log{ - Time: ts, - Content: string(content), - Metadata: map[string]string{}, +func (l *Log) WithContent(s string) *Log { + l.Content = s + return l +} + +func (l *Log) WithError(err error) *Log { + l.Error = true + + if err != nil { + l.Content = err.Error() } + + return l } func (l *Log) WithMetadataEntry(key, value string) *Log { @@ -94,9 +120,9 @@ func (l *Log) WithV1Result(result *testkube.ExecutionResult) *Log { var timestampRegexp = regexp.MustCompile("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*") -// NewLogResponseFromBytes creates new LogResponse from bytes it's aware of new and old log formats +// NewLogFromBytes creates new LogResponse from bytes it's aware of new and old log formats // default log format will be based on raw bytes with timestamp on the beginning -func NewLogResponseFromBytes(b []byte) Log { +func NewLogFromBytes(b []byte) *Log { // detect timestamp - new logs have timestamp var ( @@ -138,20 +164,19 @@ func NewLogResponseFromBytes(b []byte) Log { if err != nil { // try to read in case of some lines which we couldn't parse // sometimes we're not able to control all stdout messages from libs - return Log{ + return &Log{ Time: ts, Content: err.Error(), Type: o.Type_, Error: true, Version: LogVersionV1, - Source: JobPodLogSource, } } // pass parsed results for v1 // for new executor it'll be omitted in logs (as looks like we're not using it already) if o.Type_ == output.TypeResult { - return Log{ + return &Log{ Time: ts, Content: o.Content, Version: LogVersionV1, @@ -161,7 +186,7 @@ func NewLogResponseFromBytes(b []byte) Log { } } - return Log{ + return &Log{ Time: ts, Content: o.Content, Version: LogVersionV1, @@ -170,10 +195,9 @@ func NewLogResponseFromBytes(b []byte) Log { // END DEPRECATED // new non-JSON format (just raw lines will be logged) - return Log{ + return &Log{ Time: ts, Content: string(b), Version: LogVersionV2, - Source: JobPodLogSource, } } diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 6986c0154d6..fefbc3a3b4f 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -79,8 +79,8 @@ func TestLogs_EventsFlow(t *testing.T) { assert.NoError(t, err) // and when data pushed to the log stream - stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) - stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 2"))) + stream.Push(ctx, id, events.NewLog("hello 1")) + stream.Push(ctx, id, events.NewLog("hello 2")) // and stop event triggered _, err = stream.Stop(ctx, id) @@ -154,7 +154,7 @@ func TestLogs_EventsFlow(t *testing.T) { for i := 0; i < messagesCount; i++ { // and when data pushed to the log stream - err = stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello"))) + err = stream.Push(ctx, id, events.NewLog("hello")) assert.NoError(t, err) } @@ -226,9 +226,9 @@ func TestLogs_EventsFlow(t *testing.T) { stats := log.GetConsumersStats(ctx) assert.Equal(t, 2, stats.Count) - stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) - stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) - stream.Push(ctx, id, events.NewLogResponse(time.Now(), []byte("hello 1"))) + stream.Push(ctx, id, events.NewLog("hello 1")) + stream.Push(ctx, id, events.NewLog("hello 1")) + stream.Push(ctx, id, events.NewLog("hello 1")) // when stop event triggered r, err := stream.Stop(ctx, id) diff --git a/pkg/logs/sidecar/proxy.go b/pkg/logs/sidecar/proxy.go index d7c9aad161d..351c705ad21 100644 --- a/pkg/logs/sidecar/proxy.go +++ b/pkg/logs/sidecar/proxy.go @@ -63,7 +63,7 @@ func (p *Proxy) Run(ctx context.Context) error { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - logs := make(chan events.Log, logsBuffer) + logs := make(chan *events.Log, logsBuffer) // create stream for incoming logs _, err := p.logsStream.Init(ctx, p.executionId) @@ -83,6 +83,7 @@ func (p *Proxy) Run(ctx context.Context) error { select { case <-sigs: p.log.Warn("logs proxy received signal, exiting", "signal", sigs) + p.handleError(ErrStopSignalReceived, "context cancelled stopping logs proxy") return ErrStopSignalReceived case <-ctx.Done(): p.log.Warn("logs proxy context cancelled, exiting") @@ -100,7 +101,7 @@ func (p *Proxy) Run(ctx context.Context) error { return nil } -func (p *Proxy) streamLogs(ctx context.Context, logs chan events.Log) (err error) { +func (p *Proxy) streamLogs(ctx context.Context, logs chan *events.Log) (err error) { pods, err := executor.GetJobPods(ctx, p.podsClient, p.executionId, 1, 10) if err != nil { p.handleError(err, "error getting job pods") @@ -138,7 +139,7 @@ func (p *Proxy) streamLogs(ctx context.Context, logs chan events.Log) (err error return } -func (p *Proxy) streamLogsFromPod(pod corev1.Pod, logs chan events.Log) (err error) { +func (p *Proxy) streamLogsFromPod(pod corev1.Pod, logs chan *events.Log) (err error) { defer close(logs) var containers []string @@ -182,7 +183,8 @@ func (p *Proxy) streamLogsFromPod(pod corev1.Pod, logs chan events.Log) (err err } // parse log line - also handle old (output.Output) and new format (just unstructured []byte) - logs <- events.NewLogResponseFromBytes(b) + logs <- events.NewLogFromBytes(b). + WithSource(events.SourceJobPod) } if err != nil { @@ -239,16 +241,9 @@ func (p *Proxy) getPodContainerStatuses(pod corev1.Pod) (status string) { // handleError will handle errors and push it as log chunk to logs stream func (p *Proxy) handleError(err error, title string) { if err != nil { - ch := events.Log{ - Error: true, - Content: err.Error(), - } - p.log.Errorw(title, "error", err) - - if err == nil { - p.logsStream.Push(context.Background(), p.executionId, ch) - } else { + err = p.logsStream.Push(context.Background(), p.executionId, events.NewErrorLog(err)) + if err != nil { p.log.Errorw("error pushing error to stream", "title", title, "error", err) } diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index b64a58ee678..9eb8482b380 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -139,16 +139,14 @@ func (s *Scheduler) handleExecutionStart(ctx context.Context, execution testkube l := events.NewLog(fmt.Sprintf("starting execution %s (%s)", execution.Name, execution.Id)). WithType("execution-config"). WithVersion(events.LogVersionV2). - WithSource("test-scheduler") - - // TODO try to store map[strin]any through protobuf for now it'll be map[string]string - l.WithMetadataEntry("command", strings.Join(execution.Command, " ")) - l.WithMetadataEntry("argsmode", execution.ArgsMode) - l.WithMetadataEntry("args", strings.Join(execution.Args, " ")) - l.WithMetadataEntry("pre-run", execution.PreRunScript) - l.WithMetadataEntry("post-run", execution.PostRunScript) + WithSource("test-scheduler"). + WithMetadataEntry("command", strings.Join(execution.Command, " ")). + WithMetadataEntry("argsmode", execution.ArgsMode). + WithMetadataEntry("args", strings.Join(execution.Args, " ")). + WithMetadataEntry("pre-run", execution.PreRunScript). + WithMetadataEntry("post-run", execution.PostRunScript) - s.logsStream.Push(ctx, execution.Id, *l) + s.logsStream.Push(ctx, execution.Id, l) } } @@ -160,7 +158,7 @@ func (s *Scheduler) handleExecutionError(ctx context.Context, execution testkube WithVersion(events.LogVersionV2). WithSource("test-scheduler") - s.logsStream.Push(ctx, execution.Id, *l) + s.logsStream.Push(ctx, execution.Id, l) } From 22f002204ceea5a2f526d40b8a94dcd50e7f3188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:44:10 +0100 Subject: [PATCH 042/234] build: bump github.com/go-playground/locales from 0.14.0 to 0.14.1 (#4869) Bumps [github.com/go-playground/locales](https://github.com/go-playground/locales) from 0.14.0 to 0.14.1. - [Release notes](https://github.com/go-playground/locales/releases) - [Commits](https://github.com/go-playground/locales/compare/v0.14.0...v0.14.1) --- updated-dependencies: - dependency-name: github.com/go-playground/locales dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 391c1805158..9bab75da059 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,7 @@ require ( github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect - github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/validator/v10 v10.11.1 // indirect github.com/google/gnostic-models v0.6.8 // indirect diff --git a/go.sum b/go.sum index 98d42309225..d28315e82d6 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,9 @@ github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogB github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= From 84ba40f216fbf19138f6d3b79d86bd9586ca53c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:44:21 +0100 Subject: [PATCH 043/234] build: bump github.com/itchyny/gojq from 0.12.9 to 0.12.14 (#4870) Bumps [github.com/itchyny/gojq](https://github.com/itchyny/gojq) from 0.12.9 to 0.12.14. - [Release notes](https://github.com/itchyny/gojq/releases) - [Changelog](https://github.com/itchyny/gojq/blob/main/CHANGELOG.md) - [Commits](https://github.com/itchyny/gojq/compare/v0.12.9...v0.12.14) --- updated-dependencies: - dependency-name: github.com/itchyny/gojq dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9bab75da059..c362b04a51c 100644 --- a/go.mod +++ b/go.mod @@ -92,8 +92,8 @@ require ( github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.1 // indirect github.com/henvic/httpretty v0.1.0 // indirect - github.com/itchyny/gojq v0.12.9 // indirect - github.com/itchyny/timefmt-go v0.1.4 // indirect + github.com/itchyny/gojq v0.12.14 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index d28315e82d6..f528988818d 100644 --- a/go.sum +++ b/go.sum @@ -313,10 +313,10 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/itchyny/gojq v0.12.9 h1:biKpbKwMxVYhCU1d6mR7qMr3f0Hn9F5k5YykCVb3gmM= -github.com/itchyny/gojq v0.12.9/go.mod h1:T4Ip7AETUXeGpD+436m+UEl3m3tokRgajd5pRfsR5oE= -github.com/itchyny/timefmt-go v0.1.4 h1:hFEfWVdwsEi+CY8xY2FtgWHGQaBaC3JeHd+cve0ynVM= -github.com/itchyny/timefmt-go v0.1.4/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= +github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc= +github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s= +github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= +github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= From d1cdc4b4af0b3c2ba302ef278528c3d23ad0317c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:44:41 +0100 Subject: [PATCH 044/234] build: bump github.com/charmbracelet/glamour (#4871) Bumps [github.com/charmbracelet/glamour](https://github.com/charmbracelet/glamour) from 0.5.1-0.20220727184942-e70ff2d969da to 0.6.0. - [Release notes](https://github.com/charmbracelet/glamour/releases) - [Commits](https://github.com/charmbracelet/glamour/commits/v0.6.0) --- updated-dependencies: - dependency-name: github.com/charmbracelet/glamour dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c362b04a51c..3319b5c83c6 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/briandowns/spinner v1.19.0 // indirect - github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da // indirect + github.com/charmbracelet/glamour v0.6.0 // indirect github.com/cli/browser v1.1.0 // indirect github.com/cli/go-gh v0.1.3-0.20221102170023-e3ec45fb1d1b // indirect github.com/cli/safeexec v1.0.0 // indirect diff --git a/go.sum b/go.sum index f528988818d..830c3359af2 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,7 @@ github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -95,8 +96,8 @@ github.com/cdevents/sdk-go v0.3.0/go.mod h1:8EFl9VDZkxEmO/sr06Phzr501OiU6B5d04+e github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM= -github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94= +github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= +github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -369,7 +370,6 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -386,7 +386,6 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfr github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= @@ -413,7 +412,7 @@ github.com/moogar0880/problems v0.1.1 h1:bktLhq8NDG/czU2ZziYNigBFksx13RaYe5AVdNm github.com/moogar0880/problems v0.1.1/go.mod h1:5Dxrk2sD7BfBAgnOzQ1yaTiuCYdGPUh49L8Vhfky62c= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0= github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -570,8 +569,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= @@ -678,7 +677,6 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -686,6 +684,7 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= @@ -756,7 +755,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From da67e43edf282456db118fdd77f17039e69bca3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:45:00 +0100 Subject: [PATCH 045/234] build: bump github.com/prometheus/client_golang from 1.16.0 to 1.18.0 (#4873) Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.16.0 to 1.18.0. - [Release notes](https://github.com/prometheus/client_golang/releases) - [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md) - [Commits](https://github.com/prometheus/client_golang/compare/v1.16.0...v1.18.0) --- updated-dependencies: - dependency-name: github.com/prometheus/client_golang dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 3319b5c83c6..0e1a2e19611 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/onsi/ginkgo/v2 v2.13.2 github.com/onsi/gomega v1.30.0 github.com/otiai10/copy v1.11.0 - github.com/prometheus/client_golang v1.16.0 + github.com/prometheus/client_golang v1.18.0 github.com/pterm/pterm v0.12.62 github.com/segmentio/analytics-go/v3 v3.2.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 @@ -103,6 +103,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/microcosm-cc/bluemonday v1.0.21 // indirect github.com/minio/highwayhash v1.0.2 // indirect @@ -155,15 +156,14 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.2 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.4.0 // indirect - github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/robfig/cron v1.2.0 github.com/rs/xid v1.4.0 // indirect diff --git a/go.sum b/go.sum index 830c3359af2..d015bcef8e9 100644 --- a/go.sum +++ b/go.sum @@ -381,8 +381,8 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -454,13 +454,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= -github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= From 26ffb0a5f7334c366d7d560744d934b93198d5f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:45:12 +0100 Subject: [PATCH 046/234] build: bump github.com/emicklei/go-restful/v3 from 3.11.0 to 3.11.2 (#4872) Bumps [github.com/emicklei/go-restful/v3](https://github.com/emicklei/go-restful) from 3.11.0 to 3.11.2. - [Release notes](https://github.com/emicklei/go-restful/releases) - [Changelog](https://github.com/emicklei/go-restful/blob/v3/CHANGES.md) - [Commits](https://github.com/emicklei/go-restful/compare/v3.11.0...v3.11.2) --- updated-dependencies: - dependency-name: github.com/emicklei/go-restful/v3 dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0e1a2e19611..81ed4455878 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( github.com/cli/shurcooL-graphql v0.0.2 // indirect github.com/containerd/console v1.0.3 // indirect github.com/dlclark/regexp2 v1.8.0 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/emicklei/go-restful/v3 v3.11.2 // indirect github.com/evanphx/json-patch/v5 v5.7.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/go-errors/errors v1.5.1 // indirect diff --git a/go.sum b/go.sum index d015bcef8e9..3503a6a7c5b 100644 --- a/go.sum +++ b/go.sum @@ -143,8 +143,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 h1:90Ly+6UfUypEF6vvvW5rQIv9opIL8CbmW9FT20LDQoY= github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0/go.mod h1:V+Qd57rJe8gd4eiGzZyg4h54VLHmYVVw54iMnlAMrF8= -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/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= +github.com/emicklei/go-restful/v3 v3.11.2/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= From 56f6d67a66eac57845104c098f695f3d7b51d6f7 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 25 Jan 2024 15:48:26 +0100 Subject: [PATCH 047/234] fix: added adapter stop (#4948) --- pkg/logs/events.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/logs/events.go b/pkg/logs/events.go index 91bb1c59fe4..c8d8ab7512a 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -187,8 +187,15 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { wg.Add(1) stopped++ consumer := c.(Consumer) - go ls.stopConsumer(ctx, &wg, consumer) + + // call adapter stop to handle given id + err := adapter.Stop(event.Id) + if err != nil { + l.Errorw("stop error", "adapter", adapter.Name(), "error", err) + continue + } + } wg.Wait() @@ -200,7 +207,6 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { } else { l.Debugw("no consumers found on this pod to stop") } - } } @@ -231,7 +237,9 @@ func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, con // check if there was some messages processed if nothingToProcess && messagesDelivered { + // stop nats consumer consumer.Context.Stop() + // delete nats consumer instance from memory ls.consumerInstances.Delete(consumer.Name) l.Infow("stopping and removing consumer", "name", consumer.Name, "consumerSeq", info.Delivered.Consumer, "streamSeq", info.Delivered.Stream, "last", info.Delivered.Last) return From f1e726bf00cb10c6ddf15a821b85d8b84b37646c Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 25 Jan 2024 16:59:00 +0100 Subject: [PATCH 048/234] fix: adapter stop call after consumer stop (#4950) --- pkg/logs/events.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/logs/events.go b/pkg/logs/events.go index c8d8ab7512a..cb3ac9a5edf 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -187,14 +187,7 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { wg.Add(1) stopped++ consumer := c.(Consumer) - go ls.stopConsumer(ctx, &wg, consumer) - - // call adapter stop to handle given id - err := adapter.Stop(event.Id) - if err != nil { - l.Errorw("stop error", "adapter", adapter.Name(), "error", err) - continue - } + go ls.stopConsumer(ctx, &wg, consumer, adapter, event.Id) } @@ -210,7 +203,7 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { } } -func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, consumer Consumer) { +func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, consumer Consumer, adapter adapter.Adapter, id string) { defer wg.Done() var ( @@ -242,6 +235,14 @@ func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, con // delete nats consumer instance from memory ls.consumerInstances.Delete(consumer.Name) l.Infow("stopping and removing consumer", "name", consumer.Name, "consumerSeq", info.Delivered.Consumer, "streamSeq", info.Delivered.Stream, "last", info.Delivered.Last) + + // call adapter stop to handle given id + err := adapter.Stop(id) + if err != nil { + l.Errorw("stop error", "adapter", adapter.Name(), "error", err) + continue + } + return } From 83fa883a38f4a083c0dc51fc81dac6a23842d327 Mon Sep 17 00:00:00 2001 From: nicufk Date: Fri, 26 Jan 2024 12:21:41 +0200 Subject: [PATCH 049/234] feat: add minio adapter for logs (#4942) * feat: add minio adapter for logs * fix: add all the minio config files * fix: tests * fix: parameters in creating * fix: add more log info * fix: rename minio adapter --- cmd/logs/main.go | 19 +++++++++++++++++- pkg/logs/adapter/dummy.go | 16 ++++++++-------- pkg/logs/adapter/minio.go | 31 +++++++++++++++++------------- pkg/logs/adapter/minio_test.go | 13 ++++++------- pkg/logs/config/logs_config.go | 35 ++++++++++++++++++++++------------ pkg/logs/service_test.go | 8 ++++---- 6 files changed, 77 insertions(+), 45 deletions(-) diff --git a/cmd/logs/main.go b/cmd/logs/main.go index c642ad5d467..d4a6f93a0b8 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -55,7 +55,24 @@ func main() { WithGrpcAddress(cfg.GrpcAddress) // TODO - add adapters here - svc.AddAdapter(adapter.NewDummyAdapter()) + minioAdapter, err := adapter.NewMinioAdapter(cfg.StorageEndpoint, + cfg.StorageAccessKeyID, + cfg.StorageSecretAccessKey, + cfg.StorageRegion, + cfg.StorageToken, + cfg.StorageLogsBucket, + cfg.StorageSSL, + cfg.StorageSkipVerify, + cfg.StorageCertFile, + cfg.StorageKeyFile, + cfg.StorageCAFile) + if err != nil { + log.Errorw("error creating minio adapter, debug adapter created instead", "error", err) + svc.AddAdapter(adapter.NewDebugAdapter()) + } else { + log.Infow("minio adapter created", "bucket", cfg.StorageLogsBucket, "endpoint", cfg.StorageEndpoint) + svc.AddAdapter(minioAdapter) + } g.Add(func() error { err := interrupt(log, ctx) diff --git a/pkg/logs/adapter/dummy.go b/pkg/logs/adapter/dummy.go index 993e47254cc..489ee3af206 100644 --- a/pkg/logs/adapter/dummy.go +++ b/pkg/logs/adapter/dummy.go @@ -6,27 +6,27 @@ import ( "github.com/kubeshop/testkube/pkg/logs/events" ) -var _ Adapter = &DummyAdapter{} +var _ Adapter = &DebugAdapter{} -// NewS3Subscriber creates new DummySubscriber which will send data to local MinIO bucket -func NewDummyAdapter() *DummyAdapter { - return &DummyAdapter{} +// NewDebugAdapter creates new DebugAdapter which will write logs to stdout +func NewDebugAdapter() *DebugAdapter { + return &DebugAdapter{} } -type DummyAdapter struct { +type DebugAdapter struct { Bucket string } -func (s *DummyAdapter) Notify(id string, e events.Log) error { +func (s *DebugAdapter) Notify(id string, e events.Log) error { fmt.Printf("%s %+v\n", id, e) return nil } -func (s *DummyAdapter) Stop(id string) error { +func (s *DebugAdapter) Stop(id string) error { fmt.Printf("stopping %s \n", id) return nil } -func (s *DummyAdapter) Name() string { +func (s *DebugAdapter) Name() string { return "dummy" } diff --git a/pkg/logs/adapter/minio.go b/pkg/logs/adapter/minio.go index 25d8f15a3f2..63cba09d8b7 100644 --- a/pkg/logs/adapter/minio.go +++ b/pkg/logs/adapter/minio.go @@ -21,7 +21,7 @@ const ( defaultWriteSize = 1024 * 80 // 80KB ) -var _ Adapter = &MinioConsumer{} +var _ Adapter = &MinioAdapter{} type ErrMinioConsumerDisconnected struct { } @@ -52,9 +52,10 @@ type BufferInfo struct { } // MinioConsumer creates new MinioSubscriber which will send data to local MinIO bucket -func NewMinioConsumer(endpoint, accessKeyID, secretAccessKey, region, token, bucket string, opts ...minioconnecter.Option) (*MinioConsumer, error) { +func NewMinioAdapter(endpoint, accessKeyID, secretAccessKey, region, token, bucket string, ssl, skipVerify bool, certFile, keyFile, caFile string) (*MinioAdapter, error) { ctx := context.TODO() - c := &MinioConsumer{ + opts := minioconnecter.GetTLSOptions(ssl, skipVerify, certFile, keyFile, caFile) + c := &MinioAdapter{ minioConnecter: minioconnecter.NewConnecter(endpoint, accessKeyID, secretAccessKey, region, token, bucket, log.DefaultLogger, opts...), Log: log.DefaultLogger, bucket: bucket, @@ -86,7 +87,7 @@ func NewMinioConsumer(endpoint, accessKeyID, secretAccessKey, region, token, buc return c, nil } -type MinioConsumer struct { +type MinioAdapter struct { minioConnecter *minioconnecter.Connecter minioClient *minio.Client bucket string @@ -97,7 +98,8 @@ type MinioConsumer struct { mapLock sync.RWMutex } -func (s *MinioConsumer) Notify(id string, e events.Log) error { +func (s *MinioAdapter) Notify(id string, e events.Log) error { + s.Log.Debugw("minio consumer notify", "id", id, "event", e) if s.disconnected { s.Log.Debugw("minio consumer disconnected", "id", id) return ErrMinioConsumerDisconnected{} @@ -138,19 +140,20 @@ func (s *MinioConsumer) Notify(id string, e events.Log) error { return nil } -func (s *MinioConsumer) putData(ctx context.Context, name string, buffer *bytes.Buffer) { +func (s *MinioAdapter) putData(ctx context.Context, name string, buffer *bytes.Buffer) { if buffer != nil && buffer.Len() != 0 { _, err := s.minioClient.PutObject(ctx, s.bucket, name, buffer, int64(buffer.Len()), minio.PutObjectOptions{ContentType: "application/octet-stream"}) if err != nil { s.Log.Errorw("error putting object", "err", err) } + s.Log.Debugw("put object successfully", "name", name, "s.bucket", s.bucket) } else { s.Log.Warn("empty buffer for name: ", name) } } -func (s *MinioConsumer) combineData(ctxt context.Context, minioClient *minio.Client, id string, parts int, deleteIntermediaryData bool) error { +func (s *MinioAdapter) combineData(ctxt context.Context, minioClient *minio.Client, id string, parts int, deleteIntermediaryData bool) error { var returnedError []error returnedError = nil buffer := bytes.NewBuffer(make([]byte, 0, parts*defaultBufferSize)) @@ -174,6 +177,7 @@ func (s *MinioConsumer) combineData(ctxt context.Context, minioClient *minio.Cli s.Log.Errorw("error putting object", "err", err) return err } + s.Log.Debugw("put object successfully", "id", id, "s.bucket", s.bucket, "parts", parts) if deleteIntermediaryData { for i := 0; i < parts; i++ { @@ -194,12 +198,13 @@ func (s *MinioConsumer) combineData(ctxt context.Context, minioClient *minio.Cli return fmt.Errorf("executed with errors: %v", returnedError) } -func (s *MinioConsumer) objectExists(objectName string) bool { +func (s *MinioAdapter) objectExists(objectName string) bool { _, err := s.minioClient.StatObject(context.Background(), s.bucket, objectName, minio.StatObjectOptions{}) return err == nil } -func (s *MinioConsumer) Stop(id string) error { +func (s *MinioAdapter) Stop(id string) error { + s.Log.Debugw("minio consumer stop", "id", id) ctx := context.TODO() buffInfo, ok := s.GetBuffInfo(id) if !ok { @@ -212,24 +217,24 @@ func (s *MinioConsumer) Stop(id string) error { return s.combineData(ctx, s.minioClient, id, parts, true) } -func (s *MinioConsumer) Name() string { +func (s *MinioAdapter) Name() string { return "minio" } -func (s *MinioConsumer) GetBuffInfo(id string) (BufferInfo, bool) { +func (s *MinioAdapter) GetBuffInfo(id string) (BufferInfo, bool) { s.mapLock.RLock() defer s.mapLock.RUnlock() buffInfo, ok := s.buffInfos[id] return buffInfo, ok } -func (s *MinioConsumer) UpdateBuffInfo(id string, buffInfo BufferInfo) { +func (s *MinioAdapter) UpdateBuffInfo(id string, buffInfo BufferInfo) { s.mapLock.Lock() defer s.mapLock.Unlock() s.buffInfos[id] = buffInfo } -func (s *MinioConsumer) DeleteBuffInfo(id string) { +func (s *MinioAdapter) DeleteBuffInfo(id string) { s.mapLock.Lock() defer s.mapLock.Unlock() delete(s.buffInfos, id) diff --git a/pkg/logs/adapter/minio_test.go b/pkg/logs/adapter/minio_test.go index 307e590b5f7..4c98e7ab1a5 100644 --- a/pkg/logs/adapter/minio_test.go +++ b/pkg/logs/adapter/minio_test.go @@ -17,7 +17,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/kubeshop/testkube/pkg/logs/events" - minioconnecter "github.com/kubeshop/testkube/pkg/storage/minio" "github.com/kubeshop/testkube/pkg/utils" ) @@ -39,7 +38,7 @@ func RandString(n int) string { func TestLogs(t *testing.T) { t.Skip("skipping test") - consumer, _ := NewMinioConsumer("localhost:9000", "minio", "minio123", "", "", "test-1", minioconnecter.Insecure()) + consumer, _ := NewMinioAdapter("localhost:9000", "minio", "minio123", "", "", "test-1", false, false, "", "", "") id := "test-bla" for i := 0; i < 1000; i++ { fmt.Println("sending", i) @@ -55,7 +54,7 @@ func TestLogs(t *testing.T) { func BenchmarkLogs(b *testing.B) { randomString := RandString(5) bucket := "test-bench" - consumer, _ := NewMinioConsumer("localhost:9000", "minio", "minio123", "", "", bucket, minioconnecter.Insecure()) + consumer, _ := NewMinioAdapter("localhost:9000", "minio", "minio123", "", "", bucket, false, false, "", "", "") id := "test-bench" + "-" + randomString + "-" + strconv.Itoa(b.N) totalSize := 0 for i := 0; i < b.N; i++ { @@ -72,7 +71,7 @@ func BenchmarkLogs(b *testing.B) { func BenchmarkLogs2(b *testing.B) { bucket := "test-bench" - consumer, _ := NewMinioConsumer("localhost:9000", "minio", "minio123", "", "", bucket, minioconnecter.Insecure()) + consumer, _ := NewMinioAdapter("localhost:9000", "minio", "minio123", "", "", bucket, false, false, "", "", "") idChan := make(chan string, 100) go verifyConsumer(idChan, bucket, consumer.minioClient) var counter atomic.Int32 @@ -90,7 +89,7 @@ func BenchmarkLogs2(b *testing.B) { wg.Wait() } -func testOneConsumer(consumer *MinioConsumer, id string) { +func testOneConsumer(consumer *MinioAdapter, id string) { fmt.Println("#####starting", id) totalSize := 0 numberOFLogs := rand.Intn(100000) @@ -158,14 +157,14 @@ func verifyConsumer(idChan chan string, bucket string, minioClient *minio.Client func DoRunBenchmark() { numberOfConsumers := 100 bucket := "test-bench" - consumer, _ := NewMinioConsumer("testkube-minio-service-testkube:9000", "minio", "minio123", "", "", bucket, minioconnecter.Insecure()) + consumer, _ := NewMinioAdapter("testkube-minio-service-testkube:9000", "minio", "minio123", "", "", bucket, false, false, "", "", "") idChan := make(chan string, numberOfConsumers) DoRunBenchmark2(idChan, numberOfConsumers, consumer) verifyConsumer(idChan, bucket, consumer.minioClient) } -func DoRunBenchmark2(idChan chan string, numberOfConsumers int, consumer *MinioConsumer) { +func DoRunBenchmark2(idChan chan string, numberOfConsumers int, consumer *MinioAdapter) { var counter atomic.Int32 var wg sync.WaitGroup for i := 0; i < numberOfConsumers; i++ { diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index d776cdf7181..49c4859e1ec 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -7,18 +7,29 @@ import ( ) type Config struct { - NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` - NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` - NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` - NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` - NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` - NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` - NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` - Namespace string `envconfig:"NAMESPACE" default:"testkube"` - ExecutionId string `envconfig:"ID" default:""` - HttpAddress string `envconfig:"HTTP_ADDRESS" default:":8080"` - GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` - KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` + NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` + NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` + NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` + NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` + NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` + NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` + NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` + Namespace string `envconfig:"NAMESPACE" default:"testkube"` + ExecutionId string `envconfig:"ID" default:""` + HttpAddress string `envconfig:"HTTP_ADDRESS" default:":8080"` + GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` + KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` + StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"testkube-minio-service-testkube:9000"` + StorageLogsBucket string `envconfig:"STORAGE_LOGS_BUCKET" default:"testkube-new-logs"` + StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:"minio"` + StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:"minio123"` + StorageRegion string `envconfig:"STORAGE_REGION" default:""` + StorageToken string `envconfig:"STORAGE_TOKEN" default:""` + StorageSSL bool `envconfig:"STORAGE_SSL" default:"false"` + StorageSkipVerify bool `envconfig:"STORAGE_SKIP_VERIFY" default:"false"` + StorageCertFile string `envconfig:"STORAGE_CERT_FILE" default:""` + StorageKeyFile string `envconfig:"STORAGE_KEY_FILE" default:""` + StorageCAFile string `envconfig:"STORAGE_CA_FILE" default:""` } func Get() (*Config, error) { diff --git a/pkg/logs/service_test.go b/pkg/logs/service_test.go index 8ff1e4f1090..2ba896c6cc9 100644 --- a/pkg/logs/service_test.go +++ b/pkg/logs/service_test.go @@ -13,10 +13,10 @@ func TestLogsService_AddAdapter(t *testing.T) { t.Run("should add adapter", func(t *testing.T) { svc := LogsService{} - svc.AddAdapter(adapter.NewDummyAdapter()) - svc.AddAdapter(adapter.NewDummyAdapter()) - svc.AddAdapter(adapter.NewDummyAdapter()) - svc.AddAdapter(adapter.NewDummyAdapter()) + svc.AddAdapter(adapter.NewDebugAdapter()) + svc.AddAdapter(adapter.NewDebugAdapter()) + svc.AddAdapter(adapter.NewDebugAdapter()) + svc.AddAdapter(adapter.NewDebugAdapter()) assert.Equal(t, 4, len(svc.adapters)) }) From 6f3643a2b57ee221edb0eb903f5e52a347f144dd Mon Sep 17 00:00:00 2001 From: Catalin <20538711+devcatalin@users.noreply.github.com> Date: Fri, 26 Jan 2024 13:07:21 +0200 Subject: [PATCH 050/234] docs: update Jenkins article (#4949) * docs: update Jenkins article * docs: update sidebar * chore: add jenkins plugin url * chore: update jenkins docs env vars --- docs/docs/articles/cicd-overview.md | 1 + docs/docs/articles/jenkins.md | 93 ++++++++++++----------------- docs/sidebars.js | 13 ++-- 3 files changed, 44 insertions(+), 63 deletions(-) diff --git a/docs/docs/articles/cicd-overview.md b/docs/docs/articles/cicd-overview.md index 45142cbd97d..4a4d6c7213f 100644 --- a/docs/docs/articles/cicd-overview.md +++ b/docs/docs/articles/cicd-overview.md @@ -8,6 +8,7 @@ We have different tutorials for the options of being CI driven or using GitOps a - [Github Actions - running Testkube CLI commands with setup-testkube-action](./github-actions.md) - [Testkube Docker CLI](./testkube-cli-docker.md) - [Gitlab CI](./gitlab.md) +- [Jenkins](./jenkins.md) - [CircleCI](./circleci.md) - [GitOps Testing](./gitops-overview.md) - [Flux](./flux-integration.md) diff --git a/docs/docs/articles/jenkins.md b/docs/docs/articles/jenkins.md index 19ef0723a7b..30fe6343753 100644 --- a/docs/docs/articles/jenkins.md +++ b/docs/docs/articles/jenkins.md @@ -3,6 +3,11 @@ The Testkube Jenkins integration streamlines the installation of Testkube, enabling the execution of any [Testkube CLI](https://docs.testkube.io/cli/testkube) command within Jenkins pipelines. This integration can be effortlessly integrated into your Jenkins setup, enhancing your continuous integration and delivery processes. This Jenkins integration offers a versatile solution for managing your pipeline workflows and is compatible with Testkube Pro, Testkube Enterprise, and the open-source Testkube platform. It allows Jenkins users to effectively utilize Testkube's capabilities within their CI/CD pipelines, providing a robust and flexible framework for test execution and automation. +### Testkube CLI Jenkins Plugin + +Install the Testkube CLI plugin by searching it in the "Available Plugins" section on Jenkins Plugins, or using the following url: +[https://plugins.jenkins.io/testkube-cli](https://plugins.jenkins.io/testkube-cli) + ## Testkube Pro ### How to configure Testkube CLI action for Testkube Pro and run a test @@ -12,39 +17,31 @@ Then, pass the **organization** and **environment** IDs, along with the **token* If a test is already created, you can run it using the command `testkube run test test-name -f` . However, if you need to create a test in this workflow, please add a creation command, e.g.: `testkube create test --name test-name --file path_to_file.json`. -you'll need to create a Jenkinsfile. This Jenkinsfile should define the stages and steps necessary to execute the workflow +You'll need to create a Jenkinsfile. This Jenkinsfile should define the stages and steps necessary to execute the workflow ```groovy pipeline { agent any + environment { + TK_ORG = credentials("TK_ORG") + TK_ENV = credentials("TK_ENV") + TK_API_TOKEN = credentials("TK_API_TOKEN") + } stages { - stage('Setup Testkube') { + stage('Example') { steps { script { - // Retrieve credentials - def apiKey = credentials('TESTKUBE_API_KEY') - def orgId = credentials('TESTKUBE_ORG_ID') - def envId = credentials('TESTKUBE_ENV_ID') - - // Install Testkube - sh 'curl -sSLf https://get.testkube.io | sh' - - // Initialize Testkube - sh "testkube set context --api-key ${apiKey} --org ${orgId} --env ${envId}" + // Setup the Testkube CLI + setupTestkube() + // Run testkube commands + sh 'testkube run test your-test' + sh 'testkube run testsuite your-test-suite --some-arg --other-arg' } } } - - stage('Run Testkube Test') { - steps { - // Run a Testkube test - sh 'testkube run test test-name -f' - } - } } } - ``` ## Testkube OSS @@ -61,28 +58,18 @@ you'll need to create a Jenkinsfile. This Jenkinsfile should define the stages a pipeline { agent any + environment { + TK_NAMESPACE = 'custom-testkube-namespace' + } stages { - stage('Setup Testkube') { + stage('Example') { steps { script { - // Retrieve credentials - def namespace='custom-testkube' - - // Install Testkube - sh 'curl -sSLf https://get.testkube.io | sh' - - // Initialize Testkube - sh "testkube set context --kubeconfig --namespace ${namespace}" + setupTestkube() + sh 'testkube run test your-test' } } } - - stage('Run Testkube Test') { - steps { - // Run a Testkube test - sh 'testkube run test test-name -f' - } - } } } ``` @@ -104,6 +91,11 @@ you'll need to create a Jenkinsfile. This Jenkinsfile should define the stages a pipeline { agent any + environment { + TK_ORG = credentials("TK_ORG") + TK_ENV = credentials("TK_ENV") + TK_API_TOKEN = credentials("TK_API_TOKEN") + } stages { stage('Setup Testkube') { steps { @@ -120,17 +112,8 @@ pipeline { sh 'aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --region $AWS_REGION' } - // Installing Testkube - sh 'curl -sSLf https://get.testkube.io | sh' - - // Initializing Testkube - withCredentials([ - string(credentialsId: 'TestkubeApiKey', variable: 'TESTKUBE_API_KEY'), - string(credentialsId: 'TestkubeOrgId', variable: 'TESTKUBE_ORG_ID'), - string(credentialsId: 'TestkubeEnvId', variable: 'TESTKUBE_ENV_ID') - ]) { - sh 'testkube set context --api-key $TESTKUBE_API_KEY --org $TESTKUBE_ORG_ID --env $TESTKUBE_ENV_ID' - } + // Installing and configuring Testkube based on env vars + setupTestkube() // Running Testkube test sh 'testkube run test test-name -f' @@ -152,6 +135,11 @@ you'll need to create a Jenkinsfile. This Jenkinsfile should define the stages a pipeline { agent any + environment { + TK_ORG = credentials("TK_ORG") + TK_ENV = credentials("TK_ENV") + TK_API_TOKEN = credentials("TK_API_TOKEN") + } stages { stage('Deploy to GKE') { steps { @@ -177,15 +165,8 @@ pipeline { sh 'gcloud container clusters get-credentials $GKE_CLUSTER_NAME --zone $GKE_ZONE' } - // Installing and initializing Testkube - withCredentials([ - string(credentialsId: 'TESTKUBE_API_KEY', variable: 'TESTKUBE_API_KEY'), - string(credentialsId: 'TESTKUBE_ORG_ID', variable: 'TESTKUBE_ORG_ID'), - string(credentialsId: 'TESTKUBE_ENV_ID', variable: 'TESTKUBE_ENV_ID') - ]) { - sh 'curl -sSLf https://get.testkube.io | sh' - sh 'testkube set context --api-key $TESTKUBE_API_KEY --org $TESTKUBE_ORG_ID --env $TESTKUBE_ENV_ID' - } + // Installing and configuring Testkube based on env vars + setupTestkube() // Running Testkube test sh 'testkube run test test-name -f' diff --git a/docs/sidebars.js b/docs/sidebars.js index 2409cbc7ab0..9aeab705d98 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -72,7 +72,7 @@ const sidebars = { "articles/webhooks", "articles/test-sources", "articles/test-executions", - "articles/templates", + "articles/templates", ], }, { @@ -103,6 +103,8 @@ const sidebars = { items: [ "articles/github-actions", "articles/gitlab", + "articles/jenkins", + "articles/circleci", "articles/run-tests-with-github-actions", "articles/testkube-cli-docker", { @@ -133,7 +135,7 @@ const sidebars = { "articles/generate-test-crds", "articles/logging", "articles/install-cli", - "articles/uninstall" + "articles/uninstall", ], }, { @@ -161,7 +163,7 @@ const sidebars = { "test-types/executor-tracetest", "test-types/executor-zap", "test-types/prebuilt-executor", - "test-types/container-executor", + "test-types/container-executor", "test-types/executor-distributed-jmeter", ], }, @@ -190,10 +192,7 @@ const sidebars = { { type: "category", label: "Testkube Enterprise", - items: [ - "testkube-enterprise/articles/usage-guide", - "testkube-enterprise/articles/auth" - ], + items: ["testkube-enterprise/articles/usage-guide", "testkube-enterprise/articles/auth"], }, "articles/testkube-oss", { From d3961602b1d0e959e8fcf3fd1eb66aca25e405d6 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 29 Jan 2024 13:31:55 +0100 Subject: [PATCH 051/234] fix: use test start stop event (#4954) * fix: use test start stop event * fix: rename event * fix: handle test start and stop events already present * fix: tests --- api/v1/testkube.yaml | 68 +++++++------- cmd/logs/main.go | 6 +- .../pkg/runner/runner_integration_test.go | 3 - pkg/api/v1/testkube/model_event.go | 5 +- pkg/api/v1/testkube/model_event_extended.go | 32 +++++++ .../v1/testkube/model_event_extended_test.go | 5 ++ pkg/logs/adapter/minio.go | 3 +- pkg/logs/client/mock_client.go | 50 +++++++++++ pkg/logs/client/stream.go | 2 +- pkg/logs/client/stream_test.go | 2 +- pkg/logs/config/logs_config.go | 1 + pkg/logs/events.go | 90 ++++++++++++------- pkg/logs/events/events.go | 25 ++++-- pkg/logs/events_test.go | 79 ++++++++++++++++ pkg/logs/service.go | 9 +- pkg/scheduler/test_scheduler.go | 10 --- 16 files changed, 294 insertions(+), 96 deletions(-) create mode 100644 pkg/logs/client/mock_client.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index caad2352fae..9aa2e52aa3b 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -1132,7 +1132,7 @@ paths: - $ref: "#/components/parameters/Namespace" - $ref: "#/components/parameters/Selector" - $ref: "#/components/parameters/ExecutionSelector" - - $ref: "#/components/parameters/ConcurrencyLevel" + - $ref: "#/components/parameters/ConcurrencyLevel" tags: - api - tests @@ -2318,7 +2318,7 @@ paths: $ref: "#/components/schemas/WebhookCreateRequest" text/yaml: schema: - type: string + type: string responses: 200: description: "successful operation" @@ -2468,7 +2468,7 @@ paths: $ref: "#/components/schemas/WebhookUpdateRequest" text/yaml: schema: - type: string + type: string responses: 200: description: "successful operation" @@ -2555,7 +2555,7 @@ paths: $ref: "#/components/schemas/TemplateCreateRequest" text/yaml: schema: - type: string + type: string responses: 200: description: "successful operation" @@ -2705,7 +2705,7 @@ paths: $ref: "#/components/schemas/TemplateUpdateRequest" text/yaml: schema: - type: string + type: string responses: 200: description: "successful operation" @@ -3546,7 +3546,7 @@ components: type: array description: previous test names items: - type: string + type: string TestSuiteStep: type: object @@ -3732,14 +3732,14 @@ components: description: object name and namespace execution: $ref: "#/components/schemas/Execution" - description: test step execution + description: test step execution TestSuiteBatchStepExecutionResult: description: execution result returned from executor type: object properties: step: - $ref: "#/components/schemas/TestSuiteBatchStep" + $ref: "#/components/schemas/TestSuiteBatchStep" execute: type: array items: @@ -3900,7 +3900,7 @@ components: description: type: string description: test description - example: "this test is used for that purpose" + example: "this test is used for that purpose" type: type: string description: test type @@ -3975,7 +3975,7 @@ components: properties: type: type: string - description: | + description: | type of sources a runner can get data from. string: String content (e.g. Postman JSON file). file-uri: content stored on the webserver. @@ -4267,7 +4267,7 @@ components: example: "sleep 30" executePostRunScriptBeforeScraping: type: boolean - description: execute post run script before scraping (prebuilt executor only) + description: execute post run script before scraping (prebuilt executor only) runningContext: $ref: "#/components/schemas/RunningContext" description: running context for the test execution @@ -4290,7 +4290,7 @@ components: type: string slavePodRequest: $ref: "#/components/schemas/PodRequest" - description: configuration parameters for executed slave pods + description: configuration parameters for executed slave pods Artifact: type: object @@ -4538,7 +4538,7 @@ components: dashboardUri: type: string description: dashboard uri - example: "http://localhost:8080" + example: "http://localhost:8080" Repository: description: repository representation for tests in git repositories @@ -4672,13 +4672,13 @@ components: properties: resources: $ref: "#/components/schemas/PodResourcesRequest" - description: pod resources request parameters + description: pod resources request parameters podTemplate: type: string description: pod template extensions podTemplateReference: type: string - description: name of the template resource + description: name of the template resource PodUpdateRequest: description: pod request update body @@ -4696,7 +4696,7 @@ components: description: pod resources requests limits: $ref: "#/components/schemas/ResourceRequest" - description: pod resources limits + description: pod resources limits PodResourcesUpdateRequest: description: pod resources update request specification @@ -4797,7 +4797,7 @@ components: description: usage mode for arguments enum: - append - - override + - override image: type: string description: container image, executor will run inside this image @@ -4868,7 +4868,7 @@ components: description: job template extensions jobTemplateReference: type: string - description: name of the template resource + description: name of the template resource cronJobTemplate: type: string description: cron job template extensions @@ -4929,7 +4929,7 @@ components: type: string slavePodRequest: $ref: "#/components/schemas/PodRequest" - description: configuration parameters for executed slave pods + description: configuration parameters for executed slave pods ExecutionUpdateRequest: description: test execution request update body @@ -5210,7 +5210,7 @@ components: - junit-report meta: $ref: "#/components/schemas/ExecutorMeta" - useDataDirAsWorkingDir: + useDataDirAsWorkingDir: type: boolean description: use data dir as working dir for executor @@ -5283,12 +5283,12 @@ components: description: Slave data for executing tests in distributed environment type: object properties: - image: + image: description: slave image type: string example: kubeshop/ex-slaves-image:latest required: - - image + - image RunningContext: description: running context for test or test suite execution @@ -5382,14 +5382,14 @@ components: $ref: "#/components/schemas/TestSuiteExecution" clusterName: type: string - description: cluster name of event + description: cluster name of event envs: type: object description: "environment variables" additionalProperties: type: string example: - WEBHOOK_PARAMETER: "any value" + WEBHOOK_PARAMETER: "any value" EventResource: type: string @@ -5564,7 +5564,7 @@ components: conditionSpec: $ref: "#/components/schemas/TestTriggerConditionSpec" probeSpec: - $ref: "#/components/schemas/TestTriggerProbeSpec" + $ref: "#/components/schemas/TestTriggerProbeSpec" action: $ref: "#/components/schemas/TestTriggerActions" execution: @@ -5611,7 +5611,7 @@ components: nameRegex: type: string description: kubernetes resource name regex - example: nginx.* + example: nginx.* namespace: type: string description: resource namespace @@ -5663,7 +5663,7 @@ components: type: integer format: int32 description: duration in seconds the test trigger waits between condition checks - example: 1 + example: 1 TestTriggerCondition: description: supported condition for test triggers @@ -5685,8 +5685,8 @@ components: ttl: type: integer format: int32 - description: duration in seconds in the past from current time when the condition is still valid - example: 1 + description: duration in seconds in the past from current time when the condition is still valid + example: 1 TestTriggerConditionStatuses: description: supported kubernetes condition statuses for test triggers @@ -5713,7 +5713,7 @@ components: type: integer format: int32 description: duration in seconds the test trigger waits between probes - example: 1 + example: 1 TestTriggerProbe: description: supported probe for test triggers @@ -5722,7 +5722,7 @@ components: scheme: type: string description: test trigger condition probe scheme to connect to host, default is http - example: http + example: http host: type: string description: test trigger condition probe host, default is pod ip or service name @@ -5735,7 +5735,7 @@ components: type: integer format: int32 description: test trigger condition probe port to connect - example: 80 + example: 80 headers: type: object description: test trigger condition probe headers to submit @@ -5869,7 +5869,7 @@ components: body: type: string description: template body to use - example: "{\"id\": \"{{ .Id }}\"}" + example: '{"id": "{{ .Id }}"}' labels: type: object description: "template labels" @@ -6091,7 +6091,7 @@ components: name: testSuiteExecutionName schema: type: string - description: test suite execution name stated the test suite execution + description: test suite execution name stated the test suite execution Namespace: in: query name: namespace diff --git a/cmd/logs/main.go b/cmd/logs/main.go index d4a6f93a0b8..bba5a9f32bd 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -66,9 +66,13 @@ func main() { cfg.StorageCertFile, cfg.StorageKeyFile, cfg.StorageCAFile) + + if cfg.Debug { + svc.AddAdapter(adapter.NewDebugAdapter()) + } + if err != nil { log.Errorw("error creating minio adapter, debug adapter created instead", "error", err) - svc.AddAdapter(adapter.NewDebugAdapter()) } else { log.Infow("minio adapter created", "bucket", cfg.StorageLogsBucket, "endpoint", cfg.StorageEndpoint) svc.AddAdapter(minioAdapter) diff --git a/contrib/executor/gradle/pkg/runner/runner_integration_test.go b/contrib/executor/gradle/pkg/runner/runner_integration_test.go index 06db2507ede..df2cf97dfc2 100644 --- a/contrib/executor/gradle/pkg/runner/runner_integration_test.go +++ b/contrib/executor/gradle/pkg/runner/runner_integration_test.go @@ -3,7 +3,6 @@ package runner import ( "context" - "fmt" "os" "path/filepath" "testing" @@ -88,8 +87,6 @@ func TestRunGradle_Integration(t *testing.T) { // when result, err := runner.Run(ctx, *execution) - fmt.Printf("%+v\n", result) - // then assert.NoError(t, err) assert.Equal(t, result.Status, testkube.ExecutionStatusPassed) diff --git a/pkg/api/v1/testkube/model_event.go b/pkg/api/v1/testkube/model_event.go index 3a31df3d205..63d6ee5e984 100644 --- a/pkg/api/v1/testkube/model_event.go +++ b/pkg/api/v1/testkube/model_event.go @@ -12,8 +12,9 @@ package testkube // Event data type Event struct { // UUID of event - Id string `json:"id"` - Resource *EventResource `json:"resource"` + Id string `json:"id"` + StreamTopic string `json:"topic"` + Resource *EventResource `json:"resource"` // ID of resource ResourceId string `json:"resourceId"` Type_ *EventType `json:"type"` diff --git a/pkg/api/v1/testkube/model_event_extended.go b/pkg/api/v1/testkube/model_event_extended.go index a01edfbb288..945a8ffbbba 100644 --- a/pkg/api/v1/testkube/model_event_extended.go +++ b/pkg/api/v1/testkube/model_event_extended.go @@ -7,6 +7,19 @@ import ( "k8s.io/apimachinery/pkg/labels" ) +const ( + TestStartSubject = "events.test.start" + TestStopSubject = "events.test.stop" +) + +// check if Event implements model generic event type +var _ Trigger = Event{} + +// Trigger for generic events +type Trigger interface { + GetResourceId() string +} + func NewEvent(t *EventType, resource *EventResource, id string) Event { return Event{ Id: uuid.NewString(), @@ -21,6 +34,8 @@ func NewEventStartTest(execution *Execution) Event { Id: uuid.NewString(), Type_: EventStartTest, TestExecution: execution, + StreamTopic: TestStartSubject, + ResourceId: execution.Id, } } @@ -29,6 +44,8 @@ func NewEventEndTestSuccess(execution *Execution) Event { Id: uuid.NewString(), Type_: EventEndTestSuccess, TestExecution: execution, + StreamTopic: TestStopSubject, + ResourceId: execution.Id, } } @@ -37,6 +54,8 @@ func NewEventEndTestFailed(execution *Execution) Event { Id: uuid.NewString(), Type_: EventEndTestFailed, TestExecution: execution, + StreamTopic: TestStopSubject, + ResourceId: execution.Id, } } @@ -45,6 +64,8 @@ func NewEventEndTestAborted(execution *Execution) Event { Id: uuid.NewString(), Type_: EventEndTestAborted, TestExecution: execution, + StreamTopic: TestStopSubject, + ResourceId: execution.Id, } } @@ -53,6 +74,8 @@ func NewEventEndTestTimeout(execution *Execution) Event { Id: uuid.NewString(), Type_: EventEndTestTimeout, TestExecution: execution, + StreamTopic: TestStopSubject, + ResourceId: execution.Id, } } @@ -184,6 +207,10 @@ func (e Event) Valid(selector string, types []EventType) (valid bool) { // Topic returns topic for event based on resource and resource id // or fallback to global "events" topic func (e Event) Topic() string { + if e.StreamTopic != "" { + return e.StreamTopic + } + if e.Resource == nil { return "events.all" } @@ -194,3 +221,8 @@ func (e Event) Topic() string { return "events." + string(*e.Resource) + "." + e.ResourceId } + +// GetResourceId implmenents generic event trigger +func (e Event) GetResourceId() string { + return e.ResourceId +} diff --git a/pkg/api/v1/testkube/model_event_extended_test.go b/pkg/api/v1/testkube/model_event_extended_test.go index 8e0741beab1..b7853bdbe68 100644 --- a/pkg/api/v1/testkube/model_event_extended_test.go +++ b/pkg/api/v1/testkube/model_event_extended_test.go @@ -116,6 +116,11 @@ func TestEvent_IsSuccess(t *testing.T) { func TestEvent_Topic(t *testing.T) { + t.Run("should return events topic if explicitly set", func(t *testing.T) { + evt := Event{Type_: EventStartTest, StreamTopic: "topic"} + assert.Equal(t, "topic", evt.Topic()) + }) + t.Run("should return events topic if not resource set", func(t *testing.T) { evt := Event{Type_: EventStartTest, Resource: nil} assert.Equal(t, "events.all", evt.Topic()) diff --git a/pkg/logs/adapter/minio.go b/pkg/logs/adapter/minio.go index 63cba09d8b7..d3afbb9fade 100644 --- a/pkg/logs/adapter/minio.go +++ b/pkg/logs/adapter/minio.go @@ -177,7 +177,8 @@ func (s *MinioAdapter) combineData(ctxt context.Context, minioClient *minio.Clie s.Log.Errorw("error putting object", "err", err) return err } - s.Log.Debugw("put object successfully", "id", id, "s.bucket", s.bucket, "parts", parts) + + s.Log.Debugw("data combined", "id", id, "s.bucket", s.bucket, "parts", parts) if deleteIntermediaryData { for i := 0; i < parts; i++ { diff --git a/pkg/logs/client/mock_client.go b/pkg/logs/client/mock_client.go new file mode 100644 index 00000000000..a87031ea1af --- /dev/null +++ b/pkg/logs/client/mock_client.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: Client) + +// Package client is a generated GoMock package. +package client + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockClient) Get(arg0 context.Context, arg1 string) chan events.LogResponse { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(chan events.LogResponse) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockClientMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), arg0, arg1) +} diff --git a/pkg/logs/client/stream.go b/pkg/logs/client/stream.go index 9e20732d78f..08a23efc445 100644 --- a/pkg/logs/client/stream.go +++ b/pkg/logs/client/stream.go @@ -122,7 +122,7 @@ func (c NatsLogStream) Get(ctx context.Context, id string) (chan events.LogRespo // syncCall sends request to given subject and waits for response func (c NatsLogStream) syncCall(ctx context.Context, subject, id string) (resp StreamResponse, err error) { - b, err := json.Marshal(events.Trigger{Id: id}) + b, err := json.Marshal(events.NewTrigger(id)) if err != nil { return resp, err } diff --git a/pkg/logs/client/stream_test.go b/pkg/logs/client/stream_test.go index 7d3ed1a8845..03cafc96eac 100644 --- a/pkg/logs/client/stream_test.go +++ b/pkg/logs/client/stream_test.go @@ -25,7 +25,7 @@ func TestStream_StartStop(t *testing.T) { assert.NoError(t, err) assert.Equal(t, StreamPrefix+id, meta.Name) - err = client.PushBytes(ctx, id, []byte(`{"content":"hello 1"}`)) + err = client.PushBytes(ctx, id, []byte(`{"resourceId":"hello 1"}`)) assert.NoError(t, err) var startReceived, stopReceived bool diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index 49c4859e1ec..51fe916a5a5 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -7,6 +7,7 @@ import ( ) type Config struct { + Debug bool `envconfig:"DEBUG" default:"false"` NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` diff --git a/pkg/logs/events.go b/pkg/logs/events.go index cb3ac9a5edf..cc47db43c79 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -10,6 +10,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/logs/adapter" "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/logs/state" @@ -21,11 +22,23 @@ const ( StreamPrefix = "log" - StartSubject = "events.logs.start" - StopSubject = "events.logs.stop" - StartQueue = "logsstart" StopQueue = "logsstop" + + LogStartSubject = "events.logs.start" + LogStopSubject = "events.logs.stop" +) + +var ( + StartSubjects = map[string]string{ + "test": testkube.TestStartSubject, + "generic": LogStartSubject, + } + + StopSubjects = map[string]string{ + "test": testkube.TestStopSubject, + "generic": LogStopSubject, + } ) type Consumer struct { @@ -47,9 +60,9 @@ func (ls *LogsService) initConsumer(ctx context.Context, a adapter.Adapter, stre }) } -func (ls *LogsService) createStream(ctx context.Context, event events.Trigger) (jetstream.Stream, error) { +func (ls *LogsService) createStream(ctx context.Context, id string) (jetstream.Stream, error) { // create stream for incoming logs - streamName := StreamPrefix + event.Id + streamName := StreamPrefix + id return ls.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{ Name: streamName, Storage: jetstream.FileStorage, // durable stream as we can hit mem limit @@ -57,8 +70,8 @@ func (ls *LogsService) createStream(ctx context.Context, event events.Trigger) ( } // handleMessage will handle incoming message from logs stream and proxy it to given adapter -func (ls *LogsService) handleMessage(a adapter.Adapter, event events.Trigger) func(msg jetstream.Msg) { - log := ls.log.With("id", event.Id, "consumer", a.Name()) +func (ls *LogsService) handleMessage(a adapter.Adapter, id string) func(msg jetstream.Msg) { + log := ls.log.With("id", id, "adapter", a.Name()) return func(msg jetstream.Msg) { log.Debugw("got message", "data", string(msg.Data())) @@ -74,7 +87,7 @@ func (ls *LogsService) handleMessage(a adapter.Adapter, event events.Trigger) fu return } - err = a.Notify(event.Id, logChunk) + err = a.Notify(id, logChunk) if err != nil { if err := msg.Nak(); err != nil { log.Errorw("error nacking message", "error", err) @@ -90,7 +103,7 @@ func (ls *LogsService) handleMessage(a adapter.Adapter, event events.Trigger) fu } // handleStart will handle start event and create logs consumers, also manage state of given (execution) id -func (ls *LogsService) handleStart(ctx context.Context) func(msg *nats.Msg) { +func (ls *LogsService) handleStart(ctx context.Context, subject string) func(msg *nats.Msg) { return func(msg *nats.Msg) { event := events.Trigger{} err := json.Unmarshal(msg.Data, &event) @@ -98,23 +111,24 @@ func (ls *LogsService) handleStart(ctx context.Context) func(msg *nats.Msg) { ls.log.Errorw("can't handle start event", "error", err) return } - log := ls.log.With("id", event.Id, "event", "start") + id := event.ResourceId + log := ls.log.With("id", id, "event", "start") - ls.state.Put(ctx, event.Id, state.LogStatePending) - s, err := ls.createStream(ctx, event) + ls.state.Put(ctx, id, state.LogStatePending) + s, err := ls.createStream(ctx, id) if err != nil { - ls.log.Errorw("error creating stream", "error", err, "id", event.Id) + ls.log.Errorw("error creating stream", "error", err, "id", id) return } log.Infow("stream created", "stream", s) - streamName := StreamPrefix + event.Id + streamName := StreamPrefix + id // for each adapter create NATS consumer and consume stream from it e.g. cloud s3 or others for i, adapter := range ls.adapters { l := log.With("adapter", adapter.Name()) - c, err := ls.initConsumer(ctx, adapter, streamName, event.Id, i) + c, err := ls.initConsumer(ctx, adapter, streamName, id, i) if err != nil { log.Errorw("error creating consumer", "error", err) return @@ -122,34 +136,38 @@ func (ls *LogsService) handleStart(ctx context.Context) func(msg *nats.Msg) { // handle message per each adapter l.Infow("consumer created", "consumer", c.CachedInfo(), "stream", streamName) - cons, err := c.Consume(ls.handleMessage(adapter, event)) + cons, err := c.Consume(ls.handleMessage(adapter, id)) if err != nil { log.Errorw("error creating consumer", "error", err, "consumer", c.CachedInfo()) continue } + consumerName := id + "_" + adapter.Name() + "_" + subject // store consumer instance so we can stop it later in StopSubject handler - ls.consumerInstances.Store(event.Id+"_"+adapter.Name(), Consumer{ - Name: event.Id + "_" + adapter.Name(), + ls.consumerInstances.Store(consumerName, Consumer{ + Name: consumerName, Context: cons, Instance: c, }) - l.Infow("consumer started", "consumer", adapter.Name(), "id", event.Id, "stream", streamName) + l.Infow("consumer started", "adapter", adapter.Name(), "id", id, "stream", streamName) } - // reply to start event that everything was initialized correctly - err = msg.Respond([]byte("ok")) - if err != nil { - log.Errorw("error responding to start event", "error", err) - return + // confirm when reply is set + if msg.Reply != "" { + // reply to start event that everything was initialized correctly + err = msg.Respond([]byte("ok")) + if err != nil { + log.Errorw("error responding to start event", "error", err) + return + } } } } // handleStop will handle stop event and stop logs consumers, also clean consumers state -func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { +func (ls *LogsService) handleStop(ctx context.Context, group string) func(msg *nats.Msg) { return func(msg *nats.Msg) { var ( wg sync.WaitGroup @@ -165,29 +183,33 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { return } - l := ls.log.With("id", event.Id, "event", "stop") + id := event.ResourceId - err = msg.Respond([]byte("stop-queued")) - if err != nil { - l.Errorw("error responding to stop event", "error", err) + l := ls.log.With("id", id, "event", "stop") + + if msg.Reply != "" { + err = msg.Respond([]byte("stop-queued")) + if err != nil { + l.Errorw("error responding to stop event", "error", err) + } } for _, adapter := range ls.adapters { - consumerName := event.Id + "_" + adapter.Name() + consumerName := id + "_" + adapter.Name() + "_" + group // locate consumer on this pod c, found := ls.consumerInstances.Load(consumerName) - l.Debugw("consumer instance", "c", c, "found", found, "name", consumerName) if !found { l.Debugw("consumer not found on this pod", "found", found, "name", consumerName) continue } + l.Debugw("consumer instance found", "c", c, "found", found, "name", consumerName) // stop consumer wg.Add(1) stopped++ consumer := c.(Consumer) - go ls.stopConsumer(ctx, &wg, consumer, adapter, event.Id) + go ls.stopConsumer(ctx, &wg, consumer, adapter, id) } @@ -195,8 +217,8 @@ func (ls *LogsService) handleStop(ctx context.Context) func(msg *nats.Msg) { l.Debugw("wait completed") if stopped > 0 { - ls.state.Put(ctx, event.Id, state.LogStateFinished) - l.Infow("execution logs consumers stopped", "id", event.Id, "stopped", stopped) + ls.state.Put(ctx, event.ResourceId, state.LogStateFinished) + l.Infow("execution logs consumers stopped", "id", event.ResourceId, "stopped", stopped) } else { l.Debugw("no consumers found on this pod to stop") } diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 0febcf09c4c..26e5ef8c697 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -9,13 +9,6 @@ import ( "github.com/kubeshop/testkube/pkg/executor/output" ) -// Generic event like log-start log-end -type Trigger struct { - Id string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` -} - type LogVersion string const ( @@ -30,6 +23,24 @@ const ( SourceJobExecutor = "job-executor" ) +// check if trigger implements model generic event type +var _ testkube.Trigger = Trigger{} + +// NewTrigger returns Trigger instance +func NewTrigger(id string) Trigger { + return Trigger{ResourceId: id} +} + +// Generic event like log-start log-end with resource id +type Trigger struct { + ResourceId string `json:"resourceId,omitempty"` +} + +// GetResourceId implements testkube.Trigger interface +func (t Trigger) GetResourceId() string { + return t.ResourceId +} + type LogResponse struct { Log Log Error error diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index fefbc3a3b4f..3ee58009605 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -7,9 +7,12 @@ import ( "testing" "time" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/stretchr/testify/assert" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/event" "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/logs/adapter" "github.com/kubeshop/testkube/pkg/logs/client" @@ -93,6 +96,82 @@ func TestLogs_EventsFlow(t *testing.T) { assert.Equal(t, 0, log.GetConsumersStats(ctx).Count) }) + t.Run("should start and stop on test event", func(t *testing.T) { + // given context with 1s deadline + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute)) + defer cancel() + + // and NATS test server with connection + ns, nc := bus.TestServerWithConnection() + defer ns.Shutdown() + + id := "id1" + + // and jetstream configured + js, err := jetstream.New(nc) + assert.NoError(t, err) + + // and KV store + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "start-stop-on-test"}) + assert.NoError(t, err) + assert.NotNil(t, kv) + + // and logs state manager + state := state.NewState(kv) + + // and initialized log service + log := NewLogsService(nc, js, state). + WithRandomPort() + + // given example adapter + a := NewMockAdapter() + + messagesCount := 10000 + + // with 4 adapters (the same adapter is added 4 times so it'll receive 4 times more messages) + log.AddAdapter(a) + + // and log service running + go func() { + log.Run(ctx) + }() + + // and test event emitter + ec, err := nats.NewEncodedConn(nc, nats.JSON_ENCODER) + assert.NoError(t, err) + eventBus := bus.NewNATSBus(ec) + emitter := event.NewEmitter(eventBus, "test-cluster", map[string]string{}) + + // and stream client + stream, err := client.NewNatsLogStream(nc) + assert.NoError(t, err) + + // and initialized log stream for given ID + meta, err := stream.Init(ctx, id) + assert.NotEmpty(t, meta.Name) + assert.NoError(t, err) + + // and ready to get messages + <-log.Ready + + // when start event triggered + emitter.Notify(testkube.NewEventStartTest(&testkube.Execution{Id: "id1"})) + + for i := 0; i < messagesCount; i++ { + // and when data pushed to the log stream + err = stream.Push(ctx, id, events.NewLog("hello")) + assert.NoError(t, err) + } + + // and wait for message to be propagated + emitter.Notify(testkube.NewEventEndTestFailed(&testkube.Execution{Id: "id1"})) + + time.Sleep(time.Second) + + assertMessagesCount(t, a, messagesCount) + + }) + t.Run("should react on new message and pass data to adapter", func(t *testing.T) { // given context with 1s deadline ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute)) diff --git a/pkg/logs/service.go b/pkg/logs/service.go index 12eff7a0dff..ddea90df5a1 100644 --- a/pkg/logs/service.go +++ b/pkg/logs/service.go @@ -95,11 +95,16 @@ func (ls *LogsService) Run(ctx context.Context) (err error) { // For start event we must build stream for given execution id and start consuming it // this one will must follow a queue group each pod will get it's own bunch of executions to handle // Start event will be triggered by logs process controller (scheduler) - ls.nats.QueueSubscribe(StartSubject, StartQueue, ls.handleStart(ctx)) + // group is common name for both start and stop subjects + for group, subject := range StartSubjects { + ls.nats.QueueSubscribe(subject, StartQueue, ls.handleStart(ctx, group)) + } // listen on all pods as we don't control which one will have given consumer // Stop event will be triggered by logs process controller (scheduler) - ls.nats.Subscribe(StopSubject, ls.handleStop(ctx)) + for group, subject := range StopSubjects { + ls.nats.Subscribe(subject, ls.handleStop(ctx, group)) + } // Send ready signal ls.Ready <- struct{}{} diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 9eb8482b380..cc71410a442 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -79,18 +79,8 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request execution = newExecutionFromExecutionOptions(options) options.ID = execution.Id - // TODO consider using single event for test start and logs s.events.Notify(testkube.NewEventStartTest(&execution)) - // for logs.v2 service trigger start / stop events - if s.featureFlags.LogsV2 { - err := s.triggerLogsStartEvent(ctx, execution.Id) - if err != nil { - return execution, err - } - defer s.triggerLogsStopEvent(ctx, execution.Id) - } - if err := s.createSecretsReferences(&execution); err != nil { return s.handleExecutionError(ctx, execution, "can't create secret variables `Secret` references: %w", err) } From 58df8aeeb2da517f003334c869a7718c6c82857a Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 30 Jan 2024 14:51:04 +0100 Subject: [PATCH 052/234] fix: dummy adapter use structured logging (#4957) * fix: dummy adapter use structured logging * fix: golang ci fixes --- pkg/logs/adapter/dummy.go | 13 ++++++----- pkg/logs/adapter/minio.go | 6 ++++-- pkg/scheduler/test_scheduler.go | 38 --------------------------------- 3 files changed, 12 insertions(+), 45 deletions(-) diff --git a/pkg/logs/adapter/dummy.go b/pkg/logs/adapter/dummy.go index 489ee3af206..4366740092a 100644 --- a/pkg/logs/adapter/dummy.go +++ b/pkg/logs/adapter/dummy.go @@ -1,8 +1,9 @@ package adapter import ( - "fmt" + "go.uber.org/zap" + "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/logs/events" ) @@ -10,20 +11,22 @@ var _ Adapter = &DebugAdapter{} // NewDebugAdapter creates new DebugAdapter which will write logs to stdout func NewDebugAdapter() *DebugAdapter { - return &DebugAdapter{} + return &DebugAdapter{ + l: log.DefaultLogger, + } } type DebugAdapter struct { - Bucket string + l *zap.SugaredLogger } func (s *DebugAdapter) Notify(id string, e events.Log) error { - fmt.Printf("%s %+v\n", id, e) + s.l.Debugw("got event", "id", id, "event", e) return nil } func (s *DebugAdapter) Stop(id string) error { - fmt.Printf("stopping %s \n", id) + s.l.Debugw("Stopping", "id", id) return nil } diff --git a/pkg/logs/adapter/minio.go b/pkg/logs/adapter/minio.go index d3afbb9fade..77f7c9715d8 100644 --- a/pkg/logs/adapter/minio.go +++ b/pkg/logs/adapter/minio.go @@ -172,13 +172,14 @@ func (s *MinioAdapter) combineData(ctxt context.Context, minioClient *minio.Clie } } } - _, err := minioClient.PutObject(ctxt, s.bucket, id, buffer, int64(buffer.Len()), minio.PutObjectOptions{ContentType: "application/octet-stream"}) + + info, err := minioClient.PutObject(ctxt, s.bucket, id, buffer, int64(buffer.Len()), minio.PutObjectOptions{ContentType: "application/octet-stream"}) if err != nil { s.Log.Errorw("error putting object", "err", err) return err } - s.Log.Debugw("data combined", "id", id, "s.bucket", s.bucket, "parts", parts) + s.Log.Debugw("data combined", "id", id, "s.bucket", s.bucket, "parts", parts, "uploadinfo", info) if deleteIntermediaryData { for i := 0; i < parts; i++ { @@ -192,6 +193,7 @@ func (s *MinioAdapter) combineData(ctxt context.Context, minioClient *minio.Clie } } } + buffer.Reset() if len(returnedError) == 0 { return nil diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index cc71410a442..26c31ff84f3 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -831,41 +831,3 @@ func mergeSlavePodRequests(podBase *testkube.PodRequest, podAdjust *testkube.Pod return podBase } - -func (s *Scheduler) triggerLogsStartEvent(ctx context.Context, id string) error { - if s.featureFlags.LogsV2 { - r, err := s.logsStream.Start(ctx, id) - if err != nil { - return err - } - - if r.Error { - return errors.New(string(r.Message)) - } - - s.logger.Infow("triggering logs start event", "id", id) - } - - return nil -} - -func (s *Scheduler) triggerLogsStopEvent(ctx context.Context, id string) error { - if s.featureFlags.LogsV2 { - // as Stop is synchro - go func() { - r, err := s.logsStream.Stop(ctx, id) - if err != nil { - s.logger.Errorw("can't send stop event for logs", "id", id, "error", err) - return - } - - if r.Error { - s.logger.Errorw("received invalid response from log stream on stop event", "id", id, "response", r) - return - } - - s.logger.Infow("triggering logs stop event", "id", id, "response", string(r.Message)) - }() - } - return nil -} From bb695bc569b75df9685153b18fca9aabf0d38506 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 31 Jan 2024 12:09:32 +0200 Subject: [PATCH 053/234] test logs sidecar --- .../workflows/release-dev-log-sidecar.yaml | 1 + .github/workflows/release-log-sidecar.yaml | 1 - .../.goreleaser-docker-build-logs-server.yml | 20 +++++++++++-------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-dev-log-sidecar.yaml b/.github/workflows/release-dev-log-sidecar.yaml index 474987cd81e..2c0c0feea89 100644 --- a/.github/workflows/release-dev-log-sidecar.yaml +++ b/.github/workflows/release-dev-log-sidecar.yaml @@ -4,6 +4,7 @@ on: push: branches: - develop + - ci/logs-service-fix permissions: id-token: write diff --git a/.github/workflows/release-log-sidecar.yaml b/.github/workflows/release-log-sidecar.yaml index c2f36907536..1523e522d8b 100644 --- a/.github/workflows/release-log-sidecar.yaml +++ b/.github/workflows/release-log-sidecar.yaml @@ -68,7 +68,6 @@ jobs: DOCKER_BUILDX_CACHE_FROM: "type=gha" DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} - DOCKER_IMAGE_TAG: ${{ steps.github_sha.outputs.sha_short }} - name: Push Docker images run: | diff --git a/goreleaser_files/.goreleaser-docker-build-logs-server.yml b/goreleaser_files/.goreleaser-docker-build-logs-server.yml index 69754ae1a37..122f40df07a 100644 --- a/goreleaser_files/.goreleaser-docker-build-logs-server.yml +++ b/goreleaser_files/.goreleaser-docker-build-logs-server.yml @@ -5,12 +5,14 @@ env: # https://github.com/goreleaser/goreleaser/pull/3199 # To use a builder other than "default", set this variable. # Necessary for, e.g., GitHub actions cache integration. + - DOCKER_REPO={{ if index .Env "DOCKER_REPO" }}{{ .Env.DOCKER_REPO }}{{ else }}kubeshop{{ end }} - DOCKER_BUILDX_BUILDER={{ if index .Env "DOCKER_BUILDX_BUILDER" }}{{ .Env.DOCKER_BUILDX_BUILDER }}{{ else }}default{{ end }} # Setup to enable Docker to use, e.g., the GitHub actions cache; see # https://docs.docker.com/build/building/cache/backends/ # https://github.com/moby/buildkit#export-cache - DOCKER_BUILDX_CACHE_FROM={{ if index .Env "DOCKER_BUILDX_CACHE_FROM" }}{{ .Env.DOCKER_BUILDX_CACHE_FROM }}{{ else }}type=registry{{ end }} - DOCKER_BUILDX_CACHE_TO={{ if index .Env "DOCKER_BUILDX_CACHE_TO" }}{{ .Env.DOCKER_BUILDX_CACHE_TO }}{{ else }}type=inline{{ end }} + - DOCKER_IMAGE_TAG={{ if index .Env "DOCKER_IMAGE_TAG" }}{{ .Env.DOCKER_IMAGE_TAG }}{{ else }}{{ end }} builds: - id: "linux" main: ./cmd/logs @@ -31,7 +33,8 @@ dockers: goos: linux goarch: amd64 image_templates: - - "kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-amd64" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}-amd64{{ end }}" + - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-amd64{{ end }}" build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.title={{ .ProjectName }}" @@ -48,7 +51,8 @@ dockers: goos: linux goarch: arm64 image_templates: - - "kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}-arm64v8{{ end }}" + - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8{{ end }}" build_flag_templates: - "--platform=linux/arm64/v8" - "--label=org.opencontainers.image.created={{ .Date }}" @@ -61,14 +65,14 @@ dockers: - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" docker_manifests: - - name_template: kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }} + - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}{{ end }}" image_templates: - - kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-amd64 - - kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8 - - name_template: kubeshop/testkube-logs-server:latest + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}-arm64v8{{ end }}" + - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:latest{{ end }}" image_templates: - - kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-amd64 - - kubeshop/testkube-logs-server:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8 + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-server:{{ .Version }}-arm64v8{{ end }}" release: disable: true From 0e89c424465323f3b19ccc4d9ec5ce3a43c3bbab Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 31 Jan 2024 12:18:32 +0200 Subject: [PATCH 054/234] test logs release --- .github/workflows/release-dev-log-server.yaml | 1 + .github/workflows/release-log-server.yaml | 25 +++++++++---------- .github/workflows/release-log-sidecar.yaml | 22 ++++++++-------- .../.goreleaser-docker-build-logs-sidecar.yml | 21 ++++++++++------ 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release-dev-log-server.yaml b/.github/workflows/release-dev-log-server.yaml index 9192c4b7e1b..024a3dfb077 100644 --- a/.github/workflows/release-dev-log-server.yaml +++ b/.github/workflows/release-dev-log-server.yaml @@ -4,6 +4,7 @@ on: push: branches: - develop + - ci/logs-service-fix permissions: id-token: write diff --git a/.github/workflows/release-log-server.yaml b/.github/workflows/release-log-server.yaml index 4688b1e589d..382cc9d1464 100644 --- a/.github/workflows/release-log-server.yaml +++ b/.github/workflows/release-log-server.yaml @@ -3,7 +3,7 @@ name: Release logs server on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" permissions: id-token: write @@ -68,19 +68,18 @@ jobs: DOCKER_BUILDX_CACHE_FROM: "type=gha" DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} - DOCKER_IMAGE_TAG: ${{ steps.github_sha.outputs.sha_short }} - - name: Push Docker images - run: | - docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 - # adding the docker manifest for the latest image tag - docker manifest create kubeshop/testkube-logs-server:latest \ - kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 \ - kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker manifest annotate kubeshop/testkube-logs-server:latest kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 - docker manifest annotate kubeshop/testkube-logs-server:latest kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 - docker manifest push kubeshop/testkube-logs-server:latest +# - name: Push Docker images +# run: | +# docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 +# docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 +# # adding the docker manifest for the latest image tag +# docker manifest create kubeshop/testkube-logs-server:latest \ +# kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 \ +# kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 +# docker manifest annotate kubeshop/testkube-logs-server:latest kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 +# docker manifest annotate kubeshop/testkube-logs-server:latest kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 +# docker manifest push kubeshop/testkube-logs-server:latest - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@v1 diff --git a/.github/workflows/release-log-sidecar.yaml b/.github/workflows/release-log-sidecar.yaml index 1523e522d8b..cd7ad6c1d19 100644 --- a/.github/workflows/release-log-sidecar.yaml +++ b/.github/workflows/release-log-sidecar.yaml @@ -69,17 +69,17 @@ jobs: DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} - - name: Push Docker images - run: | - docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 - # adding the docker manifest for the latest image tag - docker manifest create kubeshop/testkube-logs-sidecar:latest \ - kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 \ - kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 - docker manifest annotate kubeshop/testkube-logs-sidecar:latest kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 - docker manifest annotate kubeshop/testkube-logs-sidecar:latest kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 - docker manifest push kubeshop/testkube-logs-sidecar:latest +# - name: Push Docker images +# run: | +# docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 +# docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 +# # adding the docker manifest for the latest image tag +# docker manifest create kubeshop/testkube-logs-sidecar:latest \ +# kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 \ +# kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 +# docker manifest annotate kubeshop/testkube-logs-sidecar:latest kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 +# docker manifest annotate kubeshop/testkube-logs-sidecar:latest kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 +# docker manifest push kubeshop/testkube-logs-sidecar:latest - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@v1 diff --git a/goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml b/goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml index a11a8b20e35..4979267192e 100644 --- a/goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml +++ b/goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml @@ -5,12 +5,14 @@ env: # https://github.com/goreleaser/goreleaser/pull/3199 # To use a builder other than "default", set this variable. # Necessary for, e.g., GitHub actions cache integration. + - DOCKER_REPO={{ if index .Env "DOCKER_REPO" }}{{ .Env.DOCKER_REPO }}{{ else }}kubeshop{{ end }} - DOCKER_BUILDX_BUILDER={{ if index .Env "DOCKER_BUILDX_BUILDER" }}{{ .Env.DOCKER_BUILDX_BUILDER }}{{ else }}default{{ end }} # Setup to enable Docker to use, e.g., the GitHub actions cache; see # https://docs.docker.com/build/building/cache/backends/ # https://github.com/moby/buildkit#export-cache - DOCKER_BUILDX_CACHE_FROM={{ if index .Env "DOCKER_BUILDX_CACHE_FROM" }}{{ .Env.DOCKER_BUILDX_CACHE_FROM }}{{ else }}type=registry{{ end }} - DOCKER_BUILDX_CACHE_TO={{ if index .Env "DOCKER_BUILDX_CACHE_TO" }}{{ .Env.DOCKER_BUILDX_CACHE_TO }}{{ else }}type=inline{{ end }} + - DOCKER_IMAGE_TAG={{ if index .Env "DOCKER_IMAGE_TAG" }}{{ .Env.DOCKER_IMAGE_TAG }}{{ else }}{{ end }} builds: - id: "linux" main: ./cmd/sidecar @@ -31,7 +33,8 @@ dockers: goos: linux goarch: amd64 image_templates: - - "kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-amd64" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}-amd64{{ end }}" + - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-amd64{{ end }}" build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.title={{ .ProjectName }}" @@ -48,7 +51,8 @@ dockers: goos: linux goarch: arm64 image_templates: - - "kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}-arm64v8{{ end }}" + - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8{{ end }}" build_flag_templates: - "--platform=linux/arm64/v8" - "--label=org.opencontainers.image.created={{ .Date }}" @@ -61,14 +65,15 @@ dockers: - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" docker_manifests: - - name_template: kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }} + - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}{{ end }}" image_templates: - - kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-amd64 - - kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8 - - name_template: kubeshop/testkube-logs-sidecar:latest + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}-arm64v8{{ end }}" + - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:latest{{ end }}" image_templates: - - kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-amd64 - - kubeshop/testkube-logs-sidecar:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8 + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-logs-sidecar:{{ .Version }}-arm64v8{{ end }}" + release: disable: true From 64683c2e51b9aafe03b051476909cf6722ea5434 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 31 Jan 2024 12:23:04 +0200 Subject: [PATCH 055/234] release logs service --- .github/workflows/release-log-server.yaml | 2 +- .github/workflows/release-log-sidecar.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-log-server.yaml b/.github/workflows/release-log-server.yaml index 382cc9d1464..fc0480b0424 100644 --- a/.github/workflows/release-log-server.yaml +++ b/.github/workflows/release-log-server.yaml @@ -59,7 +59,7 @@ jobs: with: distribution: goreleaser-pro version: latest - args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-server.yml --skip-publish + args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-server.yml env: GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution diff --git a/.github/workflows/release-log-sidecar.yaml b/.github/workflows/release-log-sidecar.yaml index cd7ad6c1d19..d152e0f9bcc 100644 --- a/.github/workflows/release-log-sidecar.yaml +++ b/.github/workflows/release-log-sidecar.yaml @@ -3,7 +3,7 @@ name: Release logs sidecar on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" permissions: id-token: write @@ -59,7 +59,7 @@ jobs: with: distribution: goreleaser-pro version: latest - args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml --skip-publish + args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml env: GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution From 74353289eb5458b9d81cf28868286586d65e15a3 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 31 Jan 2024 12:27:42 +0200 Subject: [PATCH 056/234] add cosign --- .github/workflows/release-log-server.yaml | 3 +++ .github/workflows/release-log-sidecar.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/release-log-server.yaml b/.github/workflows/release-log-server.yaml index fc0480b0424..7046560501a 100644 --- a/.github/workflows/release-log-server.yaml +++ b/.github/workflows/release-log-server.yaml @@ -29,6 +29,9 @@ jobs: id: buildx uses: docker/setup-buildx-action@v1 + - uses: sigstore/cosign-installer@v3.0.5 + - uses: anchore/sbom-action/download-syft@v0.14.2 + - name: Set up Go uses: actions/setup-go@v2 with: diff --git a/.github/workflows/release-log-sidecar.yaml b/.github/workflows/release-log-sidecar.yaml index d152e0f9bcc..c31fec31015 100644 --- a/.github/workflows/release-log-sidecar.yaml +++ b/.github/workflows/release-log-sidecar.yaml @@ -29,6 +29,9 @@ jobs: id: buildx uses: docker/setup-buildx-action@v1 + - uses: sigstore/cosign-installer@v3.0.5 + - uses: anchore/sbom-action/download-syft@v0.14.2 + - name: Set up Go uses: actions/setup-go@v2 with: From 94d2ab0a9e553d44a4f3d9e3d60b2a864e8499cd Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 31 Jan 2024 12:32:05 +0200 Subject: [PATCH 057/234] remove testing --- .github/workflows/release-dev-log-server.yaml | 2 -- .github/workflows/release-dev-log-sidecar.yaml | 2 -- .github/workflows/release-log-server.yaml | 15 +-------------- .github/workflows/release-log-sidecar.yaml | 15 +-------------- 4 files changed, 2 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release-dev-log-server.yaml b/.github/workflows/release-dev-log-server.yaml index 024a3dfb077..8abeab84322 100644 --- a/.github/workflows/release-dev-log-server.yaml +++ b/.github/workflows/release-dev-log-server.yaml @@ -4,7 +4,6 @@ on: push: branches: - develop - - ci/logs-service-fix permissions: id-token: write @@ -63,7 +62,6 @@ jobs: args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-server.yml --snapshot env: GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} - # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" DOCKER_BUILDX_CACHE_FROM: "type=gha" diff --git a/.github/workflows/release-dev-log-sidecar.yaml b/.github/workflows/release-dev-log-sidecar.yaml index 2c0c0feea89..489dfc8c24e 100644 --- a/.github/workflows/release-dev-log-sidecar.yaml +++ b/.github/workflows/release-dev-log-sidecar.yaml @@ -4,7 +4,6 @@ on: push: branches: - develop - - ci/logs-service-fix permissions: id-token: write @@ -63,7 +62,6 @@ jobs: args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml --snapshot env: GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} - # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" DOCKER_BUILDX_CACHE_FROM: "type=gha" diff --git a/.github/workflows/release-log-server.yaml b/.github/workflows/release-log-server.yaml index 7046560501a..53ad4ff6801 100644 --- a/.github/workflows/release-log-server.yaml +++ b/.github/workflows/release-log-server.yaml @@ -3,7 +3,7 @@ name: Release logs server on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+-*" + - "v[0-9]+.[0-9]+.[0-9]+" permissions: id-token: write @@ -65,25 +65,12 @@ jobs: args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-server.yml env: GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} - # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" DOCKER_BUILDX_CACHE_FROM: "type=gha" DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} -# - name: Push Docker images -# run: | -# docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 -# docker push kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 -# # adding the docker manifest for the latest image tag -# docker manifest create kubeshop/testkube-logs-server:latest \ -# kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 \ -# kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 -# docker manifest annotate kubeshop/testkube-logs-server:latest kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 -# docker manifest annotate kubeshop/testkube-logs-server:latest kubeshop/testkube-logs-server:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 -# docker manifest push kubeshop/testkube-logs-server:latest - - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@v1 env: diff --git a/.github/workflows/release-log-sidecar.yaml b/.github/workflows/release-log-sidecar.yaml index c31fec31015..bc35a3a288c 100644 --- a/.github/workflows/release-log-sidecar.yaml +++ b/.github/workflows/release-log-sidecar.yaml @@ -3,7 +3,7 @@ name: Release logs sidecar on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+-*" + - "v[0-9]+.[0-9]+.[0-9]+" permissions: id-token: write @@ -65,25 +65,12 @@ jobs: args: release -f ./goreleaser_files/.goreleaser-docker-build-logs-sidecar.yml env: GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} - # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" DOCKER_BUILDX_CACHE_FROM: "type=gha" DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} -# - name: Push Docker images -# run: | -# docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 -# docker push kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 -# # adding the docker manifest for the latest image tag -# docker manifest create kubeshop/testkube-logs-sidecar:latest \ -# kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 \ -# kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 -# docker manifest annotate kubeshop/testkube-logs-sidecar:latest kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-amd64 --arch amd64 -# docker manifest annotate kubeshop/testkube-logs-sidecar:latest kubeshop/testkube-logs-sidecar:${{ steps.github_sha.outputs.sha_short }}-arm64v8 --arch arm64 --variant v8 -# docker manifest push kubeshop/testkube-logs-sidecar:latest - - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@v1 env: From b9fa14cc0e73cb0cb2936c7810384a495267ebdf Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 31 Jan 2024 15:33:09 +0300 Subject: [PATCH 058/234] fix: duplicate merged args --- contrib/executor/jmeterd/pkg/runner/runner.go | 34 ++++- .../jmeterd/pkg/runner/runner_test.go | 139 ++++++++++++------ 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index a871d59c90b..ccd924b9810 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -138,6 +138,8 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( reportPath := filepath.Join(outputDir, "report") jmeterLogPath := filepath.Join(outputDir, "jmeter.log") args := execution.Args + args = removeDuplicatedArgs(args) + args, params := mergeDuplicatedArgs(args) hasJunit, hasReport, args := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) if mode == jmeterModeDistributed { @@ -154,7 +156,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( args = append(args, fmt.Sprintf("-R %v", slaveMeta.ToIPString())) } - args = injectAndExpandEnvVars(args, nil) + args = injectAndExpandEnvVars(args, params["-e"]) output.PrintLogf("%s Using arguments: %v", ui.IconWorld, envManager.ObfuscateStringSlice(args)) // TODO: this is a workaround, the check should be ideally performed in the getTestPathAndWorkingDir function @@ -257,7 +259,7 @@ func checkIfTestFileExists(fs filesystem.FileSystem, args []string) error { return nil } -func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool, result []string) { +func removeDuplicatedArgs(args []string) []string { counters := make(map[string]int) duplicates := make(map[string]string) for _, arg := range args { @@ -288,6 +290,34 @@ func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) } } + return args +} + +func mergeDuplicatedArgs(args []string) ([]string, map[string][]string) { + allowed := map[string]string{ + "-e": "", + } + + duplicates := make(map[string][]string) + for i := len(args) - 1; i >= 0; i-- { + if arg, ok := allowed[args[i]]; ok { + if i+1 >= len(args) { + continue + } + + if args[i+1] == arg { + continue + } + + duplicates[args[i]] = append(duplicates[args[i]], args[i+1]) + args = append(args[:i], args[i+2:]...) + } + } + + return args, duplicates +} + +func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool, result []string) { for i, arg := range args { switch arg { case "": diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index 190b4eb9a35..08699aafae9 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -81,7 +81,7 @@ func TestCheckIfTestFileExists(t *testing.T) { } } -func TestPrepareArgsReplacements(t *testing.T) { +func TestPrepareArgs(t *testing.T) { t.Parallel() tests := []struct { @@ -135,64 +135,48 @@ func TestPrepareArgsReplacements(t *testing.T) { } } -func TestPrepareArgsDuplication(t *testing.T) { +func TestRemoveDuplicatedArgs(t *testing.T) { t.Parallel() tests := []struct { - name string - args []string - expectedArgs []string - expectedJunit bool - expectedReport bool + name string + args []string + expectedArgs []string }{ { - name: "Duplicated args", - args: []string{"-t", "", "-t", "path", "-l"}, - expectedArgs: []string{"-t", "path", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Duplicated args", + args: []string{"-t", "", "-t", "path", "-l"}, + expectedArgs: []string{"-t", "path", "-l"}, }, { - name: "Multiple duplicated args", - args: []string{"-t", "", "-o", "", "-t", "path", "-o", "output", "-l"}, - expectedArgs: []string{"-t", "path", "-o", "output", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Multiple duplicated args", + args: []string{"-t", "", "-o", "", "-t", "path", "-o", "output", "-l"}, + expectedArgs: []string{"-t", "path", "-o", "output", "-l"}, }, { - name: "Non duplicated args", - args: []string{"-t", "path", "-l"}, - expectedArgs: []string{"-t", "path", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Non duplicated args", + args: []string{"-t", "path", "-l"}, + expectedArgs: []string{"-t", "path", "-l"}, }, { - name: "Wrong arg order", - args: []string{"", "-t", "-t", "path", "-l"}, - expectedArgs: []string{"-t", "-t", "path", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Wrong arg order", + args: []string{"", "-t", "-t", "path", "-l"}, + expectedArgs: []string{"-t", "-t", "path", "-l"}, }, { - name: "Missed template arg", - args: []string{"-t", "-t", "path", "-l"}, - expectedArgs: []string{"-t", "-t", "path", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Missed template arg", + args: []string{"-t", "-t", "path", "-l"}, + expectedArgs: []string{"-t", "-t", "path", "-l"}, }, { - name: "Wrong arg before template", - args: []string{"-d", "-o", "", "-t", "-t", "path", "-l"}, - expectedArgs: []string{"-d", "-o", "-t", "-t", "path", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Wrong arg before template", + args: []string{"-d", "-o", "", "-t", "-t", "path", "-l"}, + expectedArgs: []string{"-d", "-o", "-t", "-t", "path", "-l"}, }, { - name: "Duplicated not template args", - args: []string{"-t", "first", "-t", "second", "-l"}, - expectedArgs: []string{"-t", "first", "-t", "second", "-l"}, - expectedJunit: true, - expectedReport: false, + name: "Duplicated not template args", + args: []string{"-t", "first", "-t", "second", "-l"}, + expectedArgs: []string{"-t", "first", "-t", "second", "-l"}, }, } @@ -201,13 +185,72 @@ func TestPrepareArgsDuplication(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - hasJunit, hasReport, args := prepareArgs(tt.args, "", "", "", "") + args := removeDuplicatedArgs(tt.args) - for i, arg := range args { - assert.Equal(t, tt.expectedArgs[i], arg) + assert.Equal(t, len(args), len(tt.expectedArgs)) + for j, arg := range args { + assert.Equal(t, tt.expectedArgs[j], arg) + } + }) + } +} + +func TestMergeDuplicatedArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + expectedArgs []string + arg string + params []string + }{ + { + name: "Duplicated args", + args: []string{"-e", "", "-e", "var", "-l"}, + expectedArgs: []string{"-e", "", "-l"}, + arg: "-e", + params: []string{"var"}, + }, + { + name: "Multiple duplicated args", + args: []string{"-e", "", "-e", "var 1", "-e", "var 2", "-l"}, + expectedArgs: []string{"-e", "", "-l"}, + arg: "-e", + params: []string{"var 2", "var 1"}, + }, + { + name: "Non duplicated args", + args: []string{"-e", "", "-l"}, + expectedArgs: []string{"-e", "", "-l"}, + arg: "-e", + params: []string{}, + }, + { + name: "Wrong arg order", + args: []string{"-e", "", "var", "-e"}, + expectedArgs: []string{"-e", "", "var", "-e"}, + arg: "-e", + params: []string{}, + }, + } + + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + args, params := mergeDuplicatedArgs(tt.args) + + assert.Equal(t, len(args), len(tt.expectedArgs)) + for j, arg := range args { + assert.Equal(t, tt.expectedArgs[j], arg) + } + + assert.Equal(t, len(params[tt.arg]), len(tt.params)) + for j, arg := range params[tt.arg] { + assert.Equal(t, tt.params[j], arg) } - assert.Equal(t, tt.expectedJunit, hasJunit) - assert.Equal(t, tt.expectedReport, hasReport) }) } } @@ -272,7 +315,7 @@ func TestJMeterDRunner_Local_Integration(t *testing.T) { TestNamespace: "testkube", Name: "test1", Command: []string{"jmeter"}, - Args: []string{"-n", "-j", "", "-t", "", "-l", "", "-e", "-o", "", ""}, + Args: []string{"-n", "-j", "", "-t", "", "-l", "", "-o", "", "-e", ""}, Content: &testkube.TestContent{ Type_: string(testkube.TestContentTypeString), Data: tt.jmxContent, From 9b5b4a9dfc9427185af84e03f25c9ae725800ca0 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 31 Jan 2024 15:39:41 +0300 Subject: [PATCH 059/234] fix: remove unused parameter --- contrib/executor/jmeterd/pkg/runner/runner.go | 6 +++--- contrib/executor/jmeterd/pkg/runner/runner_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index ccd924b9810..b0276946204 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -140,7 +140,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( args := execution.Args args = removeDuplicatedArgs(args) args, params := mergeDuplicatedArgs(args) - hasJunit, hasReport, args := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) + hasJunit, hasReport := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) if mode == jmeterModeDistributed { clientSet, err := k8sclient.ConnectToK8s() @@ -317,7 +317,7 @@ func mergeDuplicatedArgs(args []string) ([]string, map[string][]string) { return args, duplicates } -func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool, result []string) { +func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool) { for i, arg := range args { switch arg { case "": @@ -333,7 +333,7 @@ func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) hasJunit = true } } - return hasJunit, hasReport, args + return hasJunit, hasReport } func getEntryPoint() (entrypoint string) { diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index 08699aafae9..c33b3e98790 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -124,9 +124,9 @@ func TestPrepareArgs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - hasJunit, hasReport, args := prepareArgs(tt.args, tt.path, tt.jtlPath, tt.reportPath, tt.jmeterLogPath) + hasJunit, hasReport := prepareArgs(tt.args, tt.path, tt.jtlPath, tt.reportPath, tt.jmeterLogPath) - for i, arg := range args { + for i, arg := range tt.args { assert.Equal(t, tt.expectedArgs[i], arg) } assert.Equal(t, tt.expectedJunit, hasJunit) From f13c9471504b75038841d69ed38907709c063e80 Mon Sep 17 00:00:00 2001 From: Julianne Fermi Date: Wed, 31 Jan 2024 06:52:20 -0800 Subject: [PATCH 060/234] Add selected webhooks blog content to webhooks doc. (#4958) --- docs/docs/articles/webhooks.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/docs/articles/webhooks.mdx b/docs/docs/articles/webhooks.mdx index 0c8be2837a1..bde0198b352 100644 --- a/docs/docs/articles/webhooks.mdx +++ b/docs/docs/articles/webhooks.mdx @@ -5,6 +5,25 @@ import TabItem from "@theme/TabItem"; Webhooks allow you to integrate Testkube with external systems by sending HTTP POST payloads containing information about Testkube executions and their current state when specific events occur. To set up webhooks in Testkube, you'll need to have an HTTPS endpoint to receive the events and a payload template to be sent along with the data. +:::note +Please visit our Blog, [Empowering Kubernetes Tests with Webhooks](https://testkube.io/blog/empowering-kubernetes-tests-with-webhooks) for a tutorial on setting up webhooks for Slack and Grafana Dashboard. +::: + +## Benefits of using Webhooks in Testkube + +Testkube uses webhooks to integrate with external systems, allowing you to effortlessly synchronize your testing workflows with other tools and platforms. These webhooks are designed to carry critical information regarding your tests as HTTP POST payloads. The information can include the execution and real-time status depending on how you configure it. + +To leverage webhooks, you need to ensure that the platform that you want to send information to has an HTTPS endpoint to receive the events. Testkube also allows you to customize the payloads. + +You can create a webhook from the dashboard, use the CLI, or create it as a custom resource. Before we show how it’s done, let’s understand a few scenarios where Webhooks in Testkube shine: + +- Incident Management & Response: Webhooks can be used to create incidents and alert on-call teams when a critical test fails. This ensures a timely response and avoids any potential disruption due to failures and bugs. With Testkube, you can configure incident management tools like PagerDuty and OpsGenie to receive alerts based on critical events for your tests. + +- Communication and Collaboration: You can configure Webhooks in Testkube to send alerts to your teams in your communication tool. This will notify your team of any critical event that needs attention and attend to it before the issue escalates. Some of the popular communications tools like Slack and MS Teams can be configured to receive alerts from Testkube. + +- Monitoring and Observability: Webhooks can also be used to send alerts and notifications to your monitoring and observability tools like Prometheus and Grafana. This provides visibility into your tests, alerts you, and ensures that timely corrective actions can be taken. + + ## Creating a Webhook The webhook can be created using the Dashboard, CLI, or a Custom Resource. From 2aa20020088af4a7518a16d0f2aa15e376ac9760 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 1 Feb 2024 14:18:33 +0300 Subject: [PATCH 061/234] fix: refactor merge args --- contrib/executor/jmeterd/pkg/runner/runner.go | 27 +++++++---------- .../jmeterd/pkg/runner/runner_test.go | 30 ++++--------------- 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index b0276946204..c0a56b3bfdb 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -137,9 +137,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( jtlPath := filepath.Join(outputDir, "report.jtl") reportPath := filepath.Join(outputDir, "report") jmeterLogPath := filepath.Join(outputDir, "jmeter.log") - args := execution.Args - args = removeDuplicatedArgs(args) - args, params := mergeDuplicatedArgs(args) + args := mergeDuplicatedArgs(removeDuplicatedArgs(execution.Args)) hasJunit, hasReport := prepareArgs(args, testPath, jtlPath, reportPath, jmeterLogPath) if mode == jmeterModeDistributed { @@ -156,7 +154,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( args = append(args, fmt.Sprintf("-R %v", slaveMeta.ToIPString())) } - args = injectAndExpandEnvVars(args, params["-e"]) + args = injectAndExpandEnvVars(args, nil) output.PrintLogf("%s Using arguments: %v", ui.IconWorld, envManager.ObfuscateStringSlice(args)) // TODO: this is a workaround, the check should be ideally performed in the getTestPathAndWorkingDir function @@ -293,28 +291,23 @@ func removeDuplicatedArgs(args []string) []string { return args } -func mergeDuplicatedArgs(args []string) ([]string, map[string][]string) { - allowed := map[string]string{ - "-e": "", +func mergeDuplicatedArgs(args []string) []string { + allowed := map[string]int{ + "-e": 0, } - duplicates := make(map[string][]string) for i := len(args) - 1; i >= 0; i-- { - if arg, ok := allowed[args[i]]; ok { - if i+1 >= len(args) { + if counter, ok := allowed[args[i]]; ok { + allowed[args[i]]++ + if counter == 0 { continue } - if args[i+1] == arg { - continue - } - - duplicates[args[i]] = append(duplicates[args[i]], args[i+1]) - args = append(args[:i], args[i+2:]...) + args = append(args[:i], args[i+1:]...) } } - return args, duplicates + return args } func prepareArgs(args []string, path, jtlPath, reportPath, jmeterLogPath string) (hasJunit, hasReport bool) { diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index c33b3e98790..d0f57159454 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -202,36 +202,21 @@ func TestMergeDuplicatedArgs(t *testing.T) { name string args []string expectedArgs []string - arg string - params []string }{ { name: "Duplicated args", - args: []string{"-e", "", "-e", "var", "-l"}, - expectedArgs: []string{"-e", "", "-l"}, - arg: "-e", - params: []string{"var"}, + args: []string{"-e", "", "-e"}, + expectedArgs: []string{"", "-e"}, }, { name: "Multiple duplicated args", - args: []string{"-e", "", "-e", "var 1", "-e", "var 2", "-l"}, - expectedArgs: []string{"-e", "", "-l"}, - arg: "-e", - params: []string{"var 2", "var 1"}, + args: []string{"", "-e", "-e", "-l"}, + expectedArgs: []string{"", "-e", "-l"}, }, { name: "Non duplicated args", args: []string{"-e", "", "-l"}, expectedArgs: []string{"-e", "", "-l"}, - arg: "-e", - params: []string{}, - }, - { - name: "Wrong arg order", - args: []string{"-e", "", "var", "-e"}, - expectedArgs: []string{"-e", "", "var", "-e"}, - arg: "-e", - params: []string{}, }, } @@ -240,17 +225,12 @@ func TestMergeDuplicatedArgs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - args, params := mergeDuplicatedArgs(tt.args) + args := mergeDuplicatedArgs(tt.args) assert.Equal(t, len(args), len(tt.expectedArgs)) for j, arg := range args { assert.Equal(t, tt.expectedArgs[j], arg) } - - assert.Equal(t, len(params[tt.arg]), len(tt.params)) - for j, arg := range params[tt.arg] { - assert.Equal(t, tt.params[j], arg) - } }) } } From b80ce7858412d35a9cce104e8589d0a6164c8c1e Mon Sep 17 00:00:00 2001 From: Dejan Zele Pejchev Date: Thu, 1 Feb 2024 16:54:46 +0100 Subject: [PATCH 062/234] fix: remove custom assertion logic in jmeterd executor (#4966) * fix: remove custom assertion logic in jmeterd executor * fix: fix failign jmeterd integration test --- contrib/executor/jmeterd/pkg/parser/mapper.go | 22 ++-- .../jmeterd/pkg/runner/runner_test.go | 106 +++++++++++------- 2 files changed, 79 insertions(+), 49 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/parser/mapper.go b/contrib/executor/jmeterd/pkg/parser/mapper.go index bc077431bc0..aaaf4a47cd7 100644 --- a/contrib/executor/jmeterd/pkg/parser/mapper.go +++ b/contrib/executor/jmeterd/pkg/parser/mapper.go @@ -8,10 +8,11 @@ import ( func mapCSVResultsToExecutionResults(out []byte, results CSVResults) (result testkube.ExecutionResult) { result = MakeSuccessExecution(out) - if results.HasError { - result.Status = testkube.ExecutionStatusFailed - result.ErrorMessage = results.LastErrorMessage - } + // TODO: Is it enough to just disable it here? + //if results.HasError { + // result.Status = testkube.ExecutionStatusFailed + // result.ErrorMessage = results.LastErrorMessage + //} for _, r := range results.Results { result.Steps = append( @@ -43,12 +44,13 @@ func mapXMLResultsToExecutionResults(out []byte, results XMLResults) (result tes samples := append(results.HTTPSamples, results.Samples...) for _, r := range samples { - if !r.Success { - result.Status = testkube.ExecutionStatusFailed - if r.AssertionResult != nil { - result.ErrorMessage = r.AssertionResult.FailureMessage - } - } + // TODO: Is it enough to disable it here? + //if !r.Success { + // result.Status = testkube.ExecutionStatusFailed + // if r.AssertionResult != nil { + // result.ErrorMessage = r.AssertionResult.FailureMessage + // } + //} result.Steps = append( result.Steps, diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index d0f57159454..d165cc1cc18 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -6,13 +6,13 @@ import ( "path/filepath" "testing" + "github.com/kubeshop/testkube/pkg/utils/test" + "github.com/golang/mock/gomock" "github.com/pkg/errors" "github.com/kubeshop/testkube/pkg/filesystem" - "github.com/kubeshop/testkube/pkg/utils/test" - "github.com/stretchr/testify/assert" "github.com/kubeshop/testkube/pkg/api/v1/testkube" @@ -535,54 +535,41 @@ log.info("================================="); const failureJMX = ` - - Kubeshop site simple perf test + + false - true false - - - PATH - /pricing - = - - + - continue + stopthread false 1 1 1 + 1668426657000 + 1668426657000 false true - + - - - false - $PATH - = - true - PATH - - + - testkube.io - 80 - https + testkube.kubeshop.io + + - https://testkube.io + GET true false @@ -592,18 +579,59 @@ const failureJMX = ` - - - - SOME_NONExisting_String - - - Assertion.response_data - false - 16 - - - + + + + 418 + + Assertion.response_code + false + 8 + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + false + false + false + false + false + 0 + true + true + + + + + + + groovy + + + true + println "\nJMeter negative test - failing Thread Group in purpose with System.exit(1)\n"; +System.exit(1); + + From 70ed08cd48ce76f6c439c1cdd56bfaf411fc042b Mon Sep 17 00:00:00 2001 From: Catalin <20538711+devcatalin@users.noreply.github.com> Date: Mon, 5 Feb 2024 16:08:42 +0200 Subject: [PATCH 063/234] docs: instructions for using Jenkins Plugin via the UI (#4956) * docs: update Jenkins article * docs: update sidebar * chore: add jenkins plugin url * chore: update jenkins docs env vars * docs: Jenkins via UI articles * Update docs/docs/articles/jenkins-ui.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/jenkins-ui.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/jenkins-ui.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/jenkins-ui.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/jenkins-ui.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/jenkins-ui.md Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --- docs/docs/articles/cicd-overview.md | 3 ++- docs/docs/articles/jenkins-ui.md | 33 ++++++++++++++++++++++++ docs/docs/articles/jenkins.md | 2 +- docs/docs/img/jenkins-build-step.png | Bin 0 -> 27077 bytes docs/docs/img/jenkins-environment.png | Bin 0 -> 108955 bytes docs/docs/img/jenkins-execute-shell.png | Bin 0 -> 27205 bytes docs/sidebars.js | 1 + 7 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docs/docs/articles/jenkins-ui.md create mode 100644 docs/docs/img/jenkins-build-step.png create mode 100644 docs/docs/img/jenkins-environment.png create mode 100644 docs/docs/img/jenkins-execute-shell.png diff --git a/docs/docs/articles/cicd-overview.md b/docs/docs/articles/cicd-overview.md index 4a4d6c7213f..9d267067bf5 100644 --- a/docs/docs/articles/cicd-overview.md +++ b/docs/docs/articles/cicd-overview.md @@ -8,7 +8,8 @@ We have different tutorials for the options of being CI driven or using GitOps a - [Github Actions - running Testkube CLI commands with setup-testkube-action](./github-actions.md) - [Testkube Docker CLI](./testkube-cli-docker.md) - [Gitlab CI](./gitlab.md) -- [Jenkins](./jenkins.md) +- [Jenkins Pipelines](./jenkins.md) +- [Jenkins UI](./jenkins-ui.md) - [CircleCI](./circleci.md) - [GitOps Testing](./gitops-overview.md) - [Flux](./flux-integration.md) diff --git a/docs/docs/articles/jenkins-ui.md b/docs/docs/articles/jenkins-ui.md new file mode 100644 index 00000000000..8dde9179bca --- /dev/null +++ b/docs/docs/articles/jenkins-ui.md @@ -0,0 +1,33 @@ +# Testkube Jenkins UI + +The Testkube Jenkins integration streamlines the installation of Testkube, enabling the execution of any [Testkube CLI](https://docs.testkube.io/cli/testkube) command within Jenkins Pipelines or Freestyle Projects. + +If you are using Pipelines and Groovy scripts, then look at examples from [Testkube Jenkins Pipelines](./jenkins.md). + +### Testkube CLI Jenkins Plugin + +Install the Testkube CLI plugin by searching for it in the "Available Plugins" section on Jenkins Plugins, or using the following url: +[https://plugins.jenkins.io/testkube-cli](https://plugins.jenkins.io/testkube-cli) + +## Testkube Pro + +To use Jenkins CI/CD for [Testkube Pro](https://app.testkube.io/), you need to create an [API token](https://docs.testkube.io/testkube-pro/articles/organization-management/#api-tokens). + + +### How to set up a Freestyle Project to run tests on Testkube Pro + +1. Create a new Freestyle Project. +2. In General settings, configure the environment variables: + - TK_ORG + - TK_ENV + - TK_API_TOKEN + +![jenkins environment variables configuration](../img/jenkins-environment.png) + +3. Click on "Add Build Step" and select "Testkube Setup". +![jenkins testkube setup build step](../img/jenkins-build-step.png) + +4. Specify a Testkube CLI version or leave it empty to use the latest version. + +5. Add a new "Execute Shell" Build Step and run one or multiple Testkube CLI commands. +![jenkins execute shell](../img/jenkins-execute-shell.png) diff --git a/docs/docs/articles/jenkins.md b/docs/docs/articles/jenkins.md index 30fe6343753..3b6861e1e96 100644 --- a/docs/docs/articles/jenkins.md +++ b/docs/docs/articles/jenkins.md @@ -1,4 +1,4 @@ -# Testkube Jenkins +# Testkube Jenkins Pipelines The Testkube Jenkins integration streamlines the installation of Testkube, enabling the execution of any [Testkube CLI](https://docs.testkube.io/cli/testkube) command within Jenkins pipelines. This integration can be effortlessly integrated into your Jenkins setup, enhancing your continuous integration and delivery processes. This Jenkins integration offers a versatile solution for managing your pipeline workflows and is compatible with Testkube Pro, Testkube Enterprise, and the open-source Testkube platform. It allows Jenkins users to effectively utilize Testkube's capabilities within their CI/CD pipelines, providing a robust and flexible framework for test execution and automation. diff --git a/docs/docs/img/jenkins-build-step.png b/docs/docs/img/jenkins-build-step.png new file mode 100644 index 0000000000000000000000000000000000000000..ed1820af2150d1ca0e12a48488a022616aa1ccce GIT binary patch literal 27077 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
xp~FvTuK!)a*qV)BPq}7^msr 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 Jd(Q6eKL7*!KA8Xj literal 0 HcmV?d00001 diff --git a/docs/docs/img/jenkins-execute-shell.png b/docs/docs/img/jenkins-execute-shell.png new file mode 100644 index 0000000000000000000000000000000000000000..7db10a340d1c2bd4197e9fd47d792993033665c3 GIT binary patch literal 27205 zcmcG$cQ{;a*FG#EQUpn)+#yN|B7*1)qeS!;B}9*2$LNf~5J{wuAo}PfqKw{$h$MO) z42GE`dL0Zi(ZB6}o;!EE-}^g`@B8EDIL2&y@2jnA@3q#s*15d6ucOL%iuDu?4Gp8Z z+C6<58d?|)%@Ozsdf*H+_bwIqI^wObdY7iC`^p^fhmHM1bq8&2np?o}37R7j&NOrf zw*Vhj;6p=07xj$hIPiTA_}ouBLPHCDA33<5cJ!Y+X<=!!{~RBI1J`L34V2W?fo}s_ zFME4;ZzrfviTw6wpy80S;X|K?+FBr6C`8cO4r*gB7y$7&XhI_!00Isn_CD5J0T4HL zZ%_dE+TVA8fa8PHLf5$dzQxBCeC?t3eJ&-ampzw+;2ps`*W^xdadF9d**Spp@2UK^ zIq(sSJnaLV{~gKQ`@hQq7ASOZMMzlij?nME zfu^ztXF>O!1MJ;Q?m0sM@c?7UiAqY#{(b-d>&m|){?YQ`zb&Pt4z>K_%KvQnz}wzS z2?_y*^pX2_XZ}0vpBMkzP*&(*<$p-xKVts-EFfpOQ?f$8_e}0oq?lSP4UGbg`aMO% zfFq0J$Aiu*Z+}|ljl6%CgrO1?i+hhNJJB6I!4*pL`&Z}%uGWdWf7}POxTY4$CG=Q_M_cjV=D^X3wQBl5d!z|F zagM9?Ra&&V!J*Sf9?KT}9`^4ZxE=$Iug%)^pFcFEP%fnFp%KnJ=Q{GZ{sbsN?AW0W zfGM*6-=AVekDuA}ZA(GkbLYNHNC#)VN*s{i{XyhLw%vgfY#KL4O6{YK-pp?m>T1by zBW^$ZStSR8`_-gbvo~&>^aLK(6xZ0m?3uVJ$4#5$T64pR>Rhc8x+Ob zwjevkxcPU7TBh6i-Yg8Pm?735)_0m!wnKwchR6Cp7>PG-L?Ah$k98Pt7xu*Q8nF%( z%vbGw|8c$4Y09*58%01c#OI?~BMk@RpiCe7i+cir* zAKM*5Kde%xEF#HfR{37X`1C)761VJKC0iS+!$THajSFu)Wtb$JHTrA>jv!VdiCeXU zw=|n}V7o&?mK8=!S3p0j8baROijs!{qcRv5yi-zryHyBvYN&&C`AgX+LL2H-JAnfi z^SatE)7|gADmpD{_1}Kwq?!@4+!@8~8EUkT_$o`@f0?<{__KZs zq3q6LEOzov(aL^fS1RE_bq@Uo6j@oGt70?A%xi}( zx6zqXC%O6GFdtHqG)J#q@EDJ73GY@4+>??zB!)<>P}Q3^Cce`iK3j3@ucCyC9?lBY zL-LI}d1jLPf4TX@S}A2SlX7TO1(72-7W9pS$t2r`T~~eET4KBEwKl1~-QTRLs){#W z9;>;bn<6_?t#XCwUqOZSo~lj>;1*tufhbZIeNH{27^YaM;=V8UrZ-2$$KM3hm}rml z00ocetW#aze3Pq#idq~g(`*QWouqW5JHm&4i)5xEbb$*xG^TuUC4*<9$5o9Lu}t^# zC)0C!n0gb6WjH$|2>F5{HjL%KJU+&dmGYwEj|JR|q-hqvK5Li4tnGPJb}60V#+>&R zaWwtY#1rdB^{zAjmY0Ij#YeC0jpX1nAC|3x#Z6KWANb&SpnRIU!JoJB^UYtYDM4#(4{J83*|N0~^hIpG z)J+GykF6s$TsH~!nazn!w-;V;5Mh=3*IM6;1hzxyo0c(WfzJ#IGx?c8NGkK^{7_-A z%J#8R0}`QX^Voe*x+>bze}quDO=>i&^{q2CDtO3^ffHQ=_g=OSm)PD=efw8}nDfh^ z^*+bafK`3x-n5jlz=f{_WaHJ3ZoUSkjIrRT@BKc8#vWg zV=L{b;~dqhMRm)!>Ux@)nTuf&r(rrFyS}lnFO%~u_NU@lrmBbzZE;p1*SIy##|3Xw zI?8$)Zi_l}mSU_5&8%*Q(}Q_7$undQy#J4Jf7K)f=VZ6BYLi>WMNadZ2(JzS$xR1g zmel(l=tTV~DZvLZSh7!GaZyl8&0|}M%okx1jpr|8h zq<&32{_1;Hx{$58ZHahnJvea8CE`q_+lV>v;Q^evbl{~@B?MEoM{2y|1fctSyWQ#b zGjNr_{)9BLn zPzi8s(E@e`A>#Q{QUpo}a_-A8DYdD%q=#kq-)W(X055LW!x*ylvro0H`OfwlSq5($ z8!55P9Y7<(+=h$$=(6D4j@`*3UX$DUMdl_65|AiHDe(_Mv*|6`_c!0m{`$Tj_1>F@ zQ|t~lA~`p1-BOk@S^n2|i1Sc^C#y;3iM9SnYw>^8m)(i1{d{RU$oi5nL(>lfa28>~ zs6IfLRLLs-5gwNs>=&t(x|h!$l?S#HYTPLp&pCqs{Pfk*TAHHzLNe;&?T2=7`bz?) zHpae-!$S=VE8SKI)V!U{1oaq0)LR+Y+Ii_9Dd&u{g_${<?m%{;j5@o(GQ9Q0%qF{E&(Ojz^P)uO$~KzdSF}%*xO{RM^s4 z-0DQaSn}#5dz8j=6p0X~bWk9*Jbi90sD1{!Q68I(jaNWbZsm8jUoJh5ROV*;G9_M7 ziltlp-sWQt7|6$S zu7AprE{7#_792bSVq^!6(FNeU?p+s{1l}zXER|UzI=%inj6D6$W$@$h_ID0=K`N$E zdjWWHPnjt%ZRA@u7CE6)n0nm$v(hd0hi!Ee;fs{^VrwD1NEAgq@!maJ+1!Xu2g+v4 zTnY98Bim4wna}JLgOIF!Tt$mE?>)W;AG3BBw~>UIT;JgR-74+0#4fq09|?|@BF!?5 zIu|E5mnZ5LujuxKQS9b-U#_mMmIiK&itz@nj>|{dGp?OGcdox)NVlVU{v*H0Z4GFl zA^hC$=Z)s*yIbG4aLcO*c!Af}`eHKQ&<8!v>RMis8~CmwX`}z7YI2rh*Y_q`w=<$P zUt}C+ZMZMX@7d1vrstz9)^bxrggYQpIZ5utNXc2ku92u>hq&K|$zQ81OS|5gs*R9{ zdm)sOOKU%0q1!7V_^zy5W}Umi>eP*|-@a*#*Q$#ezh$Flqmq?LPw^G5RR)aP%XAT2 zW6~%e``$EFE^X{8(?0_I>$6owf2;>vh^CJ3heXyK%x+CZmz% z2g@URYZ`S#=Wg5@wWThg8+YMWqv9bZb3;9p|QwVRb|HV9yGnfE>E^sG}nw+7bCo*{xRE zih=a}QG@tE5SZ4*1@CJmKD~M}Z?5=XhmcF3E~Ar~xQ@8&*xPU79nCXzLM1Y;DZO;v zf&G?AmYOx&>71c6sCvMad?+Jo-@e2;Hhcf;=cf$WV=;?m6#eX=*kpo`|F4yIb}TJ7 z@NF|ncR#D+@wwE~IfJ&-(cnR4#+&}jV-S*zmPwqUf9n^G;O8*&u`to1B)Jn7aJ!Rh zB4Ij4zc(oCHm^ct+?3A9riC@*n%HoM+nJ9?Qt15~n%#S#>ZZtljdb48q6k-&Tc?*! z=^o)#!z$>)PhmM~6ATecABH$|j@GZprVgC?t-rW_9i@XcNoId3yPX!6(jA%C{IDWI zkZ%|p4x-M(Ix%pYaG9l5+vn3Ja#zp)F^UlYDe51J!mpnU^A<=0dvEn~U~F4{c6lGy z9gQ1O;;c$jOzY0bgZ=3VChHz8BDKk+7CZ6(@mikXIV%ybdsq6|%bAx0L1ihj{`J^TCDy!HP@4$M|;vc+j8S zv7ujRN?S9V@m4=i-vahMY%fF6>P9ZmX?wJ;94~oB==G#Szr^pi2au9f$H5aj`hfps z!{c`jU0X`E{`<)DeEMoRI;fkjIKkMyOkylCBuM~gh0wgiqf<~e6a3MQv|oA%BPk?i7J+XbIyRT6jAW7q@AOCVzm`e*az;YCJNM*rJK6n zR_nWfc>r51$<6X7!KbjgE$S2BE{))j@_V-dlOE}}R8)+4QVW)oBP@%C?A&u6DMjsU z&Jg|JZ&=^h&GuTC!l@iJb2*m!HC`4qJ8KJe&ClKtY*&5)QmCsCT(@B~2 z_~f-|0F9tztOOX@Dq}8L{&nj()0OqwxC2J>d*W$IYEE

E;;7$W)73@82Ei2 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%0Aiors%6NhW8K~&d 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 Date: Mon, 5 Feb 2024 16:12:06 +0200 Subject: [PATCH 064/234] docs: Azure DevOps Pipelines (#4968) * docs: update Jenkins article * docs: update sidebar * chore: add jenkins plugin url * chore: update jenkins docs env vars * docs: Jenkins via UI articles * docs: Azure Pipelines integration * Update docs/docs/articles/azure.md Co-authored-by: Julianne Fermi * Update docs/docs/articles/azure.md Co-authored-by: Julianne Fermi * Apply suggestions from code review Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --- docs/docs/articles/azure.md | 158 ++++++++++++++++++++++++++++ docs/docs/articles/cicd-overview.md | 1 + docs/docs/articles/jenkins-ui.md | 2 +- 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 docs/docs/articles/azure.md diff --git a/docs/docs/articles/azure.md b/docs/docs/articles/azure.md new file mode 100644 index 00000000000..ca714276e44 --- /dev/null +++ b/docs/docs/articles/azure.md @@ -0,0 +1,158 @@ +# Testkube Azure DevOps Pipelines + +Testkube's integration with Azure DevOps streamlines the installation of Testkube, enabling the execution of any [Testkube CLI](https://docs.testkube.io/cli/testkube) command within Azure DevOps pipelines. This integration can be effortlessly integrated into your Azure DevOps setup, enhancing your continuous integration and delivery processes. +The Azure DevOps integration offers a versatile solution for managing your pipeline workflows and is compatible with Testkube Pro, Testkube Enterprise, and the open-source Testkube platform. It allows Azure DevOps users to effectively utilize Testkube's capabilities within their CI/CD pipelines, providing a robust and flexible framework for test execution and automation. + +### Azure DevOps Extension + +Install the Testkube CLI extension using the following url: +[https://marketplace.visualstudio.com/items?itemName=Testkube.testkubecli](https://marketplace.visualstudio.com/items?itemName=Testkube.testkubecli) + +## Testkube Pro + +### How to configure Testkube CLI action for Testkube Pro and run a test + +To use Azure DevOps Pipelines for [Testkube Pro](https://app.testkube.io/), you need to create an [API token](https://docs.testkube.io/testkube-pro/articles/organization-management/#api-tokens). +Then, pass the **organization** and **environment** IDs, along with the **token** and other parameters specific for your use case. + +If a test is already created, you can run it using the command `testkube run test test-name -f`. However, if you need to create a test in this workflow, you need to add a creation command, e.g.: `testkube create test --name test-name --file path_to_file.json`. + +You'll need to create a `azure-pipelines.yml`` file. This will include the stages, jobs and tasks necessary to execute the workflow + +```yaml +trigger: +- main + +pool: + vmImage: 'ubuntu-latest' + +stages: +- stage: Test + jobs: + - job: RunTestkube + steps: + - task: SetupTestkube@1 + inputs: + organization: '$(TK_ORG_ID)' + environment: '$(TK_ENV_ID)' + token: '$(TK_API_TOKEN)' + - script: testkube run test test-name -f + displayName: Run Testkube Test +``` + +## Testkube OSS + +### How to configure the Testkube CLI action for TK OSS and run a test + +To connect to the self-hosted instance, you need to have **kubectl** configured for accessing your Kubernetes cluster, and pass an optional namespace, if Testkube is not deployed in the default **testkube** namespace. + +If a test is already created, you can run it using the command `testkube run test test-name -f` . However, if you need to create a test in this workflow, please add a creation command, e.g.: `testkube create test --name test-name --file path_to_file.json`. + +```yaml +trigger: +- main + +pool: + vmImage: 'ubuntu-latest' + +stages: +- stage: Test + jobs: + - job: RunTestkube + steps: + - task: SetupTestkube@1 + inputs: + namespace: 'custom-testkube-namespace' + url: 'custom-testkube-url' + - script: testkube run test test-name -f + displayName: 'Run Testkube Test' +``` + +The steps to connect to your Kubernetes cluster differ for each provider. You should check the docs of your Cloud provider for how to connect to the Kubernetes cluster from Azure DevOps Pipelines + +### How to configure Testkube CLI action for TK OSS and run a test + +This workflow establishes a connection to the EKS cluster and creates and runs a test using TK CLI. In this example we also use variables not + to reveal sensitive data. Please make sure that the following points are satisfied: +- The **AWS_ACCESS_KEY_ID**, **AWS_SECRET_ACCESS_KEY** secrets should contain your AWS IAM keys with proper permissions to connect to the EKS cluster. +- The **AWS_REGION** secret should contain the AWS region where EKS is. +- Tke **EKS_CLUSTER_NAME** secret points to the name of the EKS cluster you want to connect. + +```yaml +trigger: +- main + +pool: + vmImage: 'ubuntu-latest' + +stages: +- stage: Test + jobs: + - job: SetupAndRunTestkube + steps: + - script: | + # Setting up AWS credentials + aws configure set aws_access_key_id $(AWS_ACCESS_KEY_ID) + aws configure set aws_secret_access_key $(AWS_SECRET_ACCESS_KEY) + aws configure set region $(AWS_REGION) + + # Updating kubeconfig for EKS + aws eks update-kubeconfig --name $(EKS_CLUSTER_NAME) --region $(AWS_REGION) + displayName: 'Setup AWS and Testkube' + + - task: SetupTestkube@1 + inputs: + organization: '$(TK_ORG_ID)' + environment: '$(TK_ENV_ID)' + token: '$(TK_API_TOKEN)' + + - script: testkube run test test-name -f + displayName: Run Testkube Test + +``` + +### How to connect to GKE (Google Kubernetes Engine) cluster and run a test + +This example connects to a k8s cluster in Google Cloud and creates and runs a test using Testkube Azure DevOps pipeline. Please make sure that the following points are satisfied: +- The **GKE Sevice Account** should have already been created in Google Cloud and added to pipeline variables along with **GKE_PROJECT** value. +- The **GKE_CLUSTER_NAME** and **GKE_ZONE** can be added as environmental variables in the workflow. + +```yaml +trigger: +- main + +pool: + vmImage: 'ubuntu-latest' + +stages: +- stage: SetupGKE + jobs: + - job: SetupGCloudAndKubectl + steps: + - task: DownloadSecureFile@1 + name: gkeServiceAccount + inputs: + secureFile: 'gke-service-account-key.json' + - task: GoogleCloudSdkInstaller@0 + inputs: + version: 'latest' + - script: | + gcloud auth activate-service-account --key-file $(gkeServiceAccount.secureFilePath) + gcloud config set project $(GKE_PROJECT) + gcloud container clusters get-credentials $(GKE_CLUSTER_NAME) --zone $(GKE_ZONE) + displayName: 'Setup GKE' + +- stage: Test + dependsOn: SetupGKE + jobs: + - job: RunTestkube + steps: + - task: SetupTestkube@1 + inputs: + organization: '$(TK_ORG_ID)' + environment: '$(TK_ENV_ID)' + token: '$(TK_API_TOKEN)' + - script: | + testkube run test test-name -f + displayName: 'Run Testkube Test' +``` diff --git a/docs/docs/articles/cicd-overview.md b/docs/docs/articles/cicd-overview.md index 9d267067bf5..76dfa386579 100644 --- a/docs/docs/articles/cicd-overview.md +++ b/docs/docs/articles/cicd-overview.md @@ -11,6 +11,7 @@ We have different tutorials for the options of being CI driven or using GitOps a - [Jenkins Pipelines](./jenkins.md) - [Jenkins UI](./jenkins-ui.md) - [CircleCI](./circleci.md) +- [Azure DevOps](./azure.md) - [GitOps Testing](./gitops-overview.md) - [Flux](./flux-integration.md) - [ArgoCD](./argocd-integration.md) diff --git a/docs/docs/articles/jenkins-ui.md b/docs/docs/articles/jenkins-ui.md index 8dde9179bca..58c89c594d7 100644 --- a/docs/docs/articles/jenkins-ui.md +++ b/docs/docs/articles/jenkins-ui.md @@ -2,7 +2,7 @@ The Testkube Jenkins integration streamlines the installation of Testkube, enabling the execution of any [Testkube CLI](https://docs.testkube.io/cli/testkube) command within Jenkins Pipelines or Freestyle Projects. -If you are using Pipelines and Groovy scripts, then look at examples from [Testkube Jenkins Pipelines](./jenkins.md). +If you're looking to use Pipelines and Groovy scripts, then look at examples from [Testkube Jenkins Pipelines](./jenkins.md). ### Testkube CLI Jenkins Plugin From 006f787893f552b9b7a15cb8de09e654b8b56184 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Mon, 5 Feb 2024 17:23:38 +0100 Subject: [PATCH 065/234] feat: JMeter/JMeterd - executor tests and special cases extended (#4972) * JMeterd - incorrect url 2 * JMeter/JMeterd - executor tests, and special cases extended --- test/jmeter/executor-tests/crd/smoke.yaml | 34 +++ .../executor-tests/crd/special-cases.yaml | 227 ++++++++++++++++++ .../jmeter-executor-smoke-incorrect-url-2.jmx | 94 ++++++++ .../special-cases/jmeter-special-cases.yaml | 12 + 4 files changed, 367 insertions(+) create mode 100644 test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url-2.jmx diff --git a/test/jmeter/executor-tests/crd/smoke.yaml b/test/jmeter/executor-tests/crd/smoke.yaml index 525a2501328..f656063dd6f 100644 --- a/test/jmeter/executor-tests/crd/smoke.yaml +++ b/test/jmeter/executor-tests/crd/smoke.yaml @@ -200,3 +200,37 @@ spec: - "-JURL_PROPERTY=testkube.io" jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" activeDeadlineSeconds: 180 +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-env-and-property-values-sl-0 + labels: + core-tests: executors +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke-env-and-property.jmx + executionRequest: + variables: + SLAVES_COUNT: + name: SLAVES_COUNT + value: "0" + type: basic + URL_ENV: + name: URL_ENV + value: "testkube.io" + type: basic + ANOTHER_CUSTOM_ENV: + name: ANOTHER_CUSTOM_ENV + value: "SOME_CUSTOM_ENV" + type: basic + args: + - "-JURL_PROPERTY=testkube.io" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml index 8a9eacf13e4..19aa8c1762f 100644 --- a/test/jmeter/executor-tests/crd/special-cases.yaml +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -310,3 +310,230 @@ spec: limits: cpu: 500m memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-duplicated-args + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + args: + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke.jmx" + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke-2.jmx" + - "-o" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-duplicated-args-2 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + args: + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke.jmx" + - "-o" + - "/data/output/custom-report.jtl" + - "-e" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-args-override-l-e + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + argsMode: override + args: + - "-n" + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke.jmx" + - "-o" + - "/data/output/custom-report.jtl" + - "-l" + - "/data/output/report.jtl" + - "-e" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-incorrect-url-2 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: develop + path: test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url-2.jmx + executionRequest: + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeter-executor-smoke-incorrect-url-2 + labels: + core-tests: special-cases-jmeter +spec: + type: jmeter/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: develop + path: test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url-2.jmx + executionRequest: + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-args-override + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + argsMode: override + args: + - "-n" + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke.jmx" + - "-o" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-args-override-workingdir + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + workingDir: test/jmeter/executor-tests + executionRequest: + argsMode: override + args: + - "-n" + - "-t" + - "jmeter-executor-smoke.jmx" + - "-o" + - "/data/output/custom-report.jtl" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url-2.jmx b/test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url-2.jmx new file mode 100644 index 00000000000..13d2e00ec97 --- /dev/null +++ b/test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url-2.jmx @@ -0,0 +1,94 @@ + + + + + + false + false + + + + + + + + continue + + false + 1 + + 1 + 1 + 1668426657000 + 1668426657000 + false + + + + + + + + + testkube.kubeshop.io + + + + + + /some-incorrect-url + GET + true + false + true + false + false + + + + + + 200 + + Assertion.response_code + false + 8 + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + false + false + false + false + false + 0 + true + true + + + + + + + + + diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml index 1ae0bf4d2bd..8379437e8f3 100644 --- a/test/suites/special-cases/jmeter-special-cases.yaml +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -31,3 +31,15 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-incorrect-file-path-negative + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-incorrect-url-2 + - stopOnFailure: false + execute: + - test: jmeter-executor-smoke-incorrect-url-2 + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-args-override + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-args-override-workingdir From 1bc03814ee38277c2ddd91138d57c98e4931300e Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 6 Feb 2024 08:44:16 +0100 Subject: [PATCH 066/234] fix: fixing timeouts (#4971) * fix: fixing timeouts * fix: podStartTimeout increased --- pkg/logs/client/client.go | 7 ++++--- pkg/logs/sidecar/proxy.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/logs/client/client.go b/pkg/logs/client/client.go index 189c9ae79c6..2c0aeddd8a4 100644 --- a/pkg/logs/client/client.go +++ b/pkg/logs/client/client.go @@ -15,7 +15,8 @@ import ( ) const ( - buffer = 100 + buffer = 100 + requestDeadline = time.Minute * 5 ) // NewGrpcClient imlpements getter interface for log stream for given ID @@ -39,7 +40,7 @@ func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse log.Debugw("getting logs", "address", c.address) go func() { // Contact the server and print out its response. - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + ctx, cancel := context.WithTimeout(context.Background(), requestDeadline) defer cancel() defer close(ch) @@ -67,7 +68,7 @@ func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse log.Debugw("received log chunk from client", "log", l, "error", err) if err == io.EOF { log.Debugw("client stream finished", "error", err) - break + return } else if err != nil { log.Errorw("error receiving log response", "error", err) ch <- events.LogResponse{Error: err} diff --git a/pkg/logs/sidecar/proxy.go b/pkg/logs/sidecar/proxy.go index 351c705ad21..5360ecdc109 100644 --- a/pkg/logs/sidecar/proxy.go +++ b/pkg/logs/sidecar/proxy.go @@ -32,7 +32,7 @@ var ( const ( pollInterval = time.Second - podStartTimeout = time.Second * 60 + podStartTimeout = 30 * time.Minute logsBuffer = 1000 ) From 0a286e203c9c21d81fadc6e4d3456e4397eaf011 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 6 Feb 2024 09:17:11 +0100 Subject: [PATCH 067/234] feat: refactored for cloud logs service (#4973) * feat: refactored for cloud logs service * fix: commment * fix: renamed mapper * fix: panic when trying to stop second time --- pkg/logs/events.go | 2 - pkg/logs/events_test.go | 4 +- pkg/logs/pb/logs.pb.go | 238 ++++++++++++++++++++++++++++-------- pkg/logs/pb/logs.proto | 20 ++- pkg/logs/pb/logs_grpc.pb.go | 130 +++++++++++++++++++- pkg/logs/pb/mapper.go | 34 +++--- 6 files changed, 347 insertions(+), 81 deletions(-) diff --git a/pkg/logs/events.go b/pkg/logs/events.go index cc47db43c79..ca64a93730f 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -262,9 +262,7 @@ func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, con err := adapter.Stop(id) if err != nil { l.Errorw("stop error", "adapter", adapter.Name(), "error", err) - continue } - return } diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 3ee58009605..1b0d046df7d 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -90,7 +90,7 @@ func TestLogs_EventsFlow(t *testing.T) { assert.NoError(t, err) // cooldown stop time - time.Sleep(waitTime * 2) + time.Sleep(waitTime) // then all adapters should be gracefully stopped assert.Equal(t, 0, log.GetConsumersStats(ctx).Count) @@ -316,7 +316,7 @@ func TestLogs_EventsFlow(t *testing.T) { assert.Equal(t, "stop-queued", string(r.Message)) // there will be wait for mess - time.Sleep(waitTime * 2) + time.Sleep(waitTime) // then all adapters should be gracefully stopped assert.Equal(t, 0, log.GetConsumersStats(ctx).Count) diff --git a/pkg/logs/pb/logs.pb.go b/pkg/logs/pb/logs.pb.go index 9e5d019812d..fb748b876a8 100644 --- a/pkg/logs/pb/logs.pb.go +++ b/pkg/logs/pb/logs.pb.go @@ -21,6 +21,52 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type StreamResponseStatus int32 + +const ( + StreamResponseStatus_Completed StreamResponseStatus = 0 + StreamResponseStatus_Failed StreamResponseStatus = 1 +) + +// Enum value maps for StreamResponseStatus. +var ( + StreamResponseStatus_name = map[int32]string{ + 0: "Completed", + 1: "Failed", + } + StreamResponseStatus_value = map[string]int32{ + "Completed": 0, + "Failed": 1, + } +) + +func (x StreamResponseStatus) Enum() *StreamResponseStatus { + p := new(StreamResponseStatus) + *p = x + return p +} + +func (x StreamResponseStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StreamResponseStatus) Descriptor() protoreflect.EnumDescriptor { + return file_logs_proto_enumTypes[0].Descriptor() +} + +func (StreamResponseStatus) Type() protoreflect.EnumType { + return &file_logs_proto_enumTypes[0] +} + +func (x StreamResponseStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StreamResponseStatus.Descriptor instead. +func (StreamResponseStatus) EnumDescriptor() ([]byte, []int) { + return file_logs_proto_rawDescGZIP(), []int{0} +} + type LogRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -68,7 +114,7 @@ func (x *LogRequest) GetExecutionId() string { return "" } -type LogResponse struct { +type Log struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields @@ -82,8 +128,8 @@ type LogResponse struct { Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` } -func (x *LogResponse) Reset() { - *x = LogResponse{} +func (x *Log) Reset() { + *x = Log{} if protoimpl.UnsafeEnabled { mi := &file_logs_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -91,13 +137,13 @@ func (x *LogResponse) Reset() { } } -func (x *LogResponse) String() string { +func (x *Log) String() string { return protoimpl.X.MessageStringOf(x) } -func (*LogResponse) ProtoMessage() {} +func (*Log) ProtoMessage() {} -func (x *LogResponse) ProtoReflect() protoreflect.Message { +func (x *Log) ProtoReflect() protoreflect.Message { mi := &file_logs_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -109,60 +155,115 @@ func (x *LogResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use LogResponse.ProtoReflect.Descriptor instead. -func (*LogResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use Log.ProtoReflect.Descriptor instead. +func (*Log) Descriptor() ([]byte, []int) { return file_logs_proto_rawDescGZIP(), []int{1} } -func (x *LogResponse) GetTime() *timestamppb.Timestamp { +func (x *Log) GetTime() *timestamppb.Timestamp { if x != nil { return x.Time } return nil } -func (x *LogResponse) GetContent() string { +func (x *Log) GetContent() string { if x != nil { return x.Content } return "" } -func (x *LogResponse) GetError() bool { +func (x *Log) GetError() bool { if x != nil { return x.Error } return false } -func (x *LogResponse) GetType() string { +func (x *Log) GetType() string { if x != nil { return x.Type } return "" } -func (x *LogResponse) GetSource() string { +func (x *Log) GetSource() string { if x != nil { return x.Source } return "" } -func (x *LogResponse) GetVersion() string { +func (x *Log) GetVersion() string { if x != nil { return x.Version } return "" } -func (x *LogResponse) GetMetadata() map[string]string { +func (x *Log) GetMetadata() map[string]string { if x != nil { return x.Metadata } return nil } +type StreamResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Status StreamResponseStatus `protobuf:"varint,2,opt,name=status,proto3,enum=logs.StreamResponseStatus" json:"status,omitempty"` +} + +func (x *StreamResponse) Reset() { + *x = StreamResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_logs_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *StreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamResponse) ProtoMessage() {} + +func (x *StreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_logs_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamResponse.ProtoReflect.Descriptor instead. +func (*StreamResponse) Descriptor() ([]byte, []int) { + return file_logs_proto_rawDescGZIP(), []int{2} +} + +func (x *StreamResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *StreamResponse) GetStatus() StreamResponseStatus { + if x != nil { + return x.Status + } + return StreamResponseStatus_Completed +} + var File_logs_proto protoreflect.FileDescriptor var file_logs_proto_rawDesc = []byte{ @@ -172,31 +273,43 @@ var file_logs_proto_rawDesc = []byte{ 0x6f, 0x74, 0x6f, 0x22, 0x2f, 0x0a, 0x0a, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, - 0x6f, 0x6e, 0x49, 0x64, 0x22, 0xad, 0x02, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, - 0x74, 0x69, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x14, - 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6c, - 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9d, 0x02, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2e, 0x0a, 0x04, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x32, 0x3c, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x2d, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, - 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x11, 0x2e, - 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x30, 0x01, 0x42, 0x0d, 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, - 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x3a, 0x02, 0x38, 0x01, 0x22, 0x5e, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x2a, 0x31, 0x0a, 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, + 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x01, 0x32, 0x34, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, + 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, 0x32, 0x3f, 0x0a, + 0x10, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, + 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x1a, 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x42, 0x0d, + 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -211,23 +324,29 @@ func file_logs_proto_rawDescGZIP() []byte { return file_logs_proto_rawDescData } -var file_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_logs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_logs_proto_goTypes = []interface{}{ - (*LogRequest)(nil), // 0: logs.LogRequest - (*LogResponse)(nil), // 1: logs.LogResponse - nil, // 2: logs.LogResponse.MetadataEntry - (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp + (StreamResponseStatus)(0), // 0: logs.StreamResponseStatus + (*LogRequest)(nil), // 1: logs.LogRequest + (*Log)(nil), // 2: logs.Log + (*StreamResponse)(nil), // 3: logs.StreamResponse + nil, // 4: logs.Log.MetadataEntry + (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp } var file_logs_proto_depIdxs = []int32{ - 3, // 0: logs.LogResponse.time:type_name -> google.protobuf.Timestamp - 2, // 1: logs.LogResponse.metadata:type_name -> logs.LogResponse.MetadataEntry - 0, // 2: logs.LogsService.Logs:input_type -> logs.LogRequest - 1, // 3: logs.LogsService.Logs:output_type -> logs.LogResponse - 3, // [3:4] is the sub-list for method output_type - 2, // [2:3] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 5, // 0: logs.Log.time:type_name -> google.protobuf.Timestamp + 4, // 1: logs.Log.metadata:type_name -> logs.Log.MetadataEntry + 0, // 2: logs.StreamResponse.status:type_name -> logs.StreamResponseStatus + 1, // 3: logs.LogsService.Logs:input_type -> logs.LogRequest + 2, // 4: logs.CloudLogsService.Stream:input_type -> logs.Log + 2, // 5: logs.LogsService.Logs:output_type -> logs.Log + 3, // 6: logs.CloudLogsService.Stream:output_type -> logs.StreamResponse + 5, // [5:7] is the sub-list for method output_type + 3, // [3:5] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_logs_proto_init() } @@ -249,7 +368,19 @@ func file_logs_proto_init() { } } file_logs_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LogResponse); i { + switch v := v.(*Log); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_logs_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StreamResponse); i { case 0: return &v.state case 1: @@ -266,13 +397,14 @@ func file_logs_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_logs_proto_rawDesc, - NumEnums: 0, - NumMessages: 3, + NumEnums: 1, + NumMessages: 4, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_logs_proto_goTypes, DependencyIndexes: file_logs_proto_depIdxs, + EnumInfos: file_logs_proto_enumTypes, MessageInfos: file_logs_proto_msgTypes, }.Build() File_logs_proto = out.File diff --git a/pkg/logs/pb/logs.proto b/pkg/logs/pb/logs.proto index caf7c5602c8..bfeaad82f19 100644 --- a/pkg/logs/pb/logs.proto +++ b/pkg/logs/pb/logs.proto @@ -7,14 +7,14 @@ option go_package = "pkg/logs/pb"; import "google/protobuf/timestamp.proto"; service LogsService { - rpc Logs(LogRequest) returns (stream LogResponse); + rpc Logs(LogRequest) returns (stream Log); } message LogRequest { string execution_id = 2; } -message LogResponse{ +message Log{ google.protobuf.Timestamp time = 1; string content = 2; @@ -29,3 +29,19 @@ message LogResponse{ } + +// CloudLogsService client will be used in cloud adapter in logs server +// CloudLogsService server will be implemented on cloud side +service CloudLogsService { + rpc Stream(stream Log) returns (StreamResponse); +} + +message StreamResponse { + string message = 1; + StreamResponseStatus status = 2; +} + +enum StreamResponseStatus { + Completed = 0; + Failed = 1; +} \ No newline at end of file diff --git a/pkg/logs/pb/logs_grpc.pb.go b/pkg/logs/pb/logs_grpc.pb.go index 13613736fe2..62cf06e7b49 100644 --- a/pkg/logs/pb/logs_grpc.pb.go +++ b/pkg/logs/pb/logs_grpc.pb.go @@ -49,7 +49,7 @@ func (c *logsServiceClient) Logs(ctx context.Context, in *LogRequest, opts ...gr } type LogsService_LogsClient interface { - Recv() (*LogResponse, error) + Recv() (*Log, error) grpc.ClientStream } @@ -57,8 +57,8 @@ type logsServiceLogsClient struct { grpc.ClientStream } -func (x *logsServiceLogsClient) Recv() (*LogResponse, error) { - m := new(LogResponse) +func (x *logsServiceLogsClient) Recv() (*Log, error) { + m := new(Log) if err := x.ClientStream.RecvMsg(m); err != nil { return nil, err } @@ -102,7 +102,7 @@ func _LogsService_Logs_Handler(srv interface{}, stream grpc.ServerStream) error } type LogsService_LogsServer interface { - Send(*LogResponse) error + Send(*Log) error grpc.ServerStream } @@ -110,7 +110,7 @@ type logsServiceLogsServer struct { grpc.ServerStream } -func (x *logsServiceLogsServer) Send(m *LogResponse) error { +func (x *logsServiceLogsServer) Send(m *Log) error { return x.ServerStream.SendMsg(m) } @@ -130,3 +130,123 @@ var LogsService_ServiceDesc = grpc.ServiceDesc{ }, Metadata: "logs.proto", } + +// CloudLogsServiceClient is the client API for CloudLogsService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type CloudLogsServiceClient interface { + Stream(ctx context.Context, opts ...grpc.CallOption) (CloudLogsService_StreamClient, error) +} + +type cloudLogsServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewCloudLogsServiceClient(cc grpc.ClientConnInterface) CloudLogsServiceClient { + return &cloudLogsServiceClient{cc} +} + +func (c *cloudLogsServiceClient) Stream(ctx context.Context, opts ...grpc.CallOption) (CloudLogsService_StreamClient, error) { + stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[0], "/logs.CloudLogsService/Stream", opts...) + if err != nil { + return nil, err + } + x := &cloudLogsServiceStreamClient{stream} + return x, nil +} + +type CloudLogsService_StreamClient interface { + Send(*Log) error + CloseAndRecv() (*StreamResponse, error) + grpc.ClientStream +} + +type cloudLogsServiceStreamClient struct { + grpc.ClientStream +} + +func (x *cloudLogsServiceStreamClient) Send(m *Log) error { + return x.ClientStream.SendMsg(m) +} + +func (x *cloudLogsServiceStreamClient) CloseAndRecv() (*StreamResponse, error) { + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + m := new(StreamResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// CloudLogsServiceServer is the server API for CloudLogsService service. +// All implementations must embed UnimplementedCloudLogsServiceServer +// for forward compatibility +type CloudLogsServiceServer interface { + Stream(CloudLogsService_StreamServer) error + mustEmbedUnimplementedCloudLogsServiceServer() +} + +// UnimplementedCloudLogsServiceServer must be embedded to have forward compatible implementations. +type UnimplementedCloudLogsServiceServer struct { +} + +func (UnimplementedCloudLogsServiceServer) Stream(CloudLogsService_StreamServer) error { + return status.Errorf(codes.Unimplemented, "method Stream not implemented") +} +func (UnimplementedCloudLogsServiceServer) mustEmbedUnimplementedCloudLogsServiceServer() {} + +// UnsafeCloudLogsServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CloudLogsServiceServer will +// result in compilation errors. +type UnsafeCloudLogsServiceServer interface { + mustEmbedUnimplementedCloudLogsServiceServer() +} + +func RegisterCloudLogsServiceServer(s grpc.ServiceRegistrar, srv CloudLogsServiceServer) { + s.RegisterService(&CloudLogsService_ServiceDesc, srv) +} + +func _CloudLogsService_Stream_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(CloudLogsServiceServer).Stream(&cloudLogsServiceStreamServer{stream}) +} + +type CloudLogsService_StreamServer interface { + SendAndClose(*StreamResponse) error + Recv() (*Log, error) + grpc.ServerStream +} + +type cloudLogsServiceStreamServer struct { + grpc.ServerStream +} + +func (x *cloudLogsServiceStreamServer) SendAndClose(m *StreamResponse) error { + return x.ServerStream.SendMsg(m) +} + +func (x *cloudLogsServiceStreamServer) Recv() (*Log, error) { + m := new(Log) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// CloudLogsService_ServiceDesc is the grpc.ServiceDesc for CloudLogsService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CloudLogsService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "logs.CloudLogsService", + HandlerType: (*CloudLogsServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Stream", + Handler: _CloudLogsService_Stream_Handler, + ClientStreams: true, + }, + }, + Metadata: "logs.proto", +} diff --git a/pkg/logs/pb/mapper.go b/pkg/logs/pb/mapper.go index bf2e87748d6..734f0e139af 100644 --- a/pkg/logs/pb/mapper.go +++ b/pkg/logs/pb/mapper.go @@ -7,33 +7,33 @@ import ( ) // TODO figure out how to pass errors better -func MapResponseToPB(r events.LogResponse) *LogResponse { - chunk := r.Log - content := chunk.Content +func MapResponseToPB(r events.LogResponse) *Log { + log := r.Log + content := log.Content isError := false if r.Error != nil { content = r.Error.Error() isError = true } - return &LogResponse{ - Time: timestamppb.New(chunk.Time), + return &Log{ + Time: timestamppb.New(log.Time), Content: content, Error: isError, - Type: chunk.Type, - Source: chunk.Source, - Metadata: chunk.Metadata, - Version: string(chunk.Version), + Type: log.Type, + Source: log.Source, + Metadata: log.Metadata, + Version: string(log.Version), } } -func MapFromPB(chunk *LogResponse) events.Log { +func MapFromPB(log *Log) events.Log { return events.Log{ - Time: chunk.Time.AsTime(), - Content: chunk.Content, - Error: chunk.Error, - Type: chunk.Type, - Source: chunk.Source, - Metadata: chunk.Metadata, - Version: events.LogVersion(chunk.Version), + Time: log.Time.AsTime(), + Content: log.Content, + Error: log.Error, + Type: log.Type, + Source: log.Source, + Metadata: log.Metadata, + Version: events.LogVersion(log.Version), } } From 57ec5a461ff5a16a18e292303178b68f9eac775b Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 6 Feb 2024 13:56:10 +0100 Subject: [PATCH 068/234] feat: cloud adapter (#4975) * feat: added cloud adapter * fix: tests for cloud adapter * fix: tests for cloud adapter * fix: test server concurrent write/read error * fix: assertion * fix: data races in emitter * fix: data races in emitter --- cmd/logs/main.go | 52 ++-- internal/config/config.go | 1 + pkg/event/emitter.go | 16 +- pkg/event/emitter_test.go | 4 +- pkg/logs/adapter/cloud.go | 76 +++++- pkg/logs/adapter/cloud_test.go | 319 ++++++++++++++++++++++++ pkg/logs/adapter/{dummy.go => debug.go} | 11 +- pkg/logs/adapter/interface.go | 12 +- pkg/logs/adapter/minio.go | 9 +- pkg/logs/adapter/minio_test.go | 15 +- pkg/logs/adapter/s3.go | 26 -- pkg/logs/config/logs_config.go | 59 +++-- pkg/logs/events.go | 15 +- pkg/logs/events_test.go | 8 +- pkg/logs/pb/mapper.go | 24 +- 15 files changed, 537 insertions(+), 110 deletions(-) create mode 100644 pkg/logs/adapter/cloud_test.go rename pkg/logs/adapter/{dummy.go => debug.go} (66%) delete mode 100644 pkg/logs/adapter/s3.go diff --git a/cmd/logs/main.go b/cmd/logs/main.go index bba5a9f32bd..aa9643fd50e 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -10,12 +10,16 @@ import ( "go.uber.org/zap" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/logs" "github.com/kubeshop/testkube/pkg/logs/adapter" "github.com/kubeshop/testkube/pkg/logs/config" + "github.com/kubeshop/testkube/pkg/logs/pb" "github.com/kubeshop/testkube/pkg/logs/state" + "github.com/kubeshop/testkube/pkg/ui" "github.com/nats-io/nats.go/jetstream" "github.com/oklog/run" @@ -30,6 +34,11 @@ func main() { cfg := Must(config.Get()) + mode := common.ModeStandalone + if cfg.TestkubeProAPIKey != "" { + mode = common.ModeAgent + } + // Event bus nc := Must(bus.NewNATSConnection(bus.ConnectionConfig{ NatsURI: cfg.NatsURI, @@ -54,26 +63,37 @@ func main() { WithHttpAddress(cfg.HttpAddress). WithGrpcAddress(cfg.GrpcAddress) - // TODO - add adapters here - minioAdapter, err := adapter.NewMinioAdapter(cfg.StorageEndpoint, - cfg.StorageAccessKeyID, - cfg.StorageSecretAccessKey, - cfg.StorageRegion, - cfg.StorageToken, - cfg.StorageLogsBucket, - cfg.StorageSSL, - cfg.StorageSkipVerify, - cfg.StorageCertFile, - cfg.StorageKeyFile, - cfg.StorageCAFile) - if cfg.Debug { svc.AddAdapter(adapter.NewDebugAdapter()) } - if err != nil { - log.Errorw("error creating minio adapter, debug adapter created instead", "error", err) - } else { + // add given log adapter depends from mode + switch mode { + + case common.ModeAgent: + grpcConn, err := agent.NewGRPCConnection(ctx, cfg.TestkubeProTLSInsecure, cfg.TestkubeProSkipVerify, cfg.TestkubeProURL+cfg.TestkubeProLogsPath, log) + ui.ExitOnError("error creating gRPC connection for logs service", err) + defer grpcConn.Close() + grpcClient := pb.NewCloudLogsServiceClient(grpcConn) + cloudAdapter := adapter.NewCloudAdapter(grpcClient, cfg.TestkubeProAPIKey) + svc.AddAdapter(cloudAdapter) + + case common.ModeStandalone: + minioAdapter, err := adapter.NewMinioAdapter(cfg.StorageEndpoint, + cfg.StorageAccessKeyID, + cfg.StorageSecretAccessKey, + cfg.StorageRegion, + cfg.StorageToken, + cfg.StorageLogsBucket, + cfg.StorageSSL, + cfg.StorageSkipVerify, + cfg.StorageCertFile, + cfg.StorageKeyFile, + cfg.StorageCAFile) + + if err != nil { + log.Errorw("error creating minio adapter", "error", err) + } log.Infow("minio adapter created", "bucket", cfg.StorageLogsBucket, "endpoint", cfg.StorageEndpoint) svc.AddAdapter(minioAdapter) } diff --git a/internal/config/config.go b/internal/config/config.go index 910796c3755..f22dc424b6e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,6 +64,7 @@ type Config struct { TestkubeOAuthScopes string `envconfig:"TESTKUBE_OAUTH_SCOPES" default:""` TestkubeProAPIKey string `envconfig:"TESTKUBE_PRO_API_KEY" default:""` TestkubeProURL string `envconfig:"TESTKUBE_PRO_URL" default:""` + TestkubeProLogsPath string `envconfig:"TESTKUBE_PRO_LOGS_PATH" default:"/logs"` TestkubeProTLSInsecure bool `envconfig:"TESTKUBE_PRO_TLS_INSECURE" default:"false"` TestkubeProWorkerCount int `envconfig:"TESTKUBE_PRO_WORKER_COUNT" default:"50"` TestkubeProLogStreamWorkerCount int `envconfig:"TESTKUBE_PRO_LOG_STREAM_WORKER_COUNT" default:"25"` diff --git a/pkg/event/emitter.go b/pkg/event/emitter.go index f14ec650491..3a49a04b612 100644 --- a/pkg/event/emitter.go +++ b/pkg/event/emitter.go @@ -38,7 +38,7 @@ type Emitter struct { Listeners common.Listeners Loader *Loader Log *zap.SugaredLogger - mutex sync.Mutex + mutex sync.RWMutex Bus bus.Bus ClusterName string Envs map[string]string @@ -195,8 +195,20 @@ func (e *Emitter) Reconcile(ctx context.Context) { default: listeners := e.Loader.Reconcile() e.UpdateListeners(listeners) - e.Log.Debugw("reconciled listeners", e.Listeners.Log()...) + e.Log.Debugw("reconciled listeners", e.Logs()...) time.Sleep(reconcileInterval) } } } + +func (e *Emitter) Logs() []any { + e.mutex.Lock() + defer e.mutex.Unlock() + return e.Listeners.Log() +} + +func (e *Emitter) GetListeners() common.Listeners { + e.mutex.RLock() + defer e.mutex.RUnlock() + return e.Listeners +} diff --git a/pkg/event/emitter_test.go b/pkg/event/emitter_test.go index 52e802dc83a..52198f5f2a9 100644 --- a/pkg/event/emitter_test.go +++ b/pkg/event/emitter_test.go @@ -140,7 +140,7 @@ func TestEmitter_Reconcile(t *testing.T) { go emitter.Reconcile(ctx) time.Sleep(100 * time.Millisecond) - assert.Len(t, emitter.Listeners, 4) + assert.Len(t, emitter.GetListeners(), 4) cancel() @@ -155,7 +155,7 @@ func TestEmitter_Reconcile(t *testing.T) { // then each reconciler (3 reconcilers) should load 2 listeners time.Sleep(100 * time.Millisecond) - assert.Len(t, emitter.Listeners, 6) + assert.Len(t, emitter.GetListeners(), 6) cancel() }) diff --git a/pkg/logs/adapter/cloud.go b/pkg/logs/adapter/cloud.go index 1c2075a24b5..1e89bccaa32 100644 --- a/pkg/logs/adapter/cloud.go +++ b/pkg/logs/adapter/cloud.go @@ -1,26 +1,86 @@ package adapter -import "github.com/kubeshop/testkube/pkg/logs/events" +import ( + "context" + "sync" + + "github.com/pkg/errors" + "go.uber.org/zap" + "google.golang.org/grpc/metadata" + + "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/logs/events" + "github.com/kubeshop/testkube/pkg/logs/pb" +) var _ Adapter = &CloudAdapter{} // NewCloudConsumer creates new CloudSubscriber which will send data to local MinIO bucket -func NewCloudConsumer() *CloudAdapter { - return &CloudAdapter{} +func NewCloudAdapter(grpcConn pb.CloudLogsServiceClient, agentApiKey string) *CloudAdapter { + return &CloudAdapter{ + client: grpcConn, + agentApiKey: agentApiKey, + logger: log.DefaultLogger.With("service", "logs-cloud-adapter"), + } } type CloudAdapter struct { - Bucket string + client pb.CloudLogsServiceClient + streams sync.Map + agentApiKey string + logger *zap.SugaredLogger +} + +func (s *CloudAdapter) Init(ctx context.Context, id string) error { + + // write metadata to the stream context + md := metadata.Pairs("api-key", s.agentApiKey, "execution-id", id) + ctx = metadata.NewOutgoingContext(ctx, md) + + stream, err := s.client.Stream(ctx) + if err != nil { + return errors.Wrap(err, "can't init stream") + } + + s.streams.Store(id, stream) + + return nil } -func (s *CloudAdapter) Notify(id string, e events.Log) error { - panic("not implemented") +func (s *CloudAdapter) Notify(ctx context.Context, id string, e events.Log) error { + c, err := s.getStreamClient(id) + if err != nil { + return errors.Wrap(err, "can't get stream client for id: "+id) + } + + return c.Send(pb.MapToPB(e)) } -func (s *CloudAdapter) Stop(id string) error { - panic("not implemented") +func (s *CloudAdapter) Stop(ctx context.Context, id string) error { + c, err := s.getStreamClient(id) + if err != nil { + return errors.Wrap(err, "can't get stream client for id: "+id) + } + + resp, err := c.CloseAndRecv() + if err != nil { + return errors.Wrap(err, "closing log stream error") + } + s.logger.Debugw("closing response", "resp", resp, "id", id) + + s.streams.Delete(id) + return nil } func (s *CloudAdapter) Name() string { return "cloud" } + +func (s *CloudAdapter) getStreamClient(id string) (client pb.CloudLogsService_StreamClient, err error) { + c, ok := s.streams.Load(id) + if !ok { + return nil, errors.New("can't find initialized stream") + } + + return c.(pb.CloudLogsService_StreamClient), nil +} diff --git a/pkg/logs/adapter/cloud_test.go b/pkg/logs/adapter/cloud_test.go new file mode 100644 index 00000000000..fb639323997 --- /dev/null +++ b/pkg/logs/adapter/cloud_test.go @@ -0,0 +1,319 @@ +package adapter + +import ( + "context" + "fmt" + "io" + "math" + "math/rand" + "net" + "sync" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/kubeshop/testkube/pkg/agent" + "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/logs/events" + "github.com/kubeshop/testkube/pkg/logs/pb" +) + +func TestCloudAdapter(t *testing.T) { + + t.Run("GRPC server receives log data", func(t *testing.T) { + // given grpc test server + ts := NewTestServer().WithRandomPort() + go ts.Run() + + ctx := context.Background() + id := "id1" + + // and connection + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + assert.NoError(t, err) + defer grpcConn.Close() + + // and log stream client + grpcClient := pb.NewCloudLogsServiceClient(grpcConn) + a := NewCloudAdapter(grpcClient, "APIKEY") + + // when stream is initialized + err = a.Init(ctx, id) + assert.NoError(t, err) + // and data is sent to it + err = a.Notify(ctx, id, *events.NewLog("log1")) + assert.NoError(t, err) + err = a.Notify(ctx, id, *events.NewLog("log2")) + assert.NoError(t, err) + err = a.Notify(ctx, id, *events.NewLog("log3")) + assert.NoError(t, err) + err = a.Notify(ctx, id, *events.NewLog("log4")) + assert.NoError(t, err) + // and stream is stopped after sending logs to it + err = a.Stop(ctx, id) + assert.NoError(t, err) + + // cooldown + time.Sleep(time.Millisecond * 100) + + // then all messahes should be delivered to the GRPC server + ts.AssertMessagesProcessed(t, id, 4) + }) + + t.Run("cleaning GRPC connections in adapter on Stop", func(t *testing.T) { + // given new test server + ts := NewTestServer().WithRandomPort() + go ts.Run() + + ctx := context.Background() + id1 := "id1" + id2 := "id2" + id3 := "id3" + + // and connection + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + assert.NoError(t, err) + defer grpcConn.Close() + grpcClient := pb.NewCloudLogsServiceClient(grpcConn) + a := NewCloudAdapter(grpcClient, "APIKEY") + + // when 3 streams are initialized, data is sent, and then stopped + err = a.Init(ctx, id1) + assert.NoError(t, err) + err = a.Notify(ctx, id1, *events.NewLog("log1")) + assert.NoError(t, err) + err = a.Stop(ctx, id1) + assert.NoError(t, err) + + err = a.Init(ctx, id2) + assert.NoError(t, err) + err = a.Notify(ctx, id2, *events.NewLog("log2")) + assert.NoError(t, err) + err = a.Stop(ctx, id2) + assert.NoError(t, err) + + err = a.Init(ctx, id3) + assert.NoError(t, err) + err = a.Notify(ctx, id3, *events.NewLog("log3")) + assert.NoError(t, err) + err = a.Stop(ctx, id3) + assert.NoError(t, err) + + // cooldown + time.Sleep(time.Millisecond * 100) + + // then messages should be delivered + ts.AssertMessagesProcessed(t, id1, 1) + ts.AssertMessagesProcessed(t, id2, 1) + ts.AssertMessagesProcessed(t, id3, 1) + + // and no stream are registered anymore in cloud adapter + assertNoStreams(t, a) + }) + + t.Run("Send and receive a lot of messages", func(t *testing.T) { + // given test server + ts := NewTestServer().WithRandomPort() + go ts.Run() + + ctx := context.Background() + id := "id1M" + + // and grpc connetion to the server + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + assert.NoError(t, err) + defer grpcConn.Close() + + // and logs stream client + grpcClient := pb.NewCloudLogsServiceClient(grpcConn) + a := NewCloudAdapter(grpcClient, "APIKEY") + + // when streams are initialized + err = a.Init(ctx, id) + assert.NoError(t, err) + + messageCount := 10_000 + for i := 0; i < messageCount; i++ { + // and data is sent + err = a.Notify(ctx, id, *events.NewLog("log1")) + assert.NoError(t, err) + } + + // cooldown + time.Sleep(time.Millisecond * 100) + + // then messages should be delivered to GRPC server + ts.AssertMessagesProcessed(t, id, messageCount) + }) + + t.Run("Send to a lot of streams in parallel", func(t *testing.T) { + // given test server + ts := NewTestServer().WithRandomPort() + go ts.Run() + + ctx := context.Background() + + // and grpc connetion to the server + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + assert.NoError(t, err) + defer grpcConn.Close() + + // and logs stream client + grpcClient := pb.NewCloudLogsServiceClient(grpcConn) + a := NewCloudAdapter(grpcClient, "APIKEY") + + streamsCount := 100 + messageCount := 1_000 + + // when streams are initialized + var wg sync.WaitGroup + wg.Add(streamsCount) + for j := 0; j < streamsCount; j++ { + err = a.Init(ctx, fmt.Sprintf("id%d", j)) + assert.NoError(t, err) + + go func(j int) { + defer wg.Done() + for i := 0; i < messageCount; i++ { + // and when data are sent + err = a.Notify(ctx, fmt.Sprintf("id%d", j), *events.NewLog("log1")) + assert.NoError(t, err) + } + }(j) + } + + wg.Wait() + + // and wait for cooldown + time.Sleep(time.Millisecond * 100) + + // then each stream should receive valid data amount + for j := 0; j < streamsCount; j++ { + ts.AssertMessagesProcessed(t, fmt.Sprintf("id%d", j), messageCount) + } + }) + +} + +func assertNoStreams(t *testing.T, a *CloudAdapter) { + t.Helper() + // no stream are registered anymore + count := 0 + a.streams.Range(func(key, value any) bool { + count++ + return true + }) + assert.Equal(t, count, 0) +} + +// Cloud Logs server mock +func NewTestServer() *TestServer { + return &TestServer{ + Received: make(map[string][]*pb.Log), + } +} + +type TestServer struct { + Url string + pb.UnimplementedCloudLogsServiceServer + Received map[string][]*pb.Log + lock sync.Mutex +} + +func getVal(ctx context.Context, key string) (string, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "", status.Error(codes.Unauthenticated, "api-key header is missing") + } + apiKeyMeta := md.Get(key) + if len(apiKeyMeta) != 1 { + return "", status.Error(codes.Unauthenticated, "api-key header is empty") + } + if apiKeyMeta[0] == "" { + return "", status.Error(codes.Unauthenticated, "api-key header value is empty") + } + + return apiKeyMeta[0], nil +} + +func (s *TestServer) Stream(stream pb.CloudLogsService_StreamServer) error { + ctx := stream.Context() + v, err := getVal(ctx, "execution-id") + if err != nil { + return err + } + id := v + + s.lock.Lock() + s.Received[id] = []*pb.Log{} + s.lock.Unlock() + + for { + in, err := stream.Recv() + if err != nil { + if err == io.EOF { + err := stream.SendAndClose(&pb.StreamResponse{Message: "completed"}) + if err != nil { + return status.Error(codes.Internal, "can't close stream: "+err.Error()) + } + return nil + } + return status.Error(codes.Internal, "can't receive stream: "+err.Error()) + } + + s.lock.Lock() + s.Received[id] = append(s.Received[id], in) + s.lock.Unlock() + } +} + +func (s *TestServer) WithRandomPort() *TestServer { + port := rand.Intn(1000) + 18000 + s.Url = fmt.Sprintf("127.0.0.1:%d", port) + return s +} + +func (s *TestServer) Run() (err error) { + lis, err := net.Listen("tcp", s.Url) + if err != nil { + return errors.Errorf("net listen: %v", err) + } + + var opts []grpc.ServerOption + creds := insecure.NewCredentials() + opts = append(opts, grpc.Creds(creds), grpc.MaxRecvMsgSize(math.MaxInt32)) + + // register server logs + srv := grpc.NewServer(opts...) + srv.RegisterService(&pb.CloudLogsService_ServiceDesc, s) + srv.Serve(lis) + + if err != nil { + return errors.Wrap(err, "grpc server error") + } + return nil +} + +func (s *TestServer) AssertMessagesProcessed(t *testing.T, id string, messageCount int) { + var received int + + for i := 0; i < 100; i++ { + s.lock.Lock() + received = len(s.Received[id]) + s.lock.Unlock() + + if received == messageCount { + return + } + time.Sleep(time.Millisecond * 10) + } + + assert.Equal(t, messageCount, received) +} diff --git a/pkg/logs/adapter/dummy.go b/pkg/logs/adapter/debug.go similarity index 66% rename from pkg/logs/adapter/dummy.go rename to pkg/logs/adapter/debug.go index 4366740092a..cd2f27e7205 100644 --- a/pkg/logs/adapter/dummy.go +++ b/pkg/logs/adapter/debug.go @@ -1,6 +1,8 @@ package adapter import ( + "context" + "go.uber.org/zap" "github.com/kubeshop/testkube/pkg/log" @@ -20,12 +22,17 @@ type DebugAdapter struct { l *zap.SugaredLogger } -func (s *DebugAdapter) Notify(id string, e events.Log) error { +func (s *DebugAdapter) Init(ctx context.Context, id string) error { + s.l.Debugw("Initializing", "id", id) + return nil +} + +func (s *DebugAdapter) Notify(ctx context.Context, id string, e events.Log) error { s.l.Debugw("got event", "id", id, "event", e) return nil } -func (s *DebugAdapter) Stop(id string) error { +func (s *DebugAdapter) Stop(ctx context.Context, id string) error { s.l.Debugw("Stopping", "id", id) return nil } diff --git a/pkg/logs/adapter/interface.go b/pkg/logs/adapter/interface.go index 2081d3b9e78..accf5989aff 100644 --- a/pkg/logs/adapter/interface.go +++ b/pkg/logs/adapter/interface.go @@ -1,13 +1,19 @@ package adapter -import "github.com/kubeshop/testkube/pkg/logs/events" +import ( + "context" + + "github.com/kubeshop/testkube/pkg/logs/events" +) // Adapter will listen to log chunks, and handle them based on log id (execution Id) type Adapter interface { + // Init will init for given id + Init(ctx context.Context, id string) error // Notify will send data log events for particaular execution id - Notify(id string, event events.Log) error + Notify(ctx context.Context, id string, event events.Log) error // Stop will stop listening subscriber from sending data - Stop(id string) error + Stop(ctx context.Context, id string) error // Name subscriber name Name() string } diff --git a/pkg/logs/adapter/minio.go b/pkg/logs/adapter/minio.go index 77f7c9715d8..d8b13302ab6 100644 --- a/pkg/logs/adapter/minio.go +++ b/pkg/logs/adapter/minio.go @@ -98,7 +98,11 @@ type MinioAdapter struct { mapLock sync.RWMutex } -func (s *MinioAdapter) Notify(id string, e events.Log) error { +func (s *MinioAdapter) Init(ctx context.Context, id string) error { + return nil +} + +func (s *MinioAdapter) Notify(ctx context.Context, id string, e events.Log) error { s.Log.Debugw("minio consumer notify", "id", id, "event", e) if s.disconnected { s.Log.Debugw("minio consumer disconnected", "id", id) @@ -206,9 +210,8 @@ func (s *MinioAdapter) objectExists(objectName string) bool { return err == nil } -func (s *MinioAdapter) Stop(id string) error { +func (s *MinioAdapter) Stop(ctx context.Context, id string) error { s.Log.Debugw("minio consumer stop", "id", id) - ctx := context.TODO() buffInfo, ok := s.GetBuffInfo(id) if !ok { return ErrIdNotFound{id} diff --git a/pkg/logs/adapter/minio_test.go b/pkg/logs/adapter/minio_test.go index 4c98e7ab1a5..515f8015acd 100644 --- a/pkg/logs/adapter/minio_test.go +++ b/pkg/logs/adapter/minio_test.go @@ -38,33 +38,35 @@ func RandString(n int) string { func TestLogs(t *testing.T) { t.Skip("skipping test") + ctx := context.Background() consumer, _ := NewMinioAdapter("localhost:9000", "minio", "minio123", "", "", "test-1", false, false, "", "", "") id := "test-bla" for i := 0; i < 1000; i++ { fmt.Println("sending", i) - consumer.Notify(id, events.Log{Time: time.Now(), + consumer.Notify(ctx, id, events.Log{Time: time.Now(), Content: fmt.Sprintf("Test %d: %s", i, hugeString), Type: "test", Source: strconv.Itoa(i)}) time.Sleep(100 * time.Millisecond) } - err := consumer.Stop(id) + err := consumer.Stop(ctx, id) assert.NoError(t, err) } func BenchmarkLogs(b *testing.B) { + ctx := context.Background() randomString := RandString(5) bucket := "test-bench" consumer, _ := NewMinioAdapter("localhost:9000", "minio", "minio123", "", "", bucket, false, false, "", "", "") id := "test-bench" + "-" + randomString + "-" + strconv.Itoa(b.N) totalSize := 0 for i := 0; i < b.N; i++ { - consumer.Notify(id, events.Log{Time: time.Now(), + consumer.Notify(ctx, id, events.Log{Time: time.Now(), Content: fmt.Sprintf("Test %d: %s", i, hugeString), Type: "test", Source: strconv.Itoa(i)}) totalSize += len(hugeString) } sizeInMB := float64(totalSize) / 1024 / 1024 - err := consumer.Stop(id) + err := consumer.Stop(ctx, id) assert.NoError(b, err) b.Logf("Total size for %s logs is %f MB", id, sizeInMB) } @@ -90,18 +92,19 @@ func BenchmarkLogs2(b *testing.B) { } func testOneConsumer(consumer *MinioAdapter, id string) { + ctx := context.Background() fmt.Println("#####starting", id) totalSize := 0 numberOFLogs := rand.Intn(100000) for i := 0; i < numberOFLogs; i++ { - consumer.Notify(id, events.Log{Time: time.Now(), + consumer.Notify(ctx, id, events.Log{Time: time.Now(), Content: fmt.Sprintf("Test %d: %s", i, hugeString), Type: "test", Source: strconv.Itoa(i)}) totalSize += len(hugeString) time.Sleep(time.Duration(rand.Intn(10)) * time.Millisecond) } sizeInMB := float64(totalSize) / 1024 / 1024 - err := consumer.Stop(id) + err := consumer.Stop(ctx, id) if err != nil { fmt.Println("#####error stopping", err) } diff --git a/pkg/logs/adapter/s3.go b/pkg/logs/adapter/s3.go deleted file mode 100644 index 33ba08dea19..00000000000 --- a/pkg/logs/adapter/s3.go +++ /dev/null @@ -1,26 +0,0 @@ -package adapter - -import "github.com/kubeshop/testkube/pkg/logs/events" - -var _ Adapter = &S3Adapter{} - -// NewS3Adapter creates new S3Subscriber which will send data to local MinIO bucket -func NewS3Adapter() *S3Adapter { - return &S3Adapter{} -} - -type S3Adapter struct { - Bucket string -} - -func (s *S3Adapter) Notify(id string, e events.Log) error { - panic("not implemented") -} - -func (s *S3Adapter) Stop(id string) error { - panic("not implemented") -} - -func (s *S3Adapter) Name() string { - return "s3" -} diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index 51fe916a5a5..2e8e52b33b6 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -7,30 +7,41 @@ import ( ) type Config struct { - Debug bool `envconfig:"DEBUG" default:"false"` - NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` - NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` - NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` - NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` - NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` - NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` - NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` - Namespace string `envconfig:"NAMESPACE" default:"testkube"` - ExecutionId string `envconfig:"ID" default:""` - HttpAddress string `envconfig:"HTTP_ADDRESS" default:":8080"` - GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` - KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` - StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"testkube-minio-service-testkube:9000"` - StorageLogsBucket string `envconfig:"STORAGE_LOGS_BUCKET" default:"testkube-new-logs"` - StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:"minio"` - StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:"minio123"` - StorageRegion string `envconfig:"STORAGE_REGION" default:""` - StorageToken string `envconfig:"STORAGE_TOKEN" default:""` - StorageSSL bool `envconfig:"STORAGE_SSL" default:"false"` - StorageSkipVerify bool `envconfig:"STORAGE_SKIP_VERIFY" default:"false"` - StorageCertFile string `envconfig:"STORAGE_CERT_FILE" default:""` - StorageKeyFile string `envconfig:"STORAGE_KEY_FILE" default:""` - StorageCAFile string `envconfig:"STORAGE_CA_FILE" default:""` + Debug bool `envconfig:"DEBUG" default:"false"` + + TestkubeProAPIKey string `envconfig:"TESTKUBE_PRO_API_KEY" default:""` + TestkubeProURL string `envconfig:"TESTKUBE_PRO_URL" default:""` + TestkubeProLogsPath string `envconfig:"TESTKUBE_PRO_LOGS_PATH" default:"/logs"` + TestkubeProTLSInsecure bool `envconfig:"TESTKUBE_PRO_TLS_INSECURE" default:"false"` + TestkubeProWorkerCount int `envconfig:"TESTKUBE_PRO_WORKER_COUNT" default:"50"` + TestkubeProLogStreamWorkerCount int `envconfig:"TESTKUBE_PRO_LOG_STREAM_WORKER_COUNT" default:"25"` + TestkubeProSkipVerify bool `envconfig:"TESTKUBE_PRO_SKIP_VERIFY" default:"false"` + + NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` + NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` + NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` + NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` + NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` + NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` + NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` + + Namespace string `envconfig:"NAMESPACE" default:"testkube"` + ExecutionId string `envconfig:"ID" default:""` + HttpAddress string `envconfig:"HTTP_ADDRESS" default:":8080"` + GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` + KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` + + StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"testkube-minio-service-testkube:9000"` + StorageLogsBucket string `envconfig:"STORAGE_LOGS_BUCKET" default:"testkube-new-logs"` + StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:"minio"` + StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:"minio123"` + StorageRegion string `envconfig:"STORAGE_REGION" default:""` + StorageToken string `envconfig:"STORAGE_TOKEN" default:""` + StorageSSL bool `envconfig:"STORAGE_SSL" default:"false"` + StorageSkipVerify bool `envconfig:"STORAGE_SKIP_VERIFY" default:"false"` + StorageCertFile string `envconfig:"STORAGE_CERT_FILE" default:""` + StorageKeyFile string `envconfig:"STORAGE_KEY_FILE" default:""` + StorageCAFile string `envconfig:"STORAGE_CA_FILE" default:""` } func Get() (*Config, error) { diff --git a/pkg/logs/events.go b/pkg/logs/events.go index ca64a93730f..4aff434700a 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -9,6 +9,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "github.com/pkg/errors" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/logs/adapter" @@ -52,6 +53,12 @@ type Consumer struct { func (ls *LogsService) initConsumer(ctx context.Context, a adapter.Adapter, streamName, id string, i int) (jetstream.Consumer, error) { name := fmt.Sprintf("lc%s%s%d", id, a.Name(), i) + + err := a.Init(ctx, id) + if err != nil { + return nil, errors.Wrap(err, "can't init adapter") + } + return ls.js.CreateOrUpdateConsumer(ctx, streamName, jetstream.ConsumerConfig{ Name: name, // Durable: name, @@ -70,7 +77,7 @@ func (ls *LogsService) createStream(ctx context.Context, id string) (jetstream.S } // handleMessage will handle incoming message from logs stream and proxy it to given adapter -func (ls *LogsService) handleMessage(a adapter.Adapter, id string) func(msg jetstream.Msg) { +func (ls *LogsService) handleMessage(ctx context.Context, a adapter.Adapter, id string) func(msg jetstream.Msg) { log := ls.log.With("id", id, "adapter", a.Name()) return func(msg jetstream.Msg) { @@ -87,7 +94,7 @@ func (ls *LogsService) handleMessage(a adapter.Adapter, id string) func(msg jets return } - err = a.Notify(id, logChunk) + err = a.Notify(ctx, id, logChunk) if err != nil { if err := msg.Nak(); err != nil { log.Errorw("error nacking message", "error", err) @@ -136,7 +143,7 @@ func (ls *LogsService) handleStart(ctx context.Context, subject string) func(msg // handle message per each adapter l.Infow("consumer created", "consumer", c.CachedInfo(), "stream", streamName) - cons, err := c.Consume(ls.handleMessage(adapter, id)) + cons, err := c.Consume(ls.handleMessage(ctx, adapter, id)) if err != nil { log.Errorw("error creating consumer", "error", err, "consumer", c.CachedInfo()) continue @@ -259,7 +266,7 @@ func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, con l.Infow("stopping and removing consumer", "name", consumer.Name, "consumerSeq", info.Delivered.Consumer, "streamSeq", info.Delivered.Stream, "last", info.Delivered.Last) // call adapter stop to handle given id - err := adapter.Stop(id) + err := adapter.Stop(ctx, id) if err != nil { l.Errorw("stop error", "adapter", adapter.Name(), "error", err) } diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 1b0d046df7d..13630a7ca05 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -346,7 +346,11 @@ type MockAdapter struct { name string } -func (s *MockAdapter) Notify(id string, e events.Log) error { +func (s *MockAdapter) Init(ctx context.Context, id string) error { + return nil +} + +func (s *MockAdapter) Notify(ctx context.Context, id string, e events.Log) error { s.lock.Lock() defer s.lock.Unlock() @@ -355,7 +359,7 @@ func (s *MockAdapter) Notify(id string, e events.Log) error { return nil } -func (s *MockAdapter) Stop(id string) error { +func (s *MockAdapter) Stop(ctx context.Context, id string) error { fmt.Printf("stopping %s \n", id) return nil } diff --git a/pkg/logs/pb/mapper.go b/pkg/logs/pb/mapper.go index 734f0e139af..32812992ae6 100644 --- a/pkg/logs/pb/mapper.go +++ b/pkg/logs/pb/mapper.go @@ -6,23 +6,23 @@ import ( "github.com/kubeshop/testkube/pkg/logs/events" ) -// TODO figure out how to pass errors better func MapResponseToPB(r events.LogResponse) *Log { log := r.Log - content := log.Content - isError := false if r.Error != nil { - content = r.Error.Error() - isError = true + log.Content = r.Error.Error() } + return MapToPB(log) +} + +func MapToPB(r events.Log) *Log { return &Log{ - Time: timestamppb.New(log.Time), - Content: content, - Error: isError, - Type: log.Type, - Source: log.Source, - Metadata: log.Metadata, - Version: string(log.Version), + Time: timestamppb.New(r.Time), + Content: r.Content, + Error: r.Error, + Type: r.Type, + Source: r.Source, + Metadata: r.Metadata, + Version: string(r.Version), } } From 3816e7312f37e075704be99d5a942910c7c0549c Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 7 Feb 2024 01:07:58 +0300 Subject: [PATCH 069/234] feat: logv2 api (#4936) * feat: add minio repository get * fix: implement minio log repository * fix: switch to interface * fix: pass log stream to factory * fix: add minio client * fix: move print logic into separate method * fix: switch back to non json reader * fix: return err * feat: features model for api * fix: send features in api response * fix: debug logs v2 * feat: don't send logs by agent in v2 * fix: unit tests * fix: separate v1 and v2 logs methods * feat: add log model * feat: add log v2 spec * feat: unit tests for logs v2 api * fix: error message * fix: error message * fix: comment * fix: minio unit test * fix: unit test * fix: make chanel later * fix: return output for logs v1 and v2 * fix: remove check * fix: support both v1 and v2 logs reading * fix: logs v1 prefix * fx: for not found log states use minio * fix: remove empty line * fix: unit test * featL cli watch logs v2 * fix: cli typo * fix: add completed message * fix: log reading error * fix: change default bucket * feat: use last modified time for logs v1 * fix: unit tests * fix: don't send not completed output * fix: v2 routes * fix: support watch for run command --- api/v1/testkube.yaml | 96 ++++++++++++ cmd/api-server/main.go | 9 +- cmd/kubectl-testkube/commands/tests/common.go | 39 +++++ cmd/kubectl-testkube/commands/tests/run.go | 13 +- cmd/kubectl-testkube/commands/tests/watch.go | 10 +- cmd/logs/main.go | 39 ++++- internal/app/api/v1/executions.go | 120 +++++++++++++++ internal/app/api/v1/executions_test.go | 78 +++++++++- internal/app/api/v1/handlers.go | 3 + internal/app/api/v1/server.go | 5 + internal/config/config.go | 1 + pkg/agent/agent.go | 18 ++- pkg/agent/agent_test.go | 3 +- pkg/agent/events_test.go | 3 +- pkg/agent/logs_test.go | 3 +- pkg/api/v1/client/common.go | 39 ++++- pkg/api/v1/client/direct_client.go | 24 +++ pkg/api/v1/client/interface.go | 3 + pkg/api/v1/client/proxy_client.go | 21 +++ pkg/api/v1/client/test.go | 9 ++ pkg/api/v1/testkube/model_features.go | 15 ++ pkg/api/v1/testkube/model_log_v1.go | 15 ++ pkg/api/v1/testkube/model_log_v2.go | 33 ++++ pkg/api/v1/testkube/model_server_info.go | 3 +- pkg/logs/adapter/minio_test.go | 6 +- pkg/logs/config/logs_config.go | 9 +- pkg/logs/events/events.go | 38 ++--- pkg/logs/logsserver_test.go | 2 +- pkg/logs/pb/mapper.go | 12 +- pkg/logs/repository/factory.go | 21 ++- pkg/logs/repository/minio.go | 130 +++++++++++++++- pkg/logs/repository/minio_test.go | 142 ++++++++++++++++++ pkg/logs/state/state.go | 12 +- pkg/logs/state/state_test.go | 5 +- pkg/repository/result/minio_output.go | 4 +- pkg/repository/result/minio_output_test.go | 4 +- pkg/repository/result/mongo.go | 105 ++++++++++--- pkg/storage/minio/minio.go | 14 +- pkg/storage/storage.go | 6 +- pkg/storage/storage_mock.go | 7 +- 40 files changed, 1012 insertions(+), 107 deletions(-) create mode 100644 pkg/api/v1/testkube/model_features.go create mode 100644 pkg/api/v1/testkube/model_log_v1.go create mode 100644 pkg/api/v1/testkube/model_log_v2.go create mode 100644 pkg/logs/repository/minio_test.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 9aa2e52aa3b..91a161f40db 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -1337,6 +1337,35 @@ paths: items: $ref: "#/components/schemas/Problem" + /executions/{id}/logs/v2: + get: + parameters: + - $ref: "#/components/parameters/ID" + tags: + - logs + - executions + - api + summary: "Get execution's logs by ID version 2" + description: "Returns logs of the given executionID version 2" + operationId: getExecutionLogsV2 + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LogV2" + 500: + description: "problem with getting execution's logs version 2" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /executions/{id}/artifacts/{filename}: get: parameters: @@ -4539,6 +4568,8 @@ components: type: string description: dashboard uri example: "http://localhost:8080" + features: + $ref: "#/components/schemas/Features" Repository: description: repository representation for tests in git repositories @@ -5252,6 +5283,62 @@ components: description: Timestamp of log example: "2018-03-20T09:12:28Z" + LogV2: + description: Log format version 2 + type: object + required: + - logVersion + - source + properties: + time: + type: string + format: date-time + description: Timestamp of log + example: "2018-03-20T09:12:28Z" + content: + type: string + description: Message/event data passed from executor (like log lines etc) + type: + type: string + description: One of possible log types + source: + type: string + description: One of possible log sources + enum: + - job-pod + - test-scheduler + - container-executor + - job-executor + error: + type: boolean + description: indicates a log error + version: + type: string + description: One of possible log versions + enum: + - v1 + - v2 + metadata: + type: object + description: additional log details + additionalProperties: + type: string + example: + argsl: "passed command arguments" + v1: + $ref: "#/components/schemas/LogV1" + description: Old output - for backwards compatibility - will be removed for non-structured logs + + LogV1: + description: Log format version 1 + type: object + required: + - type + properties: + result: + $ref: "#/components/schemas/ExecutionResult" + description: output for previous log format + ExecutorMeta: description: Executor meta data type: object @@ -5527,6 +5614,15 @@ components: type: string example: ["logline1", "logline2", "logline3"] + Features: + type: object + required: + - logsV2 + properties: + logsV2: + type: boolean + description: Log processing version 2 + TestTrigger: type: object required: diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index d9978508a21..bbaff3405f2 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -224,6 +224,11 @@ func main() { ui.ExitOnError("Creating TestKube Clientset", err) } + var logGrpcClient logsclient.StreamGetter + if features.LogsV2 { + logGrpcClient = logsclient.NewGrpcClient(cfg.LogServerGrpcAddress) + } + // DI var resultsRepository result.Repository var testResultsRepository testresult.Repository @@ -242,7 +247,7 @@ func main() { db, err := storage.GetMongoDatabase(cfg.APIMongoDSN, cfg.APIMongoDB, cfg.APIMongoDBType, cfg.APIMongoAllowTLS, mongoSSLConfig) ui.ExitOnError("Getting mongo database", err) isDocDb := cfg.APIMongoDBType == storage.TypeDocDB - mongoResultsRepository := result.NewMongoRepository(db, cfg.APIMongoAllowDiskUse, isDocDb) + mongoResultsRepository := result.NewMongoRepository(db, logGrpcClient, cfg.APIMongoAllowDiskUse, isDocDb, features) resultsRepository = mongoResultsRepository testResultsRepository = testresult.NewMongoRepository(db, cfg.APIMongoAllowDiskUse, isDocDb) configRepository = configrepository.NewMongoRepository(db) @@ -492,6 +497,7 @@ func main() { cfg.EnableSecretsEndpoint, features, logsStream, + logGrpcClient, ) if mode == common.ModeAgent { @@ -508,6 +514,7 @@ func main() { clusterId, cfg.TestkubeClusterName, envs, + features, ) if err != nil { ui.ExitOnError("Starting agent", err) diff --git a/cmd/kubectl-testkube/commands/tests/common.go b/cmd/kubectl-testkube/commands/tests/common.go index adf08934b62..50201988136 100644 --- a/cmd/kubectl-testkube/commands/tests/common.go +++ b/cmd/kubectl-testkube/commands/tests/common.go @@ -177,6 +177,45 @@ func watchLogs(id string, silentMode bool, client apiclientv1.Client) error { return result } +func watchLogsV2(id string, silentMode bool, client apiclientv1.Client) error { + ui.Info("Getting logs from test job", id) + + logs, err := client.LogsV2(id) + ui.ExitOnError("getting logs from executor", err) + + var result error + for l := range logs { + if l.Error_ { + ui.UseStderr() + ui.Errf(l.Content) + result = errors.New(l.Content) + continue + } + + if !silentMode { + ui.LogLine(l.Content) + } + } + + ui.NL() + + // TODO Websocket research + plug into Event bus (EventEmitter) + // watch for success | error status - in case of connection error on logs watch need fix in 0.8 + for range time.Tick(time.Second) { + execution, err := client.GetExecution(id) + ui.ExitOnError("get test execution details", err) + + fmt.Print(".") + + if execution.ExecutionResult.IsCompleted() { + ui.Info("Execution completed") + return result + } + } + + return result +} + func newContentFromFlags(cmd *cobra.Command) (content *testkube.TestContent, err error) { testContentType := cmd.Flag("test-content-type").Value.String() uri := cmd.Flag("uri").Value.String() diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index 360966a1acd..f16dd7fb472 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -302,8 +302,17 @@ func NewRunTestCmd() *cobra.Command { if execution.Id != "" { if watchEnabled && len(args) > 0 { - if err = watchLogs(execution.Id, silentMode, client); err != nil { - execErrors = append(execErrors, err) + info, err := client.GetServerInfo() + ui.ExitOnError("getting server info", err) + + if info.Features.LogsV2 { + if err = watchLogsV2(execution.Id, silentMode, client); err != nil { + execErrors = append(execErrors, err) + } + } else { + if err = watchLogs(execution.Id, silentMode, client); err != nil { + execErrors = append(execErrors, err) + } } } diff --git a/cmd/kubectl-testkube/commands/tests/watch.go b/cmd/kubectl-testkube/commands/tests/watch.go index 6a749dc6914..ae82c96dce9 100644 --- a/cmd/kubectl-testkube/commands/tests/watch.go +++ b/cmd/kubectl-testkube/commands/tests/watch.go @@ -28,7 +28,15 @@ func NewWatchExecutionCmd() *cobra.Command { if execution.ExecutionResult.IsCompleted() { ui.Completed("execution is already finished") } else { - err = watchLogs(execution.Id, false, client) + info, err := client.GetServerInfo() + ui.ExitOnError("getting server info", err) + + if info.Features.LogsV2 { + err = watchLogsV2(execution.Id, false, client) + } else { + err = watchLogs(execution.Id, false, client) + } + ui.NL() uiShellGetExecution(execution.Id) if err != nil { diff --git a/cmd/logs/main.go b/cmd/logs/main.go index aa9643fd50e..1d08a030cbf 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -8,6 +8,8 @@ import ( "os/signal" "syscall" + "github.com/nats-io/nats.go/jetstream" + "github.com/oklog/run" "go.uber.org/zap" "github.com/kubeshop/testkube/internal/common" @@ -16,15 +18,28 @@ import ( "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/logs" "github.com/kubeshop/testkube/pkg/logs/adapter" + "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/logs/config" "github.com/kubeshop/testkube/pkg/logs/pb" + "github.com/kubeshop/testkube/pkg/logs/repository" "github.com/kubeshop/testkube/pkg/logs/state" + "github.com/kubeshop/testkube/pkg/storage/minio" "github.com/kubeshop/testkube/pkg/ui" - - "github.com/nats-io/nats.go/jetstream" - "github.com/oklog/run" ) +func newStorageClient(cfg *config.Config) *minio.Client { + opts := minio.GetTLSOptions(cfg.StorageSSL, cfg.StorageSkipVerify, cfg.StorageCertFile, cfg.StorageKeyFile, cfg.StorageCAFile) + return minio.NewClient( + cfg.StorageEndpoint, + cfg.StorageAccessKeyID, + cfg.StorageSecretAccessKey, + cfg.StorageRegion, + cfg.StorageToken, + cfg.StorageBucket, + opts..., + ) +} + func main() { var g run.Group @@ -55,18 +70,28 @@ func main() { }() js := Must(jetstream.New(nc)) + logStream := Must(client.NewNatsLogStream(nc)) + + minioClient := newStorageClient(cfg) + if err := minioClient.Connect(); err != nil { + log.Fatalw("error connecting to minio", "error", err) + } + + if err := minioClient.SetExpirationPolicy(cfg.StorageExpiration); err != nil { + log.Warnw("error setting expiration policy", "error", err) + } kv := Must(js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: cfg.KVBucketName})) state := state.NewState(kv) svc := logs.NewLogsService(nc, js, state). WithHttpAddress(cfg.HttpAddress). - WithGrpcAddress(cfg.GrpcAddress) + WithGrpcAddress(cfg.GrpcAddress). + WithLogsRepositoryFactory(repository.NewJsMinioFactory(minioClient, cfg.StorageBucket, logStream)) if cfg.Debug { svc.AddAdapter(adapter.NewDebugAdapter()) } - // add given log adapter depends from mode switch mode { @@ -84,7 +109,7 @@ func main() { cfg.StorageSecretAccessKey, cfg.StorageRegion, cfg.StorageToken, - cfg.StorageLogsBucket, + cfg.StorageBucket, cfg.StorageSSL, cfg.StorageSkipVerify, cfg.StorageCertFile, @@ -94,7 +119,7 @@ func main() { if err != nil { log.Errorw("error creating minio adapter", "error", err) } - log.Infow("minio adapter created", "bucket", cfg.StorageLogsBucket, "endpoint", cfg.StorageEndpoint) + log.Infow("minio adapter created", "bucket", cfg.StorageBucket, "endpoint", cfg.StorageEndpoint) svc.AddAdapter(minioAdapter) } diff --git a/internal/app/api/v1/executions.go b/internal/app/api/v1/executions.go index 97d11fb2ca5..473a782a176 100644 --- a/internal/app/api/v1/executions.go +++ b/internal/app/api/v1/executions.go @@ -14,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" + "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/gofiber/fiber/v2" @@ -181,6 +182,10 @@ func (s *TestkubeAPI) GetLogsStream(ctx context.Context, executionID string) (ch func (s *TestkubeAPI) ExecutionLogsStreamHandler() fiber.Handler { return websocket.New(func(c *websocket.Conn) { + if s.featureFlags.LogsV2 { + return + } + executionID := c.Params("executionID") l := s.Log.With("executionID", executionID) @@ -200,9 +205,45 @@ func (s *TestkubeAPI) ExecutionLogsStreamHandler() fiber.Handler { }) } +func (s *TestkubeAPI) ExecutionLogsStreamHandlerV2() fiber.Handler { + return websocket.New(func(c *websocket.Conn) { + if !s.featureFlags.LogsV2 { + return + } + + executionID := c.Params("executionID") + l := s.Log.With("executionID", executionID) + + l.Debugw("getting logs from grpc log server and passing to websocket", + "id", c.Params("id"), "locals", c.Locals, "remoteAddr", c.RemoteAddr(), "localAddr", c.LocalAddr()) + + defer c.Conn.Close() + + logs, err := s.logGrpcClient.Get(context.Background(), executionID) + if err != nil { + l.Errorw("can't get logs fom grpc", "error", err) + return + } + + for logLine := range logs { + if logLine.Error != nil { + l.Errorw("can't get log line", "error", logLine.Error) + continue + } + + l.Debugw("sending log line to websocket", "line", logLine.Log) + _ = c.WriteJSON(logLine.Log) + } + }) +} + // ExecutionLogsHandler streams the logs from a test execution func (s *TestkubeAPI) ExecutionLogsHandler() fiber.Handler { return func(c *fiber.Ctx) error { + if s.featureFlags.LogsV2 { + return nil + } + executionID := c.Params("executionID") s.Log.Debug("getting logs", "executionID", executionID) @@ -243,6 +284,42 @@ func (s *TestkubeAPI) ExecutionLogsHandler() fiber.Handler { } } +// ExecutionLogsHandlerV2 streams the logs from a test execution version 2 +func (s *TestkubeAPI) ExecutionLogsHandlerV2() fiber.Handler { + return func(c *fiber.Ctx) error { + if !s.featureFlags.LogsV2 { + return nil + } + + executionID := c.Params("executionID") + + s.Log.Debug("getting logs", "executionID", executionID) + + ctx := c.Context() + + ctx.SetContentType("text/event-stream") + ctx.Response.Header.Set("Cache-Control", "no-cache") + ctx.Response.Header.Set("Connection", "keep-alive") + ctx.Response.Header.Set("Transfer-Encoding", "chunked") + + ctx.SetBodyStreamWriter(func(w *bufio.Writer) { + s.Log.Debug("start streaming logs") + _ = w.Flush() + + s.Log.Infow("getting logs from grpc log server") + logs, err := s.logGrpcClient.Get(ctx, executionID) + if err != nil { + s.Log.Errorw("can't get logs from grpc", "error", err) + return + } + + s.streamLogsFromLogServer(logs, w) + }) + + return nil + } +} + // GetExecutionHandler returns test execution object for given test and execution id/name func (s *TestkubeAPI) GetExecutionHandler() fiber.Handler { return func(c *fiber.Ctx) error { @@ -595,6 +672,25 @@ func (s *TestkubeAPI) getNewestExecutions(ctx context.Context) ([]testkube.Execu // getExecutionLogs returns logs from an execution func (s *TestkubeAPI) getExecutionLogs(ctx context.Context, execution testkube.Execution) ([]string, error) { var res []string + + if s.featureFlags.LogsV2 { + logs, err := s.logGrpcClient.Get(ctx, execution.Id) + if err != nil { + return []string{}, fmt.Errorf("could not get logs for grpc %s: %w", execution.Id, err) + } + + for out := range logs { + if out.Error != nil { + s.Log.Errorw("can't get log line", "error", out.Error) + continue + } + + res = append(res, out.Log.Content) + } + + return res, nil + } + if execution.ExecutionResult.IsCompleted() { return append(res, execution.ExecutionResult.Output), nil } @@ -645,3 +741,27 @@ func (s *TestkubeAPI) getArtifactStorage(bucket string) (storage.ArtifactsStorag return minio.NewMinIOArtifactClient(minioClient), nil } + +// streamLogsFromLogServer writes logs from the output of log server to the writer +func (s *TestkubeAPI) streamLogsFromLogServer(logs chan events.LogResponse, w *bufio.Writer) { + enc := json.NewEncoder(w) + s.Log.Infow("looping through logs channel") + // loop through grpc server log lines - it's blocking channel + // and pass single log output as sse data chunk + for out := range logs { + if out.Error != nil { + s.Log.Errorw("can't get log line", "error", out.Error) + continue + } + + s.Log.Debugw("got log line from grpc log server", "out", out.Log) + _, _ = fmt.Fprintf(w, "data: ") + err := enc.Encode(out.Log) + if err != nil { + s.Log.Infow("Encode", "error", err) + } + // enc.Encode adds \n and we need \n\n after `data: {}` chunk + _, _ = fmt.Fprintf(w, "\n") + _ = w.Flush() + } +} diff --git a/internal/app/api/v1/executions_test.go b/internal/app/api/v1/executions_test.go index 16b5afc725f..e808947e161 100644 --- a/internal/app/api/v1/executions_test.go +++ b/internal/app/api/v1/executions_test.go @@ -7,9 +7,11 @@ import ( "testing" "time" + "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/gofiber/fiber/v2" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -19,9 +21,11 @@ import ( executorv1 "github.com/kubeshop/testkube-operator/api/executor/v1" executorsclientv1 "github.com/kubeshop/testkube-operator/pkg/client/executors/v1" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/executor/client" + executorclient "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/log" + logclient "github.com/kubeshop/testkube/pkg/logs/client" + "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/server" ) @@ -120,6 +124,76 @@ func TestTestkubeAPI_ExecutionLogsHandler(t *testing.T) { } } +func TestTestkubeAPI_ExecutionLogsHandlerV2(t *testing.T) { + app := fiber.New() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + grpcClient := logclient.NewMockStreamGetter(mockCtrl) + + eventLog := events.Log{ + Content: "storage logs", + Source: events.SourceJobPod, + Version: string(events.LogVersionV2), + } + + out := make(chan events.LogResponse) + go func() { + defer func() { + close(out) + }() + + out <- events.LogResponse{Log: eventLog} + }() + + grpcClient.EXPECT().Get(gomock.Any(), "test-execution-1").Return(out, nil) + s := &TestkubeAPI{ + HTTPServer: server.HTTPServer{ + Mux: app, + Log: log.DefaultLogger, + }, + featureFlags: featureflags.FeatureFlags{LogsV2: true}, + logGrpcClient: grpcClient, + } + app.Get("/executions/:executionID/logs/v2", s.ExecutionLogsHandlerV2()) + + tests := []struct { + name string + route string + expectedCode int + eventLog events.Log + }{ + { + name: "Test getting logs from grpc client", + route: "/executions/test-execution-1/logs/v2", + expectedCode: 200, + eventLog: eventLog, + }, + } + + responsePrefix := "data: " + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.route, nil) + resp, err := app.Test(req, -1) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tt.expectedCode, resp.StatusCode, tt.name) + + b := make([]byte, len(responsePrefix)) + resp.Body.Read(b) + assert.Equal(t, responsePrefix, string(b)) + + var res events.Log + err = json.NewDecoder(resp.Body).Decode(&res) + assert.NoError(t, err) + assert.Equal(t, tt.eventLog, res) + }) + } +} + type MockExecutionResultsRepository struct { GetFn func(ctx context.Context, id string) (testkube.Execution, error) } @@ -211,7 +285,7 @@ type MockExecutor struct { LogsFn func(id string) (chan output.Output, error) } -func (e MockExecutor) Execute(ctx context.Context, execution *testkube.Execution, options client.ExecuteOptions) (*testkube.ExecutionResult, error) { +func (e MockExecutor) Execute(ctx context.Context, execution *testkube.Execution, options executorclient.ExecuteOptions) (*testkube.ExecutionResult, error) { panic("not implemented") } diff --git a/internal/app/api/v1/handlers.go b/internal/app/api/v1/handlers.go index ee3394b0018..197c2297bbc 100644 --- a/internal/app/api/v1/handlers.go +++ b/internal/app/api/v1/handlers.go @@ -70,6 +70,9 @@ func (s *TestkubeAPI) InfoHandler() fiber.Handler { OrgId: os.Getenv(cloudOrgIdEnvName), HelmchartVersion: s.helmchartVersion, DashboardUri: s.dashboardURI, + Features: &testkube.Features{ + LogsV2: s.featureFlags.LogsV2, + }, }) } } diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index f4d96d26dfb..1a0f51cf000 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -91,6 +91,7 @@ func NewTestkubeAPI( enableSecretsEndpoint bool, ff featureflags.FeatureFlags, logsStream logsclient.Stream, + logGrpcClient logsclient.StreamGetter, ) TestkubeAPI { var httpConfig server.Config @@ -137,6 +138,7 @@ func NewTestkubeAPI( enableSecretsEndpoint: enableSecretsEndpoint, featureFlags: ff, logsStream: logsStream, + logGrpcClient: logGrpcClient, } // will be reused in websockets handler @@ -195,6 +197,7 @@ type TestkubeAPI struct { enableSecretsEndpoint bool featureFlags featureflags.FeatureFlags logsStream logsclient.Stream + logGrpcClient logsclient.StreamGetter } type storageParams struct { @@ -296,6 +299,8 @@ func (s *TestkubeAPI) InitRoutes() { executions.Get("/:executionID/artifacts", s.ListArtifactsHandler()) executions.Get("/:executionID/logs", s.ExecutionLogsHandler()) executions.Get("/:executionID/logs/stream", s.ExecutionLogsStreamHandler()) + executions.Get("/:executionID/logs/v2", s.ExecutionLogsHandlerV2()) + executions.Get("/:executionID/logs/stream/v2", s.ExecutionLogsStreamHandlerV2()) executions.Get("/:executionID/artifacts/:filename", s.GetArtifactHandler()) executions.Get("/:executionID/artifact-archive", s.GetArtifactArchiveHandler()) diff --git a/internal/config/config.go b/internal/config/config.go index f22dc424b6e..6a224eb3667 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,6 +84,7 @@ type Config struct { EnableSecretsEndpoint bool `envconfig:"ENABLE_SECRETS_ENDPOINT" default:"false"` DisableMongoMigrations bool `envconfig:"DISABLE_MONGO_MIGRATIONS" default:"false"` Debug bool `envconfig:"DEBUG" default:"false"` + LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` // DEPRECATED: Use TestkubeProAPIKey instead TestkubeCloudAPIKey string `envconfig:"TESTKUBE_CLOUD_API_KEY" default:""` diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 69219565377..e7ac4d2de57 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -24,6 +24,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/cloud" ) @@ -102,6 +103,7 @@ type Agent struct { clusterID string clusterName string envs map[string]string + features featureflags.FeatureFlags } func NewAgent(logger *zap.SugaredLogger, @@ -114,6 +116,7 @@ func NewAgent(logger *zap.SugaredLogger, clusterID string, clusterName string, envs map[string]string, + features featureflags.FeatureFlags, ) (*Agent, error) { return &Agent{ handler: handler, @@ -134,6 +137,7 @@ func NewAgent(logger *zap.SugaredLogger, clusterID: clusterID, clusterName: clusterName, envs: envs, + features: features, }, nil } @@ -165,12 +169,14 @@ func (ag *Agent) run(ctx context.Context) (err error) { return ag.runEventLoop(groupCtx) }) - g.Go(func() error { - return ag.runLogStreamLoop(groupCtx) - }) - g.Go(func() error { - return ag.runLogStreamWorker(groupCtx, ag.logStreamWorkerCount) - }) + if !ag.features.LogsV2 { + g.Go(func() error { + return ag.runLogStreamLoop(groupCtx) + }) + g.Go(func() error { + return ag.runLogStreamWorker(groupCtx, ag.logStreamWorkerCount) + }) + } err = g.Wait() diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 4cd3eebfaac..5dd1bbcd3b3 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/cloud" ) @@ -56,7 +57,7 @@ func TestCommandExecution(t *testing.T) { var logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) logger, _ := zap.NewDevelopment() - agent, err := agent.NewAgent(logger.Sugar(), m, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil) + agent, err := agent.NewAgent(logger.Sugar(), m, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}) if err != nil { t.Fatal(err) } diff --git a/pkg/agent/events_test.go b/pkg/agent/events_test.go index 7eacca223a5..f24526b4ab9 100644 --- a/pkg/agent/events_test.go +++ b/pkg/agent/events_test.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/cloud" @@ -52,7 +53,7 @@ func TestEventLoop(t *testing.T) { grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) var logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) - agent, err := agent.NewAgent(logger.Sugar(), nil, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil) + agent, err := agent.NewAgent(logger.Sugar(), nil, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}) assert.NoError(t, err) go func() { l, err := agent.Load() diff --git a/pkg/agent/logs_test.go b/pkg/agent/logs_test.go index 1c45a303382..8d56c673f1b 100644 --- a/pkg/agent/logs_test.go +++ b/pkg/agent/logs_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/cloud" "github.com/kubeshop/testkube/pkg/executor/output" @@ -63,7 +64,7 @@ func TestLogStream(t *testing.T) { } logger, _ := zap.NewDevelopment() - agent, err := agent.NewAgent(logger.Sugar(), m, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil) + agent, err := agent.NewAgent(logger.Sugar(), m, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}) if err != nil { t.Fatal(err) } diff --git a/pkg/api/v1/client/common.go b/pkg/api/v1/client/common.go index f9c86bda82f..00afd3a870a 100644 --- a/pkg/api/v1/client/common.go +++ b/pkg/api/v1/client/common.go @@ -8,6 +8,7 @@ import ( "io" "github.com/kubeshop/testkube/pkg/executor/output" + "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/utils" ) @@ -32,9 +33,10 @@ func StreamToLogsChannel(resp io.Reader, logs chan output.Output) { for { b, err := utils.ReadLongLine(reader) if err != nil { - if err == io.EOF { - err = nil + if err != io.EOF { + fmt.Printf("Read long line error: %+v' \n", err) } + break } chunk := trimDataChunk(b) @@ -56,6 +58,39 @@ func StreamToLogsChannel(resp io.Reader, logs chan output.Output) { } } +// StreamToLogsChannelV2 converts io.Reader with SSE data like `data: {"type": "event", "message":"something"}` +// to channel of output.Output objects, helps with logs version 2 streaming from SSE endpoint (passed from job executor) +func StreamToLogsChannelV2(resp io.Reader, logs chan events.Log) { + reader := bufio.NewReader(resp) + + for { + b, err := utils.ReadLongLine(reader) + if err != nil { + if err != io.EOF { + fmt.Printf("Read long line error: %+v' \n", err) + } + + break + } + chunk := trimDataChunk(b) + + // ignore lines which are not JSON objects + if len(chunk) < 2 || chunk[0] != '{' { + continue + } + + // convert to events.Log object + out := events.Log{} + err = json.Unmarshal(chunk, &out) + if err != nil { + fmt.Printf("Unmarshal chunk error: %+v, json:'%s' \n", err, chunk) + continue + } + + logs <- out + } +} + // trimDataChunk remove data: and newlines from incoming SSE data line func trimDataChunk(in []byte) []byte { prefix := []byte("data: ") diff --git a/pkg/api/v1/client/direct_client.go b/pkg/api/v1/client/direct_client.go index 4f7c2254114..e7c246bc2d2 100644 --- a/pkg/api/v1/client/direct_client.go +++ b/pkg/api/v1/client/direct_client.go @@ -13,6 +13,7 @@ import ( "golang.org/x/oauth2" "github.com/kubeshop/testkube/pkg/executor/output" + "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/oauth" "github.com/kubeshop/testkube/pkg/problem" ) @@ -182,6 +183,29 @@ func (t DirectClient[A]) GetLogs(uri string, logs chan output.Output) error { return nil } +// GetLogsV2 returns logs stream version 2 from log server, based on job pods logs +func (t DirectClient[A]) GetLogsV2(uri string, logs chan events.Log) error { + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "text/event-stream") + resp, err := t.sseClient.Do(req) + if err != nil { + return err + } + + go func() { + defer close(logs) + defer resp.Body.Close() + + StreamToLogsChannelV2(resp.Body, logs) + }() + + return nil +} + // GetFile returns file artifact func (t DirectClient[A]) GetFile(uri, fileName, destination string, params map[string][]string) (name string, err error) { req, err := http.NewRequest(http.MethodGet, uri, nil) diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index 33c46305682..a654615fdc9 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -5,6 +5,7 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor/output" + "github.com/kubeshop/testkube/pkg/logs/events" ) // Client is the Testkube API client abstraction @@ -35,6 +36,7 @@ type TestAPI interface { ExecuteTest(id, executionName string, options ExecuteTestOptions) (executions testkube.Execution, err error) ExecuteTests(selector string, concurrencyLevel int, options ExecuteTestOptions) (executions []testkube.Execution, err error) Logs(id string) (logs chan output.Output, err error) + LogsV2(id string) (logs chan events.Log, err error) } // ExecutionAPI describes execution api methods @@ -251,5 +253,6 @@ type Transport[A All] interface { ExecuteMethod(method, uri, selector string, isContentExpected bool) error GetURI(pathTemplate string, params ...interface{}) string GetLogs(uri string, logs chan output.Output) error + GetLogsV2(uri string, logs chan events.Log) error GetFile(uri, fileName, destination string, params map[string][]string) (name string, err error) } diff --git a/pkg/api/v1/client/proxy_client.go b/pkg/api/v1/client/proxy_client.go index 11f1439004e..571072b4b57 100644 --- a/pkg/api/v1/client/proxy_client.go +++ b/pkg/api/v1/client/proxy_client.go @@ -15,6 +15,7 @@ import ( "k8s.io/client-go/tools/clientcmd" "github.com/kubeshop/testkube/pkg/executor/output" + "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/problem" ) @@ -149,6 +150,26 @@ func (t ProxyClient[A]) GetLogs(uri string, logs chan output.Output) error { return nil } +// GetLogsV2 returns logs version 2 stream from log server, based on job pods logs +func (t ProxyClient[A]) GetLogsV2(uri string, logs chan events.Log) error { + resp, err := t.getProxy(http.MethodGet). + Suffix(uri). + SetHeader("Accept", "text/event-stream"). + Stream(context.Background()) + if err != nil { + return err + } + + go func() { + defer close(logs) + defer resp.Close() + + StreamToLogsChannelV2(resp, logs) + }() + + return nil +} + // GetFile returns file artifact func (t ProxyClient[A]) GetFile(uri, fileName, destination string, params map[string][]string) (name string, err error) { req := t.getProxy(http.MethodGet). diff --git a/pkg/api/v1/client/test.go b/pkg/api/v1/client/test.go index b689d0708df..8ecbb571c47 100644 --- a/pkg/api/v1/client/test.go +++ b/pkg/api/v1/client/test.go @@ -9,6 +9,7 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor/output" + "github.com/kubeshop/testkube/pkg/logs/events" ) // NewTestClient creates new Test client @@ -260,6 +261,14 @@ func (c TestClient) Logs(id string) (logs chan output.Output, err error) { return logs, err } +// LogsV2 returns logs version 2 stream from log sever, based on job pods logs +func (c TestClient) LogsV2(id string) (logs chan events.Log, err error) { + logs = make(chan events.Log) + uri := c.testTransport.GetURI("/executions/%s/logs/v2", id) + err = c.testTransport.GetLogsV2(uri, logs) + return logs, err +} + // GetExecutionArtifacts returns execution artifacts func (c TestClient) GetExecutionArtifacts(executionID string) (artifacts testkube.Artifacts, err error) { uri := c.artifactTransport.GetURI("/executions/%s/artifacts", executionID) diff --git a/pkg/api/v1/testkube/model_features.go b/pkg/api/v1/testkube/model_features.go new file mode 100644 index 00000000000..68b3bc1ad29 --- /dev/null +++ b/pkg/api/v1/testkube/model_features.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type Features struct { + // Log processing version 2 + LogsV2 bool `json:"logsV2"` +} diff --git a/pkg/api/v1/testkube/model_log_v1.go b/pkg/api/v1/testkube/model_log_v1.go new file mode 100644 index 00000000000..2f87bc1f8b5 --- /dev/null +++ b/pkg/api/v1/testkube/model_log_v1.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Log format version 1 +type LogV1 struct { + Result *ExecutionResult `json:"result,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_log_v2.go b/pkg/api/v1/testkube/model_log_v2.go new file mode 100644 index 00000000000..fd435936f09 --- /dev/null +++ b/pkg/api/v1/testkube/model_log_v2.go @@ -0,0 +1,33 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +// Log format version 2 +type LogV2 struct { + // Timestamp of log + Time time.Time `json:"time,omitempty"` + // Message/event data passed from executor (like log lines etc) + Content string `json:"content,omitempty"` + // One of possible log types + Type_ string `json:"type,omitempty"` + // One of possible log sources + Source string `json:"source"` + // indicates a log error + Error_ bool `json:"error,omitempty"` + // One of possible log versions + Version string `json:"version,omitempty"` + // additional log details + Metadata map[string]string `json:"metadata,omitempty"` + V1 *LogV1 `json:"v1,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_server_info.go b/pkg/api/v1/testkube/model_server_info.go index 38e0ff0051f..84f62f9a812 100644 --- a/pkg/api/v1/testkube/model_server_info.go +++ b/pkg/api/v1/testkube/model_server_info.go @@ -26,5 +26,6 @@ type ServerInfo struct { // helm chart version HelmchartVersion string `json:"helmchartVersion,omitempty"` // dashboard uri - DashboardUri string `json:"dashboardUri,omitempty"` + DashboardUri string `json:"dashboardUri,omitempty"` + Features *Features `json:"features,omitempty"` } diff --git a/pkg/logs/adapter/minio_test.go b/pkg/logs/adapter/minio_test.go index 515f8015acd..eb941de82c7 100644 --- a/pkg/logs/adapter/minio_test.go +++ b/pkg/logs/adapter/minio_test.go @@ -45,7 +45,7 @@ func TestLogs(t *testing.T) { fmt.Println("sending", i) consumer.Notify(ctx, id, events.Log{Time: time.Now(), Content: fmt.Sprintf("Test %d: %s", i, hugeString), - Type: "test", Source: strconv.Itoa(i)}) + Type_: "test", Source: strconv.Itoa(i)}) time.Sleep(100 * time.Millisecond) } err := consumer.Stop(ctx, id) @@ -62,7 +62,7 @@ func BenchmarkLogs(b *testing.B) { for i := 0; i < b.N; i++ { consumer.Notify(ctx, id, events.Log{Time: time.Now(), Content: fmt.Sprintf("Test %d: %s", i, hugeString), - Type: "test", Source: strconv.Itoa(i)}) + Type_: "test", Source: strconv.Itoa(i)}) totalSize += len(hugeString) } sizeInMB := float64(totalSize) / 1024 / 1024 @@ -99,7 +99,7 @@ func testOneConsumer(consumer *MinioAdapter, id string) { for i := 0; i < numberOFLogs; i++ { consumer.Notify(ctx, id, events.Log{Time: time.Now(), Content: fmt.Sprintf("Test %d: %s", i, hugeString), - Type: "test", Source: strconv.Itoa(i)}) + Type_: "test", Source: strconv.Itoa(i)}) totalSize += len(hugeString) time.Sleep(time.Duration(rand.Intn(10)) * time.Millisecond) } diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index 2e8e52b33b6..a0a7515f807 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -31,10 +31,11 @@ type Config struct { GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` - StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"testkube-minio-service-testkube:9000"` - StorageLogsBucket string `envconfig:"STORAGE_LOGS_BUCKET" default:"testkube-new-logs"` - StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:"minio"` - StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:"minio123"` + StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"localhost:9000"` + StorageBucket string `envconfig:"STORAGE_BUCKET" default:"testkube-logs"` + StorageExpiration int `envconfig:"STORAGE_EXPIRATION"` + StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:""` + StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:""` StorageRegion string `envconfig:"STORAGE_REGION" default:""` StorageToken string `envconfig:"STORAGE_TOKEN" default:""` StorageSSL bool `envconfig:"STORAGE_SSL" default:"false"` diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 26e5ef8c697..92e4b11ce03 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -46,21 +46,7 @@ type LogResponse struct { Error error } -type Log struct { - Time time.Time `json:"ts,omitempty"` - Content string `json:"content,omitempty"` - Type string `json:"type,omitempty"` - Source string `json:"source,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Error bool `json:"error,omitempty"` - Version LogVersion `json:"version,omitempty"` - - // Old output - for backwards compatibility - will be removed for non-structured logs - V1 *LogOutputV1 `json:"v1,omitempty"` -} -type LogOutputV1 struct { - Result *testkube.ExecutionResult -} +type Log testkube.LogV2 func NewErrorLog(err error) *Log { var msg string @@ -68,7 +54,7 @@ func NewErrorLog(err error) *Log { msg = err.Error() } return &Log{ - Error: true, + Error_: true, Content: msg, } } @@ -92,7 +78,7 @@ func (l *Log) WithContent(s string) *Log { } func (l *Log) WithError(err error) *Log { - l.Error = true + l.Error_ = true if err != nil { l.Content = err.Error() @@ -110,7 +96,7 @@ func (l *Log) WithMetadataEntry(key, value string) *Log { } func (l *Log) WithType(t string) *Log { - l.Type = t + l.Type_ = t return l } @@ -120,7 +106,7 @@ func (l *Log) WithSource(s string) *Log { } func (l *Log) WithVersion(version LogVersion) *Log { - l.Version = version + l.Version = string(version) return l } @@ -178,9 +164,9 @@ func NewLogFromBytes(b []byte) *Log { return &Log{ Time: ts, Content: err.Error(), - Type: o.Type_, - Error: true, - Version: LogVersionV1, + Type_: o.Type_, + Error_: true, + Version: string(LogVersionV1), } } @@ -190,8 +176,8 @@ func NewLogFromBytes(b []byte) *Log { return &Log{ Time: ts, Content: o.Content, - Version: LogVersionV1, - V1: &LogOutputV1{ + Version: string(LogVersionV1), + V1: &testkube.LogV1{ Result: o.Result, }, } @@ -200,7 +186,7 @@ func NewLogFromBytes(b []byte) *Log { return &Log{ Time: ts, Content: o.Content, - Version: LogVersionV1, + Version: string(LogVersionV1), } } // END DEPRECATED @@ -209,6 +195,6 @@ func NewLogFromBytes(b []byte) *Log { return &Log{ Time: ts, Content: string(b), - Version: LogVersionV2, + Version: string(LogVersionV2), } } diff --git a/pkg/logs/logsserver_test.go b/pkg/logs/logsserver_test.go index aa21cc9ef47..c3031106b46 100644 --- a/pkg/logs/logsserver_test.go +++ b/pkg/logs/logsserver_test.go @@ -74,7 +74,7 @@ func (l LogsRepositoryMock) Get(ctx context.Context, id string) (chan events.Log defer close(ch) for i := 0; i < count; i++ { - ch <- events.LogResponse{Log: events.Log{Time: time.Now(), Content: fmt.Sprintf("test %d", i), Error: false, Type: "test", Source: "test", Metadata: map[string]string{"test": "test"}}} + ch <- events.LogResponse{Log: events.Log{Time: time.Now(), Content: fmt.Sprintf("test %d", i), Error_: false, Type_: "test", Source: "test", Metadata: map[string]string{"test": "test"}}} } return ch, nil } diff --git a/pkg/logs/pb/mapper.go b/pkg/logs/pb/mapper.go index 32812992ae6..dcee292da20 100644 --- a/pkg/logs/pb/mapper.go +++ b/pkg/logs/pb/mapper.go @@ -18,11 +18,11 @@ func MapToPB(r events.Log) *Log { return &Log{ Time: timestamppb.New(r.Time), Content: r.Content, - Error: r.Error, - Type: r.Type, + Error: r.Error_, + Type: r.Type_, Source: r.Source, Metadata: r.Metadata, - Version: string(r.Version), + Version: r.Version, } } @@ -30,10 +30,10 @@ func MapFromPB(log *Log) events.Log { return events.Log{ Time: log.Time.AsTime(), Content: log.Content, - Error: log.Error, - Type: log.Type, + Error_: log.Error, + Type_: log.Type, Source: log.Source, Metadata: log.Metadata, - Version: events.LogVersion(log.Version), + Version: log.Version, } } diff --git a/pkg/logs/repository/factory.go b/pkg/logs/repository/factory.go index 5ac80512910..ef98edc8222 100644 --- a/pkg/logs/repository/factory.go +++ b/pkg/logs/repository/factory.go @@ -5,7 +5,7 @@ import ( "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/logs/state" - "github.com/kubeshop/testkube/pkg/storage/minio" + "github.com/kubeshop/testkube/pkg/storage" ) var ErrUnknownState = errors.New("unknown state") @@ -14,18 +14,27 @@ type Factory interface { GetRepository(state state.LogState) (LogsRepository, error) } +func NewJsMinioFactory(storageClient storage.ClientBucket, bucket string, logStream client.StreamGetter) Factory { + return JsMinioFactory{ + storageClient: storageClient, + bucket: bucket, + logStream: logStream, + } +} + type JsMinioFactory struct { - minio *minio.Client - js client.StreamGetter + storageClient storage.ClientBucket + bucket string + logStream client.StreamGetter } func (b JsMinioFactory) GetRepository(s state.LogState) (LogsRepository, error) { switch s { // pending get from buffer case state.LogStatePending: - return NewJetstreamRepository(b.js), nil - case state.LogStateFinished: - return NewMinioRepository(b.minio), nil + return NewJetstreamRepository(b.logStream), nil + case state.LogStateFinished, state.LogStateUnknown: + return NewMinioRepository(b.storageClient, b.bucket), nil default: return nil, ErrUnknownState } diff --git a/pkg/logs/repository/minio.go b/pkg/logs/repository/minio.go index c9a26ffb8e2..e789df3cdd6 100644 --- a/pkg/logs/repository/minio.go +++ b/pkg/logs/repository/minio.go @@ -1,20 +1,142 @@ package repository import ( + "bufio" + "bytes" "context" + "encoding/json" + "errors" + "io" + "strings" + "time" + "go.uber.org/zap" + + "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/logs/events" - "github.com/kubeshop/testkube/pkg/storage/minio" + "github.com/kubeshop/testkube/pkg/repository/result" + "github.com/kubeshop/testkube/pkg/storage" + "github.com/kubeshop/testkube/pkg/utils" +) + +const ( + defaultBufferSize = 100 + logsV1Prefix = "{\"id\"" ) -func NewMinioRepository(minio *minio.Client) LogsRepository { - return MinioLogsRepository{} +func NewMinioRepository(storageClient storage.ClientBucket, bucket string) LogsRepository { + return MinioLogsRepository{ + storageClient: storageClient, + log: log.DefaultLogger, + bucket: bucket, + } } type MinioLogsRepository struct { + storageClient storage.ClientBucket + log *zap.SugaredLogger + bucket string } func (r MinioLogsRepository) Get(ctx context.Context, id string) (chan events.LogResponse, error) { - ch := make(chan events.LogResponse, 100) + file, info, err := r.storageClient.DownloadFileFromBucket(ctx, r.bucket, "", id) + if err != nil { + r.log.Errorw("error downloading log file from bucket", "error", err) + return nil, err + } + + ch := make(chan events.LogResponse, defaultBufferSize) + + go func() { + defer close(ch) + + buffer, version, err := r.readLineLogsV2(file, ch) + if err != nil { + ch <- events.LogResponse{Error: err} + return + } + + if version == events.LogVersionV1 { + if err = r.readLineLogsV1(ch, buffer, info.LastModified); err != nil { + ch <- events.LogResponse{Error: err} + return + } + } + }() + return ch, nil } + +func (r MinioLogsRepository) readLineLogsV2(file io.Reader, ch chan events.LogResponse) ([]byte, events.LogVersion, error) { + var buffer []byte + reader := bufio.NewReader(file) + firstLine := false + version := events.LogVersionV2 + for { + b, err := utils.ReadLongLine(reader) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + + r.log.Errorw("error getting log line", "error", err) + return nil, "", err + } + + if !firstLine { + firstLine = true + if strings.HasPrefix(string(b), logsV1Prefix) { + version = events.LogVersionV1 + } + } + + if version == events.LogVersionV1 { + buffer = append(buffer, b...) + } + + if version == events.LogVersionV2 { + var log events.Log + err = json.Unmarshal(b, &log) + if err != nil { + r.log.Errorw("error unmarshalling log line", "error", err) + ch <- events.LogResponse{Error: err} + continue + } + + ch <- events.LogResponse{Log: log} + } + } + + return buffer, version, nil +} + +func (r MinioLogsRepository) readLineLogsV1(ch chan events.LogResponse, buffer []byte, logTime time.Time) error { + var output result.ExecutionOutput + decoder := json.NewDecoder(bytes.NewBuffer(buffer)) + err := decoder.Decode(&output) + if err != nil { + r.log.Errorw("error decoding logs", "error", err) + return err + } + + reader := bufio.NewReader(bytes.NewBuffer([]byte(output.Output))) + for { + b, err := utils.ReadLongLine(reader) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + + r.log.Errorw("error getting log line", "error", err) + return err + } + + ch <- events.LogResponse{Log: events.Log{ + Time: logTime, + Content: string(b), + Version: string(events.LogVersionV1), + }} + } + + return nil +} diff --git a/pkg/logs/repository/minio_test.go b/pkg/logs/repository/minio_test.go new file mode 100644 index 00000000000..37a8b81ba1b --- /dev/null +++ b/pkg/logs/repository/minio_test.go @@ -0,0 +1,142 @@ +package repository + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/minio/minio-go/v7" + "github.com/stretchr/testify/assert" + + "github.com/kubeshop/testkube/pkg/logs/events" + "github.com/kubeshop/testkube/pkg/repository/result" + "github.com/kubeshop/testkube/pkg/storage" +) + +func TestRepository_MinioGetLogV2(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + storageClient := storage.NewMockClient(mockCtrl) + ctx := context.TODO() + + var data []byte + + eventLog1 := events.Log{ + Content: "storage logs 1", + Source: events.SourceJobPod, + Version: string(events.LogVersionV2), + } + + b, err := json.Marshal(eventLog1) + assert.NoError(t, err) + + data = append(data, b...) + data = append(data, []byte("\n")...) + + eventLog2 := events.Log{ + Content: "storage logs 2", + Source: events.SourceJobPod, + Version: string(events.LogVersionV2), + } + + b, err = json.Marshal(eventLog2) + assert.NoError(t, err) + + data = append(data, b...) + data = append(data, []byte("\n")...) + + storageClient.EXPECT().DownloadFileFromBucket(gomock.Any(), "bucket", "", "test-execution-1"). + Return(bytes.NewReader(data), minio.ObjectInfo{}, nil) + r := NewMinioRepository(storageClient, "bucket") + + tests := []struct { + name string + eventLogs []events.Log + }{ + { + name: "Test getting logs from minio", + eventLogs: []events.Log{eventLog1, eventLog2}, + }, + } + + var res []events.Log + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logs, err := r.Get(ctx, "test-execution-1") + assert.NoError(t, err) + + for out := range logs { + res = append(res, out.Log) + } + + assert.Equal(t, tt.eventLogs, res) + }) + } +} + +func TestRepository_MinioGetLogsV1(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + storageClient := storage.NewMockClient(mockCtrl) + ctx := context.TODO() + + var data []byte + + contentLog1 := "storage logs 1" + contentLog2 := "storage logs 2" + output := result.ExecutionOutput{ + Id: "id", + Name: "execution-name", + TestName: "test-name", + TestSuiteName: "testsuite-name", + Output: contentLog1 + "\n" + contentLog2, + } + + data, err := json.Marshal(output) + assert.NoError(t, err) + + current := time.Now() + storageClient.EXPECT().DownloadFileFromBucket(gomock.Any(), "bucket", "", "test-execution-1"). + Return(bytes.NewReader(data), minio.ObjectInfo{LastModified: current}, nil) + r := NewMinioRepository(storageClient, "bucket") + + tests := []struct { + name string + eventLogs []events.Log + }{ + { + name: "Test getting logs from minio", + eventLogs: []events.Log{ + { + Time: current, + Content: contentLog1, + Version: string(events.LogVersionV1), + }, + { + Time: current, + Content: contentLog2, + Version: string(events.LogVersionV1), + }, + }, + }, + } + + var res []events.Log + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logs, err := r.Get(ctx, "test-execution-1") + assert.NoError(t, err) + + for out := range logs { + res = append(res, out.Log) + } + + assert.Equal(t, tt.eventLogs, res) + }) + } +} diff --git a/pkg/logs/state/state.go b/pkg/logs/state/state.go index 577e2d86873..abc7cc5f516 100644 --- a/pkg/logs/state/state.go +++ b/pkg/logs/state/state.go @@ -7,6 +7,11 @@ import ( "github.com/nats-io/nats.go/jetstream" ) +var ( + // state not found error + ErrStateNotFound = errors.New("no state found") +) + // NewState creates new state storage func NewState(kv jetstream.KeyValue) Interface { return &State{ @@ -23,15 +28,18 @@ type State struct { func (s State) Get(ctx context.Context, key string) (LogState, error) { state, err := s.kv.Get(ctx, key) if err != nil { + if err == jetstream.ErrKeyNotFound { + return LogStateUnknown, nil + } + return LogStateUnknown, err } if len(state.Value()) == 0 { - return LogStateUnknown, errors.New("no state found") + return LogStateUnknown, ErrStateNotFound } return LogState(state.Value()[0]), nil - } // Put puts state for given key - executionId diff --git a/pkg/logs/state/state_test.go b/pkg/logs/state/state_test.go index 9d753f713fe..c19ba18d377 100644 --- a/pkg/logs/state/state_test.go +++ b/pkg/logs/state/state_test.go @@ -26,8 +26,9 @@ func TestState(t *testing.T) { s := NewState(kv) t.Run("get non existing state", func(t *testing.T) { - _, err := s.Get(ctx, "1") - assert.Error(t, err) + state1, err := s.Get(ctx, "1") + assert.NoError(t, err) + assert.Equal(t, LogStateUnknown, state1) }) t.Run("store state data and get it", func(t *testing.T) { diff --git a/pkg/repository/result/minio_output.go b/pkg/repository/result/minio_output.go index 3ae1ff6a86f..2ad4a73cda3 100644 --- a/pkg/repository/result/minio_output.go +++ b/pkg/repository/result/minio_output.go @@ -42,7 +42,7 @@ func (m *MinioRepository) GetOutput(ctx context.Context, id, testName, testSuite } func (m *MinioRepository) getOutput(ctx context.Context, id string) (ExecutionOutput, error) { - file, err := m.storage.DownloadFileFromBucket(ctx, m.bucket, "", id) + file, _, err := m.storage.DownloadFileFromBucket(ctx, m.bucket, "", id) if err != nil && err == minio.ErrArtifactsNotFound { log.DefaultLogger.Infow("output not found in minio", "id", id) return ExecutionOutput{}, nil @@ -180,7 +180,7 @@ func (m *MinioRepository) DeleteAllOutput(ctx context.Context) error { } func (m *MinioRepository) StreamOutput(ctx context.Context, executionID, testName, testSuiteName string) (reader io.Reader, err error) { - file, err := m.storage.DownloadFileFromBucket(ctx, m.bucket, "", executionID) + file, _, err := m.storage.DownloadFileFromBucket(ctx, m.bucket, "", executionID) if err != nil { return nil, err } diff --git a/pkg/repository/result/minio_output_test.go b/pkg/repository/result/minio_output_test.go index 4191cd3c2c6..f7d72e2ef2e 100644 --- a/pkg/repository/result/minio_output_test.go +++ b/pkg/repository/result/minio_output_test.go @@ -6,6 +6,7 @@ import ( "testing" gomock "github.com/golang/mock/gomock" + "github.com/minio/minio-go/v7" "github.com/stretchr/testify/assert" "github.com/kubeshop/testkube/pkg/storage" @@ -16,7 +17,8 @@ func TestGetOutputSize(t *testing.T) { storageMock := storage.NewMockClient(mockCtrl) outputClient := NewMinioOutputRepository(storageMock, nil, "test-bucket") streamContent := "test line" - storageMock.EXPECT().DownloadFileFromBucket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(strings.NewReader(streamContent), nil) + storageMock.EXPECT().DownloadFileFromBucket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(strings.NewReader(streamContent), minio.ObjectInfo{}, nil) size, err := outputClient.GetOutputSize(context.Background(), "test-id", "test-name", "test-suite-name") assert.Nil(t, err) assert.Equal(t, len(streamContent), size) diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index d4606f31782..8ed55cd2802 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -12,8 +12,12 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" + "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/log" + logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/storage" ) @@ -32,14 +36,18 @@ const ( StepMaxCount = 100 ) -func NewMongoRepository(db *mongo.Database, allowDiskUse, isDocDb bool, opts ...MongoRepositoryOpt) *MongoRepository { +func NewMongoRepository(db *mongo.Database, logGrpcClient logsclient.StreamGetter, allowDiskUse, isDocDb bool, + features featureflags.FeatureFlags, opts ...MongoRepositoryOpt) *MongoRepository { r := &MongoRepository{ db: db, ResultsColl: db.Collection(CollectionResults), SequencesColl: db.Collection(CollectionSequences), OutputRepository: NewMongoOutputRepository(db), + logGrpcClient: logGrpcClient, allowDiskUse: allowDiskUse, isDocDb: isDocDb, + features: features, + log: log.DefaultLogger, } for _, opt := range opts { @@ -53,6 +61,8 @@ func NewMongoRepositoryWithOutputRepository( db *mongo.Database, allowDiskUse bool, outputRepository OutputRepository, + logGrpcClient logsclient.StreamGetter, + features featureflags.FeatureFlags, opts ...MongoRepositoryOpt, ) *MongoRepository { r := &MongoRepository{ @@ -60,7 +70,10 @@ func NewMongoRepositoryWithOutputRepository( ResultsColl: db.Collection(CollectionResults), SequencesColl: db.Collection(CollectionSequences), OutputRepository: outputRepository, + logGrpcClient: logGrpcClient, allowDiskUse: allowDiskUse, + features: features, + log: log.DefaultLogger, } for _, opt := range opts { @@ -70,12 +83,16 @@ func NewMongoRepositoryWithOutputRepository( return r } -func NewMongoRepositoryWithMinioOutputStorage(db *mongo.Database, allowDiskUse bool, storageClient storage.Client, bucket string) *MongoRepository { +func NewMongoRepositoryWithMinioOutputStorage(db *mongo.Database, allowDiskUse bool, storageClient storage.Client, + logGrpcClient logsclient.StreamGetter, bucket string, features featureflags.FeatureFlags) *MongoRepository { repo := MongoRepository{ db: db, ResultsColl: db.Collection(CollectionResults), SequencesColl: db.Collection(CollectionSequences), + logGrpcClient: logGrpcClient, allowDiskUse: allowDiskUse, + features: features, + log: log.DefaultLogger, } repo.OutputRepository = NewMinioOutputRepository(storageClient, repo.ResultsColl, bucket) return &repo @@ -86,8 +103,11 @@ type MongoRepository struct { ResultsColl *mongo.Collection SequencesColl *mongo.Collection OutputRepository OutputRepository + logGrpcClient logsclient.StreamGetter allowDiskUse bool isDocDb bool + features featureflags.FeatureFlags + log *zap.SugaredLogger } type MongoRepositoryOpt func(*MongoRepository) @@ -104,15 +124,46 @@ func WithMongoRepositorySequenceCollection(collection *mongo.Collection) MongoRe } } +func (r *MongoRepository) getOutputFromLogServer(ctx context.Context, result *testkube.Execution) (string, error) { + if r.logGrpcClient == nil { + return "", nil + } + + if result.ExecutionResult == nil || !result.ExecutionResult.IsCompleted() { + return "", nil + } + + logs, err := r.logGrpcClient.Get(ctx, result.Id) + if err != nil { + return "", err + } + + output := "" + for log := range logs { + if log.Error != nil { + r.log.Errorw("can't get log line", "error", log.Error) + continue + } + + output += log.Log.Content + "\n" + } + + return output, nil +} + func (r *MongoRepository) Get(ctx context.Context, id string) (result testkube.Execution, err error) { err = r.ResultsColl.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result) if err != nil { return } if len(result.ExecutionResult.Output) == 0 { - result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) - if err == mongo.ErrNoDocuments { - err = nil + if r.features.LogsV2 { + result.ExecutionResult.Output, err = r.getOutputFromLogServer(ctx, &result) + } else { + result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) + if err == mongo.ErrNoDocuments { + err = nil + } } } return *result.UnscapeDots(), err @@ -124,9 +175,13 @@ func (r *MongoRepository) GetByNameAndTest(ctx context.Context, name, testName s return } if len(result.ExecutionResult.Output) == 0 { - result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) - if err == mongo.ErrNoDocuments { - err = nil + if r.features.LogsV2 { + result.ExecutionResult.Output, err = r.getOutputFromLogServer(ctx, &result) + } else { + result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) + if err == mongo.ErrNoDocuments { + err = nil + } } } return *result.UnscapeDots(), err @@ -177,9 +232,13 @@ func (r *MongoRepository) slowGetLatestByTest(ctx context.Context, testName stri } result := (&items[0]).UnscapeDots() if len(result.ExecutionResult.Output) == 0 { - result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) - if err == mongo.ErrNoDocuments { - err = nil + if r.features.LogsV2 { + result.ExecutionResult.Output, err = r.getOutputFromLogServer(ctx, result) + } else { + result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) + if err == mongo.ErrNoDocuments { + err = nil + } } } return result, err @@ -235,9 +294,13 @@ func (r *MongoRepository) GetLatestByTest(ctx context.Context, testName string) } result := (&items[0]).UnscapeDots() if len(result.ExecutionResult.Output) == 0 { - result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) - if err == mongo.ErrNoDocuments { - err = nil + if r.features.LogsV2 { + result.ExecutionResult.Output, err = r.getOutputFromLogServer(ctx, result) + } else { + result.ExecutionResult.Output, err = r.OutputRepository.GetOutput(ctx, result.Id, result.TestName, result.TestSuiteName) + if err == mongo.ErrNoDocuments { + err = nil + } } } return result, err @@ -500,7 +563,10 @@ func (r *MongoRepository) Insert(ctx context.Context, result testkube.Execution) if err != nil { return } - err = r.OutputRepository.InsertOutput(ctx, result.Id, result.TestName, result.TestSuiteName, output) + + if !r.features.LogsV2 { + err = r.OutputRepository.InsertOutput(ctx, result.Id, result.TestName, result.TestSuiteName, output) + } return } @@ -512,7 +578,10 @@ func (r *MongoRepository) Update(ctx context.Context, result testkube.Execution) if err != nil { return } - err = r.OutputRepository.UpdateOutput(ctx, result.Id, result.TestName, result.TestSuiteName, output) + + if !r.features.LogsV2 { + err = r.OutputRepository.UpdateOutput(ctx, result.Id, result.TestName, result.TestSuiteName, output) + } return } @@ -543,7 +612,9 @@ func (r *MongoRepository) UpdateResult(ctx context.Context, id string, result te return err } - err = r.OutputRepository.UpdateOutput(ctx, id, result.TestName, result.TestSuiteName, cleanOutput(output)) + if !r.features.LogsV2 { + err = r.OutputRepository.UpdateOutput(ctx, id, result.TestName, result.TestSuiteName, cleanOutput(output)) + } return } diff --git a/pkg/storage/minio/minio.go b/pkg/storage/minio/minio.go index 7a7ea70bb22..32dc4603ed2 100644 --- a/pkg/storage/minio/minio.go +++ b/pkg/storage/minio/minio.go @@ -409,9 +409,19 @@ func (c *Client) DownloadArchive(ctx context.Context, bucketFolder string, masks } // DownloadFileFromBucket downloads file from given bucket -func (c *Client) DownloadFileFromBucket(ctx context.Context, bucket, bucketFolder, file string) (io.Reader, error) { +func (c *Client) DownloadFileFromBucket(ctx context.Context, bucket, bucketFolder, file string) (io.Reader, minio.ObjectInfo, error) { c.Log.Debugw("Downloading file", "bucket", bucket, "bucketFolder", bucketFolder, "file", file) - return c.downloadFile(ctx, bucket, bucketFolder, file) + object, err := c.downloadFile(ctx, bucket, bucketFolder, file) + if err != nil { + return nil, minio.ObjectInfo{}, err + } + + info, err := object.Stat() + if err != nil { + return nil, minio.ObjectInfo{}, err + } + + return object, info, nil } // DownloadArrchiveFromBucket downloads archive from given bucket diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index d96c9a6e3eb..803e661b0f3 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -10,14 +10,14 @@ import ( ) // Client is storage client abstraction +// +//go:generate mockgen -destination=./storage_mock.go -package=storage "github.com/kubeshop/testkube/pkg/storage" Client type Client interface { ClientBucket ClientImplicitBucket } // ClientImplicitBucket is storage client abstraction where bucket name is provided from config -// -//go:generate mockgen -destination=./storage_mock.go -package=storage "github.com/kubeshop/testkube/pkg/storage" ClientImplicitBucket type ClientImplicitBucket interface { IsConnectionPossible(ctx context.Context) (bool, error) ListFiles(ctx context.Context, bucketFolder string) ([]testkube.Artifact, error) @@ -34,7 +34,7 @@ type ClientBucket interface { CreateBucket(ctx context.Context, bucket string) error DeleteBucket(ctx context.Context, bucket string, force bool) error ListBuckets(ctx context.Context) ([]string, error) - DownloadFileFromBucket(ctx context.Context, bucket, bucketFolder, file string) (io.Reader, error) + DownloadFileFromBucket(ctx context.Context, bucket, bucketFolder, file string) (io.Reader, minio.ObjectInfo, error) DownloadArchiveFromBucket(ctx context.Context, bucket, bucketFolder string, masks []string) (io.Reader, error) UploadFileToBucket(ctx context.Context, bucket, bucketFolder, filePath string, reader io.Reader, objectSize int64) error GetValidBucketName(parentType string, parentName string) string diff --git a/pkg/storage/storage_mock.go b/pkg/storage/storage_mock.go index addaa2b5d7a..ed3b80b6737 100644 --- a/pkg/storage/storage_mock.go +++ b/pkg/storage/storage_mock.go @@ -139,12 +139,13 @@ func (mr *MockClientMockRecorder) DownloadFile(arg0, arg1, arg2 interface{}) *go } // DownloadFileFromBucket mocks base method. -func (m *MockClient) DownloadFileFromBucket(arg0 context.Context, arg1, arg2, arg3 string) (io.Reader, error) { +func (m *MockClient) DownloadFileFromBucket(arg0 context.Context, arg1, arg2, arg3 string) (io.Reader, minio.ObjectInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DownloadFileFromBucket", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(io.Reader) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(minio.ObjectInfo) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // DownloadFileFromBucket indicates an expected call of DownloadFileFromBucket. From a63eb4bd55ef1cf75c98d75ff17ca215ee6acdde Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 7 Feb 2024 08:17:02 +0100 Subject: [PATCH 070/234] fix: grpc client creates endless loops (#4976) --- pkg/logs/client/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/logs/client/client.go b/pkg/logs/client/client.go index 2c0aeddd8a4..472d55fde1b 100644 --- a/pkg/logs/client/client.go +++ b/pkg/logs/client/client.go @@ -72,7 +72,7 @@ func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse } else if err != nil { log.Errorw("error receiving log response", "error", err) ch <- events.LogResponse{Error: err} - continue + return } // send to the channel From 129470bed295f17f8dfe00a53d234bcd07c40783 Mon Sep 17 00:00:00 2001 From: nicufk Date: Wed, 7 Feb 2024 13:28:04 +0200 Subject: [PATCH 071/234] fix: minor renames for minio adapter (#4974) * fix: minor renames for minio adapter * fix: logs * fix: unused var --- pkg/logs/adapter/minio.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/logs/adapter/minio.go b/pkg/logs/adapter/minio.go index d8b13302ab6..12a48708709 100644 --- a/pkg/logs/adapter/minio.go +++ b/pkg/logs/adapter/minio.go @@ -23,10 +23,10 @@ const ( var _ Adapter = &MinioAdapter{} -type ErrMinioConsumerDisconnected struct { +type ErrMinioAdapterDisconnected struct { } -func (e ErrMinioConsumerDisconnected) Error() string { +func (e ErrMinioAdapterDisconnected) Error() string { return "minio consumer disconnected" } @@ -38,11 +38,11 @@ func (e ErrIdNotFound) Error() string { return fmt.Sprintf("id %s not found", e.Id) } -type ErrChucnkTooBig struct { +type ErrChunckTooBig struct { Length int } -func (e ErrChucnkTooBig) Error() string { +func (e ErrChunckTooBig) Error() string { return fmt.Sprintf("chunk too big: %d", e.Length) } @@ -51,7 +51,7 @@ type BufferInfo struct { Part int } -// MinioConsumer creates new MinioSubscriber which will send data to local MinIO bucket +// NewMinioAdapter creates new MinioAdapter which will send data to local MinIO bucket func NewMinioAdapter(endpoint, accessKeyID, secretAccessKey, region, token, bucket string, ssl, skipVerify bool, certFile, keyFile, caFile string) (*MinioAdapter, error) { ctx := context.TODO() opts := minioconnecter.GetTLSOptions(ssl, skipVerify, certFile, keyFile, caFile) @@ -106,7 +106,7 @@ func (s *MinioAdapter) Notify(ctx context.Context, id string, e events.Log) erro s.Log.Debugw("minio consumer notify", "id", id, "event", e) if s.disconnected { s.Log.Debugw("minio consumer disconnected", "id", id) - return ErrMinioConsumerDisconnected{} + return ErrMinioAdapterDisconnected{} } buffInfo, ok := s.GetBuffInfo(id) @@ -122,7 +122,7 @@ func (s *MinioAdapter) Notify(ctx context.Context, id string, e events.Log) erro if len(chunckToAdd) > defaultWriteSize { s.Log.Warnw("chunck too big", "length", len(chunckToAdd)) - return ErrChucnkTooBig{len(chunckToAdd)} + return ErrChunckTooBig{len(chunckToAdd)} } chunckToAdd = append(chunckToAdd, []byte("\n")...) @@ -182,8 +182,7 @@ func (s *MinioAdapter) combineData(ctxt context.Context, minioClient *minio.Clie s.Log.Errorw("error putting object", "err", err) return err } - - s.Log.Debugw("data combined", "id", id, "s.bucket", s.bucket, "parts", parts, "uploadinfo", info) + s.Log.Debugw("put object successfully", "id", id, "s.bucket", s.bucket, "parts", parts, "uploadInfo", info) if deleteIntermediaryData { for i := 0; i < parts; i++ { From aff493b54e9fe95f8d966d48c517e61d5e6df4cc Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 7 Feb 2024 16:47:25 +0300 Subject: [PATCH 072/234] fix: args mode replace --- api/v1/testkube.yaml | 2 ++ cmd/kubectl-testkube/commands/tests/create.go | 2 +- cmd/kubectl-testkube/commands/tests/run.go | 2 +- docs/docs/articles/creating-tests.md | 6 +++--- docs/docs/cli/testkube_create_test.md | 2 +- docs/docs/cli/testkube_generate_tests-crds.md | 2 +- docs/docs/cli/testkube_run_test.md | 2 +- docs/docs/cli/testkube_update_test.md | 2 +- pkg/api/v1/testkube/model_test_content_extended.go | 1 + pkg/executor/containerexecutor/containerexecutor.go | 2 +- 10 files changed, 13 insertions(+), 10 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 91a161f40db..145f99a9fc7 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -4224,6 +4224,7 @@ components: enum: - append - override + - replace variables: $ref: "#/components/schemas/Variables" isVariablesFileUploaded: @@ -4829,6 +4830,7 @@ components: enum: - append - override + - replace image: type: string description: container image, executor will run inside this image diff --git a/cmd/kubectl-testkube/commands/tests/create.go b/cmd/kubectl-testkube/commands/tests/create.go index 4a4ca6e0d95..eaecf6ebc0b 100644 --- a/cmd/kubectl-testkube/commands/tests/create.go +++ b/cmd/kubectl-testkube/commands/tests/create.go @@ -233,7 +233,7 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateCommonFlags) { cmd.Flags().StringVarP(&flags.Schedule, "schedule", "", "", "test schedule in a cron job form: * * * * *") cmd.Flags().StringArrayVar(&flags.Command, "command", []string{}, "command passed to image in executor") cmd.Flags().StringArrayVarP(&flags.ExecutorArgs, "executor-args", "", []string{}, "executor binary additional arguments") - cmd.Flags().StringVarP(&flags.ArgsMode, "args-mode", "", "append", "usage mode for arguments. one of append|override") + cmd.Flags().StringVarP(&flags.ArgsMode, "args-mode", "", "append", "usage mode for arguments. one of append|override|replace") cmd.Flags().StringVarP(&flags.ExecutionName, "execution-name", "", "", "execution name, if empty will be autogenerated") cmd.Flags().StringVarP(&flags.VariablesFile, "variables-file", "", "", "variables file path, e.g. postman env file - will be passed to executor if supported") cmd.Flags().StringToStringVarP(&flags.Envs, "env", "", map[string]string{}, "envs in a form of name1=val1 passed to executor") diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index f16dd7fb472..f12e08075cc 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -348,7 +348,7 @@ func NewRunTestCmd() *cobra.Command { cmd.Flags().StringToStringVarP(&secretVariables, "secret-variable", "s", map[string]string{}, "execution secret variable passed to executor") cmd.Flags().StringArrayVar(&command, "command", []string{}, "command passed to image in executor") cmd.Flags().StringArrayVarP(&binaryArgs, "args", "", []string{}, "executor binary additional arguments") - cmd.Flags().StringVarP(&argsMode, "args-mode", "", "append", "usage mode for argumnets. one of append|override") + cmd.Flags().StringVarP(&argsMode, "args-mode", "", "append", "usage mode for argumnets. one of append|override|replace") cmd.Flags().BoolVarP(&watchEnabled, "watch", "f", false, "watch for changes after start") cmd.Flags().StringVar(&downloadDir, "download-dir", "artifacts", "download dir") cmd.Flags().BoolVarP(&downloadArtifactsEnabled, "download-artifacts", "d", false, "download artifacts automatically") diff --git a/docs/docs/articles/creating-tests.md b/docs/docs/articles/creating-tests.md index c2343e43de8..1f098162acd 100644 --- a/docs/docs/articles/creating-tests.md +++ b/docs/docs/articles/creating-tests.md @@ -353,7 +353,7 @@ There are many differences between `--variables-file` and `--copy-files`. The fo ### Redefining the Prebuilt Executor Command and Arguments -Each of Testkube Prebuilt executors has a default command and arguments it uses to execute the test. They are provided as a part of Executor CRD and can be either overidden or appended during test creation or execution, for example: +Each of Testkube Prebuilt executors has a default command and arguments it uses to execute the test. They are provided as a part of Executor CRD and can be either overidden(replaced) or appended during test creation or execution, for example: ```sh testkube create test --name maven-example-test --git-uri https://github.com/kubeshop/testkube-executor-maven.git --git-path examples/hello-maven --type maven/test --git-branch main --command "mvn" --args-mode "override" --executor-args="--settings -Duser.home " @@ -381,13 +381,13 @@ There are two modes to pass arguments to the executor: ```sh $ testkube create test --help ... - --args-mode string usage mode for arguments. one of append|override (default "append") + --args-mode string usage mode for arguments. one of append|override|replace (default "append") ... ``` By default, `--args-mode` is set to `append`, which means that the default list will be kept, and whatever is set in `--executor-args` will be added to the end. -The `override` mode will ignore the default arguments and use only what is set in `--executor-args`. If there are default values in between chevrons (`<>`), they can be reused in `--executor-args`. +The `override` or `replace` mode will ignore the default arguments and use only what is set in `--executor-args`. If there are default values in between chevrons (`<>`), they can be reused in `--executor-args`. When using `--args-mode` with `testkube run test ...` pay attention to set the arguments via the `--args` flag, not `--executor-args`. diff --git a/docs/docs/cli/testkube_create_test.md b/docs/docs/cli/testkube_create_test.md index 0015a3a7f70..bfa7019fbdb 100644 --- a/docs/docs/cli/testkube_create_test.md +++ b/docs/docs/cli/testkube_create_test.md @@ -13,7 +13,7 @@ testkube create test [flags] ### Options ``` - --args-mode string usage mode for arguments. one of append|override (default "append") + --args-mode string usage mode for arguments. one of append|override|replace (default "append") --artifact-dir stringArray artifact dirs for scraping --artifact-mask stringArray regexp to filter scraped artifacts, single or comma separated, like report/.* or .*\.json,.*\.js$ --artifact-omit-folder-per-execution don't store artifacts in execution folder diff --git a/docs/docs/cli/testkube_generate_tests-crds.md b/docs/docs/cli/testkube_generate_tests-crds.md index f34efd49b9a..66b4a4c1836 100644 --- a/docs/docs/cli/testkube_generate_tests-crds.md +++ b/docs/docs/cli/testkube_generate_tests-crds.md @@ -13,7 +13,7 @@ testkube generate tests-crds [flags] ### Options ``` - --args-mode string usage mode for arguments. one of append|override (default "append") + --args-mode string usage mode for arguments. one of append|override|replace (default "append") --artifact-dir stringArray artifact dirs for scraping --artifact-mask stringArray regexp to filter scraped artifacts, single or comma separated, like report/.* or .*\.json,.*\.js$ --artifact-omit-folder-per-execution don't store artifacts in execution folder diff --git a/docs/docs/cli/testkube_run_test.md b/docs/docs/cli/testkube_run_test.md index 62b186732b0..b2052f7b9db 100644 --- a/docs/docs/cli/testkube_run_test.md +++ b/docs/docs/cli/testkube_run_test.md @@ -14,7 +14,7 @@ testkube run test [flags] ``` --args stringArray executor binary additional arguments - --args-mode string usage mode for argumnets. one of append|override (default "append") + --args-mode string usage mode for argumnets. one of append|override|replace (default "append") --artifact-dir stringArray artifact dirs for scraping --artifact-mask stringArray regexp to filter scraped artifacts, single or comma separated, like report/.* or .*\.json,.*\.js$ --artifact-omit-folder-per-execution don't store artifacts in execution folder diff --git a/docs/docs/cli/testkube_update_test.md b/docs/docs/cli/testkube_update_test.md index cc472b3af10..16654161f10 100644 --- a/docs/docs/cli/testkube_update_test.md +++ b/docs/docs/cli/testkube_update_test.md @@ -13,7 +13,7 @@ testkube update test [flags] ### Options ``` - --args-mode string usage mode for arguments. one of append|override (default "append") + --args-mode string usage mode for arguments. one of append|override|replace (default "append") --artifact-dir stringArray artifact dirs for scraping --artifact-mask stringArray regexp to filter scraped artifacts, single or comma separated, like report/.* or .*\.json,.*\.js$ --artifact-omit-folder-per-execution don't store artifacts in execution folder diff --git a/pkg/api/v1/testkube/model_test_content_extended.go b/pkg/api/v1/testkube/model_test_content_extended.go index b37ec7f0807..a18a91192be 100644 --- a/pkg/api/v1/testkube/model_test_content_extended.go +++ b/pkg/api/v1/testkube/model_test_content_extended.go @@ -19,6 +19,7 @@ type ArgsModeType string const ( ArgsModeTypeAppend ArgsModeType = "append" ArgsModeTypeOverride ArgsModeType = "override" + ArgsModeTypeReplace ArgsModeType = "replace" ) var ErrTestContentTypeNotFile = fmt.Errorf("unsupported content type use one of: file-uri, git-file, string") diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index 00f5f12e7ac..148104f7586 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -580,7 +580,7 @@ func NewJobOptionsFromExecutionOptions(options client.ExecuteOptions) *JobOption args = append(options.ExecutorSpec.Args, args...) } - if argsMode == string(testkube.ArgsModeTypeOverride) { + if argsMode == string(testkube.ArgsModeTypeOverride) || argsMode == string(testkube.ArgsModeTypeReplace) { args = options.Request.Args if options.TestSpec.ExecutionRequest != nil && len(args) == 0 { args = options.TestSpec.ExecutionRequest.Args From da0e1ac785c81d5c3d1b34e899c014566093fc9d Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 7 Feb 2024 18:44:34 +0300 Subject: [PATCH 073/234] Update docs/docs/articles/creating-tests.md Co-authored-by: Lilla Vass --- docs/docs/articles/creating-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/articles/creating-tests.md b/docs/docs/articles/creating-tests.md index 1f098162acd..ce5fd28d5e7 100644 --- a/docs/docs/articles/creating-tests.md +++ b/docs/docs/articles/creating-tests.md @@ -353,7 +353,7 @@ There are many differences between `--variables-file` and `--copy-files`. The fo ### Redefining the Prebuilt Executor Command and Arguments -Each of Testkube Prebuilt executors has a default command and arguments it uses to execute the test. They are provided as a part of Executor CRD and can be either overidden(replaced) or appended during test creation or execution, for example: +Each of Testkube Prebuilt executors has a default command and arguments it uses to execute the test. They are provided as a part of Executor CRD and can be either overridden, replaced, or appended during test creation or execution, for example: ```sh testkube create test --name maven-example-test --git-uri https://github.com/kubeshop/testkube-executor-maven.git --git-path examples/hello-maven --type maven/test --git-branch main --command "mvn" --args-mode "override" --executor-args="--settings -Duser.home " From eecfa9f2add84450ea5d8b8a0479eeb3aa9f53ea Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 7 Feb 2024 19:24:17 +0100 Subject: [PATCH 074/234] fix: log finish handled correctly (#4978) * fix: log finish handled correctly * chore: use init instead of copied function * fix: generate mocks * fix: tests --- cmd/kubectl-testkube/commands/tests/run.go | 2 +- cmd/logs/main.go | 2 +- internal/app/api/v1/executions.go | 6 +- pkg/logs/client/client.go | 22 +++- pkg/logs/client/interface.go | 6 ++ pkg/logs/client/mock_stream.go | 14 +++ pkg/logs/client/stream.go | 69 ++++++++---- pkg/logs/client/stream_test.go | 116 +++++++++++++++------ pkg/logs/events.go | 22 ++-- pkg/logs/events/events.go | 12 +++ pkg/logs/events_test.go | 45 ++++++-- pkg/logs/logsserver.go | 1 - pkg/logs/logsserver_test.go | 2 +- pkg/logs/service.go | 7 +- 14 files changed, 245 insertions(+), 81 deletions(-) diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index f12e08075cc..fde809b9ce2 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -305,7 +305,7 @@ func NewRunTestCmd() *cobra.Command { info, err := client.GetServerInfo() ui.ExitOnError("getting server info", err) - if info.Features.LogsV2 { + if info.Features != nil && info.Features.LogsV2 { if err = watchLogsV2(execution.Id, silentMode, client); err != nil { execErrors = append(execErrors, err) } diff --git a/cmd/logs/main.go b/cmd/logs/main.go index 1d08a030cbf..fb2f6bb7b03 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -84,7 +84,7 @@ func main() { kv := Must(js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: cfg.KVBucketName})) state := state.NewState(kv) - svc := logs.NewLogsService(nc, js, state). + svc := logs.NewLogsService(nc, js, state, logStream). WithHttpAddress(cfg.HttpAddress). WithGrpcAddress(cfg.GrpcAddress). WithLogsRepositoryFactory(repository.NewJsMinioFactory(minioClient, cfg.StorageBucket, logStream)) diff --git a/internal/app/api/v1/executions.go b/internal/app/api/v1/executions.go index 473a782a176..df7d71a9568 100644 --- a/internal/app/api/v1/executions.go +++ b/internal/app/api/v1/executions.go @@ -234,6 +234,8 @@ func (s *TestkubeAPI) ExecutionLogsStreamHandlerV2() fiber.Handler { l.Debugw("sending log line to websocket", "line", logLine.Log) _ = c.WriteJSON(logLine.Log) } + + l.Debug("stream stopped in v2 logs handler") }) } @@ -293,7 +295,7 @@ func (s *TestkubeAPI) ExecutionLogsHandlerV2() fiber.Handler { executionID := c.Params("executionID") - s.Log.Debug("getting logs", "executionID", executionID) + s.Log.Debugw("getting logs", "executionID", executionID) ctx := c.Context() @@ -764,4 +766,6 @@ func (s *TestkubeAPI) streamLogsFromLogServer(logs chan events.LogResponse, w *b _, _ = fmt.Fprintf(w, "\n") _ = w.Flush() } + + s.Log.Debugw("logs streaming stopped") } diff --git a/pkg/logs/client/client.go b/pkg/logs/client/client.go index 472d55fde1b..1b750d94815 100644 --- a/pkg/logs/client/client.go +++ b/pkg/logs/client/client.go @@ -35,9 +35,11 @@ type GrpcClient struct { // Get returns channel with log stream chunks for given execution id connects through GRPC to log service func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse, error) { ch := make(chan events.LogResponse, buffer) + log := c.log.With("id", id) log.Debugw("getting logs", "address", c.address) + go func() { // Contact the server and print out its response. ctx, cancel := context.WithTimeout(context.Background(), requestDeadline) @@ -63,20 +65,32 @@ func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse } log.Debugw("client start streaming") + defer func() { + log.Debugw("client stopped streaming") + }() + for { l, err := r.Recv() - log.Debugw("received log chunk from client", "log", l, "error", err) if err == io.EOF { - log.Debugw("client stream finished", "error", err) + log.Infow("client stream finished", "error", err) return } else if err != nil { - log.Errorw("error receiving log response", "error", err) ch <- events.LogResponse{Error: err} + log.Errorw("error receiving log response", "error", err) + return + } + + logChunk := pb.MapFromPB(l) + + // catch finish event + if events.IsFinished(&logChunk) { + log.Infow("received finish", "log", l) return } + log.Debugw("grpc client log", "log", l) // send to the channel - ch <- events.LogResponse{Log: pb.MapFromPB(l)} + ch <- events.LogResponse{Log: logChunk} } }() diff --git a/pkg/logs/client/interface.go b/pkg/logs/client/interface.go index edeed1bc248..a026f77b8b5 100644 --- a/pkg/logs/client/interface.go +++ b/pkg/logs/client/interface.go @@ -19,6 +19,7 @@ type Stream interface { StreamPusher StreamTrigger StreamGetter + StreamFinisher } //go:generate mockgen -destination=./mock_initializedstreampusher.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" InitializedStreamPusher @@ -49,6 +50,11 @@ type StreamPusher interface { PushBytes(ctx context.Context, id string, bytes []byte) error } +type StreamFinisher interface { + // Finish sends termination log message + Finish(ctx context.Context, id string) error +} + // StreamGetter interface for getting logs stream channel // //go:generate mockgen -destination=./mock_streamgetter.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" StreamGetter diff --git a/pkg/logs/client/mock_stream.go b/pkg/logs/client/mock_stream.go index eb0cb02cbe2..e0d010203b7 100644 --- a/pkg/logs/client/mock_stream.go +++ b/pkg/logs/client/mock_stream.go @@ -35,6 +35,20 @@ func (m *MockStream) EXPECT() *MockStreamMockRecorder { return m.recorder } +// Finish mocks base method. +func (m *MockStream) Finish(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Finish", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Finish indicates an expected call of Finish. +func (mr *MockStreamMockRecorder) Finish(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finish", reflect.TypeOf((*MockStream)(nil).Finish), arg0, arg1) +} + // Get mocks base method. func (m *MockStream) Get(arg0 context.Context, arg1 string) (chan events.LogResponse, error) { m.ctrl.T.Helper() diff --git a/pkg/logs/client/stream.go b/pkg/logs/client/stream.go index 08a23efc445..867c36722c3 100644 --- a/pkg/logs/client/stream.go +++ b/pkg/logs/client/stream.go @@ -50,6 +50,10 @@ func (c NatsLogStream) Init(ctx context.Context, id string) (StreamMetadata, err } +func (c NatsLogStream) Finish(ctx context.Context, id string) error { + return c.Push(ctx, id, events.NewFinishLog()) +} + // Push log chunk to NATS stream func (c NatsLogStream) Push(ctx context.Context, id string, log *events.Log) error { b, err := json.Marshal(log) @@ -81,43 +85,68 @@ func (c NatsLogStream) Get(ctx context.Context, id string) (chan events.LogRespo ch := make(chan events.LogResponse) name := fmt.Sprintf("%s%s%s", ConsumerPrefix, id, utils.RandAlphanum(6)) - cons, err := c.js.CreateOrUpdateConsumer(ctx, c.streamName(id), jetstream.ConsumerConfig{ - Name: name, - Durable: name, - DeliverPolicy: jetstream.DeliverAllPolicy, - }) + cons, err := c.js.CreateOrUpdateConsumer( + ctx, + c.streamName(id), + jetstream.ConsumerConfig{ + Name: name, + Durable: name, + DeliverPolicy: jetstream.DeliverAllPolicy, + }, + ) if err != nil { return ch, err } log := c.log.With("id", id) - cons.Consume(func(msg jetstream.Msg) { - log.Debugw("got message", "data", string(msg.Data())) - - // deliver to subscriber - logChunk := events.Log{} - err := json.Unmarshal(msg.Data(), &logChunk) - if err != nil { - if err := msg.Nak(); err != nil { - log.Errorw("error nacking message", "error", err) + + go func() { + defer close(ch) + for { + msg, err := cons.Next() + if err != nil { ch <- events.LogResponse{Error: err} return } - return + + if finished := c.handleJetstreamMessage(log, ch, msg); finished { + return + } } + }() - if err := msg.Ack(); err != nil { + return ch, nil +} + +func (c NatsLogStream) handleJetstreamMessage(log *zap.SugaredLogger, ch chan events.LogResponse, msg jetstream.Msg) (finish bool) { + log.Debugw("got message", "data", string(msg.Data())) + + // deliver to subscriber + logChunk := events.Log{} + err := json.Unmarshal(msg.Data(), &logChunk) + if err != nil { + if err := msg.Nak(); err != nil { + log.Errorw("error nacking message", "error", err) ch <- events.LogResponse{Error: err} - log.Errorw("error acking message", "error", err) return } + return + } - ch <- events.LogResponse{Log: logChunk} - }) + if err := msg.Ack(); err != nil { + ch <- events.LogResponse{Error: err} + log.Errorw("error acking message", "error", err) + return + } - return ch, nil + if events.IsFinished(&logChunk) { + return true + } + + ch <- events.LogResponse{Log: logChunk} + return } // syncCall sends request to given subject and waits for response diff --git a/pkg/logs/client/stream_test.go b/pkg/logs/client/stream_test.go index 03cafc96eac..2adc57ec2af 100644 --- a/pkg/logs/client/stream_test.go +++ b/pkg/logs/client/stream_test.go @@ -2,54 +2,112 @@ package client import ( "context" + "fmt" "testing" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/kubeshop/testkube/pkg/event/bus" + "github.com/kubeshop/testkube/pkg/logs/events" ) func TestStream_StartStop(t *testing.T) { - ns, nc := bus.TestServerWithConnection() - defer ns.Shutdown() + t.Run("start and stop events are triggered", func(t *testing.T) { + // given nats server with jetstream + ns, nc := bus.TestServerWithConnection() + defer ns.Shutdown() - id := "111" + id := "111" - ctx := context.Background() + ctx := context.Background() - client, err := NewNatsLogStream(nc) - assert.NoError(t, err) + // and log stream + client, err := NewNatsLogStream(nc) + assert.NoError(t, err) - meta, err := client.Init(ctx, id) - assert.NoError(t, err) - assert.Equal(t, StreamPrefix+id, meta.Name) + // initialized + meta, err := client.Init(ctx, id) + assert.NoError(t, err) + assert.Equal(t, StreamPrefix+id, meta.Name) - err = client.PushBytes(ctx, id, []byte(`{"resourceId":"hello 1"}`)) - assert.NoError(t, err) + // when data are passed + err = client.PushBytes(ctx, id, []byte(`{"resourceId":"hello 1"}`)) + assert.NoError(t, err) - var startReceived, stopReceived bool + var startReceived, stopReceived bool - _, err = nc.Subscribe(StartSubject, func(m *nats.Msg) { - m.Respond([]byte("ok")) - startReceived = true - }) - assert.NoError(t, err) - _, err = nc.Subscribe(StopSubject, func(m *nats.Msg) { - m.Respond([]byte("ok")) - stopReceived = true + _, err = nc.Subscribe(StartSubject, func(m *nats.Msg) { + m.Respond([]byte("ok")) + startReceived = true + }) + assert.NoError(t, err) + _, err = nc.Subscribe(StopSubject, func(m *nats.Msg) { + m.Respond([]byte("ok")) + stopReceived = true + }) + + assert.NoError(t, err) + + // and stream started + d, err := client.Start(ctx, id) + assert.NoError(t, err) + assert.Equal(t, "ok", string(d.Message)) + + // and stream stopped + d, err = client.Stop(ctx, id) + assert.NoError(t, err) + assert.Equal(t, "ok", string(d.Message)) + + // then start/stop subjects should be notified + assert.True(t, startReceived) + assert.True(t, stopReceived) }) - assert.NoError(t, err) + t.Run("channel is closed when log is finished", func(t *testing.T) { + // given nats server with jetstream + ns, nc := bus.TestServerWithConnection() + defer ns.Shutdown() + + id := "222" - d, err := client.Start(ctx, id) - assert.NoError(t, err) - assert.Equal(t, "ok", string(d.Message)) + ctx := context.Background() - d, err = client.Stop(ctx, id) - assert.NoError(t, err) - assert.Equal(t, "ok", string(d.Message)) + // and log stream + client, err := NewNatsLogStream(nc) + assert.NoError(t, err) - assert.True(t, startReceived) - assert.True(t, stopReceived) + // initialized + meta, err := client.Init(ctx, id) + assert.NoError(t, err) + assert.Equal(t, StreamPrefix+id, meta.Name) + + // when messages are sent + err = client.Push(ctx, id, events.NewLog("log line 1")) + assert.NoError(t, err) + err = client.Push(ctx, id, events.NewLog("log line 2")) + assert.NoError(t, err) + err = client.Push(ctx, id, events.NewLog("log line 3")) + assert.NoError(t, err) + // and stream is set as finished + err = client.Finish(ctx, id) + assert.NoError(t, err) + + // and replay of messages is done + ch, err := client.Get(ctx, id) + assert.NoError(t, err) + + messagesCount := 0 + + for l := range ch { + fmt.Printf("%+v\n", l) + messagesCount++ + if events.IsFinished(&l.Log) { + break + } + } + + // then + assert.Equal(t, 3, messagesCount) + }) } diff --git a/pkg/logs/events.go b/pkg/logs/events.go index 4aff434700a..4ddfe1fa194 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -67,15 +67,6 @@ func (ls *LogsService) initConsumer(ctx context.Context, a adapter.Adapter, stre }) } -func (ls *LogsService) createStream(ctx context.Context, id string) (jetstream.Stream, error) { - // create stream for incoming logs - streamName := StreamPrefix + id - return ls.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{ - Name: streamName, - Storage: jetstream.FileStorage, // durable stream as we can hit mem limit - }) -} - // handleMessage will handle incoming message from logs stream and proxy it to given adapter func (ls *LogsService) handleMessage(ctx context.Context, a adapter.Adapter, id string) func(msg jetstream.Msg) { log := ls.log.With("id", id, "adapter", a.Name()) @@ -122,7 +113,8 @@ func (ls *LogsService) handleStart(ctx context.Context, subject string) func(msg log := ls.log.With("id", id, "event", "start") ls.state.Put(ctx, id, state.LogStatePending) - s, err := ls.createStream(ctx, id) + + s, err := ls.logStream.Init(ctx, id) if err != nil { ls.log.Errorw("error creating stream", "error", err, "id", id) return @@ -216,8 +208,8 @@ func (ls *LogsService) handleStop(ctx context.Context, group string) func(msg *n wg.Add(1) stopped++ consumer := c.(Consumer) - go ls.stopConsumer(ctx, &wg, consumer, adapter, id) + go ls.stopConsumer(ctx, &wg, consumer, adapter, id) } wg.Wait() @@ -243,6 +235,14 @@ func (ls *LogsService) stopConsumer(ctx context.Context, wg *sync.WaitGroup, con maxRetries = 50 ) + defer func() { + // send log finish message as consumer listening for logs needs to be closed + err = ls.logStream.Finish(ctx, id) + if err != nil { + ls.log.Errorw("log stream finish error") + } + }() + l.Debugw("stopping consumer", "name", consumer.Name) for { diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 92e4b11ce03..10f6a106fe3 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -48,6 +48,18 @@ type LogResponse struct { type Log testkube.LogV2 +func NewFinishLog() *Log { + return &Log{ + Content: "processing logs finished", + Type_: "finish", + Source: "log-server", + } +} + +func IsFinished(log *Log) bool { + return log.Type_ == "finish" +} + func NewErrorLog(err error) *Log { var msg string if err != nil { diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 13630a7ca05..2c9727555ae 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -48,8 +48,11 @@ func TestLogs_EventsFlow(t *testing.T) { // and logs state manager state := state.NewState(kv) + logsStream, err := client.NewNatsLogStream(nc) + assert.NoError(t, err) + // and initialized log service - log := NewLogsService(nc, js, state). + log := NewLogsService(nc, js, state, logsStream). WithRandomPort() // given example adapters @@ -119,8 +122,11 @@ func TestLogs_EventsFlow(t *testing.T) { // and logs state manager state := state.NewState(kv) + logsStream, err := client.NewNatsLogStream(nc) + assert.NoError(t, err) + // and initialized log service - log := NewLogsService(nc, js, state). + log := NewLogsService(nc, js, state, logsStream). WithRandomPort() // given example adapter @@ -195,20 +201,26 @@ func TestLogs_EventsFlow(t *testing.T) { // and logs state manager state := state.NewState(kv) + logsStream, err := client.NewNatsLogStream(nc) + assert.NoError(t, err) + // and initialized log service - log := NewLogsService(nc, js, state). + log := NewLogsService(nc, js, state, logsStream). WithRandomPort() // given example adapter - a := NewMockAdapter() + a1 := NewMockAdapter() + a2 := NewMockAdapter() + a3 := NewMockAdapter() + a4 := NewMockAdapter() - messagesCount := 10000 + messagesCount := 1000 // with 4 adapters (the same adapter is added 4 times so it'll receive 4 times more messages) - log.AddAdapter(a) - log.AddAdapter(a) - log.AddAdapter(a) - log.AddAdapter(a) + log.AddAdapter(a1) + log.AddAdapter(a2) + log.AddAdapter(a3) + log.AddAdapter(a4) // and log service running go func() { @@ -241,7 +253,11 @@ func TestLogs_EventsFlow(t *testing.T) { _, err = stream.Stop(ctx, id) assert.NoError(t, err) - assertMessagesCount(t, a, 4*messagesCount) + // then each adapter should receive messages + assertMessagesCount(t, a1, messagesCount) + assertMessagesCount(t, a2, messagesCount) + assertMessagesCount(t, a3, messagesCount) + assertMessagesCount(t, a4, messagesCount) }) @@ -268,8 +284,11 @@ func TestLogs_EventsFlow(t *testing.T) { // and logs state manager state := state.NewState(kv) + logsStream, err := client.NewNatsLogStream(nc) + assert.NoError(t, err) + // and initialized log service - log := NewLogsService(nc, js, state). + log := NewLogsService(nc, js, state, logsStream). WithRandomPort() // given example adapters @@ -351,6 +370,10 @@ func (s *MockAdapter) Init(ctx context.Context, id string) error { } func (s *MockAdapter) Notify(ctx context.Context, id string, e events.Log) error { + // don't count finished logs + if events.IsFinished(&e) { + return nil + } s.lock.Lock() defer s.lock.Unlock() diff --git a/pkg/logs/logsserver.go b/pkg/logs/logsserver.go index 70810c48bd3..c22321232c4 100644 --- a/pkg/logs/logsserver.go +++ b/pkg/logs/logsserver.go @@ -59,5 +59,4 @@ func (s LogsServer) Logs(req *pb.LogRequest, stream pb.LogsService_LogsServer) e s.log.Debugw("stream finished", "id", req.ExecutionId) return nil - } diff --git a/pkg/logs/logsserver_test.go b/pkg/logs/logsserver_test.go index c3031106b46..8e6ef73e323 100644 --- a/pkg/logs/logsserver_test.go +++ b/pkg/logs/logsserver_test.go @@ -23,7 +23,7 @@ func TestGRPC_Server(t *testing.T) { state := &StateMock{state: state.LogStatePending} - ls := NewLogsService(nil, nil, state). + ls := NewLogsService(nil, nil, state, nil). WithLogsRepositoryFactory(LogsFactoryMock{}). WithRandomPort() diff --git a/pkg/logs/service.go b/pkg/logs/service.go index ddea90df5a1..c1764dc04f3 100644 --- a/pkg/logs/service.go +++ b/pkg/logs/service.go @@ -20,6 +20,7 @@ import ( "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/logs/adapter" + "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/logs/pb" "github.com/kubeshop/testkube/pkg/logs/repository" "github.com/kubeshop/testkube/pkg/logs/state" @@ -32,7 +33,7 @@ const ( defaultStopPauseInterval = 200 * time.Millisecond ) -func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interface) *LogsService { +func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interface, stream client.Stream) *LogsService { return &LogsService{ nats: nats, adapters: []adapter.Adapter{}, @@ -44,6 +45,7 @@ func NewLogsService(nats *nats.Conn, js jetstream.JetStream, state state.Interfa consumerInstances: sync.Map{}, state: state, stopPauseInterval: defaultStopPauseInterval, + logStream: stream, } } @@ -54,6 +56,9 @@ type LogsService struct { js jetstream.JetStream adapters []adapter.Adapter + // logStream to manage and send data to logs streams + logStream client.Stream + Ready chan struct{} // grpcAddress is address for grpc server From 47241f3c1d1d3fa209e7aac1e2c4ea9a1ec7c87c Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 8 Feb 2024 10:25:54 +0100 Subject: [PATCH 075/234] fix: duplicated logs when watching results in real time (#4981) --- .../commands/common/render/common.go | 15 +++++++++++---- cmd/kubectl-testkube/commands/tests/executions.go | 2 +- .../commands/tests/renderer/execution_obj.go | 2 +- cmd/kubectl-testkube/commands/tests/run.go | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cmd/kubectl-testkube/commands/common/render/common.go b/cmd/kubectl-testkube/commands/common/render/common.go index af161a62b92..21e3f194043 100644 --- a/cmd/kubectl-testkube/commands/common/render/common.go +++ b/cmd/kubectl-testkube/commands/common/render/common.go @@ -65,7 +65,7 @@ func RenderPrettyList(obj ui.TableData, w io.Writer) error { return nil } -func RenderExecutionResult(client client.Client, execution *testkube.Execution, logsOnly bool) error { +func RenderExecutionResult(client client.Client, execution *testkube.Execution, logsOnly bool, showLogs bool) error { result := execution.ExecutionResult if result == nil { ui.Errf("got execution without `Result`") @@ -81,7 +81,10 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, ui.Warn("Test execution started") case result.IsPassed(): - ui.Info(result.Output) + if showLogs { + ui.Info(result.Output) + } + if !logsOnly { duration := execution.EndTime.Sub(execution.StartTime) ui.Success("Test execution completed with success in " + duration.String()) @@ -112,7 +115,9 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, PrintExecutionURIs(execution, info.DashboardUri) } - ui.Info(result.Output) + if showLogs { + ui.Info(result.Output) + } return errors.New(result.ErrorMessage) default: @@ -124,7 +129,9 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, ui.Errf(result.ErrorMessage) } - ui.Info(result.Output) + if showLogs { + ui.Info(result.Output) + } return errors.New(result.ErrorMessage) } diff --git a/cmd/kubectl-testkube/commands/tests/executions.go b/cmd/kubectl-testkube/commands/tests/executions.go index b5577e3623a..1c5f39556de 100644 --- a/cmd/kubectl-testkube/commands/tests/executions.go +++ b/cmd/kubectl-testkube/commands/tests/executions.go @@ -35,7 +35,7 @@ func NewGetExecutionCmd() *cobra.Command { ui.ExitOnError("getting test execution: "+executionID, err) if logsOnly { - if err = render.RenderExecutionResult(client, &execution, logsOnly); err != nil { + if err = render.RenderExecutionResult(client, &execution, logsOnly, true); err != nil { os.Exit(1) } } else { diff --git a/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go b/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go index 50f32e29b3b..7db9284c400 100644 --- a/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go +++ b/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go @@ -59,7 +59,7 @@ func ExecutionRenderer(client client.Client, ui *ui.UI, obj interface{}) error { ui.Warn(" Auth type: ", execution.Content.Repository.AuthType) } - if err := render.RenderExecutionResult(client, &execution, false); err != nil { + if err := render.RenderExecutionResult(client, &execution, false, true); err != nil { return err } diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index fde809b9ce2..be12e60c140 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -320,7 +320,7 @@ func NewRunTestCmd() *cobra.Command { ui.ExitOnError("getting recent execution data id:"+execution.Id, err) } - if err = render.RenderExecutionResult(client, &execution, false); err != nil { + if err = render.RenderExecutionResult(client, &execution, false, !watchEnabled); err != nil { execErrors = append(execErrors, err) } From 0fad5b2935d9b585bfbe98956a01e946981caa2a Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 7 Feb 2024 20:13:19 +0300 Subject: [PATCH 076/234] feat: get executor by test type --- api/v1/testkube.yaml | 52 +++++++++++++++++++++++++++++++++ internal/app/api/v1/executor.go | 22 ++++++++++++++ internal/app/api/v1/server.go | 1 + 3 files changed, 75 insertions(+) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 145f99a9fc7..d6fec9b0ad0 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -2259,6 +2259,51 @@ paths: items: $ref: "#/components/schemas/Problem" + /executors/{testType}/type: + get: + parameters: + - $ref: "#/components/parameters/TestType" + tags: + - api + - executor + summary: "Get executor details by type" + description: "Returns executors data with executions passed to executor" + operationId: getExecutorByType + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutorDetails" + text/yaml: + schema: + type: string + 400: + description: "problem with input for CRD generation" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: "problem with communicating with kubernetes cluster" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 500: + description: "problem with getting executor data" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /labels: get: tags: @@ -6218,6 +6263,13 @@ components: default: false description: dont delete executions required: false + TestType: + in: path + name: testType + schema: + type: string + required: true + description: test type of the executor requestBodies: UploadsBody: description: "Upload files request body data" diff --git a/internal/app/api/v1/executor.go b/internal/app/api/v1/executor.go index 797ce5fdcf9..5b5a27642a3 100644 --- a/internal/app/api/v1/executor.go +++ b/internal/app/api/v1/executor.go @@ -196,3 +196,25 @@ func (s TestkubeAPI) DeleteExecutorsHandler() fiber.Handler { return nil } } + +func (s TestkubeAPI) GetExecutorByTestTypeHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + testType := c.Params("testType") + errPrefix := fmt.Sprintf("failed to get executor by test type %s", testType) + + item, err := s.ExecutorsClient.GetByType(testType) + if err != nil { + return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could not get executor: %w", errPrefix, err)) + } + + if c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML { + result := executorsmapper.MapCRDToAPI(*item) + result.QuoteExecutorTextFields() + data, err := crd.GenerateYAML(crd.TemplateExecutor, []testkube.ExecutorUpsertRequest{result}) + return s.getCRDs(c, data, err) + } + + result := executorsmapper.MapExecutorCRDToExecutorDetails(*item) + return c.JSON(result) + } +} diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 1a0f51cf000..9ca91624624 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -278,6 +278,7 @@ func (s *TestkubeAPI) InitRoutes() { executors.Post("/", s.CreateExecutorHandler()) executors.Get("/", s.ListExecutorsHandler()) executors.Get("/:name", s.GetExecutorHandler()) + executors.Get("/:testType/type", s.GetExecutorByTestTypeHandler()) executors.Patch("/:name", s.UpdateExecutorHandler()) executors.Delete("/:name", s.DeleteExecutorHandler()) executors.Delete("/", s.DeleteExecutorsHandler()) From 354467f23f34ba419e6ae329db0b9721ec72d77d Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 8 Feb 2024 00:40:06 +0300 Subject: [PATCH 077/234] fixL nik map --- pkg/executor/client/job.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index dc9a49739e5..ae1cab848cf 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -993,6 +993,11 @@ func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface if err != nil { return jobOptions, err } + + if jobOptions.Variables == nil { + jobOptions.Variables = make(map[string]testkube.Variable) + } + jobOptions.Variables[executor.SlavesConfigsEnv] = testkube.NewBasicVariable(executor.SlavesConfigsEnv, string(slvesConfigs)) } From 88b17754647a8bf20d88950c0044cf7167c28e61 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 8 Feb 2024 15:05:13 +0300 Subject: [PATCH 078/234] fix: unescape test type --- internal/app/api/v1/executor.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/app/api/v1/executor.go b/internal/app/api/v1/executor.go index 5b5a27642a3..2eb57972fda 100644 --- a/internal/app/api/v1/executor.go +++ b/internal/app/api/v1/executor.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "net/http" + "net/url" "github.com/gofiber/fiber/v2" "k8s.io/apimachinery/pkg/api/errors" @@ -199,8 +200,12 @@ func (s TestkubeAPI) DeleteExecutorsHandler() fiber.Handler { func (s TestkubeAPI) GetExecutorByTestTypeHandler() fiber.Handler { return func(c *fiber.Ctx) error { - testType := c.Params("testType") - errPrefix := fmt.Sprintf("failed to get executor by test type %s", testType) + errPrefix := "failed to get executor by test type" + + testType, err := url.PathUnescape(c.Params("testType")) + if err != nil { + return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse test type: %w", errPrefix, err)) + } item, err := s.ExecutorsClient.GetByType(testType) if err != nil { From dc7ccd36777b5e81fb3d42b02947ec2f498a545e Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 8 Feb 2024 21:49:40 +0300 Subject: [PATCH 079/234] fix: suppot override mode --- contrib/executor/curl/pkg/runner/runner.go | 2 +- contrib/executor/jmeter/pkg/runner/runner.go | 2 +- .../executor/jmeterd/pkg/runner/helpers.go | 35 +++++++++++++++++-- contrib/executor/jmeterd/pkg/runner/runner.go | 13 ++++--- .../jmeterd/pkg/runner/runner_test.go | 2 +- .../postman/pkg/runner/newman/newman.go | 2 +- contrib/executor/soapui/pkg/runner/runner.go | 2 +- 7 files changed, 44 insertions(+), 14 deletions(-) diff --git a/contrib/executor/curl/pkg/runner/runner.go b/contrib/executor/curl/pkg/runner/runner.go index 5d094589e1c..9908514c57e 100644 --- a/contrib/executor/curl/pkg/runner/runner.go +++ b/contrib/executor/curl/pkg/runner/runner.go @@ -85,7 +85,7 @@ func (r *CurlRunner) Run(ctx context.Context, execution testkube.Execution) (res output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, path) // sanity checking for test script - scriptFile := filepath.Join(path, workingDir, scriptName) + scriptFile := filepath.Join(workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) diff --git a/contrib/executor/jmeter/pkg/runner/runner.go b/contrib/executor/jmeter/pkg/runner/runner.go index 9cb299c7fd3..2bcf5457e06 100644 --- a/contrib/executor/jmeter/pkg/runner/runner.go +++ b/contrib/executor/jmeter/pkg/runner/runner.go @@ -85,7 +85,7 @@ func (r *JMeterRunner) Run(ctx context.Context, execution testkube.Execution) (r output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, path) // sanity checking for test script - scriptFile := filepath.Join(path, workingDir, scriptName) + scriptFile := filepath.Join(workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) diff --git a/contrib/executor/jmeterd/pkg/runner/helpers.go b/contrib/executor/jmeterd/pkg/runner/helpers.go index e83947748d1..b1df9de06cc 100644 --- a/contrib/executor/jmeterd/pkg/runner/helpers.go +++ b/contrib/executor/jmeterd/pkg/runner/helpers.go @@ -34,20 +34,51 @@ func getTestPathAndWorkingDir(fs filesystem.FileSystem, execution *testkube.Exec return "", "", "", err } - if fileInfo.IsDir() { + testFlag := "" + for i, arg := range execution.Args { + if arg == jmeterTestFileFlag { + if (i + 1) < len(execution.Args) { + if execution.Args[i+1] != "" { + testFlag = execution.Args[i+1] + i++ + continue + } + } + } + } + + if workingDir == "" { + workingDir = dataDir + } + + sanityCheck := false + if testFlag != "" { + if filepath.IsAbs(testFlag) { + testPath = dataDir + testFile = strings.TrimPrefix(testFlag, dataDir) + } else { + testPath = workingDir + testFile = testFlag + } + sanityCheck = true + } else if fileInfo.IsDir() { testFile, err = findTestFile(fs, execution, testPath, jmxExtension) if err != nil { return "", "", "", errors.Wrapf(err, "error searching for %s file in test path %s", jmxExtension, testPath) } + sanityCheck = true + } + if sanityCheck { // sanity checking for test script testPath = filepath.Join(testPath, testFile) - fileInfo, err := fs.Stat(testPath) + fileInfo, err = fs.Stat(testPath) if err != nil || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, testFile, err) return "", "", "", errors.Wrapf(err, "could not find file %s in the directory", testFile) } } + return } diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index c0a56b3bfdb..41d53ef4300 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -117,11 +117,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( // Add user plugins folder in slaves env variables slavesEnvVariables["JMETER_PARENT_TEST_FOLDER"] = testkube.NewBasicVariable("JMETER_PARENT_TEST_FOLDER", parentTestFolder) - runPath := r.Params.DataDir - if workingDir != "" { - runPath = workingDir - } - + runPath := workingDir outputDir := filepath.Join(runPath, "output") err = os.Setenv("OUTPUT_DIR", outputDir) if err != nil { @@ -158,7 +154,7 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( output.PrintLogf("%s Using arguments: %v", ui.IconWorld, envManager.ObfuscateStringSlice(args)) // TODO: this is a workaround, the check should be ideally performed in the getTestPathAndWorkingDir function - if err := checkIfTestFileExists(r.fs, args); err != nil { + if err := checkIfTestFileExists(r.fs, args, workingDir); err != nil { output.PrintLogf("%s Error validating test file exists: %v", ui.IconCross, err.Error()) return result, errors.WithStack(err) } @@ -238,7 +234,7 @@ func initSlaves( return slaveMeta, cleanupFunc, nil } -func checkIfTestFileExists(fs filesystem.FileSystem, args []string) error { +func checkIfTestFileExists(fs filesystem.FileSystem, args []string, workingDir string) error { if len(args) == 0 { return errors.New("no arguments provided") } @@ -246,6 +242,9 @@ func checkIfTestFileExists(fs filesystem.FileSystem, args []string) error { if err != nil { return errors.Wrapf(err, "error extracting value for %s flag", jmeterTestFileFlag) } + if !filepath.IsAbs(testParamValue) { + testParamValue = filepath.Join(workingDir, testParamValue) + } info, err := fs.Stat(testParamValue) if err != nil { return errors.WithStack(err) diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index d165cc1cc18..646b3fe78df 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -71,7 +71,7 @@ func TestCheckIfTestFileExists(t *testing.T) { t.Parallel() mockFS := filesystem.NewMockFileSystem(mockCtrl) tc.setupMock(mockFS) - err := checkIfTestFileExists(mockFS, tc.args) + err := checkIfTestFileExists(mockFS, tc.args, "") if tc.expectError { assert.Error(t, err) } else { diff --git a/contrib/executor/postman/pkg/runner/newman/newman.go b/contrib/executor/postman/pkg/runner/newman/newman.go index ba55be878e8..ba191f82781 100644 --- a/contrib/executor/postman/pkg/runner/newman/newman.go +++ b/contrib/executor/postman/pkg/runner/newman/newman.go @@ -79,7 +79,7 @@ func (r *NewmanRunner) Run(ctx context.Context, execution testkube.Execution) (r output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, path) // sanity checking for test script - scriptFile := filepath.Join(path, workingDir, scriptName) + scriptFile := filepath.Join(workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) diff --git a/contrib/executor/soapui/pkg/runner/runner.go b/contrib/executor/soapui/pkg/runner/runner.go index f8b625418fb..e448ada43c4 100644 --- a/contrib/executor/soapui/pkg/runner/runner.go +++ b/contrib/executor/soapui/pkg/runner/runner.go @@ -82,7 +82,7 @@ func (r *SoapUIRunner) Run(ctx context.Context, execution testkube.Execution) (r output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, testFile) // sanity checking for test script - scriptFile := filepath.Join(testFile, workingDir, scriptName) + scriptFile := filepath.Join(workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) From 6308a06981ba0ca2c785ffb638095dc4f35cc433 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 9 Feb 2024 13:25:59 +0300 Subject: [PATCH 080/234] fix: unit test --- contrib/executor/jmeterd/pkg/runner/helpers_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/helpers_test.go b/contrib/executor/jmeterd/pkg/runner/helpers_test.go index c91d3f7d7bf..960ac3df657 100644 --- a/contrib/executor/jmeterd/pkg/runner/helpers_test.go +++ b/contrib/executor/jmeterd/pkg/runner/helpers_test.go @@ -41,7 +41,7 @@ func TestGetTestPathAndWorkingDir(t *testing.T) { dataDir: "/tmp/data", }, wantTestPath: "/tmp/data/test-content", - wantWorkingDir: "", + wantWorkingDir: "/tmp/data", wantTestFile: "", wantErr: false, setup: func(fs *filesystem.MockFileSystem) { @@ -59,7 +59,7 @@ func TestGetTestPathAndWorkingDir(t *testing.T) { dataDir: "/tmp/data", }, wantTestPath: "/tmp/data/repo/test.jmx", - wantWorkingDir: "", + wantWorkingDir: "/tmp/data", wantTestFile: "test.jmx", wantErr: false, setup: func(fs *filesystem.MockFileSystem) { From 1acd131f4a45be439839e38d3ccf9897b49b289a Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 9 Feb 2024 14:39:07 +0300 Subject: [PATCH 081/234] fix: add more unit tests --- .../executor/jmeterd/pkg/runner/helpers.go | 11 +- .../jmeterd/pkg/runner/helpers_test.go | 106 ++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/helpers.go b/contrib/executor/jmeterd/pkg/runner/helpers.go index b1df9de06cc..e0cdb47df93 100644 --- a/contrib/executor/jmeterd/pkg/runner/helpers.go +++ b/contrib/executor/jmeterd/pkg/runner/helpers.go @@ -54,24 +54,25 @@ func getTestPathAndWorkingDir(fs filesystem.FileSystem, execution *testkube.Exec sanityCheck := false if testFlag != "" { if filepath.IsAbs(testFlag) { - testPath = dataDir - testFile = strings.TrimPrefix(testFlag, dataDir) + testPath = testFlag } else { - testPath = workingDir - testFile = testFlag + testPath = filepath.Join(workingDir, testFlag) } + + testFile = filepath.Base(testPath) sanityCheck = true } else if fileInfo.IsDir() { testFile, err = findTestFile(fs, execution, testPath, jmxExtension) if err != nil { return "", "", "", errors.Wrapf(err, "error searching for %s file in test path %s", jmxExtension, testPath) } + + testPath = filepath.Join(testPath, testFile) sanityCheck = true } if sanityCheck { // sanity checking for test script - testPath = filepath.Join(testPath, testFile) fileInfo, err = fs.Stat(testPath) if err != nil || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, testFile, err) diff --git a/contrib/executor/jmeterd/pkg/runner/helpers_test.go b/contrib/executor/jmeterd/pkg/runner/helpers_test.go index 960ac3df657..c5f6ba70e6e 100644 --- a/contrib/executor/jmeterd/pkg/runner/helpers_test.go +++ b/contrib/executor/jmeterd/pkg/runner/helpers_test.go @@ -114,6 +114,112 @@ func TestGetTestPathAndWorkingDir(t *testing.T) { fs.EXPECT().Stat(gomock.Any()).Return(nil, errors.New("stat error")) }, }, + { + name: "Get test path and working dir for -t absolute with working dir", + args: args{ + fs: filesystem.NewMockFileSystem(mockCtrl), + execution: testkube.Execution{ + Content: &testkube.TestContent{ + Type_: string(testkube.TestContentTypeGitFile), + Repository: &testkube.Repository{ + WorkingDir: "tests", + Path: "tests/test1", + }, + }, + Args: []string{"-t", "/tmp/data/repo/tests/test1/test.jmx"}, + }, + dataDir: "/tmp/data", + }, + wantTestPath: "/tmp/data/repo/tests/test1/test.jmx", + wantWorkingDir: "/tmp/data/repo/tests", + wantTestFile: "test.jmx", + wantErr: false, + setup: func(fs *filesystem.MockFileSystem) { + gomock.InOrder( + fs.EXPECT().Stat("/tmp/data/repo/tests/test1").Return(&filesystem.MockFileInfo{FIsDir: true}, nil), + fs.EXPECT().Stat("/tmp/data/repo/tests/test1/test.jmx").Return(&filesystem.MockFileInfo{FIsDir: false}, nil), + ) + }, + }, + { + name: "Get test path and working dir for -t absolute without working dir", + args: args{ + fs: filesystem.NewMockFileSystem(mockCtrl), + execution: testkube.Execution{ + Content: &testkube.TestContent{ + Type_: string(testkube.TestContentTypeGitFile), + Repository: &testkube.Repository{ + Path: "tests/test1", + }, + }, + Args: []string{"-t", "/tmp/data/repo/tests/test1/test.jmx"}, + }, + dataDir: "/tmp/data", + }, + wantTestPath: "/tmp/data/repo/tests/test1/test.jmx", + wantWorkingDir: "/tmp/data", + wantTestFile: "test.jmx", + wantErr: false, + setup: func(fs *filesystem.MockFileSystem) { + gomock.InOrder( + fs.EXPECT().Stat("/tmp/data/repo/tests/test1").Return(&filesystem.MockFileInfo{FIsDir: true}, nil), + fs.EXPECT().Stat("/tmp/data/repo/tests/test1/test.jmx").Return(&filesystem.MockFileInfo{FIsDir: false}, nil), + ) + }, + }, + { + name: "Get test path and working dir for -t relative with working dir", + args: args{ + fs: filesystem.NewMockFileSystem(mockCtrl), + execution: testkube.Execution{ + Content: &testkube.TestContent{ + Type_: string(testkube.TestContentTypeGitFile), + Repository: &testkube.Repository{ + WorkingDir: "tests", + Path: "tests/test1", + }, + }, + Args: []string{"-t", "test1/test.jmx"}, + }, + dataDir: "/tmp/data", + }, + wantTestPath: "/tmp/data/repo/tests/test1/test.jmx", + wantWorkingDir: "/tmp/data/repo/tests", + wantTestFile: "test.jmx", + wantErr: false, + setup: func(fs *filesystem.MockFileSystem) { + gomock.InOrder( + fs.EXPECT().Stat("/tmp/data/repo/tests/test1").Return(&filesystem.MockFileInfo{FIsDir: true}, nil), + fs.EXPECT().Stat("/tmp/data/repo/tests/test1/test.jmx").Return(&filesystem.MockFileInfo{FIsDir: false}, nil), + ) + }, + }, + { + name: "Get test path and working dir for -t relative without working dir", + args: args{ + fs: filesystem.NewMockFileSystem(mockCtrl), + execution: testkube.Execution{ + Content: &testkube.TestContent{ + Type_: string(testkube.TestContentTypeGitFile), + Repository: &testkube.Repository{ + Path: "tests/test1", + }, + }, + Args: []string{"-t", "repo/tests/test1/test.jmx"}, + }, + dataDir: "/tmp/data", + }, + wantTestPath: "/tmp/data/repo/tests/test1/test.jmx", + wantWorkingDir: "/tmp/data", + wantTestFile: "test.jmx", + wantErr: false, + setup: func(fs *filesystem.MockFileSystem) { + gomock.InOrder( + fs.EXPECT().Stat("/tmp/data/repo/tests/test1").Return(&filesystem.MockFileInfo{FIsDir: true}, nil), + fs.EXPECT().Stat("/tmp/data/repo/tests/test1/test.jmx").Return(&filesystem.MockFileInfo{FIsDir: false}, nil), + ) + }, + }, } for _, tt := range tests { From a53f26e3db1faa302bd9af4ed6d51cde672a7b78 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Fri, 9 Feb 2024 14:31:23 +0100 Subject: [PATCH 082/234] fix: added count method (#4985) * fix: added count method * fix: added Count method to results and suites results * fix: added cool down to flaky test --- internal/app/api/v1/executions_test.go | 4 ++++ pkg/cloud/data/result/result.go | 4 ++++ pkg/cloud/data/testresult/testresult.go | 4 ++++ .../containerexecutor_test.go | 5 +++++ pkg/executor/scraper/mock_scraper.go | 1 - pkg/executor/scraper/mock_uploader.go | 1 - pkg/logs/events_test.go | 5 ++++- pkg/repository/config/mock_repository.go | 1 - pkg/repository/result/interface.go | 4 +++- pkg/repository/result/mock_repository.go | 16 +++++++++++++++- pkg/repository/result/mongo.go | 5 +++++ pkg/repository/testresult/interface.go | 6 ++++-- pkg/repository/testresult/mock_repository.go | 18 ++++++++++++++++-- pkg/repository/testresult/mongo.go | 5 +++++ pkg/storage/artifacts_mock.go | 1 - 15 files changed, 69 insertions(+), 11 deletions(-) diff --git a/internal/app/api/v1/executions_test.go b/internal/app/api/v1/executions_test.go index e808947e161..7325eb232c3 100644 --- a/internal/app/api/v1/executions_test.go +++ b/internal/app/api/v1/executions_test.go @@ -281,6 +281,10 @@ func (r MockExecutionResultsRepository) GetTestMetrics(ctx context.Context, name panic("not implemented") } +func (r MockExecutionResultsRepository) Count(ctx context.Context, filter result.Filter) (int64, error) { + panic("not implemented") +} + type MockExecutor struct { LogsFn func(id string) (chan output.Output, error) } diff --git a/pkg/cloud/data/result/result.go b/pkg/cloud/data/result/result.go index a0e41f38564..43e8ca330c6 100644 --- a/pkg/cloud/data/result/result.go +++ b/pkg/cloud/data/result/result.go @@ -316,3 +316,7 @@ func (r *CloudRepository) GetTestMetrics(ctx context.Context, name string, limit } return commandResponse.Metrics, nil } + +func (r *CloudRepository) Count(ctx context.Context, filter result.Filter) (int64, error) { + return 0, nil +} diff --git a/pkg/cloud/data/testresult/testresult.go b/pkg/cloud/data/testresult/testresult.go index 6a67eb20ed2..37425ca1ba5 100644 --- a/pkg/cloud/data/testresult/testresult.go +++ b/pkg/cloud/data/testresult/testresult.go @@ -234,3 +234,7 @@ func (r *CloudRepository) GetTestSuiteMetrics(ctx context.Context, name string, } return commandResponse.Metrics, nil } + +func (r *CloudRepository) Count(ctx context.Context, filter testresult.Filter) (int64, error) { + return 0, nil +} diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 78222674972..288f8d76a16 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -450,6 +450,11 @@ func (r FakeResultRepository) GetTestMetrics(ctx context.Context, name string, l panic("implement me") } +func (r FakeResultRepository) Count(ctx context.Context, filter result.Filter) (count int64, err error) { + //TODO implement me + panic("implement me") +} + func (FakeResultRepository) Get(ctx context.Context, id string) (testkube.Execution, error) { return testkube.Execution{}, nil } diff --git a/pkg/executor/scraper/mock_scraper.go b/pkg/executor/scraper/mock_scraper.go index 5092e04414a..f2df1ab1a8d 100644 --- a/pkg/executor/scraper/mock_scraper.go +++ b/pkg/executor/scraper/mock_scraper.go @@ -9,7 +9,6 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) diff --git a/pkg/executor/scraper/mock_uploader.go b/pkg/executor/scraper/mock_uploader.go index f1ab4091a45..638901f69de 100644 --- a/pkg/executor/scraper/mock_uploader.go +++ b/pkg/executor/scraper/mock_uploader.go @@ -9,7 +9,6 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) diff --git a/pkg/logs/events_test.go b/pkg/logs/events_test.go index 2c9727555ae..27cd80a3d45 100644 --- a/pkg/logs/events_test.go +++ b/pkg/logs/events_test.go @@ -172,7 +172,7 @@ func TestLogs_EventsFlow(t *testing.T) { // and wait for message to be propagated emitter.Notify(testkube.NewEventEndTestFailed(&testkube.Execution{Id: "id1"})) - time.Sleep(time.Second) + time.Sleep(waitTime) assertMessagesCount(t, a, messagesCount) @@ -253,6 +253,9 @@ func TestLogs_EventsFlow(t *testing.T) { _, err = stream.Stop(ctx, id) assert.NoError(t, err) + // cool down + time.Sleep(waitTime) + // then each adapter should receive messages assertMessagesCount(t, a1, messagesCount) assertMessagesCount(t, a2, messagesCount) diff --git a/pkg/repository/config/mock_repository.go b/pkg/repository/config/mock_repository.go index 55aa28b5d8f..3dbb1e61a7c 100644 --- a/pkg/repository/config/mock_repository.go +++ b/pkg/repository/config/mock_repository.go @@ -9,7 +9,6 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) diff --git a/pkg/repository/result/interface.go b/pkg/repository/result/interface.go index 26cf04ac2cf..c22e1248f82 100644 --- a/pkg/repository/result/interface.go +++ b/pkg/repository/result/interface.go @@ -69,8 +69,10 @@ type Repository interface { DeleteByTestSuites(ctx context.Context, testSuiteNames []string) (err error) // DeleteForAllTestSuites deletes execution results for all test suites DeleteForAllTestSuites(ctx context.Context) (err error) - + // GetTestMetrics returns metrics for test GetTestMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) + // Count returns executions count + Count(ctx context.Context, filter Filter) (int64, error) } type Sequences interface { diff --git a/pkg/repository/result/mock_repository.go b/pkg/repository/result/mock_repository.go index 29f7faa3294..82810f2882d 100644 --- a/pkg/repository/result/mock_repository.go +++ b/pkg/repository/result/mock_repository.go @@ -10,7 +10,6 @@ import ( time "time" gomock "github.com/golang/mock/gomock" - testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) @@ -37,6 +36,21 @@ func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { return m.recorder } +// Count mocks base method. +func (m *MockRepository) Count(arg0 context.Context, arg1 Filter) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Count", arg0, arg1) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Count indicates an expected call of Count. +func (mr *MockRepositoryMockRecorder) Count(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockRepository)(nil).Count), arg0, arg1) +} + // DeleteAll mocks base method. func (m *MockRepository) DeleteAll(arg0 context.Context) error { m.ctrl.T.Helper() diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index 8ed55cd2802..7448d6213ef 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -455,6 +455,11 @@ func (r *MongoRepository) GetExecutions(ctx context.Context, filter Filter) (res return } +func (r *MongoRepository) Count(ctx context.Context, filter Filter) (count int64, err error) { + query, _ := composeQueryAndOpts(filter) + return r.ResultsColl.CountDocuments(ctx, query) +} + func (r *MongoRepository) GetExecutionTotals(ctx context.Context, paging bool, filter ...Filter) (totals testkube.ExecutionsTotals, err error) { var result []struct { Status string `bson:"_id"` diff --git a/pkg/repository/testresult/interface.go b/pkg/repository/testresult/interface.go index ddd1c11d173..2fc76c2d49f 100644 --- a/pkg/repository/testresult/interface.go +++ b/pkg/repository/testresult/interface.go @@ -27,7 +27,7 @@ type Filter interface { Selector() string } -//go:generate mockgen -destination=./mock_repository.go -package=testresult "github.com/kubeshop/testkube/internal/pkg/api/repository/testresult" Repository +//go:generate mockgen -destination=./mock_repository.go -package=testresult "github.com/kubeshop/testkube/pkg/repository/testresult" Repository type Repository interface { // Get gets execution result by id or name Get(ctx context.Context, id string) (testkube.TestSuiteExecution, error) @@ -55,6 +55,8 @@ type Repository interface { DeleteAll(ctx context.Context) error // DeleteByTestSuites deletes execution results by test suites DeleteByTestSuites(ctx context.Context, testSuiteNames []string) (err error) - + // GetTestSuiteMetrics returns metrics for test suite GetTestSuiteMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) + // Count returns executions count + Count(ctx context.Context, filter Filter) (int64, error) } diff --git a/pkg/repository/testresult/mock_repository.go b/pkg/repository/testresult/mock_repository.go index 9b0fd0025bb..d74f63f6cea 100644 --- a/pkg/repository/testresult/mock_repository.go +++ b/pkg/repository/testresult/mock_repository.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/kubeshop/testkube/internal/pkg/api/repository/testresult (interfaces: Repository) +// Source: github.com/kubeshop/testkube/pkg/repository/testresult (interfaces: Repository) // Package testresult is a generated GoMock package. package testresult @@ -10,7 +10,6 @@ import ( time "time" gomock "github.com/golang/mock/gomock" - testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) @@ -37,6 +36,21 @@ func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { return m.recorder } +// Count mocks base method. +func (m *MockRepository) Count(arg0 context.Context, arg1 Filter) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Count", arg0, arg1) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Count indicates an expected call of Count. +func (mr *MockRepositoryMockRecorder) Count(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockRepository)(nil).Count), arg0, arg1) +} + // DeleteAll mocks base method. func (m *MockRepository) DeleteAll(arg0 context.Context) error { m.ctrl.T.Helper() diff --git a/pkg/repository/testresult/mongo.go b/pkg/repository/testresult/mongo.go index f5fc69f4ec8..b1f16a11f7e 100644 --- a/pkg/repository/testresult/mongo.go +++ b/pkg/repository/testresult/mongo.go @@ -288,6 +288,11 @@ func (r *MongoRepository) GetNewestExecutions(ctx context.Context, limit int) (r return } +func (r *MongoRepository) Count(ctx context.Context, filter Filter) (count int64, err error) { + query, _ := composeQueryAndOpts(filter) + return r.Coll.CountDocuments(ctx, query) +} + func (r *MongoRepository) GetExecutionsTotals(ctx context.Context, filter ...Filter) (totals testkube.ExecutionsTotals, err error) { var result []struct { Status string `bson:"_id"` diff --git a/pkg/storage/artifacts_mock.go b/pkg/storage/artifacts_mock.go index 8a62c76869e..0417b7e72c3 100644 --- a/pkg/storage/artifacts_mock.go +++ b/pkg/storage/artifacts_mock.go @@ -10,7 +10,6 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) From 68bfb3a1ae4374591ef218c0d0dd6939772b0ce2 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 9 Feb 2024 18:58:37 +0300 Subject: [PATCH 083/234] fix: use user OUTPUT_DIR --- contrib/executor/jmeterd/pkg/runner/runner.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index 41d53ef4300..f9625f83b25 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -118,11 +118,20 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( slavesEnvVariables["JMETER_PARENT_TEST_FOLDER"] = testkube.NewBasicVariable("JMETER_PARENT_TEST_FOLDER", parentTestFolder) runPath := workingDir - outputDir := filepath.Join(runPath, "output") - err = os.Setenv("OUTPUT_DIR", outputDir) - if err != nil { - output.PrintLogf("%s Failed to set output directory %s", ui.IconWarning, outputDir) + + outputDir := "" + if envVar, ok := envManager.Variables["OUTPUT_DIR"]; ok { + outputDir = envVar.Value } + + if outputDir == "" { + outputDir = filepath.Join(runPath, "output") + err = os.Setenv("OUTPUT_DIR", outputDir) + if err != nil { + output.PrintLogf("%s Failed to set output directory %s", ui.IconWarning, outputDir) + } + } + slavesEnvVariables["OUTPUT_DIR"] = testkube.NewBasicVariable("OUTPUT_DIR", outputDir) // recreate output directory with wide permissions so JMeter can create report files From b97161daed31d31700e4a6f060415e65f0c90873 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Mon, 12 Feb 2024 12:19:38 +0100 Subject: [PATCH 084/234] feat: executor tests - jmeter `negative` disabled after exit code changes, `jmeterd-executor-smoke-output-dir` (#4996) * executor tests - jmeterd-executor-smoke-output-dir * typo fixed * empty lines added --- test/jmeter/executor-tests/crd/other.yaml | 12 ++---- .../executor-tests/crd/special-cases.yaml | 42 +++++++++++++++++++ test/suites/executor-jmeter-other-tests.yaml | 8 ++-- .../special-cases/jmeter-special-cases.yaml | 3 ++ 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/test/jmeter/executor-tests/crd/other.yaml b/test/jmeter/executor-tests/crd/other.yaml index 03eaf3ca9fd..5059d27893b 100644 --- a/test/jmeter/executor-tests/crd/other.yaml +++ b/test/jmeter/executor-tests/crd/other.yaml @@ -21,7 +21,7 @@ spec: apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-incorrect-url-assertion-negative + name: jmeterd-executor-smoke-incorrect-url-assertion labels: core-tests: executors spec: @@ -34,14 +34,13 @@ spec: branch: main path: test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url.jmx executionRequest: - negativeTest: true jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" activeDeadlineSeconds: 180 --- apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-incorrect-url-assertion-slaves-negative + name: jmeterd-executor-smoke-incorrect-url-assertion-slaves labels: core-tests: executors spec: @@ -54,7 +53,6 @@ spec: branch: main path: test/jmeter/executor-tests/jmeter-executor-smoke-incorrect-url.jmx executionRequest: - negativeTest: true variables: SLAVES_COUNT: name: SLAVES_COUNT @@ -74,7 +72,7 @@ spec: apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-correct-url-failed-assertion-negative + name: jmeterd-executor-smoke-correct-url-failed-assertion labels: core-tests: executors spec: @@ -87,14 +85,13 @@ spec: branch: main path: test/jmeter/executor-tests/jmeter-executor-smoke-correct-url-failed-assertion.jmx executionRequest: - negativeTest: true jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" activeDeadlineSeconds: 180 --- apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-failed-assertion-slaves-negative + name: jmeterd-executor-smoke-failed-assertion-slaves labels: core-tests: executors spec: @@ -107,7 +104,6 @@ spec: branch: main path: test/jmeter/executor-tests/jmeter-executor-smoke-correct-url-failed-assertion.jmx executionRequest: - negativeTest: true variables: SLAVES_COUNT: name: SLAVES_COUNT diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml index 19aa8c1762f..5d6cf6a5816 100644 --- a/test/jmeter/executor-tests/crd/special-cases.yaml +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -537,3 +537,45 @@ spec: limits: cpu: 500m memory: 512Mi +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeterd-executor-smoke-output-dir + labels: + core-tests: special-cases-jmeter +spec: + type: jmeterd/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests + executionRequest: + argsMode: override + args: + - "-n" + - "-t" + - "/data/repo/test/jmeter/executor-tests/jmeter-executor-smoke.jmx" + - "-o" + - "/data/artifacts-custom/custom-report.jtl" + - "-l" + - "/data/artifacts-custom/report.jtl" + - "-e" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + slavePodRequest: + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + cpu: 500m + memory: 512Mi + variables: + OUTPUT_DIR: + name: OUTPUT_DIR + value: "/data/artifacts-custom" + type: basic diff --git a/test/suites/executor-jmeter-other-tests.yaml b/test/suites/executor-jmeter-other-tests.yaml index 9d99b59c4f4..761c7d3bc22 100644 --- a/test/suites/executor-jmeter-other-tests.yaml +++ b/test/suites/executor-jmeter-other-tests.yaml @@ -12,16 +12,16 @@ spec: - test: jmeter-executor-smoke-incorrect-url-assertion-negative - stopOnFailure: false execute: - - test: jmeterd-executor-smoke-incorrect-url-assertion-negative + - test: jmeterd-executor-smoke-incorrect-url-assertion - stopOnFailure: false execute: - - test: jmeterd-executor-smoke-incorrect-url-assertion-slaves-negative + - test: jmeterd-executor-smoke-incorrect-url-assertion-slaves - stopOnFailure: false execute: - - test: jmeterd-executor-smoke-correct-url-failed-assertion-negative + - test: jmeterd-executor-smoke-correct-url-failed-assertion - stopOnFailure: false execute: - - test: jmeterd-executor-smoke-failed-assertion-slaves-negative + - test: jmeterd-executor-smoke-failed-assertion-slaves - stopOnFailure: false execute: - test: jmeterd-executor-smoke-failure-exit-code-0-negative diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml index 8379437e8f3..59c30d7c041 100644 --- a/test/suites/special-cases/jmeter-special-cases.yaml +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -43,3 +43,6 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-args-override-workingdir + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-output-dir From 40a597a75081738ef9d607e1943b4b4a0cd433ee Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 12 Feb 2024 14:03:26 +0300 Subject: [PATCH 085/234] fix: switch to query instead of path param --- api/v1/testkube.yaml | 4 ++-- internal/app/api/v1/executor.go | 7 +++---- internal/app/api/v1/server.go | 4 +++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index d6fec9b0ad0..f22164ada28 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -2259,7 +2259,7 @@ paths: items: $ref: "#/components/schemas/Problem" - /executors/{testType}/type: + /executor-by-types: get: parameters: - $ref: "#/components/parameters/TestType" @@ -6264,7 +6264,7 @@ components: description: dont delete executions required: false TestType: - in: path + in: query name: testType schema: type: string diff --git a/internal/app/api/v1/executor.go b/internal/app/api/v1/executor.go index 2eb57972fda..a5d601ccf83 100644 --- a/internal/app/api/v1/executor.go +++ b/internal/app/api/v1/executor.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "net/http" - "net/url" "github.com/gofiber/fiber/v2" "k8s.io/apimachinery/pkg/api/errors" @@ -202,9 +201,9 @@ func (s TestkubeAPI) GetExecutorByTestTypeHandler() fiber.Handler { return func(c *fiber.Ctx) error { errPrefix := "failed to get executor by test type" - testType, err := url.PathUnescape(c.Params("testType")) - if err != nil { - return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse test type: %w", errPrefix, err)) + testType := c.Query("testType", "") + if testType == "" { + return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not fine test type", errPrefix)) } item, err := s.ExecutorsClient.GetByType(testType) diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 9ca91624624..2935b160ea9 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -278,11 +278,13 @@ func (s *TestkubeAPI) InitRoutes() { executors.Post("/", s.CreateExecutorHandler()) executors.Get("/", s.ListExecutorsHandler()) executors.Get("/:name", s.GetExecutorHandler()) - executors.Get("/:testType/type", s.GetExecutorByTestTypeHandler()) executors.Patch("/:name", s.UpdateExecutorHandler()) executors.Delete("/:name", s.DeleteExecutorHandler()) executors.Delete("/", s.DeleteExecutorsHandler()) + executorByTypes := root.Group("/executor-by-types") + executorByTypes.Get("/", s.GetExecutorByTestTypeHandler()) + webhooks := root.Group("/webhooks") webhooks.Post("/", s.CreateWebhookHandler()) From eb9c89866575f743eba64fd402e91a95964214e6 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 12 Feb 2024 15:07:25 +0300 Subject: [PATCH 086/234] fix: return path to script check --- contrib/executor/curl/pkg/runner/runner.go | 4 ++-- contrib/executor/jmeter/pkg/runner/runner.go | 4 ++-- contrib/executor/postman/pkg/runner/newman/newman.go | 4 ++-- contrib/executor/soapui/pkg/runner/runner.go | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/contrib/executor/curl/pkg/runner/runner.go b/contrib/executor/curl/pkg/runner/runner.go index 9908514c57e..d0040d6999a 100644 --- a/contrib/executor/curl/pkg/runner/runner.go +++ b/contrib/executor/curl/pkg/runner/runner.go @@ -75,7 +75,7 @@ func (r *CurlRunner) Run(ctx context.Context, execution testkube.Execution) (res if fileInfo.IsDir() { scriptName := execution.Args[len(execution.Args)-1] if workingDir != "" { - path = filepath.Join(r.Params.DataDir, "repo") + path = "" if execution.Content != nil && execution.Content.Repository != nil { scriptName = filepath.Join(execution.Content.Repository.Path, scriptName) } @@ -85,7 +85,7 @@ func (r *CurlRunner) Run(ctx context.Context, execution testkube.Execution) (res output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, path) // sanity checking for test script - scriptFile := filepath.Join(workingDir, scriptName) + scriptFile := filepath.Join(path, workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) diff --git a/contrib/executor/jmeter/pkg/runner/runner.go b/contrib/executor/jmeter/pkg/runner/runner.go index 2bcf5457e06..ca25d14b13e 100644 --- a/contrib/executor/jmeter/pkg/runner/runner.go +++ b/contrib/executor/jmeter/pkg/runner/runner.go @@ -75,7 +75,7 @@ func (r *JMeterRunner) Run(ctx context.Context, execution testkube.Execution) (r if fileInfo.IsDir() { scriptName := execution.Args[len(execution.Args)-1] if workingDir != "" { - path = filepath.Join(r.Params.DataDir, "repo") + path = "" if execution.Content != nil && execution.Content.Repository != nil { scriptName = filepath.Join(execution.Content.Repository.Path, scriptName) } @@ -85,7 +85,7 @@ func (r *JMeterRunner) Run(ctx context.Context, execution testkube.Execution) (r output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, path) // sanity checking for test script - scriptFile := filepath.Join(workingDir, scriptName) + scriptFile := filepath.Join(path, workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) diff --git a/contrib/executor/postman/pkg/runner/newman/newman.go b/contrib/executor/postman/pkg/runner/newman/newman.go index ba191f82781..9cee312c887 100644 --- a/contrib/executor/postman/pkg/runner/newman/newman.go +++ b/contrib/executor/postman/pkg/runner/newman/newman.go @@ -69,7 +69,7 @@ func (r *NewmanRunner) Run(ctx context.Context, execution testkube.Execution) (r if fileInfo.IsDir() { scriptName := execution.Args[len(execution.Args)-1] if workingDir != "" { - path = filepath.Join(r.Params.DataDir, "repo") + path = "" if execution.Content != nil && execution.Content.Repository != nil { scriptName = filepath.Join(execution.Content.Repository.Path, scriptName) } @@ -79,7 +79,7 @@ func (r *NewmanRunner) Run(ctx context.Context, execution testkube.Execution) (r output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, path) // sanity checking for test script - scriptFile := filepath.Join(workingDir, scriptName) + scriptFile := filepath.Join(path, workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) diff --git a/contrib/executor/soapui/pkg/runner/runner.go b/contrib/executor/soapui/pkg/runner/runner.go index e448ada43c4..08edebb6d14 100644 --- a/contrib/executor/soapui/pkg/runner/runner.go +++ b/contrib/executor/soapui/pkg/runner/runner.go @@ -72,7 +72,7 @@ func (r *SoapUIRunner) Run(ctx context.Context, execution testkube.Execution) (r if fileInfo.IsDir() { scriptName := execution.Args[len(execution.Args)-1] if workingDir != "" { - testFile = filepath.Join(r.Params.DataDir, "repo") + testFile = "" if execution.Content != nil && execution.Content.Repository != nil { scriptName = filepath.Join(execution.Content.Repository.Path, scriptName) } @@ -82,7 +82,7 @@ func (r *SoapUIRunner) Run(ctx context.Context, execution testkube.Execution) (r output.PrintLogf("%s It is a directory test - trying to find file from the last executor argument %s in directory %s", ui.IconWorld, scriptName, testFile) // sanity checking for test script - scriptFile := filepath.Join(workingDir, scriptName) + scriptFile := filepath.Join(testFile, workingDir, scriptName) fileInfo, errFile := os.Stat(scriptFile) if errors.Is(errFile, os.ErrNotExist) || fileInfo.IsDir() { output.PrintLogf("%s Could not find file %s in the directory, error: %s", ui.IconCross, scriptName, errFile) From d0c837b94cc4aa00c3bcc71f3eed81757389ee81 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Mon, 12 Feb 2024 13:38:55 +0100 Subject: [PATCH 087/234] executor tests - jmeterd-executor-smoke-slaves-sharedbetweenpods excluded from testsuite (have to be run at specific cluster with NFS volume) (#4999) --- test/jmeter/executor-tests/crd/special-cases.yaml | 2 +- test/suites/special-cases/jmeter-special-cases.yaml | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml index 5d6cf6a5816..424f1f84f1f 100644 --- a/test/jmeter/executor-tests/crd/special-cases.yaml +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -129,7 +129,7 @@ spec: apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-slaves-sharedbetweenpods # can be run only at cluster with storageClassName (NFS volume) + name: jmeterd-executor-smoke-slaves-sharedbetweenpods # can be run only at cluster with storageClassName (NFS volume), not included in TestSuite labels: core-tests: executors spec: diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml index 59c30d7c041..0a7ea19a47f 100644 --- a/test/suites/special-cases/jmeter-special-cases.yaml +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -19,9 +19,6 @@ spec: - stopOnFailure: false execute: - test: jmeterd-executor-smoke-directory-2 - - stopOnFailure: false - execute: - - test: jmeterd-executor-smoke-slaves-sharedbetweenpods - stopOnFailure: false execute: - test: jmeterd-executor-smoke-directory-t-o From 4901f3fbb89f9d6e329ba69a1fe116492a26983a Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Mon, 12 Feb 2024 14:53:39 +0100 Subject: [PATCH 088/234] fix: update cloud env vars to pro (#4994) * fix: cloud env vars rename to pro * fix: update cloud env vars to pro --- cmd/api-server/main.go | 18 +++++- contrib/executor/init/pkg/runner/runner.go | 4 +- docs/docs/articles/running-tests.md | 60 ++++++++++-------- docs/docs/articles/webhooks.mdx | 36 ++++++++--- internal/app/api/v1/handlers.go | 17 +++-- internal/app/api/v1/server.go | 13 +++- internal/config/config.go | 21 +++++++ internal/config/procontext.go | 14 +++++ .../{featueflags.go => featureflags.go} | 0 pkg/agent/agent.go | 37 +++++------ pkg/agent/agent_test.go | 4 +- pkg/agent/events_test.go | 4 +- pkg/agent/logs_test.go | 4 +- pkg/envs/variables.go | 63 ++++++++++++++++--- pkg/executor/common.go | 48 ++++++++++---- .../containerexecutor_test.go | 15 +++-- pkg/executor/scraper/factory/factory.go | 12 ++-- pkg/telemetry/payload.go | 6 +- pkg/telemetry/sender_sio.go | 9 +-- pkg/utils/utils.go | 14 +++++ 20 files changed, 289 insertions(+), 110 deletions(-) create mode 100644 internal/config/procontext.go rename internal/featureflags/{featueflags.go => featureflags.go} (100%) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index bbaff3405f2..d894bb09014 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -502,19 +502,31 @@ func main() { if mode == common.ModeAgent { log.DefaultLogger.Info("starting agent service") + proContext := config.ProContext{ + APIKey: cfg.TestkubeProAPIKey, + URL: cfg.TestkubeProURL, + LogsPath: cfg.TestkubeProLogsPath, + TLSInsecure: cfg.TestkubeProTLSInsecure, + WorkerCount: cfg.TestkubeProWorkerCount, + LogStreamWorkerCount: cfg.TestkubeProLogStreamWorkerCount, + SkipVerify: cfg.TestkubeProSkipVerify, + EnvID: cfg.TestkubeProEnvID, + OrgID: cfg.TestkubeProOrgID, + Migrate: cfg.TestkubeProMigrate, + } + + api.WithProContext(&proContext) agentHandle, err := agent.NewAgent( log.DefaultLogger, api.Mux.Handler(), - cfg.TestkubeProAPIKey, grpcClient, - cfg.TestkubeProWorkerCount, - cfg.TestkubeProLogStreamWorkerCount, api.GetLogsStream, clusterId, cfg.TestkubeClusterName, envs, features, + proContext, ) if err != nil { ui.ExitOnError("Starting agent", err) diff --git a/contrib/executor/init/pkg/runner/runner.go b/contrib/executor/init/pkg/runner/runner.go index 5db22700520..4c90871f661 100755 --- a/contrib/executor/init/pkg/runner/runner.go +++ b/contrib/executor/init/pkg/runner/runner.go @@ -130,13 +130,13 @@ func (r *InitRunner) Run(ctx context.Context, execution testkube.Execution) (res } // TODO: write a proper cloud implementation - if r.Params.Endpoint != "" && !r.Params.CloudMode { + if r.Params.Endpoint != "" && !r.Params.ProMode { output.PrintLogf("%s Fetching uploads from object store %s...", ui.IconFile, r.Params.Endpoint) opts := minio.GetTLSOptions(r.Params.Ssl, r.Params.SkipVerify, r.Params.CertFile, r.Params.KeyFile, r.Params.CAFile) minioClient := minio.NewClient(r.Params.Endpoint, r.Params.AccessKeyID, r.Params.SecretAccessKey, r.Params.Region, r.Params.Token, r.Params.Bucket, opts...) fp := content.NewCopyFilesPlacer(minioClient) fp.PlaceFiles(ctx, execution.TestName, execution.BucketName) - } else if r.Params.CloudMode { + } else if r.Params.ProMode { output.PrintLogf("%s Copy files functionality is currently not supported in cloud mode", ui.IconWarning) } diff --git a/docs/docs/articles/running-tests.md b/docs/docs/articles/running-tests.md index 5d2a7490192..84dbaddb9e2 100644 --- a/docs/docs/articles/running-tests.md +++ b/docs/docs/articles/running-tests.md @@ -212,32 +212,40 @@ By default, there is a 10 second timeout limit on all requests on the client sid The following environment variables are automatically injected into each executed test pod: -DEBUG: if debug mode is on -RUNNER_ENDPOINT: minio endpoint -RUNNER_ACCESSKEYID: minio access key id -RUNNER_SECRETACCESSKEY: minio secret access key -RUNNER_REGION: minio region -RUNNER_TOKEN: mnio token -RUNNER_SSL: if minio ssl is on -RUNNER_SCRAPPERENABLED: if scraping is on -RUNNER_DATADIR: data directory -RUNNER_CDEVENTS_TARGET: cd events target endpoint -RUNNER_COMPRESSARTIFACTS: if artfifacts should be compressed -RUNNER_CLOUD_MODE: cloud mode -RUNNER_CLOUD_API_KEY: cloud api key -RUNNER_CLOUD_API_TLS_INSECURE: if cloud connection is insecure -RUNNER_CLOUD_API_URL: cloud api url -RUNNER_DASHBOARD_URI: dashboard uri -CI: ci flag -RUNNER_CLUSTERID: cluster id -RUNNER_BUCKET: minio bucket -RUNNER_WORKINGDIR: working directory -RUNNER_EXECUTIONID: test execution id -RUNNER_TESTNAME: test name -RUNNER_EXECUTIONNUMBER: test execution number -RUNNER_CONTEXTTYPE: running context type -RUNNER_CONTEXTDATA: running context data -RUNNER_APIURI: api uri +DEBUG: if debug mode is on +RUNNER_ENDPOINT: minio endpoint +RUNNER_ACCESSKEYID: minio access key id +RUNNER_SECRETACCESSKEY: minio secret access key +RUNNER_REGION: minio region +RUNNER_TOKEN: minio token +RUNNER_SSL: if minio ssl is on +RUNNER_SCRAPPERENABLED: if scraping is on +RUNNER_DATADIR: data directory +RUNNER_CDEVENTS_TARGET: cd events target endpoint +RUNNER_COMPRESSARTIFACTS: if artfifacts should be compressed +RUNNER_PRO_MODE: pro mode +RUNNER_PRO_API_KEY: pro api key +RUNNER_PRO_API_TLS_INSECURE: if pro connection is insecure +RUNNER_PRO_API_URL: pro api url +RUNNER_PRO_CONNECTION_TIMEOUT: pro connection timeout limit +RUNNER_PRO_API_SKIP_VERIFY: if pro connection tls verification is off +RUNNER_CLOUD_MODE: DEPRECATED: please use RUNNER_PRO_MODE instead +RUNNER_CLOUD_API_KEY: DEPRECATED: please use RUNNER_PRO_API_KEY instead +RUNNER_CLOUD_API_TLS_INSECURE: DEPRECATED: please use RUNNER_PRO_API_TLS_INSECURE instead +RUNNER_CLOUD_API_URL: DEPRECATED: please use RUNNER_PRO_API_URL instead +RUNNER_CLOUD_CONNECTION_TIMEOUT: DEPRECATED: please use RUNNER_PRO_CONNECTION_TIMEOUT instead +RUNNER_CLOUD_API_SKIP_VERIFY: DEPRECATED: please use RUNNER_PRO_API_SKIP_VERITY instead +RUNNER_DASHBOARD_URI: dashboard uri +CI: ci flag +RUNNER_CLUSTERID: cluster id +RUNNER_BUCKET: minio bucket +RUNNER_WORKINGDIR: working directory +RUNNER_EXECUTIONID: test execution id +RUNNER_TESTNAME: test name +RUNNER_EXECUTIONNUMBER: test execution number +RUNNER_CONTEXTTYPE: running context type +RUNNER_CONTEXTDATA: running context data +RUNNER_APIURI: api uri ## Summary diff --git a/docs/docs/articles/webhooks.mdx b/docs/docs/articles/webhooks.mdx index bde0198b352..6a6a2e707ce 100644 --- a/docs/docs/articles/webhooks.mdx +++ b/docs/docs/articles/webhooks.mdx @@ -23,8 +23,8 @@ You can create a webhook from the dashboard, use the CLI, or create it as a cust - Monitoring and Observability: Webhooks can also be used to send alerts and notifications to your monitoring and observability tools like Prometheus and Grafana. This provides visibility into your tests, alerts you, and ensures that timely corrective actions can be taken. - ## Creating a Webhook + The webhook can be created using the Dashboard, CLI, or a Custom Resource. @@ -56,6 +56,7 @@ Webhooks can be created with Testkube CLI using the `create webhook` command. ```sh testkube create webhook --name example-webhook --events start-test --events end-test-success --events end-test-failed --uri ``` + `--name` - Your webhook name (in this case `example-webhook`). `--events` - Event that will trigger a webhook. Multiple `--events` can be defined (in this case `--events start-test --events end-test-success --events end-test-failed`). All available trigger events can be found in the [Supported Event types](#supported-event-types) section. `--uri` - The HTTPS endpoint where you want to receive the webhook events. @@ -78,6 +79,7 @@ spec: - end-test-failed selector: "" ``` + Where should be replaced with the HTTPS endpoint URL where you want to receive the webhook events. And then apply with: @@ -91,6 +93,7 @@ kubectl apply -f webhook.yaml ### Resource Selector (labels) + In order to limit webhook triggers to a specific resource, or resources, the Resource Selector can be used. It allows you to select the specific resource by label, or labels. @@ -134,10 +137,10 @@ spec: - ### Webhook Payload Webhook payload can be configured - in this example, `event id`: + ``` {"text": "event id {{ .Id }}"} ``` @@ -166,8 +169,8 @@ And set it with `--payload-template template.json`. ```sh testkube create webhook --name example-webhook --events start-test --events end-test-passed --events end-test-failed --payload-template template.json --uri ``` -Where should be replaced with the HTTPS endpoint URL where you want to receive the webhook events. +Where should be replaced with the HTTPS endpoint URL where you want to receive the webhook events. ```sh title="Expected output:" Webhook created example-webhook 🥇 @@ -178,12 +181,14 @@ Webhook created example-webhook 🥇 Payload template can be configured with `spec.payloadTemplate`. + ``` payloadTemplate: | {"text": "event id {{ .Id }}"} ``` Example: + ``` apiVersion: executor.testkube.io/v1 kind: Webhook @@ -207,35 +212,43 @@ spec: ### Webhook Payload Variables -Webhook payload can contain **event-specific** variables - they will be replaced with actual data when the events occurs. In the above examples, only the event `Id` is being sent. + +Webhook payload can contain **event-specific** variables - they will be replaced with actual data when the events occurs. In the above examples, only the event `Id` is being sent. However, any of these [supported Event Variables](#supported-event-variables) can be used. For example, the following payload: + ``` {"text": "Event {{ .Type_ }} - Test '{{ .TestExecution.TestName }}' execution ({{ .TestExecution.Number }}) finished with '{{ .TestExecution.ExecutionResult.Status }}' status"} ``` + will result in: + ``` {"text": "Event end-test-success - Test 'postman-executor-smoke' execution (948) finished with 'passed' status"} ``` #### testkube-api-server ENV variables + In addition to event-specific variables, it's also possible to pass testkube-api-server ENV variables: ```sh title="template.txt" -TESTKUBE_CLOUD_URL: {{ index .Envs "TESTKUBE_CLOUD_URL" }} +TESTKUBE_PRO_URL: {{ index .Envs "TESTKUBE_PRO_URL" }} ``` ### URI and HTTP Headers + You can add additional HTTP headers like `Authorization` or `x-api-key` to have a secret token. It's possible to use golang based template string as header or uri value. ### Helper methods + We also provide special helper methods to use in the webhook template: `executionstatustostring` is the method to convert a pointer to a execution status to a string type. `testsuiteexecutionstatustostring` is the method to convert a pointer to a test suite execution status to a string type. Usage example: + ```yaml - name: TEXT_COLOUR value: {{ if eq (.TestSuiteExecution.Status | testsuiteexecutionstatustostring ) "passed" }}"00FF00"{{ else }}"FF0000"{{ end }} @@ -288,7 +301,9 @@ spec: ## Supported Event types + Webhooks can be triggered on any of the following events: + - start-test - end-test-success - end-test-failed @@ -304,6 +319,7 @@ Webhooks can be triggered on any of the following events: - deleted They can be triggered by the following resources: + - test - testsuite - executor @@ -315,6 +331,7 @@ They can be triggered by the following resources: ## Supported Event Variables ### Event-specific variables: + - `Id` - event ID (for example, `2a20c7da-3b77-4ea9-a33d-403187d3e9e6`) - `Resource` - `ResourceId` @@ -327,16 +344,17 @@ They can be triggered by the following resources: The full Event Data Model can be found [here](https://github.com/kubeshop/testkube/blob/main/pkg/api/v1/testkube/model_event.go). ### TestExecution (Execution): + - `Id` - Execution ID (for example, `64f8cf3c712890925aea51ce`) - `TestName` - Test Name (for example, `postman-executor-smoke`) - `TestSuiteName` - Test Suite name (if run as a part of a Test Suite) - `TestNamespace` - Execution namespace, where testkube is installed (for example, `testkube`) - `TestType` - Test type (for example, `postman/collection`) -- `Name` - Execution name (for example, `postman-executor-smoke-937) +- `Name` - Execution name (for example, `postman-executor-smoke-937) - `Number` - Execution number (for example, `937`) -- `Envs` - List of ENV variables for specific Test (if defined) +- `Envs` - List of ENV variables for specific Test (if defined) - `Command` - Command executed inside the Pod (for example, `newman`) -- `Args` - Command arguments (for example, `run -e --reporters cli,json --reporter-json-export `) +- `Args` - Command arguments (for example, `run -e --reporters cli,json --reporter-json-export `) - `Variables` - List of variables - `Content` - Test content - `StartTime` - Test start time (for example, `2023-09-06 19:23:34.543433547 +0000 UTC`) @@ -369,10 +387,12 @@ The full TestSuiteExecution data model can be found [here](https://github.com/ku ## Additional Examples ### Microsoft Teams + Webhooks can also be used to send messages to Microsoft Teams channels. First, you need to create an incoming webhook in Teams for a specific channel. You can see how to do it in the Teams Docs [here](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook?tabs=dotnet#create-incoming-webhooks-1). After your Teams incoming webhook is created, you can use it with Testkube webhooks - just use the URL provided (it will probably look like this: `https://xxxxx.webhook.office.com/xxxxxxxxx`). In order to send the message when test execution finishes, the following Webhook can be used: + ``` apiVersion: executor.testkube.io/v1 kind: Webhook diff --git a/internal/app/api/v1/handlers.go b/internal/app/api/v1/handlers.go index 197c2297bbc..f90edfac2d9 100644 --- a/internal/app/api/v1/handlers.go +++ b/internal/app/api/v1/handlers.go @@ -3,7 +3,6 @@ package v1 import ( "fmt" "net/http" - "os" "strings" "github.com/gofiber/fiber/v2" @@ -22,11 +21,6 @@ const ( // mediaTypeYAML is yaml media type mediaTypeYAML = "text/yaml" - // env names for cloud context - cloudApiKeyEnvName = "TESTKUBE_CLOUD_API_KEY" - cloudEnvIdEnvName = "TESTKUBE_CLOUD_ENV_ID" - cloudOrgIdEnvName = "TESTKUBE_CLOUD_ORG_ID" - // contextCloud is cloud context contextCloud = "cloud" // contextOSS is oss context @@ -57,17 +51,22 @@ func (s *TestkubeAPI) AuthHandler() fiber.Handler { // InfoHandler is a handler to get info func (s *TestkubeAPI) InfoHandler() fiber.Handler { apiContext := contextOSS - if os.Getenv(cloudApiKeyEnvName) != "" { + if s.proContext != nil && s.proContext.APIKey != "" { apiContext = contextCloud } + var envID, orgID string + if s.proContext != nil { + envID = s.proContext.EnvID + orgID = s.proContext.OrgID + } return func(c *fiber.Ctx) error { return c.JSON(testkube.ServerInfo{ Commit: version.Commit, Version: version.Version, Namespace: s.Namespace, Context: apiContext, - EnvId: os.Getenv(cloudEnvIdEnvName), - OrgId: os.Getenv(cloudOrgIdEnvName), + EnvId: envID, + OrgId: orgID, HelmchartVersion: s.helmchartVersion, DashboardUri: s.dashboardURI, Features: &testkube.Features{ diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 2935b160ea9..03bfe0df3f4 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -13,8 +13,9 @@ import ( "github.com/pkg/errors" + "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/repository/config" + repoConfig "github.com/kubeshop/testkube/pkg/repository/config" "github.com/kubeshop/testkube/pkg/version" @@ -71,7 +72,7 @@ func NewTestkubeAPI( clientset kubernetes.Interface, testkubeClientset testkubeclientset.Interface, testsourcesClient *testsourcesclientv1.TestSourcesClient, - configMap config.Repository, + configMap repoConfig.Repository, clusterId string, eventsEmitter *event.Emitter, executor client.Executor, @@ -183,7 +184,7 @@ type TestkubeAPI struct { oauthParams oauthParams WebsocketLoader *ws.WebsocketLoader Events *event.Emitter - ConfigMap config.Repository + ConfigMap repoConfig.Repository scheduler *scheduler.Scheduler Clientset kubernetes.Interface slackLoader *slack.SlackLoader @@ -198,6 +199,7 @@ type TestkubeAPI struct { featureFlags featureflags.FeatureFlags logsStream logsclient.Stream logGrpcClient logsclient.StreamGetter + proContext *config.ProContext } type storageParams struct { @@ -588,3 +590,8 @@ func getFilterFromRequest(c *fiber.Ctx) result.Filter { return filter } + +func (s *TestkubeAPI) WithProContext(proContext *config.ProContext) *TestkubeAPI { + s.proContext = proContext + return s +} diff --git a/internal/config/config.go b/internal/config/config.go index 6a224eb3667..203ded0025a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,9 @@ type Config struct { TestkubeProWorkerCount int `envconfig:"TESTKUBE_PRO_WORKER_COUNT" default:"50"` TestkubeProLogStreamWorkerCount int `envconfig:"TESTKUBE_PRO_LOG_STREAM_WORKER_COUNT" default:"25"` TestkubeProSkipVerify bool `envconfig:"TESTKUBE_PRO_SKIP_VERIFY" default:"false"` + TestkubeProEnvID string `envconfig:"TESTKUBE_PRO_ENV_ID" default:""` + TestkubeProOrgID string `envconfig:"TESTKUBE_PRO_ORG_ID" default:""` + TestkubeProMigrate string `envconfig:"TESTKUBE_PRO_MIGRATE" default:"false"` TestkubeWatcherNamespaces string `envconfig:"TESTKUBE_WATCHER_NAMESPACES" default:""` GraphqlPort string `envconfig:"TESTKUBE_GRAPHQL_PORT" default:"8070"` TestkubeRegistry string `envconfig:"TESTKUBE_REGISTRY" default:""` @@ -96,6 +99,12 @@ type Config struct { TestkubeCloudWorkerCount int `envconfig:"TESTKUBE_CLOUD_WORKER_COUNT" default:"50"` // DEPRECATED: Use TestkubeProLogStreamWorkerCount instead TestkubeCloudLogStreamWorkerCount int `envconfig:"TESTKUBE_CLOUD_LOG_STREAM_WORKER_COUNT" default:"25"` + // DEPRECATED: Use TestkubeProEnvID instead + TestkubeCloudEnvID string `envconfig:"TESTKUBE_CLOUD_ENV_ID" default:""` + // DEPRECATED: Use TestkubeProOrgID instead + TestkubeCloudOrgID string `envconfig:"TESTKUBE_CLOUD_ORG_ID" default:""` + // DEPRECATED: Use TestkubeProMigrate instead + TestkubeCloudMigrate string `envconfig:"TESTKUBE_CLOUD_MIGRATE" default:"false"` } func Get() (*Config, error) { @@ -127,4 +136,16 @@ func (c *Config) CleanLegacyVars() { if c.TestkubeProLogStreamWorkerCount == 0 && c.TestkubeCloudLogStreamWorkerCount != 0 { c.TestkubeProLogStreamWorkerCount = c.TestkubeCloudLogStreamWorkerCount } + + if c.TestkubeProEnvID == "" && c.TestkubeCloudEnvID != "" { + c.TestkubeProEnvID = c.TestkubeCloudEnvID + } + + if c.TestkubeProOrgID == "" && c.TestkubeCloudOrgID != "" { + c.TestkubeProOrgID = c.TestkubeCloudOrgID + } + + if c.TestkubeProMigrate == "" && c.TestkubeCloudMigrate != "" { + c.TestkubeProMigrate = c.TestkubeCloudMigrate + } } diff --git a/internal/config/procontext.go b/internal/config/procontext.go new file mode 100644 index 00000000000..163d74297ae --- /dev/null +++ b/internal/config/procontext.go @@ -0,0 +1,14 @@ +package config + +type ProContext struct { + APIKey string + URL string + LogsPath string + TLSInsecure bool + WorkerCount int + LogStreamWorkerCount int + SkipVerify bool + EnvID string + OrgID string + Migrate string +} diff --git a/internal/featureflags/featueflags.go b/internal/featureflags/featureflags.go similarity index 100% rename from internal/featureflags/featueflags.go rename to internal/featureflags/featureflags.go diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index e7ac4d2de57..84676975829 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "fmt" "math" - "os" "time" "google.golang.org/grpc/keepalive" @@ -24,6 +23,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/cloud" @@ -37,10 +37,6 @@ const ( orgIdMeta = "environment-id" envIdMeta = "organization-id" healthcheckCommand = "healthcheck" - - cloudMigrateEnvName = "TESTKUBE_CLOUD_MIGRATE" - cloudEnvIdEnvName = "TESTKUBE_CLOUD_ENV_ID" - cloudOrgIdEnvName = "TESTKUBE_CLOUD_ORG_ID" ) // buffer up to five messages per worker @@ -104,40 +100,41 @@ type Agent struct { clusterName string envs map[string]string features featureflags.FeatureFlags + + proContext config.ProContext } func NewAgent(logger *zap.SugaredLogger, handler fasthttp.RequestHandler, - apiKey string, client cloud.TestKubeCloudAPIClient, - workerCount int, - logStreamWorkerCount int, logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error), clusterID string, clusterName string, envs map[string]string, features featureflags.FeatureFlags, + proContext config.ProContext, ) (*Agent, error) { return &Agent{ handler: handler, logger: logger, - apiKey: apiKey, + apiKey: proContext.APIKey, client: client, events: make(chan testkube.Event), - workerCount: workerCount, - requestBuffer: make(chan *cloud.ExecuteRequest, bufferSizePerWorker*workerCount), - responseBuffer: make(chan *cloud.ExecuteResponse, bufferSizePerWorker*workerCount), + workerCount: proContext.WorkerCount, + requestBuffer: make(chan *cloud.ExecuteRequest, bufferSizePerWorker*proContext.WorkerCount), + responseBuffer: make(chan *cloud.ExecuteResponse, bufferSizePerWorker*proContext.WorkerCount), receiveTimeout: 5 * time.Minute, sendTimeout: 30 * time.Second, healthcheckInterval: 30 * time.Second, - logStreamWorkerCount: logStreamWorkerCount, - logStreamRequestBuffer: make(chan *cloud.LogsStreamRequest, bufferSizePerWorker*logStreamWorkerCount), - logStreamResponseBuffer: make(chan *cloud.LogsStreamResponse, bufferSizePerWorker*logStreamWorkerCount), + logStreamWorkerCount: proContext.LogStreamWorkerCount, + logStreamRequestBuffer: make(chan *cloud.LogsStreamRequest, bufferSizePerWorker*proContext.LogStreamWorkerCount), + logStreamResponseBuffer: make(chan *cloud.LogsStreamResponse, bufferSizePerWorker*proContext.LogStreamWorkerCount), logStreamFunc: logStreamFunc, clusterID: clusterID, clusterName: clusterName, envs: envs, features: features, + proContext: proContext, }, nil } @@ -244,14 +241,14 @@ func (ag *Agent) receiveCommand(ctx context.Context, stream cloud.TestKubeCloudA } func (ag *Agent) runCommandLoop(ctx context.Context) error { - ctx = AddAPIKeyMeta(ctx, ag.apiKey) + ctx = AddAPIKeyMeta(ctx, ag.proContext.APIKey) ctx = metadata.AppendToOutgoingContext(ctx, clusterIDMeta, ag.clusterID) - ctx = metadata.AppendToOutgoingContext(ctx, cloudMigrateMeta, os.Getenv(cloudMigrateEnvName)) - ctx = metadata.AppendToOutgoingContext(ctx, envIdMeta, os.Getenv(cloudEnvIdEnvName)) - ctx = metadata.AppendToOutgoingContext(ctx, orgIdMeta, os.Getenv(cloudOrgIdEnvName)) + ctx = metadata.AppendToOutgoingContext(ctx, cloudMigrateMeta, ag.proContext.Migrate) + ctx = metadata.AppendToOutgoingContext(ctx, envIdMeta, ag.proContext.EnvID) + ctx = metadata.AppendToOutgoingContext(ctx, orgIdMeta, ag.proContext.OrgID) - ag.logger.Infow("initiating streaming connection with Cloud API") + ag.logger.Infow("initiating streaming connection with Pro API") // creates a new Stream from the client side. ctx is used for the lifetime of the stream. opts := []grpc.CallOption{grpc.UseCompressor(gzip.Name), grpc.MaxCallRecvMsgSize(math.MaxInt32)} stream, err := ag.client.ExecuteAsync(ctx, opts...) diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 5dd1bbcd3b3..0dedd5ff6a1 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/cloud" @@ -57,7 +58,8 @@ func TestCommandExecution(t *testing.T) { var logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) logger, _ := zap.NewDevelopment() - agent, err := agent.NewAgent(logger.Sugar(), m, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}) + proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5} + agent, err := agent.NewAgent(logger.Sugar(), m, grpcClient, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) if err != nil { t.Fatal(err) } diff --git a/pkg/agent/events_test.go b/pkg/agent/events_test.go index f24526b4ab9..dc2c716c5fc 100644 --- a/pkg/agent/events_test.go +++ b/pkg/agent/events_test.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/api/v1/testkube" @@ -53,7 +54,8 @@ func TestEventLoop(t *testing.T) { grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) var logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) - agent, err := agent.NewAgent(logger.Sugar(), nil, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}) + proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5} + agent, err := agent.NewAgent(logger.Sugar(), nil, grpcClient, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) assert.NoError(t, err) go func() { l, err := agent.Load() diff --git a/pkg/agent/logs_test.go b/pkg/agent/logs_test.go index 8d56c673f1b..c38b125e05e 100644 --- a/pkg/agent/logs_test.go +++ b/pkg/agent/logs_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/cloud" @@ -64,7 +65,8 @@ func TestLogStream(t *testing.T) { } logger, _ := zap.NewDevelopment() - agent, err := agent.NewAgent(logger.Sugar(), m, "api-key", grpcClient, 5, 5, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}) + proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5} + agent, err := agent.NewAgent(logger.Sugar(), m, grpcClient, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) if err != nil { t.Fatal(err) } diff --git a/pkg/envs/variables.go b/pkg/envs/variables.go index 292f0bfb9e5..5c9fd06aa7f 100644 --- a/pkg/envs/variables.go +++ b/pkg/envs/variables.go @@ -42,6 +42,12 @@ type Params struct { CloudAPIURL string `envconfig:"RUNNER_CLOUD_API_URL"` // RUNNER_CLOUD_API_URL CloudConnectionTimeoutSec int `envconfig:"RUNNER_CLOUD_CONNECTION_TIMEOUT" default:"10"` // RUNNER_CLOUD_CONNECTION_TIMEOUT CloudAPISkipVerify bool `envconfig:"RUNNER_CLOUD_API_SKIP_VERIFY" default:"false"` // RUNNER_CLOUD_API_SKIP_VERIFY + ProMode bool `envconfig:"RUNNER_PRO_MODE"` // RUNNER_PRO_MODE + ProAPIKey string `envconfig:"RUNNER_PRO_API_KEY"` // RUNNER_PRO_API_KEY + ProAPITLSInsecure bool `envconfig:"RUNNER_PRO_API_TLS_INSECURE"` // RUNNER_PRO_API_TLS_INSECURE + ProAPIURL string `envconfig:"RUNNER_PRO_API_URL"` // RUNNER_PRO_API_URL + ProConnectionTimeoutSec int `envconfig:"RUNNER_PRO_CONNECTION_TIMEOUT" default:"10"` // RUNNER_PRO_CONNECTION_TIMEOUT + ProAPISkipVerify bool `envconfig:"RUNNER_PRO_API_SKIP_VERIFY" default:"false"` // RUNNER_PRO_API_SKIP_VERIFY SlavesConfigs string `envconfig:"RUNNER_SLAVES_CONFIGS"` // RUNNER_SLAVES_CONFIGS } @@ -52,7 +58,7 @@ func LoadTestkubeVariables() (Params, error) { if err != nil { return params, errors.Errorf("failed to read environment variables: %v", err) } - + cleanDeprecatedParams(¶ms) return params, nil } @@ -81,12 +87,19 @@ func PrintParams(params Params) { output.PrintLogf("RUNNER_CLUSTERID=\"%s\"", params.ClusterID) output.PrintLogf("RUNNER_CDEVENTS_TARGET=\"%s\"", params.CDEventsTarget) output.PrintLogf("RUNNER_DASHBOARD_URI=\"%s\"", params.DashboardURI) - output.PrintLogf("RUNNER_CLOUD_MODE=\"%t\"", params.CloudMode) - output.PrintLogf("RUNNER_CLOUD_API_TLS_INSECURE=\"%t\"", params.CloudAPITLSInsecure) - output.PrintLogf("RUNNER_CLOUD_API_URL=\"%s\"", params.CloudAPIURL) - printSensitiveParam("RUNNER_CLOUD_API_KEY", params.CloudAPIKey) - output.PrintLogf("RUNNER_CLOUD_CONNECTION_TIMEOUT=%d", params.CloudConnectionTimeoutSec) - output.PrintLogf("RUNNER_CLOUD_API_SKIP_VERIFY=\"%t\"", params.CloudAPISkipVerify) + output.PrintLogf("RUNNER_CLOUD_MODE=\"%t\" - DEPRECATED: please use RUNNER_PRO_MODE instead", params.CloudMode) + output.PrintLogf("RUNNER_CLOUD_API_TLS_INSECURE=\"%t\" - DEPRECATED: please use RUNNER_PRO_API_TLS_INSECURE instead", params.CloudAPITLSInsecure) + output.PrintLogf("RUNNER_CLOUD_API_URL=\"%s\" - DEPRECATED: please use RUNNER_PRO_API_URL instead", params.CloudAPIURL) + printSensitiveDeprecatedParam("RUNNER_CLOUD_API_KEY", params.CloudAPIKey, "RUNNER_PRO_API_KEY") + output.PrintLogf("RUNNER_CLOUD_CONNECTION_TIMEOUT=%d - DEPRECATED: please use RUNNER_PRO_CONNECTION_TIMEOUT instead", params.CloudConnectionTimeoutSec) + output.PrintLogf("RUNNER_CLOUD_API_SKIP_VERIFY=\"%t\" - DEPRECATED: please use RUNNER_PRO_API_SKIP_VERIFY instead", params.CloudAPISkipVerify) + output.PrintLogf("RUNNER_PRO_MODE=\"%t\"", params.ProMode) + output.PrintLogf("RUNNER_PRO_API_TLS_INSECURE=\"%t\"", params.ProAPITLSInsecure) + output.PrintLogf("RUNNER_PRO_API_URL=\"%s\"", params.ProAPIURL) + printSensitiveParam("RUNNER_PRO_API_KEY", params.ProAPIKey) + output.PrintLogf("RUNNER_PRO_CONNECTION_TIMEOUT=%d", params.ProConnectionTimeoutSec) + output.PrintLogf("RUNNER_PRO_API_SKIP_VERIFY=\"%t\"", params.ProAPISkipVerify) + } // printSensitiveParam shows in logs if a parameter is set or not @@ -97,3 +110,39 @@ func printSensitiveParam(name string, value string) { output.PrintLogf("%s=\"********\"", name) } } + +// printSensitiveDeprecatedParam shows in logs if a parameter is set or not +func printSensitiveDeprecatedParam(name string, value string, newName string) { + if len(value) == 0 { + output.PrintLogf("%s=\"\" - DEPRECATED: please use %s instead", name, newName) + } else { + output.PrintLogf("%s=\"********\" - DEPRECATED: please use %s instead", name, newName) + } +} + +// cleanDeprecatedParams makes sure deprecated parameter values are set in replacements +func cleanDeprecatedParams(params *Params) { + if !params.ProMode && params.CloudMode { + params.ProMode = params.CloudMode + } + + if params.ProAPIKey == "" && params.CloudAPIKey != "" { + params.ProAPIKey = params.CloudAPIKey + } + + if !params.ProAPITLSInsecure && params.CloudAPITLSInsecure { + params.ProAPITLSInsecure = params.CloudAPITLSInsecure + } + + if params.ProAPIURL == "" && params.CloudAPIURL != "" { + params.ProAPIURL = params.CloudAPIURL + } + + if params.ProConnectionTimeoutSec == 0 && params.CloudConnectionTimeoutSec != 0 { + params.ProConnectionTimeoutSec = params.CloudConnectionTimeoutSec + } + + if !params.ProAPISkipVerify && params.CloudAPISkipVerify { + params.ProAPISkipVerify = params.CloudAPISkipVerify + } +} diff --git a/pkg/executor/common.go b/pkg/executor/common.go index e832c8f8acf..c10723a8b4b 100644 --- a/pkg/executor/common.go +++ b/pkg/executor/common.go @@ -23,6 +23,7 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/log" executorsmapper "github.com/kubeshop/testkube/pkg/mapper/executors" + "github.com/kubeshop/testkube/pkg/utils" ) var ErrPodInitializing = errors.New("PodInitializing") @@ -103,23 +104,23 @@ var RunnerEnvVars = []corev1.EnvVar{ Value: getOr("COMPRESSARTIFACTS", "false"), }, { - Name: "RUNNER_CLOUD_MODE", - Value: getRunnerCloudMode(), + Name: "RUNNER_PRO_MODE", + Value: getRunnerProMode(), }, { - Name: "RUNNER_CLOUD_API_KEY", - Value: os.Getenv("TESTKUBE_CLOUD_API_KEY"), + Name: "RUNNER_PRO_API_KEY", + Value: utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_API_KEY", "TESTKUBE_CLOUD_API_KEY", ""), }, { - Name: "RUNNER_CLOUD_API_TLS_INSECURE", - Value: getOr("TESTKUBE_CLOUD_TLS_INSECURE", "false"), + Name: "RUNNER_PRO_API_TLS_INSECURE", + Value: utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_TLS_INSECURE", "TESTKUBE_CLOUD_TLS_INSECURE", "false"), }, { - Name: "RUNNER_CLOUD_API_URL", - Value: os.Getenv("TESTKUBE_CLOUD_URL"), + Name: "RUNNER_PRO_API_URL", + Value: utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_URL", "TESTKUBE_CLOUD_URL", ""), }, { - Name: "RUNNER_CLOUD_API_SKIP_VERIFY", + Name: "RUNNER_PRO_API_SKIP_VERIFY", Value: getOr("TESTKUBE_PRO_SKIP_VERIFY", "false"), }, { @@ -130,6 +131,31 @@ var RunnerEnvVars = []corev1.EnvVar{ Name: "CI", Value: "1", }, + // DEPRECATED: Use RUNNER_PRO_MODE instead + { + Name: "RUNNER_CLOUD_MODE", + Value: getRunnerProMode(), + }, + // DEPRECATED: Use RUNNER_PRO_API_KEY instead + { + Name: "RUNNER_CLOUD_API_KEY", + Value: utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_API_KEY", "TESTKUBE_CLOUD_API_KEY", ""), + }, + // DEPRECATED: Use RUNNER_PRO_API_TLS_INSECURE instead + { + Name: "RUNNER_CLOUD_API_TLS_INSECURE", + Value: utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_TLS_INSECURE", "TESTKUBE_CLOUD_TLS_INSECURE", "false"), + }, + // DEPRECATED: Use RUNNER_PRO_API_URL instead + { + Name: "RUNNER_CLOUD_API_URL", + Value: utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_URL", "TESTKUBE_CLOUD_URL", ""), + }, + // DEPRECATED: Use RUNNER_PRO_API_SKIP_VERIFY instead + { + Name: "RUNNER_CLOUD_API_SKIP_VERIFY", + Value: getOr("TESTKUBE_PRO_SKIP_VERIFY", "false"), + }, } type SlavesConfigs struct { @@ -183,9 +209,9 @@ func getOr(key, defaultVal string) string { return defaultVal } -func getRunnerCloudMode() string { +func getRunnerProMode() string { val := "false" - if os.Getenv("TESTKUBE_CLOUD_API_KEY") != "" { + if utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_API_KEY", "TESTKUBE_CLOUD_API_KEY", "") != "" { val = "true" } return val diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 288f8d76a16..1901ef68dc3 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -153,11 +153,16 @@ func TestNewExecutorJobSpecWithArgs(t *testing.T) { {Name: "RUNNER_CONTEXTTYPE", Value: ""}, {Name: "RUNNER_CONTEXTDATA", Value: ""}, {Name: "RUNNER_APIURI", Value: ""}, - {Name: "RUNNER_CLOUD_MODE", Value: "false"}, - {Name: "RUNNER_CLOUD_API_KEY", Value: ""}, - {Name: "RUNNER_CLOUD_API_URL", Value: ""}, - {Name: "RUNNER_CLOUD_API_TLS_INSECURE", Value: "false"}, - {Name: "RUNNER_CLOUD_API_SKIP_VERIFY", Value: "false"}, + {Name: "RUNNER_PRO_MODE", Value: "false"}, + {Name: "RUNNER_PRO_API_KEY", Value: ""}, + {Name: "RUNNER_PRO_API_URL", Value: ""}, + {Name: "RUNNER_PRO_API_TLS_INSECURE", Value: "false"}, + {Name: "RUNNER_PRO_API_SKIP_VERIFY", Value: "false"}, + {Name: "RUNNER_CLOUD_MODE", Value: "false"}, // DEPRECATED + {Name: "RUNNER_CLOUD_API_KEY", Value: ""}, // DEPRECATED + {Name: "RUNNER_CLOUD_API_URL", Value: ""}, // DEPRECATED + {Name: "RUNNER_CLOUD_API_TLS_INSECURE", Value: "false"}, // DEPRECATED + {Name: "RUNNER_CLOUD_API_SKIP_VERIFY", Value: "false"}, // DEPRECATED {Name: "RUNNER_CLUSTERID", Value: ""}, {Name: "CI", Value: "1"}, {Name: "key", Value: "value"}, diff --git a/pkg/executor/scraper/factory/factory.go b/pkg/executor/scraper/factory/factory.go index dc402468e2b..ef29fcd942c 100644 --- a/pkg/executor/scraper/factory/factory.go +++ b/pkg/executor/scraper/factory/factory.go @@ -33,7 +33,7 @@ const ( func TryGetScrapper(ctx context.Context, params envs.Params) (scraper.Scraper, error) { if params.ScrapperEnabled { uploader := MinIOUploader - if params.CloudMode { + if params.ProMode { uploader = CloudUploader } extractor := RecursiveFilesystemExtractor @@ -58,7 +58,7 @@ func GetScraper(ctx context.Context, params envs.Params, extractorType Extractor extractor = scraper.NewRecursiveFilesystemExtractor(filesystem.NewOSFileSystem()) case ArchiveFilesystemExtractor: var opts []scraper.ArchiveFilesystemExtractorOpts - if params.CloudMode { + if params.ProMode { opts = append(opts, scraper.GenerateTarballMetaFile()) } extractor = scraper.NewArchiveFilesystemExtractor(filesystem.NewOSFileSystem(), opts...) @@ -96,20 +96,20 @@ func GetScraper(ctx context.Context, params envs.Params, extractorType Extractor func getRemoteStorageUploader(ctx context.Context, params envs.Params) (uploader *cloudscraper.CloudUploader, err error) { // timeout blocking connection to cloud - ctxTimeout, cancel := context.WithTimeout(ctx, time.Duration(params.CloudConnectionTimeoutSec)*time.Second) + ctxTimeout, cancel := context.WithTimeout(ctx, time.Duration(params.ProConnectionTimeoutSec)*time.Second) defer cancel() output.PrintLogf( "%s Uploading artifacts using Remote Storage Uploader (timeout:%ds, agentInsecure:%v, agentSkipVerify: %v, url: %s, scraperSkipVerify: %v)", - ui.IconCheckMark, params.CloudConnectionTimeoutSec, params.CloudAPITLSInsecure, params.CloudAPISkipVerify, params.CloudAPIURL, params.SkipVerify) - grpcConn, err := agent.NewGRPCConnection(ctxTimeout, params.CloudAPITLSInsecure, params.CloudAPISkipVerify, params.CloudAPIURL, log.DefaultLogger) + ui.IconCheckMark, params.ProConnectionTimeoutSec, params.ProAPITLSInsecure, params.ProAPISkipVerify, params.ProAPIURL, params.SkipVerify) + grpcConn, err := agent.NewGRPCConnection(ctxTimeout, params.ProAPITLSInsecure, params.ProAPISkipVerify, params.ProAPIURL, log.DefaultLogger) if err != nil { return nil, err } output.PrintLogf("%s Connected to Agent API", ui.IconCheckMark) grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) - cloudExecutor := cloudexecutor.NewCloudGRPCExecutor(grpcClient, grpcConn, params.CloudAPIKey) + cloudExecutor := cloudexecutor.NewCloudGRPCExecutor(grpcClient, grpcConn, params.ProAPIKey) return cloudscraper.NewCloudUploader(cloudExecutor, params.SkipVerify), nil } diff --git a/pkg/telemetry/payload.go b/pkg/telemetry/payload.go index 60a7cf108ad..4ba46da0efd 100644 --- a/pkg/telemetry/payload.go +++ b/pkg/telemetry/payload.go @@ -1,10 +1,10 @@ package telemetry import ( - "os" "runtime" "strings" + "github.com/kubeshop/testkube/pkg/utils" "github.com/kubeshop/testkube/pkg/utils/text" ) @@ -194,8 +194,8 @@ func AnonymizeHost(host string) string { } func getAgentContext() RunContext { - orgID := os.Getenv("TESTKUBE_CLOUD_ORG_ID") - envID := os.Getenv("TESTKUBE_CLOUD_ENV_ID") + orgID := utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_ORG_ID", "TESTKUBE_CLOUD_ORG_ID", "") + envID := utils.GetEnvVarWithDeprecation("TESTKUBE_PRO_ENV_ID", "TESTKUBE_CLOUD_ENV_ID", "") if orgID == "" || envID == "" { return RunContext{} diff --git a/pkg/telemetry/sender_sio.go b/pkg/telemetry/sender_sio.go index c9253a4ec64..7a26a91076d 100644 --- a/pkg/telemetry/sender_sio.go +++ b/pkg/telemetry/sender_sio.go @@ -8,10 +8,12 @@ import ( "github.com/segmentio/analytics-go/v3" "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/utils" ) const SegmentioEnvVariableName = "TESTKUBE_SEGMENTIO_KEY" const CloudEnvVariableName = "TESTKUBE_CLOUD_API_KEY" +const ProEnvVariableName = "TESTKUBE_PRO_API_KEY" // Brew builds can't be parametrized so we are embedding this one var SegmentioKey = "jELokNFNcLeQhxdpGF47PcxCtOLpwVuu" @@ -40,10 +42,9 @@ func SegmentioSender(client *http.Client, payload Payload) (out string, err erro if key, ok := os.LookupEnv(SegmentioEnvVariableName); ok { SegmentioKey = key } - if key, ok := os.LookupEnv(CloudEnvVariableName); ok { - if key != "" { - SegmentioKey = CloudSegmentioKey - } + key := utils.GetEnvVarWithDeprecation(ProEnvVariableName, CloudEnvVariableName, "") + if key != "" { + SegmentioKey = CloudSegmentioKey } segmentio, err := analytics.NewWithConfig(SegmentioKey, analytics.Config{Logger: StdLogger()}) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 6881b120b98..d8981cbc921 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "math/big" + "os" "path/filepath" "regexp" "strings" @@ -141,3 +142,16 @@ func IsBase64Encoded(base64Val string) bool { encoded := base64.StdEncoding.EncodeToString(decoded) return base64Val == encoded } + +// GetEnvVarWithDeprecation returns the value of the environment variable with the given key, +// or the value of the environment variable with the given deprecated key, or the default value +// if neither is set +func GetEnvVarWithDeprecation(key, deprecatedKey, defaultVal string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + if val, ok := os.LookupEnv(deprecatedKey); ok { + return val + } + return defaultVal +} From 7a927b85c8ed9fd0236d0e7b38a8969f450dff50 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 12 Feb 2024 17:07:12 +0100 Subject: [PATCH 089/234] fix: kill application when on 2nd termination signal provided (#5002) --- cmd/api-server/main.go | 4 ++++ cmd/kubectl-testkube/commands/root.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index d894bb09014..efedda6ad1e 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -150,6 +150,10 @@ func main() { case <-ctx.Done(): return nil case sig := <-stopSignal: + go func() { + <-stopSignal + os.Exit(137) + }() // Returning an error cancels the errgroup. return errors.Errorf("received signal: %v", sig) } diff --git a/cmd/kubectl-testkube/commands/root.go b/cmd/kubectl-testkube/commands/root.go index 6dcf94c328a..5c5468d0055 100644 --- a/cmd/kubectl-testkube/commands/root.go +++ b/cmd/kubectl-testkube/commands/root.go @@ -180,6 +180,10 @@ func Execute() { case <-ctx.Done(): return nil case sig := <-stopSignal: + go func() { + <-stopSignal + os.Exit(137) + }() return errors.Errorf("received signal: %v", sig) } }) From 04ed38c10a42c6f549a3049096ffeb4480f8031a Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Tue, 13 Feb 2024 11:34:24 +0100 Subject: [PATCH 090/234] feat: Executor tests - pre/post run script cases (#5003) * executor tests - expected failures extended - pre/post-run script for container executor * executor tests - expected failures testsuite extended * executor tests - expected failures - failed test, passed pre/post-run scripts * empty lines added --- .../edge-cases-expected-fails.yaml | 64 +++++++++++++++++++ .../edge-cases-expected-fails.yaml | 9 +++ 2 files changed, 73 insertions(+) diff --git a/test/special-cases/edge-cases-expected-fails.yaml b/test/special-cases/edge-cases-expected-fails.yaml index 3dcf831b28c..1d5960c65f0 100644 --- a/test/special-cases/edge-cases-expected-fails.yaml +++ b/test/special-cases/edge-cases-expected-fails.yaml @@ -359,3 +359,67 @@ spec: preRunScript: "echo \"===== pre-run script\"" postRunScript: "echo \"===== post-run script - EXPECTED FAIL\" && exit 128" jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: expected-fail-container-pre-run-script + labels: + core-tests: expected-fail +spec: + type: container-executor-postman-newman-6-alpine/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/postman/executor-tests/postman-executor-smoke.postman_collection.json + workingDir: test/postman/executor-tests + executionRequest: + args: ["run", "postman-executor-smoke.postman_collection.json", "--env-var", "TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value"] + preRunScript: "echo \"===== pre-run script - EXPECTED FAIL\" && exit 128" + postRunScript: "echo \"===== post-run script\"" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: expected-fail-container-post-run-script + labels: + core-tests: expected-fail +spec: + type: container-executor-postman-newman-6-alpine/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/postman/executor-tests/postman-executor-smoke.postman_collection.json + workingDir: test/postman/executor-tests + executionRequest: + args: ["run", "postman-executor-smoke.postman_collection.json", "--env-var", "TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value"] + preRunScript: "echo \"===== pre-run script\"" + postRunScript: "echo \"===== post-run script - EXPECTED FAIL\" && exit 128" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: expected-fail-pre-post-run-script + labels: + core-tests: expected-fail +spec: + type: postman/collection + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/postman/executor-tests/postman-executor-smoke-negative.postman_collection.json + executionRequest: + preRunScript: "echo \"===== pre-run script\"" + postRunScript: "echo \"===== post-run script\"" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" diff --git a/test/suites/special-cases/edge-cases-expected-fails.yaml b/test/suites/special-cases/edge-cases-expected-fails.yaml index 555d9e66193..ba3319d6053 100644 --- a/test/suites/special-cases/edge-cases-expected-fails.yaml +++ b/test/suites/special-cases/edge-cases-expected-fails.yaml @@ -64,3 +64,12 @@ spec: - stopOnFailure: false execute: - test: expected-fail-post-run-script + - stopOnFailure: false + execute: + - test: expected-fail-container-pre-run-script + - stopOnFailure: false + execute: + - test: expected-fail-container-post-run-script + - stopOnFailure: false + execute: + - test: expected-fail-pre-post-run-script From 8e47d51112a4cadff4791ebb0860e3a2f804fbef Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 14 Feb 2024 10:35:42 +0100 Subject: [PATCH 091/234] chore: ignore mocks in the code coverage (#5006) --- codecov.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codecov.yaml b/codecov.yaml index 186d462fe1a..1a3ad9a0358 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -1,5 +1,7 @@ ignore: - - "cmd/****" + - "cmd/**/*" + - "**/*_mock.go" + - "**/mock_*.go" coverage: status: From 3ef45989e585f2f7300ace98571c06a8ccbbf8f7 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 14 Feb 2024 10:37:02 +0100 Subject: [PATCH 092/234] feat: improve the Docker image inspection to cache the results in Memory and/or ConfigMap (#5005) --- cmd/api-server/main.go | 15 ++ internal/config/config.go | 2 + .../containerexecutor/containerexecutor.go | 21 +- .../containerexecutor_test.go | 7 + pkg/executor/containerexecutor/tmpl.go | 49 +---- pkg/imageinspector/configmapstorage.go | 142 ++++++++++++++ pkg/imageinspector/configmapstorage_test.go | 179 ++++++++++++++++++ pkg/imageinspector/inspector.go | 100 ++++++++++ pkg/imageinspector/inspector_test.go | 56 ++++++ pkg/imageinspector/memorystorage.go | 59 ++++++ pkg/imageinspector/memorystorage_test.go | 114 +++++++++++ pkg/imageinspector/mock_infofetcher.go | 51 +++++ pkg/imageinspector/mock_inspector.go | 51 +++++ pkg/imageinspector/mock_secretfetcher.go | 51 +++++ pkg/imageinspector/mock_storage.go | 97 ++++++++++ pkg/imageinspector/secretfetcher.go | 50 +++++ pkg/imageinspector/secretfetcher_test.go | 66 +++++++ pkg/imageinspector/serialization.go | 28 +++ pkg/imageinspector/skopeofetcher.go | 35 ++++ pkg/imageinspector/types.go | 57 ++++++ pkg/skopeo/client.go | 1 + 21 files changed, 1187 insertions(+), 44 deletions(-) create mode 100644 pkg/imageinspector/configmapstorage.go create mode 100644 pkg/imageinspector/configmapstorage_test.go create mode 100644 pkg/imageinspector/inspector.go create mode 100644 pkg/imageinspector/inspector_test.go create mode 100644 pkg/imageinspector/memorystorage.go create mode 100644 pkg/imageinspector/memorystorage_test.go create mode 100644 pkg/imageinspector/mock_infofetcher.go create mode 100644 pkg/imageinspector/mock_inspector.go create mode 100644 pkg/imageinspector/mock_secretfetcher.go create mode 100644 pkg/imageinspector/mock_storage.go create mode 100644 pkg/imageinspector/secretfetcher.go create mode 100644 pkg/imageinspector/secretfetcher_test.go create mode 100644 pkg/imageinspector/serialization.go create mode 100644 pkg/imageinspector/skopeofetcher.go create mode 100644 pkg/imageinspector/types.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index efedda6ad1e..abf397aba98 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -15,6 +15,7 @@ import ( "github.com/nats-io/nats.go" executorsclientv1 "github.com/kubeshop/testkube-operator/pkg/client/executors/v1" + "github.com/kubeshop/testkube/pkg/imageinspector" "go.mongodb.org/mongo-driver/mongo" "google.golang.org/grpc" @@ -415,11 +416,25 @@ func main() { ui.ExitOnError("Creating container job templates", err) } + inspectorStorages := []imageinspector.Storage{imageinspector.NewMemoryStorage()} + if cfg.EnableImageDataPersistentCache { + configmapStorage := imageinspector.NewConfigMapStorage(configMapClient, cfg.ImageDataPersistentCacheKey, true) + _ = configmapStorage.CopyTo(context.Background(), inspectorStorages[0].(imageinspector.StorageTransfer)) + inspectorStorages = append(inspectorStorages, configmapStorage) + } + inspector := imageinspector.NewInspector( + cfg.TestkubeRegistry, + imageinspector.NewSkopeoFetcher(), + imageinspector.NewSecretFetcher(secretClient), + inspectorStorages..., + ) + containerExecutor, err := containerexecutor.NewContainerExecutor( resultsRepository, cfg.TestkubeNamespace, images, containerTemplates, + inspector, cfg.JobServiceAccountName, metrics, eventsEmitter, diff --git a/internal/config/config.go b/internal/config/config.go index 203ded0025a..6dc5ee50790 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,6 +88,8 @@ type Config struct { DisableMongoMigrations bool `envconfig:"DISABLE_MONGO_MIGRATIONS" default:"false"` Debug bool `envconfig:"DEBUG" default:"false"` LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` + EnableImageDataPersistentCache bool `envconfig:"ENABLE_IMAGE_DATA_PERSISTENT_CACHE" default:"false"` + ImageDataPersistentCacheKey string `envconfig:"IMAGE_DATA_PERSISTENT_CACHE_KEY" default:"testkube-image-cache"` // DEPRECATED: Use TestkubeProAPIKey instead TestkubeCloudAPIKey string `envconfig:"TESTKUBE_CLOUD_API_KEY" default:""` diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index 148104f7586..c94b94b43de 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -6,8 +6,12 @@ import ( "path/filepath" "time" + "github.com/pkg/errors" + "github.com/kubeshop/testkube/internal/featureflags" + "github.com/kubeshop/testkube/pkg/imageinspector" "github.com/kubeshop/testkube/pkg/repository/config" + "github.com/kubeshop/testkube/pkg/secret" "github.com/kubeshop/testkube/pkg/utils" "github.com/kubeshop/testkube/pkg/repository/result" @@ -57,6 +61,7 @@ func NewContainerExecutor( namespace string, images executor.Images, templates executor.Templates, + imageInspector imageinspector.Inspector, serviceAccountName string, metrics ExecutionCounter, emiter EventEmitter, @@ -87,6 +92,7 @@ func NewContainerExecutor( namespace: namespace, images: images, templates: templates, + imageInspector: imageInspector, configMap: configMap, serviceAccountName: serviceAccountName, metrics: metrics, @@ -119,6 +125,7 @@ type ContainerExecutor struct { namespace string images executor.Images templates executor.Templates + imageInspector imageinspector.Inspector metrics ExecutionCounter emitter EventEmitter configMap config.Repository @@ -285,8 +292,18 @@ func (c *ContainerExecutor) Execute(ctx context.Context, execution *testkube.Exe func (c *ContainerExecutor) createJob(ctx context.Context, execution testkube.Execution, options client.ExecuteOptions) (*JobOptions, error) { jobsClient := c.clientSet.BatchV1().Jobs(c.namespace) - jobOptions, err := NewJobOptions(c.log, c.templatesClient, c.images, c.templates, c.serviceAccountName, - c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) + // Fallback to one-time inspector when non-default namespace is needed + inspector := c.imageInspector + if len(options.ImagePullSecretNames) > 0 && options.Namespace != "" && c.namespace != options.Namespace { + secretClient, err := secret.NewClient(options.Namespace) + if err != nil { + return nil, errors.Wrap(err, "failed to build secrets client") + } + inspector = imageinspector.NewInspector(c.registry, imageinspector.NewSkopeoFetcher(), imageinspector.NewSecretFetcher(secretClient)) + } + + jobOptions, err := NewJobOptions(c.log, c.templatesClient, c.images, c.templates, inspector, + c.serviceAccountName, c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) if err != nil { return nil, err } diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 1901ef68dc3..dfe0776e5c2 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -21,6 +21,7 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/imageinspector" "github.com/kubeshop/testkube/pkg/repository/result" ) @@ -202,12 +203,14 @@ func TestNewExecutorJobSpecWithWorkingDirRelative(t *testing.T) { defer mockCtrl.Finish() mockTemplatesClient := templatesclientv1.NewMockInterface(mockCtrl) + mockInspector := imageinspector.NewMockInspector(mockCtrl) jobOptions, _ := NewJobOptions( logger(), mockTemplatesClient, executor.Images{}, executor.Templates{}, + mockInspector, "", "", "", @@ -247,12 +250,14 @@ func TestNewExecutorJobSpecWithWorkingDirAbsolute(t *testing.T) { defer mockCtrl.Finish() mockTemplatesClient := templatesclientv1.NewMockInterface(mockCtrl) + mockInspector := imageinspector.NewMockInspector(mockCtrl) jobOptions, _ := NewJobOptions( logger(), mockTemplatesClient, executor.Images{}, executor.Templates{}, + mockInspector, "", "", "", @@ -291,12 +296,14 @@ func TestNewExecutorJobSpecWithoutWorkingDir(t *testing.T) { defer mockCtrl.Finish() mockTemplatesClient := templatesclientv1.NewMockInterface(mockCtrl) + mockInspector := imageinspector.NewMockInspector(mockCtrl) jobOptions, _ := NewJobOptions( logger(), mockTemplatesClient, executor.Images{}, executor.Templates{}, + mockInspector, "", "", "", diff --git a/pkg/executor/containerexecutor/tmpl.go b/pkg/executor/containerexecutor/tmpl.go index 31dbe9bae35..e1ce0822867 100644 --- a/pkg/executor/containerexecutor/tmpl.go +++ b/pkg/executor/containerexecutor/tmpl.go @@ -2,14 +2,14 @@ package containerexecutor import ( "bytes" + "context" + _ "embed" "encoding/json" "fmt" "os" "path/filepath" "strings" - _ "embed" - "go.uber.org/zap" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" @@ -22,8 +22,7 @@ import ( "github.com/kubeshop/testkube/pkg/executor" "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/executor/env" - "github.com/kubeshop/testkube/pkg/secret" - "github.com/kubeshop/testkube/pkg/skopeo" + "github.com/kubeshop/testkube/pkg/imageinspector" "github.com/kubeshop/testkube/pkg/utils" ) @@ -203,56 +202,22 @@ func NewScraperJobSpec(log *zap.SugaredLogger, options *JobOptions) (*batchv1.Jo return &job, nil } -// InspectDockerImage inspects docker image -func InspectDockerImage(namespace, registry, image string, imageSecrets []string) ([]string, string, error) { - inspector := skopeo.NewClient() - if len(imageSecrets) != 0 { - secretClient, err := secret.NewClient(namespace) - if err != nil { - return nil, "", err - } - - var secrets []corev1.Secret - for _, imageSecret := range imageSecrets { - object, err := secretClient.GetObject(imageSecret) - if err != nil { - return nil, "", err - } - - secrets = append(secrets, *object) - } - - inspector, err = skopeo.NewClientFromSecrets(secrets, registry) - if err != nil { - return nil, "", err - } - } - - dockerImage, err := inspector.Inspect(image) - if err != nil { - return nil, "", err - } - - return append(dockerImage.Config.Entrypoint, dockerImage.Config.Cmd...), dockerImage.Shell, nil -} - // TODO refactor JobOptions to use builder pattern // TODO extract JobOptions for both container and job executor to common package in separate PR // NewJobOptions provides job options for templates func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface, images executor.Images, - templates executor.Templates, serviceAccountName, registry, clusterID, apiURI string, + templates executor.Templates, inspector imageinspector.Inspector, serviceAccountName, registry, clusterID, apiURI string, execution testkube.Execution, options client.ExecuteOptions, natsUri string, debug bool) (*JobOptions, error) { jobOptions := NewJobOptionsFromExecutionOptions(options) if execution.PreRunScript != "" || execution.PostRunScript != "" { jobOptions.Command = []string{filepath.Join(executor.VolumeDir, EntrypointScriptName)} if jobOptions.Image != "" { - cmd, shell, err := InspectDockerImage(jobOptions.Namespace, registry, jobOptions.Image, jobOptions.ImagePullSecrets) + info, err := inspector.Inspect(context.Background(), registry, jobOptions.Image, corev1.PullIfNotPresent, jobOptions.ImagePullSecrets) if err == nil { if len(execution.Command) == 0 { - execution.Command = cmd + execution.Command = info.Cmd } - - execution.ContainerShell = shell + execution.ContainerShell = info.Shell } else { log.Errorw("Docker image inspection error", "error", err) } diff --git a/pkg/imageinspector/configmapstorage.go b/pkg/imageinspector/configmapstorage.go new file mode 100644 index 00000000000..84ae09dd33b --- /dev/null +++ b/pkg/imageinspector/configmapstorage.go @@ -0,0 +1,142 @@ +package imageinspector + +import ( + "context" + "encoding/json" + "slices" + "sync" + "time" + + "github.com/pkg/errors" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/kubeshop/testkube/pkg/configmap" +) + +type configmapStorage struct { + client configmap.Interface + name string + avoidDirectGet bool // if there is memory storage prior to this one, all the contents will be copied there anyway + mu sync.RWMutex +} + +func NewConfigMapStorage(client configmap.Interface, name string, avoidDirectGet bool) StorageWithTransfer { + return &configmapStorage{ + client: client, + name: name, + avoidDirectGet: avoidDirectGet, + } +} + +func (c *configmapStorage) fetch(ctx context.Context) (map[string]string, error) { + c.mu.RLock() + defer c.mu.RUnlock() + cache, err := c.client.Get(ctx, c.name) + if err != nil && !k8serrors.IsNotFound(err) { + return nil, errors.Wrap(err, "getting configmap cache") + } + return cache, nil +} + +func cleanOldRecords(currentData *map[string]string) { + // Unmarshal the fetched date for the records + type Entry struct { + time time.Time + name string + } + dates := make([]Entry, 0, len(*currentData)) + var vv Info + for k := range *currentData { + _ = json.Unmarshal([]byte((*currentData)[k]), &vv) + dates = append(dates, Entry{time: vv.FetchedAt, name: k}) + } + slices.SortFunc(dates, func(a, b Entry) int { + if a.time.Before(b.time) { + return -1 + } + return 1 + }) + + // Delete half of the records + for i := 0; i < len(*currentData)/2; i++ { + delete(*currentData, dates[i].name) + } +} + +func (c *configmapStorage) save(ctx context.Context, serializedData map[string]string) error { + c.mu.Lock() + defer c.mu.Unlock() + + // Save data + err := c.client.Apply(ctx, c.name, serializedData) + + // When the cache is too big, delete the oldest items and try again + if err != nil && k8serrors.IsRequestEntityTooLargeError(err) { + cleanOldRecords(&serializedData) + err = c.client.Apply(ctx, c.name, serializedData) + } + return err +} + +func (c *configmapStorage) StoreMany(ctx context.Context, data map[Hash]Info) (err error) { + if data == nil { + return + } + serialized, err := c.fetch(ctx) + if err != nil { + return + } + for k, v := range data { + serialized[string(k)], err = marshalInfo(v) + if err != nil { + return + } + } + return c.save(ctx, serialized) +} + +func (c *configmapStorage) CopyTo(ctx context.Context, other ...StorageTransfer) (err error) { + serialized, err := c.fetch(ctx) + if err != nil { + return + } + data := make(map[Hash]Info, len(serialized)) + for k, v := range serialized { + data[Hash(k)], err = unmarshalInfo(v) + if err != nil { + return + } + } + for _, v := range other { + err = v.StoreMany(ctx, data) + if err != nil { + return + } + } + return +} + +func (c *configmapStorage) Store(ctx context.Context, request RequestBase, info Info) error { + return c.StoreMany(ctx, map[Hash]Info{ + hash(request.Registry, request.Image): info, + }) +} + +func (c *configmapStorage) Get(ctx context.Context, request RequestBase) (*Info, error) { + if c.avoidDirectGet { + return nil, nil + } + data, err := c.fetch(ctx) + if err != nil { + return nil, err + } + value, ok := data[string(hash(request.Registry, request.Image))] + if !ok { + return nil, nil + } + v, err := unmarshalInfo(value) + if err != nil { + return nil, err + } + return &v, nil +} diff --git a/pkg/imageinspector/configmapstorage_test.go b/pkg/imageinspector/configmapstorage_test.go new file mode 100644 index 00000000000..24820d95a03 --- /dev/null +++ b/pkg/imageinspector/configmapstorage_test.go @@ -0,0 +1,179 @@ +package imageinspector + +import ( + "context" + "maps" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kubeshop/testkube/pkg/configmap" +) + +func mustMarshalInfo(v Info) string { + s, e := marshalInfo(v) + if e != nil { + panic(e) + } + return s +} + +func TestConfigMapStorageGet(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + value := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + } + + client.EXPECT().Get(gomock.Any(), "dummy").Return(value, nil) + + v1, err1 := m.Get(context.Background(), req1) + assert.NoError(t, err1) + assert.Equal(t, &info1, v1) +} + +func TestConfigMapStorageGetEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + + client.EXPECT().Get(gomock.Any(), "dummy"). + Return(nil, k8serrors.NewNotFound(schema.GroupResource{}, "dummy")) + + v1, err1 := m.Get(context.Background(), req1) + assert.NoError(t, err1) + assert.Equal(t, noInfoPtr, v1) +} + +func TestConfigMapStorageStore(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + value := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + } + expected := map[string]string{ + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + } + maps.Copy(expected, value) + + client.EXPECT().Get(gomock.Any(), "dummy").Return(value, nil) + client.EXPECT().Apply(gomock.Any(), "dummy", expected).Return(nil) + + err1 := m.Store(context.Background(), req2, info2) + assert.NoError(t, err1) +} + +func TestConfigMapStorageStoreTooLarge(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + value := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + string(hash(req1.Registry+"A", req1.Image)): mustMarshalInfo(info1), + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req2.Registry+"A", req2.Image)): mustMarshalInfo(info2), + } + initial := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + string(hash(req1.Registry+"A", req1.Image)): mustMarshalInfo(info1), + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req2.Registry+"A", req2.Image)): mustMarshalInfo(info2), + string(hash(req3.Registry, req3.Image)): mustMarshalInfo(info3), + } + expected := map[string]string{ + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req2.Registry+"A", req2.Image)): mustMarshalInfo(info2), + string(hash(req3.Registry, req3.Image)): mustMarshalInfo(info3), + } + + client.EXPECT().Get(gomock.Any(), "dummy").Return(value, nil) + client.EXPECT().Apply(gomock.Any(), "dummy", initial).Return(k8serrors.NewRequestEntityTooLargeError("test")) + client.EXPECT().Apply(gomock.Any(), "dummy", expected).Return(nil) + + err1 := m.Store(context.Background(), req3, info3) + assert.NoError(t, err1) +} + +func TestConfigMapStorageStoreMany(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + value := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + } + expected := map[string]string{ + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req3.Registry, req3.Image)): mustMarshalInfo(info3), + } + maps.Copy(expected, value) + + client.EXPECT().Get(gomock.Any(), "dummy").Return(value, nil) + client.EXPECT().Apply(gomock.Any(), "dummy", expected).Return(nil) + + err1 := m.StoreMany(context.Background(), map[Hash]Info{ + hash(req2.Registry, req2.Image): info2, + hash(req3.Registry, req3.Image): info3, + }) + assert.NoError(t, err1) +} + +func TestConfigMapStorageStoreManyTooLarge(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + value := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + string(hash(req1.Registry+"A", req1.Image)): mustMarshalInfo(info1), + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + } + initial := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + string(hash(req1.Registry+"A", req1.Image)): mustMarshalInfo(info1), + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req2.Registry+"A", req2.Image)): mustMarshalInfo(info2), + string(hash(req3.Registry, req3.Image)): mustMarshalInfo(info3), + } + expected := map[string]string{ + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req2.Registry+"A", req2.Image)): mustMarshalInfo(info2), + string(hash(req3.Registry, req3.Image)): mustMarshalInfo(info3), + } + + client.EXPECT().Get(gomock.Any(), "dummy").Return(value, nil) + client.EXPECT().Apply(gomock.Any(), "dummy", initial).Return(k8serrors.NewRequestEntityTooLargeError("test")) + client.EXPECT().Apply(gomock.Any(), "dummy", expected).Return(nil) + + err1 := m.StoreMany(context.Background(), map[Hash]Info{ + hash(req2.Registry+"A", req2.Image): info2, + hash(req3.Registry, req3.Image): info3, + }) + assert.NoError(t, err1) +} + +func TestConfigMapStorageCopyTo(t *testing.T) { + ctrl := gomock.NewController(t) + client := configmap.NewMockInterface(ctrl) + s := NewMockStorageWithTransfer(ctrl) + m := NewConfigMapStorage(client, "dummy", false) + value := map[string]string{ + string(hash(req1.Registry, req1.Image)): mustMarshalInfo(info1), + string(hash(req2.Registry, req2.Image)): mustMarshalInfo(info2), + string(hash(req3.Registry, req3.Image)): mustMarshalInfo(info3), + } + expected := map[Hash]Info{ + hash(req1.Registry, req1.Image): info1, + hash(req2.Registry, req2.Image): info2, + hash(req3.Registry, req3.Image): info3, + } + client.EXPECT().Get(gomock.Any(), "dummy").Return(value, nil) + s.EXPECT().StoreMany(gomock.Any(), expected).Return(nil) + + err1 := m.CopyTo(context.Background(), s) + assert.NoError(t, err1) +} diff --git a/pkg/imageinspector/inspector.go b/pkg/imageinspector/inspector.go new file mode 100644 index 00000000000..129a2721d0d --- /dev/null +++ b/pkg/imageinspector/inspector.go @@ -0,0 +1,100 @@ +package imageinspector + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + + "github.com/kubeshop/testkube/pkg/log" +) + +type inspector struct { + defaultRegistry string + fetcher InfoFetcher + secrets SecretFetcher + storage []Storage +} + +func NewInspector(defaultRegistry string, infoFetcher InfoFetcher, secretFetcher SecretFetcher, storage ...Storage) Inspector { + return &inspector{ + defaultRegistry: defaultRegistry, + fetcher: infoFetcher, + secrets: secretFetcher, + storage: storage, + } +} + +func (i *inspector) get(ctx context.Context, registry, image string) *Info { + for _, s := range i.storage { + v, err := s.Get(ctx, RequestBase{Registry: registry, Image: image}) + if err != nil && !errors.Is(err, context.Canceled) { + log.DefaultLogger.Warnw("error while getting image details from cache", "registry", registry, "image", image, "error", err) + } + if v != nil { + return v + } + } + return nil +} + +func (i *inspector) fetch(ctx context.Context, registry, image string, pullSecretNames []string) (*Info, error) { + // Fetch the secrets + secrets := make([]corev1.Secret, len(pullSecretNames)) + for idx, name := range pullSecretNames { + secret, err := i.secrets.Get(ctx, name) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("fetching '%s' pull secret", name)) + } + secrets[idx] = *secret + } + + // Load the image details + info, err := i.fetcher.Fetch(ctx, registry, image, secrets) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("fetching '%s' image from '%s' registry", image, registry)) + } else if info == nil { + return nil, fmt.Errorf("unknown problem with fetching '%s' image from '%s' registry", image, registry) + } + if info.Shell != "" && !filepath.IsAbs(info.Shell) { + info.Shell = "" + } + return info, err +} + +func (i *inspector) save(ctx context.Context, registry, image string, info *Info) { + if info == nil { + return + } + for _, s := range i.storage { + if err := s.Store(ctx, RequestBase{Registry: registry, Image: image}, *info); err != nil { + log.DefaultLogger.Warnw("error while saving image details in the cache", "registry", registry, "image", image, "error", err) + } + } +} + +func (i *inspector) Inspect(ctx context.Context, registry, image string, pullPolicy corev1.PullPolicy, pullSecretNames []string) (*Info, error) { + // Load from cache + if pullPolicy != corev1.PullAlways { + value := i.get(ctx, registry, image) + if value != nil { + return value, nil + } + } + + // Fetch the data + value, err := i.fetch(ctx, registry, image, pullSecretNames) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("inspecting image: '%s' at '%s' registry", image, registry)) + } + if value == nil { + return nil, fmt.Errorf("not found image details for: '%s' at '%s' registry", image, registry) + } + + // Save asynchronously + go i.save(context.Background(), registry, image, value) + + return value, nil +} diff --git a/pkg/imageinspector/inspector_test.go b/pkg/imageinspector/inspector_test.go new file mode 100644 index 00000000000..e005d7a51d0 --- /dev/null +++ b/pkg/imageinspector/inspector_test.go @@ -0,0 +1,56 @@ +package imageinspector + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestInspectorInspect(t *testing.T) { + ctrl := gomock.NewController(t) + infos := NewMockInfoFetcher(ctrl) + secrets := NewMockSecretFetcher(ctrl) + storage1 := NewMockStorageWithTransfer(ctrl) + storage2 := NewMockStorageWithTransfer(ctrl) + inspector := NewInspector("default", infos, secrets, storage1, storage2) + + sec := corev1.Secret{StringData: map[string]string{"foo": "bar"}} + req := RequestBase{Registry: "regname", Image: "imgname"} + storage1.EXPECT().Get(gomock.Any(), req).Return(nil, nil) + storage2.EXPECT().Get(gomock.Any(), req).Return(nil, nil) + secrets.EXPECT().Get(gomock.Any(), "secname").Return(&sec, nil) + infos.EXPECT().Fetch(gomock.Any(), req.Registry, req.Image, []corev1.Secret{sec}).Return(&info1, nil) + + storage1.EXPECT().Store(gomock.Any(), req, info1).Return(nil) + storage2.EXPECT().Store(gomock.Any(), req, info1).Return(nil) + + v, err := inspector.Inspect(context.Background(), req.Registry, req.Image, corev1.PullIfNotPresent, []string{"secname"}) + assert.NoError(t, err) + assert.Equal(t, &info1, v) + + // Wait until asynchronous storage will be done + <-time.After(10 * time.Millisecond) +} + +func TestInspectorInspectWithCache(t *testing.T) { + ctrl := gomock.NewController(t) + infos := NewMockInfoFetcher(ctrl) + secrets := NewMockSecretFetcher(ctrl) + storage1 := NewMockStorageWithTransfer(ctrl) + storage2 := NewMockStorageWithTransfer(ctrl) + inspector := NewInspector("default", infos, secrets, storage1, storage2) + + req := RequestBase{Registry: "regname", Image: "imgname"} + storage1.EXPECT().Get(gomock.Any(), req).Return(&info1, nil) + + v, err := inspector.Inspect(context.Background(), req.Registry, req.Image, corev1.PullIfNotPresent, []string{"secname"}) + assert.NoError(t, err) + assert.Equal(t, &info1, v) + + // Wait until asynchronous storage will be done + <-time.After(10 * time.Millisecond) +} diff --git a/pkg/imageinspector/memorystorage.go b/pkg/imageinspector/memorystorage.go new file mode 100644 index 00000000000..c9ddfdc115a --- /dev/null +++ b/pkg/imageinspector/memorystorage.go @@ -0,0 +1,59 @@ +package imageinspector + +import ( + "context" + "sync" +) + +type memoryStorage struct { + data map[Hash]Info + mu sync.RWMutex +} + +func NewMemoryStorage() StorageWithTransfer { + return &memoryStorage{ + data: make(map[Hash]Info), + } +} + +func (m *memoryStorage) StoreMany(_ context.Context, data map[Hash]Info) error { + if data == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + for k, v := range data { + if vv, ok := m.data[k]; !ok || v.FetchedAt.After(vv.FetchedAt) { + m.data[k] = v + } + } + return nil +} + +func (m *memoryStorage) CopyTo(ctx context.Context, other ...StorageTransfer) (err error) { + if len(other) == 0 { + return + } + for _, v := range other { + err = v.StoreMany(ctx, m.data) + if err != nil { + return + } + } + return +} + +func (m *memoryStorage) Store(ctx context.Context, request RequestBase, info Info) error { + return m.StoreMany(ctx, map[Hash]Info{ + hash(request.Registry, request.Image): info, + }) +} + +func (m *memoryStorage) Get(_ context.Context, request RequestBase) (*Info, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if v, ok := m.data[hash(request.Registry, request.Image)]; ok { + return &v, nil + } + return nil, nil +} diff --git a/pkg/imageinspector/memorystorage_test.go b/pkg/imageinspector/memorystorage_test.go new file mode 100644 index 00000000000..c43bae44f62 --- /dev/null +++ b/pkg/imageinspector/memorystorage_test.go @@ -0,0 +1,114 @@ +package imageinspector + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +var ( + time1 = time.Now().UTC().Add(-4 * time.Minute) + time2 = time.Now().UTC().Add(-2 * time.Minute) + time3 = time.Now().UTC() + noInfoPtr *Info + info1 = Info{ + FetchedAt: time1, + Entrypoint: []string{"en", "try"}, + Cmd: []string{"c", "md"}, + Shell: "/bin/shell", + WorkingDir: "some-wd", + } + info2 = Info{ + FetchedAt: time2, + Entrypoint: []string{"en", "try2"}, + Cmd: []string{"c", "md2"}, + Shell: "/bin/shell", + WorkingDir: "some-wd", + } + info3 = Info{ + FetchedAt: time3, + Entrypoint: []string{"en", "try3"}, + Cmd: []string{"c", "md3"}, + Shell: "/bin/shell", + WorkingDir: "some-wd", + } + req1 = RequestBase{ + Registry: "foo", + Image: "bar", + } + req1Copy = RequestBase{ + Registry: "foo", + Image: "bar", + } + req2 = RequestBase{ + Registry: "foo2", + Image: "bar2", + } + req3 = RequestBase{ + Registry: "foo3", + Image: "bar3", + } +) + +func TestMemoryStorageGetAndStore(t *testing.T) { + m := NewMemoryStorage() + err1 := m.Store(context.Background(), req1, info1) + err2 := m.Store(context.Background(), req2, info2) + v1, gErr1 := m.Get(context.Background(), req1) + v2, gErr2 := m.Get(context.Background(), req2) + v3, gErr3 := m.Get(context.Background(), req1Copy) + v4, gErr4 := m.Get(context.Background(), req3) + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NoError(t, gErr1) + assert.NoError(t, gErr2) + assert.NoError(t, gErr3) + assert.NoError(t, gErr4) + assert.Equal(t, &info1, v1) + assert.Equal(t, &info2, v2) + assert.Equal(t, &info1, v3) + assert.Equal(t, noInfoPtr, v4) +} + +func TestMemoryStorageStoreManyAndGet(t *testing.T) { + m := NewMemoryStorage() + err1 := m.StoreMany(context.Background(), map[Hash]Info{ + hash(req1.Registry, req1.Image): info1, + hash(req2.Registry, req2.Image): info2, + }) + v1, gErr1 := m.Get(context.Background(), req1) + v2, gErr2 := m.Get(context.Background(), req2) + v3, gErr3 := m.Get(context.Background(), req1Copy) + v4, gErr4 := m.Get(context.Background(), req3) + assert.NoError(t, err1) + assert.NoError(t, gErr1) + assert.NoError(t, gErr2) + assert.NoError(t, gErr3) + assert.NoError(t, gErr4) + assert.Equal(t, &info1, v1) + assert.Equal(t, &info2, v2) + assert.Equal(t, &info1, v3) + assert.Equal(t, noInfoPtr, v4) +} + +func TestMemoryStorageFillAndCopyTo(t *testing.T) { + m := NewMemoryStorage() + value := map[Hash]Info{ + hash(req1.Registry, req1.Image): info1, + hash(req2.Registry, req2.Image): info2, + } + err1 := m.StoreMany(context.Background(), value) + + ctrl := gomock.NewController(t) + mockStorage1 := NewMockStorageWithTransfer(ctrl) + mockStorage2 := NewMockStorageWithTransfer(ctrl) + mockStorage1.EXPECT().StoreMany(gomock.Any(), value) + mockStorage2.EXPECT().StoreMany(gomock.Any(), value) + err2 := m.CopyTo(context.Background(), mockStorage1, mockStorage2) + + assert.NoError(t, err1) + assert.NoError(t, err2) +} diff --git a/pkg/imageinspector/mock_infofetcher.go b/pkg/imageinspector/mock_infofetcher.go new file mode 100644 index 00000000000..ba2b092cf12 --- /dev/null +++ b/pkg/imageinspector/mock_infofetcher.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/imageinspector (interfaces: InfoFetcher) + +// Package imageinspector is a generated GoMock package. +package imageinspector + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "k8s.io/api/core/v1" +) + +// MockInfoFetcher is a mock of InfoFetcher interface. +type MockInfoFetcher struct { + ctrl *gomock.Controller + recorder *MockInfoFetcherMockRecorder +} + +// MockInfoFetcherMockRecorder is the mock recorder for MockInfoFetcher. +type MockInfoFetcherMockRecorder struct { + mock *MockInfoFetcher +} + +// NewMockInfoFetcher creates a new mock instance. +func NewMockInfoFetcher(ctrl *gomock.Controller) *MockInfoFetcher { + mock := &MockInfoFetcher{ctrl: ctrl} + mock.recorder = &MockInfoFetcherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInfoFetcher) EXPECT() *MockInfoFetcherMockRecorder { + return m.recorder +} + +// Fetch mocks base method. +func (m *MockInfoFetcher) Fetch(arg0 context.Context, arg1, arg2 string, arg3 []v1.Secret) (*Info, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Fetch", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*Info) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Fetch indicates an expected call of Fetch. +func (mr *MockInfoFetcherMockRecorder) Fetch(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetch", reflect.TypeOf((*MockInfoFetcher)(nil).Fetch), arg0, arg1, arg2, arg3) +} diff --git a/pkg/imageinspector/mock_inspector.go b/pkg/imageinspector/mock_inspector.go new file mode 100644 index 00000000000..944c9b6a230 --- /dev/null +++ b/pkg/imageinspector/mock_inspector.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/imageinspector (interfaces: Inspector) + +// Package imageinspector is a generated GoMock package. +package imageinspector + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "k8s.io/api/core/v1" +) + +// MockInspector is a mock of Inspector interface. +type MockInspector struct { + ctrl *gomock.Controller + recorder *MockInspectorMockRecorder +} + +// MockInspectorMockRecorder is the mock recorder for MockInspector. +type MockInspectorMockRecorder struct { + mock *MockInspector +} + +// NewMockInspector creates a new mock instance. +func NewMockInspector(ctrl *gomock.Controller) *MockInspector { + mock := &MockInspector{ctrl: ctrl} + mock.recorder = &MockInspectorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInspector) EXPECT() *MockInspectorMockRecorder { + return m.recorder +} + +// Inspect mocks base method. +func (m *MockInspector) Inspect(arg0 context.Context, arg1, arg2 string, arg3 v1.PullPolicy, arg4 []string) (*Info, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*Info) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockInspectorMockRecorder) Inspect(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockInspector)(nil).Inspect), arg0, arg1, arg2, arg3, arg4) +} diff --git a/pkg/imageinspector/mock_secretfetcher.go b/pkg/imageinspector/mock_secretfetcher.go new file mode 100644 index 00000000000..d4ff8588512 --- /dev/null +++ b/pkg/imageinspector/mock_secretfetcher.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/imageinspector (interfaces: SecretFetcher) + +// Package imageinspector is a generated GoMock package. +package imageinspector + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "k8s.io/api/core/v1" +) + +// MockSecretFetcher is a mock of SecretFetcher interface. +type MockSecretFetcher struct { + ctrl *gomock.Controller + recorder *MockSecretFetcherMockRecorder +} + +// MockSecretFetcherMockRecorder is the mock recorder for MockSecretFetcher. +type MockSecretFetcherMockRecorder struct { + mock *MockSecretFetcher +} + +// NewMockSecretFetcher creates a new mock instance. +func NewMockSecretFetcher(ctrl *gomock.Controller) *MockSecretFetcher { + mock := &MockSecretFetcher{ctrl: ctrl} + mock.recorder = &MockSecretFetcherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSecretFetcher) EXPECT() *MockSecretFetcherMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockSecretFetcher) Get(arg0 context.Context, arg1 string) (*v1.Secret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(*v1.Secret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockSecretFetcherMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSecretFetcher)(nil).Get), arg0, arg1) +} diff --git a/pkg/imageinspector/mock_storage.go b/pkg/imageinspector/mock_storage.go new file mode 100644 index 00000000000..22c00a993a3 --- /dev/null +++ b/pkg/imageinspector/mock_storage.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/imageinspector (interfaces: StorageWithTransfer) + +// Package imageinspector is a generated GoMock package. +package imageinspector + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockStorageWithTransfer is a mock of StorageWithTransfer interface. +type MockStorageWithTransfer struct { + ctrl *gomock.Controller + recorder *MockStorageWithTransferMockRecorder +} + +// MockStorageWithTransferMockRecorder is the mock recorder for MockStorageWithTransfer. +type MockStorageWithTransferMockRecorder struct { + mock *MockStorageWithTransfer +} + +// NewMockStorageWithTransfer creates a new mock instance. +func NewMockStorageWithTransfer(ctrl *gomock.Controller) *MockStorageWithTransfer { + mock := &MockStorageWithTransfer{ctrl: ctrl} + mock.recorder = &MockStorageWithTransferMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStorageWithTransfer) EXPECT() *MockStorageWithTransferMockRecorder { + return m.recorder +} + +// CopyTo mocks base method. +func (m *MockStorageWithTransfer) CopyTo(arg0 context.Context, arg1 ...StorageTransfer) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CopyTo", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// CopyTo indicates an expected call of CopyTo. +func (mr *MockStorageWithTransferMockRecorder) CopyTo(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CopyTo", reflect.TypeOf((*MockStorageWithTransfer)(nil).CopyTo), varargs...) +} + +// Get mocks base method. +func (m *MockStorageWithTransfer) Get(arg0 context.Context, arg1 RequestBase) (*Info, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(*Info) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStorageWithTransferMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStorageWithTransfer)(nil).Get), arg0, arg1) +} + +// Store mocks base method. +func (m *MockStorageWithTransfer) Store(arg0 context.Context, arg1 RequestBase, arg2 Info) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Store", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Store indicates an expected call of Store. +func (mr *MockStorageWithTransferMockRecorder) Store(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockStorageWithTransfer)(nil).Store), arg0, arg1, arg2) +} + +// StoreMany mocks base method. +func (m *MockStorageWithTransfer) StoreMany(arg0 context.Context, arg1 map[Hash]Info) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreMany", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreMany indicates an expected call of StoreMany. +func (mr *MockStorageWithTransferMockRecorder) StoreMany(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreMany", reflect.TypeOf((*MockStorageWithTransfer)(nil).StoreMany), arg0, arg1) +} diff --git a/pkg/imageinspector/secretfetcher.go b/pkg/imageinspector/secretfetcher.go new file mode 100644 index 00000000000..44b81f51159 --- /dev/null +++ b/pkg/imageinspector/secretfetcher.go @@ -0,0 +1,50 @@ +package imageinspector + +import ( + "context" + "sync" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + + "github.com/kubeshop/testkube/pkg/secret" +) + +type secretFetcher struct { + client secret.Interface + cache map[string]*corev1.Secret + mu sync.RWMutex +} + +func NewSecretFetcher(client secret.Interface) SecretFetcher { + return &secretFetcher{ + client: client, + cache: make(map[string]*corev1.Secret), + } +} + +func (s *secretFetcher) Get(ctx context.Context, name string) (*corev1.Secret, error) { + // Get cached secret + s.mu.RLock() + if v, ok := s.cache[name]; ok { + s.mu.RUnlock() + return v, nil + } + s.mu.RUnlock() + + // Load secret from the Kubernetes + obj, err := s.client.GetObject(name) + if err != nil { + return nil, errors.Wrap(err, "fetching image pull secret") + } + + // Save in cache + s.mu.Lock() + s.cache[name] = obj + s.mu.Unlock() + + if ctx.Err() != nil { + return nil, ctx.Err() + } + return obj, nil +} diff --git a/pkg/imageinspector/secretfetcher_test.go b/pkg/imageinspector/secretfetcher_test.go new file mode 100644 index 00000000000..e60e7cd5ee6 --- /dev/null +++ b/pkg/imageinspector/secretfetcher_test.go @@ -0,0 +1,66 @@ +package imageinspector + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kubeshop/testkube/pkg/secret" +) + +func TestSecretFetcherGetExisting(t *testing.T) { + ctrl := gomock.NewController(t) + client := secret.NewMockInterface(ctrl) + fetcher := NewSecretFetcher(client) + + expected := corev1.Secret{ + StringData: map[string]string{"key": "value"}, + } + client.EXPECT().GetObject("dummy").Return(&expected, nil) + + result, err := fetcher.Get(context.Background(), "dummy") + assert.NoError(t, err) + assert.Equal(t, &expected, result) +} + +func TestSecretFetcherGetCache(t *testing.T) { + ctrl := gomock.NewController(t) + client := secret.NewMockInterface(ctrl) + fetcher := NewSecretFetcher(client) + + expected := corev1.Secret{ + StringData: map[string]string{"key": "value"}, + } + client.EXPECT().GetObject("dummy").Return(&expected, nil) + + result1, err1 := fetcher.Get(context.Background(), "dummy") + result2, err2 := fetcher.Get(context.Background(), "dummy") + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.Equal(t, &expected, result1) + assert.Equal(t, &expected, result2) +} + +func TestSecretFetcherGetError(t *testing.T) { + ctrl := gomock.NewController(t) + client := secret.NewMockInterface(ctrl) + fetcher := NewSecretFetcher(client) + + client.EXPECT().GetObject("dummy").Return(nil, k8serrors.NewNotFound(schema.GroupResource{}, "dummy")) + client.EXPECT().GetObject("dummy").Return(nil, k8serrors.NewNotFound(schema.GroupResource{}, "dummy")) + + result1, err1 := fetcher.Get(context.Background(), "dummy") + result2, err2 := fetcher.Get(context.Background(), "dummy") + var noSecret *corev1.Secret + assert.Error(t, err1) + assert.Error(t, err2) + assert.True(t, k8serrors.IsNotFound(err1)) + assert.True(t, k8serrors.IsNotFound(err2)) + assert.Equal(t, noSecret, result1) + assert.Equal(t, noSecret, result2) +} diff --git a/pkg/imageinspector/serialization.go b/pkg/imageinspector/serialization.go new file mode 100644 index 00000000000..749f992ffda --- /dev/null +++ b/pkg/imageinspector/serialization.go @@ -0,0 +1,28 @@ +package imageinspector + +import ( + "encoding/json" + "fmt" + "regexp" +) + +type Hash string + +var hashKeyRe = regexp.MustCompile("[^a-zA-Z0-9-_/]") + +func hash(registry, image string) Hash { + return Hash(hashKeyRe.ReplaceAllString(fmt.Sprintf("%s/%s", registry, image), "_-")) +} + +func marshalInfo(v Info) (string, error) { + res, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(res), nil +} + +func unmarshalInfo(s string) (v Info, err error) { + err = json.Unmarshal([]byte(s), &v) + return +} diff --git a/pkg/imageinspector/skopeofetcher.go b/pkg/imageinspector/skopeofetcher.go new file mode 100644 index 00000000000..45ef2bcfd94 --- /dev/null +++ b/pkg/imageinspector/skopeofetcher.go @@ -0,0 +1,35 @@ +package imageinspector + +import ( + "context" + "time" + + corev1 "k8s.io/api/core/v1" + + "github.com/kubeshop/testkube/pkg/skopeo" +) + +type skopeoFetcher struct { +} + +func NewSkopeoFetcher() InfoFetcher { + return &skopeoFetcher{} +} + +func (s *skopeoFetcher) Fetch(ctx context.Context, registry, image string, pullSecrets []corev1.Secret) (*Info, error) { + client, err := skopeo.NewClientFromSecrets(pullSecrets, registry) + if err != nil { + return nil, err + } + info, err := client.Inspect(image) // TODO: Support passing context + if err != nil { + return nil, err + } + return &Info{ + FetchedAt: time.Now(), + Entrypoint: info.Config.Entrypoint, + Cmd: info.Config.Cmd, + Shell: info.Shell, + WorkingDir: info.Config.WorkingDir, + }, nil +} diff --git a/pkg/imageinspector/types.go b/pkg/imageinspector/types.go new file mode 100644 index 00000000000..29c617f8ccb --- /dev/null +++ b/pkg/imageinspector/types.go @@ -0,0 +1,57 @@ +package imageinspector + +import ( + "context" + "time" + + corev1 "k8s.io/api/core/v1" +) + +//go:generate mockgen -destination=./mock_inspector.go -package=imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" Inspector +type Inspector interface { + Inspect(ctx context.Context, registry, image string, pullPolicy corev1.PullPolicy, pullSecretNames []string) (*Info, error) +} + +type StorageTransfer interface { + StoreMany(ctx context.Context, data map[Hash]Info) error + CopyTo(ctx context.Context, other ...StorageTransfer) error +} + +type Storage interface { + Store(ctx context.Context, request RequestBase, info Info) error + Get(ctx context.Context, request RequestBase) (*Info, error) +} + +//go:generate mockgen -destination=./mock_storage.go -package=imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" StorageWithTransfer +type StorageWithTransfer interface { + StorageTransfer + Storage +} + +//go:generate mockgen -destination=./mock_secretfetcher.go -package=imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" SecretFetcher +type SecretFetcher interface { + Get(ctx context.Context, name string) (*corev1.Secret, error) +} + +//go:generate mockgen -destination=./mock_infofetcher.go -package=imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" InfoFetcher +type InfoFetcher interface { + Fetch(ctx context.Context, registry, image string, pullSecrets []corev1.Secret) (*Info, error) +} + +type Info struct { + FetchedAt time.Time `json:"a,omitempty"` + Entrypoint []string `json:"e,omitempty"` + Cmd []string `json:"c,omitempty"` + Shell string `json:"s,omitempty"` + WorkingDir string `json:"w,omitempty"` +} + +type RequestBase struct { + Image string + Registry string +} + +type Request struct { + RequestBase + PullPolicy corev1.PullPolicy +} diff --git a/pkg/skopeo/client.go b/pkg/skopeo/client.go index 7bb9a31c052..1b8c8d476b4 100644 --- a/pkg/skopeo/client.go +++ b/pkg/skopeo/client.go @@ -40,6 +40,7 @@ type DockerImage struct { Config struct { Entrypoint []string `json:"Entrypoint"` Cmd []string `json:"Cmd"` + WorkingDir string `json:"WorkingDir"` } `json:"config"` History []struct { Created time.Time `json:"created"` From 85899d01381e9ddc2e3a0e4db17f2bdad9548c6a Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 14 Feb 2024 12:39:06 +0100 Subject: [PATCH 093/234] fix: rollback NewMongoREpository construct function to not be changed (#5008) --- cmd/api-server/main.go | 2 +- pkg/repository/result/mongo.go | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index abf397aba98..5f6644b5ca8 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -252,7 +252,7 @@ func main() { db, err := storage.GetMongoDatabase(cfg.APIMongoDSN, cfg.APIMongoDB, cfg.APIMongoDBType, cfg.APIMongoAllowTLS, mongoSSLConfig) ui.ExitOnError("Getting mongo database", err) isDocDb := cfg.APIMongoDBType == storage.TypeDocDB - mongoResultsRepository := result.NewMongoRepository(db, logGrpcClient, cfg.APIMongoAllowDiskUse, isDocDb, features) + mongoResultsRepository := result.NewMongoRepository(db, cfg.APIMongoAllowDiskUse, isDocDb, result.WithFeatureFlags(features), result.WithLogsClient(logGrpcClient)) resultsRepository = mongoResultsRepository testResultsRepository = testresult.NewMongoRepository(db, cfg.APIMongoAllowDiskUse, isDocDb) configRepository = configrepository.NewMongoRepository(db) diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index 7448d6213ef..1a44acbaf37 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -36,17 +36,15 @@ const ( StepMaxCount = 100 ) -func NewMongoRepository(db *mongo.Database, logGrpcClient logsclient.StreamGetter, allowDiskUse, isDocDb bool, - features featureflags.FeatureFlags, opts ...MongoRepositoryOpt) *MongoRepository { +// NewMongoRepository creates a new MongoRepository - used by other testkube components - use opts to extend the functionality +func NewMongoRepository(db *mongo.Database, allowDiskUse, isDocDb bool, opts ...MongoRepositoryOpt) *MongoRepository { r := &MongoRepository{ db: db, ResultsColl: db.Collection(CollectionResults), SequencesColl: db.Collection(CollectionSequences), OutputRepository: NewMongoOutputRepository(db), - logGrpcClient: logGrpcClient, allowDiskUse: allowDiskUse, isDocDb: isDocDb, - features: features, log: log.DefaultLogger, } @@ -112,6 +110,18 @@ type MongoRepository struct { type MongoRepositoryOpt func(*MongoRepository) +func WithLogsClient(client logsclient.StreamGetter) MongoRepositoryOpt { + return func(r *MongoRepository) { + r.logGrpcClient = client + } +} + +func WithFeatureFlags(features featureflags.FeatureFlags) MongoRepositoryOpt { + return func(r *MongoRepository) { + r.features = features + } +} + func WithMongoRepositoryResultCollection(collection *mongo.Collection) MongoRepositoryOpt { return func(r *MongoRepository) { r.ResultsColl = collection From b6eb2e69936e6ec569251b5c2bc9e799e9d3b231 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 14 Feb 2024 14:02:31 +0100 Subject: [PATCH 094/234] fix: removed unnecessary values from cloud only constructors (#5010) --- pkg/repository/result/mongo.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index 1a44acbaf37..50edb1c8a76 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -59,8 +59,6 @@ func NewMongoRepositoryWithOutputRepository( db *mongo.Database, allowDiskUse bool, outputRepository OutputRepository, - logGrpcClient logsclient.StreamGetter, - features featureflags.FeatureFlags, opts ...MongoRepositoryOpt, ) *MongoRepository { r := &MongoRepository{ @@ -68,9 +66,7 @@ func NewMongoRepositoryWithOutputRepository( ResultsColl: db.Collection(CollectionResults), SequencesColl: db.Collection(CollectionSequences), OutputRepository: outputRepository, - logGrpcClient: logGrpcClient, allowDiskUse: allowDiskUse, - features: features, log: log.DefaultLogger, } @@ -81,15 +77,12 @@ func NewMongoRepositoryWithOutputRepository( return r } -func NewMongoRepositoryWithMinioOutputStorage(db *mongo.Database, allowDiskUse bool, storageClient storage.Client, - logGrpcClient logsclient.StreamGetter, bucket string, features featureflags.FeatureFlags) *MongoRepository { +func NewMongoRepositoryWithMinioOutputStorage(db *mongo.Database, allowDiskUse bool, storageClient storage.Client, bucket string) *MongoRepository { repo := MongoRepository{ db: db, ResultsColl: db.Collection(CollectionResults), SequencesColl: db.Collection(CollectionSequences), - logGrpcClient: logGrpcClient, allowDiskUse: allowDiskUse, - features: features, log: log.DefaultLogger, } repo.OutputRepository = NewMinioOutputRepository(storageClient, repo.ResultsColl, bucket) From 74e2526df2cfb250a58c37f6996e4ba0e743d1bc Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 14 Feb 2024 17:27:16 +0300 Subject: [PATCH 095/234] feat: grpc tls (#5001) * feat: add client tls cert for log server * fix: change config file * fix: unit test * feat: grpc server tls * fix: unit test * fix: switch to mounted secrets * fix: error message * fix: don't send nil creds * fix: non init structure --- cmd/api-server/main.go | 15 +++++++- cmd/logs/main.go | 19 +++++++++- internal/config/config.go | 7 +++- pkg/logs/client/client.go | 65 ++++++++++++++++++++++++++++++++-- pkg/logs/config/logs_config.go | 6 ++++ pkg/logs/healthcheck_test.go | 2 +- pkg/logs/logsserver_test.go | 4 +-- pkg/logs/service.go | 63 ++++++++++++++++++++++++++++++-- 8 files changed, 171 insertions(+), 10 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 5f6644b5ca8..7bca30f8f88 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -19,6 +19,7 @@ import ( "go.mongodb.org/mongo-driver/mongo" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" cloudartifacts "github.com/kubeshop/testkube/pkg/cloud/data/artifact" @@ -231,7 +232,9 @@ func main() { var logGrpcClient logsclient.StreamGetter if features.LogsV2 { - logGrpcClient = logsclient.NewGrpcClient(cfg.LogServerGrpcAddress) + creds, err := newGRPCTransportCredentials(cfg) + ui.ExitOnError("Getting log server TLS credentials", err) + logGrpcClient = logsclient.NewGrpcClient(cfg.LogServerGrpcAddress, creds) } // DI @@ -756,3 +759,13 @@ func getMongoSSLConfig(cfg *config.Config, secretClient *secret.Client) *storage SSLCertificateAuthoritiyFile: rootCAPath, } } + +func newGRPCTransportCredentials(cfg *config.Config) (credentials.TransportCredentials, error) { + return logsclient.GetGrpcTransportCredentials(logsclient.GrpcConnectionConfig{ + Secure: cfg.LogServerSecure, + SkipVerify: cfg.LogServerSkipVerify, + CertFile: cfg.LogServerCertFile, + KeyFile: cfg.LogServerKeyFile, + CAFile: cfg.LogServerCAFile, + }) +} diff --git a/cmd/logs/main.go b/cmd/logs/main.go index fb2f6bb7b03..15a67f9889c 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -11,6 +11,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "github.com/oklog/run" "go.uber.org/zap" + "google.golang.org/grpc/credentials" "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/agent" @@ -92,6 +93,12 @@ func main() { if cfg.Debug { svc.AddAdapter(adapter.NewDebugAdapter()) } + + creds, err := newGRPCTransportCredentials(cfg) + if err != nil { + log.Fatalw("error getting tls credentials", "error", err) + } + // add given log adapter depends from mode switch mode { @@ -142,7 +149,7 @@ func main() { }) g.Add(func() error { - return svc.RunGRPCServer(ctx) + return svc.RunGRPCServer(ctx, creds) }, func(error) { cancel() }) @@ -179,3 +186,13 @@ func Must[T any](val T, err error) T { } return val } + +func newGRPCTransportCredentials(cfg *config.Config) (credentials.TransportCredentials, error) { + return logs.GetGrpcTransportCredentials(logs.GrpcConnectionConfig{ + Secure: cfg.GrpcSecure, + ClientAuth: cfg.GrpcClientAuth, + CertFile: cfg.GrpcCertFile, + KeyFile: cfg.GrpcKeyFile, + ClientCAFile: cfg.GrpcClientCAFile, + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6dc5ee50790..6de38388193 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,9 +87,14 @@ type Config struct { EnableSecretsEndpoint bool `envconfig:"ENABLE_SECRETS_ENDPOINT" default:"false"` DisableMongoMigrations bool `envconfig:"DISABLE_MONGO_MIGRATIONS" default:"false"` Debug bool `envconfig:"DEBUG" default:"false"` - LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` EnableImageDataPersistentCache bool `envconfig:"ENABLE_IMAGE_DATA_PERSISTENT_CACHE" default:"false"` ImageDataPersistentCacheKey string `envconfig:"IMAGE_DATA_PERSISTENT_CACHE_KEY" default:"testkube-image-cache"` + LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` + LogServerSecure bool `envconfig:"LOG_SERVER_SECURE" default:"false"` + LogServerSkipVerify bool `envconfig:"LOG_SERVER_SKIP_VERIFY" default:"false"` + LogServerCertFile string `envconfig:"LOG_SERVER_CERT_FILE" default:""` + LogServerKeyFile string `envconfig:"LOG_SERVER_KEY_FILE" default:""` + LogServerCAFile string `envconfig:"LOG_SERVER_CA_FILE" default:""` // DEPRECATED: Use TestkubeProAPIKey instead TestkubeCloudAPIKey string `envconfig:"TESTKUBE_CLOUD_API_KEY" default:""` diff --git a/pkg/logs/client/client.go b/pkg/logs/client/client.go index 1b750d94815..d537a28b847 100644 --- a/pkg/logs/client/client.go +++ b/pkg/logs/client/client.go @@ -2,11 +2,16 @@ package client import ( "context" + "crypto/tls" + "crypto/x509" + "fmt" "io" + "os" "time" "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "github.com/kubeshop/testkube/pkg/log" @@ -20,15 +25,17 @@ const ( ) // NewGrpcClient imlpements getter interface for log stream for given ID -func NewGrpcClient(address string) StreamGetter { +func NewGrpcClient(address string, creds credentials.TransportCredentials) StreamGetter { return &GrpcClient{ log: log.DefaultLogger.With("service", "logs-grpc-client"), + creds: creds, address: address, } } type GrpcClient struct { log *zap.SugaredLogger + creds credentials.TransportCredentials address string } @@ -47,7 +54,12 @@ func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse defer close(ch) // TODO add TLS to GRPC client - conn, err := grpc.Dial(c.address, grpc.WithTransportCredentials(insecure.NewCredentials())) + creds := insecure.NewCredentials() + if c.creds != nil { + creds = c.creds + } + + conn, err := grpc.Dial(c.address, grpc.WithTransportCredentials(creds)) if err != nil { ch <- events.LogResponse{Error: err} return @@ -96,3 +108,52 @@ func (c GrpcClient) Get(ctx context.Context, id string) (chan events.LogResponse return ch, nil } + +// GrpcConnectionConfig contains GRPC connection parameters +type GrpcConnectionConfig struct { + Secure bool + SkipVerify bool + CertFile string + KeyFile string + CAFile string +} + +// GetGrpcTransportCredentials returns transport credentials for GRPC connection config +func GetGrpcTransportCredentials(cfg GrpcConnectionConfig) (credentials.TransportCredentials, error) { + var creds credentials.TransportCredentials + + if cfg.Secure { + var tlsConfig tls.Config + + if cfg.SkipVerify { + tlsConfig.InsecureSkipVerify = true + } else { + if cfg.CertFile != "" && cfg.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, err + } + + tlsConfig.Certificates = []tls.Certificate{cert} + } + + if cfg.CAFile != "" { + caCertificate, err := os.ReadFile(cfg.CAFile) + if err != nil { + return nil, err + } + + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(caCertificate) { + return nil, fmt.Errorf("failed to add server CA's certificate") + } + + tlsConfig.RootCAs = certPool + } + } + + creds = credentials.NewTLS(&tlsConfig) + } + + return creds, nil +} diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index a0a7515f807..89b820cdb68 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -31,6 +31,12 @@ type Config struct { GrpcAddress string `envconfig:"GRPC_ADDRESS" default:":9090"` KVBucketName string `envconfig:"KV_BUCKET_NAME" default:"logsState"` + GrpcSecure bool `envconfig:"GRPC_SECURE" default:"false"` + GrpcClientAuth bool `envconfig:"GRPC_CLIENT_AUTH" default:"false"` + GrpcCertFile string `envconfig:"GRPC_CERT_FILE" default:""` + GrpcKeyFile string `envconfig:"GRPC_KEY_FILE" default:""` + GrpcClientCAFile string `envconfig:"GRPC_CLIENT_CA_FILE" default:""` + StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"localhost:9000"` StorageBucket string `envconfig:"STORAGE_BUCKET" default:"testkube-logs"` StorageExpiration int `envconfig:"STORAGE_EXPIRATION"` diff --git a/pkg/logs/healthcheck_test.go b/pkg/logs/healthcheck_test.go index 8c9191aef8a..6fbd2134a6f 100644 --- a/pkg/logs/healthcheck_test.go +++ b/pkg/logs/healthcheck_test.go @@ -20,7 +20,7 @@ func TestLogsService_RunHealthcheckHandler(t *testing.T) { svc := LogsService{log: log.DefaultLogger} svc.WithRandomPort() go svc.RunHealthCheckHandler(ctx) - go svc.RunGRPCServer(ctx) + go svc.RunGRPCServer(ctx, nil) defer svc.Shutdown(ctx) time.Sleep(100 * time.Millisecond) diff --git a/pkg/logs/logsserver_test.go b/pkg/logs/logsserver_test.go index 8e6ef73e323..de18fcec532 100644 --- a/pkg/logs/logsserver_test.go +++ b/pkg/logs/logsserver_test.go @@ -27,14 +27,14 @@ func TestGRPC_Server(t *testing.T) { WithLogsRepositoryFactory(LogsFactoryMock{}). WithRandomPort() - go ls.RunGRPCServer(ctx) + go ls.RunGRPCServer(ctx, nil) // allow server to splin up time.Sleep(time.Millisecond * 100) expectedCount := 0 - stream := client.NewGrpcClient(ls.grpcAddress) + stream := client.NewGrpcClient(ls.grpcAddress, nil) ch, err := stream.Get(ctx, "id1") assert.NoError(t, err) diff --git a/pkg/logs/service.go b/pkg/logs/service.go index c1764dc04f3..5b617266a9c 100644 --- a/pkg/logs/service.go +++ b/pkg/logs/service.go @@ -6,10 +6,13 @@ package logs import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "math/rand" "net" "net/http" + "os" "sync" "time" @@ -17,6 +20,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/logs/adapter" @@ -121,13 +125,19 @@ func (ls *LogsService) Run(ctx context.Context) (err error) { } // TODO handle TLS -func (ls *LogsService) RunGRPCServer(ctx context.Context) error { +func (ls *LogsService) RunGRPCServer(ctx context.Context, creds credentials.TransportCredentials) error { lis, err := net.Listen("tcp", ls.grpcAddress) if err != nil { return err } - ls.grpcServer = grpc.NewServer() + var opts []grpc.ServerOption + if creds != nil { + opts = append(opts, grpc.Creds(creds)) + } + + ls.grpcServer = grpc.NewServer(opts...) + pb.RegisterLogsServiceServer(ls.grpcServer, NewLogsServer(ls.logsRepositoryFactory, ls.state)) ls.log.Infow("starting grpc server", "address", ls.grpcAddress) @@ -176,3 +186,52 @@ func (ls *LogsService) WithLogsRepositoryFactory(f repository.Factory) *LogsServ ls.logsRepositoryFactory = f return ls } + +// GrpcConnectionConfig contains GRPC connection parameters +type GrpcConnectionConfig struct { + Secure bool + ClientAuth bool + CertFile string + KeyFile string + ClientCAFile string +} + +// GetGrpcTransportCredentials returns transport credentials for GRPC connection config +func GetGrpcTransportCredentials(cfg GrpcConnectionConfig) (credentials.TransportCredentials, error) { + var creds credentials.TransportCredentials + + if cfg.Secure { + var tlsConfig tls.Config + tlsConfig.ClientAuth = tls.NoClientCert + if cfg.ClientAuth { + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + } + + if cfg.CertFile != "" && cfg.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, err + } + + tlsConfig.Certificates = []tls.Certificate{cert} + } + + if cfg.ClientCAFile != "" { + caCertificate, err := os.ReadFile(cfg.ClientCAFile) + if err != nil { + return nil, err + } + + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(caCertificate) { + return nil, fmt.Errorf("failed to add client CA's certificate") + } + + tlsConfig.ClientCAs = certPool + } + + creds = credentials.NewTLS(&tlsConfig) + } + + return creds, nil +} From c997b88cbdf038b6a898c0d8b8e7301467dc3e2c Mon Sep 17 00:00:00 2001 From: nicufk Date: Wed, 14 Feb 2024 21:54:35 +0200 Subject: [PATCH 096/234] fix: nil pointer in compose slack message (#5011) --- pkg/slack/slack.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/slack/slack.go b/pkg/slack/slack.go index c6ab0f2734d..c2bdc74e5e6 100644 --- a/pkg/slack/slack.go +++ b/pkg/slack/slack.go @@ -221,17 +221,25 @@ func (s *Notifier) composeTestMessage(execution *testkube.Execution, eventType t Labels: testkube.MapToString(execution.Labels), TestName: execution.TestName, TestType: execution.TestType, - Status: string(*execution.ExecutionResult.Status), + Status: string(testkube.QUEUED_ExecutionStatus), StartTime: execution.StartTime.String(), EndTime: execution.EndTime.String(), Duration: execution.Duration, - TotalSteps: len(execution.ExecutionResult.Steps), - FailedSteps: execution.ExecutionResult.FailedStepsCount(), + TotalSteps: 0, + FailedSteps: 0, ClusterName: s.clusterName, DashboardURI: s.dashboardURI, Envs: s.envs, } + if execution.ExecutionResult != nil { + if execution.ExecutionResult.Status != nil { + args.Status = string(*execution.ExecutionResult.Status) + } + args.TotalSteps = len(execution.ExecutionResult.Steps) + args.FailedSteps = execution.ExecutionResult.FailedStepsCount() + } + log.DefaultLogger.Infow("Execution changed", "status", execution.ExecutionResult.Status) var message bytes.Buffer From e660188166b02322fbe888522cf38c74cf88d4cc Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 15 Feb 2024 14:58:17 +0300 Subject: [PATCH 097/234] fix: remove uuid from metrics --- internal/app/api/metrics/metrics.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/app/api/metrics/metrics.go b/internal/app/api/metrics/metrics.go index 28cf64c22a5..977de8c6d18 100644 --- a/internal/app/api/metrics/metrics.go +++ b/internal/app/api/metrics/metrics.go @@ -14,12 +14,12 @@ import ( var testExecutionCount = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "testkube_test_executions_count", Help: "The total number of test executions", -}, []string{"type", "name", "result", "labels", "test_uri", "execution_uri"}) +}, []string{"type", "name", "result", "labels", "test_uri"}) var testSuiteExecutionCount = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "testkube_testsuite_executions_count", Help: "The total number of test suite executions", -}, []string{"name", "result", "labels", "testsuite_uri", "execution_uri"}) +}, []string{"name", "result", "labels", "testsuite_uri"}) var testCreationCount = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "testkube_test_creations_count", @@ -121,8 +121,6 @@ func (m Metrics) IncExecuteTest(execution testkube.Execution, dashboardURI strin "result": status, "labels": strings.Join(labels, ","), "test_uri": fmt.Sprintf("%s/tests/%s", dashboardURI, execution.TestName), - "execution_uri": fmt.Sprintf("%s/tests/%s/executions/%s", dashboardURI, - execution.TestName, execution.Id), }).Inc() } @@ -153,8 +151,6 @@ func (m Metrics) IncExecuteTestSuite(execution testkube.TestSuiteExecution, dash "result": status, "labels": strings.Join(labels, ","), "testsuite_uri": fmt.Sprintf("%s/test-suites/%s", dashboardURI, testSuiteName), - "execution_uri": fmt.Sprintf("%s/test-suites/%s/executions/%s", dashboardURI, - testSuiteName, execution.Id), }).Inc() } From 0ca234e4607e7d4c23cd27b264e85ea99156c1f4 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 19 Feb 2024 15:11:25 +0300 Subject: [PATCH 098/234] fix: rename outout dir --- contrib/executor/jmeterd/pkg/runner/runner.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index f9625f83b25..e54e320c3d9 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -120,20 +120,18 @@ func (r *JMeterDRunner) Run(ctx context.Context, execution testkube.Execution) ( runPath := workingDir outputDir := "" - if envVar, ok := envManager.Variables["OUTPUT_DIR"]; ok { + if envVar, ok := envManager.Variables["RUNNER_ARTIFACTS_DIR"]; ok { outputDir = envVar.Value } if outputDir == "" { outputDir = filepath.Join(runPath, "output") - err = os.Setenv("OUTPUT_DIR", outputDir) + err = os.Setenv("RUNNER_ARTIFACTS_DIR", outputDir) if err != nil { output.PrintLogf("%s Failed to set output directory %s", ui.IconWarning, outputDir) } } - slavesEnvVariables["OUTPUT_DIR"] = testkube.NewBasicVariable("OUTPUT_DIR", outputDir) - // recreate output directory with wide permissions so JMeter can create report files if err = os.Mkdir(outputDir, 0777); err != nil { return *result.Err(errors.Wrapf(err, "error creating directory %s", outputDir)), nil From dcee0538cedf15b26179b93bdf7c8ca602f54e17 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 19 Feb 2024 16:11:40 +0300 Subject: [PATCH 099/234] fix: remove duplicate -n --- contrib/executor/jmeterd/pkg/runner/runner.go | 1 + contrib/executor/jmeterd/pkg/runner/runner_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index e54e320c3d9..cff699b972e 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -300,6 +300,7 @@ func removeDuplicatedArgs(args []string) []string { func mergeDuplicatedArgs(args []string) []string { allowed := map[string]int{ "-e": 0, + "-n": 0, } for i := len(args) - 1; i >= 0; i-- { diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index 646b3fe78df..6522355b77a 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -210,13 +210,13 @@ func TestMergeDuplicatedArgs(t *testing.T) { }, { name: "Multiple duplicated args", - args: []string{"", "-e", "-e", "-l"}, - expectedArgs: []string{"", "-e", "-l"}, + args: []string{"", "-e", "-e", "-n", "-n", "-l"}, + expectedArgs: []string{"", "-e", "-n", "-l"}, }, { name: "Non duplicated args", - args: []string{"-e", "", "-l"}, - expectedArgs: []string{"-e", "", "-l"}, + args: []string{"-e", "-n", "", "-l"}, + expectedArgs: []string{"-e", "-n", "", "-l"}, }, } From 18e6c5ebff338df3bbf7e3833ce09ecc339d9771 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Tue, 20 Feb 2024 08:43:40 +0100 Subject: [PATCH 100/234] feat: logs grpc server for get logs (#5028) --- Makefile | 4 + pkg/logs/pb/logs.pb.go | 181 ++++++++++++++++++------------------ pkg/logs/pb/logs.proto | 1 + pkg/logs/pb/logs_grpc.pb.go | 69 +++++++++++++- 4 files changed, 164 insertions(+), 91 deletions(-) diff --git a/Makefile b/Makefile index 7d43529ebbe..cfe0b9c041a 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,10 @@ refresh-config: wget "https://raw.githubusercontent.com/kubeshop/helm-charts/develop/charts/testkube-api/slack-config.json" -O config/slack-config.json & wget "https://raw.githubusercontent.com/kubeshop/helm-charts/develop/charts/testkube-api/slack-template.json" -O config/slack-template.json + +generate-protobuf: use-env-file + protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative pkg/logs/pb/logs.proto + just-run-api: use-env-file TESTKUBE_DASHBOARD_URI=$(DASHBOARD_URI) APISERVER_CONFIG=testkube-api-server-config-testkube TESTKUBE_ANALYTICS_ENABLED=$(TESTKUBE_ANALYTICS_ENABLED) TESTKUBE_NAMESPACE=$(NAMESPACE) SCRAPPERENABLED=true STORAGE_SSL=true DEBUG=$(DEBUG) APISERVER_PORT=8088 go run -ldflags='$(LD_FLAGS)' cmd/api-server/main.go diff --git a/pkg/logs/pb/logs.pb.go b/pkg/logs/pb/logs.pb.go index fb748b876a8..45bb9c5814f 100644 --- a/pkg/logs/pb/logs.pb.go +++ b/pkg/logs/pb/logs.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.28.1 // protoc v3.19.4 -// source: logs.proto +// source: pkg/logs/pb/logs.proto package pb @@ -51,11 +51,11 @@ func (x StreamResponseStatus) String() string { } func (StreamResponseStatus) Descriptor() protoreflect.EnumDescriptor { - return file_logs_proto_enumTypes[0].Descriptor() + return file_pkg_logs_pb_logs_proto_enumTypes[0].Descriptor() } func (StreamResponseStatus) Type() protoreflect.EnumType { - return &file_logs_proto_enumTypes[0] + return &file_pkg_logs_pb_logs_proto_enumTypes[0] } func (x StreamResponseStatus) Number() protoreflect.EnumNumber { @@ -64,7 +64,7 @@ func (x StreamResponseStatus) Number() protoreflect.EnumNumber { // Deprecated: Use StreamResponseStatus.Descriptor instead. func (StreamResponseStatus) EnumDescriptor() ([]byte, []int) { - return file_logs_proto_rawDescGZIP(), []int{0} + return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{0} } type LogRequest struct { @@ -78,7 +78,7 @@ type LogRequest struct { func (x *LogRequest) Reset() { *x = LogRequest{} if protoimpl.UnsafeEnabled { - mi := &file_logs_proto_msgTypes[0] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -91,7 +91,7 @@ func (x *LogRequest) String() string { func (*LogRequest) ProtoMessage() {} func (x *LogRequest) ProtoReflect() protoreflect.Message { - mi := &file_logs_proto_msgTypes[0] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -104,7 +104,7 @@ func (x *LogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogRequest.ProtoReflect.Descriptor instead. func (*LogRequest) Descriptor() ([]byte, []int) { - return file_logs_proto_rawDescGZIP(), []int{0} + return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{0} } func (x *LogRequest) GetExecutionId() string { @@ -131,7 +131,7 @@ type Log struct { func (x *Log) Reset() { *x = Log{} if protoimpl.UnsafeEnabled { - mi := &file_logs_proto_msgTypes[1] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -144,7 +144,7 @@ func (x *Log) String() string { func (*Log) ProtoMessage() {} func (x *Log) ProtoReflect() protoreflect.Message { - mi := &file_logs_proto_msgTypes[1] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -157,7 +157,7 @@ func (x *Log) ProtoReflect() protoreflect.Message { // Deprecated: Use Log.ProtoReflect.Descriptor instead. func (*Log) Descriptor() ([]byte, []int) { - return file_logs_proto_rawDescGZIP(), []int{1} + return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{1} } func (x *Log) GetTime() *timestamppb.Timestamp { @@ -221,7 +221,7 @@ type StreamResponse struct { func (x *StreamResponse) Reset() { *x = StreamResponse{} if protoimpl.UnsafeEnabled { - mi := &file_logs_proto_msgTypes[2] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -234,7 +234,7 @@ func (x *StreamResponse) String() string { func (*StreamResponse) ProtoMessage() {} func (x *StreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_logs_proto_msgTypes[2] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -247,7 +247,7 @@ func (x *StreamResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamResponse.ProtoReflect.Descriptor instead. func (*StreamResponse) Descriptor() ([]byte, []int) { - return file_logs_proto_rawDescGZIP(), []int{2} + return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{2} } func (x *StreamResponse) GetMessage() string { @@ -264,69 +264,72 @@ func (x *StreamResponse) GetStatus() StreamResponseStatus { return StreamResponseStatus_Completed } -var File_logs_proto protoreflect.FileDescriptor - -var file_logs_proto_rawDesc = []byte{ - 0x0a, 0x0a, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x6c, 0x6f, - 0x67, 0x73, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0x2f, 0x0a, 0x0a, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, - 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x9d, 0x02, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2e, 0x0a, 0x04, - 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, - 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x2e, - 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x22, 0x5e, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x2a, 0x31, 0x0a, 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, - 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x01, 0x32, 0x34, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, - 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, 0x32, 0x3f, 0x0a, - 0x10, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, - 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x1a, 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, - 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x42, 0x0d, - 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, +var File_pkg_logs_pb_logs_proto protoreflect.FileDescriptor + +var file_pkg_logs_pb_logs_proto_rawDesc = []byte{ + 0x0a, 0x16, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, 0x62, 0x2f, 0x6c, 0x6f, + 0x67, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x1a, 0x1f, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, + 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, + 0x2f, 0x0a, 0x0a, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, + 0x0c, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, + 0x22, 0x9d, 0x02, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2e, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x33, + 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x17, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x5e, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x32, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, + 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x2a, 0x31, 0x0a, 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x64, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x61, 0x69, 0x6c, 0x65, + 0x64, 0x10, 0x01, 0x32, 0x34, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, + 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, + 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, 0x32, 0x66, 0x0a, 0x10, 0x43, 0x6c, 0x6f, + 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2b, 0x0a, + 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, + 0x6f, 0x67, 0x1a, 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x25, 0x0a, 0x04, 0x4c, 0x6f, + 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, + 0x01, 0x42, 0x0d, 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( - file_logs_proto_rawDescOnce sync.Once - file_logs_proto_rawDescData = file_logs_proto_rawDesc + file_pkg_logs_pb_logs_proto_rawDescOnce sync.Once + file_pkg_logs_pb_logs_proto_rawDescData = file_pkg_logs_pb_logs_proto_rawDesc ) -func file_logs_proto_rawDescGZIP() []byte { - file_logs_proto_rawDescOnce.Do(func() { - file_logs_proto_rawDescData = protoimpl.X.CompressGZIP(file_logs_proto_rawDescData) +func file_pkg_logs_pb_logs_proto_rawDescGZIP() []byte { + file_pkg_logs_pb_logs_proto_rawDescOnce.Do(func() { + file_pkg_logs_pb_logs_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_logs_pb_logs_proto_rawDescData) }) - return file_logs_proto_rawDescData + return file_pkg_logs_pb_logs_proto_rawDescData } -var file_logs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 4) -var file_logs_proto_goTypes = []interface{}{ +var file_pkg_logs_pb_logs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_pkg_logs_pb_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pkg_logs_pb_logs_proto_goTypes = []interface{}{ (StreamResponseStatus)(0), // 0: logs.StreamResponseStatus (*LogRequest)(nil), // 1: logs.LogRequest (*Log)(nil), // 2: logs.Log @@ -334,28 +337,30 @@ var file_logs_proto_goTypes = []interface{}{ nil, // 4: logs.Log.MetadataEntry (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp } -var file_logs_proto_depIdxs = []int32{ +var file_pkg_logs_pb_logs_proto_depIdxs = []int32{ 5, // 0: logs.Log.time:type_name -> google.protobuf.Timestamp 4, // 1: logs.Log.metadata:type_name -> logs.Log.MetadataEntry 0, // 2: logs.StreamResponse.status:type_name -> logs.StreamResponseStatus 1, // 3: logs.LogsService.Logs:input_type -> logs.LogRequest 2, // 4: logs.CloudLogsService.Stream:input_type -> logs.Log - 2, // 5: logs.LogsService.Logs:output_type -> logs.Log - 3, // 6: logs.CloudLogsService.Stream:output_type -> logs.StreamResponse - 5, // [5:7] is the sub-list for method output_type - 3, // [3:5] is the sub-list for method input_type + 1, // 5: logs.CloudLogsService.Logs:input_type -> logs.LogRequest + 2, // 6: logs.LogsService.Logs:output_type -> logs.Log + 3, // 7: logs.CloudLogsService.Stream:output_type -> logs.StreamResponse + 2, // 8: logs.CloudLogsService.Logs:output_type -> logs.Log + 6, // [6:9] is the sub-list for method output_type + 3, // [3:6] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name } -func init() { file_logs_proto_init() } -func file_logs_proto_init() { - if File_logs_proto != nil { +func init() { file_pkg_logs_pb_logs_proto_init() } +func file_pkg_logs_pb_logs_proto_init() { + if File_pkg_logs_pb_logs_proto != nil { return } if !protoimpl.UnsafeEnabled { - file_logs_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_pkg_logs_pb_logs_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*LogRequest); i { case 0: return &v.state @@ -367,7 +372,7 @@ func file_logs_proto_init() { return nil } } - file_logs_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + file_pkg_logs_pb_logs_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Log); i { case 0: return &v.state @@ -379,7 +384,7 @@ func file_logs_proto_init() { return nil } } - file_logs_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + file_pkg_logs_pb_logs_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StreamResponse); i { case 0: return &v.state @@ -396,19 +401,19 @@ func file_logs_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_logs_proto_rawDesc, + RawDescriptor: file_pkg_logs_pb_logs_proto_rawDesc, NumEnums: 1, NumMessages: 4, NumExtensions: 0, NumServices: 2, }, - GoTypes: file_logs_proto_goTypes, - DependencyIndexes: file_logs_proto_depIdxs, - EnumInfos: file_logs_proto_enumTypes, - MessageInfos: file_logs_proto_msgTypes, + GoTypes: file_pkg_logs_pb_logs_proto_goTypes, + DependencyIndexes: file_pkg_logs_pb_logs_proto_depIdxs, + EnumInfos: file_pkg_logs_pb_logs_proto_enumTypes, + MessageInfos: file_pkg_logs_pb_logs_proto_msgTypes, }.Build() - File_logs_proto = out.File - file_logs_proto_rawDesc = nil - file_logs_proto_goTypes = nil - file_logs_proto_depIdxs = nil + File_pkg_logs_pb_logs_proto = out.File + file_pkg_logs_pb_logs_proto_rawDesc = nil + file_pkg_logs_pb_logs_proto_goTypes = nil + file_pkg_logs_pb_logs_proto_depIdxs = nil } diff --git a/pkg/logs/pb/logs.proto b/pkg/logs/pb/logs.proto index bfeaad82f19..d1b59901dc7 100644 --- a/pkg/logs/pb/logs.proto +++ b/pkg/logs/pb/logs.proto @@ -34,6 +34,7 @@ message Log{ // CloudLogsService server will be implemented on cloud side service CloudLogsService { rpc Stream(stream Log) returns (StreamResponse); + rpc Logs(LogRequest) returns (stream Log); } message StreamResponse { diff --git a/pkg/logs/pb/logs_grpc.pb.go b/pkg/logs/pb/logs_grpc.pb.go index 62cf06e7b49..342b554a44c 100644 --- a/pkg/logs/pb/logs_grpc.pb.go +++ b/pkg/logs/pb/logs_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.2.0 // - protoc v3.19.4 -// source: logs.proto +// source: pkg/logs/pb/logs.proto package pb @@ -128,7 +128,7 @@ var LogsService_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, }, }, - Metadata: "logs.proto", + Metadata: "pkg/logs/pb/logs.proto", } // CloudLogsServiceClient is the client API for CloudLogsService service. @@ -136,6 +136,7 @@ var LogsService_ServiceDesc = grpc.ServiceDesc{ // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type CloudLogsServiceClient interface { Stream(ctx context.Context, opts ...grpc.CallOption) (CloudLogsService_StreamClient, error) + Logs(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) } type cloudLogsServiceClient struct { @@ -180,11 +181,44 @@ func (x *cloudLogsServiceStreamClient) CloseAndRecv() (*StreamResponse, error) { return m, nil } +func (c *cloudLogsServiceClient) Logs(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) { + stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[1], "/logs.CloudLogsService/Logs", opts...) + if err != nil { + return nil, err + } + x := &cloudLogsServiceLogsClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type CloudLogsService_LogsClient interface { + Recv() (*Log, error) + grpc.ClientStream +} + +type cloudLogsServiceLogsClient struct { + grpc.ClientStream +} + +func (x *cloudLogsServiceLogsClient) Recv() (*Log, error) { + m := new(Log) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // CloudLogsServiceServer is the server API for CloudLogsService service. // All implementations must embed UnimplementedCloudLogsServiceServer // for forward compatibility type CloudLogsServiceServer interface { Stream(CloudLogsService_StreamServer) error + Logs(*LogRequest, CloudLogsService_LogsServer) error mustEmbedUnimplementedCloudLogsServiceServer() } @@ -195,6 +229,9 @@ type UnimplementedCloudLogsServiceServer struct { func (UnimplementedCloudLogsServiceServer) Stream(CloudLogsService_StreamServer) error { return status.Errorf(codes.Unimplemented, "method Stream not implemented") } +func (UnimplementedCloudLogsServiceServer) Logs(*LogRequest, CloudLogsService_LogsServer) error { + return status.Errorf(codes.Unimplemented, "method Logs not implemented") +} func (UnimplementedCloudLogsServiceServer) mustEmbedUnimplementedCloudLogsServiceServer() {} // UnsafeCloudLogsServiceServer may be embedded to opt out of forward compatibility for this service. @@ -234,6 +271,27 @@ func (x *cloudLogsServiceStreamServer) Recv() (*Log, error) { return m, nil } +func _CloudLogsService_Logs_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(LogRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(CloudLogsServiceServer).Logs(m, &cloudLogsServiceLogsServer{stream}) +} + +type CloudLogsService_LogsServer interface { + Send(*Log) error + grpc.ServerStream +} + +type cloudLogsServiceLogsServer struct { + grpc.ServerStream +} + +func (x *cloudLogsServiceLogsServer) Send(m *Log) error { + return x.ServerStream.SendMsg(m) +} + // CloudLogsService_ServiceDesc is the grpc.ServiceDesc for CloudLogsService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -247,6 +305,11 @@ var CloudLogsService_ServiceDesc = grpc.ServiceDesc{ Handler: _CloudLogsService_Stream_Handler, ClientStreams: true, }, + { + StreamName: "Logs", + Handler: _CloudLogsService_Logs_Handler, + ServerStreams: true, + }, }, - Metadata: "logs.proto", + Metadata: "pkg/logs/pb/logs.proto", } From 61f12902b9e7d9ebff0801cfe095357f86bc1ece Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 20 Feb 2024 14:46:58 +0300 Subject: [PATCH 101/234] fix: remove dirs duplication --- contrib/executor/jmeterd/pkg/runner/runner.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index cff699b972e..b7d20d305ef 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -383,7 +383,10 @@ func runScraperIfEnabled(ctx context.Context, enabled bool, scraper scraper.Scra directories := dirs var masks []string if execution.ArtifactRequest != nil { - directories = append(directories, execution.ArtifactRequest.Dirs...) + if len(execution.ArtifactRequest.Dirs) != 0 { + directories = execution.ArtifactRequest.Dirs + } + masks = execution.ArtifactRequest.Masks } From 72a7466c3c4b49ed9f5d938dde774ef7fd7ad0ac Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 21 Feb 2024 09:35:19 +0100 Subject: [PATCH 102/234] feat: extracted stream name to interface for cloud (#5029) * feat: extracted stream name to interface for cloud * chore: tests * chore: tests * fix: regenerate PB * fix: generate mocks --- pkg/logs/client/interface.go | 7 ++ pkg/logs/client/mock_namer.go | 52 ++++++++++++ pkg/logs/client/mock_stream.go | 18 +++++ pkg/logs/client/stream.go | 16 ++-- pkg/logs/client/stream_test.go | 21 +++++ pkg/logs/events/events.go | 71 +++++++++++++++-- pkg/logs/pb/logs.pb.go | 140 +++++++++++++++++++++++++-------- pkg/logs/pb/logs.proto | 11 ++- pkg/logs/pb/logs_grpc.pb.go | 10 +-- 9 files changed, 293 insertions(+), 53 deletions(-) create mode 100644 pkg/logs/client/mock_namer.go diff --git a/pkg/logs/client/interface.go b/pkg/logs/client/interface.go index a026f77b8b5..d942682f6c7 100644 --- a/pkg/logs/client/interface.go +++ b/pkg/logs/client/interface.go @@ -20,6 +20,7 @@ type Stream interface { StreamTrigger StreamGetter StreamFinisher + StreamNamer } //go:generate mockgen -destination=./mock_initializedstreampusher.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" InitializedStreamPusher @@ -55,6 +56,12 @@ type StreamFinisher interface { Finish(ctx context.Context, id string) error } +//go:generate mockgen -destination=./mock_namer.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" StreamNamer +type StreamNamer interface { + // Name returns stream name based on possible name groups + Name(parts ...string) string +} + // StreamGetter interface for getting logs stream channel // //go:generate mockgen -destination=./mock_streamgetter.go -package=client "github.com/kubeshop/testkube/pkg/logs/client" StreamGetter diff --git a/pkg/logs/client/mock_namer.go b/pkg/logs/client/mock_namer.go new file mode 100644 index 00000000000..93e588865cc --- /dev/null +++ b/pkg/logs/client/mock_namer.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/logs/client (interfaces: StreamNamer) + +// Package client is a generated GoMock package. +package client + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockStreamNamer is a mock of StreamNamer interface. +type MockStreamNamer struct { + ctrl *gomock.Controller + recorder *MockStreamNamerMockRecorder +} + +// MockStreamNamerMockRecorder is the mock recorder for MockStreamNamer. +type MockStreamNamerMockRecorder struct { + mock *MockStreamNamer +} + +// NewMockStreamNamer creates a new mock instance. +func NewMockStreamNamer(ctrl *gomock.Controller) *MockStreamNamer { + mock := &MockStreamNamer{ctrl: ctrl} + mock.recorder = &MockStreamNamerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStreamNamer) EXPECT() *MockStreamNamerMockRecorder { + return m.recorder +} + +// Name mocks base method. +func (m *MockStreamNamer) Name(arg0 ...string) string { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Name", varargs...) + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockStreamNamerMockRecorder) Name(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockStreamNamer)(nil).Name), arg0...) +} diff --git a/pkg/logs/client/mock_stream.go b/pkg/logs/client/mock_stream.go index e0d010203b7..012fd03b3c5 100644 --- a/pkg/logs/client/mock_stream.go +++ b/pkg/logs/client/mock_stream.go @@ -79,6 +79,24 @@ func (mr *MockStreamMockRecorder) Init(arg0, arg1 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockStream)(nil).Init), arg0, arg1) } +// Name mocks base method. +func (m *MockStream) Name(arg0 ...string) string { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Name", varargs...) + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockStreamMockRecorder) Name(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockStream)(nil).Name), arg0...) +} + // Push mocks base method. func (m *MockStream) Push(arg0 context.Context, arg1 string, arg2 *events.Log) error { m.ctrl.T.Helper() diff --git a/pkg/logs/client/stream.go b/pkg/logs/client/stream.go index 867c36722c3..2de949a287a 100644 --- a/pkg/logs/client/stream.go +++ b/pkg/logs/client/stream.go @@ -38,7 +38,7 @@ type NatsLogStream struct { func (c NatsLogStream) Init(ctx context.Context, id string) (StreamMetadata, error) { s, err := c.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{ - Name: c.streamName(id), + Name: c.Name(id), Storage: jetstream.FileStorage, // durable stream }) @@ -46,7 +46,7 @@ func (c NatsLogStream) Init(ctx context.Context, id string) (StreamMetadata, err c.log.Debugw("stream upserted", "info", s.CachedInfo()) } - return StreamMetadata{Name: c.streamName(id)}, err + return StreamMetadata{Name: c.Name(id)}, err } @@ -66,7 +66,7 @@ func (c NatsLogStream) Push(ctx context.Context, id string, log *events.Log) err // Push log chunk to NATS stream // TODO handle message repeat with backoff strategy on error func (c NatsLogStream) PushBytes(ctx context.Context, id string, bytes []byte) error { - _, err := c.js.Publish(ctx, c.streamName(id), bytes) + _, err := c.js.Publish(ctx, c.Name(id), bytes) return err } @@ -87,7 +87,7 @@ func (c NatsLogStream) Get(ctx context.Context, id string) (chan events.LogRespo name := fmt.Sprintf("%s%s%s", ConsumerPrefix, id, utils.RandAlphanum(6)) cons, err := c.js.CreateOrUpdateConsumer( ctx, - c.streamName(id), + c.Name(id), jetstream.ConsumerConfig{ Name: name, Durable: name, @@ -163,6 +163,10 @@ func (c NatsLogStream) syncCall(ctx context.Context, subject, id string) (resp S return StreamResponse{Message: m.Data}, nil } -func (c NatsLogStream) streamName(id string) string { - return StreamPrefix + id +func (c NatsLogStream) Name(id ...string) string { + if len(id) > 0 { + return StreamPrefix + id[0] + } + + return StreamPrefix + utils.RandAlphanum(10) } diff --git a/pkg/logs/client/stream_test.go b/pkg/logs/client/stream_test.go index 2adc57ec2af..75705f2ddf7 100644 --- a/pkg/logs/client/stream_test.go +++ b/pkg/logs/client/stream_test.go @@ -111,3 +111,24 @@ func TestStream_StartStop(t *testing.T) { assert.Equal(t, 3, messagesCount) }) } + +func TestStream_Name(t *testing.T) { + client, err := NewNatsLogStream(nil) + assert.NoError(t, err) + + t.Run("passed one string param", func(t *testing.T) { + name := client.Name("111") + assert.Equal(t, StreamPrefix+"111", name) + }) + + t.Run("passed no string params generates random name", func(t *testing.T) { + name := client.Name() + assert.Len(t, name, len(StreamPrefix)+10) + }) + + t.Run("passed more string params ignore rest", func(t *testing.T) { + name := client.Name("111", "222", "333") + assert.Equal(t, StreamPrefix+"111", name) + }) + +} diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 10f6a106fe3..3e9a7bbc7d2 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -2,6 +2,7 @@ package events import ( "bytes" + "encoding/json" "regexp" "time" @@ -173,13 +174,7 @@ func NewLogFromBytes(b []byte) *Log { if err != nil { // try to read in case of some lines which we couldn't parse // sometimes we're not able to control all stdout messages from libs - return &Log{ - Time: ts, - Content: err.Error(), - Type_: o.Type_, - Error_: true, - Version: string(LogVersionV1), - } + return newErrorLog(err, content) } // pass parsed results for v1 @@ -210,3 +205,65 @@ func NewLogFromBytes(b []byte) *Log { Version: string(LogVersionV2), } } + +// ReadLogLine tries to read possible log lines from any source +// - logv2 - JSON +// - logv1 - old log format JSON - DEPRECATED +// - possible errors or raw log lines +func ReadLogLine(b []byte) *Log { + logsV1Prefix := []byte("{\"id\"") + logsV2Prefix := []byte("{") + + switch true { + case bytes.HasPrefix(b, logsV1Prefix): + o, err := output.GetLogEntry(b) + if err != nil { + return newErrorLog(err, b) + } + return mapLogV1toV2(o) + + case bytes.HasPrefix(b, logsV2Prefix): + var o Log + err := json.Unmarshal(b, &o) + if err != nil { + return newErrorLog(err, b) + } + return &o + } + + return &Log{ + Content: string(b), + } +} + +func newErrorLog(err error, b []byte) *Log { + return &Log{ + Content: string(b), + Error_: true, + Version: string(LogVersionV1), + Metadata: map[string]string{"error": err.Error()}, + } + +} + +func mapLogV1toV2(o output.Output) *Log { + // pass parsed results for v1 + // for new executor it'll be omitted in logs (as looks like we're not using it already) + if o.Type_ == output.TypeResult { + return &Log{ + Time: o.Time, + Content: o.Content, + Version: string(LogVersionV1), + V1: &testkube.LogV1{ + Result: o.Result, + }, + } + } + + return &Log{ + Time: o.Time, + Content: o.Content, + Version: string(LogVersionV1), + } + +} diff --git a/pkg/logs/pb/logs.pb.go b/pkg/logs/pb/logs.pb.go index 45bb9c5814f..611f0844f03 100644 --- a/pkg/logs/pb/logs.pb.go +++ b/pkg/logs/pb/logs.pb.go @@ -209,6 +209,61 @@ func (x *Log) GetMetadata() map[string]string { return nil } +type CloudLogRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + EnvironmentId string `protobuf:"bytes,1,opt,name=environment_id,json=environmentId,proto3" json:"environment_id,omitempty"` + ExecutionId string `protobuf:"bytes,2,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` +} + +func (x *CloudLogRequest) Reset() { + *x = CloudLogRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_logs_pb_logs_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CloudLogRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloudLogRequest) ProtoMessage() {} + +func (x *CloudLogRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_logs_pb_logs_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloudLogRequest.ProtoReflect.Descriptor instead. +func (*CloudLogRequest) Descriptor() ([]byte, []int) { + return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{2} +} + +func (x *CloudLogRequest) GetEnvironmentId() string { + if x != nil { + return x.EnvironmentId + } + return "" +} + +func (x *CloudLogRequest) GetExecutionId() string { + if x != nil { + return x.ExecutionId + } + return "" +} + type StreamResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -221,7 +276,7 @@ type StreamResponse struct { func (x *StreamResponse) Reset() { *x = StreamResponse{} if protoimpl.UnsafeEnabled { - mi := &file_pkg_logs_pb_logs_proto_msgTypes[2] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -234,7 +289,7 @@ func (x *StreamResponse) String() string { func (*StreamResponse) ProtoMessage() {} func (x *StreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_pkg_logs_pb_logs_proto_msgTypes[2] + mi := &file_pkg_logs_pb_logs_proto_msgTypes[3] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -247,7 +302,7 @@ func (x *StreamResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamResponse.ProtoReflect.Descriptor instead. func (*StreamResponse) Descriptor() ([]byte, []int) { - return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{2} + return file_pkg_logs_pb_logs_proto_rawDescGZIP(), []int{3} } func (x *StreamResponse) GetMessage() string { @@ -292,27 +347,33 @@ var file_pkg_logs_pb_logs_proto_rawDesc = []byte{ 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0x5e, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x32, 0x0a, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, - 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x2a, 0x31, 0x0a, 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x61, 0x69, 0x6c, 0x65, - 0x64, 0x10, 0x01, 0x32, 0x34, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, - 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, - 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, 0x32, 0x66, 0x0a, 0x10, 0x43, 0x6c, 0x6f, - 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2b, 0x0a, - 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, - 0x6f, 0x67, 0x1a, 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x25, 0x0a, 0x04, 0x4c, 0x6f, - 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, - 0x01, 0x42, 0x0d, 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, 0x62, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x22, 0x5b, 0x0a, 0x0f, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, + 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x65, 0x6e, 0x76, + 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x78, + 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x5e, 0x0a, + 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x67, 0x73, + 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2a, 0x31, 0x0a, + 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, + 0x65, 0x64, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x01, + 0x32, 0x34, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, + 0x25, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, + 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, + 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, 0x32, 0x6b, 0x0a, 0x10, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, + 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x1a, + 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x2a, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, + 0x15, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, + 0x67, 0x30, 0x01, 0x42, 0x0d, 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, + 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -328,24 +389,25 @@ func file_pkg_logs_pb_logs_proto_rawDescGZIP() []byte { } var file_pkg_logs_pb_logs_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pkg_logs_pb_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pkg_logs_pb_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_pkg_logs_pb_logs_proto_goTypes = []interface{}{ (StreamResponseStatus)(0), // 0: logs.StreamResponseStatus (*LogRequest)(nil), // 1: logs.LogRequest (*Log)(nil), // 2: logs.Log - (*StreamResponse)(nil), // 3: logs.StreamResponse - nil, // 4: logs.Log.MetadataEntry - (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp + (*CloudLogRequest)(nil), // 3: logs.CloudLogRequest + (*StreamResponse)(nil), // 4: logs.StreamResponse + nil, // 5: logs.Log.MetadataEntry + (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp } var file_pkg_logs_pb_logs_proto_depIdxs = []int32{ - 5, // 0: logs.Log.time:type_name -> google.protobuf.Timestamp - 4, // 1: logs.Log.metadata:type_name -> logs.Log.MetadataEntry + 6, // 0: logs.Log.time:type_name -> google.protobuf.Timestamp + 5, // 1: logs.Log.metadata:type_name -> logs.Log.MetadataEntry 0, // 2: logs.StreamResponse.status:type_name -> logs.StreamResponseStatus 1, // 3: logs.LogsService.Logs:input_type -> logs.LogRequest 2, // 4: logs.CloudLogsService.Stream:input_type -> logs.Log - 1, // 5: logs.CloudLogsService.Logs:input_type -> logs.LogRequest + 3, // 5: logs.CloudLogsService.Logs:input_type -> logs.CloudLogRequest 2, // 6: logs.LogsService.Logs:output_type -> logs.Log - 3, // 7: logs.CloudLogsService.Stream:output_type -> logs.StreamResponse + 4, // 7: logs.CloudLogsService.Stream:output_type -> logs.StreamResponse 2, // 8: logs.CloudLogsService.Logs:output_type -> logs.Log 6, // [6:9] is the sub-list for method output_type 3, // [3:6] is the sub-list for method input_type @@ -385,6 +447,18 @@ func file_pkg_logs_pb_logs_proto_init() { } } file_pkg_logs_pb_logs_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CloudLogRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_logs_pb_logs_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StreamResponse); i { case 0: return &v.state @@ -403,7 +477,7 @@ func file_pkg_logs_pb_logs_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_logs_pb_logs_proto_rawDesc, NumEnums: 1, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 2, }, diff --git a/pkg/logs/pb/logs.proto b/pkg/logs/pb/logs.proto index d1b59901dc7..e639407f59a 100644 --- a/pkg/logs/pb/logs.proto +++ b/pkg/logs/pb/logs.proto @@ -14,6 +14,8 @@ message LogRequest { string execution_id = 2; } + + message Log{ google.protobuf.Timestamp time = 1; @@ -29,14 +31,19 @@ message Log{ } - // CloudLogsService client will be used in cloud adapter in logs server // CloudLogsService server will be implemented on cloud side service CloudLogsService { rpc Stream(stream Log) returns (StreamResponse); - rpc Logs(LogRequest) returns (stream Log); + rpc Logs(CloudLogRequest) returns (stream Log); } +message CloudLogRequest { + string environment_id = 1; + string execution_id = 2; +} + + message StreamResponse { string message = 1; StreamResponseStatus status = 2; diff --git a/pkg/logs/pb/logs_grpc.pb.go b/pkg/logs/pb/logs_grpc.pb.go index 342b554a44c..720ec076511 100644 --- a/pkg/logs/pb/logs_grpc.pb.go +++ b/pkg/logs/pb/logs_grpc.pb.go @@ -136,7 +136,7 @@ var LogsService_ServiceDesc = grpc.ServiceDesc{ // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type CloudLogsServiceClient interface { Stream(ctx context.Context, opts ...grpc.CallOption) (CloudLogsService_StreamClient, error) - Logs(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) + Logs(ctx context.Context, in *CloudLogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) } type cloudLogsServiceClient struct { @@ -181,7 +181,7 @@ func (x *cloudLogsServiceStreamClient) CloseAndRecv() (*StreamResponse, error) { return m, nil } -func (c *cloudLogsServiceClient) Logs(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) { +func (c *cloudLogsServiceClient) Logs(ctx context.Context, in *CloudLogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) { stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[1], "/logs.CloudLogsService/Logs", opts...) if err != nil { return nil, err @@ -218,7 +218,7 @@ func (x *cloudLogsServiceLogsClient) Recv() (*Log, error) { // for forward compatibility type CloudLogsServiceServer interface { Stream(CloudLogsService_StreamServer) error - Logs(*LogRequest, CloudLogsService_LogsServer) error + Logs(*CloudLogRequest, CloudLogsService_LogsServer) error mustEmbedUnimplementedCloudLogsServiceServer() } @@ -229,7 +229,7 @@ type UnimplementedCloudLogsServiceServer struct { func (UnimplementedCloudLogsServiceServer) Stream(CloudLogsService_StreamServer) error { return status.Errorf(codes.Unimplemented, "method Stream not implemented") } -func (UnimplementedCloudLogsServiceServer) Logs(*LogRequest, CloudLogsService_LogsServer) error { +func (UnimplementedCloudLogsServiceServer) Logs(*CloudLogRequest, CloudLogsService_LogsServer) error { return status.Errorf(codes.Unimplemented, "method Logs not implemented") } func (UnimplementedCloudLogsServiceServer) mustEmbedUnimplementedCloudLogsServiceServer() {} @@ -272,7 +272,7 @@ func (x *cloudLogsServiceStreamServer) Recv() (*Log, error) { } func _CloudLogsService_Logs_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(LogRequest) + m := new(CloudLogRequest) if err := stream.RecvMsg(m); err != nil { return err } From a6b281237162c09c2c81b40ddf72d8fd77947e4c Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Wed, 21 Feb 2024 13:38:22 +0100 Subject: [PATCH 103/234] feat: [TKC-1207] TCL license checker (#5021) * feat: license-checker * fix: format license * feat: plan check via grpc * docs: add reference to FAQ * fix: test * ci: goimports * fix: remove check based on rest endpoint --- cmd/api-server/main.go | 1 + go.mod | 2 +- go.sum | 2 + internal/config/config.go | 1 + internal/config/procontext.go | 1 + licenses/TCL.txt | 391 ++++++++++++++++++ pkg/cloud/data/config/commands.go | 1 + pkg/executor/common.go | 4 + .../containerexecutor_test.go | 1 + pkg/tcl/README.md | 7 + pkg/tcl/checktcl/organization_plan.go | 50 +++ pkg/tcl/checktcl/subscription.go | 83 ++++ pkg/tcl/checktcl/subscription_test.go | 192 +++++++++ 13 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 licenses/TCL.txt create mode 100644 pkg/tcl/README.md create mode 100644 pkg/tcl/checktcl/organization_plan.go create mode 100644 pkg/tcl/checktcl/subscription.go create mode 100644 pkg/tcl/checktcl/subscription_test.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 7bca30f8f88..bb388683137 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -535,6 +535,7 @@ func main() { EnvID: cfg.TestkubeProEnvID, OrgID: cfg.TestkubeProOrgID, Migrate: cfg.TestkubeProMigrate, + ConnectionTimeout: cfg.TestkubeProConnectionTimeout, } api.WithProContext(&proContext) diff --git a/go.mod b/go.mod index 81ed4455878..716851edc56 100644 --- a/go.mod +++ b/go.mod @@ -188,7 +188,7 @@ require ( golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.31.0 + google.golang.org/protobuf v1.32.0 gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 3503a6a7c5b..c903a6d5a9b 100644 --- a/go.sum +++ b/go.sum @@ -960,6 +960,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 6de38388193..84eeaa28077 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,6 +72,7 @@ type Config struct { TestkubeProEnvID string `envconfig:"TESTKUBE_PRO_ENV_ID" default:""` TestkubeProOrgID string `envconfig:"TESTKUBE_PRO_ORG_ID" default:""` TestkubeProMigrate string `envconfig:"TESTKUBE_PRO_MIGRATE" default:"false"` + TestkubeProConnectionTimeout int `envconfig:"TESTKUBE_PRO_CONNECTION_TIMEOUT" default:"10"` TestkubeWatcherNamespaces string `envconfig:"TESTKUBE_WATCHER_NAMESPACES" default:""` GraphqlPort string `envconfig:"TESTKUBE_GRAPHQL_PORT" default:"8070"` TestkubeRegistry string `envconfig:"TESTKUBE_REGISTRY" default:""` diff --git a/internal/config/procontext.go b/internal/config/procontext.go index 163d74297ae..24831170d5f 100644 --- a/internal/config/procontext.go +++ b/internal/config/procontext.go @@ -11,4 +11,5 @@ type ProContext struct { EnvID string OrgID string Migrate string + ConnectionTimeout int } diff --git a/licenses/TCL.txt b/licenses/TCL.txt new file mode 100644 index 00000000000..ee5b74c2386 --- /dev/null +++ b/licenses/TCL.txt @@ -0,0 +1,391 @@ +Testkube Community License Agreement + +Please read this Testkube Community License Agreement (the “Agreement”) +carefully before using Testkube (as defined below), which is offered by +Testkube or its affiliated Legal Entities (“Testkube”). + +By accessing, installing, downloading or in any manner using Testkube, +You agree that You have read and agree to be bound by the terms of this +Agreement. If You are accessing Testkube on behalf of a Legal Entity, +You represent and warrant that You have the authority to agree to these +terms on its behalf and the right to bind that Legal Entity to this +Agreement. Use of Testkube is expressly conditioned upon Your assent to +all the terms of this Agreement, as well as the other Testkube agreements, +including the Testkube Privacy Policy and Testkube Terms and Conditions, +accessible at: https://testkube.io/privacy-policy and +https://testkube.io/terms-and-conditions. + +1. Definitions. In addition to other terms defined elsewhere in this Agreement +and in the other Testkube agreements, the terms below have the following +meanings. + +(a) “Testkube” shall mean the Test Orchestration and Execution software +provided by Testkube, including both Testkube Core and Testkube Pro, as +defined below. + +(b) “Testkube Core” shall mean the version and features of Testkube designated +as free of charge at https://testkube.io/pricing and available at +https://github.com/kubeshop/testkube pursuant to the terms of the MIT license. + +(c) “Testkube Pro” shall mean the version of Testkube which includes the +additional paid features of Testkube designated at +https://testkube.io/pricing and made available by Testkube, also at +https://github.com/kubeshop/testkube, the use of which is subject to additional +terms set out below. + +(d) “Contribution” shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Testkube for +inclusion in the Work by the copyright owner or by an individual or Legal Entity +authorized to submit on behalf of the copyright owner. For the purposes of this +definition, “submitted” means any form of electronic, verbal, or written +communication sent to Testkube or its representatives, including but not +limited to communication on electronic mailing lists, source code control +systems, and issue tracking systems that are managed by, or on behalf of, +Testkube for the purpose of discussing and improving the Work, but excluding +communication that is conspicuously marked or otherwise designated in writing +by the copyright owner as “Not a Contribution.” + +(e) “Contributor” shall mean any copyright owner or individual or Legal Entity +authorized by the copyright owner, other than Testkube, from whom Testkube +receives a Contribution that Testkube subsequently incorporates within the Work. + +(f) “Derivative Works” shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work, such as a translation, abridgement, +condensation, or any other recasting, transformation, or adaptation for which +the editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes of this +License, Derivative Works shall not include works that remain separable from, or +merely link (or bind by name) to the interfaces of, the Work and Derivative +Works thereof. You may create certain Derivative Works of Testkube Pro (“Pro +Derivative Works”, as defined below) provided that such Pro Derivative Works +are solely created, distributed, and accessed for Your internal use, and are +not created, distributed, or accessed in such a way that the Pro Derivative +Works would modify, circumvent, or otherwise bypass controls implemented, if +any, to ensure that Testkube Pro users comply with the terms of the Paid Pro +License. Notwithstanding anything contained herein to the contrary, You may not +modify or alter the Source of Testkube Pro absent Testkube’s prior express +written permission. If You have any questions about creating Pro Derivative +Works or otherwise modifying or redistributing Testkube Pro, please contact +Testkube at support@testkube.io. + +(g) “Legal Entity” shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with that +entity. For the purposes of this definition, “control” means (i) the power, +direct or indirect, to cause the direction or management of such entity, whether +by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of +the outstanding shares, or (iii) beneficial ownership of such entity. + +(h) “License” shall mean the terms and conditions for use, reproduction, and +distribution of a Work as defined by this Agreement. + +(i) “Licensor” shall mean Testkube or a Contributor, as applicable. + +(j) “Object” form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +(k) “Source” form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +(l) “Third Party Works” shall mean Works, including Contributions, and other +technology owned by a person or Legal Entity other than Testkube, as indicated +by a copyright notice that is included in or attached to such Works or technology. + +(m) “Work” shall mean the work of authorship, whether in Source or Object form, +made available under a License, as indicated by a copyright notice that is +included in or attached to the work. + +(n) “You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +2. Licenses. + +(a) License to Testkube Core. The License for the applicable version of +Testkube Core can be found on the Testkube Licensing FAQs page and in the +applicable license file within the Testkube GitHub repository(ies). Testkube +Core is a no-cost, entry-level license and as such, contains the following +disclaimers: NOTWITHSTANDING ANYTHING TO THE CONTRARY HEREIN, TESTKUBE CORE +IS PROVIDED “AS IS” AND “AS AVAILABLE”, AND ALL EXPRESS OR IMPLIED WARRANTIES +ARE EXCLUDED AND DISCLAIMED, INCLUDING WITHOUT LIMITATION THE IMPLIED WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND ANY +WARRANTIES ARISING BY STATUTE OR OTHERWISE IN LAW OR FROM COURSE OF DEALING, +COURSE OF PERFORMANCE, OR USE IN TRADE. For clarity, the terms of this Agreement, +other than the relevant definitions in Section 1 and this Section 2(a) do not +apply to Testkube Core. + +(b) License to Testkube Pro. + +(i) Grant of Copyright License: Subject to the terms of this Agreement, Licensor +hereby grants to You a worldwide, non-exclusive, non-transferable limited +license to reproduce, prepare Pro Derivative Works (as defined below) of, +publicly display, publicly perform, sublicense, and distribute Testkube Pro for +Your business purposes, for so long as You are not in violation of this +Section 2(b) and are current on all payments required by Section 4 below. + +(ii) Grant of Patent License: Subject to the terms of this Agreement, Licensor +hereby grants to You a worldwide, non-exclusive, non-transferable limited patent +license to make, have made, use, and import Testkube Pro, where such license +applies only to those patent claims licensable by Licensor that are necessarily +infringed by their Contribution(s) alone or by combination of their +Contribution(s) with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a cross-claim or +counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated +within the Work constitutes direct or contributory patent infringement, then any +patent licenses granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +(iii) License to Third Party Works: From time to time Testkube may use, or +provide You access to, Third Party Works in connection with Testkube Pro. You +acknowledge and agree that in addition to this Agreement, Your use of Third Party +Works is subject to all other terms and conditions set forth in the License +provided with or contained in such Third Party Works. Some Third Party Works may +be licensed to You solely for use with Testkube Pro under the terms of a third +party License, or as otherwise notified by Testkube, and not under the terms of +this Agreement. You agree that the owners and third party licensors of Third +Party Works are intended third party beneficiaries to this Agreement, and You +agree to abide by all third party terms and conditions and licenses. + +3. Support. From time to time, in its sole discretion, Testkube may offer +professional services or support for Testkube, which may now or in the future be +subject to additional fees, as outlined at https://testkube.io/pricing. + +4. Fees for Testkube Pro or Testkube Support. + +(a) Fees. The License to Testkube Pro is conditioned upon Your entering into a +subscription agreement with Testkube for its use (a “Paid Pro License”) and +timely paying Testkube for such Paid Pro License; provided that features of +Testkube Pro that are features of Testkube Core and are not designated as “Pro +features” at https://testkube.io/pricing may be used for free under the terms of +the Agreement without a Paid Pro License. Testkube Pro may at its discretion +include within Testkube Pro certain Source code solely intended to determine +Your compliance with the Paid Pro License which may be accessed without a Paid +Pro License, provided that under no circumstances may You modify Testkube Pro +to circumvent the Paid Pro License requirement. Any professional services or +support for Testkube may also be subject to Your payment of fees, which will be +specified by Testkube when you sign up to receive such professional services or +support. Testkube reserves the right to change the fees at any time with prior +written notice; for recurring fees, any such adjustments will take effect as of +the next pay period. + +(b) Overdue Payments and Taxes. Overdue payments are subject to a service charge +equal to the lesser of 1.5% per month or the maximum legal interest rate allowed +by law, and You shall pay all Testkube reasonable costs of collection, including +court costs and attorneys’ fees. Fees are stated and payable in U.S. dollars and +are exclusive of all sales, use, value added and similar taxes, duties, +withholdings and other governmental assessments (but excluding taxes based on +Testkube income) that may be levied on the transactions contemplated by this +Agreement in any jurisdiction, all of which are Your responsibility unless you +have provided Testkube with a valid tax-exempt certificate. If You owe Testkube +overdue payments, Testkube reserves the right to revoke any license(s) granted +by this Agreement and revoke to Your access to Testkube Core and to Testkube Pro. + +(c) Record-keeping and Audit. If fees for Testkube Pro are based on the number +of environments running on Testkube Pro or another use-based unit of measurement, +including number of users, You must maintain complete and accurate records with +respect Your use of Testkube Pro and will provide such records to Testkube for +inspection or audit upon Testkube’s reasonable request. If an inspection or +audit uncovers additional usage by You for which fees are owed under this +Agreement, then You shall pay for such additional usage at Testkube’s +then-current rates. + +5. Trial License. If You have signed up for a trial or evaluation of Testkube +Pro, Your License to Testkube Pro is granted without charge for the trial or +evaluation period specified when You signed up, or if no term was specified, for +forty-five (45) calendar days, provided that Your License is granted solely for +purposes of Your internal evaluation of Testkube Pro during the trial or +evaluation period (a “Trial License”). You may not use Testkube Pro or any +Testkube Pro features under a Trial License more than once in any twelve (12) +month period. Testkube may revoke a Trial License at any time and for any reason. +Sections 3, 4, 9 and 11 of this Agreement do not apply to Trial Licenses. + +6. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy of +this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, including for internal purposes at Your Legal Entities, all +copyright, patent, trademark, and attribution notices from the Source form of +the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and + +(d) If the Work includes a “NOTICE” or equivalent text file as part of its +distribution, then any Derivative Works that You distribute must include a +readable copy of the attribution notices contained within such NOTICE file, +excluding those notices that do not pertain to any part of the Derivative Works, +in at least one of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or documentation, if +provided along with the Derivative Works; or, within a display generated by the +Derivative Works, if and wherever such third-party notices normally appear. The +contents of the NOTICE or equivalent files are for informational purposes only +and do not modify the License. You may add Your own attribution notices within +Derivative Works that You distribute for Your internal use, alongside or as an +addendum to the NOTICE text from the Work, provided that such additional +attribution notices cannot be construed as modifying the License. + +You may not create Derivative Works, including Pro Derivative Works (as defined +below), which add Your own copyright statements or provide additional or +different license terms and conditions for use, reproduction, or distribution of +Your modifications, or for any such Derivative Works as a whole. All Derivative +Works, including Your use, reproduction, and distribution of the Work, must +comply in all respects with the conditions stated in this License. + +(e) Pro Derivative Works: Derivative Works of Testkube Pro (“Pro Derivative +Works”) may only be made, reproduced and distributed, without modifications, in +Source or Object form, provided that such Pro Derivative Works are solely for +Your internal use. Each Pro Derivative Work shall be governed by this Agreement, +shall include a License to Testkube Pro, and thus will be subject to the payment +of fees to Testkube by any user of the Pro Derivative Work. + +7. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution submitted for inclusion in Testkube Pro by You to Testkube shall be +under the terms and conditions of this Agreement, without any additional terms +or conditions, payments of royalties or otherwise to Your benefit. Testkube may +at any time, at its sole discretion, elect for the Contribution to be subject to +the Paid Pro License. If You wish to reserve any rights regarding Your +Contribution, You must contact Testkube at support@testkube.io prior to +submitting the Contribution. + +8. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of Licensor, except as required for +reasonable and customary use in describing the origin of the Work and reproducing +the content of the NOTICE or equivalent file. + +9. Limited Warranty. + +(a) Warranties. Subject to the terms of the Paid Pro License, or any other +agreement between You and Testkube which governs the terms of Your access to +Testkube Pro, Testkube warrants to You that: (i) Testkube Pro will materially +perform in accordance with the applicable documentation for thirty (30) days +after initial delivery to You; and (ii) any professional services performed by +Testkube under this Agreement will be performed in a workmanlike manner, in +accordance with general industry standards. + +(b) Exclusions. Testkube’s warranties in this Section 9 do not extend to problems +that result from: (i) Your failure to implement updates issued by Testkube during +the warranty period; (ii) any alterations or additions (including Pro Derivative +Works and Contributions) to Testkube not performed by or at the direction of +Testkube; (iii) failures that are not reproducible by Testkube; (iv) operation +of Testkube Pro in violation of this Agreement or not in accordance with its +documentation; (v) failures caused by software, hardware, or products not +licensed or provided by Testkube hereunder; or (vi) Third Party Works. + +(c) Remedies. In the event of a breach of a warranty under this Section 9, +Testkube will, at its discretion and cost, either repair, replace or re-perform +the applicable Works or services or refund a portion of fees previously paid to +Testkube that are associated with the defective Works or services. This is Your +exclusive remedy, and Testkube’s sole liability, arising in connection with the +limited warranties herein and shall, in all cases, be limited to the fees paid +to Testkube in the three (3) months preceding the delivery of the defective Works +or services. + +10. Disclaimer of Warranty. Except as set out in Section 9, unless required by +applicable law, Licensor provides the Work (and each Contributor provides its +Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied, arising out of course of dealing, course of +performance, or usage in trade, including, without limitation, any warranties +or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, CORRECTNESS, +RELIABILITY, or FITNESS FOR A PARTICULAR PURPOSE, all of which are hereby +disclaimed. You are solely responsible for determining the appropriateness of +using or redistributing Works and assume any risks associated with Your exercise +of permissions under the applicable License for such Works. + +11. Limited Indemnity. + +(a) Indemnity. Testkube will defend, indemnify and hold You harmless against +any third party claims, liabilities or expenses incurred (including reasonable +attorneys’ fees), as well as amounts finally awarded in a settlement or a +non-appealable judgement by a court (“Losses”), to the extent arising from any +claim or allegation by a third party that Testkube Pro infringes or +misappropriates a valid United States patent, copyright, or trade secret right +of a third party; provided that You give Testkube: (i) prompt written notice of +any such claim or allegation; (ii) sole control of the defense and settlement +thereof; and (iii) reasonable cooperation and assistance in such defense or +settlement. If any Work within Testkube Pro becomes or in Testkube’s opinion is +likely to become, the subject of an injunction, Testkube may, at its option, +(A) procure for You the right to continue using such Work, (B) replace or modify +such Work so that it becomes non-infringing without substantially compromising +its functionality, or, if (A) and (B) are not commercially practicable, then (C) +terminate Your license to the allegedly infringing Work and refund to You a +prorated portion of the prepaid and unearned fees for such infringing Work. The +foregoing comprises the entire liability of Testkube with respect to infringement +of patents, copyrights, trade secrets, or other intellectual property rights. + +(b) Exclusions. The foregoing obligations on Testkube shall not apply to: (i) +Works modified by any party other than Testkube (including Pro Derivative Works +and Contributions) where the alleged infringement relates to such modification, +(ii) Works combined or bundled with any products, processes, or materials not +provided by Testkube where the alleged infringement relates to such combination, +(iii) use of a version of Testkube Pro other than the version that was current at +the time of such use, as long as a non-infringing version had been released at +the time of the alleged infringement, (iv) any Works created to Your +specifications, (v) infringement or misappropriation of any proprietary or +intellectual property right in which You have an interest, or (vi) Third Party +Works. You will defend, indemnify, and hold Testkube harmless against any Losses +arising from any such claim or allegation as described in the scenarios in this +Section 11(b), subject to conditions reciprocal to those in Section 11(a). + +12. Limitation of Liability. In no event and under no legal or equitable theory, +whether in tort (including negligence), contract, or otherwise, unless required +by applicable law (such as deliberate and grossly negligent acts), and +notwithstanding anything in this Agreement to the contrary, shall Licensor or +any Contributor be liable to You for (i) any amounts in excess, in the aggregate, +of the fees paid by You to Testkube under this Agreement in the twelve (12) +months preceding the date the first cause of liability arose, or (ii) any +indirect, special, incidental, punitive, exemplary, reliance, or consequential +damages of any character arising as a result of this Agreement or out of the use +or inability to use the Work (including but not limited to damages for loss of +goodwill, profits, data or data use, work stoppage, computer failure or +malfunction, cost of procurement of substitute goods, technology or services, +or any and all other commercial damages or losses), even if such Licensor or +Contributor has been advised of the possibility of such damages. THESE +LIMITATIONS SHALL APPLY NOTWITHSTANDING THE FAILURE OF THE ESSENTIAL PURPOSE OF +ANY LIMITED REMEDY. + +13. General. + +(a) Relationship of Parties. You and Testkube are independent contractors, and +nothing herein shall be deemed to constitute either party as the agent or +representative of the other or both parties as joint venturers or partners for +any purpose. + +(b) Export Control. You shall comply with the U.S. Foreign Corrupt Practices Act +and all applicable export laws, restrictions and regulations of the U.S. +Department of Commerce, U.S. Department of Treasury, and any other applicable +U.S. and foreign authority(ies). + +(c) Assignment. This Agreement and the rights and obligations herein may not be +assigned or transferred, in whole or in part, by You without the prior written +consent of Testkube. Any assignment in violation of this provision is void. This +Agreement shall be binding upon, and inure to the benefit of, the successors and +permitted assigns of the parties. + +(d) Governing Law. This Agreement shall be governed by and construed under the +laws of the State of Delaware and the United States without regard to conflicts +of laws provisions thereof, and without regard to the Uniform Computer +Information Transactions Act. + +(e) Attorneys’ Fees. In any action or proceeding to enforce rights under this +Agreement, the prevailing party shall be entitled to recover its costs, expenses, +and attorneys’ fees. + +(f) Severability. If any provision of this Agreement is held to be invalid, +illegal, or unenforceable in any respect, that provision shall be limited or +eliminated to the minimum extent necessary so that this Agreement otherwise +remains in full force and effect and enforceable. + +(g) Entire Agreement; Waivers; Modification. This Agreement constitutes the +entire agreement between the parties relating to the subject matter hereof and +supersedes all proposals, understandings, or discussions, whether written or +oral, relating to the subject matter of this Agreement and all past dealing or +industry custom. The failure of either party to enforce its rights under this +Agreement at any time for any period shall not be construed as a waiver of such +rights. No changes, modifications or waivers to this Agreement will be effective +unless in writing and signed by both parties. diff --git a/pkg/cloud/data/config/commands.go b/pkg/cloud/data/config/commands.go index b6a2668880b..e4ab0d4bdb5 100644 --- a/pkg/cloud/data/config/commands.go +++ b/pkg/cloud/data/config/commands.go @@ -7,4 +7,5 @@ const ( CmdConfigGetTelemetryEnabled executor.Command = "get_telemetry_enabled" CmdConfigGet executor.Command = "get" CmdConfigUpsert executor.Command = "upsert" + CmdConfigGetOrganizationPlan executor.Command = "get_org_plan" ) diff --git a/pkg/executor/common.go b/pkg/executor/common.go index c10723a8b4b..0a281c8dca2 100644 --- a/pkg/executor/common.go +++ b/pkg/executor/common.go @@ -123,6 +123,10 @@ var RunnerEnvVars = []corev1.EnvVar{ Name: "RUNNER_PRO_API_SKIP_VERIFY", Value: getOr("TESTKUBE_PRO_SKIP_VERIFY", "false"), }, + { + Name: "RUNNER_PRO_CONNECTION_TIMEOUT", + Value: getOr("TESTKUBE_PRO_CONNECTION_TIMEOUT", "10"), + }, { Name: "RUNNER_DASHBOARD_URI", Value: os.Getenv("TESTKUBE_DASHBOARD_URI"), diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index dfe0776e5c2..f11ac56f194 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -159,6 +159,7 @@ func TestNewExecutorJobSpecWithArgs(t *testing.T) { {Name: "RUNNER_PRO_API_URL", Value: ""}, {Name: "RUNNER_PRO_API_TLS_INSECURE", Value: "false"}, {Name: "RUNNER_PRO_API_SKIP_VERIFY", Value: "false"}, + {Name: "RUNNER_PRO_CONNECTION_TIMEOUT", Value: "10"}, {Name: "RUNNER_CLOUD_MODE", Value: "false"}, // DEPRECATED {Name: "RUNNER_CLOUD_API_KEY", Value: ""}, // DEPRECATED {Name: "RUNNER_CLOUD_API_URL", Value: ""}, // DEPRECATED diff --git a/pkg/tcl/README.md b/pkg/tcl/README.md new file mode 100644 index 00000000000..19ed9a40b45 --- /dev/null +++ b/pkg/tcl/README.md @@ -0,0 +1,7 @@ +# Testkube Operator - TCL Package + +This folder contains special code with the Testkube Community license. + +## License + +The code in this folder is licensed under the Testkube Community License. Please see the [LICENSE](../../licenses/TCL.txt) file and consult the [FAQ](../../docs/docs/articles/testkube-licensing-FAQ.md) for more information. diff --git a/pkg/tcl/checktcl/organization_plan.go b/pkg/tcl/checktcl/organization_plan.go new file mode 100644 index 00000000000..8d38df6ec6f --- /dev/null +++ b/pkg/tcl/checktcl/organization_plan.go @@ -0,0 +1,50 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package checktcl + +// Enterprise / Pro mode. +type OrganizationPlanTestkubeMode string + +const ( + OrganizationPlanTestkubeModeEnterprise OrganizationPlanTestkubeMode = "enterprise" + // TODO: Use "pro" in the future when refactoring TK Pro API server to use "pro" instead of "cloud" + OrganizationPlanTestkubeModePro OrganizationPlanTestkubeMode = "cloud" +) + +// Ref: #/components/schemas/PlanStatus +type PlanStatus string + +const ( + PlanStatusActive PlanStatus = "Active" + PlanStatusCanceled PlanStatus = "Canceled" + PlanStatusIncomplete PlanStatus = "Incomplete" + PlanStatusIncompleteExpired PlanStatus = "IncompleteExpired" + PlanStatusPastDue PlanStatus = "PastDue" + PlanStatusTrailing PlanStatus = "Trailing" + PlanStatusUnpaid PlanStatus = "Unpaid" + PlanStatusDeleted PlanStatus = "Deleted" + PlanStatusLocked PlanStatus = "Locked" + PlanStatusBlocked PlanStatus = "Blocked" +) + +// Ref: #/components/schemas/OrganizationPlan +type OrganizationPlan struct { + // Enterprise / Pro mode. + TestkubeMode OrganizationPlanTestkubeMode `json:"testkubeMode"` + // Is current plan trial. + IsTrial bool `json:"isTrial"` + PlanStatus PlanStatus `json:"planStatus"` +} + +type GetOrganizationPlanRequest struct{} +type GetOrganizationPlanResponse struct { + TestkubeMode string + IsTrial bool + PlanStatus string +} diff --git a/pkg/tcl/checktcl/subscription.go b/pkg/tcl/checktcl/subscription.go new file mode 100644 index 00000000000..0759e3b9061 --- /dev/null +++ b/pkg/tcl/checktcl/subscription.go @@ -0,0 +1,83 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package checktcl + +import ( + "context" + "encoding/json" + + "github.com/pkg/errors" + "google.golang.org/grpc" + + "github.com/kubeshop/testkube/internal/config" + "github.com/kubeshop/testkube/pkg/cloud" + cloudconfig "github.com/kubeshop/testkube/pkg/cloud/data/config" + "github.com/kubeshop/testkube/pkg/cloud/data/executor" +) + +type SubscriptionChecker struct { + proContext config.ProContext + orgPlan *OrganizationPlan +} + +// NewSubscriptionChecker creates a new subscription checker using the agent token +func NewSubscriptionChecker(ctx context.Context, proContext config.ProContext, cloudClient cloud.TestKubeCloudAPIClient, grpcConn *grpc.ClientConn) (*SubscriptionChecker, error) { + executor := executor.NewCloudGRPCExecutor(cloudClient, grpcConn, proContext.APIKey) + + req := GetOrganizationPlanRequest{} + response, err := executor.Execute(ctx, cloudconfig.CmdConfigGetOrganizationPlan, req) + if err != nil { + return nil, err + } + + var commandResponse GetOrganizationPlanResponse + if err := json.Unmarshal(response, &commandResponse); err != nil { + return nil, err + } + + subscription := OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeMode(commandResponse.TestkubeMode), + IsTrial: commandResponse.IsTrial, + PlanStatus: PlanStatus(commandResponse.PlanStatus), + } + + return &SubscriptionChecker{proContext: proContext, orgPlan: &subscription}, nil +} + +// GetCurrentOrganizationPlan returns current organization plan +func (c *SubscriptionChecker) GetCurrentOrganizationPlan() (*OrganizationPlan, error) { + if c.orgPlan == nil { + return nil, errors.New("organization plan is not set") + } + return c.orgPlan, nil +} + +// IsOrgPlanEnterprise checks if organization plan is enterprise +func (c *SubscriptionChecker) IsOrgPlanEnterprise() (bool, error) { + if c.orgPlan == nil { + return false, errors.New("organization plan is not set") + } + return c.orgPlan.TestkubeMode == OrganizationPlanTestkubeModeEnterprise, nil +} + +// IsOrgPlanCloud checks if organization plan is cloud +func (c *SubscriptionChecker) IsOrgPlanPro() (bool, error) { + if c.orgPlan == nil { + return false, errors.New("organization plan is not set") + } + return c.orgPlan.TestkubeMode == OrganizationPlanTestkubeModePro, nil +} + +// IsOrgPlanActive checks if organization plan is active +func (c *SubscriptionChecker) IsOrgPlanActive() (bool, error) { + if c.orgPlan == nil { + return false, errors.New("organization plan is not set") + } + return c.orgPlan.PlanStatus == PlanStatusActive, nil +} diff --git a/pkg/tcl/checktcl/subscription_test.go b/pkg/tcl/checktcl/subscription_test.go new file mode 100644 index 00000000000..fa662477a34 --- /dev/null +++ b/pkg/tcl/checktcl/subscription_test.go @@ -0,0 +1,192 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package checktcl + +import ( + "reflect" + "testing" +) + +func TestSubscriptionChecker_GetCurrentOrganizationPlan(t *testing.T) { + tests := []struct { + name string + orgPlan *OrganizationPlan + want *OrganizationPlan + wantErr bool + }{ + { + name: "Org plan does not exist", + wantErr: true, + }, + { + name: "Org plan exists", + orgPlan: &OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModeEnterprise, + IsTrial: false, + PlanStatus: PlanStatusActive, + }, + want: &OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModeEnterprise, + IsTrial: false, + PlanStatus: PlanStatusActive, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &SubscriptionChecker{ + orgPlan: tt.orgPlan, + } + got, err := c.GetCurrentOrganizationPlan() + if (err != nil) != tt.wantErr { + t.Errorf("SubscriptionChecker.GetCurrentOrganizationPlan() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("SubscriptionChecker.GetCurrentOrganizationPlan() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSubscriptionChecker_IsOrgPlanEnterprise(t *testing.T) { + tests := []struct { + name string + orgPlan *OrganizationPlan + want bool + wantErr bool + }{ + { + name: "no org plan", + wantErr: true, + }, + { + name: "enterprise org plan", + orgPlan: &OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModeEnterprise, + }, + want: true, + wantErr: false, + }, + { + name: "pro org plan", + orgPlan: &OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModePro, + }, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &SubscriptionChecker{ + orgPlan: tt.orgPlan, + } + got, err := c.IsOrgPlanEnterprise() + if (err != nil) != tt.wantErr { + t.Errorf("SubscriptionChecker.IsOrgPlanEnterprise() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SubscriptionChecker.IsOrgPlanEnterprise() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSubscriptionChecker_IsOrgPlanPro(t *testing.T) { + tests := []struct { + name string + orgPlan *OrganizationPlan + want bool + wantErr bool + }{ + { + name: "no org plan", + wantErr: true, + }, + { + name: "enterprise org plan", + orgPlan: &OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModeEnterprise, + }, + want: false, + wantErr: false, + }, + { + name: "pro org plan", + orgPlan: &OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModePro, + }, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &SubscriptionChecker{ + orgPlan: tt.orgPlan, + } + got, err := c.IsOrgPlanPro() + if (err != nil) != tt.wantErr { + t.Errorf("SubscriptionChecker.IsOrgPlanPro() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SubscriptionChecker.IsOrgPlanPro() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSubscriptionChecker_IsOrgPlanActive(t *testing.T) { + tests := []struct { + name string + orgPlan *OrganizationPlan + want bool + wantErr bool + }{ + { + name: "no org plan", + wantErr: true, + }, + { + name: "active org plan", + orgPlan: &OrganizationPlan{ + PlanStatus: PlanStatusActive, + }, + want: true, + wantErr: false, + }, + { + name: "inactive org plan", + orgPlan: &OrganizationPlan{ + PlanStatus: PlanStatusUnpaid, + }, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &SubscriptionChecker{ + orgPlan: tt.orgPlan, + } + got, err := c.IsOrgPlanActive() + if (err != nil) != tt.wantErr { + t.Errorf("SubscriptionChecker.IsOrgPlanActive() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SubscriptionChecker.IsOrgPlanActive() = %v, want %v", got, tt.want) + } + }) + } +} From a33d8cad80206c49f485205ed79b13b71e3e56fe Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 20 Feb 2024 21:00:43 +0300 Subject: [PATCH 104/234] feat: disable secret creation --- api/v1/testkube.yaml | 3 +++ cmd/api-server/main.go | 1 + internal/app/api/v1/handlers.go | 17 +++++++++-------- internal/app/api/v1/server.go | 3 +++ internal/app/api/v1/tests.go | 4 ++-- internal/app/api/v1/testsource.go | 6 +++--- internal/config/config.go | 1 + pkg/api/v1/testkube/model_event.go | 5 ++--- pkg/api/v1/testkube/model_server_info.go | 6 ++++-- 9 files changed, 28 insertions(+), 18 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index f22164ada28..81eab0d8d10 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -4614,6 +4614,9 @@ components: type: string description: dashboard uri example: "http://localhost:8080" + disableSecretCreation: + type: boolean + description: disable secret creation for tests and test sources features: $ref: "#/components/schemas/Features" diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index bb388683137..a05d342340d 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -520,6 +520,7 @@ func main() { features, logsStream, logGrpcClient, + cfg.DisableSecretCreation, ) if mode == common.ModeAgent { diff --git a/internal/app/api/v1/handlers.go b/internal/app/api/v1/handlers.go index f90edfac2d9..a4a700f17fa 100644 --- a/internal/app/api/v1/handlers.go +++ b/internal/app/api/v1/handlers.go @@ -61,14 +61,15 @@ func (s *TestkubeAPI) InfoHandler() fiber.Handler { } return func(c *fiber.Ctx) error { return c.JSON(testkube.ServerInfo{ - Commit: version.Commit, - Version: version.Version, - Namespace: s.Namespace, - Context: apiContext, - EnvId: envID, - OrgId: orgID, - HelmchartVersion: s.helmchartVersion, - DashboardUri: s.dashboardURI, + Commit: version.Commit, + Version: version.Version, + Namespace: s.Namespace, + Context: apiContext, + EnvId: envID, + OrgId: orgID, + HelmchartVersion: s.helmchartVersion, + DashboardUri: s.dashboardURI, + DisableSecretCreation: s.disableSecretCreation, Features: &testkube.Features{ LogsV2: s.featureFlags.LogsV2, }, diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 03bfe0df3f4..68e77e1ccec 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -93,6 +93,7 @@ func NewTestkubeAPI( ff featureflags.FeatureFlags, logsStream logsclient.Stream, logGrpcClient logsclient.StreamGetter, + disableSecretCreation bool, ) TestkubeAPI { var httpConfig server.Config @@ -140,6 +141,7 @@ func NewTestkubeAPI( featureFlags: ff, logsStream: logsStream, logGrpcClient: logGrpcClient, + disableSecretCreation: disableSecretCreation, } // will be reused in websockets handler @@ -200,6 +202,7 @@ type TestkubeAPI struct { logsStream logsclient.Stream logGrpcClient logsclient.StreamGetter proContext *config.ProContext + disableSecretCreation bool } type storageParams struct { diff --git a/internal/app/api/v1/tests.go b/internal/app/api/v1/tests.go index 6eefb5644d7..2316e076cb4 100644 --- a/internal/app/api/v1/tests.go +++ b/internal/app/api/v1/tests.go @@ -363,7 +363,7 @@ func (s TestkubeAPI) CreateTestHandler() fiber.Handler { test = testsmapper.MapUpsertToSpec(request) test.Namespace = s.Namespace - if request.Content != nil && request.Content.Repository != nil { + if request.Content != nil && request.Content.Repository != nil && !s.disableSecretCreation { secrets = createTestSecretsData(request.Content.Repository.Username, request.Content.Repository.Token) } } @@ -439,7 +439,7 @@ func (s TestkubeAPI) UpdateTestHandler() fiber.Handler { if request.Content != nil && (*request.Content) != nil && (*request.Content).Repository != nil && *(*request.Content).Repository != nil { username := (*(*request.Content).Repository).Username token := (*(*request.Content).Repository).Token - if username != nil || token != nil { + if (username != nil || token != nil) && !s.disableSecretCreation { data, err := s.SecretClient.Get(secret.GetMetadataName(name, client.SecretTest)) if err != nil && !errors.IsNotFound(err) { return s.Error(c, http.StatusBadGateway, err) diff --git a/internal/app/api/v1/testsource.go b/internal/app/api/v1/testsource.go index d75cf1bf9cf..e5e71624e9a 100644 --- a/internal/app/api/v1/testsource.go +++ b/internal/app/api/v1/testsource.go @@ -47,7 +47,7 @@ func (s TestkubeAPI) CreateTestSourceHandler() fiber.Handler { testSource = testsourcesmapper.MapAPIToCRD(request) testSource.Namespace = s.Namespace - if request.Repository != nil { + if request.Repository != nil && !s.disableSecretCreation { secrets = createTestSecretsData(request.Repository.Username, request.Repository.Token) } } @@ -104,7 +104,7 @@ func (s TestkubeAPI) UpdateTestSourceHandler() fiber.Handler { if request.Repository != nil && (*request.Repository) != nil { username := (*request.Repository).Username token := (*request.Repository).Token - if username != nil || token != nil { + if (username != nil || token != nil) && !s.disableSecretCreation { data, err := s.SecretClient.Get(secret.GetMetadataName(name, client.SecretSource)) if err != nil && !errors.IsNotFound(err) { return s.Error(c, http.StatusBadGateway, err) @@ -246,7 +246,7 @@ func (s TestkubeAPI) ProcessTestSourceBatchHandler() fiber.Handler { for name, item := range testSourceBatch { testSource := testsourcesmapper.MapAPIToCRD(item) var username, token string - if item.Repository != nil { + if item.Repository != nil && !s.disableSecretCreation { username = item.Repository.Username token = item.Repository.Token } diff --git a/internal/config/config.go b/internal/config/config.go index 84eeaa28077..4d5cb549ad2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -96,6 +96,7 @@ type Config struct { LogServerCertFile string `envconfig:"LOG_SERVER_CERT_FILE" default:""` LogServerKeyFile string `envconfig:"LOG_SERVER_KEY_FILE" default:""` LogServerCAFile string `envconfig:"LOG_SERVER_CA_FILE" default:""` + DisableSecretCreation bool `envconfig:"DISABLE_SECRET_CREATION" default:"false"` // DEPRECATED: Use TestkubeProAPIKey instead TestkubeCloudAPIKey string `envconfig:"TESTKUBE_CLOUD_API_KEY" default:""` diff --git a/pkg/api/v1/testkube/model_event.go b/pkg/api/v1/testkube/model_event.go index 63d6ee5e984..3a31df3d205 100644 --- a/pkg/api/v1/testkube/model_event.go +++ b/pkg/api/v1/testkube/model_event.go @@ -12,9 +12,8 @@ package testkube // Event data type Event struct { // UUID of event - Id string `json:"id"` - StreamTopic string `json:"topic"` - Resource *EventResource `json:"resource"` + Id string `json:"id"` + Resource *EventResource `json:"resource"` // ID of resource ResourceId string `json:"resourceId"` Type_ *EventType `json:"type"` diff --git a/pkg/api/v1/testkube/model_server_info.go b/pkg/api/v1/testkube/model_server_info.go index 84f62f9a812..5f3f0193b96 100644 --- a/pkg/api/v1/testkube/model_server_info.go +++ b/pkg/api/v1/testkube/model_server_info.go @@ -26,6 +26,8 @@ type ServerInfo struct { // helm chart version HelmchartVersion string `json:"helmchartVersion,omitempty"` // dashboard uri - DashboardUri string `json:"dashboardUri,omitempty"` - Features *Features `json:"features,omitempty"` + DashboardUri string `json:"dashboardUri,omitempty"` + // disable secret creation for tests and test sources + DisableSecretCreation bool `json:"disableSecretCreation,omitempty"` + Features *Features `json:"features,omitempty"` } From fe5b31fee4897eb7a4378af6ae2d3852e3a59faf Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 21 Feb 2024 13:18:47 +0300 Subject: [PATCH 105/234] fix: missing model field --- api/v1/testkube.yaml | 3 +++ pkg/api/v1/testkube/model_event.go | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 81eab0d8d10..fec2e4eff44 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -5506,6 +5506,9 @@ components: id: type: string description: UUID of event + streamTopic: + type: string + description: stream topic resource: $ref: "#/components/schemas/EventResource" resourceId: diff --git a/pkg/api/v1/testkube/model_event.go b/pkg/api/v1/testkube/model_event.go index 3a31df3d205..7cdccd26fd7 100644 --- a/pkg/api/v1/testkube/model_event.go +++ b/pkg/api/v1/testkube/model_event.go @@ -12,8 +12,10 @@ package testkube // Event data type Event struct { // UUID of event - Id string `json:"id"` - Resource *EventResource `json:"resource"` + Id string `json:"id"` + // stream topic + StreamTopic string `json:"streamTopic,omitempty"` + Resource *EventResource `json:"resource"` // ID of resource ResourceId string `json:"resourceId"` Type_ *EventType `json:"type"` From 8133ce8e44606bfcf101262d131be9961bc814bb Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 21 Feb 2024 14:01:32 +0300 Subject: [PATCH 106/234] feat: check disable secret creation flag in client --- .../commands/common/repository.go | 87 +++++++++++++++---- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/cmd/kubectl-testkube/commands/common/repository.go b/cmd/kubectl-testkube/commands/common/repository.go index f714537d987..80f8bf1897e 100644 --- a/cmd/kubectl-testkube/commands/common/repository.go +++ b/cmd/kubectl-testkube/commands/common/repository.go @@ -2,15 +2,21 @@ package common import ( "fmt" + "strconv" "github.com/spf13/cobra" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/ui" ) -func hasGitParamsInCmd(cmd *cobra.Command) bool { - var fields = []string{"git-uri", "git-branch", "git-commit", "git-path", "git-username", "git-token", +func hasGitParamsInCmd(cmd *cobra.Command, crdOnly bool) bool { + var fields = []string{"git-uri", "git-branch", "git-commit", "git-path", "git-username-secret", "git-token-secret", "git-working-dir", "git-certificate-secret", "git-auth-type"} + if !crdOnly { + fields = append(fields, "git-username", "git-token") + } + for _, field := range fields { if cmd.Flag(field).Changed { return true @@ -22,12 +28,30 @@ func hasGitParamsInCmd(cmd *cobra.Command) bool { // NewRepositoryFromFlags creates repository from command flags func NewRepositoryFromFlags(cmd *cobra.Command) (repository *testkube.Repository, err error) { + crdOnly, err := strconv.ParseBool(cmd.Flag("crd-only").Value.String()) + if err != nil { + return nil, err + } + gitUri := cmd.Flag("git-uri").Value.String() gitBranch := cmd.Flag("git-branch").Value.String() gitCommit := cmd.Flag("git-commit").Value.String() gitPath := cmd.Flag("git-path").Value.String() - gitUsername := cmd.Flag("git-username").Value.String() - gitToken := cmd.Flag("git-token").Value.String() + + var gitUsername, gitToken string + if !crdOnly { + client, _, err := GetClient(cmd) + ui.ExitOnError("getting client", err) + + info, err := client.GetServerInfo() + ui.ExitOnError("getting server info", err) + + if !info.DisableSecretCreation { + gitUsername = cmd.Flag("git-username").Value.String() + gitToken = cmd.Flag("git-token").Value.String() + } + } + gitUsernameSecret, err := cmd.Flags().GetStringToString("git-username-secret") if err != nil { return nil, err @@ -42,7 +66,7 @@ func NewRepositoryFromFlags(cmd *cobra.Command) (repository *testkube.Repository gitCertificateSecret := cmd.Flag("git-certificate-secret").Value.String() gitAuthType := cmd.Flag("git-auth-type").Value.String() - hasGitParams := hasGitParamsInCmd(cmd) + hasGitParams := hasGitParamsInCmd(cmd, crdOnly) if !hasGitParams { return nil, nil } @@ -101,14 +125,6 @@ func NewRepositoryUpdateFromFlags(cmd *cobra.Command) (repository *testkube.Repo "git-path", &repository.Path, }, - { - "git-username", - &repository.Username, - }, - { - "git-token", - &repository.Token, - }, { "git-working-dir", &repository.WorkingDir, @@ -123,6 +139,27 @@ func NewRepositoryUpdateFromFlags(cmd *cobra.Command) (repository *testkube.Repo }, } + client, _, err := GetClient(cmd) + ui.ExitOnError("getting client", err) + + info, err := client.GetServerInfo() + ui.ExitOnError("getting server info", err) + + if !info.DisableSecretCreation { + fields = append(fields, []struct { + name string + destination **string + }{ + { + "git-username", + &repository.Username, + }, + { + "git-token", + &repository.Token, + }}...) + } + var nonEmpty bool for _, field := range fields { if cmd.Flag(field.name).Changed { @@ -174,11 +211,29 @@ func NewRepositoryUpdateFromFlags(cmd *cobra.Command) (repository *testkube.Repo // ValidateUpsertOptions validates upsert options func ValidateUpsertOptions(cmd *cobra.Command, sourceName string) error { + crdOnly, err := strconv.ParseBool(cmd.Flag("crd-only").Value.String()) + if err != nil { + return err + } + gitUri := cmd.Flag("git-uri").Value.String() gitBranch := cmd.Flag("git-branch").Value.String() gitCommit := cmd.Flag("git-commit").Value.String() - gitUsername := cmd.Flag("git-username").Value.String() - gitToken := cmd.Flag("git-token").Value.String() + + var gitUsername, gitToken string + if !crdOnly { + client, _, err := GetClient(cmd) + ui.ExitOnError("getting client", err) + + info, err := client.GetServerInfo() + ui.ExitOnError("getting server info", err) + + if !info.DisableSecretCreation { + gitUsername = cmd.Flag("git-username").Value.String() + gitToken = cmd.Flag("git-token").Value.String() + } + } + gitUsernameSecret, err := cmd.Flags().GetStringToString("git-username-secret") if err != nil { return err @@ -193,7 +248,7 @@ func ValidateUpsertOptions(cmd *cobra.Command, sourceName string) error { file := cmd.Flag("file").Value.String() uri := cmd.Flag("uri").Value.String() - hasGitParams := hasGitParamsInCmd(cmd) + hasGitParams := hasGitParamsInCmd(cmd, crdOnly) if hasGitParams && uri != "" { return fmt.Errorf("found git params and `--uri` flag, please use `--git-uri` for git based repo or `--uri` without git based params") } From a53e4186163ec2e4a73e3276b57c0b91bc5e7073 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 21 Feb 2024 15:23:04 +0300 Subject: [PATCH 107/234] fix: add helm chart var to readme --- docs/docs/articles/helm-chart.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/docs/articles/helm-chart.md b/docs/docs/articles/helm-chart.md index a441a4f1ae6..cc8f6e27e5c 100644 --- a/docs/docs/articles/helm-chart.md +++ b/docs/docs/articles/helm-chart.md @@ -118,7 +118,8 @@ The following Helm defaults are used in the `testkube` chart: | testkube-api.storage.compressArtifacts | yes | true | | testkube-api.enableSecretsEndpoint | yes | false | | testkube-api.disableMongoMigrations | yes | false | -| testkube-api.enabledExecutors | no | "" | +| testkube-api.enabledExecutors | yes | "" | +| testkube-api.disableSecretCreation | yes | false | >For more configuration parameters of a `MongoDB` chart please visit: From 43883c32aee36d5411463dccebc2af6e4255f7bc Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 21 Feb 2024 15:57:59 +0100 Subject: [PATCH 108/234] feat(TKC-1457): add OpenAPI models/mapping for TestWorkflows (#5038) * feat(TKC-1457): use testkube-operator with TestWorkflow/TestWorkflowTemplate CRDs * feat(TKC-1457): add OpenAPI models for the TestWorkflows * feat(TKC-1457): add TestWorkflow conversion between OpenAPI and Kubernetes --- api/v1/testkube.yaml | 713 ++++++++++++++++++ go.mod | 2 +- go.sum | 2 + internal/common/common.go | 56 ++ pkg/api/v1/testkube/model_boxed_boolean.go | 14 + pkg/api/v1/testkube/model_boxed_integer.go | 14 + pkg/api/v1/testkube/model_boxed_string.go | 14 + .../v1/testkube/model_boxed_string_list.go | 14 + .../testkube/model_config_map_env_source.go | 15 + .../testkube/model_content_git_auth_type.go | 19 + pkg/api/v1/testkube/model_env_from_source.go | 16 + pkg/api/v1/testkube/model_env_var.go | 16 + pkg/api/v1/testkube/model_env_var_source.go | 18 + ...model_env_var_source_config_map_key_ref.go | 20 + .../model_env_var_source_field_ref.go | 18 + ...model_env_var_source_resource_field_ref.go | 19 + .../model_env_var_source_secret_key_ref.go | 20 + .../v1/testkube/model_image_pull_policy.go | 19 + .../v1/testkube/model_secret_env_source.go | 15 + pkg/api/v1/testkube/model_security_context.go | 19 + pkg/api/v1/testkube/model_test_workflow.go | 29 + .../model_test_workflow_container_config.go | 25 + .../testkube/model_test_workflow_content.go | 15 + .../model_test_workflow_content_file.go | 19 + .../model_test_workflow_content_git.go | 28 + .../model_test_workflow_independent_step.go | 38 + .../model_test_workflow_job_config.go | 17 + .../model_test_workflow_parameter_schema.go | 32 + .../model_test_workflow_parameter_type.go | 21 + .../model_test_workflow_pod_config.go | 23 + .../v1/testkube/model_test_workflow_ref.go | 16 + .../testkube/model_test_workflow_resources.go | 15 + .../model_test_workflow_resources_list.go | 21 + .../model_test_workflow_retry_policy.go | 17 + .../v1/testkube/model_test_workflow_spec.go | 22 + .../v1/testkube/model_test_workflow_step.go | 41 + .../model_test_workflow_step_artifacts.go | 16 + ...est_workflow_step_artifacts_compression.go | 15 + .../model_test_workflow_step_execute.go | 21 + ...del_test_workflow_step_execute_test_ref.go | 15 + .../testkube/model_test_workflow_template.go | 29 + .../model_test_workflow_template_ref.go | 16 + .../model_test_workflow_template_spec.go | 21 + tcl/workflowstcl/mappers/kube_openapi.go | 473 ++++++++++++ tcl/workflowstcl/mappers/mappers_test.go | 404 ++++++++++ tcl/workflowstcl/mappers/openapi_kube.go | 523 +++++++++++++ 46 files changed, 2954 insertions(+), 1 deletion(-) create mode 100644 pkg/api/v1/testkube/model_boxed_boolean.go create mode 100644 pkg/api/v1/testkube/model_boxed_integer.go create mode 100644 pkg/api/v1/testkube/model_boxed_string.go create mode 100644 pkg/api/v1/testkube/model_boxed_string_list.go create mode 100644 pkg/api/v1/testkube/model_config_map_env_source.go create mode 100644 pkg/api/v1/testkube/model_content_git_auth_type.go create mode 100644 pkg/api/v1/testkube/model_env_from_source.go create mode 100644 pkg/api/v1/testkube/model_env_var.go create mode 100644 pkg/api/v1/testkube/model_env_var_source.go create mode 100644 pkg/api/v1/testkube/model_env_var_source_config_map_key_ref.go create mode 100644 pkg/api/v1/testkube/model_env_var_source_field_ref.go create mode 100644 pkg/api/v1/testkube/model_env_var_source_resource_field_ref.go create mode 100644 pkg/api/v1/testkube/model_env_var_source_secret_key_ref.go create mode 100644 pkg/api/v1/testkube/model_image_pull_policy.go create mode 100644 pkg/api/v1/testkube/model_secret_env_source.go create mode 100644 pkg/api/v1/testkube/model_security_context.go create mode 100644 pkg/api/v1/testkube/model_test_workflow.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_container_config.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_content.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_content_file.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_content_git.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_independent_step.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_job_config.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_parameter_schema.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_parameter_type.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_pod_config.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_ref.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_resources.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_resources_list.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_retry_policy.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_spec.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_artifacts.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_artifacts_compression.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_execute.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_execute_test_ref.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_template.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_template_ref.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_template_spec.go create mode 100644 tcl/workflowstcl/mappers/kube_openapi.go create mode 100644 tcl/workflowstcl/mappers/mappers_test.go create mode 100644 tcl/workflowstcl/mappers/openapi_kube.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index fec2e4eff44..07088f2462d 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -6070,6 +6070,719 @@ components: type: string example: ["key1", "key2", "key3"] + TestWorkflow: + type: object + properties: + name: + type: string + description: kubernetes resource name + namespace: + type: string + description: kubernetes namespace + description: + type: string + description: human-readable description + labels: + type: object + description: "test workflow labels" + additionalProperties: + type: string + example: + env: "prod" + app: "backend" + annotations: + type: object + description: "test workflow annotations" + additionalProperties: + type: string + created: + type: string + format: date-time + example: "2022-07-30T06:54:15Z" + spec: + $ref: "#/components/schemas/TestWorkflowSpec" + + TestWorkflowTemplate: + type: object + properties: + name: + type: string + description: kubernetes resource name + namespace: + type: string + description: kubernetes namespace + description: + type: string + description: human-readable description + labels: + type: object + description: "test workflow labels" + additionalProperties: + type: string + example: + env: "prod" + app: "backend" + annotations: + type: object + description: "test workflow annotations" + additionalProperties: + type: string + created: + type: string + format: date-time + example: "2022-07-30T06:54:15Z" + spec: + $ref: "#/components/schemas/TestWorkflowTemplateSpec" + + TestWorkflowSpec: + type: object + properties: + use: + type: array + items: + $ref: "#/components/schemas/TestWorkflowTemplateRef" + config: + $ref: "#/components/schemas/TestWorkflowConfigSchema" + content: + $ref: "#/components/schemas/TestWorkflowContent" + container: + $ref: "#/components/schemas/TestWorkflowContainerConfig" + job: + $ref: "#/components/schemas/TestWorkflowJobConfig" + pod: + $ref: "#/components/schemas/TestWorkflowPodConfig" + setup: + type: array + items: + $ref: "#/components/schemas/TestWorkflowStep" + steps: + type: array + items: + $ref: "#/components/schemas/TestWorkflowStep" + after: + type: array + items: + $ref: "#/components/schemas/TestWorkflowStep" + + TestWorkflowTemplateSpec: + type: object + properties: + config: + $ref: "#/components/schemas/TestWorkflowConfigSchema" + content: + $ref: "#/components/schemas/TestWorkflowContent" + container: + $ref: "#/components/schemas/TestWorkflowContainerConfig" + job: + $ref: "#/components/schemas/TestWorkflowJobConfig" + pod: + $ref: "#/components/schemas/TestWorkflowPodConfig" + setup: + type: array + items: + $ref: "#/components/schemas/TestWorkflowIndependentStep" + steps: + type: array + items: + $ref: "#/components/schemas/TestWorkflowIndependentStep" + after: + type: array + items: + $ref: "#/components/schemas/TestWorkflowIndependentStep" + + TestWorkflowIndependentStep: + type: object + properties: + name: + type: string + description: readable name for the step + condition: + type: string + description: expression to declare under which conditions the step should be run; defaults to "passed", except artifacts where it defaults to "always" + negative: + type: boolean + description: is the step expected to fail + optional: + type: boolean + description: is the step optional, so the failure won't affect the TestWorkflow result + virtualGroup: + type: boolean + description: should not display it as a nested group + retry: + $ref: "#/components/schemas/TestWorkflowRetryPolicy" + timeout: + type: string + pattern: "^((0|[1-9][0-9]*)h)?((0|[1-9][0-9]*)m)?((0|[1-9][0-9]*)s)?((0|[1-9][0-9]*)ms)?$" + description: maximum time this step may take + delay: + type: string + pattern: "^((0|[1-9][0-9]*)h)?((0|[1-9][0-9]*)m)?((0|[1-9][0-9]*)s)?((0|[1-9][0-9]*)ms)?$" + description: delay before the step + content: + $ref: "#/components/schemas/TestWorkflowContent" + shell: + type: string + description: script to run in a default shell for the container + run: + $ref: "#/components/schemas/TestWorkflowContainerConfig" + workingDir: + $ref: "#/components/schemas/BoxedString" + container: + $ref: "#/components/schemas/TestWorkflowContainerConfig" + execute: + $ref: "#/components/schemas/TestWorkflowStepExecute" + artifacts: + $ref: "#/components/schemas/TestWorkflowStepArtifacts" + steps: + type: array + description: nested steps to run + items: + $ref: "#/components/schemas/TestWorkflowIndependentStep" + + TestWorkflowStep: + type: object + properties: + name: + type: string + description: readable name for the step + condition: + type: string + description: expression to declare under which conditions the step should be run; defaults to "passed", except artifacts where it defaults to "always" + negative: + type: boolean + description: is the step expected to fail + optional: + type: boolean + description: is the step optional, so the failure won't affect the TestWorkflow result + virtualGroup: + type: boolean + description: should not display it as a nested group + use: + type: array + description: list of TestWorkflowTemplates to use + items: + $ref: "#/components/schemas/TestWorkflowTemplateRef" + template: + $ref: "#/components/schemas/TestWorkflowTemplateRef" + retry: + $ref: "#/components/schemas/TestWorkflowRetryPolicy" + timeout: + type: string + pattern: "^((0|[1-9][0-9]*)h)?((0|[1-9][0-9]*)m)?((0|[1-9][0-9]*)s)?((0|[1-9][0-9]*)ms)?$" + description: maximum time this step may take + delay: + type: string + pattern: "^((0|[1-9][0-9]*)h)?((0|[1-9][0-9]*)m)?((0|[1-9][0-9]*)s)?((0|[1-9][0-9]*)ms)?$" + description: delay before the step + content: + $ref: "#/components/schemas/TestWorkflowContent" + shell: + type: string + description: script to run in a default shell for the container + run: + $ref: "#/components/schemas/TestWorkflowContainerConfig" + workingDir: + $ref: "#/components/schemas/BoxedString" + container: + $ref: "#/components/schemas/TestWorkflowContainerConfig" + execute: + $ref: "#/components/schemas/TestWorkflowStepExecute" + artifacts: + $ref: "#/components/schemas/TestWorkflowStepArtifacts" + steps: + type: array + description: nested steps to run + items: + $ref: "#/components/schemas/TestWorkflowStep" + + TestWorkflowStepExecute: + type: object + properties: + parallelism: + type: integer + description: how many resources could be scheduled in parallel + async: + type: boolean + description: only schedule the resources, don't watch for the results (unless it is needed for parallelism) + tests: + type: array + description: tests to schedule + items: + $ref: "#/components/schemas/TestWorkflowStepExecuteTestRef" + workflows: + type: array + description: workflows to schedule + items: + $ref: "#/components/schemas/TestWorkflowRef" + + TestWorkflowStepExecuteTestRef: + type: object + properties: + name: + type: string + description: test name to schedule + + TestWorkflowStepArtifacts: + type: object + properties: + compress: + $ref: "#/components/schemas/TestWorkflowStepArtifactsCompression" + paths: + type: array + description: file paths to fetch from the container + items: + type: string + minItems: 1 + required: + - paths + + TestWorkflowStepArtifactsCompression: + type: object + properties: + name: + type: string + description: artifact name + + TestWorkflowRetryPolicy: + type: object + properties: + count: + type: integer + minimum: 1 + description: how many times at most it should retry + until: + type: string + description: until when it should retry (defaults to "passed") + required: + - count + + TestWorkflowContent: + type: object + properties: + git: + $ref: "#/components/schemas/TestWorkflowContentGit" + files: + type: array + items: + $ref: "#/components/schemas/TestWorkflowContentFile" + + TestWorkflowContentGit: + type: object + properties: + uri: + type: string + description: uri for the Git repository + revision: + type: string + description: branch, commit or a tag name to fetch + username: + type: string + description: plain text username to fetch with + usernameFrom: + $ref: "#/components/schemas/EnvVarSource" + token: + type: string + description: plain text token to fetch with + tokenFrom: + $ref: "#/components/schemas/EnvVarSource" + authType: + $ref: "#/components/schemas/ContentGitAuthType" + mountPath: + type: string + description: where to mount the fetched repository contents (defaults to "repo" directory in the data volume) + paths: + type: array + description: paths to fetch for the sparse checkout + items: + type: string + + TestWorkflowContentFile: + type: object + properties: + path: + type: string + description: path where the file should be accessible at + minLength: 1 + content: + type: string + description: plain-text content to put inside + contentFrom: + $ref: "#/components/schemas/EnvVarSource" + mode: + $ref: "#/components/schemas/BoxedInteger" + required: + - path + + TestWorkflowRef: + type: object + properties: + name: + type: string + description: TestWorkflow name to include + config: + $ref: "#/components/schemas/TestWorkflowConfigValue" + required: + - name + - path + + TestWorkflowTemplateRef: + type: object + properties: + name: + type: string + description: TestWorkflowTemplate name to include + config: + $ref: "#/components/schemas/TestWorkflowConfigValue" + required: + - name + + TestWorkflowJobConfig: + type: object + properties: + labels: + type: object + description: labels to attach to the job + additionalProperties: + type: string + annotations: + type: object + description: annotations to attach to the job + additionalProperties: + type: string + + TestWorkflowPodConfig: + type: object + properties: + labels: + type: object + description: labels to attach to the pod + additionalProperties: + type: string + annotations: + type: object + description: annotations to attach to the pod + additionalProperties: + type: string + imagePullSecrets: + type: array + description: secret references for pulling images + items: + $ref: "#/components/schemas/LocalObjectReference" + serviceAccountName: + type: string + description: default service account name for the containers + nodeSelector: + type: object + description: label selector for node that the pod should land on + additionalProperties: + type: string + + TestWorkflowContainerConfig: + type: object + properties: + workingDir: + $ref: "#/components/schemas/BoxedString" + image: + type: string + description: image to be used for the container + imagePullPolicy: + $ref: "#/components/schemas/ImagePullPolicy" + env: + type: array + description: environment variables to append to the container + items: + $ref: "#/components/schemas/EnvVar" + envFrom: + type: array + description: external environment variables to append to the container + items: + $ref: "#/components/schemas/EnvFromSource" + command: + $ref: "#/components/schemas/BoxedStringList" + args: + $ref: "#/components/schemas/BoxedStringList" + resources: + $ref: "#/components/schemas/TestWorkflowResources" + securityContext: + $ref: "#/components/schemas/SecurityContext" + + TestWorkflowConfigValue: + type: object + description: configuration values to pass to the template + additionalProperties: + type: string + + TestWorkflowConfigSchema: + type: object + description: configuration definition + additionalProperties: + $ref: "#/components/schemas/TestWorkflowParameterSchema" + + TestWorkflowResources: + type: object + properties: + limits: + $ref: "#/components/schemas/TestWorkflowResourcesList" + requests: + $ref: "#/components/schemas/TestWorkflowResourcesList" + + TestWorkflowResourcesList: + type: object + properties: + cpu: + type: string + description: number of CPUs + pattern: "^[0-9]+m?$" + memory: + type: string + description: size of RAM memory + pattern: "^[0-9]+[GMK]i$" + storage: + type: string + description: storage size + pattern: "^[0-9]+[GMK]i$" + ephemeral-storage: + type: string + description: ephemeral storage size + pattern: "^[0-9]+[GMK]i$" + + TestWorkflowParameterSchema: + type: object + properties: + description: + type: string + description: human-readable description for the property + type: + $ref: "#/components/schemas/TestWorkflowParameterType" + enum: + type: array + description: list of acceptable values + items: + type: string + example: + type: string + description: example value for the parameter + default: + $ref: "#/components/schemas/BoxedString" + format: + type: string + description: "predefined format for the string" + pattern: + type: string + description: "regular expression to match" + minLength: + $ref: "#/components/schemas/BoxedInteger" + maxLength: + $ref: "#/components/schemas/BoxedInteger" + minimum: + $ref: "#/components/schemas/BoxedInteger" + maximum: + $ref: "#/components/schemas/BoxedInteger" + exclusiveMinimum: + $ref: "#/components/schemas/BoxedInteger" + exclusiveMaximum: + $ref: "#/components/schemas/BoxedInteger" + multipleOf: + $ref: "#/components/schemas/BoxedInteger" + required: + - type + + TestWorkflowParameterType: + type: string + description: type of the config parameter + enum: + - string + - integer + - number + - boolean + + ContentGitAuthType: + type: string + description: auth type for git requests + enum: + - basic + - header + + BoxedStringList: + type: object + properties: + value: + type: array + items: + type: string + required: + - value + + BoxedString: + type: object + properties: + value: + type: string + required: + - value + + BoxedInteger: + type: object + properties: + value: + type: integer + required: + - value + + BoxedBoolean: + type: object + properties: + value: + type: boolean + required: + - value + + ImagePullPolicy: + type: string + enum: + - Always + - Never + - IfNotPresent + + EnvVar: + type: object + properties: + name: + type: string + value: + type: string + valueFrom: + $ref: "#/components/schemas/EnvVarSource" + + ConfigMapEnvSource: + type: object + properties: + name: + type: string + optional: + type: boolean + default: false + required: + - name + + SecretEnvSource: + type: object + properties: + name: + type: string + optional: + type: boolean + default: false + required: + - name + + EnvFromSource: + type: object + properties: + prefix: + type: string + configMapRef: + $ref: "#/components/schemas/ConfigMapEnvSource" + secretRef: + $ref: "#/components/schemas/SecretEnvSource" + + SecurityContext: + type: object + properties: + privileged: + $ref: "#/components/schemas/BoxedBoolean" + runAsUser: + $ref: "#/components/schemas/BoxedInteger" + runAsGroup: + $ref: "#/components/schemas/BoxedInteger" + runAsNonRoot: + $ref: "#/components/schemas/BoxedBoolean" + readOnlyRootFilesystem: + $ref: "#/components/schemas/BoxedBoolean" + allowPrivilegeEscalation: + $ref: "#/components/schemas/BoxedBoolean" + + EnvVarSource: + type: object + description: EnvVarSource represents a source for the value + of an EnvVar. + properties: + configMapKeyRef: + type: object + required: + - key + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + fieldRef: + type: object + required: + - fieldPath + description: 'Selects a field of the pod: supports metadata.name, + metadata.namespace, `metadata.labels['''']`, + `metadata.annotations['''']`, spec.nodeName, + spec.serviceAccountName, status.hostIP, status.podIP, + status.podIPs.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the + specified API version. + type: string + resourceFieldRef: + type: object + required: + - resource + description: 'Selects a resource of the container: only + resources limits and requests (limits.cpu, limits.memory, + limits.ephemeral-storage, requests.cpu, requests.memory + and requests.ephemeral-storage) are currently supported.' + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + type: string + pattern: "^[0-9]+(m|[GMK]i)$" + resource: + description: 'Required: resource to select' + type: string + secretKeyRef: + type: object + required: + - key + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + # # Errors # diff --git a/go.mod b/go.mod index 716851edc56..0368de65ccf 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/joshdk/go-junit v1.0.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index c903a6d5a9b..536574aa257 100644 --- a/go.sum +++ b/go.sum @@ -358,6 +358,8 @@ github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3 h1:R6xdH//ctWpE18U1GYwzNvq1HLiT9LUJogXkfyKDDGo= github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 h1:4hnZi3dMBmpz4SxE9PrsJTG2JA/P5h+4PMrSXjUEEbA= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/internal/common/common.go b/internal/common/common.go index d584554e008..27b578ff324 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -10,3 +10,59 @@ func MergeMaps(ms ...map[string]string) map[string]string { } return res } + +func Ptr[T any](v T) *T { + return &v +} + +func MapPtr[T any, U any](v *T, fn func(T) U) *U { + if v == nil { + return nil + } + return Ptr(fn(*v)) +} + +func PtrOrNil[T comparable](v T) *T { + var zero T + if zero == v { + return nil + } + return &v +} + +func ResolvePtr[T any](v *T, def T) T { + if v == nil { + return def + } + return *v +} + +func MapSlice[T any, U any](s []T, fn func(T) U) []U { + if len(s) == 0 { + return nil + } + result := make([]U, len(s)) + for i := range s { + result[i] = fn(s[i]) + } + return result +} + +func MapMap[T any, U any](m map[string]T, fn func(T) U) map[string]U { + if len(m) == 0 { + return nil + } + res := make(map[string]U, len(m)) + for k, v := range m { + res[k] = fn(v) + } + return res +} + +func GetMapValue[T any, K comparable](m map[K]T, k K, def T) T { + v, ok := m[k] + if ok { + return v + } + return def +} diff --git a/pkg/api/v1/testkube/model_boxed_boolean.go b/pkg/api/v1/testkube/model_boxed_boolean.go new file mode 100644 index 00000000000..b13d178fdaa --- /dev/null +++ b/pkg/api/v1/testkube/model_boxed_boolean.go @@ -0,0 +1,14 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type BoxedBoolean struct { + Value bool `json:"value"` +} diff --git a/pkg/api/v1/testkube/model_boxed_integer.go b/pkg/api/v1/testkube/model_boxed_integer.go new file mode 100644 index 00000000000..85d66f73c8a --- /dev/null +++ b/pkg/api/v1/testkube/model_boxed_integer.go @@ -0,0 +1,14 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type BoxedInteger struct { + Value int32 `json:"value"` +} diff --git a/pkg/api/v1/testkube/model_boxed_string.go b/pkg/api/v1/testkube/model_boxed_string.go new file mode 100644 index 00000000000..a1aa9a4ad93 --- /dev/null +++ b/pkg/api/v1/testkube/model_boxed_string.go @@ -0,0 +1,14 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type BoxedString struct { + Value string `json:"value"` +} diff --git a/pkg/api/v1/testkube/model_boxed_string_list.go b/pkg/api/v1/testkube/model_boxed_string_list.go new file mode 100644 index 00000000000..087040470c6 --- /dev/null +++ b/pkg/api/v1/testkube/model_boxed_string_list.go @@ -0,0 +1,14 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type BoxedStringList struct { + Value []string `json:"value"` +} diff --git a/pkg/api/v1/testkube/model_config_map_env_source.go b/pkg/api/v1/testkube/model_config_map_env_source.go new file mode 100644 index 00000000000..2a9ae4c2b67 --- /dev/null +++ b/pkg/api/v1/testkube/model_config_map_env_source.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type ConfigMapEnvSource struct { + Name string `json:"name"` + Optional bool `json:"optional,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_content_git_auth_type.go b/pkg/api/v1/testkube/model_content_git_auth_type.go new file mode 100644 index 00000000000..039ff182d94 --- /dev/null +++ b/pkg/api/v1/testkube/model_content_git_auth_type.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// ContentGitAuthType : auth type for git requests +type ContentGitAuthType string + +// List of ContentGitAuthType +const ( + BASIC_ContentGitAuthType ContentGitAuthType = "basic" + HEADER_ContentGitAuthType ContentGitAuthType = "header" +) diff --git a/pkg/api/v1/testkube/model_env_from_source.go b/pkg/api/v1/testkube/model_env_from_source.go new file mode 100644 index 00000000000..d63a456a9b0 --- /dev/null +++ b/pkg/api/v1/testkube/model_env_from_source.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type EnvFromSource struct { + Prefix string `json:"prefix,omitempty"` + ConfigMapRef *ConfigMapEnvSource `json:"configMapRef,omitempty"` + SecretRef *SecretEnvSource `json:"secretRef,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_env_var.go b/pkg/api/v1/testkube/model_env_var.go new file mode 100644 index 00000000000..ea07fac4d77 --- /dev/null +++ b/pkg/api/v1/testkube/model_env_var.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type EnvVar struct { + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + ValueFrom *EnvVarSource `json:"valueFrom,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_env_var_source.go b/pkg/api/v1/testkube/model_env_var_source.go new file mode 100644 index 00000000000..bce205fa969 --- /dev/null +++ b/pkg/api/v1/testkube/model_env_var_source.go @@ -0,0 +1,18 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// EnvVarSource represents a source for the value of an EnvVar. +type EnvVarSource struct { + ConfigMapKeyRef *EnvVarSourceConfigMapKeyRef `json:"configMapKeyRef,omitempty"` + FieldRef *EnvVarSourceFieldRef `json:"fieldRef,omitempty"` + ResourceFieldRef *EnvVarSourceResourceFieldRef `json:"resourceFieldRef,omitempty"` + SecretKeyRef *EnvVarSourceSecretKeyRef `json:"secretKeyRef,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_env_var_source_config_map_key_ref.go b/pkg/api/v1/testkube/model_env_var_source_config_map_key_ref.go new file mode 100644 index 00000000000..66855009267 --- /dev/null +++ b/pkg/api/v1/testkube/model_env_var_source_config_map_key_ref.go @@ -0,0 +1,20 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Selects a key of a ConfigMap. +type EnvVarSourceConfigMapKeyRef struct { + // The key to select. + Key string `json:"key"` + // Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid? + Name string `json:"name,omitempty"` + // Specify whether the ConfigMap or its key must be defined + Optional bool `json:"optional,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_env_var_source_field_ref.go b/pkg/api/v1/testkube/model_env_var_source_field_ref.go new file mode 100644 index 00000000000..4a69682e3ba --- /dev/null +++ b/pkg/api/v1/testkube/model_env_var_source_field_ref.go @@ -0,0 +1,18 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. +type EnvVarSourceFieldRef struct { + // Version of the schema the FieldPath is written in terms of, defaults to \"v1\". + ApiVersion string `json:"apiVersion,omitempty"` + // Path of the field to select in the specified API version. + FieldPath string `json:"fieldPath"` +} diff --git a/pkg/api/v1/testkube/model_env_var_source_resource_field_ref.go b/pkg/api/v1/testkube/model_env_var_source_resource_field_ref.go new file mode 100644 index 00000000000..01f0fb66fdd --- /dev/null +++ b/pkg/api/v1/testkube/model_env_var_source_resource_field_ref.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. +type EnvVarSourceResourceFieldRef struct { + // Container name: required for volumes, optional for env vars + ContainerName string `json:"containerName,omitempty"` + Divisor string `json:"divisor,omitempty"` + // Required: resource to select + Resource string `json:"resource"` +} diff --git a/pkg/api/v1/testkube/model_env_var_source_secret_key_ref.go b/pkg/api/v1/testkube/model_env_var_source_secret_key_ref.go new file mode 100644 index 00000000000..551fdd65de7 --- /dev/null +++ b/pkg/api/v1/testkube/model_env_var_source_secret_key_ref.go @@ -0,0 +1,20 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Selects a key of a secret in the pod's namespace +type EnvVarSourceSecretKeyRef struct { + // The key of the secret to select from. Must be a valid secret key. + Key string `json:"key"` + // Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid? + Name string `json:"name,omitempty"` + // Specify whether the Secret or its key must be defined + Optional bool `json:"optional,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_image_pull_policy.go b/pkg/api/v1/testkube/model_image_pull_policy.go new file mode 100644 index 00000000000..0f698dec386 --- /dev/null +++ b/pkg/api/v1/testkube/model_image_pull_policy.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type ImagePullPolicy string + +// List of ImagePullPolicy +const ( + ALWAYS_ImagePullPolicy ImagePullPolicy = "Always" + NEVER_ImagePullPolicy ImagePullPolicy = "Never" + IF_NOT_PRESENT_ImagePullPolicy ImagePullPolicy = "IfNotPresent" +) diff --git a/pkg/api/v1/testkube/model_secret_env_source.go b/pkg/api/v1/testkube/model_secret_env_source.go new file mode 100644 index 00000000000..0255909b9ff --- /dev/null +++ b/pkg/api/v1/testkube/model_secret_env_source.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type SecretEnvSource struct { + Name string `json:"name"` + Optional bool `json:"optional,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_security_context.go b/pkg/api/v1/testkube/model_security_context.go new file mode 100644 index 00000000000..af41964a42f --- /dev/null +++ b/pkg/api/v1/testkube/model_security_context.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type SecurityContext struct { + Privileged *BoxedBoolean `json:"privileged,omitempty"` + RunAsUser *BoxedInteger `json:"runAsUser,omitempty"` + RunAsGroup *BoxedInteger `json:"runAsGroup,omitempty"` + RunAsNonRoot *BoxedBoolean `json:"runAsNonRoot,omitempty"` + ReadOnlyRootFilesystem *BoxedBoolean `json:"readOnlyRootFilesystem,omitempty"` + AllowPrivilegeEscalation *BoxedBoolean `json:"allowPrivilegeEscalation,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow.go b/pkg/api/v1/testkube/model_test_workflow.go new file mode 100644 index 00000000000..0e237900d00 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow.go @@ -0,0 +1,29 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflow struct { + // kubernetes resource name + Name string `json:"name,omitempty"` + // kubernetes namespace + Namespace string `json:"namespace,omitempty"` + // human-readable description + Description string `json:"description,omitempty"` + // test workflow labels + Labels map[string]string `json:"labels,omitempty"` + // test workflow annotations + Annotations map[string]string `json:"annotations,omitempty"` + Created time.Time `json:"created,omitempty"` + Spec *TestWorkflowSpec `json:"spec,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_container_config.go b/pkg/api/v1/testkube/model_test_workflow_container_config.go new file mode 100644 index 00000000000..1c04baf02aa --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_container_config.go @@ -0,0 +1,25 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowContainerConfig struct { + WorkingDir *BoxedString `json:"workingDir,omitempty"` + // image to be used for the container + Image string `json:"image,omitempty"` + ImagePullPolicy *ImagePullPolicy `json:"imagePullPolicy,omitempty"` + // environment variables to append to the container + Env []EnvVar `json:"env,omitempty"` + // external environment variables to append to the container + EnvFrom []EnvFromSource `json:"envFrom,omitempty"` + Command *BoxedStringList `json:"command,omitempty"` + Args *BoxedStringList `json:"args,omitempty"` + Resources *TestWorkflowResources `json:"resources,omitempty"` + SecurityContext *SecurityContext `json:"securityContext,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_content.go b/pkg/api/v1/testkube/model_test_workflow_content.go new file mode 100644 index 00000000000..b659a86b3ae --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_content.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowContent struct { + Git *TestWorkflowContentGit `json:"git,omitempty"` + Files []TestWorkflowContentFile `json:"files,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_content_file.go b/pkg/api/v1/testkube/model_test_workflow_content_file.go new file mode 100644 index 00000000000..c1ddfd048ee --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_content_file.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowContentFile struct { + // path where the file should be accessible at + Path string `json:"path"` + // plain-text content to put inside + Content string `json:"content,omitempty"` + ContentFrom *EnvVarSource `json:"contentFrom,omitempty"` + Mode *BoxedInteger `json:"mode,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_content_git.go b/pkg/api/v1/testkube/model_test_workflow_content_git.go new file mode 100644 index 00000000000..24ec57835a1 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_content_git.go @@ -0,0 +1,28 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowContentGit struct { + // uri for the Git repository + Uri string `json:"uri,omitempty"` + // branch, commit or a tag name to fetch + Revision string `json:"revision,omitempty"` + // plain text username to fetch with + Username string `json:"username,omitempty"` + UsernameFrom *EnvVarSource `json:"usernameFrom,omitempty"` + // plain text token to fetch with + Token string `json:"token,omitempty"` + TokenFrom *EnvVarSource `json:"tokenFrom,omitempty"` + AuthType *ContentGitAuthType `json:"authType,omitempty"` + // where to mount the fetched repository contents (defaults to \"repo\" directory in the data volume) + MountPath string `json:"mountPath,omitempty"` + // paths to fetch for the sparse checkout + Paths []string `json:"paths,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_independent_step.go b/pkg/api/v1/testkube/model_test_workflow_independent_step.go new file mode 100644 index 00000000000..c2d3f8cc13d --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_independent_step.go @@ -0,0 +1,38 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowIndependentStep struct { + // readable name for the step + Name string `json:"name,omitempty"` + // expression to declare under which conditions the step should be run; defaults to \"passed\", except artifacts where it defaults to \"always\" + Condition string `json:"condition,omitempty"` + // is the step expected to fail + Negative bool `json:"negative,omitempty"` + // is the step optional, so the failure won't affect the TestWorkflow result + Optional bool `json:"optional,omitempty"` + // should not display it as a nested group + VirtualGroup bool `json:"virtualGroup,omitempty"` + Retry *TestWorkflowRetryPolicy `json:"retry,omitempty"` + // maximum time this step may take + Timeout string `json:"timeout,omitempty"` + // delay before the step + Delay string `json:"delay,omitempty"` + Content *TestWorkflowContent `json:"content,omitempty"` + // script to run in a default shell for the container + Shell string `json:"shell,omitempty"` + Run *TestWorkflowContainerConfig `json:"run,omitempty"` + WorkingDir *BoxedString `json:"workingDir,omitempty"` + Container *TestWorkflowContainerConfig `json:"container,omitempty"` + Execute *TestWorkflowStepExecute `json:"execute,omitempty"` + Artifacts *TestWorkflowStepArtifacts `json:"artifacts,omitempty"` + // nested steps to run + Steps []TestWorkflowIndependentStep `json:"steps,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_job_config.go b/pkg/api/v1/testkube/model_test_workflow_job_config.go new file mode 100644 index 00000000000..387db9b7e6c --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_job_config.go @@ -0,0 +1,17 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowJobConfig struct { + // labels to attach to the job + Labels map[string]string `json:"labels,omitempty"` + // annotations to attach to the job + Annotations map[string]string `json:"annotations,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_parameter_schema.go b/pkg/api/v1/testkube/model_test_workflow_parameter_schema.go new file mode 100644 index 00000000000..5d3e22617d0 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_parameter_schema.go @@ -0,0 +1,32 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowParameterSchema struct { + // human-readable description for the property + Description string `json:"description,omitempty"` + Type_ *TestWorkflowParameterType `json:"type"` + // list of acceptable values + Enum []string `json:"enum,omitempty"` + // example value for the parameter + Example string `json:"example,omitempty"` + Default_ *BoxedString `json:"default,omitempty"` + // predefined format for the string + Format string `json:"format,omitempty"` + // regular expression to match + Pattern string `json:"pattern,omitempty"` + MinLength *BoxedInteger `json:"minLength,omitempty"` + MaxLength *BoxedInteger `json:"maxLength,omitempty"` + Minimum *BoxedInteger `json:"minimum,omitempty"` + Maximum *BoxedInteger `json:"maximum,omitempty"` + ExclusiveMinimum *BoxedInteger `json:"exclusiveMinimum,omitempty"` + ExclusiveMaximum *BoxedInteger `json:"exclusiveMaximum,omitempty"` + MultipleOf *BoxedInteger `json:"multipleOf,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_parameter_type.go b/pkg/api/v1/testkube/model_test_workflow_parameter_type.go new file mode 100644 index 00000000000..7e680454cd2 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_parameter_type.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// TestWorkflowParameterType : type of the config parameter +type TestWorkflowParameterType string + +// List of TestWorkflowParameterType +const ( + STRING__TestWorkflowParameterType TestWorkflowParameterType = "string" + INTEGER_TestWorkflowParameterType TestWorkflowParameterType = "integer" + NUMBER_TestWorkflowParameterType TestWorkflowParameterType = "number" + BOOLEAN_TestWorkflowParameterType TestWorkflowParameterType = "boolean" +) diff --git a/pkg/api/v1/testkube/model_test_workflow_pod_config.go b/pkg/api/v1/testkube/model_test_workflow_pod_config.go new file mode 100644 index 00000000000..396c716376d --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_pod_config.go @@ -0,0 +1,23 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowPodConfig struct { + // labels to attach to the pod + Labels map[string]string `json:"labels,omitempty"` + // annotations to attach to the pod + Annotations map[string]string `json:"annotations,omitempty"` + // secret references for pulling images + ImagePullSecrets []LocalObjectReference `json:"imagePullSecrets,omitempty"` + // default service account name for the containers + ServiceAccountName string `json:"serviceAccountName,omitempty"` + // label selector for node that the pod should land on + NodeSelector map[string]string `json:"nodeSelector,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_ref.go b/pkg/api/v1/testkube/model_test_workflow_ref.go new file mode 100644 index 00000000000..ce3eebef629 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_ref.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowRef struct { + // TestWorkflow name to include + Name string `json:"name"` + Config map[string]string `json:"config,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_resources.go b/pkg/api/v1/testkube/model_test_workflow_resources.go new file mode 100644 index 00000000000..645b69634eb --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_resources.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowResources struct { + Limits *TestWorkflowResourcesList `json:"limits,omitempty"` + Requests *TestWorkflowResourcesList `json:"requests,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_resources_list.go b/pkg/api/v1/testkube/model_test_workflow_resources_list.go new file mode 100644 index 00000000000..f491e82bc3b --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_resources_list.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowResourcesList struct { + // number of CPUs + Cpu string `json:"cpu,omitempty"` + // size of RAM memory + Memory string `json:"memory,omitempty"` + // storage size + Storage string `json:"storage,omitempty"` + // ephemeral storage size + EphemeralStorage string `json:"ephemeral-storage,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_retry_policy.go b/pkg/api/v1/testkube/model_test_workflow_retry_policy.go new file mode 100644 index 00000000000..93c16008b32 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_retry_policy.go @@ -0,0 +1,17 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowRetryPolicy struct { + // how many times at most it should retry + Count int32 `json:"count"` + // until when it should retry (defaults to \"passed\") + Until string `json:"until,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_spec.go b/pkg/api/v1/testkube/model_test_workflow_spec.go new file mode 100644 index 00000000000..8d051514dca --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_spec.go @@ -0,0 +1,22 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowSpec struct { + Use []TestWorkflowTemplateRef `json:"use,omitempty"` + Config map[string]TestWorkflowParameterSchema `json:"config,omitempty"` + Content *TestWorkflowContent `json:"content,omitempty"` + Container *TestWorkflowContainerConfig `json:"container,omitempty"` + Job *TestWorkflowJobConfig `json:"job,omitempty"` + Pod *TestWorkflowPodConfig `json:"pod,omitempty"` + Setup []TestWorkflowStep `json:"setup,omitempty"` + Steps []TestWorkflowStep `json:"steps,omitempty"` + After []TestWorkflowStep `json:"after,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step.go b/pkg/api/v1/testkube/model_test_workflow_step.go new file mode 100644 index 00000000000..08dd8f7d511 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step.go @@ -0,0 +1,41 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStep struct { + // readable name for the step + Name string `json:"name,omitempty"` + // expression to declare under which conditions the step should be run; defaults to \"passed\", except artifacts where it defaults to \"always\" + Condition string `json:"condition,omitempty"` + // is the step expected to fail + Negative bool `json:"negative,omitempty"` + // is the step optional, so the failure won't affect the TestWorkflow result + Optional bool `json:"optional,omitempty"` + // should not display it as a nested group + VirtualGroup bool `json:"virtualGroup,omitempty"` + // list of TestWorkflowTemplates to use + Use []TestWorkflowTemplateRef `json:"use,omitempty"` + Template *TestWorkflowTemplateRef `json:"template,omitempty"` + Retry *TestWorkflowRetryPolicy `json:"retry,omitempty"` + // maximum time this step may take + Timeout string `json:"timeout,omitempty"` + // delay before the step + Delay string `json:"delay,omitempty"` + Content *TestWorkflowContent `json:"content,omitempty"` + // script to run in a default shell for the container + Shell string `json:"shell,omitempty"` + Run *TestWorkflowContainerConfig `json:"run,omitempty"` + WorkingDir *BoxedString `json:"workingDir,omitempty"` + Container *TestWorkflowContainerConfig `json:"container,omitempty"` + Execute *TestWorkflowStepExecute `json:"execute,omitempty"` + Artifacts *TestWorkflowStepArtifacts `json:"artifacts,omitempty"` + // nested steps to run + Steps []TestWorkflowStep `json:"steps,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go b/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go new file mode 100644 index 00000000000..c4843d57516 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStepArtifacts struct { + Compress *TestWorkflowStepArtifactsCompression `json:"compress,omitempty"` + // file paths to fetch from the container + Paths []string `json:"paths"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_artifacts_compression.go b/pkg/api/v1/testkube/model_test_workflow_step_artifacts_compression.go new file mode 100644 index 00000000000..e0dc9a7e171 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_artifacts_compression.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStepArtifactsCompression struct { + // artifact name + Name string `json:"name,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_execute.go b/pkg/api/v1/testkube/model_test_workflow_step_execute.go new file mode 100644 index 00000000000..1fe93dcb222 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_execute.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStepExecute struct { + // how many resources could be scheduled in parallel + Parallelism int32 `json:"parallelism,omitempty"` + // only schedule the resources, don't watch for the results (unless it is needed for parallelism) + Async bool `json:"async,omitempty"` + // tests to schedule + Tests []TestWorkflowStepExecuteTestRef `json:"tests,omitempty"` + // workflows to schedule + Workflows []TestWorkflowRef `json:"workflows,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_execute_test_ref.go b/pkg/api/v1/testkube/model_test_workflow_step_execute_test_ref.go new file mode 100644 index 00000000000..a31c446d497 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_execute_test_ref.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStepExecuteTestRef struct { + // test name to schedule + Name string `json:"name,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_template.go b/pkg/api/v1/testkube/model_test_workflow_template.go new file mode 100644 index 00000000000..b0fb09424a8 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_template.go @@ -0,0 +1,29 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowTemplate struct { + // kubernetes resource name + Name string `json:"name,omitempty"` + // kubernetes namespace + Namespace string `json:"namespace,omitempty"` + // human-readable description + Description string `json:"description,omitempty"` + // test workflow labels + Labels map[string]string `json:"labels,omitempty"` + // test workflow annotations + Annotations map[string]string `json:"annotations,omitempty"` + Created time.Time `json:"created,omitempty"` + Spec *TestWorkflowTemplateSpec `json:"spec,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_template_ref.go b/pkg/api/v1/testkube/model_test_workflow_template_ref.go new file mode 100644 index 00000000000..ef405df3932 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_template_ref.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowTemplateRef struct { + // TestWorkflowTemplate name to include + Name string `json:"name"` + Config map[string]string `json:"config,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_template_spec.go b/pkg/api/v1/testkube/model_test_workflow_template_spec.go new file mode 100644 index 00000000000..97c560da6ec --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_template_spec.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowTemplateSpec struct { + Config map[string]TestWorkflowParameterSchema `json:"config,omitempty"` + Content *TestWorkflowContent `json:"content,omitempty"` + Container *TestWorkflowContainerConfig `json:"container,omitempty"` + Job *TestWorkflowJobConfig `json:"job,omitempty"` + Pod *TestWorkflowPodConfig `json:"pod,omitempty"` + Setup []TestWorkflowIndependentStep `json:"setup,omitempty"` + Steps []TestWorkflowIndependentStep `json:"steps,omitempty"` + After []TestWorkflowIndependentStep `json:"after,omitempty"` +} diff --git a/tcl/workflowstcl/mappers/kube_openapi.go b/tcl/workflowstcl/mappers/kube_openapi.go new file mode 100644 index 00000000000..a1f65c145a2 --- /dev/null +++ b/tcl/workflowstcl/mappers/kube_openapi.go @@ -0,0 +1,473 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package mappers + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +func MapIntOrStringToString(i intstr.IntOrString) string { + return i.String() +} + +func MapIntOrStringPtrToStringPtr(i *intstr.IntOrString) *string { + if i == nil { + return nil + } + return common.Ptr(MapIntOrStringToString(*i)) +} + +func MapStringToBoxedString(v *string) *testkube.BoxedString { + if v == nil { + return nil + } + return &testkube.BoxedString{Value: *v} +} + +func MapBoolToBoxedBoolean(v *bool) *testkube.BoxedBoolean { + if v == nil { + return nil + } + return &testkube.BoxedBoolean{Value: *v} +} + +func MapStringSliceToBoxedStringList(v *[]string) *testkube.BoxedStringList { + if v == nil { + return nil + } + return &testkube.BoxedStringList{Value: *v} +} + +func MapInt64ToBoxedInteger(v *int64) *testkube.BoxedInteger { + if v == nil { + return MapInt32ToBoxedInteger(nil) + } + return MapInt32ToBoxedInteger(common.Ptr(int32(*v))) +} + +func MapInt32ToBoxedInteger(v *int32) *testkube.BoxedInteger { + if v == nil { + return nil + } + return &testkube.BoxedInteger{Value: *v} +} + +func MapEnvVarKubeToAPI(v corev1.EnvVar) testkube.EnvVar { + return testkube.EnvVar{ + Name: v.Name, + Value: v.Value, + ValueFrom: common.MapPtr(v.ValueFrom, MapEnvVarSourceKubeToAPI), + } +} + +func MapConfigMapKeyRefKubeToAPI(v *corev1.ConfigMapKeySelector) *testkube.EnvVarSourceConfigMapKeyRef { + if v == nil { + return nil + } + return &testkube.EnvVarSourceConfigMapKeyRef{ + Key: v.Key, + Name: v.Name, + Optional: common.ResolvePtr(v.Optional, false), + } +} + +func MapFieldRefKubeToAPI(v *corev1.ObjectFieldSelector) *testkube.EnvVarSourceFieldRef { + if v == nil { + return nil + } + return &testkube.EnvVarSourceFieldRef{ + ApiVersion: v.APIVersion, + FieldPath: v.FieldPath, + } +} + +func MapResourceFieldRefKubeToAPI(v *corev1.ResourceFieldSelector) *testkube.EnvVarSourceResourceFieldRef { + if v == nil { + return nil + } + divisor := "" + if !v.Divisor.IsZero() { + divisor = v.Divisor.String() + } + return &testkube.EnvVarSourceResourceFieldRef{ + ContainerName: v.ContainerName, + Divisor: divisor, + Resource: v.Resource, + } +} + +func MapSecretKeyRefKubeToAPI(v *corev1.SecretKeySelector) *testkube.EnvVarSourceSecretKeyRef { + if v == nil { + return nil + } + return &testkube.EnvVarSourceSecretKeyRef{ + Key: v.Key, + Name: v.Name, + Optional: common.ResolvePtr(v.Optional, false), + } +} + +func MapEnvVarSourceKubeToAPI(v corev1.EnvVarSource) testkube.EnvVarSource { + return testkube.EnvVarSource{ + ConfigMapKeyRef: MapConfigMapKeyRefKubeToAPI(v.ConfigMapKeyRef), + FieldRef: MapFieldRefKubeToAPI(v.FieldRef), + ResourceFieldRef: MapResourceFieldRefKubeToAPI(v.ResourceFieldRef), + SecretKeyRef: MapSecretKeyRefKubeToAPI(v.SecretKeyRef), + } +} + +func MapConfigMapEnvSourceKubeToAPI(v *corev1.ConfigMapEnvSource) *testkube.ConfigMapEnvSource { + if v == nil { + return nil + } + return &testkube.ConfigMapEnvSource{ + Name: v.Name, + Optional: common.ResolvePtr(v.Optional, false), + } +} + +func MapSecretEnvSourceKubeToAPI(v *corev1.SecretEnvSource) *testkube.SecretEnvSource { + if v == nil { + return nil + } + return &testkube.SecretEnvSource{ + Name: v.Name, + Optional: common.ResolvePtr(v.Optional, false), + } +} + +func MapEnvFromSourceKubeToAPI(v corev1.EnvFromSource) testkube.EnvFromSource { + return testkube.EnvFromSource{ + Prefix: v.Prefix, + ConfigMapRef: MapConfigMapEnvSourceKubeToAPI(v.ConfigMapRef), + SecretRef: MapSecretEnvSourceKubeToAPI(v.SecretRef), + } +} + +func MapSecurityContextKubeToAPI(v *corev1.SecurityContext) *testkube.SecurityContext { + if v == nil { + return nil + } + return &testkube.SecurityContext{ + Privileged: MapBoolToBoxedBoolean(v.Privileged), + RunAsUser: MapInt64ToBoxedInteger(v.RunAsUser), + RunAsGroup: MapInt64ToBoxedInteger(v.RunAsGroup), + RunAsNonRoot: MapBoolToBoxedBoolean(v.RunAsNonRoot), + ReadOnlyRootFilesystem: MapBoolToBoxedBoolean(v.ReadOnlyRootFilesystem), + AllowPrivilegeEscalation: MapBoolToBoxedBoolean(v.AllowPrivilegeEscalation), + } +} + +func MapLocalObjectReferenceKubeToAPI(v corev1.LocalObjectReference) testkube.LocalObjectReference { + return testkube.LocalObjectReference{Name: v.Name} +} + +func MapConfigValueKubeToAPI(v map[string]intstr.IntOrString) map[string]string { + return common.MapMap(v, MapIntOrStringToString) +} + +func MapParameterTypeKubeToAPI(v testworkflowsv1.ParameterType) *testkube.TestWorkflowParameterType { + if v == "" { + return nil + } + return common.Ptr(testkube.TestWorkflowParameterType(v)) +} + +func MapGitAuthTypeKubeToAPI(v testsv3.GitAuthType) *testkube.ContentGitAuthType { + if v == "" { + return nil + } + return common.Ptr(testkube.ContentGitAuthType(v)) +} + +func MapImagePullPolicyKubeToAPI(v corev1.PullPolicy) *testkube.ImagePullPolicy { + if v == "" { + return nil + } + return common.Ptr(testkube.ImagePullPolicy(v)) +} + +func MapParameterSchemaKubeToAPI(v testworkflowsv1.ParameterSchema) testkube.TestWorkflowParameterSchema { + return testkube.TestWorkflowParameterSchema{ + Description: v.Description, + Type_: MapParameterTypeKubeToAPI(v.Type), + Enum: v.Enum, + Example: common.ResolvePtr(common.MapPtr(v.Example, MapIntOrStringToString), ""), + Default_: MapStringToBoxedString(MapIntOrStringPtrToStringPtr(v.Default)), + Format: v.Format, + Pattern: v.Pattern, + MinLength: MapInt64ToBoxedInteger(v.MinLength), + MaxLength: MapInt64ToBoxedInteger(v.MaxLength), + Minimum: MapInt64ToBoxedInteger(v.Minimum), + Maximum: MapInt64ToBoxedInteger(v.Maximum), + ExclusiveMinimum: MapInt64ToBoxedInteger(v.ExclusiveMinimum), + ExclusiveMaximum: MapInt64ToBoxedInteger(v.ExclusiveMaximum), + MultipleOf: MapInt64ToBoxedInteger(v.MultipleOf), + } +} + +func MapTemplateRefKubeToAPI(v testworkflowsv1.TemplateRef) testkube.TestWorkflowTemplateRef { + return testkube.TestWorkflowTemplateRef{ + Name: v.Name, + Config: MapConfigValueKubeToAPI(v.Config), + } +} + +func MapContentGitKubeToAPI(v testworkflowsv1.ContentGit) testkube.TestWorkflowContentGit { + return testkube.TestWorkflowContentGit{ + Uri: v.Uri, + Revision: v.Revision, + Username: v.Username, + UsernameFrom: common.MapPtr(v.UsernameFrom, MapEnvVarSourceKubeToAPI), + Token: v.Token, + TokenFrom: common.MapPtr(v.TokenFrom, MapEnvVarSourceKubeToAPI), + AuthType: MapGitAuthTypeKubeToAPI(v.AuthType), + MountPath: v.MountPath, + Paths: v.Paths, + } +} + +func MapContentKubeToAPI(v testworkflowsv1.Content) testkube.TestWorkflowContent { + return testkube.TestWorkflowContent{ + Git: common.MapPtr(v.Git, MapContentGitKubeToAPI), + Files: common.MapSlice(v.Files, MapContentFileKubeToAPI), + } +} + +func MapContentFileKubeToAPI(v testworkflowsv1.ContentFile) testkube.TestWorkflowContentFile { + return testkube.TestWorkflowContentFile{ + Path: v.Path, + Content: v.Content, + ContentFrom: common.MapPtr(v.ContentFrom, MapEnvVarSourceKubeToAPI), + Mode: MapInt32ToBoxedInteger(v.Mode), + } +} + +func MapResourcesListKubeToAPI(v map[corev1.ResourceName]intstr.IntOrString) *testkube.TestWorkflowResourcesList { + if len(v) == 0 { + return nil + } + empty := intstr.IntOrString{Type: intstr.String, StrVal: ""} + return &testkube.TestWorkflowResourcesList{ + Cpu: MapIntOrStringToString(common.GetMapValue(v, corev1.ResourceCPU, empty)), + Memory: MapIntOrStringToString(common.GetMapValue(v, corev1.ResourceMemory, empty)), + Storage: MapIntOrStringToString(common.GetMapValue(v, corev1.ResourceStorage, empty)), + EphemeralStorage: MapIntOrStringToString(common.GetMapValue(v, corev1.ResourceEphemeralStorage, empty)), + } +} + +func MapResourcesKubeToAPI(v testworkflowsv1.Resources) testkube.TestWorkflowResources { + requests := MapResourcesListKubeToAPI(v.Requests) + limits := MapResourcesListKubeToAPI(v.Limits) + return testkube.TestWorkflowResources{ + Limits: limits, + Requests: requests, + } +} + +func MapJobConfigKubeToAPI(v testworkflowsv1.JobConfig) testkube.TestWorkflowJobConfig { + return testkube.TestWorkflowJobConfig{ + Labels: v.Labels, + Annotations: v.Annotations, + } +} + +func MapPodConfigKubeToAPI(v testworkflowsv1.PodConfig) testkube.TestWorkflowPodConfig { + return testkube.TestWorkflowPodConfig{ + ServiceAccountName: v.ServiceAccountName, + ImagePullSecrets: common.MapSlice(v.ImagePullSecrets, MapLocalObjectReferenceKubeToAPI), + NodeSelector: v.NodeSelector, + Labels: v.Labels, + Annotations: v.Annotations, + } +} + +func MapContainerConfigKubeToAPI(v testworkflowsv1.ContainerConfig) testkube.TestWorkflowContainerConfig { + return testkube.TestWorkflowContainerConfig{ + WorkingDir: MapStringToBoxedString(v.WorkingDir), + Image: v.Image, + ImagePullPolicy: MapImagePullPolicyKubeToAPI(v.ImagePullPolicy), + Env: common.MapSlice(v.Env, MapEnvVarKubeToAPI), + EnvFrom: common.MapSlice(v.EnvFrom, MapEnvFromSourceKubeToAPI), + Command: MapStringSliceToBoxedStringList(v.Command), + Args: MapStringSliceToBoxedStringList(v.Args), + Resources: common.MapPtr(v.Resources, MapResourcesKubeToAPI), + SecurityContext: MapSecurityContextKubeToAPI(v.SecurityContext), + } +} + +func MapStepRunKubeToAPI(v testworkflowsv1.StepRun) testkube.TestWorkflowContainerConfig { + return MapContainerConfigKubeToAPI(v.ContainerConfig) +} + +func MapStepExecuteTestKubeToAPI(v testworkflowsv1.StepExecuteTest) testkube.TestWorkflowStepExecuteTestRef { + return testkube.TestWorkflowStepExecuteTestRef{ + Name: v.Name, + } +} + +func MapTestWorkflowRefKubeToAPI(v testworkflowsv1.StepExecuteWorkflow) testkube.TestWorkflowRef { + return testkube.TestWorkflowRef{ + Name: v.Name, + Config: MapConfigValueKubeToAPI(v.Config), + } +} + +func MapStepExecuteKubeToAPI(v testworkflowsv1.StepExecute) testkube.TestWorkflowStepExecute { + return testkube.TestWorkflowStepExecute{ + Parallelism: v.Parallelism, + Async: v.Async, + Tests: common.MapSlice(v.Tests, MapStepExecuteTestKubeToAPI), + Workflows: common.MapSlice(v.Workflows, MapTestWorkflowRefKubeToAPI), + } +} + +func MapStepArtifactsCompressionKubeToAPI(v testworkflowsv1.ArtifactCompression) testkube.TestWorkflowStepArtifactsCompression { + return testkube.TestWorkflowStepArtifactsCompression{ + Name: v.Name, + } +} + +func MapStepArtifactsKubeToAPI(v testworkflowsv1.StepArtifacts) testkube.TestWorkflowStepArtifacts { + return testkube.TestWorkflowStepArtifacts{ + Compress: common.MapPtr(v.Compress, MapStepArtifactsCompressionKubeToAPI), + Paths: v.Paths, + } +} + +func MapRetryPolicyKubeToAPI(v testworkflowsv1.RetryPolicy) testkube.TestWorkflowRetryPolicy { + return testkube.TestWorkflowRetryPolicy{ + Count: v.Count, + Until: string(v.Until), + } +} + +func MapStepKubeToAPI(v testworkflowsv1.Step) testkube.TestWorkflowStep { + return testkube.TestWorkflowStep{ + Name: v.Name, + Condition: string(v.Condition), + Negative: v.Negative, + Optional: v.Optional, + VirtualGroup: v.VirtualGroup, + Use: common.MapSlice(v.Use, MapTemplateRefKubeToAPI), + Template: common.MapPtr(v.Template, MapTemplateRefKubeToAPI), + Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentKubeToAPI), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunKubeToAPI), + WorkingDir: MapStringToBoxedString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), + Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), + Steps: common.MapSlice(v.Steps, MapStepKubeToAPI), + } +} + +func MapIndependentStepKubeToAPI(v testworkflowsv1.IndependentStep) testkube.TestWorkflowIndependentStep { + return testkube.TestWorkflowIndependentStep{ + Name: v.Name, + Condition: string(v.Condition), + Negative: v.Negative, + Optional: v.Optional, + VirtualGroup: v.VirtualGroup, + Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentKubeToAPI), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunKubeToAPI), + WorkingDir: MapStringToBoxedString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), + Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), + Steps: common.MapSlice(v.Steps, MapIndependentStepKubeToAPI), + } +} + +func MapSpecKubeToAPI(v testworkflowsv1.TestWorkflowSpec) testkube.TestWorkflowSpec { + return testkube.TestWorkflowSpec{ + Use: common.MapSlice(v.Use, MapTemplateRefKubeToAPI), + Config: common.MapMap(v.Config, MapParameterSchemaKubeToAPI), + Content: common.MapPtr(v.Content, MapContentKubeToAPI), + Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), + Job: common.MapPtr(v.Job, MapJobConfigKubeToAPI), + Pod: common.MapPtr(v.Pod, MapPodConfigKubeToAPI), + Setup: common.MapSlice(v.Setup, MapStepKubeToAPI), + Steps: common.MapSlice(v.Steps, MapStepKubeToAPI), + After: common.MapSlice(v.After, MapStepKubeToAPI), + } +} + +func MapTemplateSpecKubeToAPI(v testworkflowsv1.TestWorkflowTemplateSpec) testkube.TestWorkflowTemplateSpec { + return testkube.TestWorkflowTemplateSpec{ + Config: common.MapMap(v.Config, MapParameterSchemaKubeToAPI), + Content: common.MapPtr(v.Content, MapContentKubeToAPI), + Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), + Job: common.MapPtr(v.Job, MapJobConfigKubeToAPI), + Pod: common.MapPtr(v.Pod, MapPodConfigKubeToAPI), + Setup: common.MapSlice(v.Setup, MapIndependentStepKubeToAPI), + Steps: common.MapSlice(v.Steps, MapIndependentStepKubeToAPI), + After: common.MapSlice(v.After, MapIndependentStepKubeToAPI), + } +} + +func MapTestWorkflowKubeToAPI(w testworkflowsv1.TestWorkflow) testkube.TestWorkflow { + return testkube.TestWorkflow{ + Name: w.Name, + Namespace: w.Namespace, + Labels: w.Labels, + Annotations: w.Annotations, + Created: w.CreationTimestamp.Time, + Spec: common.Ptr(MapSpecKubeToAPI(w.Spec)), + } +} + +func MapTestWorkflowTemplateKubeToAPI(w testworkflowsv1.TestWorkflowTemplate) testkube.TestWorkflowTemplate { + return testkube.TestWorkflowTemplate{ + Name: w.Name, + Namespace: w.Namespace, + Labels: w.Labels, + Annotations: w.Annotations, + Created: w.CreationTimestamp.Time, + Spec: common.Ptr(MapTemplateSpecKubeToAPI(w.Spec)), + } +} + +func MapTemplateKubeToAPI(w *testworkflowsv1.TestWorkflowTemplate) *testkube.TestWorkflowTemplate { + return common.MapPtr(w, MapTestWorkflowTemplateKubeToAPI) +} + +func MapKubeToAPI(w *testworkflowsv1.TestWorkflow) *testkube.TestWorkflow { + return common.MapPtr(w, MapTestWorkflowKubeToAPI) +} + +func MapListKubeToAPI(v *testworkflowsv1.TestWorkflowList) []testkube.TestWorkflow { + workflows := make([]testkube.TestWorkflow, len(v.Items)) + for i, item := range v.Items { + workflows[i] = MapTestWorkflowKubeToAPI(item) + } + return workflows +} + +func MapTemplateListKubeToAPI(v *testworkflowsv1.TestWorkflowTemplateList) []testkube.TestWorkflowTemplate { + workflows := make([]testkube.TestWorkflowTemplate, len(v.Items)) + for i, item := range v.Items { + workflows[i] = MapTestWorkflowTemplateKubeToAPI(item) + } + return workflows +} diff --git a/tcl/workflowstcl/mappers/mappers_test.go b/tcl/workflowstcl/mappers/mappers_test.go new file mode 100644 index 00000000000..dbe86308720 --- /dev/null +++ b/tcl/workflowstcl/mappers/mappers_test.go @@ -0,0 +1,404 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package mappers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" +) + +var ( + container = testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("/wd"), + Image: "some-image", + ImagePullPolicy: "IfNotPresent", + Env: []corev1.EnvVar{ + {Name: "some-naaame", Value: "some-value"}, + {Name: "some-naaame", ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "api.value.1", + FieldPath: "the.field.pa", + }, + ResourceFieldRef: &corev1.ResourceFieldSelector{ + ContainerName: "con-name", + Resource: "anc", + }, + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "cfg-name"}, + Key: "cfg-key", + }, + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "some-sec"}, + Key: "sec-key", + }, + }}, + }, + EnvFrom: []corev1.EnvFromSource{ + { + Prefix: "some-prefix", + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "some-name", + }, + }, + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "some-sec", + }, + Optional: common.Ptr(true), + }, + }, + }, + Command: common.Ptr([]string{"c", "d"}), + Args: common.Ptr([]string{"ar", "gs"}), + Resources: &testworkflowsv1.Resources{ + Limits: map[corev1.ResourceName]intstr.IntOrString{ + corev1.ResourceCPU: {Type: intstr.String, StrVal: "300m"}, + corev1.ResourceMemory: {Type: intstr.Int, IntVal: 1024}, + }, + Requests: map[corev1.ResourceName]intstr.IntOrString{ + corev1.ResourceCPU: {Type: intstr.String, StrVal: "3800m"}, + corev1.ResourceMemory: {Type: intstr.Int, IntVal: 10204}, + }, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: common.Ptr(int64(334)), + RunAsGroup: common.Ptr(int64(11)), + RunAsNonRoot: common.Ptr(true), + ReadOnlyRootFilesystem: common.Ptr(false), + AllowPrivilegeEscalation: nil, + }, + } + content = testworkflowsv1.Content{ + Git: &testworkflowsv1.ContentGit{ + Uri: "some-uri", + Revision: "some-revision", + Username: "some-username", + UsernameFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "testworkflows.dummy.io/v1", + FieldPath: "the.field.path", + }, + ResourceFieldRef: &corev1.ResourceFieldSelector{ + ContainerName: "container.name", + Resource: "the.resource", + Divisor: resource.MustParse("300"), + }, + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "the-name-config"}, + Key: "the-key", + Optional: common.Ptr(true), + }, + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "the-name-secret"}, + Key: "the-key-secret", + Optional: nil, + }, + }, + Token: "the-token", + TokenFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "some.dummy.api/v1", + FieldPath: "some.field", + }, + ResourceFieldRef: &corev1.ResourceFieldSelector{ + ContainerName: "some-container-name", + Resource: "some-resource", + Divisor: resource.MustParse("200"), + }, + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "the-name"}, + Key: "the-abc", + Optional: nil, + }, + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "xyz"}, + Key: "222", + Optional: nil, + }, + }, + AuthType: "basic", + MountPath: "/some/output/path", + Paths: []string{"a", "b", "c"}, + }, + Files: []testworkflowsv1.ContentFile{ + { + Path: "some-path", + Content: "some-content", + ContentFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "api.version.abc", + FieldPath: "field.path", + }, + }, + Mode: common.Ptr(int32(0777)), + }, + }, + } + stepBase = testworkflowsv1.StepBase{ + Name: "some-name", + Condition: "some-condition", + Negative: true, + Optional: false, + VirtualGroup: false, + Retry: &testworkflowsv1.RetryPolicy{ + Count: 444, + Until: "abc", + }, + Timeout: "3h15m", + Delay: "2m40s", + Content: &testworkflowsv1.Content{ + Git: &testworkflowsv1.ContentGit{ + Uri: "some-url", + Revision: "another-rev", + Username: "some-username", + UsernameFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "dummy.api", + FieldPath: "field.path.there", + }, + ResourceFieldRef: &corev1.ResourceFieldSelector{ + ContainerName: "con-name", + Resource: "abc1", + }, + }, + Token: "", + TokenFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "test.v1", + FieldPath: "abc.there", + }, + }, + AuthType: "basic", + MountPath: "/a/b/c", + Paths: []string{"p", "a", "th"}, + }, + Files: []testworkflowsv1.ContentFile{ + {Path: "abc", Content: "some-content"}, + }, + }, + Shell: "shell-to-run", + Run: &testworkflowsv1.StepRun{ + ContainerConfig: testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("/abc"), + Image: "im-g", + ImagePullPolicy: "IfNotPresent", + Env: []corev1.EnvVar{ + {Name: "abc", Value: "230"}, + }, + EnvFrom: []corev1.EnvFromSource{ + {Prefix: "abc"}, + }, + Command: common.Ptr([]string{"c", "m", "d"}), + Args: common.Ptr([]string{"arg", "s", "d"}), + Resources: &testworkflowsv1.Resources{ + Limits: map[corev1.ResourceName]intstr.IntOrString{ + corev1.ResourceCPU: {Type: intstr.Int, IntVal: 444}, + }, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: common.Ptr(int64(444)), + RunAsGroup: nil, + RunAsNonRoot: common.Ptr(true), + ReadOnlyRootFilesystem: nil, + AllowPrivilegeEscalation: nil, + }, + }, + }, + WorkingDir: common.Ptr("/ssss"), + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("/aaaa"), + Image: "ssss", + ImagePullPolicy: "Never", + Env: []corev1.EnvVar{{Name: "xyz", Value: "bar"}}, + Command: common.Ptr([]string{"ab"}), + Args: common.Ptr([]string{"abrgs"}), + Resources: &testworkflowsv1.Resources{ + Requests: map[corev1.ResourceName]intstr.IntOrString{ + corev1.ResourceMemory: {Type: intstr.String, StrVal: "300m"}, + }, + }, + SecurityContext: &corev1.SecurityContext{ + Privileged: common.Ptr(true), + RunAsUser: common.Ptr(int64(33)), + }, + }, + Execute: &testworkflowsv1.StepExecute{ + Parallelism: 880, + Async: false, + Tests: []testworkflowsv1.StepExecuteTest{{Name: "some-name-test"}}, + Workflows: []testworkflowsv1.StepExecuteWorkflow{{Name: "some-workflow", Config: map[string]intstr.IntOrString{ + "id": {Type: intstr.String, StrVal: "xyzz"}, + }}}, + }, + Artifacts: &testworkflowsv1.StepArtifacts{ + Compress: &testworkflowsv1.ArtifactCompression{ + Name: "some-artifact.tar.gz", + }, + Paths: []string{"/get", "/from/there"}, + }, + } + step = testworkflowsv1.Step{ + StepBase: stepBase, + Use: []testworkflowsv1.TemplateRef{ + {Name: "/abc", Config: map[string]intstr.IntOrString{ + "xxx": {Type: intstr.Int, IntVal: 322}, + }}, + }, + Template: &testworkflowsv1.TemplateRef{ + Name: "other-one", + Config: map[string]intstr.IntOrString{ + "foo": {Type: intstr.String, StrVal: "bar"}, + }, + }, + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "xyz"}}, + }, + } + independentStep = testworkflowsv1.IndependentStep{ + StepBase: stepBase, + Steps: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "xyz"}}, + }, + } + workflowSpecBase = testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "some-key": { + Description: "some-description", + Type: "integer", + Enum: []string{"en", "um"}, + Example: &intstr.IntOrString{ + Type: intstr.String, + StrVal: "some-vale", + }, + Default: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 233, + }, + ParameterStringSchema: testworkflowsv1.ParameterStringSchema{ + Format: "url", + Pattern: "^abc$", + MinLength: common.Ptr(int64(1)), + MaxLength: common.Ptr(int64(2)), + }, + ParameterNumberSchema: testworkflowsv1.ParameterNumberSchema{ + Minimum: common.Ptr(int64(3)), + Maximum: common.Ptr(int64(4)), + ExclusiveMinimum: common.Ptr(int64(5)), + ExclusiveMaximum: common.Ptr(int64(7)), + MultipleOf: common.Ptr(int64(8)), + }, + }, + }, + Content: &content, + Container: &container, + Job: &testworkflowsv1.JobConfig{ + Labels: map[string]string{"some-key": "some-value"}, + Annotations: map[string]string{"some-key=2": "some-value-2"}, + }, + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "some-name", + ImagePullSecrets: []corev1.LocalObjectReference{{Name: "v1"}, {Name: "v2"}}, + NodeSelector: map[string]string{"some-key-3": "some-value"}, + Labels: map[string]string{"some-key-4": "some-value"}, + Annotations: map[string]string{"some-key=5": "some-value-2"}, + }, + } +) + +func TestMapTestWorkflowBackAndForth(t *testing.T) { + want := testworkflowsv1.TestWorkflow{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflow", + APIVersion: "testworkflows.testkube.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy", + Namespace: "dummy-namespace", + }, + Spec: testworkflowsv1.TestWorkflowSpec{ + Use: []testworkflowsv1.TemplateRef{ + { + Name: "some-name", + Config: map[string]intstr.IntOrString{ + "some-key": {Type: intstr.String, StrVal: "some-value"}, + "some-key-2": {Type: intstr.Int, IntVal: 444}, + }, + }, + }, + TestWorkflowSpecBase: workflowSpecBase, + Setup: []testworkflowsv1.Step{step}, + Steps: []testworkflowsv1.Step{step, step}, + After: []testworkflowsv1.Step{step, step, step, step}, + }, + } + got := MapTestWorkflowAPIToKube(MapTestWorkflowKubeToAPI(*want.DeepCopy())) + assert.Equal(t, want, got) +} + +func TestMapEmptyTestWorkflowBackAndForth(t *testing.T) { + want := testworkflowsv1.TestWorkflow{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflow", + APIVersion: "testworkflows.testkube.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy", + Namespace: "dummy-namespace", + }, + Spec: testworkflowsv1.TestWorkflowSpec{}, + } + got := MapTestWorkflowAPIToKube(MapTestWorkflowKubeToAPI(*want.DeepCopy())) + assert.Equal(t, want, got) +} + +func TestMapTestWorkflowTemplateBackAndForth(t *testing.T) { + want := testworkflowsv1.TestWorkflowTemplate{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflowTemplate", + APIVersion: "testworkflows.testkube.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy", + Namespace: "dummy-namespace", + }, + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: workflowSpecBase, + Setup: []testworkflowsv1.IndependentStep{independentStep}, + Steps: []testworkflowsv1.IndependentStep{independentStep, independentStep}, + After: []testworkflowsv1.IndependentStep{independentStep, independentStep, independentStep, independentStep}, + }, + } + got := MapTestWorkflowTemplateAPIToKube(MapTestWorkflowTemplateKubeToAPI(*want.DeepCopy())) + assert.Equal(t, want, got) +} + +func TestMapEmptyTestWorkflowTemplateBackAndForth(t *testing.T) { + want := testworkflowsv1.TestWorkflowTemplate{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflowTemplate", + APIVersion: "testworkflows.testkube.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy", + Namespace: "dummy-namespace", + }, + Spec: testworkflowsv1.TestWorkflowTemplateSpec{}, + } + got := MapTestWorkflowTemplateAPIToKube(MapTestWorkflowTemplateKubeToAPI(*want.DeepCopy())) + assert.Equal(t, want, got) +} diff --git a/tcl/workflowstcl/mappers/openapi_kube.go b/tcl/workflowstcl/mappers/openapi_kube.go new file mode 100644 index 00000000000..7cfa1def6a6 --- /dev/null +++ b/tcl/workflowstcl/mappers/openapi_kube.go @@ -0,0 +1,523 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package mappers + +import ( + "strconv" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +func MapStringToIntOrString(i string) intstr.IntOrString { + if v, err := strconv.ParseInt(i, 10, 32); err == nil { + return intstr.IntOrString{Type: intstr.Int, IntVal: int32(v)} + } + return intstr.IntOrString{Type: intstr.String, StrVal: i} +} + +func MapStringPtrToIntOrStringPtr(i *string) *intstr.IntOrString { + if i == nil { + return nil + } + return common.Ptr(MapStringToIntOrString(*i)) +} + +func MapBoxedStringToString(v *testkube.BoxedString) *string { + if v == nil { + return nil + } + return &v.Value +} + +func MapBoxedBooleanToBool(v *testkube.BoxedBoolean) *bool { + if v == nil { + return nil + } + return &v.Value +} + +func MapBoxedStringListToStringSlice(v *testkube.BoxedStringList) *[]string { + if v == nil { + return nil + } + return &v.Value +} + +func MapBoxedIntegerToInt64(v *testkube.BoxedInteger) *int64 { + if v == nil { + return nil + } + return common.Ptr(int64(v.Value)) +} + +func MapBoxedIntegerToInt32(v *testkube.BoxedInteger) *int32 { + if v == nil { + return nil + } + return &v.Value +} + +func MapEnvVarAPIToKube(v testkube.EnvVar) corev1.EnvVar { + return corev1.EnvVar{ + Name: v.Name, + Value: v.Value, + ValueFrom: common.MapPtr(v.ValueFrom, MapEnvVarSourceAPIToKube), + } +} + +func MapConfigMapKeyRefAPIToKube(v *testkube.EnvVarSourceConfigMapKeyRef) *corev1.ConfigMapKeySelector { + if v == nil { + return nil + } + return &corev1.ConfigMapKeySelector{ + Key: v.Key, + LocalObjectReference: corev1.LocalObjectReference{Name: v.Name}, + Optional: common.PtrOrNil(v.Optional), + } +} + +func MapFieldRefAPIToKube(v *testkube.EnvVarSourceFieldRef) *corev1.ObjectFieldSelector { + if v == nil { + return nil + } + return &corev1.ObjectFieldSelector{ + APIVersion: v.ApiVersion, + FieldPath: v.FieldPath, + } +} + +func MapResourceFieldRefAPIToKube(v *testkube.EnvVarSourceResourceFieldRef) *corev1.ResourceFieldSelector { + if v == nil { + return nil + } + divisor, _ := resource.ParseQuantity(v.Divisor) + return &corev1.ResourceFieldSelector{ + ContainerName: v.ContainerName, + Divisor: divisor, + Resource: v.Resource, + } +} + +func MapSecretKeyRefAPIToKube(v *testkube.EnvVarSourceSecretKeyRef) *corev1.SecretKeySelector { + if v == nil { + return nil + } + return &corev1.SecretKeySelector{ + Key: v.Key, + LocalObjectReference: corev1.LocalObjectReference{Name: v.Name}, + Optional: common.PtrOrNil(v.Optional), + } +} + +func MapEnvVarSourceAPIToKube(v testkube.EnvVarSource) corev1.EnvVarSource { + return corev1.EnvVarSource{ + ConfigMapKeyRef: MapConfigMapKeyRefAPIToKube(v.ConfigMapKeyRef), + FieldRef: MapFieldRefAPIToKube(v.FieldRef), + ResourceFieldRef: MapResourceFieldRefAPIToKube(v.ResourceFieldRef), + SecretKeyRef: MapSecretKeyRefAPIToKube(v.SecretKeyRef), + } +} + +func MapConfigMapEnvSourceAPIToKube(v *testkube.ConfigMapEnvSource) *corev1.ConfigMapEnvSource { + if v == nil { + return nil + } + return &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: v.Name}, + Optional: common.PtrOrNil(v.Optional), + } +} + +func MapSecretEnvSourceAPIToKube(v *testkube.SecretEnvSource) *corev1.SecretEnvSource { + if v == nil { + return nil + } + return &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: v.Name}, + Optional: common.PtrOrNil(v.Optional), + } +} + +func MapEnvFromSourceAPIToKube(v testkube.EnvFromSource) corev1.EnvFromSource { + return corev1.EnvFromSource{ + Prefix: v.Prefix, + ConfigMapRef: MapConfigMapEnvSourceAPIToKube(v.ConfigMapRef), + SecretRef: MapSecretEnvSourceAPIToKube(v.SecretRef), + } +} + +func MapSecurityContextAPIToKube(v *testkube.SecurityContext) *corev1.SecurityContext { + if v == nil { + return nil + } + return &corev1.SecurityContext{ + Privileged: MapBoxedBooleanToBool(v.Privileged), + RunAsUser: MapBoxedIntegerToInt64(v.RunAsUser), + RunAsGroup: MapBoxedIntegerToInt64(v.RunAsGroup), + RunAsNonRoot: MapBoxedBooleanToBool(v.RunAsNonRoot), + ReadOnlyRootFilesystem: MapBoxedBooleanToBool(v.ReadOnlyRootFilesystem), + AllowPrivilegeEscalation: MapBoxedBooleanToBool(v.AllowPrivilegeEscalation), + } +} + +func MapLocalObjectReferenceAPIToKube(v testkube.LocalObjectReference) corev1.LocalObjectReference { + return corev1.LocalObjectReference{Name: v.Name} +} + +func MapConfigValueAPIToKube(v map[string]string) map[string]intstr.IntOrString { + return common.MapMap(v, MapStringToIntOrString) +} + +func MapParameterTypeAPIToKube(v *testkube.TestWorkflowParameterType) testworkflowsv1.ParameterType { + if v == nil { + return "" + } + return testworkflowsv1.ParameterType(*v) +} + +func MapGitAuthTypeAPIToKube(v *testkube.ContentGitAuthType) testsv3.GitAuthType { + if v == nil { + return "" + } + return testsv3.GitAuthType(*v) +} + +func MapImagePullPolicyAPIToKube(v *testkube.ImagePullPolicy) corev1.PullPolicy { + if v == nil { + return "" + } + return corev1.PullPolicy(*v) +} + +func MapParameterSchemaAPIToKube(v testkube.TestWorkflowParameterSchema) testworkflowsv1.ParameterSchema { + var example *intstr.IntOrString + if v.Example != "" { + example = common.Ptr(MapStringToIntOrString(v.Example)) + } + return testworkflowsv1.ParameterSchema{ + Description: v.Description, + Type: MapParameterTypeAPIToKube(v.Type_), + Enum: v.Enum, + Example: example, + Default: MapStringPtrToIntOrStringPtr(MapBoxedStringToString(v.Default_)), + ParameterStringSchema: testworkflowsv1.ParameterStringSchema{ + Format: v.Format, + Pattern: v.Pattern, + MinLength: MapBoxedIntegerToInt64(v.MinLength), + MaxLength: MapBoxedIntegerToInt64(v.MaxLength), + }, + ParameterNumberSchema: testworkflowsv1.ParameterNumberSchema{ + Minimum: MapBoxedIntegerToInt64(v.Minimum), + Maximum: MapBoxedIntegerToInt64(v.Maximum), + ExclusiveMinimum: MapBoxedIntegerToInt64(v.ExclusiveMinimum), + ExclusiveMaximum: MapBoxedIntegerToInt64(v.ExclusiveMaximum), + MultipleOf: MapBoxedIntegerToInt64(v.MultipleOf), + }, + } +} + +func MapTemplateRefAPIToKube(v testkube.TestWorkflowTemplateRef) testworkflowsv1.TemplateRef { + return testworkflowsv1.TemplateRef{ + Name: v.Name, + Config: MapConfigValueAPIToKube(v.Config), + } +} + +func MapContentGitAPIToKube(v testkube.TestWorkflowContentGit) testworkflowsv1.ContentGit { + return testworkflowsv1.ContentGit{ + Uri: v.Uri, + Revision: v.Revision, + Username: v.Username, + UsernameFrom: common.MapPtr(v.UsernameFrom, MapEnvVarSourceAPIToKube), + Token: v.Token, + TokenFrom: common.MapPtr(v.TokenFrom, MapEnvVarSourceAPIToKube), + AuthType: MapGitAuthTypeAPIToKube(v.AuthType), + MountPath: v.MountPath, + Paths: v.Paths, + } +} + +func MapContentAPIToKube(v testkube.TestWorkflowContent) testworkflowsv1.Content { + return testworkflowsv1.Content{ + Git: common.MapPtr(v.Git, MapContentGitAPIToKube), + Files: common.MapSlice(v.Files, MapContentFileAPIToKube), + } +} + +func MapContentFileAPIToKube(v testkube.TestWorkflowContentFile) testworkflowsv1.ContentFile { + return testworkflowsv1.ContentFile{ + Path: v.Path, + Content: v.Content, + ContentFrom: common.MapPtr(v.ContentFrom, MapEnvVarSourceAPIToKube), + Mode: MapBoxedIntegerToInt32(v.Mode), + } +} + +func MapResourcesListAPIToKube(v *testkube.TestWorkflowResourcesList) map[corev1.ResourceName]intstr.IntOrString { + if v == nil { + return nil + } + res := make(map[corev1.ResourceName]intstr.IntOrString) + if v.Cpu != "" { + res[corev1.ResourceCPU] = MapStringToIntOrString(v.Cpu) + } + if v.Memory != "" { + res[corev1.ResourceMemory] = MapStringToIntOrString(v.Memory) + } + if v.Storage != "" { + res[corev1.ResourceStorage] = MapStringToIntOrString(v.Storage) + } + if v.EphemeralStorage != "" { + res[corev1.ResourceEphemeralStorage] = MapStringToIntOrString(v.EphemeralStorage) + } + return res +} + +func MapResourcesAPIToKube(v testkube.TestWorkflowResources) testworkflowsv1.Resources { + return testworkflowsv1.Resources{ + Limits: MapResourcesListAPIToKube(v.Limits), + Requests: MapResourcesListAPIToKube(v.Requests), + } +} + +func MapJobConfigAPIToKube(v testkube.TestWorkflowJobConfig) testworkflowsv1.JobConfig { + return testworkflowsv1.JobConfig{ + Labels: v.Labels, + Annotations: v.Annotations, + } +} + +func MapPodConfigAPIToKube(v testkube.TestWorkflowPodConfig) testworkflowsv1.PodConfig { + return testworkflowsv1.PodConfig{ + ServiceAccountName: v.ServiceAccountName, + ImagePullSecrets: common.MapSlice(v.ImagePullSecrets, MapLocalObjectReferenceAPIToKube), + NodeSelector: v.NodeSelector, + Labels: v.Labels, + Annotations: v.Annotations, + } +} + +func MapContainerConfigAPIToKube(v testkube.TestWorkflowContainerConfig) testworkflowsv1.ContainerConfig { + return testworkflowsv1.ContainerConfig{ + WorkingDir: MapBoxedStringToString(v.WorkingDir), + Image: v.Image, + ImagePullPolicy: MapImagePullPolicyAPIToKube(v.ImagePullPolicy), + Env: common.MapSlice(v.Env, MapEnvVarAPIToKube), + EnvFrom: common.MapSlice(v.EnvFrom, MapEnvFromSourceAPIToKube), + Command: MapBoxedStringListToStringSlice(v.Command), + Args: MapBoxedStringListToStringSlice(v.Args), + Resources: common.MapPtr(v.Resources, MapResourcesAPIToKube), + SecurityContext: MapSecurityContextAPIToKube(v.SecurityContext), + } +} + +func MapStepRunAPIToKube(v testkube.TestWorkflowContainerConfig) testworkflowsv1.StepRun { + return testworkflowsv1.StepRun{ + ContainerConfig: MapContainerConfigAPIToKube(v), + } +} + +func MapStepExecuteTestAPIToKube(v testkube.TestWorkflowStepExecuteTestRef) testworkflowsv1.StepExecuteTest { + return testworkflowsv1.StepExecuteTest{ + Name: v.Name, + } +} + +func MapTestWorkflowRefAPIToKube(v testkube.TestWorkflowRef) testworkflowsv1.StepExecuteWorkflow { + return testworkflowsv1.StepExecuteWorkflow{ + Name: v.Name, + Config: MapConfigValueAPIToKube(v.Config), + } +} + +func MapStepExecuteAPIToKube(v testkube.TestWorkflowStepExecute) testworkflowsv1.StepExecute { + return testworkflowsv1.StepExecute{ + Parallelism: v.Parallelism, + Async: v.Async, + Tests: common.MapSlice(v.Tests, MapStepExecuteTestAPIToKube), + Workflows: common.MapSlice(v.Workflows, MapTestWorkflowRefAPIToKube), + } +} + +func MapStepArtifactsCompressionAPIToKube(v testkube.TestWorkflowStepArtifactsCompression) testworkflowsv1.ArtifactCompression { + return testworkflowsv1.ArtifactCompression{ + Name: v.Name, + } +} + +func MapStepArtifactsAPIToKube(v testkube.TestWorkflowStepArtifacts) testworkflowsv1.StepArtifacts { + return testworkflowsv1.StepArtifacts{ + Compress: common.MapPtr(v.Compress, MapStepArtifactsCompressionAPIToKube), + Paths: v.Paths, + } +} + +func MapRetryPolicyAPIToKube(v testkube.TestWorkflowRetryPolicy) testworkflowsv1.RetryPolicy { + return testworkflowsv1.RetryPolicy{ + Count: v.Count, + Until: testworkflowsv1.Expression(v.Until), + } +} + +func MapStepAPIToKube(v testkube.TestWorkflowStep) testworkflowsv1.Step { + return testworkflowsv1.Step{ + StepBase: testworkflowsv1.StepBase{ + Name: v.Name, + Condition: testworkflowsv1.Expression(v.Condition), + Negative: v.Negative, + Optional: v.Optional, + VirtualGroup: v.VirtualGroup, + Retry: common.MapPtr(v.Retry, MapRetryPolicyAPIToKube), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentAPIToKube), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunAPIToKube), + WorkingDir: MapBoxedStringToString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), + Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), + }, + Use: common.MapSlice(v.Use, MapTemplateRefAPIToKube), + Template: common.MapPtr(v.Template, MapTemplateRefAPIToKube), + Steps: common.MapSlice(v.Steps, MapStepAPIToKube), + } +} + +func MapIndependentStepAPIToKube(v testkube.TestWorkflowIndependentStep) testworkflowsv1.IndependentStep { + return testworkflowsv1.IndependentStep{ + StepBase: testworkflowsv1.StepBase{ + Name: v.Name, + Condition: testworkflowsv1.Expression(v.Condition), + Negative: v.Negative, + Optional: v.Optional, + VirtualGroup: v.VirtualGroup, + Retry: common.MapPtr(v.Retry, MapRetryPolicyAPIToKube), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentAPIToKube), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunAPIToKube), + WorkingDir: MapBoxedStringToString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), + Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), + }, + Steps: common.MapSlice(v.Steps, MapIndependentStepAPIToKube), + } +} + +func MapSpecAPIToKube(v testkube.TestWorkflowSpec) testworkflowsv1.TestWorkflowSpec { + return testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: common.MapMap(v.Config, MapParameterSchemaAPIToKube), + Content: common.MapPtr(v.Content, MapContentAPIToKube), + Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), + Job: common.MapPtr(v.Job, MapJobConfigAPIToKube), + Pod: common.MapPtr(v.Pod, MapPodConfigAPIToKube), + }, + Use: common.MapSlice(v.Use, MapTemplateRefAPIToKube), + Setup: common.MapSlice(v.Setup, MapStepAPIToKube), + Steps: common.MapSlice(v.Steps, MapStepAPIToKube), + After: common.MapSlice(v.After, MapStepAPIToKube), + } +} + +func MapTemplateSpecAPIToKube(v testkube.TestWorkflowTemplateSpec) testworkflowsv1.TestWorkflowTemplateSpec { + return testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: common.MapMap(v.Config, MapParameterSchemaAPIToKube), + Content: common.MapPtr(v.Content, MapContentAPIToKube), + Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), + Job: common.MapPtr(v.Job, MapJobConfigAPIToKube), + Pod: common.MapPtr(v.Pod, MapPodConfigAPIToKube), + }, + Setup: common.MapSlice(v.Setup, MapIndependentStepAPIToKube), + Steps: common.MapSlice(v.Steps, MapIndependentStepAPIToKube), + After: common.MapSlice(v.After, MapIndependentStepAPIToKube), + } +} + +func MapTestWorkflowAPIToKube(w testkube.TestWorkflow) testworkflowsv1.TestWorkflow { + return testworkflowsv1.TestWorkflow{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflow", + APIVersion: testworkflowsv1.GroupVersion.Group + "/" + testworkflowsv1.GroupVersion.Version, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: w.Name, + Namespace: w.Namespace, + Labels: w.Labels, + Annotations: w.Annotations, + CreationTimestamp: metav1.Time{Time: w.Created}, + }, + Spec: common.ResolvePtr(common.MapPtr(w.Spec, MapSpecAPIToKube), testworkflowsv1.TestWorkflowSpec{}), + } +} + +func MapTestWorkflowTemplateAPIToKube(w testkube.TestWorkflowTemplate) testworkflowsv1.TestWorkflowTemplate { + return testworkflowsv1.TestWorkflowTemplate{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflowTemplate", + APIVersion: testworkflowsv1.GroupVersion.Group + "/" + testworkflowsv1.GroupVersion.Version, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: w.Name, + Namespace: w.Namespace, + Labels: w.Labels, + Annotations: w.Annotations, + CreationTimestamp: metav1.Time{Time: w.Created}, + }, + Spec: common.ResolvePtr(common.MapPtr(w.Spec, MapTemplateSpecAPIToKube), testworkflowsv1.TestWorkflowTemplateSpec{}), + } +} + +func MapTemplateAPIToKube(w *testkube.TestWorkflowTemplate) *testworkflowsv1.TestWorkflowTemplate { + return common.MapPtr(w, MapTestWorkflowTemplateAPIToKube) +} + +func MapAPIToKube(w *testkube.TestWorkflow) *testworkflowsv1.TestWorkflow { + return common.MapPtr(w, MapTestWorkflowAPIToKube) +} + +func MapListAPIToKube(v []testkube.TestWorkflow) testworkflowsv1.TestWorkflowList { + items := make([]testworkflowsv1.TestWorkflow, len(v)) + for i, item := range v { + items[i] = MapTestWorkflowAPIToKube(item) + } + return testworkflowsv1.TestWorkflowList{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflowList", + APIVersion: testworkflowsv1.GroupVersion.String(), + }, + Items: items, + } +} + +func MapTemplateListAPIToKube(v []testkube.TestWorkflowTemplate) testworkflowsv1.TestWorkflowTemplateList { + items := make([]testworkflowsv1.TestWorkflowTemplate, len(v)) + for i, item := range v { + items[i] = MapTestWorkflowTemplateAPIToKube(item) + } + return testworkflowsv1.TestWorkflowTemplateList{ + TypeMeta: metav1.TypeMeta{ + Kind: "TestWorkflowTemplateList", + APIVersion: testworkflowsv1.GroupVersion.String(), + }, + Items: items, + } +} From 62fb735001f5dce60a1d56e5f8ea744780cea1b2 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 21 Feb 2024 16:53:07 +0100 Subject: [PATCH 109/234] fix: Go template rendering (#5042) --- cmd/kubectl-testkube/commands/common/render/list.go | 9 +++++++-- cmd/kubectl-testkube/commands/common/render/obj.go | 8 +------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cmd/kubectl-testkube/commands/common/render/list.go b/cmd/kubectl-testkube/commands/common/render/list.go index 5eef452673f..6ec89864f51 100644 --- a/cmd/kubectl-testkube/commands/common/render/list.go +++ b/cmd/kubectl-testkube/commands/common/render/list.go @@ -3,6 +3,7 @@ package render import ( "fmt" "io" + "reflect" "github.com/spf13/cobra" @@ -25,10 +26,14 @@ func List(cmd *cobra.Command, obj interface{}, w io.Writer) error { return RenderJSON(obj, w) case OutputGoTemplate: tpl := cmd.Flag("go-template").Value.String() - list, ok := obj.([]interface{}) - if !ok { + value := reflect.ValueOf(obj) + if value.Kind() != reflect.Slice { return fmt.Errorf("can't render, need list type but got: %+v", obj) } + list := make([]interface{}, value.Len()) + for i := 0; i < value.Len(); i++ { + list[i] = value.Index(i).Interface() + } return RenderGoTemplateList(list, w, tpl) default: return RenderYaml(obj, w) diff --git a/cmd/kubectl-testkube/commands/common/render/obj.go b/cmd/kubectl-testkube/commands/common/render/obj.go index f1a23fc7d0f..4cc6093d60e 100644 --- a/cmd/kubectl-testkube/commands/common/render/obj.go +++ b/cmd/kubectl-testkube/commands/common/render/obj.go @@ -1,7 +1,6 @@ package render import ( - "fmt" "io" "github.com/spf13/cobra" @@ -30,12 +29,7 @@ func Obj(cmd *cobra.Command, obj interface{}, w io.Writer, renderer ...CliObjRen return RenderJSON(obj, w) case OutputGoTemplate: tpl := cmd.Flag("go-template").Value.String() - // need to make type assetion to list first - list, ok := obj.([]interface{}) - if !ok { - return fmt.Errorf("can't render, need list type but got: %+v", obj) - } - return RenderGoTemplateList(list, w, tpl) + return RenderGoTemplate(obj, w, tpl) default: return RenderYaml(obj, w) } From 515b5a574e98c28b532b833052bc140abbcb8f56 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Wed, 21 Feb 2024 17:41:44 +0100 Subject: [PATCH 110/234] docs: remove FAQ reference (#5043) --- pkg/tcl/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tcl/README.md b/pkg/tcl/README.md index 19ed9a40b45..ccc5589ecb1 100644 --- a/pkg/tcl/README.md +++ b/pkg/tcl/README.md @@ -4,4 +4,4 @@ This folder contains special code with the Testkube Community license. ## License -The code in this folder is licensed under the Testkube Community License. Please see the [LICENSE](../../licenses/TCL.txt) file and consult the [FAQ](../../docs/docs/articles/testkube-licensing-FAQ.md) for more information. +The code in this folder is licensed under the Testkube Community License. Please see the [LICENSE](../../licenses/TCL.txt) file for more information. From 24cc463b1513f7065305dd8c771fddfa38885b43 Mon Sep 17 00:00:00 2001 From: Ale <93217218+alelthomas@users.noreply.github.com> Date: Wed, 21 Feb 2024 15:43:01 -0500 Subject: [PATCH 111/234] docs: add status page example to docs (#5044) --- docs/docs/testkube-pro/articles/status-pages.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/testkube-pro/articles/status-pages.md b/docs/docs/testkube-pro/articles/status-pages.md index 19d69913780..065d2ab754d 100644 --- a/docs/docs/testkube-pro/articles/status-pages.md +++ b/docs/docs/testkube-pro/articles/status-pages.md @@ -12,6 +12,8 @@ export const ProBadge = () => { The Testkube status pages are designed to help both technical and non-technical users understand and utilize the results of tests run on Testkube effectively. Whether you're a developer, project manager, or simply a stakeholder interested in monitoring software project status via running tests, Testkube has you covered. +You can see a live example of a Status Page [here](https://app.testkube.io/status/testkube). + ## Overview ![status-page-main](../../img/status-page-main.png) From b9233fc93ad6664705a72d5b5cd2113d2e206292 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Thu, 22 Feb 2024 02:36:23 +0100 Subject: [PATCH 112/234] feat: executor tests - container executor for jmeter and soapui, playwright artifacts fixed, run script updated (#5045) * executor tests - container executor - soapui * run script extended - container-soapui-smoke * executor tests - container executor - soapui - artifacts * executor tests - container executor - jmeter * executor tests - container executor - jmeter - command fixed, soapui artifacts commented out * executor tests - cypress container - video artifacts only, playwright container - playwright-report artifacts fixed * empty lines added * empty lines added --- .../executor-smoke/crd/cypress.yaml | 35 +++++++++++++++++++ .../executor-smoke/crd/jmeter.yaml | 26 ++++++++++++++ .../executor-smoke/crd/playwright.yaml | 10 ++++++ .../executor-smoke/crd/soapui.yaml | 28 +++++++++++++++ test/executors/container-executor-jmeter.yaml | 13 +++++++ .../container-executor-playwright.yaml | 4 +-- test/executors/container-executor-soapui.yaml | 11 ++++++ test/scripts/executor-tests/run.sh | 26 ++++++++++++++ ...xecutor-container-cypress-smoke-tests.yaml | 3 ++ ...executor-container-jmeter-smoke-tests.yaml | 12 +++++++ ...executor-container-soapui-smoke-tests.yaml | 12 +++++++ 11 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 test/container-executor/executor-smoke/crd/jmeter.yaml create mode 100644 test/container-executor/executor-smoke/crd/soapui.yaml create mode 100644 test/executors/container-executor-jmeter.yaml create mode 100644 test/executors/container-executor-soapui.yaml create mode 100644 test/suites/executor-container-jmeter-smoke-tests.yaml create mode 100644 test/suites/executor-container-soapui-smoke-tests.yaml diff --git a/test/container-executor/executor-smoke/crd/cypress.yaml b/test/container-executor/executor-smoke/crd/cypress.yaml index c48a40f58c8..fb7cfbde557 100644 --- a/test/container-executor/executor-smoke/crd/cypress.yaml +++ b/test/container-executor/executor-smoke/crd/cypress.yaml @@ -67,3 +67,38 @@ spec: - ./ jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 2Gi\n cpu: 2\n" activeDeadlineSeconds: 600 +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: container-executor-cypress-v12.7.0-video-artifacts-only + labels: + core-tests: executors +spec: + type: container-executor-cypress-v12.7.0/test + content: + type: git-dir + repository: + type: git-dir + uri: https://github.com/kubeshop/testkube + branch: main + path: test/cypress/executor-tests/cypress-12 + workingDir: test/cypress/executor-tests/cypress-12 + executionRequest: + variables: + CYPRESS_CUSTOM_ENV: + name: CYPRESS_CUSTOM_ENV + value: CYPRESS_CUSTOM_ENV_value + type: basic + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - '{"screenshotsFolder":"/data/artifacts/screenshots","videosFolder":"/data/artifacts/videos"}' + artifactRequest: + storageClassName: standard + volumeMountPath: /data/artifacts/videos + dirs: + - ./ + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 2Gi\n cpu: 2\n" + activeDeadlineSeconds: 600 diff --git a/test/container-executor/executor-smoke/crd/jmeter.yaml b/test/container-executor/executor-smoke/crd/jmeter.yaml new file mode 100644 index 00000000000..34f658eca75 --- /dev/null +++ b/test/container-executor/executor-smoke/crd/jmeter.yaml @@ -0,0 +1,26 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: container-executor-jmeter-smoke + labels: + core-tests: executors +spec: + type: container-executor-jmeter-5.5/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + workingDir: test/jmeter/executor-tests + executionRequest: + executePostRunScriptBeforeScraping: true + postRunScript: "echo 'post-run script' && cd /data/artifacts && ls -lah" + args: + - "-n -t jmeter-executor-smoke.jmx -j /data/artifacts/jmeter.log -o /data/artifacts/report -l /data/artifacts/jtl-report.jtl -e" + artifactRequest: + storageClassName: standard + volumeMountPath: /data/artifacts + dirs: + - ./ diff --git a/test/container-executor/executor-smoke/crd/playwright.yaml b/test/container-executor/executor-smoke/crd/playwright.yaml index 79287346717..d7098cd0d6e 100644 --- a/test/container-executor/executor-smoke/crd/playwright.yaml +++ b/test/container-executor/executor-smoke/crd/playwright.yaml @@ -25,6 +25,11 @@ spec: preRunScript: "npm ci" args: - "tests/smoke2.spec.js" + variables: + PLAYWRIGHT_HTML_REPORT: + name: PLAYWRIGHT_HTML_REPORT + value: "/data/artifacts/playwright-report" + type: basic --- apiVersion: tests.testkube.io/v3 kind: Test @@ -50,3 +55,8 @@ spec: - ./ jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 2Gi\n cpu: 2\n" activeDeadlineSeconds: 600 + variables: + PLAYWRIGHT_HTML_REPORT: + name: PLAYWRIGHT_HTML_REPORT + value: "/data/artifacts/playwright-report" + type: basic diff --git a/test/container-executor/executor-smoke/crd/soapui.yaml b/test/container-executor/executor-smoke/crd/soapui.yaml new file mode 100644 index 00000000000..3a06247269c --- /dev/null +++ b/test/container-executor/executor-smoke/crd/soapui.yaml @@ -0,0 +1,28 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: container-executor-soapui-smoke + labels: + core-tests: executors +spec: + type: container-executor-soapui-5.7/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube + branch: main + path: test/soapui/executor-smoke/soapui-smoke-test.xml + executionRequest: + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 256Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 + variables: + COMMAND_LINE: + name: COMMAND_LINE + value: "-r -f /reports -a -j /data/repo/test/soapui/executor-smoke/soapui-smoke-incorrect-name.xml" + type: basic + # artifactRequest: # TODO: temporary disabled - not working for some reason + # storageClassName: standard + # volumeMountPath: /artifacts + # dirs: + # - ./ diff --git a/test/executors/container-executor-jmeter.yaml b/test/executors/container-executor-jmeter.yaml new file mode 100644 index 00000000000..92aadf31594 --- /dev/null +++ b/test/executors/container-executor-jmeter.yaml @@ -0,0 +1,13 @@ +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: container-executor-jmeter-5.5 +spec: + image: justb4/jmeter:5.5 + command: + - "jmeter" + executor_type: container + types: + - container-executor-jmeter-5.5/test + features: + - artifacts diff --git a/test/executors/container-executor-playwright.yaml b/test/executors/container-executor-playwright.yaml index 481b9baf340..a6b1c6e7ec7 100644 --- a/test/executors/container-executor-playwright.yaml +++ b/test/executors/container-executor-playwright.yaml @@ -4,7 +4,7 @@ metadata: name: container-executor-playwright-v1.32.3-args spec: image: mcr.microsoft.com/playwright:v1.32.3-focal - command: ["npx", "--yes", "playwright@1.32.3", "test", "--output", "/data/artifacts"] + command: ["npx", "--yes", "playwright@1.32.3", "test", "--output", "/data/artifacts/playwright-results"] executor_type: container types: - container-executor-playwright-v1.32.3-args/test @@ -19,7 +19,7 @@ spec: image: mcr.microsoft.com/playwright:v1.32.3-focal command: ["/bin/sh", "-c"] args: - - "npm ci && CI=1 npx --yes playwright@1.32.3 test --output /data/artifacts" + - "npm ci && CI=1 npx --yes playwright@1.32.3 test --output /data/artifacts/playwright-results" executor_type: container types: - container-executor-playwright-v1.32.3/test diff --git a/test/executors/container-executor-soapui.yaml b/test/executors/container-executor-soapui.yaml new file mode 100644 index 00000000000..81fbf5f9feb --- /dev/null +++ b/test/executors/container-executor-soapui.yaml @@ -0,0 +1,11 @@ +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: container-executor-soapui-5.7 +spec: + image: smartbear/soapuios-testrunner:5.7.2 + executor_type: container + types: + - container-executor-soapui-5.7/test + features: + - artifacts diff --git a/test/scripts/executor-tests/run.sh b/test/scripts/executor-tests/run.sh index ed5a080d5e6..1b3c7bc046c 100755 --- a/test/scripts/executor-tests/run.sh +++ b/test/scripts/executor-tests/run.sh @@ -153,6 +153,17 @@ container-gradle-smoke() { common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" } +container-jmeter-smoke() { + name="Container executor - JMeter" + test_crd_file="test/container-executor/executor-smoke/crd/jmeter.yaml" + testsuite_name="executor-container-jmeter-smoke-tests" + testsuite_file="test/suites/executor-container-jmeter-smoke-tests.yaml" + + custom_executor_crd_file="test/executors/container-executor-jmeter.yaml" + + common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" +} + container-k6-smoke() { name="Container executor - K6" test_crd_file="test/container-executor/executor-smoke/crd/k6.yaml" @@ -197,6 +208,17 @@ container-postman-smoke() { common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" } +container-soapui-smoke() { + name="Container executor - SoapUI" + test_crd_file="test/container-executor/executor-smoke/crd/soapui.yaml" + testsuite_name="executor-container-soapui-smoke-tests" + testsuite_file="test/suites/executor-container-soapui-smoke-tests.yaml" + + custom_executor_crd_file="test/executors/container-executor-soapui.yaml" + + common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" "$custom_executor_crd_file" +} + curl-smoke() { name="curl" test_crd_file="test/curl/executor-tests/crd/smoke.yaml" @@ -367,10 +389,12 @@ main() { container-curl-smoke container-cypress-smoke container-gradle-smoke + container-jmeter-smoke container-k6-smoke container-maven-smoke container-postman-smoke container-playwright-smoke + container-soapui-smoke curl-smoke cypress-smoke ginkgo-smoke @@ -390,10 +414,12 @@ main() { container-curl-smoke container-cypress-smoke container-gradle-smoke + container-jmeter-smoke container-k6-smoke container-maven-smoke container-postman-smoke container-playwright-smoke + container-soapui-smoke curl-smoke cypress-smoke ginkgo-smoke diff --git a/test/suites/executor-container-cypress-smoke-tests.yaml b/test/suites/executor-container-cypress-smoke-tests.yaml index 950c7abb34b..9ddbe173422 100644 --- a/test/suites/executor-container-cypress-smoke-tests.yaml +++ b/test/suites/executor-container-cypress-smoke-tests.yaml @@ -13,3 +13,6 @@ spec: - stopOnFailure: false execute: - test: container-executor-cypress-v12.7.0-smoke-git-dir + - stopOnFailure: false + execute: + - test: container-executor-cypress-v12.7.0-video-artifacts-only diff --git a/test/suites/executor-container-jmeter-smoke-tests.yaml b/test/suites/executor-container-jmeter-smoke-tests.yaml new file mode 100644 index 00000000000..8ac7b8cd6a1 --- /dev/null +++ b/test/suites/executor-container-jmeter-smoke-tests.yaml @@ -0,0 +1,12 @@ +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: executor-container-jmeter-smoke-tests + labels: + app: testkube +spec: + description: "container executor jmeter smoke tests" + steps: + - stopOnFailure: false + execute: + - test: container-executor-jmeter-smoke diff --git a/test/suites/executor-container-soapui-smoke-tests.yaml b/test/suites/executor-container-soapui-smoke-tests.yaml new file mode 100644 index 00000000000..d8ad9936b1e --- /dev/null +++ b/test/suites/executor-container-soapui-smoke-tests.yaml @@ -0,0 +1,12 @@ +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: executor-container-soapui-smoke-tests + labels: + app: testkube +spec: + description: "container executor soapui smoke tests" + steps: + - stopOnFailure: false + execute: + - test: container-executor-soapui-smoke From a3cb43d837262a984038801fac777b0d4d83608a Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 22 Feb 2024 08:44:56 +0100 Subject: [PATCH 113/234] feat(TKC-1458): add API for managing TestWorkflows (#5041) * feat(TKC-1458): add boilerplate for the TCL-licensed API server endpoints * feat(TKC-1458): add API for managing TestWorkflows/TestWorkflowTemplates * feat(TKC-1458): add API Client methods for managing the TestWorkflows/TestWorkflowTemplates * feat(TKC-1458): check ProContext for detecting Pro functionality in TCL API * feat(TKC-1458): add OpenAPI definition for TestWorkflow endpoints * feat(TKC-1458): add endpoints to delete TestWorkflows/TestWorkflowTemplates by labels * feat(TKC-1458): adjust TestWorkflowTemplate client to reformat input template name --- api/v1/testkube.yaml | 539 ++++++++++++++++++ cmd/api-server/main.go | 11 +- internal/app/api/metrics/metrics.go | 156 ++++- internal/common/crd.go | 126 ++++ internal/common/crd_test.go | 224 ++++++++ pkg/api/v1/client/api.go | 44 +- pkg/api/v1/client/interface.go | 25 +- pkg/api/v1/client/testworkflow.go | 80 +++ pkg/api/v1/client/testworkflowtemplate.go | 84 +++ .../testkube/model_test_workflow_extended.go | 3 + .../model_test_workflow_template_extended.go | 3 + pkg/tcl/apitcl/v1/pro.go | 45 ++ pkg/tcl/apitcl/v1/server.go | 93 +++ pkg/tcl/apitcl/v1/testworkflows.go | 205 +++++++ pkg/tcl/apitcl/v1/testworkflowtemplates.go | 188 ++++++ pkg/tcl/apitcl/v1/utils.go | 79 +++ .../tcl}/workflowstcl/mappers/kube_openapi.go | 0 .../tcl}/workflowstcl/mappers/mappers_test.go | 0 .../tcl}/workflowstcl/mappers/openapi_kube.go | 0 19 files changed, 1859 insertions(+), 46 deletions(-) create mode 100644 internal/common/crd.go create mode 100644 internal/common/crd_test.go create mode 100644 pkg/api/v1/client/testworkflow.go create mode 100644 pkg/api/v1/client/testworkflowtemplate.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_template_extended.go create mode 100644 pkg/tcl/apitcl/v1/pro.go create mode 100644 pkg/tcl/apitcl/v1/server.go create mode 100644 pkg/tcl/apitcl/v1/testworkflows.go create mode 100644 pkg/tcl/apitcl/v1/testworkflowtemplates.go create mode 100644 pkg/tcl/apitcl/v1/utils.go rename {tcl => pkg/tcl}/workflowstcl/mappers/kube_openapi.go (100%) rename {tcl => pkg/tcl}/workflowstcl/mappers/mappers_test.go (100%) rename {tcl => pkg/tcl}/workflowstcl/mappers/openapi_kube.go (100%) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 07088f2462d..fb0e67940f3 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3289,6 +3289,545 @@ paths: items: $ref: "#/components/schemas/Problem" + /test-workflows: + get: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/Selector" + summary: List test workflows + description: List test workflows from the kubernetes cluster + operationId: listTestWorkflows + responses: + 200: + description: successful list operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + delete: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/Selector" + summary: Delete test workflows + description: Delete test workflows from the kubernetes cluster + operationId: deleteTestWorkflows + responses: + 204: + description: no content + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + post: + tags: + - test-workflows + - api + summary: Create test workflow + description: Create test workflow in the kubernetes cluster + operationId: createTestWorkflow + requestBody: + description: test workflow body + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + responses: + 200: + description: successful creation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + 400: + description: "problem with body parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflows/{id}: + get: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/ID" + summary: Get test workflow details + description: Get test workflow details from the kubernetes cluster + operationId: getTestWorkflow + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + put: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/ID" + summary: Update test workflow details + description: Update test workflow details in the kubernetes cluster + operationId: updateTestWorkflow + requestBody: + description: test workflow body + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + delete: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/ID" + - $ref: "#/components/parameters/SkipDeleteExecutions" + summary: Delete test workflow + description: Delete test workflow from the kubernetes cluster + operationId: deleteTestWorkflow + responses: + 204: + description: no content + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + + /test-workflow-templates: + get: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/Selector" + summary: List test workflow templates + description: List test workflow templates from the kubernetes cluster + operationId: listTestWorkflowTemplates + responses: + 200: + description: successful list operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflowTemplate" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + delete: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/Selector" + summary: Delete test workflow templates + description: Delete test workflow templates from the kubernetes cluster + operationId: deleteTestWorkflowTemplates + responses: + 204: + description: no content + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + post: + tags: + - test-workflows + - api + summary: Create test workflow template + description: Create test workflow template in the kubernetes cluster + operationId: createTestWorkflowTemplate + requestBody: + description: test workflow template body + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflowTemplate" + text/yaml: + schema: + type: string + responses: + 200: + description: successful creation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflowTemplate" + text/yaml: + schema: + type: string + 400: + description: "problem with body parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflow-templates/{id}: + get: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/ID" + summary: Get test workflow template details + description: Get test workflow template details from the kubernetes cluster + operationId: getTestWorkflowTemplate + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflowTemplate" + text/yaml: + schema: + type: string + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + put: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/ID" + summary: Update test workflow template details + description: Update test workflow template details in the kubernetes cluster + operationId: updateTestWorkflow + requestBody: + description: test workflow template body + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflowTemplate" + text/yaml: + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflowTemplate" + text/yaml: + schema: + type: string + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + delete: + tags: + - test-workflows + - api + parameters: + - $ref: "#/components/parameters/ID" + summary: Delete test workflow template + description: Delete test workflow template from the kubernetes cluster + operationId: deleteTestWorkflowTemplate + responses: + 204: + description: no content + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + components: schemas: ExecutionsMetrics: diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index a05d342340d..7d50f058de6 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -16,6 +16,7 @@ import ( executorsclientv1 "github.com/kubeshop/testkube-operator/pkg/client/executors/v1" "github.com/kubeshop/testkube/pkg/imageinspector" + apitclv1 "github.com/kubeshop/testkube/pkg/tcl/apitcl/v1" "go.mongodb.org/mongo-driver/mongo" "google.golang.org/grpc" @@ -523,9 +524,10 @@ func main() { cfg.DisableSecretCreation, ) + var proContext *config.ProContext if mode == common.ModeAgent { log.DefaultLogger.Info("starting agent service") - proContext := config.ProContext{ + proContext = &config.ProContext{ APIKey: cfg.TestkubeProAPIKey, URL: cfg.TestkubeProURL, LogsPath: cfg.TestkubeProLogsPath, @@ -539,7 +541,7 @@ func main() { ConnectionTimeout: cfg.TestkubeProConnectionTimeout, } - api.WithProContext(&proContext) + api.WithProContext(proContext) agentHandle, err := agent.NewAgent( log.DefaultLogger, @@ -550,7 +552,7 @@ func main() { cfg.TestkubeClusterName, envs, features, - proContext, + *proContext, ) if err != nil { ui.ExitOnError("Starting agent", err) @@ -565,6 +567,9 @@ func main() { eventsEmitter.Loader.Register(agentHandle) } + // Apply Pro server enhancements + apitclv1.NewApiTCL(api, proContext, kubeClient).AppendRoutes() + api.InitEvents() if !cfg.DisableTestTriggers { diff --git a/internal/app/api/metrics/metrics.go b/internal/app/api/metrics/metrics.go index 977de8c6d18..e29365a9a93 100644 --- a/internal/app/api/metrics/metrics.go +++ b/internal/app/api/metrics/metrics.go @@ -71,36 +71,78 @@ var testAbortCount = promauto.NewCounterVec(prometheus.CounterOpts{ Help: "The total number of tests aborted by type events", }, []string{"type", "result"}) +var testWorkflowCreationCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "testkube_testworkflow_creations_count", + Help: "The total number of test workflow created by type events", +}, []string{"result"}) + +var testWorkflowUpdatesCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "testkube_testworkflow_updates_count", + Help: "The total number of test workflow updated by type events", +}, []string{"result"}) + +var testWorkflowDeletesCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "testkube_testworkflow_deletes_count", + Help: "The total number of test workflow deleted events", +}, []string{"result"}) + +var testWorkflowTemplateCreationCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "testkube_testworkflowtemplate_creations_count", + Help: "The total number of test workflow template created by type events", +}, []string{"result"}) + +var testWorkflowTemplateUpdatesCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "testkube_testworkflowtemplate_updates_count", + Help: "The total number of test workflow template updated by type events", +}, []string{"result"}) + +var testWorkflowTemplateDeletesCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "testkube_testworkflowtemplate_deletes_count", + Help: "The total number of test workflow template deleted events", +}, []string{"result"}) + func NewMetrics() Metrics { return Metrics{ - TestExecutions: testExecutionCount, - TestSuiteExecutions: testSuiteExecutionCount, - TestCreations: testCreationCount, - TestSuiteCreations: testSuiteCreationCount, - TestUpdates: testUpdatesCount, - TestSuiteUpdates: testSuiteUpdatesCount, - TestTriggerCreations: testTriggerCreationCount, - TestTriggerUpdates: testTriggerUpdatesCount, - TestTriggerDeletes: testTriggerDeletesCount, - TestTriggerBulkUpdates: testTriggerBulkUpdatesCount, - TestTriggerBulkDeletes: testTriggerBulkDeletesCount, - TestAbort: testAbortCount, + TestExecutions: testExecutionCount, + TestSuiteExecutions: testSuiteExecutionCount, + TestCreations: testCreationCount, + TestSuiteCreations: testSuiteCreationCount, + TestUpdates: testUpdatesCount, + TestSuiteUpdates: testSuiteUpdatesCount, + TestTriggerCreations: testTriggerCreationCount, + TestTriggerUpdates: testTriggerUpdatesCount, + TestTriggerDeletes: testTriggerDeletesCount, + TestTriggerBulkUpdates: testTriggerBulkUpdatesCount, + TestTriggerBulkDeletes: testTriggerBulkDeletesCount, + TestAbort: testAbortCount, + TestWorkflowCreations: testWorkflowCreationCount, + TestWorkflowUpdates: testWorkflowUpdatesCount, + TestWorkflowDeletes: testWorkflowDeletesCount, + TestWorkflowTemplateCreations: testWorkflowTemplateCreationCount, + TestWorkflowTemplateUpdates: testWorkflowTemplateUpdatesCount, + TestWorkflowTemplateDeletes: testWorkflowTemplateDeletesCount, } } type Metrics struct { - TestExecutions *prometheus.CounterVec - TestSuiteExecutions *prometheus.CounterVec - TestCreations *prometheus.CounterVec - TestSuiteCreations *prometheus.CounterVec - TestUpdates *prometheus.CounterVec - TestSuiteUpdates *prometheus.CounterVec - TestTriggerCreations *prometheus.CounterVec - TestTriggerUpdates *prometheus.CounterVec - TestTriggerDeletes *prometheus.CounterVec - TestTriggerBulkUpdates *prometheus.CounterVec - TestTriggerBulkDeletes *prometheus.CounterVec - TestAbort *prometheus.CounterVec + TestExecutions *prometheus.CounterVec + TestSuiteExecutions *prometheus.CounterVec + TestCreations *prometheus.CounterVec + TestSuiteCreations *prometheus.CounterVec + TestUpdates *prometheus.CounterVec + TestSuiteUpdates *prometheus.CounterVec + TestTriggerCreations *prometheus.CounterVec + TestTriggerUpdates *prometheus.CounterVec + TestTriggerDeletes *prometheus.CounterVec + TestTriggerBulkUpdates *prometheus.CounterVec + TestTriggerBulkDeletes *prometheus.CounterVec + TestAbort *prometheus.CounterVec + TestWorkflowCreations *prometheus.CounterVec + TestWorkflowUpdates *prometheus.CounterVec + TestWorkflowDeletes *prometheus.CounterVec + TestWorkflowTemplateCreations *prometheus.CounterVec + TestWorkflowTemplateUpdates *prometheus.CounterVec + TestWorkflowTemplateDeletes *prometheus.CounterVec } func (m Metrics) IncExecuteTest(execution testkube.Execution, dashboardURI string) { @@ -266,3 +308,69 @@ func (m Metrics) IncAbortTest(testType string, failed bool) { "result": result, }).Inc() } + +func (m Metrics) IncCreateTestWorkflow(err error) { + result := "created" + if err != nil { + result = "error" + } + + m.TestWorkflowCreations.With(map[string]string{ + "result": result, + }).Inc() +} + +func (m Metrics) IncUpdateTestWorkflow(err error) { + result := "updated" + if err != nil { + result = "error" + } + + m.TestWorkflowUpdates.With(map[string]string{ + "result": result, + }).Inc() +} + +func (m Metrics) IncDeleteTestWorkflow(err error) { + result := "deleted" + if err != nil { + result = "error" + } + + m.TestWorkflowDeletes.With(map[string]string{ + "result": result, + }).Inc() +} + +func (m Metrics) IncCreateTestWorkflowTemplate(err error) { + result := "created" + if err != nil { + result = "error" + } + + m.TestWorkflowTemplateCreations.With(map[string]string{ + "result": result, + }).Inc() +} + +func (m Metrics) IncUpdateTestWorkflowTemplate(err error) { + result := "updated" + if err != nil { + result = "error" + } + + m.TestWorkflowTemplateUpdates.With(map[string]string{ + "result": result, + }).Inc() +} + +func (m Metrics) IncDeleteTestWorkflowTemplate(err error) { + result := "deleted" + if err != nil { + result = "error" + } + + m.TestWorkflowTemplateDeletes.With(map[string]string{ + "result": result, + }).Inc() +} diff --git a/internal/common/crd.go b/internal/common/crd.go new file mode 100644 index 00000000000..ed2ff77fcca --- /dev/null +++ b/internal/common/crd.go @@ -0,0 +1,126 @@ +package common + +import ( + "encoding/json" + "reflect" + "regexp" + + "gopkg.in/yaml.v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" +) + +type SerializeOptions struct { + OmitCreationTimestamp bool + CleanMeta bool + Kind string + GroupVersion *schema.GroupVersion +} + +type ObjectWithTypeMeta interface { + SetGroupVersionKind(schema.GroupVersionKind) +} + +func AppendTypeMeta(kind string, version schema.GroupVersion, crs ...ObjectWithTypeMeta) { + for _, cr := range crs { + cr.SetGroupVersionKind(schema.GroupVersionKind{ + Group: version.Group, + Version: version.Version, + Kind: kind, + }) + } +} + +func CleanObjectMeta(crs ...metav1.Object) { + for _, cr := range crs { + cr.SetGeneration(0) + cr.SetResourceVersion("") + cr.SetSelfLink("") + cr.SetUID("") + cr.SetFinalizers(nil) + cr.SetOwnerReferences(nil) + cr.SetManagedFields(nil) + + annotations := cr.GetAnnotations() + delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") + cr.SetAnnotations(annotations) + } +} + +var creationTsNullRegex = regexp.MustCompile(`\n\s+creationTimestamp: null`) +var creationTsRegex = regexp.MustCompile(`\n\s+creationTimestamp:[^\n]*`) + +func SerializeCRD(cr interface{}, opts SerializeOptions) ([]byte, error) { + if opts.CleanMeta || (opts.Kind != "" && opts.GroupVersion != nil) { + // For simplicity, support both direct struct (as in *List.Items), as well as the pointer itself + if reflect.ValueOf(cr).Kind() == reflect.Struct { + v := reflect.ValueOf(cr) + p := reflect.New(v.Type()) + p.Elem().Set(v) + cr = p.Interface() + } + + // Deep copy object, as it will have modifications + switch cr.(type) { + case runtime.Object: + cr = cr.(runtime.Object).DeepCopyObject() + } + + // Clean messy metadata + if opts.CleanMeta { + if v, ok := cr.(metav1.Object); ok { + CleanObjectMeta(v) + cr = v + } + } + + // Append metadata when expected + if opts.Kind != "" && opts.GroupVersion != nil { + if v, ok := cr.(ObjectWithTypeMeta); ok { + AppendTypeMeta(opts.Kind, *opts.GroupVersion, v) + cr = v + } + } + } + + out, err := json.Marshal(cr) + if err != nil { + return nil, err + } + m := yaml.MapSlice{} + _ = yaml.Unmarshal(out, &m) + b, _ := yaml.Marshal(m) + if opts.OmitCreationTimestamp { + b = creationTsRegex.ReplaceAll(b, nil) + } else { + b = creationTsNullRegex.ReplaceAll(b, nil) + } + return b, err +} + +var crdSeparator = []byte("---\n") + +// SerializeCRDs builds a serialized version of CRD, +// persisting the order of properties from the struct. +func SerializeCRDs[T interface{}](crs []T, opts SerializeOptions) ([]byte, error) { + result := []byte(nil) + for _, cr := range crs { + b, err := SerializeCRD(cr, opts) + if err != nil { + return nil, err + } + if len(result) > 0 { + result = append(append(result, crdSeparator...), b...) + } else { + result = b + } + } + return result, nil +} + +func DeserializeCRD(cr runtime.Object, content []byte) error { + _, _, err := scheme.Codecs.UniversalDeserializer().Decode(content, nil, cr) + return err +} diff --git a/internal/common/crd_test.go b/internal/common/crd_test.go new file mode 100644 index 00000000000..5d3e27d1ac3 --- /dev/null +++ b/internal/common/crd_test.go @@ -0,0 +1,224 @@ +package common + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" +) + +var ( + time1 = time.Now().UTC() + testBare = testsv3.Test{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: testsv3.TestSpec{ + Description: "some-description", + }, + } + testWithCreationTimestamp = testsv3.Test{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + CreationTimestamp: metav1.Time{Time: time1}, + }, + Spec: testsv3.TestSpec{ + Description: "some-description", + }, + } + testWrongOrder = testsv3.Test{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + // Use keys that are not alphabetically ordered + Spec: testsv3.TestSpec{ + Schedule: "abc", + Name: "example-name", + Description: "some-description", + }, + } + testMessyData = testsv3.Test{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + ManagedFields: []metav1.ManagedFieldsEntry{ + { + Manager: "some-manager", + Operation: "some-operation", + APIVersion: "v1", + FieldsType: "blah", + Subresource: "meh", + }, + }, + }, + // Use keys that are not alphabetically ordered + Spec: testsv3.TestSpec{ + Description: "some-description", + }, + } +) + +func TestSerializeCRDNoMutations(t *testing.T) { + value := testBare.DeepCopy() + _, _ = SerializeCRD(value, SerializeOptions{ + CleanMeta: true, + OmitCreationTimestamp: true, + Kind: "Test", + GroupVersion: &testsv3.GroupVersion, + }) + + assert.Equal(t, value.TypeMeta, testBare.TypeMeta) + assert.Equal(t, value.ObjectMeta, testBare.ObjectMeta) +} + +func TestSerializeCRD(t *testing.T) { + b, err := SerializeCRD(testBare.DeepCopy(), SerializeOptions{}) + b2, err2 := SerializeCRD(testWithCreationTimestamp.DeepCopy(), SerializeOptions{OmitCreationTimestamp: true}) + want := strings.TrimSpace(` +metadata: + name: test-name +spec: + description: some-description +status: {} +`) + assert.NoError(t, err) + assert.Equal(t, want+"\n", string(b)) + assert.NoError(t, err2) + assert.Equal(t, want+"\n", string(b2)) +} + +func TestSerializeCRDWithCreationTimestamp(t *testing.T) { + b, err := SerializeCRD(testWithCreationTimestamp.DeepCopy(), SerializeOptions{}) + want := strings.TrimSpace(` +metadata: + name: test-name + creationTimestamp: "%s" +spec: + description: some-description +status: {} +`) + want = fmt.Sprintf(want, time1.Format(time.RFC3339)) + assert.NoError(t, err) + assert.Equal(t, want+"\n", string(b)) +} + +func TestSerializeCRDWithMessyData(t *testing.T) { + b, err := SerializeCRD(testMessyData.DeepCopy(), SerializeOptions{}) + b2, err2 := SerializeCRD(testMessyData.DeepCopy(), SerializeOptions{CleanMeta: true}) + want := strings.TrimSpace(` +metadata: + name: test-name + managedFields: + - manager: some-manager + operation: some-operation + apiVersion: v1 + fieldsType: blah + subresource: meh +spec: + description: some-description +status: {} +`) + want2 := strings.TrimSpace(` +metadata: + name: test-name +spec: + description: some-description +status: {} +`) + assert.NoError(t, err) + assert.Equal(t, want+"\n", string(b)) + assert.NoError(t, err2) + assert.Equal(t, want2+"\n", string(b2)) +} + +func TestSerializeCRDKeepOrder(t *testing.T) { + b, err := SerializeCRD(*testWrongOrder.DeepCopy(), SerializeOptions{}) + want := strings.TrimSpace(` +metadata: + name: test-name +spec: + name: example-name + description: some-description + schedule: abc +status: {} +`) + assert.NoError(t, err) + assert.Equal(t, want+"\n", string(b)) +} + +func TestSerializeCRDs(t *testing.T) { + b, err := SerializeCRDs([]testsv3.Test{ + *testWrongOrder.DeepCopy(), + *testBare.DeepCopy(), + }, SerializeOptions{}) + want := strings.TrimSpace(` +metadata: + name: test-name +spec: + name: example-name + description: some-description + schedule: abc +status: {} +--- +metadata: + name: test-name +spec: + description: some-description +status: {} +`) + assert.NoError(t, err) + assert.Equal(t, want+"\n", string(b)) +} + +func TestSerializeCRDsFullCleanup(t *testing.T) { + list := testsv3.TestList{ + Items: []testsv3.Test{ + *testWrongOrder.DeepCopy(), + *testBare.DeepCopy(), + *testWithCreationTimestamp.DeepCopy(), + }, + } + b, err := SerializeCRDs(list.Items, SerializeOptions{ + CleanMeta: true, + OmitCreationTimestamp: true, + Kind: "Test", + GroupVersion: &testsv3.GroupVersion, + }) + want := strings.TrimSpace(` +kind: Test +apiVersion: tests.testkube.io/v3 +metadata: + name: test-name +spec: + name: example-name + description: some-description + schedule: abc +status: {} +--- +kind: Test +apiVersion: tests.testkube.io/v3 +metadata: + name: test-name +spec: + description: some-description +status: {} +--- +kind: Test +apiVersion: tests.testkube.io/v3 +metadata: + name: test-name +spec: + description: some-description +status: {} +`) + assert.NoError(t, err) + assert.Equal(t, want+"\n", string(b)) +} diff --git a/pkg/api/v1/client/api.go b/pkg/api/v1/client/api.go index 73ed3ba089b..0e45568f3ca 100644 --- a/pkg/api/v1/client/api.go +++ b/pkg/api/v1/client/api.go @@ -32,12 +32,14 @@ func NewProxyAPIClient(client kubernetes.Interface, config APIConfig) APIClient NewProxyClient[testkube.TestSuiteExecutionsResult](client, config), NewProxyClient[testkube.Artifact](client, config), ), - ExecutorClient: NewExecutorClient(NewProxyClient[testkube.ExecutorDetails](client, config)), - WebhookClient: NewWebhookClient(NewProxyClient[testkube.Webhook](client, config)), - ConfigClient: NewConfigClient(NewProxyClient[testkube.Config](client, config)), - TestSourceClient: NewTestSourceClient(NewProxyClient[testkube.TestSource](client, config)), - CopyFileClient: NewCopyFileProxyClient(client, config), - TemplateClient: NewTemplateClient(NewProxyClient[testkube.Template](client, config)), + ExecutorClient: NewExecutorClient(NewProxyClient[testkube.ExecutorDetails](client, config)), + WebhookClient: NewWebhookClient(NewProxyClient[testkube.Webhook](client, config)), + ConfigClient: NewConfigClient(NewProxyClient[testkube.Config](client, config)), + TestSourceClient: NewTestSourceClient(NewProxyClient[testkube.TestSource](client, config)), + CopyFileClient: NewCopyFileProxyClient(client, config), + TemplateClient: NewTemplateClient(NewProxyClient[testkube.Template](client, config)), + TestWorkflowClient: NewTestWorkflowClient(NewProxyClient[testkube.TestWorkflow](client, config)), + TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewProxyClient[testkube.TestWorkflowTemplate](client, config)), } } @@ -62,12 +64,14 @@ func NewDirectAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, NewDirectClient[testkube.TestSuiteExecutionsResult](httpClient, apiURI, apiPathPrefix), NewDirectClient[testkube.Artifact](httpClient, apiURI, apiPathPrefix), ), - ExecutorClient: NewExecutorClient(NewDirectClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), - WebhookClient: NewWebhookClient(NewDirectClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), - ConfigClient: NewConfigClient(NewDirectClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), - TestSourceClient: NewTestSourceClient(NewDirectClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), - CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), - TemplateClient: NewTemplateClient(NewDirectClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), + ExecutorClient: NewExecutorClient(NewDirectClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), + WebhookClient: NewWebhookClient(NewDirectClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), + ConfigClient: NewConfigClient(NewDirectClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), + TestSourceClient: NewTestSourceClient(NewDirectClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), + CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), + TemplateClient: NewTemplateClient(NewDirectClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), + TestWorkflowClient: NewTestWorkflowClient(NewDirectClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix)), + TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewDirectClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } } @@ -92,12 +96,14 @@ func NewCloudAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, NewCloudClient[testkube.TestSuiteExecutionsResult](httpClient, apiURI, apiPathPrefix), NewCloudClient[testkube.Artifact](httpClient, apiURI, apiPathPrefix), ), - ExecutorClient: NewExecutorClient(NewCloudClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), - WebhookClient: NewWebhookClient(NewCloudClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), - ConfigClient: NewConfigClient(NewCloudClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), - TestSourceClient: NewTestSourceClient(NewCloudClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), - CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), - TemplateClient: NewTemplateClient(NewCloudClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), + ExecutorClient: NewExecutorClient(NewCloudClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), + WebhookClient: NewWebhookClient(NewCloudClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), + ConfigClient: NewConfigClient(NewCloudClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), + TestSourceClient: NewTestSourceClient(NewCloudClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), + CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), + TemplateClient: NewTemplateClient(NewCloudClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), + TestWorkflowClient: NewTestWorkflowClient(NewCloudClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix)), + TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewCloudClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } } @@ -111,4 +117,6 @@ type APIClient struct { TestSourceClient CopyFileClient TemplateClient + TestWorkflowClient + TestWorkflowTemplateClient } diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index a654615fdc9..712ae554c99 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -21,6 +21,8 @@ type Client interface { TestSourceAPI CopyFileAPI TemplateAPI + TestWorkflowAPI + TestWorkflowTemplateAPI } // TestAPI describes test api methods @@ -126,6 +128,26 @@ type TestSourceAPI interface { DeleteTestSources(selector string) (err error) } +// TestWorkflowAPI describes test workflow api methods +type TestWorkflowAPI interface { + GetTestWorkflow(id string) (testkube.TestWorkflow, error) + ListTestWorkflows(selector string) (testkube.TestWorkflows, error) + DeleteTestWorkflows(selector string) error + CreateTestWorkflow(workflow testkube.TestWorkflow) (testkube.TestWorkflow, error) + UpdateTestWorkflow(workflow testkube.TestWorkflow) (testkube.TestWorkflow, error) + DeleteTestWorkflow(name string) error +} + +// TestWorkflowTemplateAPI describes test workflow api methods +type TestWorkflowTemplateAPI interface { + GetTestWorkflowTemplate(id string) (testkube.TestWorkflowTemplate, error) + ListTestWorkflowTemplates(selector string) (testkube.TestWorkflowTemplates, error) + DeleteTestWorkflowTemplates(selector string) error + CreateTestWorkflowTemplate(workflow testkube.TestWorkflowTemplate) (testkube.TestWorkflowTemplate, error) + UpdateTestWorkflowTemplate(workflow testkube.TestWorkflowTemplate) (testkube.TestWorkflowTemplate, error) + DeleteTestWorkflowTemplate(name string) error +} + // CopyFileAPI describes methods to handle files in the object storage type CopyFileAPI interface { UploadFile(parentName string, parentType TestingType, filePath string, fileContent []byte, timeout time.Duration) error @@ -231,7 +253,8 @@ type Gettable interface { testkube.Test | testkube.TestSuite | testkube.ExecutorDetails | testkube.Webhook | testkube.TestWithExecution | testkube.TestSuiteWithExecution | testkube.TestWithExecutionSummary | testkube.TestSuiteWithExecutionSummary | testkube.Artifact | testkube.ServerInfo | testkube.Config | testkube.DebugInfo | - testkube.TestSource | testkube.Template + testkube.TestSource | testkube.Template | + testkube.TestWorkflow | testkube.TestWorkflowTemplate } // Executable is an interface of executable objects diff --git a/pkg/api/v1/client/testworkflow.go b/pkg/api/v1/client/testworkflow.go new file mode 100644 index 00000000000..6541ead170d --- /dev/null +++ b/pkg/api/v1/client/testworkflow.go @@ -0,0 +1,80 @@ +package client + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// NewTestWorkflowClient creates new TestWorkflow client +func NewTestWorkflowClient( + testWorkflowTransport Transport[testkube.TestWorkflow], +) TestWorkflowClient { + return TestWorkflowClient{ + testWorkflowTransport: testWorkflowTransport, + } +} + +// TestWorkflowClient is a client for tests +type TestWorkflowClient struct { + testWorkflowTransport Transport[testkube.TestWorkflow] +} + +// GetTestWorkflow returns single test by id +func (c TestWorkflowClient) GetTestWorkflow(id string) (testkube.TestWorkflow, error) { + uri := c.testWorkflowTransport.GetURI("/test-workflows/%s", id) + return c.testWorkflowTransport.Execute(http.MethodGet, uri, nil, nil) +} + +// ListTestWorkflows list all tests +func (c TestWorkflowClient) ListTestWorkflows(selector string) (testkube.TestWorkflows, error) { + uri := c.testWorkflowTransport.GetURI("/test-workflows") + params := map[string]string{"selector": selector} + return c.testWorkflowTransport.ExecuteMultiple(http.MethodGet, uri, nil, params) +} + +// DeleteTestWorkflows deletes multiple test workflows by labels +func (c TestWorkflowClient) DeleteTestWorkflows(selector string) error { + uri := c.testWorkflowTransport.GetURI("/test-workflows") + return c.testWorkflowTransport.Delete(uri, selector, true) +} + +// CreateTestWorkflow creates new TestWorkflow Custom Resource +func (c TestWorkflowClient) CreateTestWorkflow(workflow testkube.TestWorkflow) (result testkube.TestWorkflow, err error) { + uri := c.testWorkflowTransport.GetURI("/test-workflows") + + body, err := json.Marshal(workflow) + if err != nil { + return result, err + } + + return c.testWorkflowTransport.Execute(http.MethodPost, uri, body, nil) +} + +// UpdateTestWorkflow updates TestWorkflow Custom Resource +func (c TestWorkflowClient) UpdateTestWorkflow(workflow testkube.TestWorkflow) (result testkube.TestWorkflow, err error) { + if workflow.Name == "" { + return result, fmt.Errorf("test workflow name '%s' is not valid", workflow.Name) + } + + uri := c.testWorkflowTransport.GetURI("/test-workflows/%s", workflow.Name) + + body, err := json.Marshal(workflow) + if err != nil { + return result, err + } + + return c.testWorkflowTransport.Execute(http.MethodPut, uri, body, nil) +} + +// DeleteTestWorkflow deletes single test by name +func (c TestWorkflowClient) DeleteTestWorkflow(name string) error { + if name == "" { + return fmt.Errorf("test workflow name '%s' is not valid", name) + } + + uri := c.testWorkflowTransport.GetURI("/test-workflows/%s", name) + return c.testWorkflowTransport.Delete(uri, "", true) +} diff --git a/pkg/api/v1/client/testworkflowtemplate.go b/pkg/api/v1/client/testworkflowtemplate.go new file mode 100644 index 00000000000..3928666c2ed --- /dev/null +++ b/pkg/api/v1/client/testworkflowtemplate.go @@ -0,0 +1,84 @@ +package client + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// NewTestWorkflowTemplateClient creates new TestWorkflowTemplate client +func NewTestWorkflowTemplateClient( + testWorkflowTemplateTransport Transport[testkube.TestWorkflowTemplate], +) TestWorkflowTemplateClient { + return TestWorkflowTemplateClient{ + testWorkflowTemplateTransport: testWorkflowTemplateTransport, + } +} + +// TestWorkflowTemplateClient is a client for tests +type TestWorkflowTemplateClient struct { + testWorkflowTemplateTransport Transport[testkube.TestWorkflowTemplate] +} + +// GetTestWorkflowTemplate returns single test by id +func (c TestWorkflowTemplateClient) GetTestWorkflowTemplate(id string) (testkube.TestWorkflowTemplate, error) { + id = strings.ReplaceAll(id, "/", "--") + uri := c.testWorkflowTemplateTransport.GetURI("/test-workflow-templates/%s", id) + return c.testWorkflowTemplateTransport.Execute(http.MethodGet, uri, nil, nil) +} + +// ListTestWorkflowTemplates list all tests +func (c TestWorkflowTemplateClient) ListTestWorkflowTemplates(selector string) (testkube.TestWorkflowTemplates, error) { + params := map[string]string{"selector": selector} + uri := c.testWorkflowTemplateTransport.GetURI("/test-workflow-templates") + return c.testWorkflowTemplateTransport.ExecuteMultiple(http.MethodGet, uri, nil, params) +} + +// DeleteTestWorkflowTemplates deletes multiple test workflow templates by labels +func (c TestWorkflowTemplateClient) DeleteTestWorkflowTemplates(selector string) error { + uri := c.testWorkflowTemplateTransport.GetURI("/test-workflow-templates") + return c.testWorkflowTemplateTransport.Delete(uri, selector, true) +} + +// CreateTestWorkflowTemplate creates new TestWorkflowTemplate Custom Resource +func (c TestWorkflowTemplateClient) CreateTestWorkflowTemplate(template testkube.TestWorkflowTemplate) (result testkube.TestWorkflowTemplate, err error) { + template.Name = strings.ReplaceAll(template.Name, "/", "--") + + body, err := json.Marshal(template) + if err != nil { + return result, err + } + + uri := c.testWorkflowTemplateTransport.GetURI("/test-workflow-templates") + return c.testWorkflowTemplateTransport.Execute(http.MethodPost, uri, body, nil) +} + +// UpdateTestWorkflowTemplate updates TestWorkflowTemplate Custom Resource +func (c TestWorkflowTemplateClient) UpdateTestWorkflowTemplate(template testkube.TestWorkflowTemplate) (result testkube.TestWorkflowTemplate, err error) { + if template.Name == "" { + return result, fmt.Errorf("test workflow template name '%s' is not valid", template.Name) + } + template.Name = strings.ReplaceAll(template.Name, "/", "--") + + body, err := json.Marshal(template) + if err != nil { + return result, err + } + + uri := c.testWorkflowTemplateTransport.GetURI("/test-workflow-templates/%s", template.Name) + return c.testWorkflowTemplateTransport.Execute(http.MethodPut, uri, body, nil) +} + +// DeleteTestWorkflowTemplate deletes single test by name +func (c TestWorkflowTemplateClient) DeleteTestWorkflowTemplate(name string) error { + if name == "" { + return fmt.Errorf("test workflow template name '%s' is not valid", name) + } + name = strings.ReplaceAll(name, "/", "--") + + uri := c.testWorkflowTemplateTransport.GetURI("/test-workflow-templates/%s", name) + return c.testWorkflowTemplateTransport.Delete(uri, "", true) +} diff --git a/pkg/api/v1/testkube/model_test_workflow_extended.go b/pkg/api/v1/testkube/model_test_workflow_extended.go new file mode 100644 index 00000000000..9721569c407 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_extended.go @@ -0,0 +1,3 @@ +package testkube + +type TestWorkflows []TestWorkflow diff --git a/pkg/api/v1/testkube/model_test_workflow_template_extended.go b/pkg/api/v1/testkube/model_test_workflow_template_extended.go new file mode 100644 index 00000000000..230ef94bc4d --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_template_extended.go @@ -0,0 +1,3 @@ +package testkube + +type TestWorkflowTemplates []TestWorkflowTemplate diff --git a/pkg/tcl/apitcl/v1/pro.go b/pkg/tcl/apitcl/v1/pro.go new file mode 100644 index 00000000000..6be330962ae --- /dev/null +++ b/pkg/tcl/apitcl/v1/pro.go @@ -0,0 +1,45 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "net/http" + + "github.com/gofiber/fiber/v2" + "github.com/pkg/errors" +) + +func (s *apiTCL) isPro() bool { + return s.ProContext != nil +} + +//nolint:unused +func (s *apiTCL) isProPaid() bool { + // TODO: Replace with proper implementation + return s.isPro() +} + +func (s *apiTCL) pro(h fiber.Handler) fiber.Handler { + return func(ctx *fiber.Ctx) error { + if s.isPro() { + return h(ctx) + } + return s.Error(ctx, http.StatusPaymentRequired, errors.New("this functionality is only for the Pro/Enterprise subscription")) + } +} + +//nolint:unused +func (s *apiTCL) proPaid(h fiber.Handler) fiber.Handler { + return func(ctx *fiber.Ctx) error { + if s.isProPaid() { + return h(ctx) + } + return s.Error(ctx, http.StatusPaymentRequired, errors.New("this functionality is only for paid plans of Pro/Enterprise subscription")) + } +} diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go new file mode 100644 index 00000000000..db50437838c --- /dev/null +++ b/pkg/tcl/apitcl/v1/server.go @@ -0,0 +1,93 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gofiber/fiber/v2" + kubeclient "sigs.k8s.io/controller-runtime/pkg/client" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/pkg/client/testworkflows/v1" + apiv1 "github.com/kubeshop/testkube/internal/app/api/v1" + "github.com/kubeshop/testkube/internal/config" +) + +type apiTCL struct { + apiv1.TestkubeAPI + ProContext *config.ProContext + TestWorkflowsClient testworkflowsv1.Interface + TestWorkflowTemplatesClient testworkflowsv1.TestWorkflowTemplatesInterface +} + +type ApiTCL interface { + AppendRoutes() +} + +func NewApiTCL( + testkubeAPI apiv1.TestkubeAPI, + proContext *config.ProContext, + kubeClient kubeclient.Client, +) ApiTCL { + return &apiTCL{ + TestkubeAPI: testkubeAPI, + ProContext: proContext, + TestWorkflowsClient: testworkflowsv1.NewClient(kubeClient, testkubeAPI.Namespace), + TestWorkflowTemplatesClient: testworkflowsv1.NewTestWorkflowTemplatesClient(kubeClient, testkubeAPI.Namespace), + } +} + +func (s *apiTCL) NotImplemented(c *fiber.Ctx) error { + return s.Error(c, http.StatusNotImplemented, errors.New("not implemented yet")) +} + +func (s *apiTCL) BadGateway(c *fiber.Ctx, prefix, description string, err error) error { + return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: %s: %w", prefix, description, err)) +} + +func (s *apiTCL) InternalError(c *fiber.Ctx, prefix, description string, err error) error { + return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: %s: %w", prefix, description, err)) +} + +func (s *apiTCL) BadRequest(c *fiber.Ctx, prefix, description string, err error) error { + return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: %s: %w", prefix, description, err)) +} + +func (s *apiTCL) NotFound(c *fiber.Ctx, prefix, description string, err error) error { + return s.Error(c, http.StatusNotFound, fmt.Errorf("%s: %s: %w", prefix, description, err)) +} + +func (s *apiTCL) ClientError(c *fiber.Ctx, prefix string, err error) error { + if IsNotFound(err) { + return s.NotFound(c, prefix, "client not found", err) + } + return s.BadGateway(c, prefix, "client problem", err) +} + +func (s *apiTCL) AppendRoutes() { + root := s.Routes + + testWorkflows := root.Group("/test-workflows") + testWorkflows.Get("/", s.pro(s.ListTestWorkflowsHandler())) + testWorkflows.Post("/", s.pro(s.CreateTestWorkflowHandler())) + testWorkflows.Delete("/", s.pro(s.DeleteTestWorkflowsHandler())) + testWorkflows.Get("/:id", s.pro(s.GetTestWorkflowHandler())) + testWorkflows.Put("/:id", s.pro(s.UpdateTestWorkflowHandler())) + testWorkflows.Delete("/:id", s.pro(s.DeleteTestWorkflowHandler())) + + testWorkflowTemplates := root.Group("/test-workflow-templates") + testWorkflowTemplates.Get("/", s.pro(s.ListTestWorkflowTemplatesHandler())) + testWorkflowTemplates.Post("/", s.pro(s.CreateTestWorkflowTemplateHandler())) + testWorkflowTemplates.Delete("/", s.pro(s.DeleteTestWorkflowTemplatesHandler())) + testWorkflowTemplates.Get("/:id", s.pro(s.GetTestWorkflowTemplateHandler())) + testWorkflowTemplates.Put("/:id", s.pro(s.UpdateTestWorkflowTemplateHandler())) + testWorkflowTemplates.Delete("/:id", s.pro(s.DeleteTestWorkflowTemplateHandler())) +} diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go new file mode 100644 index 00000000000..3f07acd8231 --- /dev/null +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -0,0 +1,205 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/pkg/errors" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + mappers2 "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" +) + +func (s *apiTCL) ListTestWorkflowsHandler() fiber.Handler { + errPrefix := "failed to list test workflows" + return func(c *fiber.Ctx) (err error) { + workflows, err := s.getFilteredTestWorkflowList(c) + if err != nil { + return s.BadGateway(c, errPrefix, "client problem", err) + } + err = SendResourceList(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapTestWorkflowKubeToAPI, workflows.Items...) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) GetTestWorkflowHandler() fiber.Handler { + return func(c *fiber.Ctx) (err error) { + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to get test workflow '%s'", name) + workflow, err := s.TestWorkflowsClient.Get(name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, workflow) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) DeleteTestWorkflowHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to delete test workflow '%s'", name) + err := s.TestWorkflowsClient.Delete(name) + s.Metrics.IncDeleteTestWorkflow(err) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + //skipExecutions := c.Query("skipDeleteExecutions", "") + //if skipExecutions != "true" { + // // TODO: Delete Executions + //} + return c.SendStatus(http.StatusNoContent) + } +} + +func (s *apiTCL) DeleteTestWorkflowsHandler() fiber.Handler { + errPrefix := "failed to delete test workflows" + return func(c *fiber.Ctx) error { + selector := c.Query("selector") + _, err := s.TestWorkflowsClient.List(selector) + if err != nil { + return s.BadGateway(c, errPrefix, "client problem", err) + } + + err = s.TestWorkflowsClient.DeleteByLabels(selector) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + //skipExecutions := c.Query("skipDeleteExecutions", "") + //for range workflows.Items { + // s.Metrics.IncDeleteTestWorkflow(err) + // if skipExecutions != "true" { + // // TODO: Delete Executions + // } + //} + return c.SendStatus(http.StatusNoContent) + } +} + +func (s *apiTCL) CreateTestWorkflowHandler() fiber.Handler { + errPrefix := "failed to create test workflow" + return func(c *fiber.Ctx) (err error) { + // Deserialize resource + obj := new(testworkflowsv1.TestWorkflow) + if HasYAML(c) { + err = common.DeserializeCRD(obj, c.Body()) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + } else { + var v *testkube.TestWorkflow + err = c.BodyParser(&v) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + obj = mappers2.MapAPIToKube(v) + } + + // Validate resource + if obj == nil || obj.Name == "" { + return s.BadRequest(c, errPrefix, "invalid body", errors.New("name is required")) + } + obj.Namespace = s.Namespace + + // Create the resource + obj, err = s.TestWorkflowsClient.Create(obj) + s.Metrics.IncCreateTestWorkflow(err) + if err != nil { + return s.BadRequest(c, errPrefix, "client error", err) + } + + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, obj) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) UpdateTestWorkflowHandler() fiber.Handler { + errPrefix := "failed to update test workflow" + return func(c *fiber.Ctx) (err error) { + name := c.Params("id") + + // Deserialize resource + obj := new(testworkflowsv1.TestWorkflow) + if HasYAML(c) { + err = common.DeserializeCRD(obj, c.Body()) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + } else { + var v *testkube.TestWorkflow + err = c.BodyParser(&v) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + obj = mappers2.MapAPIToKube(v) + } + + // Read existing resource + workflow, err := s.TestWorkflowsClient.Get(name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + // Validate resource + if obj == nil { + return s.BadRequest(c, errPrefix, "invalid body", errors.New("body is required")) + } + obj.Namespace = workflow.Namespace + obj.Name = workflow.Name + obj.ResourceVersion = workflow.ResourceVersion + + // Update the resource + obj, err = s.TestWorkflowsClient.Update(obj) + s.Metrics.IncUpdateTestWorkflow(err) + if err != nil { + return s.BadRequest(c, errPrefix, "client error", err) + } + + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, obj) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) getFilteredTestWorkflowList(c *fiber.Ctx) (*testworkflowsv1.TestWorkflowList, error) { + crWorkflows, err := s.TestWorkflowsClient.List(c.Query("selector")) + if err != nil { + return nil, err + } + + search := c.Query("textSearch") + if search != "" { + // filter items array + for i := len(crWorkflows.Items) - 1; i >= 0; i-- { + if !strings.Contains(crWorkflows.Items[i].Name, search) { + crWorkflows.Items = append(crWorkflows.Items[:i], crWorkflows.Items[i+1:]...) + } + } + } + + return crWorkflows, nil +} diff --git a/pkg/tcl/apitcl/v1/testworkflowtemplates.go b/pkg/tcl/apitcl/v1/testworkflowtemplates.go new file mode 100644 index 00000000000..56754ab2738 --- /dev/null +++ b/pkg/tcl/apitcl/v1/testworkflowtemplates.go @@ -0,0 +1,188 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/pkg/errors" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + mappers2 "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" +) + +func (s *apiTCL) ListTestWorkflowTemplatesHandler() fiber.Handler { + errPrefix := "failed to list test workflow templates" + return func(c *fiber.Ctx) (err error) { + templates, err := s.getFilteredTestWorkflowTemplateList(c) + if err != nil { + return s.BadGateway(c, errPrefix, "client problem", err) + } + err = SendResourceList(c, "TestWorkflowTemplate", testworkflowsv1.GroupVersion, mappers2.MapTestWorkflowTemplateKubeToAPI, templates.Items...) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) GetTestWorkflowTemplateHandler() fiber.Handler { + return func(c *fiber.Ctx) (err error) { + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to get test workflow template '%s'", name) + template, err := s.TestWorkflowTemplatesClient.Get(name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + err = SendResource(c, "TestWorkflowTemplate", testworkflowsv1.GroupVersion, mappers2.MapTemplateKubeToAPI, template) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) DeleteTestWorkflowTemplateHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to delete test workflow template '%s'", name) + err := s.TestWorkflowTemplatesClient.Delete(name) + s.Metrics.IncDeleteTestWorkflowTemplate(err) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + return c.SendStatus(http.StatusNoContent) + } +} + +func (s *apiTCL) DeleteTestWorkflowTemplatesHandler() fiber.Handler { + errPrefix := "failed to delete test workflow templates" + return func(c *fiber.Ctx) error { + selector := c.Query("selector") + err := s.TestWorkflowTemplatesClient.DeleteByLabels(selector) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + return c.SendStatus(http.StatusNoContent) + } +} + +func (s *apiTCL) CreateTestWorkflowTemplateHandler() fiber.Handler { + errPrefix := "failed to create test workflow template" + return func(c *fiber.Ctx) (err error) { + // Deserialize resource + obj := new(testworkflowsv1.TestWorkflowTemplate) + if HasYAML(c) { + err = common.DeserializeCRD(obj, c.Body()) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + } else { + var v *testkube.TestWorkflowTemplate + err = c.BodyParser(&v) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + obj = mappers2.MapTemplateAPIToKube(v) + } + + // Validate resource + if obj == nil || obj.Name == "" { + return s.BadRequest(c, errPrefix, "invalid body", errors.New("name is required")) + } + obj.Namespace = s.Namespace + + // Create the resource + obj, err = s.TestWorkflowTemplatesClient.Create(obj) + s.Metrics.IncCreateTestWorkflowTemplate(err) + if err != nil { + return s.BadRequest(c, errPrefix, "client error", err) + } + + err = SendResource(c, "TestWorkflowTemplate", testworkflowsv1.GroupVersion, mappers2.MapTemplateKubeToAPI, obj) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) UpdateTestWorkflowTemplateHandler() fiber.Handler { + errPrefix := "failed to update test workflow template" + return func(c *fiber.Ctx) (err error) { + name := c.Params("id") + + // Deserialize resource + obj := new(testworkflowsv1.TestWorkflowTemplate) + if HasYAML(c) { + err = common.DeserializeCRD(obj, c.Body()) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + } else { + var v *testkube.TestWorkflowTemplate + err = c.BodyParser(&v) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + obj = mappers2.MapTemplateAPIToKube(v) + } + + // Read existing resource + template, err := s.TestWorkflowTemplatesClient.Get(name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + // Validate resource + if obj == nil { + return s.BadRequest(c, errPrefix, "invalid body", errors.New("body is required")) + } + obj.Namespace = template.Namespace + obj.Name = template.Name + obj.ResourceVersion = template.ResourceVersion + + // Update the resource + obj, err = s.TestWorkflowTemplatesClient.Update(obj) + s.Metrics.IncUpdateTestWorkflowTemplate(err) + if err != nil { + return s.BadRequest(c, errPrefix, "client error", err) + } + + err = SendResource(c, "TestWorkflowTemplate", testworkflowsv1.GroupVersion, mappers2.MapTemplateKubeToAPI, obj) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + +func (s *apiTCL) getFilteredTestWorkflowTemplateList(c *fiber.Ctx) (*testworkflowsv1.TestWorkflowTemplateList, error) { + crTemplates, err := s.TestWorkflowTemplatesClient.List(c.Query("selector")) + if err != nil { + return nil, err + } + + search := c.Query("textSearch") + if search != "" { + search = strings.ReplaceAll(search, "/", "--") + for i := len(crTemplates.Items) - 1; i >= 0; i-- { + if !strings.Contains(crTemplates.Items[i].Name, search) { + crTemplates.Items = append(crTemplates.Items[:i], crTemplates.Items[i+1:]...) + } + } + } + + return crTemplates, nil +} diff --git a/pkg/tcl/apitcl/v1/utils.go b/pkg/tcl/apitcl/v1/utils.go new file mode 100644 index 00000000000..16eef63188a --- /dev/null +++ b/pkg/tcl/apitcl/v1/utils.go @@ -0,0 +1,79 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "github.com/gofiber/fiber/v2" + "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/mongo" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kubeshop/testkube/internal/common" +) + +const ( + mediaTypeJSON = "application/json" + mediaTypeYAML = "text/yaml" +) + +func ExpectsYAML(c *fiber.Ctx) bool { + return c.Accepts(mediaTypeJSON, mediaTypeYAML) == mediaTypeYAML || c.Query("_yaml") == "true" +} + +func HasYAML(c *fiber.Ctx) bool { + return string(c.Request().Header.ContentType()) == mediaTypeYAML +} + +func SendResourceList[T interface{}, U interface{}](c *fiber.Ctx, kind string, groupVersion schema.GroupVersion, jsonMapper func(T) U, data ...T) error { + if ExpectsYAML(c) { + return SendCRDs(c, kind, groupVersion, data...) + } + result := make([]U, len(data)) + for i, item := range data { + result[i] = jsonMapper(item) + } + return c.JSON(result) +} + +func SendResource[T interface{}, U interface{}](c *fiber.Ctx, kind string, groupVersion schema.GroupVersion, jsonMapper func(T) U, data T) error { + if ExpectsYAML(c) { + return SendCRDs(c, kind, groupVersion, data) + } + return c.JSON(jsonMapper(data)) +} + +func SendCRDs[T interface{}](c *fiber.Ctx, kind string, groupVersion schema.GroupVersion, crds ...T) error { + b, err := common.SerializeCRDs(crds, common.SerializeOptions{ + OmitCreationTimestamp: true, + CleanMeta: true, + Kind: kind, + GroupVersion: &groupVersion, + }) + if err != nil { + return err + } + c.Context().SetContentType(mediaTypeYAML) + return c.Send(b) +} + +func IsNotFound(err error) bool { + if err == nil { + return false + } + if errors.Is(err, mongo.ErrNoDocuments) || k8serrors.IsNotFound(err) { + return true + } + if e, ok := status.FromError(err); ok { + return e.Code() == codes.NotFound + } + return false +} diff --git a/tcl/workflowstcl/mappers/kube_openapi.go b/pkg/tcl/workflowstcl/mappers/kube_openapi.go similarity index 100% rename from tcl/workflowstcl/mappers/kube_openapi.go rename to pkg/tcl/workflowstcl/mappers/kube_openapi.go diff --git a/tcl/workflowstcl/mappers/mappers_test.go b/pkg/tcl/workflowstcl/mappers/mappers_test.go similarity index 100% rename from tcl/workflowstcl/mappers/mappers_test.go rename to pkg/tcl/workflowstcl/mappers/mappers_test.go diff --git a/tcl/workflowstcl/mappers/openapi_kube.go b/pkg/tcl/workflowstcl/mappers/openapi_kube.go similarity index 100% rename from tcl/workflowstcl/mappers/openapi_kube.go rename to pkg/tcl/workflowstcl/mappers/openapi_kube.go From 81dcd7e871fb147fe7a5588fc3d7a5b59a8ce885 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 22 Feb 2024 09:35:34 +0100 Subject: [PATCH 114/234] feat(TKC-1462): add CLI commands for managing TestWorkflows (#5046) * feat(TKC-1462): add CLI commands for getting/listing TestWorkflows/TestWorkflowTemplates * feat(TKC-1462): add CLI commands for deleting TestWorkflows and TestWorkflowTemplates * feat(TKC-1462): add CLI commands for creating/updating TestWorkflows and TestWorkflowTemplates --- cmd/kubectl-testkube/commands/create.go | 4 + cmd/kubectl-testkube/commands/delete.go | 4 + cmd/kubectl-testkube/commands/get.go | 4 + .../commands/testworkflows/create.go | 85 +++++++++++++++++++ .../commands/testworkflows/delete.go | 54 ++++++++++++ .../commands/testworkflows/get.go | 64 ++++++++++++++ .../renderer/testworkflow_obj.go | 32 +++++++ .../commands/testworkflowtemplates/create.go | 85 +++++++++++++++++++ .../commands/testworkflowtemplates/delete.go | 54 ++++++++++++ .../commands/testworkflowtemplates/get.go | 64 ++++++++++++++ .../renderer/testworkflow_obj.go | 32 +++++++ .../testkube/model_test_workflow_extended.go | 14 +++ .../model_test_workflow_template_extended.go | 16 ++++ pkg/ui/ui.go | 19 +++++ 14 files changed, 531 insertions(+) create mode 100644 cmd/kubectl-testkube/commands/testworkflows/create.go create mode 100644 cmd/kubectl-testkube/commands/testworkflows/delete.go create mode 100644 cmd/kubectl-testkube/commands/testworkflows/get.go create mode 100644 cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflow_obj.go create mode 100644 cmd/kubectl-testkube/commands/testworkflowtemplates/create.go create mode 100644 cmd/kubectl-testkube/commands/testworkflowtemplates/delete.go create mode 100644 cmd/kubectl-testkube/commands/testworkflowtemplates/get.go create mode 100644 cmd/kubectl-testkube/commands/testworkflowtemplates/renderer/testworkflow_obj.go diff --git a/cmd/kubectl-testkube/commands/create.go b/cmd/kubectl-testkube/commands/create.go index 46de9b05cc9..c428b011f0c 100644 --- a/cmd/kubectl-testkube/commands/create.go +++ b/cmd/kubectl-testkube/commands/create.go @@ -10,6 +10,8 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/tests" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsources" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsuites" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflowtemplates" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/webhooks" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" "github.com/kubeshop/testkube/pkg/ui" @@ -43,6 +45,8 @@ func NewCreateCmd() *cobra.Command { cmd.AddCommand(executors.NewCreateExecutorCmd()) cmd.AddCommand(testsources.NewCreateTestSourceCmd()) cmd.AddCommand(templates.NewCreateTemplateCmd()) + cmd.AddCommand(testworkflows.NewCreateTestWorkflowCmd()) + cmd.AddCommand(testworkflowtemplates.NewCreateTestWorkflowTemplateCmd()) cmd.PersistentFlags().BoolVar(&crdOnly, "crd-only", false, "generate only crd") diff --git a/cmd/kubectl-testkube/commands/delete.go b/cmd/kubectl-testkube/commands/delete.go index 8f8cfa87727..e3ae7f95db7 100644 --- a/cmd/kubectl-testkube/commands/delete.go +++ b/cmd/kubectl-testkube/commands/delete.go @@ -10,6 +10,8 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/tests" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsources" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsuites" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflowtemplates" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/webhooks" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" "github.com/kubeshop/testkube/pkg/ui" @@ -42,6 +44,8 @@ func NewDeleteCmd() *cobra.Command { cmd.AddCommand(executors.NewDeleteExecutorCmd()) cmd.AddCommand(testsources.NewDeleteTestSourceCmd()) cmd.AddCommand(templates.NewDeleteTemplateCmd()) + cmd.AddCommand(testworkflows.NewDeleteTestWorkflowCmd()) + cmd.AddCommand(testworkflowtemplates.NewDeleteTestWorkflowTemplateCmd()) return cmd } diff --git a/cmd/kubectl-testkube/commands/get.go b/cmd/kubectl-testkube/commands/get.go index b0355e79623..a5a1368bad2 100644 --- a/cmd/kubectl-testkube/commands/get.go +++ b/cmd/kubectl-testkube/commands/get.go @@ -12,6 +12,8 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/tests" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsources" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsuites" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflowtemplates" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/webhooks" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" "github.com/kubeshop/testkube/pkg/ui" @@ -48,6 +50,8 @@ func NewGetCmd() *cobra.Command { cmd.AddCommand(testsources.NewGetTestSourceCmd()) cmd.AddCommand(context.NewGetContextCmd()) cmd.AddCommand(templates.NewGetTemplateCmd()) + cmd.AddCommand(testworkflows.NewGetTestWorkflowsCmd()) + cmd.AddCommand(testworkflowtemplates.NewGetTestWorkflowTemplatesCmd()) cmd.PersistentFlags().StringP("output", "o", "pretty", "output type can be one of json|yaml|pretty|go-template") cmd.PersistentFlags().StringP("go-template", "", "{{.}}", "go template to render") diff --git a/cmd/kubectl-testkube/commands/testworkflows/create.go b/cmd/kubectl-testkube/commands/testworkflows/create.go new file mode 100644 index 00000000000..8f2004f8a56 --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/create.go @@ -0,0 +1,85 @@ +package testworkflows + +import ( + "io" + "os" + + "github.com/spf13/cobra" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + common2 "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewCreateTestWorkflowCmd() *cobra.Command { + var ( + name string + filePath string + update bool + ) + + cmd := &cobra.Command{ + Use: "testworkflow", + Aliases: []string{"testworkflows", "tw"}, + Args: cobra.MaximumNArgs(0), + Short: "Create test workflow", + + Run: func(cmd *cobra.Command, _ []string) { + namespace := cmd.Flag("namespace").Value.String() + + var input io.Reader + if filePath == "" { + fi, err := os.Stdin.Stat() + ui.ExitOnError("reading stdin", err) + if fi.Mode()&os.ModeDevice != 0 { + ui.Failf("you need to pass stdin or --file argument with file path") + } + input = cmd.InOrStdin() + } else { + file, err := os.Open(filePath) + ui.ExitOnError("reading "+filePath+" file", err) + input = file + } + + bytes, err := io.ReadAll(input) + ui.ExitOnError("reading input", err) + + obj := new(testworkflowsv1.TestWorkflow) + err = common2.DeserializeCRD(obj, bytes) + ui.ExitOnError("deserializing input", err) + if obj.Kind != "" && obj.Kind != "TestWorkflow" { + ui.Failf("Only TestWorkflow objects are accepted. Received: %s", obj.Kind) + } + common2.AppendTypeMeta("TestWorkflow", testworkflowsv1.GroupVersion, obj) + obj.Namespace = namespace + if name != "" { + obj.Name = name + } + + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + workflow, _ := client.GetTestWorkflow(obj.Name) + if workflow.Name != "" { + if !update { + ui.Failf("Test workflow with name '%s' already exists in namespace %s, use --update flag for upsert", obj.Name, namespace) + } + _, err = client.UpdateTestWorkflow(mappers.MapTestWorkflowKubeToAPI(*obj)) + ui.ExitOnError("updating test workflow "+obj.Name+" in namespace "+obj.Namespace, err) + ui.Success("Test workflow updated", namespace, "/", obj.Name) + } else { + _, err = client.CreateTestWorkflow(mappers.MapTestWorkflowKubeToAPI(*obj)) + ui.ExitOnError("creating test workflow "+obj.Name+" in namespace "+obj.Namespace, err) + ui.Success("Test workflow created", namespace, "/", obj.Name) + } + }, + } + + cmd.Flags().StringVar(&name, "name", "", "test workflow name") + cmd.Flags().BoolVar(&update, "update", false, "update, if test workflow already exists") + cmd.Flags().StringVarP(&filePath, "file", "f", "", "file path to get the test workflow specification") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflows/delete.go b/cmd/kubectl-testkube/commands/testworkflows/delete.go new file mode 100644 index 00000000000..c601d94cc64 --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/delete.go @@ -0,0 +1,54 @@ +package testworkflows + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewDeleteTestWorkflowCmd() *cobra.Command { + var deleteAll bool + var selectors []string + + cmd := &cobra.Command{ + Use: "testworkflow [name]", + Aliases: []string{"testworkflows", "tw"}, + Args: cobra.MaximumNArgs(1), + Short: "Delete test workflows", + + Run: func(cmd *cobra.Command, args []string) { + namespace := cmd.Flag("namespace").Value.String() + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + if len(args) == 0 { + if len(selectors) > 0 { + selector := strings.Join(selectors, ",") + err = client.DeleteTestWorkflows(selector) + ui.ExitOnError("deleting test workflows by labels: "+selector, err) + ui.SuccessAndExit("Successfully deleted test workflows by labels", selector) + } else if deleteAll { + err = client.DeleteTestWorkflows("") + ui.ExitOnError("delete all test workflows from namespace "+namespace, err) + ui.SuccessAndExit("Successfully deleted all test workflows in namespace", namespace) + } else { + ui.Failf("Pass test workflow name, --all flag to delete all or labels to delete by labels") + } + return + } + + name := args[0] + err = client.DeleteTestWorkflow(name) + ui.ExitOnError("delete test workflow "+name+" from namespace "+namespace, err) + ui.SuccessAndExit("Successfully deleted test workflow", name) + }, + } + + cmd.Flags().BoolVar(&deleteAll, "all", false, "Delete all test workflows") + cmd.Flags().StringSliceVarP(&selectors, "label", "l", nil, "label key value pair: --label key1=value1") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflows/get.go b/cmd/kubectl-testkube/commands/testworkflows/get.go new file mode 100644 index 00000000000..16b46df976a --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/get.go @@ -0,0 +1,64 @@ +package testworkflows + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows/renderer" + "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewGetTestWorkflowsCmd() *cobra.Command { + var ( + selectors []string + crdOnly bool + ) + + cmd := &cobra.Command{ + Use: "testworkflow [name]", + Aliases: []string{"testworkflows", "tw"}, + Args: cobra.MaximumNArgs(1), + Short: "Get all available test workflows", + Long: `Getting all available test workflows from given namespace - if no namespace given "testkube" namespace is used`, + + Run: func(cmd *cobra.Command, args []string) { + namespace := cmd.Flag("namespace").Value.String() + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + if len(args) == 0 { + workflows, err := client.ListTestWorkflows(strings.Join(selectors, ",")) + ui.ExitOnError("getting all test workflows in namespace "+namespace, err) + + if crdOnly { + ui.PrintCRDs(mappers.MapListAPIToKube(workflows).Items, "TestWorkflow", testworkflowsv1.GroupVersion) + } else { + err = render.List(cmd, workflows, os.Stdout) + ui.PrintOnError("Rendering list", err) + } + return + } + + name := args[0] + workflow, err := client.GetTestWorkflow(name) + ui.ExitOnError("getting test workflow in namespace "+namespace, err) + + if crdOnly { + ui.PrintCRD(mappers.MapTestWorkflowAPIToKube(workflow), "TestWorkflow", testworkflowsv1.GroupVersion) + } else { + err = render.Obj(cmd, workflow, os.Stdout, renderer.TestWorkflowRenderer) + ui.ExitOnError("rendering obj", err) + } + }, + } + cmd.Flags().StringSliceVarP(&selectors, "label", "l", nil, "label key value pair: --label key1=value1") + cmd.Flags().BoolVar(&crdOnly, "crd-only", false, "show only test workflow crd") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflow_obj.go b/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflow_obj.go new file mode 100644 index 00000000000..75ad485db3e --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflow_obj.go @@ -0,0 +1,32 @@ +package renderer + +import ( + "fmt" + + "github.com/kubeshop/testkube/pkg/api/v1/client" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/ui" +) + +func TestWorkflowRenderer(client client.Client, ui *ui.UI, obj interface{}) error { + workflow, ok := obj.(testkube.TestWorkflow) + if !ok { + return fmt.Errorf("can't use '%T' as testkube.TestWorkflow in RenderObj for test workflow", obj) + } + + ui.Info("Test Workflow:") + ui.Warn("Name: ", workflow.Name) + ui.Warn("Namespace:", workflow.Namespace) + ui.Warn("Created: ", workflow.Created.String()) + if workflow.Description != "" { + ui.NL() + ui.Warn("Description: ", workflow.Description) + } + if len(workflow.Labels) > 0 { + ui.NL() + ui.Warn("Labels: ", testkube.MapToString(workflow.Labels)) + } + + return nil + +} diff --git a/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go b/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go new file mode 100644 index 00000000000..c156ac1c85b --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go @@ -0,0 +1,85 @@ +package testworkflowtemplates + +import ( + "io" + "os" + + "github.com/spf13/cobra" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + common2 "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewCreateTestWorkflowTemplateCmd() *cobra.Command { + var ( + name string + filePath string + update bool + ) + + cmd := &cobra.Command{ + Use: "testworkflowtemplate", + Aliases: []string{"testworkflowtemplates", "twt"}, + Args: cobra.MaximumNArgs(0), + Short: "Create test workflow template", + + Run: func(cmd *cobra.Command, _ []string) { + namespace := cmd.Flag("namespace").Value.String() + + var input io.Reader + if filePath == "" { + fi, err := os.Stdin.Stat() + ui.ExitOnError("reading stdin", err) + if fi.Mode()&os.ModeDevice != 0 { + ui.Failf("you need to pass stdin or --file argument with file path") + } + input = cmd.InOrStdin() + } else { + file, err := os.Open(filePath) + ui.ExitOnError("reading "+filePath+" file", err) + input = file + } + + bytes, err := io.ReadAll(input) + ui.ExitOnError("reading input", err) + + obj := new(testworkflowsv1.TestWorkflowTemplate) + err = common2.DeserializeCRD(obj, bytes) + ui.ExitOnError("deserializing input", err) + if obj.Kind != "" && obj.Kind != "TestWorkflowTemplate" { + ui.Failf("Only TestWorkflowTemplate objects are accepted. Received: %s", obj.Kind) + } + common2.AppendTypeMeta("TestWorkflowTemplate", testworkflowsv1.GroupVersion, obj) + obj.Namespace = namespace + if name != "" { + obj.Name = name + } + + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + workflow, _ := client.GetTestWorkflowTemplate(obj.Name) + if workflow.Name != "" { + if !update { + ui.Failf("Test workflow template with name '%s' already exists in namespace %s, use --update flag for upsert", obj.Name, namespace) + } + _, err = client.UpdateTestWorkflowTemplate(mappers.MapTestWorkflowTemplateKubeToAPI(*obj)) + ui.ExitOnError("updating test workflow template "+obj.Name+" in namespace "+obj.Namespace, err) + ui.Success("Test workflow template updated", namespace, "/", obj.Name) + } else { + _, err = client.CreateTestWorkflowTemplate(mappers.MapTestWorkflowTemplateKubeToAPI(*obj)) + ui.ExitOnError("creating test workflow "+obj.Name+" in namespace "+obj.Namespace, err) + ui.Success("Test workflow template created", namespace, "/", obj.Name) + } + }, + } + + cmd.Flags().StringVar(&name, "name", "", "test workflow template name") + cmd.Flags().BoolVar(&update, "update", false, "update, if test workflow template already exists") + cmd.Flags().StringVarP(&filePath, "file", "f", "", "file path to get the test workflow template specification") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflowtemplates/delete.go b/cmd/kubectl-testkube/commands/testworkflowtemplates/delete.go new file mode 100644 index 00000000000..5cb1b1ab8cc --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflowtemplates/delete.go @@ -0,0 +1,54 @@ +package testworkflowtemplates + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewDeleteTestWorkflowTemplateCmd() *cobra.Command { + var deleteAll bool + var selectors []string + + cmd := &cobra.Command{ + Use: "testworkflowtemplate [name]", + Aliases: []string{"testworkflowtemplates", "twt"}, + Args: cobra.MaximumNArgs(1), + Short: "Delete test workflow templates", + + Run: func(cmd *cobra.Command, args []string) { + namespace := cmd.Flag("namespace").Value.String() + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + if len(args) == 0 { + if len(selectors) > 0 { + selector := strings.Join(selectors, ",") + err = client.DeleteTestWorkflowTemplates(selector) + ui.ExitOnError("deleting test workflow templates by labels: "+selector, err) + ui.SuccessAndExit("Successfully deleted test workflow templates by labels", selector) + } else if deleteAll { + err = client.DeleteTestWorkflowTemplates("") + ui.ExitOnError("delete all test workflow templates from namespace "+namespace, err) + ui.SuccessAndExit("Successfully deleted all test workflow templates in namespace", namespace) + } else { + ui.Failf("Pass test workflow template name, --all flag to delete all or labels to delete by labels") + } + return + } + + name := args[0] + err = client.DeleteTestWorkflowTemplate(name) + ui.ExitOnError("delete test workflow template "+name+" from namespace "+namespace, err) + ui.SuccessAndExit("Successfully deleted test workflow template", name) + }, + } + + cmd.Flags().BoolVar(&deleteAll, "all", false, "Delete all test workflow templates") + cmd.Flags().StringSliceVarP(&selectors, "label", "l", nil, "label key value pair: --label key1=value1") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go b/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go new file mode 100644 index 00000000000..752f60d5c50 --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go @@ -0,0 +1,64 @@ +package testworkflowtemplates + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflowtemplates/renderer" + "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewGetTestWorkflowTemplatesCmd() *cobra.Command { + var ( + selectors []string + crdOnly bool + ) + + cmd := &cobra.Command{ + Use: "testworkflowtemplate [name]", + Aliases: []string{"testworkflowtemplates", "twt"}, + Args: cobra.MaximumNArgs(1), + Short: "Get all available test workflow templates", + Long: `Getting all available test workflow templates from given namespace - if no namespace given "testkube" namespace is used`, + + Run: func(cmd *cobra.Command, args []string) { + namespace := cmd.Flag("namespace").Value.String() + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + if len(args) == 0 { + templates, err := client.ListTestWorkflowTemplates(strings.Join(selectors, ",")) + ui.ExitOnError("getting all test workflow templates in namespace "+namespace, err) + + if crdOnly { + ui.PrintCRDs(mappers.MapTemplateListAPIToKube(templates).Items, "TestWorkflowTemplate", testworkflowsv1.GroupVersion) + } else { + err = render.List(cmd, templates, os.Stdout) + ui.PrintOnError("Rendering list", err) + } + return + } + + name := args[0] + template, err := client.GetTestWorkflowTemplate(name) + ui.ExitOnError("getting test workflow in namespace "+namespace, err) + + if crdOnly { + ui.PrintCRD(mappers.MapTestWorkflowTemplateAPIToKube(template), "TestWorkflowTemplate", testworkflowsv1.GroupVersion) + } else { + err = render.Obj(cmd, template, os.Stdout, renderer.TestWorkflowTemplateRenderer) + ui.ExitOnError("rendering obj", err) + } + }, + } + cmd.Flags().StringSliceVarP(&selectors, "label", "l", nil, "label key value pair: --label key1=value1") + cmd.Flags().BoolVar(&crdOnly, "crd-only", false, "show only test workflow template crd") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflowtemplates/renderer/testworkflow_obj.go b/cmd/kubectl-testkube/commands/testworkflowtemplates/renderer/testworkflow_obj.go new file mode 100644 index 00000000000..8bd70e4ca99 --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflowtemplates/renderer/testworkflow_obj.go @@ -0,0 +1,32 @@ +package renderer + +import ( + "fmt" + + "github.com/kubeshop/testkube/pkg/api/v1/client" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/ui" +) + +func TestWorkflowTemplateRenderer(client client.Client, ui *ui.UI, obj interface{}) error { + template, ok := obj.(testkube.TestWorkflowTemplate) + if !ok { + return fmt.Errorf("can't use '%T' as testkube.TestWorkflowTemplate in RenderObj for test workflow template", obj) + } + + ui.Info("Test Workflow Template:") + ui.Warn("Name: ", template.Name) + ui.Warn("Namespace:", template.Namespace) + ui.Warn("Created: ", template.Created.String()) + if template.Description != "" { + ui.NL() + ui.Warn("Description: ", template.Description) + } + if len(template.Labels) > 0 { + ui.NL() + ui.Warn("Labels: ", testkube.MapToString(template.Labels)) + } + + return nil + +} diff --git a/pkg/api/v1/testkube/model_test_workflow_extended.go b/pkg/api/v1/testkube/model_test_workflow_extended.go index 9721569c407..3812a07e322 100644 --- a/pkg/api/v1/testkube/model_test_workflow_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_extended.go @@ -1,3 +1,17 @@ package testkube type TestWorkflows []TestWorkflow + +func (t TestWorkflows) Table() (header []string, output [][]string) { + header = []string{"Name", "Description", "Created", "Labels"} + for _, e := range t { + output = append(output, []string{ + e.Name, + e.Description, + e.Created.String(), + MapToString(e.Labels), + }) + } + + return +} diff --git a/pkg/api/v1/testkube/model_test_workflow_template_extended.go b/pkg/api/v1/testkube/model_test_workflow_template_extended.go index 230ef94bc4d..601c6addc0c 100644 --- a/pkg/api/v1/testkube/model_test_workflow_template_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_template_extended.go @@ -1,3 +1,19 @@ package testkube +import "strings" + type TestWorkflowTemplates []TestWorkflowTemplate + +func (t TestWorkflowTemplates) Table() (header []string, output [][]string) { + header = []string{"Name", "Description", "Created", "Labels"} + for _, e := range t { + output = append(output, []string{ + strings.ReplaceAll(e.Name, "--", "/"), + e.Description, + e.Created.String(), + MapToString(e.Labels), + }) + } + + return +} diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index bad58ffd09f..e6658035ed8 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -4,6 +4,10 @@ package ui import ( "io" "os" + + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kubeshop/testkube/internal/common" ) const ( @@ -90,5 +94,20 @@ func Confirm(message string) bool { return ui.Confirm( func Select(title string, options []string) string { return ui.Select(title, options) } func TextInput(message string) string { return ui.TextInput(message) } +func PrintCRD[T interface{}](cr T, kind string, groupVersion schema.GroupVersion) { + PrintCRDs([]T{cr}, kind, groupVersion) +} + +func PrintCRDs[T interface{}](crs []T, kind string, groupVersion schema.GroupVersion) { + bytes, err := common.SerializeCRDs(crs, common.SerializeOptions{ + OmitCreationTimestamp: true, + CleanMeta: true, + Kind: kind, + GroupVersion: &groupVersion, + }) + ui.ExitOnError("serializing the crds", err) + _, _ = os.Stdout.Write(bytes) +} + func UseStdout() { ui = uiOut } func UseStderr() { ui = uiErr } From 8d1bec25899030f1fd1e7d32e29be471c623a79b Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Thu, 22 Feb 2024 11:12:50 +0100 Subject: [PATCH 115/234] feat: executor tests - expected-fail-container-pre-post-run-script (#5047) * expected-fail-container-pre-post-run-script * executor tests - postman container - command fixed * empty lines added --- .../executors/container-executor-postman.yaml | 1 + .../edge-cases-expected-fails.yaml | 22 +++++++++++++++++++ .../edge-cases-expected-fails.yaml | 3 +++ 3 files changed, 26 insertions(+) diff --git a/test/executors/container-executor-postman.yaml b/test/executors/container-executor-postman.yaml index 8e40636595c..10591222791 100644 --- a/test/executors/container-executor-postman.yaml +++ b/test/executors/container-executor-postman.yaml @@ -7,3 +7,4 @@ spec: executor_type: container types: - container-executor-postman-newman-6-alpine/test + command: ["newman"] diff --git a/test/special-cases/edge-cases-expected-fails.yaml b/test/special-cases/edge-cases-expected-fails.yaml index 1d5960c65f0..ca8240b1bda 100644 --- a/test/special-cases/edge-cases-expected-fails.yaml +++ b/test/special-cases/edge-cases-expected-fails.yaml @@ -423,3 +423,25 @@ spec: preRunScript: "echo \"===== pre-run script\"" postRunScript: "echo \"===== post-run script\"" jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: expected-fail-container-pre-post-run-script + labels: + core-tests: expected-fail +spec: + type: container-executor-postman-newman-6-alpine/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/postman/executor-tests/postman-executor-smoke-negative.postman_collection.json + workingDir: test/postman/executor-tests + executionRequest: + args: ["run", "postman-executor-smoke.postman_collection.json"] + preRunScript: "echo \"===== pre-run script\"" + postRunScript: "echo \"===== post-run script\"" + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" diff --git a/test/suites/special-cases/edge-cases-expected-fails.yaml b/test/suites/special-cases/edge-cases-expected-fails.yaml index ba3319d6053..748f22d29d1 100644 --- a/test/suites/special-cases/edge-cases-expected-fails.yaml +++ b/test/suites/special-cases/edge-cases-expected-fails.yaml @@ -73,3 +73,6 @@ spec: - stopOnFailure: false execute: - test: expected-fail-pre-post-run-script + - stopOnFailure: false + execute: + - test: expected-fail-container-pre-post-run-script From 1cc81e1fe41f93f24e643b7381450323192a9649 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 22 Feb 2024 12:39:58 +0100 Subject: [PATCH 116/234] fix: added test name for s3 logs request (#5049) --- pkg/logs/pb/logs.pb.go | 56 ++++++++++++++++++++++--------------- pkg/logs/pb/logs.proto | 1 + pkg/logs/pb/logs_grpc.pb.go | 17 ++++++++--- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/pkg/logs/pb/logs.pb.go b/pkg/logs/pb/logs.pb.go index 611f0844f03..9a6c90ffc02 100644 --- a/pkg/logs/pb/logs.pb.go +++ b/pkg/logs/pb/logs.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.28.1 +// protoc-gen-go v1.32.0 // protoc v3.19.4 // source: pkg/logs/pb/logs.proto @@ -216,6 +216,7 @@ type CloudLogRequest struct { EnvironmentId string `protobuf:"bytes,1,opt,name=environment_id,json=environmentId,proto3" json:"environment_id,omitempty"` ExecutionId string `protobuf:"bytes,2,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` + TestName string `protobuf:"bytes,3,opt,name=test_name,json=testName,proto3" json:"test_name,omitempty"` } func (x *CloudLogRequest) Reset() { @@ -264,6 +265,13 @@ func (x *CloudLogRequest) GetExecutionId() string { return "" } +func (x *CloudLogRequest) GetTestName() string { + if x != nil { + return x.TestName + } + return "" +} + type StreamResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -347,33 +355,35 @@ var file_pkg_logs_pb_logs_proto_rawDesc = []byte{ 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0x5b, 0x0a, 0x0f, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, + 0x22, 0x78, 0x0a, 0x0f, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x5e, 0x0a, - 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x67, 0x73, - 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2a, 0x31, 0x0a, - 0x14, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x64, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x01, - 0x32, 0x34, 0x0a, 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x25, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, - 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, - 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, 0x32, 0x6b, 0x0a, 0x10, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, - 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x53, 0x74, - 0x72, 0x65, 0x61, 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x1a, - 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x2a, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, - 0x15, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x52, + 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1b, 0x0a, + 0x09, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x74, 0x65, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x5e, 0x0a, 0x0e, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2a, 0x31, 0x0a, 0x14, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x10, + 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x01, 0x32, 0x34, 0x0a, + 0x0b, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x25, 0x0a, 0x04, + 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x10, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, - 0x67, 0x30, 0x01, 0x42, 0x0d, 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, - 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x67, 0x30, 0x01, 0x32, 0x6b, 0x0a, 0x10, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x73, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x12, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x1a, 0x14, 0x2e, 0x6c, + 0x6f, 0x67, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x28, 0x01, 0x12, 0x2a, 0x0a, 0x04, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x15, 0x2e, 0x6c, + 0x6f, 0x67, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x09, 0x2e, 0x6c, 0x6f, 0x67, 0x73, 0x2e, 0x4c, 0x6f, 0x67, 0x30, 0x01, + 0x42, 0x0d, 0x5a, 0x0b, 0x70, 0x6b, 0x67, 0x2f, 0x6c, 0x6f, 0x67, 0x73, 0x2f, 0x70, 0x62, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/logs/pb/logs.proto b/pkg/logs/pb/logs.proto index e639407f59a..168e19ebb56 100644 --- a/pkg/logs/pb/logs.proto +++ b/pkg/logs/pb/logs.proto @@ -41,6 +41,7 @@ service CloudLogsService { message CloudLogRequest { string environment_id = 1; string execution_id = 2; + string test_name = 3; } diff --git a/pkg/logs/pb/logs_grpc.pb.go b/pkg/logs/pb/logs_grpc.pb.go index 720ec076511..13dac477e57 100644 --- a/pkg/logs/pb/logs_grpc.pb.go +++ b/pkg/logs/pb/logs_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.2.0 +// - protoc-gen-go-grpc v1.3.0 // - protoc v3.19.4 // source: pkg/logs/pb/logs.proto @@ -18,6 +18,10 @@ import ( // Requires gRPC-Go v1.32.0 or later. const _ = grpc.SupportPackageIsVersion7 +const ( + LogsService_Logs_FullMethodName = "/logs.LogsService/Logs" +) + // LogsServiceClient is the client API for LogsService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. @@ -34,7 +38,7 @@ func NewLogsServiceClient(cc grpc.ClientConnInterface) LogsServiceClient { } func (c *logsServiceClient) Logs(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (LogsService_LogsClient, error) { - stream, err := c.cc.NewStream(ctx, &LogsService_ServiceDesc.Streams[0], "/logs.LogsService/Logs", opts...) + stream, err := c.cc.NewStream(ctx, &LogsService_ServiceDesc.Streams[0], LogsService_Logs_FullMethodName, opts...) if err != nil { return nil, err } @@ -131,6 +135,11 @@ var LogsService_ServiceDesc = grpc.ServiceDesc{ Metadata: "pkg/logs/pb/logs.proto", } +const ( + CloudLogsService_Stream_FullMethodName = "/logs.CloudLogsService/Stream" + CloudLogsService_Logs_FullMethodName = "/logs.CloudLogsService/Logs" +) + // CloudLogsServiceClient is the client API for CloudLogsService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. @@ -148,7 +157,7 @@ func NewCloudLogsServiceClient(cc grpc.ClientConnInterface) CloudLogsServiceClie } func (c *cloudLogsServiceClient) Stream(ctx context.Context, opts ...grpc.CallOption) (CloudLogsService_StreamClient, error) { - stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[0], "/logs.CloudLogsService/Stream", opts...) + stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[0], CloudLogsService_Stream_FullMethodName, opts...) if err != nil { return nil, err } @@ -182,7 +191,7 @@ func (x *cloudLogsServiceStreamClient) CloseAndRecv() (*StreamResponse, error) { } func (c *cloudLogsServiceClient) Logs(ctx context.Context, in *CloudLogRequest, opts ...grpc.CallOption) (CloudLogsService_LogsClient, error) { - stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[1], "/logs.CloudLogsService/Logs", opts...) + stream, err := c.cc.NewStream(ctx, &CloudLogsService_ServiceDesc.Streams[1], CloudLogsService_Logs_FullMethodName, opts...) if err != nil { return nil, err } From 5d70876445c2441347129b384040a4b9b0250edf Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 22 Feb 2024 20:59:02 +0300 Subject: [PATCH 117/234] feat: execution namespace model --- api/v1/testkube.yaml | 6 +++ cmd/kubectl-testkube/commands/tests/common.go | 6 +++ cmd/kubectl-testkube/commands/tests/create.go | 2 + .../commands/tests/renderer/test_obj.go | 4 ++ cmd/kubectl-testkube/commands/tests/run.go | 3 ++ go.mod | 2 +- go.sum | 8 +--- pkg/api/v1/client/interface.go | 1 + pkg/api/v1/client/test.go | 2 + pkg/api/v1/testkube/model_execution.go | 2 + .../v1/testkube/model_execution_request.go | 2 + .../model_execution_update_request.go | 2 + pkg/crd/templates/test.tmpl | 5 ++- pkg/mapper/testexecutions/mapper.go | 5 ++- pkg/mapper/tests/kube_openapi.go | 9 ++++- pkg/mapper/tests/openapi_kube.go | 11 ++++- pkg/mapper/testsuiteexecutions/mapper.go | 5 ++- pkg/tcl/mappertcl/testexecutions/mapper.go | 25 ++++++++++++ pkg/tcl/mappertcl/tests/kube_openapi.go | 35 ++++++++++++++++ pkg/tcl/mappertcl/tests/openapi_kube.go | 40 +++++++++++++++++++ .../mappertcl/testsuiteexecutions/mapper.go | 25 ++++++++++++ 21 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 pkg/tcl/mappertcl/testexecutions/mapper.go create mode 100644 pkg/tcl/mappertcl/tests/kube_openapi.go create mode 100644 pkg/tcl/mappertcl/tests/openapi_kube.go create mode 100644 pkg/tcl/mappertcl/testsuiteexecutions/mapper.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index fb0e67940f3..ca834839cff 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -4905,6 +4905,9 @@ components: slavePodRequest: $ref: "#/components/schemas/PodRequest" description: configuration parameters for executed slave pods + executionNamespace: + type: string + description: namespace for test execution (Pro edition only) Artifact: type: object @@ -5550,6 +5553,9 @@ components: slavePodRequest: $ref: "#/components/schemas/PodRequest" description: configuration parameters for executed slave pods + executionNamespace: + type: string + description: namespace for test execution (Pro edition only) ExecutionUpdateRequest: description: test execution request update body diff --git a/cmd/kubectl-testkube/commands/tests/common.go b/cmd/kubectl-testkube/commands/tests/common.go index 50201988136..537f93f7f72 100644 --- a/cmd/kubectl-testkube/commands/tests/common.go +++ b/cmd/kubectl-testkube/commands/tests/common.go @@ -490,6 +490,7 @@ func newExecutionRequestFromFlags(cmd *cobra.Command) (request *testkube.Executi cronJobTemplateReference := cmd.Flag("cronjob-template-reference").Value.String() scraperTemplateReference := cmd.Flag("scraper-template-reference").Value.String() pvcTemplateReference := cmd.Flag("pvc-template-reference").Value.String() + executionNamespace := cmd.Flag("execution-namespace").Value.String() executePostRunScriptBeforeScraping, err := cmd.Flags().GetBool("execute-postrun-script-before-scraping") if err != nil { return nil, err @@ -514,6 +515,7 @@ func newExecutionRequestFromFlags(cmd *cobra.Command) (request *testkube.Executi PvcTemplateReference: pvcTemplateReference, NegativeTest: negativeTest, ExecutePostRunScriptBeforeScraping: executePostRunScriptBeforeScraping, + ExecutionNamespace: executionNamespace, } var fields = []struct { @@ -909,6 +911,10 @@ func newExecutionUpdateRequestFromFlags(cmd *cobra.Command) (request *testkube.E "pvc-template-reference", &request.PvcTemplateReference, }, + { + "execution-namespace", + &request.ExecutionNamespace, + }, } var nonEmpty bool diff --git a/cmd/kubectl-testkube/commands/tests/create.go b/cmd/kubectl-testkube/commands/tests/create.go index eaecf6ebc0b..d7bc5e78d51 100644 --- a/cmd/kubectl-testkube/commands/tests/create.go +++ b/cmd/kubectl-testkube/commands/tests/create.go @@ -67,6 +67,7 @@ type CreateCommonFlags struct { SlavePodLimitsMemory string SlavePodTemplate string SlavePodTemplateReference string + ExecutionNamespace string } // NewCreateTestsCmd is a command tp create new Test Custom Resource @@ -276,6 +277,7 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateCommonFlags) { cmd.Flags().StringVar(&flags.SlavePodLimitsMemory, "slave-pod-limits-memory", "", "slave pod resource limits memory") cmd.Flags().StringVar(&flags.SlavePodTemplate, "slave-pod-template", "", "slave pod template file path for extensions to slave pod template") cmd.Flags().StringVar(&flags.SlavePodTemplateReference, "slave-pod-template-reference", "", "reference to slave pod template to use for the test") + cmd.Flags().StringVar(&flags.ExecutionNamespace, "execution-namespace", "", "namespace for test execution (Pro edition only)") } func validateExecutorTypeAndContent(executorType, contentType string, executors testkube.ExecutorsDetails) error { diff --git a/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go b/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go index 2b8b49264d7..e32a2dd74ef 100644 --- a/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go +++ b/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go @@ -176,6 +176,10 @@ func TestRenderer(client client.Client, ui *ui.UI, obj interface{}) error { ui.Warn(" PVC template reference: ", test.ExecutionRequest.PvcTemplateReference) } + if test.ExecutionRequest.ExecutionNamespace != "" { + ui.Warn(" Execution namespace: ", test.ExecutionRequest.ExecutionNamespace) + } + if test.ExecutionRequest.SlavePodRequest != nil { ui.Warn(" Slave pod request: ") if test.ExecutionRequest.SlavePodRequest.Resources != nil { diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index be12e60c140..d001d61ceba 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -76,6 +76,7 @@ func NewRunTestCmd() *cobra.Command { slavePodLimitsMemory string slavePodTemplate string slavePodTemplateReference string + executionNamespace string ) cmd := &cobra.Command{ @@ -123,6 +124,7 @@ func NewRunTestCmd() *cobra.Command { Context: runningContext, }, ExecutePostRunScriptBeforeScraping: executePostRunScriptBeforeScraping, + ExecutionNamespace: executionNamespace, } var fields = []struct { @@ -400,6 +402,7 @@ func NewRunTestCmd() *cobra.Command { cmd.Flags().StringVar(&slavePodLimitsMemory, "slave-pod-limits-memory", "", "slave pod resource limits memory") cmd.Flags().StringVar(&slavePodTemplate, "slave-pod-template", "", "slave pod template file path for extensions to slave pod template") cmd.Flags().StringVar(&slavePodTemplateReference, "slave-pod-template-reference", "", "reference to slave pod template to use for the test") + cmd.Flags().StringVar(&executionNamespace, "execution-namespace", "", "namespace for test execution (Pro edition only)") return cmd } diff --git a/go.mod b/go.mod index 0368de65ccf..35a9a02070a 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/joshdk/go-junit v1.0.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240222133855-3548c867ee93 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 536574aa257..0fcdc701e2e 100644 --- a/go.sum +++ b/go.sum @@ -356,10 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3 h1:R6xdH//ctWpE18U1GYwzNvq1HLiT9LUJogXkfyKDDGo= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 h1:4hnZi3dMBmpz4SxE9PrsJTG2JA/P5h+4PMrSXjUEEbA= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240222133855-3548c867ee93 h1:Acq/Cnk4gFocS7ZzsvlS+pyHv1fplxqREfskWaUD7Pk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240222133855-3548c867ee93/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= @@ -960,8 +958,6 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index 712ae554c99..4b025b4e8b5 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -229,6 +229,7 @@ type ExecuteTestOptions struct { EnvSecrets []testkube.EnvReference RunningContext *testkube.RunningContext SlavePodRequest *testkube.PodRequest + ExecutionNamespace string } // ExecuteTestSuiteOptions contains test suite run options diff --git a/pkg/api/v1/client/test.go b/pkg/api/v1/client/test.go index 8ecbb571c47..86dd9e540e8 100644 --- a/pkg/api/v1/client/test.go +++ b/pkg/api/v1/client/test.go @@ -169,6 +169,7 @@ func (c TestClient) ExecuteTest(id, executionName string, options ExecuteTestOpt EnvSecrets: options.EnvSecrets, RunningContext: options.RunningContext, SlavePodRequest: options.SlavePodRequest, + ExecutionNamespace: options.ExecutionNamespace, } body, err := json.Marshal(request) @@ -211,6 +212,7 @@ func (c TestClient) ExecuteTests(selector string, concurrencyLevel int, options IsNegativeTestChangedOnRun: options.IsNegativeTestChangedOnRun, RunningContext: options.RunningContext, SlavePodRequest: options.SlavePodRequest, + ExecutionNamespace: options.ExecutionNamespace, } body, err := json.Marshal(request) diff --git a/pkg/api/v1/testkube/model_execution.go b/pkg/api/v1/testkube/model_execution.go index 77cc094d517..7b8e56ceebb 100644 --- a/pkg/api/v1/testkube/model_execution.go +++ b/pkg/api/v1/testkube/model_execution.go @@ -80,4 +80,6 @@ type Execution struct { // test names for artifacts to download from latest executions DownloadArtifactTestNames []string `json:"downloadArtifactTestNames,omitempty"` SlavePodRequest *PodRequest `json:"slavePodRequest,omitempty"` + // namespace for test execution (Pro edition only) + ExecutionNamespace string `json:"executionNamespace,omitempty"` } diff --git a/pkg/api/v1/testkube/model_execution_request.go b/pkg/api/v1/testkube/model_execution_request.go index e6a8334c3af..f4397799245 100644 --- a/pkg/api/v1/testkube/model_execution_request.go +++ b/pkg/api/v1/testkube/model_execution_request.go @@ -100,4 +100,6 @@ type ExecutionRequest struct { // test names for artifacts to download from latest executions DownloadArtifactTestNames []string `json:"downloadArtifactTestNames,omitempty"` SlavePodRequest *PodRequest `json:"slavePodRequest,omitempty"` + // namespace for test execution (Pro edition only) + ExecutionNamespace string `json:"executionNamespace,omitempty"` } diff --git a/pkg/api/v1/testkube/model_execution_update_request.go b/pkg/api/v1/testkube/model_execution_update_request.go index 13f762d2087..4504323d7b9 100644 --- a/pkg/api/v1/testkube/model_execution_update_request.go +++ b/pkg/api/v1/testkube/model_execution_update_request.go @@ -100,4 +100,6 @@ type ExecutionUpdateRequest struct { // test names for artifacts to download from latest executions DownloadArtifactTestNames *[]string `json:"downloadArtifactTestNames,omitempty"` SlavePodRequest **PodUpdateRequest `json:"slavePodRequest,omitempty"` + // namespace for test execution (Pro edition only) + ExecutionNamespace *string `json:"executionNamespace,omitempty"` } diff --git a/pkg/crd/templates/test.tmpl b/pkg/crd/templates/test.tmpl index 0eb3e951765..c279d0fefb8 100644 --- a/pkg/crd/templates/test.tmpl +++ b/pkg/crd/templates/test.tmpl @@ -82,7 +82,7 @@ spec: schedule: {{ .Schedule }} {{- end }} {{- if .ExecutionRequest }} - {{- if or (.ExecutionRequest.Name) (.ExecutionRequest.NegativeTest) (.ExecutionRequest.VariablesFile) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (ne (len .ExecutionRequest.Envs) 0) (ne (len .ExecutionRequest.SecretEnvs) 0) (.ExecutionRequest.Image) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.ImagePullSecrets) 0) (ne .ExecutionRequest.ActiveDeadlineSeconds 0) (.ExecutionRequest.ArtifactRequest) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.PreRunScript) (.ExecutionRequest.PostRunScript) (.ExecutionRequest.ExecutePostRunScriptBeforeScraping) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (ne (len .ExecutionRequest.EnvConfigMaps) 0) (ne (len .ExecutionRequest.EnvSecrets) 0) (.ExecutionRequest.SlavePodRequest)}} + {{- if or (.ExecutionRequest.Name) (.ExecutionRequest.NegativeTest) (.ExecutionRequest.VariablesFile) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (ne (len .ExecutionRequest.Envs) 0) (ne (len .ExecutionRequest.SecretEnvs) 0) (.ExecutionRequest.Image) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.ImagePullSecrets) 0) (ne .ExecutionRequest.ActiveDeadlineSeconds 0) (.ExecutionRequest.ArtifactRequest) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.PreRunScript) (.ExecutionRequest.PostRunScript) (.ExecutionRequest.ExecutePostRunScriptBeforeScraping) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (ne (len .ExecutionRequest.EnvConfigMaps) 0) (ne (len .ExecutionRequest.EnvSecrets) 0) (.ExecutionRequest.SlavePodRequest) (.ExecutionRequest.ExecutionNamespace)}} executionRequest: {{- if .ExecutionRequest.Name }} name: {{ .ExecutionRequest.Name }} @@ -235,6 +235,9 @@ spec: {{- if .ExecutionRequest.PvcTemplateReference }} pvcTemplateReference: {{ .ExecutionRequest.PvcTemplateReference }} {{- end }} + {{- if .ExecutionRequest.ExecutionNamespace }} + executionNamespace: {{ .ExecutionRequest.ExecutionNamespace }} + {{- end }} {{- if .ExecutionRequest.SlavePodRequest }} slavePodRequest: {{- if .ExecutionRequest.SlavePodRequest.Resources }} diff --git a/pkg/mapper/testexecutions/mapper.go b/pkg/mapper/testexecutions/mapper.go index f7fcc8d36ec..4fae363c70f 100644 --- a/pkg/mapper/testexecutions/mapper.go +++ b/pkg/mapper/testexecutions/mapper.go @@ -5,6 +5,7 @@ import ( testexecutionv1 "github.com/kubeshop/testkube-operator/api/testexecution/v1" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + mappertcl "github.com/kubeshop/testkube/pkg/tcl/mappertcl/testexecutions" ) // MapCRDVariables maps variables between API and operator CRDs @@ -216,5 +217,7 @@ func MapAPIToCRD(request *testkube.Execution, generation int64) testexecutionv1. result.LatestExecution.StartTime.Time = request.StartTime result.LatestExecution.EndTime.Time = request.EndTime - return result + + // Pro edition only (tcl protected code) + return *mappertcl.MapAPIToCRD(request, &result) } diff --git a/pkg/mapper/tests/kube_openapi.go b/pkg/mapper/tests/kube_openapi.go index fbe4baae632..57ac91afab8 100644 --- a/pkg/mapper/tests/kube_openapi.go +++ b/pkg/mapper/tests/kube_openapi.go @@ -6,6 +6,7 @@ import ( commonv1 "github.com/kubeshop/testkube-operator/api/common/v1" testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + mappertcl "github.com/kubeshop/testkube/pkg/tcl/mappertcl/tests" ) // MapTestListKubeToAPI maps CRD list data to OpenAPI spec tests list @@ -153,7 +154,7 @@ func MapExecutionRequestFromSpec(specExecutionRequest *testsv3.ExecutionRequest) podRequest.PodTemplateReference = specExecutionRequest.SlavePodRequest.PodTemplateReference } - return &testkube.ExecutionRequest{ + result := &testkube.ExecutionRequest{ Name: specExecutionRequest.Name, TestSuiteName: specExecutionRequest.TestSuiteName, Number: specExecutionRequest.Number, @@ -192,6 +193,9 @@ func MapExecutionRequestFromSpec(specExecutionRequest *testsv3.ExecutionRequest) EnvSecrets: MapEnvReferences(specExecutionRequest.EnvSecrets), SlavePodRequest: podRequest, } + + // Pro edition only (tcl protected code) + return mappertcl.MapExecutionRequestFromSpec(specExecutionRequest, result) } // MapImagePullSecrets maps Kubernetes spec to testkube model @@ -519,6 +523,9 @@ func MapSpecExecutionRequestToExecutionUpdateRequest( executionRequest.EnvSecrets = &envSecrets executionRequest.ExecutePostRunScriptBeforeScraping = &request.ExecutePostRunScriptBeforeScraping + // Pro edition only (tcl protected code) + mappertcl.MapSpecExecutionRequestToExecutionUpdateRequest(request, executionRequest) + if request.ArtifactRequest != nil { artifactRequest := &testkube.ArtifactUpdateRequest{ StorageClassName: &request.ArtifactRequest.StorageClassName, diff --git a/pkg/mapper/tests/openapi_kube.go b/pkg/mapper/tests/openapi_kube.go index 919583ced42..fab9532b609 100644 --- a/pkg/mapper/tests/openapi_kube.go +++ b/pkg/mapper/tests/openapi_kube.go @@ -7,6 +7,7 @@ import ( testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + mappertcl "github.com/kubeshop/testkube/pkg/tcl/mappertcl/tests" ) // MapUpsertToSpec maps TestUpsertRequest to Test CRD spec @@ -165,7 +166,7 @@ func MapExecutionRequestToSpecExecutionRequest(executionRequest *testkube.Execut podRequest.PodTemplateReference = executionRequest.SlavePodRequest.PodTemplateReference } - return &testsv3.ExecutionRequest{ + result := &testsv3.ExecutionRequest{ Name: executionRequest.Name, TestSuiteName: executionRequest.TestSuiteName, Number: executionRequest.Number, @@ -204,6 +205,9 @@ func MapExecutionRequestToSpecExecutionRequest(executionRequest *testkube.Execut EnvSecrets: mapEnvReferences(executionRequest.EnvSecrets), SlavePodRequest: podRequest, } + + // Pro edition only (tcl protected code) + return mappertcl.MapExecutionRequestToSpecExecutionRequest(executionRequest, result) } func mapImagePullSecrets(secrets []testkube.LocalObjectReference) (res []v1.LocalObjectReference) { @@ -627,6 +631,11 @@ func MapExecutionUpdateRequestToSpecExecutionRequest(executionRequest *testkube. emptyExecution = false } + // Pro edition only (tcl protected code) + if !mappertcl.MapExecutionUpdateRequestToSpecExecutionRequest(executionRequest, request) { + emptyExecution = false + } + if executionRequest.ArtifactRequest != nil { emptyArtifact := true if !(*executionRequest.ArtifactRequest == nil || (*executionRequest.ArtifactRequest).IsEmpty()) { diff --git a/pkg/mapper/testsuiteexecutions/mapper.go b/pkg/mapper/testsuiteexecutions/mapper.go index 80fd67f8f32..006f017bba8 100644 --- a/pkg/mapper/testsuiteexecutions/mapper.go +++ b/pkg/mapper/testsuiteexecutions/mapper.go @@ -6,6 +6,7 @@ import ( testsuiteexecutionv1 "github.com/kubeshop/testkube-operator/api/testsuiteexecution/v1" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + mappertcl "github.com/kubeshop/testkube/pkg/tcl/mappertcl/testsuiteexecutions" ) // MapCRDVariables maps variables between API and operator CRDs @@ -218,7 +219,9 @@ func MapExecutionCRD(request *testkube.Execution) *testsuiteexecutionv1.Executio result.StartTime.Time = request.StartTime result.EndTime.Time = request.EndTime - return result + + // Pro edition only (tcl protected code) + return mappertcl.MapExecutionCRD(request, result) } func MapTestSuiteStepV2ToCRD(request *testkube.TestSuiteStepV2) *testsuiteexecutionv1.TestSuiteStepV2 { diff --git a/pkg/tcl/mappertcl/testexecutions/mapper.go b/pkg/tcl/mappertcl/testexecutions/mapper.go new file mode 100644 index 00000000000..1f90da61d2f --- /dev/null +++ b/pkg/tcl/mappertcl/testexecutions/mapper.go @@ -0,0 +1,25 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testexecutions + +import ( + testexecutionv1 "github.com/kubeshop/testkube-operator/api/testexecution/v1" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// MapAPIToCRD maps OpenAPI spec Execution to CRD TestExecutionStatus +func MapAPIToCRD(sourceRequest *testkube.Execution, + destinationRequest *testexecutionv1.TestExecutionStatus) *testexecutionv1.TestExecutionStatus { + if sourceRequest == nil || destinationRequest == nil { + return destinationRequest + } + + destinationRequest.LatestExecution.ExecutionNamespace = sourceRequest.ExecutionNamespace + return destinationRequest +} diff --git a/pkg/tcl/mappertcl/tests/kube_openapi.go b/pkg/tcl/mappertcl/tests/kube_openapi.go new file mode 100644 index 00000000000..360df1f93a5 --- /dev/null +++ b/pkg/tcl/mappertcl/tests/kube_openapi.go @@ -0,0 +1,35 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package tests + +import ( + testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// MapExecutionRequestFromSpec maps CRD to OpenAPI spec ExecutionREquest +func MapExecutionRequestFromSpec(sourceRequest *testsv3.ExecutionRequest, + destinationRequest *testkube.ExecutionRequest) *testkube.ExecutionRequest { + if sourceRequest == nil || destinationRequest == nil { + return destinationRequest + } + + destinationRequest.ExecutionNamespace = sourceRequest.ExecutionNamespace + return destinationRequest +} + +// MapSpecExecutionRequestToExecutionUpdateRequest maps ExecutionRequest CRD spec to ExecutionUpdateRequest OpenAPI spec to +func MapSpecExecutionRequestToExecutionUpdateRequest( + sourceRequest *testsv3.ExecutionRequest, destinationRequest *testkube.ExecutionUpdateRequest) { + if sourceRequest == nil || destinationRequest == nil { + return + } + + destinationRequest.ExecutionNamespace = &sourceRequest.ExecutionNamespace +} diff --git a/pkg/tcl/mappertcl/tests/openapi_kube.go b/pkg/tcl/mappertcl/tests/openapi_kube.go new file mode 100644 index 00000000000..bb531e99cad --- /dev/null +++ b/pkg/tcl/mappertcl/tests/openapi_kube.go @@ -0,0 +1,40 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package tests + +import ( + testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// MapExecutionRequestToSpecExecutionRequest maps ExecutionRequest OpenAPI spec to ExecutionRequest CRD spec +func MapExecutionRequestToSpecExecutionRequest(sourceRequest *testkube.ExecutionRequest, + destinationRequest *testsv3.ExecutionRequest) *testsv3.ExecutionRequest { + if sourceRequest == nil || destinationRequest == nil { + return destinationRequest + } + + destinationRequest.ExecutionNamespace = sourceRequest.ExecutionNamespace + return destinationRequest +} + +// MapExecutionUpdateRequestToSpecExecutionRequest maps ExecutionUpdateRequest OpenAPI spec to ExecutionRequest CRD spec +func MapExecutionUpdateRequestToSpecExecutionRequest(sourceRequest *testkube.ExecutionUpdateRequest, + destinationRequest *testsv3.ExecutionRequest) bool { + if sourceRequest == nil || destinationRequest == nil { + return true + } + + if sourceRequest.ExecutionNamespace != nil { + destinationRequest.ExecutionNamespace = *sourceRequest.ExecutionNamespace + return false + } + + return true +} diff --git a/pkg/tcl/mappertcl/testsuiteexecutions/mapper.go b/pkg/tcl/mappertcl/testsuiteexecutions/mapper.go new file mode 100644 index 00000000000..d06e31b8d47 --- /dev/null +++ b/pkg/tcl/mappertcl/testsuiteexecutions/mapper.go @@ -0,0 +1,25 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testsuiteexecutions + +import ( + testsuiteexecutionv1 "github.com/kubeshop/testkube-operator/api/testsuiteexecution/v1" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// MapExecutionCRD maps OpenAPI spec Execution to CRD +func MapExecutionCRD(sourceRequest *testkube.Execution, + destinationRequest *testsuiteexecutionv1.Execution) *testsuiteexecutionv1.Execution { + if sourceRequest == nil || destinationRequest == nil { + return destinationRequest + } + + destinationRequest.ExecutionNamespace = sourceRequest.ExecutionNamespace + return destinationRequest +} From efd84fabedf95cb001a61db363416d7c54941a04 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Fri, 23 Feb 2024 11:38:49 +0100 Subject: [PATCH 118/234] feat: [TKC-1055] test suite steps (#5056) * docs: fix README * feat: test suite steps * docs: test step arg docs * feat: test suite step executions persistence * fix: cleanup * fix: propagate operator code review changes * fix: remove outdated reference * ci: bump linter * fix: remove unnecessary file * fix: mark pro/enterprise features --- .github/workflows/lint.yaml | 2 +- api/v1/testkube.yaml | 84 +++++ cmd/api-server/main.go | 5 + .../running-parallel-tests-with-test-suite.md | 83 ++++- go.mod | 2 +- go.sum | 2 + internal/app/api/v1/server.go | 10 + internal/app/api/v1/testsuites.go | 34 +- pkg/api/v1/testkube/model_test_suite_step.go | 3 +- ...model_test_suite_step_execution_request.go | 48 +++ pkg/crd/templates/testsuite.tmpl | 296 +++++++++++++++++- pkg/mapper/testsuites/kube_openapi.go | 3 + pkg/mapper/testsuites/openapi_kube.go | 3 + pkg/scheduler/testsuite_scheduler.go | 6 + pkg/tcl/README.md | 2 +- pkg/tcl/testsuitestcl/steps.go | 202 ++++++++++++ pkg/tcl/testsuitestcl/steps_test.go | 106 +++++++ 17 files changed, 881 insertions(+), 10 deletions(-) create mode 100644 pkg/api/v1/testkube/model_test_suite_step_execution_request.go create mode 100644 pkg/tcl/testsuitestcl/steps.go create mode 100644 pkg/tcl/testsuitestcl/steps_test.go diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 1737d7b9deb..3ebd9165abb 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -47,7 +47,7 @@ jobs: ${{ runner.os }}-go- - name: Lint using golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v4 with: version: latest args: --timeout=5m diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index fb0e67940f3..b6e258894c2 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -4173,6 +4173,9 @@ components: format: duration example: 1s description: delay duration in time units + executionRequest: + $ref: "#/components/schemas/TestSuiteStepExecutionRequest" + description: test suite step execution request parameters TestSuiteStepV2: type: object @@ -5551,6 +5554,87 @@ components: $ref: "#/components/schemas/PodRequest" description: configuration parameters for executed slave pods + TestSuiteStepExecutionRequest: + description: test step execution request body + type: object + readOnly: true + properties: + executionLabels: + type: object + description: "test execution labels" + additionalProperties: + type: string + example: + users: "3" + prefix: "some-" + variables: + $ref: "#/components/schemas/Variables" + command: + type: array + description: "executor image command" + items: + type: string + example: + - "curl" + args: + type: array + description: "additional executor binary arguments" + items: + type: string + example: + - "--repeats" + - "5" + - "--insecure" + args_mode: + type: string + description: usage mode for arguments + enum: + - append + - override + - replace + sync: + type: boolean + description: whether to start execution sync or async + httpProxy: + type: string + description: http proxy for executor containers + example: user:pass@my.proxy.server:8080 + httpsProxy: + type: string + description: https proxy for executor containers + example: user:pass@my.proxy.server:8081 + negativeTest: + type: boolean + description: whether to run test as negative test + example: false + jobTemplate: + type: string + description: job template extensions + jobTemplateReference: + type: string + description: name of the template resource + cronJobTemplate: + type: string + description: cron job template extensions + cronJobTemplateReference: + type: string + description: name of the template resource + scraperTemplate: + type: string + description: scraper template extensions + scraperTemplateReference: + type: string + description: name of the template resource + pvcTemplate: + type: string + description: pvc template extensions + pvcTemplateReference: + type: string + description: name of the template resource + runningContext: + $ref: "#/components/schemas/RunningContext" + description: running context for the test execution + ExecutionUpdateRequest: description: test execution request update body type: object diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 7d50f058de6..5c56fe25cab 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -17,6 +17,7 @@ import ( executorsclientv1 "github.com/kubeshop/testkube-operator/pkg/client/executors/v1" "github.com/kubeshop/testkube/pkg/imageinspector" apitclv1 "github.com/kubeshop/testkube/pkg/tcl/apitcl/v1" + "github.com/kubeshop/testkube/pkg/tcl/checktcl" "go.mongodb.org/mongo-driver/mongo" "google.golang.org/grpc" @@ -542,6 +543,10 @@ func main() { } api.WithProContext(proContext) + // Check Pro/Enterprise subscription + subscriptionChecker, err := checktcl.NewSubscriptionChecker(ctx, *proContext, grpcClient, grpcConn) + ui.WarnOnError("Creating subscription checker", err) + api.WithSubscriptionChecker(*subscriptionChecker) agentHandle, err := agent.NewAgent( log.DefaultLogger, diff --git a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md index c00a099bc19..170fa08e597 100644 --- a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md +++ b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md @@ -1,6 +1,6 @@ # Advanced Test Orchestration -Creating Test Suites with Tracetest allows for the orchestration of tests. Individual tests that can be run at the same time, in parallel, helps to speed up overall testing. +Creating Test Suites with Testkube allows for the orchestration of tests. Individual tests that can be run at the same time, in parallel, helps to speed up overall testing. ## Running Parallel Tests in a Test Suite @@ -26,4 +26,83 @@ For this test suite, we have added 5 tests that all run in parallel: Here is an example of a Test Suite sequence with 2 tests running in parallel and, when they complete, a single test runs, then 2 addtional parallel tests: -![Test and Order of Execution](../../img/test-and-order-of-execution.png) \ No newline at end of file +![Test and Order of Execution](../../img/test-and-order-of-execution.png) + +## Test Suite Steps + +Test Suite Steps can be of two types: + +1. Tests: tests to be run. +2. Delays: time delays to wait in between tests. + +Similarly to running a Test, running a Test Suite Step based on a test allows for specific execution request parameters to be overwritten. Step level parameters overwrite Test Suite level parameters, which in turn overwrite Test level parameters. The Step level parameters are configurable only via CRDs at the moment. + +For details on which parameters are available in the CRDs, please consult the table below: + +| Parameter | Test | Test Suite | Test Step | +| ---------------------------------- | ---- | ---------- | --------- | +| name | ✓ | ✓ | | +| testSuiteName | ✓ | | | +| number | ✓ | | | +| executionLabels | ✓ | ✓ | ✓ | +| namespace | ✓ | ✓ | | +| variablesFile | ✓ | | | +| isVariablesFileUploaded | ✓ | | | +| variables | ✓ | ✓ | | +| testSecretUUID | ✓ | | | +| testSuiteSecretUUID | ✓ | | | +| args | ✓ | | ✓ | +| argsMode | ✓ | | ✓ | +| command | ✓ | | ✓ | +| image | ✓ | | | +| imagePullSecrets | ✓ | | | +| sync | ✓ | ✓ | ✓ | +| httpProxy | ✓ | ✓ | ✓ | +| httpsProxy | ✓ | ✓ | ✓ | +| negativeTest | ✓ | | | +| activeDeadlineSeconds | ✓ | | | +| artifactRequest | ✓ | | | +| jobTemplate | ✓ | ✓ | ✓ | +| jobTemplateReference | ✓ | ✓ | ✓ | +| cronJobTemplate | ✓ | ✓ | ✓ | +| cronJobTemplateReference | ✓ | ✓ | ✓ | +| preRunScript | ✓ | | | +| postRunScript | ✓ | | | +| executePostRunScriptBeforeScraping | ✓ | | | +| scraperTemplate | ✓ | ✓ | ✓ | +| scraperTemplateReference | ✓ | ✓ | ✓ | +| pvcTemplate | ✓ | ✓ | ✓ | +| pvcTemplateReference | ✓ | ✓ | ✓ | +| envConfigMaps | ✓ | | | +| envSecrets | ✓ | | | +| runningContext | ✓ | ✓ | ✓ | +| slavePodRequest | ✓ | | | +| secretUUID | | ✓ | | +| labels | | ✓ | | +| timeout | | ✓ | | + +Similarly to Tests and Test Suites, Test Suite Steps can also have a field of type `executionRequest` like in the example below: + +```bash +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: jmeter-special-cases + namespace: testkube + labels: + core-tests: special-cases +spec: + description: "jmeter and jmeterd executor - special-cases" + steps: + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-custom-envs-replication + executionRequest: + args: ["-d", "-s"] // <- new field + ... + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-env-value-in-args +``` + +The `Definition` section of each Test Suite in the Testkube UI offers the opportunity to directly edit the Test Suite CRDs. Besides that, consider also using `kubectl edit testsuite/jmeter-special-cases -n testkube`. diff --git a/go.mod b/go.mod index 0368de65ccf..27e67d7a167 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/joshdk/go-junit v1.0.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223085500-6396dbe900f3 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 536574aa257..9639388ee87 100644 --- a/go.sum +++ b/go.sum @@ -360,6 +360,8 @@ github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 h1:4hnZi3dMBmpz4SxE9PrsJTG2JA/P5h+4PMrSXjUEEbA= github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223085500-6396dbe900f3 h1:6DXb2h8gfC5rULKLaOibXOzh9AeKV3t0HkeuNYtyD0U= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223085500-6396dbe900f3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 68e77e1ccec..84948f8b5ff 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -16,6 +16,7 @@ import ( "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/pkg/api/v1/testkube" repoConfig "github.com/kubeshop/testkube/pkg/repository/config" + "github.com/kubeshop/testkube/pkg/tcl/checktcl" "github.com/kubeshop/testkube/pkg/version" @@ -203,6 +204,7 @@ type TestkubeAPI struct { logGrpcClient logsclient.StreamGetter proContext *config.ProContext disableSecretCreation bool + SubscriptionChecker checktcl.SubscriptionChecker } type storageParams struct { @@ -594,7 +596,15 @@ func getFilterFromRequest(c *fiber.Ctx) result.Filter { return filter } +// WithProContext sets pro context for the API func (s *TestkubeAPI) WithProContext(proContext *config.ProContext) *TestkubeAPI { s.proContext = proContext return s } + +// WithSubscriptionChecker sets subscription checker for the API +// This is used to check if Pro/Enterprise subscription is valid +func (s *TestkubeAPI) WithSubscriptionChecker(subscriptionChecker checktcl.SubscriptionChecker) *TestkubeAPI { + s.SubscriptionChecker = subscriptionChecker + return s +} diff --git a/internal/app/api/v1/testsuites.go b/internal/app/api/v1/testsuites.go index 34e635bf099..adccbf86660 100644 --- a/internal/app/api/v1/testsuites.go +++ b/internal/app/api/v1/testsuites.go @@ -27,6 +27,7 @@ import ( testsuitesmapper "github.com/kubeshop/testkube/pkg/mapper/testsuites" "github.com/kubeshop/testkube/pkg/repository/testresult" "github.com/kubeshop/testkube/pkg/scheduler" + "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" "github.com/kubeshop/testkube/pkg/types" "github.com/kubeshop/testkube/pkg/utils" "github.com/kubeshop/testkube/pkg/workerpool" @@ -43,7 +44,16 @@ func (s TestkubeAPI) CreateTestSuiteHandler() fiber.Handler { if err := decoder.Decode(&testSuite); err != nil { return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse yaml request: %w", errPrefix, err)) } - + // Pro/Enterprise feature: step execution requests + if testsuitestcl.HasStepsExecutionRequest(testSuite) { + ok, err := s.SubscriptionChecker.IsOrgPlanActive() + if err != nil { + return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are a Pro feature: %w", errPrefix, err)) + } + if !ok { + return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are not available: inactive subscription plan", errPrefix)) + } + } errPrefix = errPrefix + " " + testSuite.Name } else { var request testkube.TestSuiteUpsertRequest @@ -116,7 +126,16 @@ func (s TestkubeAPI) UpdateTestSuiteHandler() fiber.Handler { if err := decoder.Decode(&testSuite); err != nil { return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse yaml request: %w", errPrefix, err)) } - + // Pro/Enterprise feature: step execution requests + if testsuitestcl.HasStepsExecutionRequest(testSuite) { + ok, err := s.SubscriptionChecker.IsOrgPlanActive() + if err != nil { + return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are a Pro feature: %w", errPrefix, err)) + } + if !ok { + return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are not available: inactive subscription plan", errPrefix)) + } + } request = testsuitesmapper.MapTestSuiteTestCRDToUpdateRequest(&testSuite) } else { data := c.Body() @@ -564,7 +583,16 @@ func (s TestkubeAPI) ExecuteTestSuitesHandler() fiber.Handler { return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could get test suite: %w", errPrefix, err)) } - + // Pro/Enterprise feature: step execution requests + if testsuitestcl.HasStepsExecutionRequest(*testSuite) { + ok, err := s.SubscriptionChecker.IsOrgPlanActive() + if err != nil { + return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are a pro feature: %w", errPrefix, err)) + } + if !ok { + return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are not available: inactive subscription plan", errPrefix)) + } + } testSuites = append(testSuites, *testSuite) } else { testSuiteList, err := s.TestsSuitesClient.List(selector) diff --git a/pkg/api/v1/testkube/model_test_suite_step.go b/pkg/api/v1/testkube/model_test_suite_step.go index 1e1020dce2f..66efe7a308c 100644 --- a/pkg/api/v1/testkube/model_test_suite_step.go +++ b/pkg/api/v1/testkube/model_test_suite_step.go @@ -13,5 +13,6 @@ type TestSuiteStep struct { // object name Test string `json:"test,omitempty"` // delay duration in time units - Delay string `json:"delay,omitempty"` + Delay string `json:"delay,omitempty"` + ExecutionRequest *TestSuiteStepExecutionRequest `json:"executionRequest,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_suite_step_execution_request.go b/pkg/api/v1/testkube/model_test_suite_step_execution_request.go new file mode 100644 index 00000000000..85d64b5e1b6 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_suite_step_execution_request.go @@ -0,0 +1,48 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// test step execution request body +type TestSuiteStepExecutionRequest struct { + // test execution labels + ExecutionLabels map[string]string `json:"executionLabels,omitempty"` + Variables map[string]Variable `json:"variables,omitempty"` + // executor image command + Command []string `json:"command,omitempty"` + // additional executor binary arguments + Args []string `json:"args,omitempty"` + // usage mode for arguments + ArgsMode string `json:"args_mode,omitempty"` + // whether to start execution sync or async + Sync bool `json:"sync,omitempty"` + // http proxy for executor containers + HttpProxy string `json:"httpProxy,omitempty"` + // https proxy for executor containers + HttpsProxy string `json:"httpsProxy,omitempty"` + // whether to run test as negative test + NegativeTest bool `json:"negativeTest,omitempty"` + // job template extensions + JobTemplate string `json:"jobTemplate,omitempty"` + // name of the template resource + JobTemplateReference string `json:"jobTemplateReference,omitempty"` + // cron job template extensions + CronJobTemplate string `json:"cronJobTemplate,omitempty"` + // name of the template resource + CronJobTemplateReference string `json:"cronJobTemplateReference,omitempty"` + // scraper template extensions + ScraperTemplate string `json:"scraperTemplate,omitempty"` + // name of the template resource + ScraperTemplateReference string `json:"scraperTemplateReference,omitempty"` + // pvc template extensions + PvcTemplate string `json:"pvcTemplate,omitempty"` + // name of the template resource + PvcTemplateReference string `json:"pvcTemplateReference,omitempty"` + RunningContext *RunningContext `json:"runningContext,omitempty"` +} diff --git a/pkg/crd/templates/testsuite.tmpl b/pkg/crd/templates/testsuite.tmpl index 6e26a4bf19f..54e4c4c12d8 100644 --- a/pkg/crd/templates/testsuite.tmpl +++ b/pkg/crd/templates/testsuite.tmpl @@ -38,6 +38,104 @@ spec: {{- range .Execute }} {{- if .Test }} - test: {{ .Test }} + {{- if .ExecutionRequest }} + {{- if or (ne (len .ExecutionRequest.ExecutionLabels) 0) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (.ExecutionRequest.RunningContext)}} + executionRequest: + {{- if ne (len .ExecutionRequest.ExecutionLabels) 0 }} + executionLabels: + {{- range $key, $value := .ExecutionRequest.ExecutionLabels }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if ne (len .ExecutionRequest.Variables) 0 }} + variables: + {{- range $key, $value := .ExecutionRequest.Variables }} + {{ $key }}: + name: {{ $key }} + {{- if $value.Value }} + value: {{ $value.Value }} + {{- end }} + {{- if $value.Type_ }} + type: {{ $value.Type_ }} + {{- end }} + {{- if $value.SecretRef }} + valueFrom: + secretKeyRef: + {{- if $value.SecretRef.Name }} + name: {{ $value.SecretRef.Name }} + {{- end }} + {{- if $value.SecretRef.Key }} + key: {{ $value.SecretRef.Key }} + {{- end }} + {{- end }} + {{- if $value.ConfigMapRef }} + valueFrom: + configMapKeyRef: + {{- if $value.ConfigMapRef.Name }} + name: {{ $value.ConfigMapRef.Name }} + {{- end }} + {{- if $value.ConfigMapRef.Key }} + key: {{ $value.ConfigMapRef.Key }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- if ne (len .ExecutionRequest.Args) 0 }} + args: + {{- range .ExecutionRequest.Args }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .ExecutionRequest.ArgsMode }} + argsMode: {{ .ExecutionRequest.ArgsMode }} + {{- end }} + {{- if gt (len .ExecutionRequest.Command) 0 }} + command: + {{- range $cmd := .ExecutionRequest.Command }} + - {{ $cmd -}} + {{- end }} + {{- end -}} + {{- if .ExecutionRequest.Sync }} + sync: {{ .ExecutionRequest.Sync }} + {{- end }} + {{- if .ExecutionRequest.HttpProxy }} + httpProxy: {{ .ExecutionRequest.HttpProxy }} + {{- end }} + {{- if .ExecutionRequest.HttpsProxy }} + httpsProxy: {{ .ExecutionRequest.HttpsProxy }} + {{- end }} + {{- if .ExecutionRequest.NegativeTest }} + negativeTest: {{ .ExecutionRequest.NegativeTest }} + {{- end }} + {{- if .ExecutionRequest.JobTemplate }} + jobTemplate: {{ .ExecutionRequest.JobTemplate }} + {{- end }} + {{- if .ExecutionRequest.JobTemplateReference }} + jobTemplateReference: {{ .ExecutionRequest.JobTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.CronJobTemplate }} + cronJobTemplate: {{ .ExecutionRequest.CronJobTemplate }} + {{- end }} + {{- if .ExecutionRequest.CronJobTemplateReference }} + cronJobTemplateReference: {{ .ExecutionRequest.CronJobTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.ScraperTemplate }} + scraperTemplate: {{ .ExecutionRequest.ScraperTemplate }} + {{- end }} + {{- if .ExecutionRequest.ScraperTemplateReference }} + scraperTemplateReference: {{ .ExecutionRequest.ScraperTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.PvcTemplate }} + pvcTemplate: {{ .ExecutionRequest.PvcTemplate }} + {{- end }} + {{- if .ExecutionRequest.PvcTemplateReference }} + pvcTemplateReference: {{ .ExecutionRequest.PvcTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.RunningContext }} + runningContext: {{ .ExecutionRequest.RunningContext }} + {{- end }} + {{- end }} + {{- end }} {{- end }} {{- if .Delay }} - delay: {{ .Delay }} @@ -71,6 +169,104 @@ spec: {{- range .Execute }} {{- if .Test }} - test: {{ .Test }} + {{- if .ExecutionRequest }} + {{- if or (ne (len .ExecutionRequest.ExecutionLabels) 0) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (.ExecutionRequest.RunningContext)}} + executionRequest: + {{- if ne (len .ExecutionRequest.ExecutionLabels) 0 }} + executionLabels: + {{- range $key, $value := .ExecutionRequest.ExecutionLabels }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if ne (len .ExecutionRequest.Variables) 0 }} + variables: + {{- range $key, $value := .ExecutionRequest.Variables }} + {{ $key }}: + name: {{ $key }} + {{- if $value.Value }} + value: {{ $value.Value }} + {{- end }} + {{- if $value.Type_ }} + type: {{ $value.Type_ }} + {{- end }} + {{- if $value.SecretRef }} + valueFrom: + secretKeyRef: + {{- if $value.SecretRef.Name }} + name: {{ $value.SecretRef.Name }} + {{- end }} + {{- if $value.SecretRef.Key }} + key: {{ $value.SecretRef.Key }} + {{- end }} + {{- end }} + {{- if $value.ConfigMapRef }} + valueFrom: + configMapKeyRef: + {{- if $value.ConfigMapRef.Name }} + name: {{ $value.ConfigMapRef.Name }} + {{- end }} + {{- if $value.ConfigMapRef.Key }} + key: {{ $value.ConfigMapRef.Key }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- if ne (len .ExecutionRequest.Args) 0 }} + args: + {{- range .ExecutionRequest.Args }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .ExecutionRequest.ArgsMode }} + argsMode: {{ .ExecutionRequest.ArgsMode }} + {{- end }} + {{- if gt (len .ExecutionRequest.Command) 0 }} + command: + {{- range $cmd := .ExecutionRequest.Command }} + - {{ $cmd -}} + {{- end }} + {{- end -}} + {{- if .ExecutionRequest.Sync }} + sync: {{ .ExecutionRequest.Sync }} + {{- end }} + {{- if .ExecutionRequest.HttpProxy }} + httpProxy: {{ .ExecutionRequest.HttpProxy }} + {{- end }} + {{- if .ExecutionRequest.HttpsProxy }} + httpsProxy: {{ .ExecutionRequest.HttpsProxy }} + {{- end }} + {{- if .ExecutionRequest.NegativeTest }} + negativeTest: {{ .ExecutionRequest.NegativeTest }} + {{- end }} + {{- if .ExecutionRequest.JobTemplate }} + jobTemplate: {{ .ExecutionRequest.JobTemplate }} + {{- end }} + {{- if .ExecutionRequest.JobTemplateReference }} + jobTemplateReference: {{ .ExecutionRequest.JobTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.CronJobTemplate }} + cronJobTemplate: {{ .ExecutionRequest.CronJobTemplate }} + {{- end }} + {{- if .ExecutionRequest.CronJobTemplateReference }} + cronJobTemplateReference: {{ .ExecutionRequest.CronJobTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.ScraperTemplate }} + scraperTemplate: {{ .ExecutionRequest.ScraperTemplate }} + {{- end }} + {{- if .ExecutionRequest.ScraperTemplateReference }} + scraperTemplateReference: {{ .ExecutionRequest.ScraperTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.PvcTemplate }} + pvcTemplate: {{ .ExecutionRequest.PvcTemplate }} + {{- end }} + {{- if .ExecutionRequest.PvcTemplateReference }} + pvcTemplateReference: {{ .ExecutionRequest.PvcTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.RunningContext }} + runningContext: {{ .ExecutionRequest.RunningContext }} + {{- end }} + {{- end }} + {{- end }} {{- end }} {{- if .Delay }} - delay: {{ .Delay }} @@ -104,10 +300,108 @@ spec: {{- range .Execute }} {{- if .Test }} - test: {{ .Test }} + {{- if .ExecutionRequest }} + {{- if or (ne (len .ExecutionRequest.ExecutionLabels) 0) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (.ExecutionRequest.RunningContext)}} + executionRequest: + {{- if ne (len .ExecutionRequest.ExecutionLabels) 0 }} + executionLabels: + {{- range $key, $value := .ExecutionRequest.ExecutionLabels }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if ne (len .ExecutionRequest.Variables) 0 }} + variables: + {{- range $key, $value := .ExecutionRequest.Variables }} + {{ $key }}: + name: {{ $key }} + {{- if $value.Value }} + value: {{ $value.Value }} + {{- end }} + {{- if $value.Type_ }} + type: {{ $value.Type_ }} + {{- end }} + {{- if $value.SecretRef }} + valueFrom: + secretKeyRef: + {{- if $value.SecretRef.Name }} + name: {{ $value.SecretRef.Name }} + {{- end }} + {{- if $value.SecretRef.Key }} + key: {{ $value.SecretRef.Key }} + {{- end }} + {{- end }} + {{- if $value.ConfigMapRef }} + valueFrom: + configMapKeyRef: + {{- if $value.ConfigMapRef.Name }} + name: {{ $value.ConfigMapRef.Name }} + {{- end }} + {{- if $value.ConfigMapRef.Key }} + key: {{ $value.ConfigMapRef.Key }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- if ne (len .ExecutionRequest.Args) 0 }} + args: + {{- range .ExecutionRequest.Args }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- if .ExecutionRequest.ArgsMode }} + argsMode: {{ .ExecutionRequest.ArgsMode }} + {{- end }} + {{- if gt (len .ExecutionRequest.Command) 0 }} + command: + {{- range $cmd := .ExecutionRequest.Command }} + - {{ $cmd -}} + {{- end }} + {{- end -}} + {{- if .ExecutionRequest.Sync }} + sync: {{ .ExecutionRequest.Sync }} + {{- end }} + {{- if .ExecutionRequest.HttpProxy }} + httpProxy: {{ .ExecutionRequest.HttpProxy }} + {{- end }} + {{- if .ExecutionRequest.HttpsProxy }} + httpsProxy: {{ .ExecutionRequest.HttpsProxy }} + {{- end }} + {{- if .ExecutionRequest.NegativeTest }} + negativeTest: {{ .ExecutionRequest.NegativeTest }} + {{- end }} + {{- if .ExecutionRequest.JobTemplate }} + jobTemplate: {{ .ExecutionRequest.JobTemplate }} + {{- end }} + {{- if .ExecutionRequest.JobTemplateReference }} + jobTemplateReference: {{ .ExecutionRequest.JobTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.CronJobTemplate }} + cronJobTemplate: {{ .ExecutionRequest.CronJobTemplate }} + {{- end }} + {{- if .ExecutionRequest.CronJobTemplateReference }} + cronJobTemplateReference: {{ .ExecutionRequest.CronJobTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.ScraperTemplate }} + scraperTemplate: {{ .ExecutionRequest.ScraperTemplate }} + {{- end }} + {{- if .ExecutionRequest.ScraperTemplateReference }} + scraperTemplateReference: {{ .ExecutionRequest.ScraperTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.PvcTemplate }} + pvcTemplate: {{ .ExecutionRequest.PvcTemplate }} + {{- end }} + {{- if .ExecutionRequest.PvcTemplateReference }} + pvcTemplateReference: {{ .ExecutionRequest.PvcTemplateReference }} + {{- end }} + {{- if .ExecutionRequest.RunningContext }} + runningContext: {{ .ExecutionRequest.RunningContext }} + {{- end }} + {{- end }} + {{- end }} {{- end }} {{- if .Delay }} - delay: {{ .Delay }} - {{- end }} + {{- end }} {{- end }} {{- end }} {{- end }} diff --git a/pkg/mapper/testsuites/kube_openapi.go b/pkg/mapper/testsuites/kube_openapi.go index c845608de7b..1a4da943068 100644 --- a/pkg/mapper/testsuites/kube_openapi.go +++ b/pkg/mapper/testsuites/kube_openapi.go @@ -6,6 +6,7 @@ import ( commonv1 "github.com/kubeshop/testkube-operator/api/common/v1" testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" ) // MapTestSuiteListKubeToAPI maps TestSuiteList CRD to list of OpenAPI spec TestSuite @@ -81,6 +82,8 @@ func mapCRStepToAPI(crstep testsuitesv3.TestSuiteStepSpec) (teststep testkube.Te case crstep.Test != "": teststep = testkube.TestSuiteStep{ Test: crstep.Test, + // Pro/Enterprise feature: step execution requests + ExecutionRequest: testsuitestcl.MapTestStepExecutionRequestCRDToAPI(crstep.ExecutionRequest), } case crstep.Delay.Duration != 0: diff --git a/pkg/mapper/testsuites/openapi_kube.go b/pkg/mapper/testsuites/openapi_kube.go index f3ba5892363..406a942184f 100644 --- a/pkg/mapper/testsuites/openapi_kube.go +++ b/pkg/mapper/testsuites/openapi_kube.go @@ -7,6 +7,7 @@ import ( testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" "github.com/kubeshop/testkube/pkg/types" ) @@ -198,6 +199,8 @@ func mapTestStepToCRD(step testkube.TestSuiteStep) (stepSpec testsuitesv3.TestSu } case testkube.TestSuiteStepTypeExecuteTest: stepSpec.Test = step.Test + // Pro/Enterprise feature: step execution requests + stepSpec.ExecutionRequest = testsuitestcl.MapTestStepExecutionRequestCRD(step.ExecutionRequest) } return stepSpec, nil diff --git a/pkg/scheduler/testsuite_scheduler.go b/pkg/scheduler/testsuite_scheduler.go index d8aa33189ea..8e12e92545a 100644 --- a/pkg/scheduler/testsuite_scheduler.go +++ b/pkg/scheduler/testsuite_scheduler.go @@ -14,6 +14,8 @@ import ( "github.com/kubeshop/testkube/pkg/event/bus" testsuiteexecutionsmapper "github.com/kubeshop/testkube/pkg/mapper/testsuiteexecutions" testsuitesmapper "github.com/kubeshop/testkube/pkg/mapper/testsuites" + "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" + "github.com/kubeshop/testkube/pkg/telemetry" "github.com/kubeshop/testkube/pkg/version" "github.com/kubeshop/testkube/pkg/workerpool" @@ -27,6 +29,7 @@ const ( type testTuple struct { test testkube.Test executionID string + stepRequest *testkube.TestSuiteStepExecutionRequest } func (s *Scheduler) PrepareTestSuiteRequests(work []testsuitesv3.TestSuite, request testkube.TestSuiteExecutionRequest) []workerpool.Request[ @@ -444,6 +447,7 @@ func (s *Scheduler) executeTestStep(ctx context.Context, testsuiteExecution test testTuples = append(testTuples, testTuple{ test: testkube.Test{Name: executeTestStep, Namespace: testsuiteExecution.TestSuite.Namespace}, executionID: execution.Id, + stepRequest: step.ExecutionRequest, }) case testkube.TestSuiteStepTypeDelay: if step.Delay == "" { @@ -506,6 +510,8 @@ func (s *Scheduler) executeTestStep(ctx context.Context, testsuiteExecution test for i := range testTuples { req.Name = fmt.Sprintf("%s-%s", testSuiteName, testTuples[i].test.Name) req.Id = testTuples[i].executionID + // Pro/Enterprise feature: step execution requests + req = testsuitestcl.MergeStepRequest(testTuples[i].stepRequest, req) requests[i] = workerpool.Request[testkube.Test, testkube.ExecutionRequest, testkube.Execution]{ Object: testTuples[i].test, Options: req, diff --git a/pkg/tcl/README.md b/pkg/tcl/README.md index ccc5589ecb1..25ca004f001 100644 --- a/pkg/tcl/README.md +++ b/pkg/tcl/README.md @@ -1,4 +1,4 @@ -# Testkube Operator - TCL Package +# Testkube - TCL Package This folder contains special code with the Testkube Community license. diff --git a/pkg/tcl/testsuitestcl/steps.go b/pkg/tcl/testsuitestcl/steps.go new file mode 100644 index 00000000000..e56a87585f7 --- /dev/null +++ b/pkg/tcl/testsuitestcl/steps.go @@ -0,0 +1,202 @@ +// Copyright 2024 Kubeshop. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/master/licenses/TCL.txt + +package testsuitestcl + +import ( + v1 "github.com/kubeshop/testkube-operator/api/common/v1" + testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" + testsuitestclop "github.com/kubeshop/testkube-operator/pkg/tcl/testsuitestcl" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// MergeStepRequest inherits step request fields with execution request +func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, executionRequest testkube.ExecutionRequest) testkube.ExecutionRequest { + if stepRequest.ExecutionLabels != nil { + executionRequest.ExecutionLabels = stepRequest.ExecutionLabels + } + + if stepRequest.Variables != nil { + executionRequest.Variables = mergeVariables(executionRequest.Variables, stepRequest.Variables) + } + + if len(stepRequest.Args) != 0 { + if stepRequest.ArgsMode == string(testkube.ArgsModeTypeAppend) || stepRequest.ArgsMode == "" { + executionRequest.Args = append(executionRequest.Args, stepRequest.Args...) + } + + if stepRequest.ArgsMode == string(testkube.ArgsModeTypeOverride) || stepRequest.ArgsMode == string(testkube.ArgsModeTypeReplace) { + executionRequest.Args = stepRequest.Args + } + } + + if stepRequest.Command != nil { + executionRequest.Command = stepRequest.Command + } + executionRequest.Sync = stepRequest.Sync + executionRequest.HttpProxy = setStringField(executionRequest.HttpProxy, stepRequest.HttpProxy) + executionRequest.HttpsProxy = setStringField(executionRequest.HttpsProxy, stepRequest.HttpsProxy) + executionRequest.CronJobTemplate = setStringField(executionRequest.CronJobTemplate, stepRequest.CronJobTemplate) + executionRequest.CronJobTemplateReference = setStringField(executionRequest.CronJobTemplateReference, stepRequest.CronJobTemplateReference) + executionRequest.JobTemplate = setStringField(executionRequest.JobTemplate, stepRequest.JobTemplate) + executionRequest.JobTemplateReference = setStringField(executionRequest.JobTemplateReference, stepRequest.JobTemplateReference) + executionRequest.ScraperTemplate = setStringField(executionRequest.ScraperTemplate, stepRequest.ScraperTemplate) + executionRequest.ScraperTemplateReference = setStringField(executionRequest.ScraperTemplateReference, stepRequest.ScraperTemplateReference) + executionRequest.PvcTemplate = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplate) + executionRequest.PvcTemplateReference = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplateReference) + + if stepRequest.RunningContext != nil { + executionRequest.RunningContext = &testkube.RunningContext{ + Type_: string(stepRequest.RunningContext.Type_), + Context: stepRequest.RunningContext.Context, + } + } + + return executionRequest +} + +// HasStepsExecutionRequest checks if test suite has steps with execution requests +func HasStepsExecutionRequest(testSuite testsuitesv3.TestSuite) bool { + for _, batch := range testSuite.Spec.Before { + for _, step := range batch.Execute { + if step.ExecutionRequest != nil { + return true + } + } + } + for _, batch := range testSuite.Spec.Steps { + for _, step := range batch.Execute { + if step.ExecutionRequest != nil { + return true + } + } + } + for _, batch := range testSuite.Spec.After { + for _, step := range batch.Execute { + if step.ExecutionRequest != nil { + return true + } + } + } + return false +} + +func setStringField(oldValue string, newValue string) string { + if newValue != "" { + return newValue + } + return oldValue +} + +func mergeVariables(vars1 map[string]testkube.Variable, vars2 map[string]testkube.Variable) map[string]testkube.Variable { + variables := map[string]testkube.Variable{} + for k, v := range vars1 { + variables[k] = v + } + + for k, v := range vars2 { + variables[k] = v + } + + return variables +} + +func MapTestStepExecutionRequestCRD(request *testkube.TestSuiteStepExecutionRequest) *testsuitestclop.TestSuiteStepExecutionRequest { + if request == nil { + return nil + } + + variables := map[string]testsuitestclop.Variable{} + for k, v := range request.Variables { + variables[k] = testsuitestclop.Variable{ + Name: v.Name, + Value: v.Value, + Type_: string(*v.Type_), + } + } + + var runningContext *v1.RunningContext + if request.RunningContext != nil { + runningContext = &v1.RunningContext{ + Type_: v1.RunningContextType(request.RunningContext.Type_), + Context: request.RunningContext.Context, + } + } + + return &testsuitestclop.TestSuiteStepExecutionRequest{ + ExecutionLabels: request.ExecutionLabels, + Variables: variables, + Args: request.Args, + ArgsMode: testsuitestclop.ArgsModeType(request.ArgsMode), + Command: request.Command, + Sync: request.Sync, + HttpProxy: request.HttpProxy, + HttpsProxy: request.HttpsProxy, + NegativeTest: request.NegativeTest, + JobTemplate: request.JobTemplate, + JobTemplateReference: request.JobTemplateReference, + CronJobTemplate: request.CronJobTemplate, + CronJobTemplateReference: request.CronJobTemplateReference, + ScraperTemplate: request.ScraperTemplate, + ScraperTemplateReference: request.ScraperTemplateReference, + PvcTemplate: request.PvcTemplate, + PvcTemplateReference: request.PvcTemplateReference, + RunningContext: runningContext, + } +} + +func MapTestStepExecutionRequestCRDToAPI(request *testsuitestclop.TestSuiteStepExecutionRequest) *testkube.TestSuiteStepExecutionRequest { + if request == nil { + return nil + } + variables := map[string]testkube.Variable{} + for k, v := range request.Variables { + varType := testkube.VariableType(v.Type_) + variables[k] = testkube.Variable{ + Name: v.Name, + Value: v.Value, + Type_: &varType, + } + } + + var runningContext *testkube.RunningContext + + if request.RunningContext != nil { + runningContext = &testkube.RunningContext{ + Type_: string(request.RunningContext.Type_), + Context: request.RunningContext.Context, + } + } + + argsMode := "" + if request.ArgsMode != "" { + argsMode = string(request.ArgsMode) + } + + return &testkube.TestSuiteStepExecutionRequest{ + ExecutionLabels: request.ExecutionLabels, + Variables: variables, + Command: request.Command, + Args: request.Args, + ArgsMode: argsMode, + Sync: request.Sync, + HttpProxy: request.HttpProxy, + HttpsProxy: request.HttpsProxy, + NegativeTest: request.NegativeTest, + JobTemplate: request.JobTemplate, + JobTemplateReference: request.JobTemplateReference, + CronJobTemplate: request.CronJobTemplate, + CronJobTemplateReference: request.CronJobTemplateReference, + ScraperTemplate: request.ScraperTemplate, + ScraperTemplateReference: request.ScraperTemplateReference, + PvcTemplate: request.PvcTemplate, + PvcTemplateReference: request.PvcTemplateReference, + RunningContext: runningContext, + } +} diff --git a/pkg/tcl/testsuitestcl/steps_test.go b/pkg/tcl/testsuitestcl/steps_test.go new file mode 100644 index 00000000000..03fe0727b8f --- /dev/null +++ b/pkg/tcl/testsuitestcl/steps_test.go @@ -0,0 +1,106 @@ +// Copyright 2024 Kubeshop. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/master/licenses/TCL.txt + +package testsuitestcl + +import ( + "testing" + + testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" + testsuitestclop "github.com/kubeshop/testkube-operator/pkg/tcl/testsuitestcl" +) + +func TestHasStepsExecutionRequest(t *testing.T) { + tests := []struct { + name string + testSuite testsuitesv3.TestSuite + want bool + }{ + { + name: "TestSuiteSpec with steps execution request in before", + testSuite: testsuitesv3.TestSuite{ + Spec: testsuitesv3.TestSuiteSpec{ + Before: []testsuitesv3.TestSuiteBatchStep{ + { + Execute: []testsuitesv3.TestSuiteStepSpec{ + { + ExecutionRequest: &testsuitestclop.TestSuiteStepExecutionRequest{ + Args: []string{"arg1", "arg2"}, + }, + }, + }, + }, + }, + }, + }, + want: true, + }, + { + name: "TestSuiteSpec with steps execution request in steps", + testSuite: testsuitesv3.TestSuite{ + Spec: testsuitesv3.TestSuiteSpec{ + Steps: []testsuitesv3.TestSuiteBatchStep{ + { + Execute: []testsuitesv3.TestSuiteStepSpec{ + { + ExecutionRequest: &testsuitestclop.TestSuiteStepExecutionRequest{ + Args: []string{"arg1", "arg2"}, + }, + }, + }, + }, + }, + }, + }, + want: true, + }, + { + name: "TestSuiteSpec with steps execution request in after", + testSuite: testsuitesv3.TestSuite{ + Spec: testsuitesv3.TestSuiteSpec{ + After: []testsuitesv3.TestSuiteBatchStep{ + { + Execute: []testsuitesv3.TestSuiteStepSpec{ + { + ExecutionRequest: &testsuitestclop.TestSuiteStepExecutionRequest{ + Args: []string{"arg1", "arg2"}, + }, + }, + }, + }, + }, + }, + }, + want: true, + }, + { + name: "TestSuiteSpec with no steps execution request", + testSuite: testsuitesv3.TestSuite{ + Spec: testsuitesv3.TestSuiteSpec{ + Before: []testsuitesv3.TestSuiteBatchStep{ + { + Execute: []testsuitesv3.TestSuiteStepSpec{ + { + Test: "test", + }, + }, + }, + }, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasStepsExecutionRequest(tt.testSuite); got != tt.want { + t.Errorf("HasStepsExecutionRequest() = %v, want %v", got, tt.want) + } + }) + } +} From 9771787ff0bca6cbb16dbcdcc86851fd69efc9f7 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 23 Feb 2024 15:15:33 +0300 Subject: [PATCH 119/234] feat: refactor namespace for job executor --- cmd/api-server/main.go | 2 - internal/app/api/v1/executions.go | 10 ++--- pkg/executor/client/interface.go | 2 +- pkg/executor/client/job.go | 39 +++++++++---------- .../containerexecutor/containerexecutor.go | 35 ++++++++--------- .../containerexecutor_test.go | 2 - pkg/executor/containerexecutor/logs.go | 10 ++--- pkg/tcl/schedulertcl/test_scheduler.go | 34 ++++++++++++++++ 8 files changed, 79 insertions(+), 55 deletions(-) create mode 100644 pkg/tcl/schedulertcl/test_scheduler.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 7d50f058de6..acc8eb2abdd 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -390,7 +390,6 @@ func main() { executor, err := client.NewJobExecutor( resultsRepository, - cfg.TestkubeNamespace, images, jobTemplates, cfg.JobServiceAccountName, @@ -435,7 +434,6 @@ func main() { containerExecutor, err := containerexecutor.NewContainerExecutor( resultsRepository, - cfg.TestkubeNamespace, images, containerTemplates, inspector, diff --git a/internal/app/api/v1/executions.go b/internal/app/api/v1/executions.go index df7d71a9568..d7c226bf4cf 100644 --- a/internal/app/api/v1/executions.go +++ b/internal/app/api/v1/executions.go @@ -172,7 +172,7 @@ func (s *TestkubeAPI) GetLogsStream(ctx context.Context, executionID string) (ch return nil, fmt.Errorf("can't get executor for test type %s: %w", execution.TestType, err) } - logs, err := executor.Logs(ctx, executionID) + logs, err := executor.Logs(ctx, executionID, execution.TestNamespace) if err != nil { return nil, fmt.Errorf("can't get executor logs: %w", err) } @@ -279,7 +279,7 @@ func (s *TestkubeAPI) ExecutionLogsHandler() fiber.Handler { return } - s.streamLogsFromJob(ctx, executionID, execution.TestType, w) + s.streamLogsFromJob(ctx, executionID, execution.TestType, execution.TestNamespace, w) }) return nil @@ -583,7 +583,7 @@ func (s *TestkubeAPI) streamLogsFromResult(executionResult *testkube.ExecutionRe } // streamLogsFromJob streams logs in chunks to writer from the running execution -func (s *TestkubeAPI) streamLogsFromJob(ctx context.Context, executionID, testType string, w *bufio.Writer) { +func (s *TestkubeAPI) streamLogsFromJob(ctx context.Context, executionID, testType, namespace string, w *bufio.Writer) { enc := json.NewEncoder(w) s.Log.Infow("getting logs from Kubernetes job") @@ -595,7 +595,7 @@ func (s *TestkubeAPI) streamLogsFromJob(ctx context.Context, executionID, testTy return } - logs, err := executor.Logs(ctx, executionID) + logs, err := executor.Logs(ctx, executionID, namespace) s.Log.Debugw("waiting for jobs channel", "channelSize", len(logs)) if err != nil { output.PrintError(os.Stdout, err) @@ -697,7 +697,7 @@ func (s *TestkubeAPI) getExecutionLogs(ctx context.Context, execution testkube.E return append(res, execution.ExecutionResult.Output), nil } - logs, err := s.Executor.Logs(ctx, execution.Id) + logs, err := s.Executor.Logs(ctx, execution.Id, execution.TestNamespace) if err != nil { return []string{}, fmt.Errorf("could not get logs for execution %s: %w", execution.Id, err) } diff --git a/pkg/executor/client/interface.go b/pkg/executor/client/interface.go index 18e8f27eea1..3b6b4f3dba7 100644 --- a/pkg/executor/client/interface.go +++ b/pkg/executor/client/interface.go @@ -26,7 +26,7 @@ type Executor interface { // Abort aborts pending execution, do nothing when there is no pending execution Abort(ctx context.Context, execution *testkube.Execution) (result *testkube.ExecutionResult, err error) - Logs(ctx context.Context, id string) (logs chan output.Output, err error) + Logs(ctx context.Context, id, namespace string) (logs chan output.Output, err error) } // HTTPClient interface for getting REST based requests diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index ae1cab848cf..1022db5dfdd 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -77,7 +77,6 @@ const ( // NewJobExecutor creates new job executor func NewJobExecutor( repo result.Repository, - namespace string, images executor.Images, templates executor.Templates, serviceAccountName string, @@ -102,7 +101,6 @@ func NewJobExecutor( ClientSet: clientset, Repository: repo, Log: log.DefaultLogger, - Namespace: namespace, images: images, templates: templates, serviceAccountName: serviceAccountName, @@ -133,7 +131,6 @@ type JobExecutor struct { Repository result.Repository Log *zap.SugaredLogger ClientSet kubernetes.Interface - Namespace string Cmd string images executor.Images templates executor.Templates @@ -196,7 +193,7 @@ type JobOptions struct { } // Logs returns job logs stream channel using kubernetes api -func (c *JobExecutor) Logs(ctx context.Context, id string) (out chan output.Output, err error) { +func (c *JobExecutor) Logs(ctx context.Context, id, namespace string) (out chan output.Output, err error) { out = make(chan output.Output) logs := make(chan []byte) @@ -206,7 +203,7 @@ func (c *JobExecutor) Logs(ctx context.Context, id string) (out chan output.Outp close(out) }() - if err := c.TailJobLogs(ctx, id, logs); err != nil { + if err := c.TailJobLogs(ctx, id, namespace, logs); err != nil { out <- output.NewOutputError(err) return } @@ -237,10 +234,10 @@ func (c *JobExecutor) Execute(ctx context.Context, execution *testkube.Execution c.streamLog(ctx, execution.Id, events.NewLog("created kubernetes job").WithSource(events.SourceJobExecutor)) if !options.Sync { - go c.MonitorJobForTimeout(ctx, execution.Id) + go c.MonitorJobForTimeout(ctx, execution.Id, execution.TestNamespace) } - podsClient := c.ClientSet.CoreV1().Pods(c.Namespace) + podsClient := c.ClientSet.CoreV1().Pods(execution.TestNamespace) pods, err := executor.GetJobPods(ctx, podsClient, execution.Id, 1, 10) if err != nil { return result.Err(err), err @@ -274,7 +271,7 @@ func (c *JobExecutor) Execute(ctx context.Context, execution *testkube.Execution return result, nil } -func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName string) { +func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName, namespace string) { ticker := time.NewTicker(pollJobStatus) l := c.Log.With("jobName", jobName) for { @@ -283,7 +280,7 @@ func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName string) l.Infow("context done, stopping job timeout monitor") return case <-ticker.C: - jobs, err := c.ClientSet.BatchV1().Jobs(c.Namespace).List(ctx, metav1.ListOptions{LabelSelector: "job-name=" + jobName}) + jobs, err := c.ClientSet.BatchV1().Jobs(namespace).List(ctx, metav1.ListOptions{LabelSelector: "job-name=" + jobName}) if err != nil { l.Errorw("could not get jobs", "error", err) return @@ -321,7 +318,7 @@ func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName string) // CreateJob creates new Kubernetes job based on execution and execute options func (c *JobExecutor) CreateJob(ctx context.Context, execution testkube.Execution, options ExecuteOptions) error { - jobs := c.ClientSet.BatchV1().Jobs(c.Namespace) + jobs := c.ClientSet.BatchV1().Jobs(execution.TestNamespace) jobOptions, err := NewJobOptions(c.Log, c.templatesClient, c.images, c.templates, c.serviceAccountName, c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) if err != nil { @@ -331,7 +328,7 @@ func (c *JobExecutor) CreateJob(ctx context.Context, execution testkube.Executio if jobOptions.ArtifactRequest != nil && jobOptions.ArtifactRequest.StorageClassName != "" { c.Log.Debug("creating persistent volume claim with options", "options", jobOptions) - pvcsClient := c.ClientSet.CoreV1().PersistentVolumeClaims(c.Namespace) + pvcsClient := c.ClientSet.CoreV1().PersistentVolumeClaims(execution.TestNamespace) pvcSpec, err := NewPersistentVolumeClaimSpec(c.Log, NewPVCOptionsFromJobOptions(jobOptions)) if err != nil { return err @@ -366,14 +363,14 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, }() // wait for pod to be loggable - if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.ClientSet, pod.Name, c.Namespace)); err != nil { + if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.ClientSet, pod.Name, execution.TestNamespace)); err != nil { c.streamLog(ctx, execution.Id, events.NewErrorLog(errors.Wrap(err, "can't start test job pod"))) l.Errorw("waiting for pod started error", "error", err) } l.Debug("poll immediate waiting for pod") // wait for pod - if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.ClientSet, pod.Name, c.Namespace)); err != nil { + if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.ClientSet, pod.Name, execution.TestNamespace)); err != nil { // continue on poll err and try to get logs later c.streamLog(ctx, execution.Id, events.NewErrorLog(errors.Wrap(err, "can't read data from pod, pod was not completed"))) l.Errorw("waiting for pod complete error", "error", err) @@ -387,7 +384,7 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, c.streamLog(ctx, execution.Id, events.NewLog("analyzing test results and artfacts")) if execution.ArtifactRequest != nil && execution.ArtifactRequest.StorageClassName != "" { - pvcsClient := c.ClientSet.CoreV1().PersistentVolumeClaims(c.Namespace) + pvcsClient := c.ClientSet.CoreV1().PersistentVolumeClaims(execution.TestNamespace) err = pvcsClient.Delete(ctx, execution.Id+"-pvc", metav1.DeleteOptions{}) if err != nil { return execution.ExecutionResult, err @@ -395,7 +392,7 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, } var logs []byte - logs, err = executor.GetPodLogs(ctx, c.ClientSet, c.Namespace, pod) + logs, err = executor.GetPodLogs(ctx, c.ClientSet, execution.TestNamespace, pod) if err != nil { l.Errorw("get pod logs error", "error", err) c.streamLog(ctx, execution.Id, events.NewErrorLog(err)) @@ -627,9 +624,9 @@ func NewJobOptionsFromExecutionOptions(options ExecuteOptions) JobOptions { } // TailJobLogs - locates logs for job pod(s) -func (c *JobExecutor) TailJobLogs(ctx context.Context, id string, logs chan []byte) (err error) { +func (c *JobExecutor) TailJobLogs(ctx context.Context, id, namespace string, logs chan []byte) (err error) { - podsClient := c.ClientSet.CoreV1().Pods(c.Namespace) + podsClient := c.ClientSet.CoreV1().Pods(namespace) pods, err := executor.GetJobPods(ctx, podsClient, id, 1, 10) if err != nil { @@ -655,7 +652,7 @@ func (c *JobExecutor) TailJobLogs(ctx context.Context, id string, logs chan []by default: l.Debugw("tailing job logs: waiting for pod to be ready") - if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.ClientSet, pod.Name, c.Namespace)); err != nil { + if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.ClientSet, pod.Name, namespace)); err != nil { l.Errorw("poll immediate error when tailing logs", "error", err) return err } @@ -692,7 +689,7 @@ func (c *JobExecutor) TailPodLogs(ctx context.Context, pod corev1.Pod, logs chan } podLogRequest := c.ClientSet.CoreV1(). - Pods(c.Namespace). + Pods(pod.Namespace). GetLogs(pod.Name, &podLogOptions) stream, err := podLogRequest.Stream(ctx) @@ -726,7 +723,7 @@ func (c *JobExecutor) TailPodLogs(ctx context.Context, pod corev1.Pod, logs chan // GetPodLogError returns last line as error func (c *JobExecutor) GetPodLogError(ctx context.Context, pod corev1.Pod) (logsBytes []byte, err error) { // error line should be last one - return executor.GetPodLogs(ctx, c.ClientSet, c.Namespace, pod, 1) + return executor.GetPodLogs(ctx, c.ClientSet, pod.Namespace, pod, 1) } // GetLastLogLineError return error if last line is failed @@ -752,7 +749,7 @@ func (c *JobExecutor) GetLastLogLineError(ctx context.Context, pod corev1.Pod) e // Abort aborts K8S by job name func (c *JobExecutor) Abort(ctx context.Context, execution *testkube.Execution) (result *testkube.ExecutionResult, err error) { l := c.Log.With("execution", execution.Id) - result, err = executor.AbortJob(ctx, c.ClientSet, c.Namespace, execution.Id) + result, err = executor.AbortJob(ctx, c.ClientSet, execution.TestNamespace, execution.Id) if err != nil { l.Errorw("error aborting job", "execution", execution.Id, "error", err) } diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index c94b94b43de..18dff65ffa0 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -58,7 +58,6 @@ type EventEmitter interface { // NewContainerExecutor creates new job executor func NewContainerExecutor( repo result.Repository, - namespace string, images executor.Images, templates executor.Templates, imageInspector imageinspector.Inspector, @@ -89,7 +88,6 @@ func NewContainerExecutor( clientSet: clientSet, repository: repo, log: log.DefaultLogger, - namespace: namespace, images: images, templates: templates, imageInspector: imageInspector, @@ -122,7 +120,6 @@ type ContainerExecutor struct { repository result.Repository log *zap.SugaredLogger clientSet kubernetes.Interface - namespace string images executor.Images templates executor.Templates imageInspector imageinspector.Inspector @@ -191,7 +188,7 @@ type JobOptions struct { } // Logs returns job logs stream channel using kubernetes api -func (c *ContainerExecutor) Logs(ctx context.Context, id string) (out chan output.Output, err error) { +func (c *ContainerExecutor) Logs(ctx context.Context, id, namespace string) (out chan output.Output, err error) { out = make(chan output.Output) go func() { @@ -229,7 +226,7 @@ func (c *ContainerExecutor) Logs(ctx context.Context, id string) (out chan outpu for _, podName := range ids { logs := make(chan []byte) - if err := c.TailJobLogs(ctx, podName, logs); err != nil { + if err := c.TailJobLogs(ctx, podName, namespace, logs); err != nil { out <- output.NewOutputError(err) return } @@ -256,7 +253,7 @@ func (c *ContainerExecutor) Execute(ctx context.Context, execution *testkube.Exe return executionResult, err } - podsClient := c.clientSet.CoreV1().Pods(c.namespace) + podsClient := c.clientSet.CoreV1().Pods(execution.TestNamespace) pods, err := executor.GetJobPods(ctx, podsClient, execution.Id, 1, 10) if err != nil { executionResult.Err(err) @@ -290,11 +287,11 @@ func (c *ContainerExecutor) Execute(ctx context.Context, execution *testkube.Exe // createJob creates new Kubernetes job based on execution and execute options func (c *ContainerExecutor) createJob(ctx context.Context, execution testkube.Execution, options client.ExecuteOptions) (*JobOptions, error) { - jobsClient := c.clientSet.BatchV1().Jobs(c.namespace) + jobsClient := c.clientSet.BatchV1().Jobs(execution.TestNamespace) // Fallback to one-time inspector when non-default namespace is needed inspector := c.imageInspector - if len(options.ImagePullSecretNames) > 0 && options.Namespace != "" && c.namespace != options.Namespace { + if len(options.ImagePullSecretNames) > 0 && options.Namespace != "" && execution.TestNamespace != options.Namespace { secretClient, err := secret.NewClient(options.Namespace) if err != nil { return nil, errors.Wrap(err, "failed to build secrets client") @@ -311,7 +308,7 @@ func (c *ContainerExecutor) createJob(ctx context.Context, execution testkube.Ex if jobOptions.ArtifactRequest != nil && jobOptions.ArtifactRequest.StorageClassName != "" { c.log.Debug("creating persistent volume claim with options", "options", jobOptions) - pvcsClient := c.clientSet.CoreV1().PersistentVolumeClaims(c.namespace) + pvcsClient := c.clientSet.CoreV1().PersistentVolumeClaims(execution.TestNamespace) pvcSpec, err := client.NewPersistentVolumeClaimSpec(c.log, NewPVCOptionsFromJobOptions(*jobOptions)) if err != nil { return nil, err @@ -348,9 +345,9 @@ func (c *ContainerExecutor) updateResultsFromPod( // wait for pod l.Debug("poll immediate waiting for executor pod") - if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.clientSet, executorPod.Name, c.namespace)); err != nil { + if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.clientSet, executorPod.Name, execution.TestNamespace)); err != nil { l.Errorw("waiting for executor pod started error", "error", err) - } else if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.clientSet, executorPod.Name, c.namespace)); err != nil { + } else if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.clientSet, executorPod.Name, execution.TestNamespace)); err != nil { // continue on poll err and try to get logs later l.Errorw("waiting for executor pod complete error", "error", err) } @@ -360,7 +357,7 @@ func (c *ContainerExecutor) updateResultsFromPod( l.Debug("poll executor immediate end") // we need to retrieve the Pod to get its latest status - podsClient := c.clientSet.CoreV1().Pods(c.namespace) + podsClient := c.clientSet.CoreV1().Pods(execution.TestNamespace) latestExecutorPod, err := podsClient.Get(context.Background(), executorPod.Name, metav1.GetOptions{}) if err != nil { return execution.ExecutionResult, err @@ -370,7 +367,7 @@ func (c *ContainerExecutor) updateResultsFromPod( if jobOptions.ArtifactRequest != nil && jobOptions.ArtifactRequest.StorageClassName != "" { c.log.Debug("creating scraper job with options", "options", jobOptions) - jobsClient := c.clientSet.BatchV1().Jobs(c.namespace) + jobsClient := c.clientSet.BatchV1().Jobs(execution.TestNamespace) scraperSpec, err := NewScraperJobSpec(c.log, jobOptions) if err != nil { return execution.ExecutionResult, err @@ -391,9 +388,9 @@ func (c *ContainerExecutor) updateResultsFromPod( for _, scraperPod := range scraperPods.Items { if scraperPod.Status.Phase != corev1.PodRunning && scraperPod.Labels["job-name"] == scraperPodName { l.Debug("poll immediate waiting for scraper pod to succeed") - if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.clientSet, scraperPod.Name, c.namespace)); err != nil { + if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.clientSet, scraperPod.Name, execution.TestNamespace)); err != nil { l.Errorw("waiting for scraper pod started error", "error", err) - } else if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.clientSet, scraperPod.Name, c.namespace)); err != nil { + } else if err = wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, executor.IsPodReady(c.clientSet, scraperPod.Name, execution.TestNamespace)); err != nil { // continue on poll err and try to get logs later l.Errorw("waiting for scraper pod complete error", "error", err) } @@ -404,7 +401,7 @@ func (c *ContainerExecutor) updateResultsFromPod( return execution.ExecutionResult, err } - pvcsClient := c.clientSet.CoreV1().PersistentVolumeClaims(c.namespace) + pvcsClient := c.clientSet.CoreV1().PersistentVolumeClaims(execution.TestNamespace) err = pvcsClient.Delete(ctx, execution.Id+"-pvc", metav1.DeleteOptions{}) if err != nil { return execution.ExecutionResult, err @@ -417,7 +414,7 @@ func (c *ContainerExecutor) updateResultsFromPod( execution.ExecutionResult.Error() } - scraperLogs, err = executor.GetPodLogs(ctx, c.clientSet, c.namespace, *latestScraperPod) + scraperLogs, err = executor.GetPodLogs(ctx, c.clientSet, execution.TestNamespace, *latestScraperPod) if err != nil { l.Errorw("get scraper pod logs error", "error", err) return execution.ExecutionResult, err @@ -437,7 +434,7 @@ func (c *ContainerExecutor) updateResultsFromPod( } } - executorLogs, err := executor.GetPodLogs(ctx, c.clientSet, c.namespace, *latestExecutorPod) + executorLogs, err := executor.GetPodLogs(ctx, c.clientSet, execution.TestNamespace, *latestExecutorPod) if err != nil { l.Errorw("get executor pod logs error", "error", err) execution.ExecutionResult.Err(err) @@ -707,7 +704,7 @@ func NewJobOptionsFromExecutionOptions(options client.ExecuteOptions) *JobOption // Abort K8sJob aborts K8S by job name func (c *ContainerExecutor) Abort(ctx context.Context, execution *testkube.Execution) (*testkube.ExecutionResult, error) { - return executor.AbortJob(ctx, c.clientSet, c.namespace, execution.Id) + return executor.AbortJob(ctx, c.clientSet, execution.TestNamespace, execution.Id) } func NewPVCOptionsFromJobOptions(options JobOptions) client.PVCOptions { diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index f11ac56f194..6a03af091ae 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -36,7 +36,6 @@ func TestExecuteAsync(t *testing.T) { repository: FakeResultRepository{}, metrics: FakeMetricCounter{}, emitter: FakeEmitter{}, - namespace: "default", configMap: FakeConfigRepository{}, testsClient: FakeTestsClient{}, executorsClient: FakeExecutorsClient{}, @@ -62,7 +61,6 @@ func TestExecuteSync(t *testing.T) { repository: FakeResultRepository{}, metrics: FakeMetricCounter{}, emitter: FakeEmitter{}, - namespace: "default", configMap: FakeConfigRepository{}, testsClient: FakeTestsClient{}, executorsClient: FakeExecutorsClient{}, diff --git a/pkg/executor/containerexecutor/logs.go b/pkg/executor/containerexecutor/logs.go index beb482c45a8..8e3f3934a9c 100644 --- a/pkg/executor/containerexecutor/logs.go +++ b/pkg/executor/containerexecutor/logs.go @@ -17,8 +17,8 @@ import ( // TailJobLogs - locates logs for job pod(s) // These methods here are similar to Job executor, but they don't require the json structure. -func (c *ContainerExecutor) TailJobLogs(ctx context.Context, id string, logs chan []byte) (err error) { - podsClient := c.clientSet.CoreV1().Pods(c.namespace) +func (c *ContainerExecutor) TailJobLogs(ctx context.Context, id, namespace string, logs chan []byte) (err error) { + podsClient := c.clientSet.CoreV1().Pods(namespace) pods, err := executor.GetJobPods(ctx, podsClient, id, 1, 10) if err != nil { close(logs) @@ -34,7 +34,7 @@ func (c *ContainerExecutor) TailJobLogs(ctx context.Context, id string, logs cha case corev1.PodRunning: l.Debug("tailing pod logs: immediately") - return tailPodLogs(c.log, c.clientSet, c.namespace, pod, logs) + return tailPodLogs(c.log, c.clientSet, namespace, pod, logs) case corev1.PodFailed: err := fmt.Errorf("can't get pod logs, pod failed: %s/%s", pod.Namespace, pod.Name) @@ -43,13 +43,13 @@ func (c *ContainerExecutor) TailJobLogs(ctx context.Context, id string, logs cha default: l.Debugw("tailing job logs: waiting for pod to be ready") - if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.clientSet, pod.Name, c.namespace)); err != nil { + if err = wait.PollUntilContextTimeout(ctx, pollInterval, c.podStartTimeout, true, executor.IsPodLoggable(c.clientSet, pod.Name, namespace)); err != nil { l.Errorw("poll immediate error when tailing logs", "error", err) return err } l.Debug("tailing pod logs") - return tailPodLogs(c.log, c.clientSet, c.namespace, pod, logs) + return tailPodLogs(c.log, c.clientSet, namespace, pod, logs) } } } diff --git a/pkg/tcl/schedulertcl/test_scheduler.go b/pkg/tcl/schedulertcl/test_scheduler.go new file mode 100644 index 00000000000..2837cc90104 --- /dev/null +++ b/pkg/tcl/schedulertcl/test_scheduler.go @@ -0,0 +1,34 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package schedulertcl + +import ( + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/executor/client" +) + +// NewExecutionFromExecutionOptions creates new execution from execution options +func NewExecutionFromExecutionOptions(options client.ExecuteOptions, execution testkube.Execution) testkube.Execution { + execution.ExecutionNamespace = options.Request.ExecutionNamespace + return execution +} + +// GetExecuteOptions returns execute options +func GetExecuteOptions(sourceRequest *testkube.ExecutionRequest, + destinationRequest testkube.ExecutionRequest) testkube.ExecutionRequest { + if sourceRequest == nil { + return destinationRequest + } + + if destinationRequest.ExecutionNamespace == "" && sourceRequest.ExecutionNamespace != "" { + destinationRequest.ExecutionNamespace = sourceRequest.ExecutionNamespace + } + + return destinationRequest +} From febf4f56cc8da50ac8078df03167ca7462076583 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 23 Feb 2024 15:51:11 +0300 Subject: [PATCH 120/234] fix: use execution ns --- pkg/scheduler/test_scheduler.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 26c31ff84f3..174ab812052 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -17,6 +17,7 @@ import ( "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/logs/events" testsmapper "github.com/kubeshop/testkube/pkg/mapper/tests" + "github.com/kubeshop/testkube/pkg/tcl/schedulertcl" "github.com/kubeshop/testkube/pkg/workerpool" ) @@ -270,7 +271,8 @@ func newExecutionFromExecutionOptions(options client.ExecuteOptions) testkube.Ex execution.DownloadArtifactTestNames = options.Request.DownloadArtifactTestNames execution.SlavePodRequest = options.Request.SlavePodRequest - return execution + // Pro edition only (tcl protected code) + return schedulertcl.NewExecutionFromExecutionOptions(options, execution) } func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.ExecutionRequest) (options client.ExecuteOptions, err error) { @@ -397,6 +399,9 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe s.logger.Infow("setting negative test from test definition", "test", test.Name, "negativeTest", test.ExecutionRequest.NegativeTest) request.NegativeTest = test.ExecutionRequest.NegativeTest } + + // Pro edition only (tcl protected code) + request = schedulertcl.GetExecuteOptions(test.ExecutionRequest, request) } // get executor from kubernetes CRs From 442d5d44f5550a7e9c21f1ea86425b990ac94bae Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 23 Feb 2024 14:07:09 +0100 Subject: [PATCH 121/234] chore: move TestWorkflow mappers / add Pro tag for their OpenAPI schema (#5058) * chore: move TestWorkflow mappers to common mapperstcl directory * chore: add "pro" tag to OpenAPI methods of TestWorkflows --- api/v1/testkube.yaml | 14 +++++++++++++- .../commands/testworkflows/create.go | 6 +++--- cmd/kubectl-testkube/commands/testworkflows/get.go | 6 +++--- .../commands/testworkflowtemplates/create.go | 6 +++--- .../commands/testworkflowtemplates/get.go | 6 +++--- pkg/tcl/apitcl/v1/testworkflows.go | 2 +- pkg/tcl/apitcl/v1/testworkflowtemplates.go | 2 +- .../testworkflows}/kube_openapi.go | 2 +- .../testworkflows}/mappers_test.go | 2 +- .../testworkflows}/openapi_kube.go | 2 +- 10 files changed, 30 insertions(+), 18 deletions(-) rename pkg/tcl/{workflowstcl/mappers => mapperstcl/testworkflows}/kube_openapi.go (99%) rename pkg/tcl/{workflowstcl/mappers => mapperstcl/testworkflows}/mappers_test.go (99%) rename pkg/tcl/{workflowstcl/mappers => mapperstcl/testworkflows}/openapi_kube.go (99%) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index b6e258894c2..4ec5f968f91 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3294,6 +3294,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/Selector" summary: List test workflows @@ -3339,6 +3340,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/Selector" summary: Delete test workflows @@ -3375,6 +3377,7 @@ paths: tags: - test-workflows - api + - pro summary: Create test workflow description: Create test workflow in the kubernetes cluster operationId: createTestWorkflow @@ -3429,6 +3432,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/ID" summary: Get test workflow details @@ -3472,6 +3476,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/ID" summary: Update test workflow details @@ -3525,6 +3530,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/ID" - $ref: "#/components/parameters/SkipDeleteExecutions" @@ -3564,6 +3570,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/Selector" summary: List test workflow templates @@ -3609,6 +3616,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/Selector" summary: Delete test workflow templates @@ -3645,6 +3653,7 @@ paths: tags: - test-workflows - api + - pro summary: Create test workflow template description: Create test workflow template in the kubernetes cluster operationId: createTestWorkflowTemplate @@ -3699,6 +3708,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/ID" summary: Get test workflow template details @@ -3742,11 +3752,12 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/ID" summary: Update test workflow template details description: Update test workflow template details in the kubernetes cluster - operationId: updateTestWorkflow + operationId: updateTestWorkflowTemplate requestBody: description: test workflow template body required: true @@ -3795,6 +3806,7 @@ paths: tags: - test-workflows - api + - pro parameters: - $ref: "#/components/parameters/ID" summary: Delete test workflow template diff --git a/cmd/kubectl-testkube/commands/testworkflows/create.go b/cmd/kubectl-testkube/commands/testworkflows/create.go index 8f2004f8a56..5615d42a291 100644 --- a/cmd/kubectl-testkube/commands/testworkflows/create.go +++ b/cmd/kubectl-testkube/commands/testworkflows/create.go @@ -9,7 +9,7 @@ import ( testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" common2 "github.com/kubeshop/testkube/internal/common" - "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" "github.com/kubeshop/testkube/pkg/ui" ) @@ -66,11 +66,11 @@ func NewCreateTestWorkflowCmd() *cobra.Command { if !update { ui.Failf("Test workflow with name '%s' already exists in namespace %s, use --update flag for upsert", obj.Name, namespace) } - _, err = client.UpdateTestWorkflow(mappers.MapTestWorkflowKubeToAPI(*obj)) + _, err = client.UpdateTestWorkflow(testworkflows.MapTestWorkflowKubeToAPI(*obj)) ui.ExitOnError("updating test workflow "+obj.Name+" in namespace "+obj.Namespace, err) ui.Success("Test workflow updated", namespace, "/", obj.Name) } else { - _, err = client.CreateTestWorkflow(mappers.MapTestWorkflowKubeToAPI(*obj)) + _, err = client.CreateTestWorkflow(testworkflows.MapTestWorkflowKubeToAPI(*obj)) ui.ExitOnError("creating test workflow "+obj.Name+" in namespace "+obj.Namespace, err) ui.Success("Test workflow created", namespace, "/", obj.Name) } diff --git a/cmd/kubectl-testkube/commands/testworkflows/get.go b/cmd/kubectl-testkube/commands/testworkflows/get.go index 16b46df976a..f1b7beafa4d 100644 --- a/cmd/kubectl-testkube/commands/testworkflows/get.go +++ b/cmd/kubectl-testkube/commands/testworkflows/get.go @@ -10,7 +10,7 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows/renderer" - "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" "github.com/kubeshop/testkube/pkg/ui" ) @@ -37,7 +37,7 @@ func NewGetTestWorkflowsCmd() *cobra.Command { ui.ExitOnError("getting all test workflows in namespace "+namespace, err) if crdOnly { - ui.PrintCRDs(mappers.MapListAPIToKube(workflows).Items, "TestWorkflow", testworkflowsv1.GroupVersion) + ui.PrintCRDs(testworkflows.MapListAPIToKube(workflows).Items, "TestWorkflow", testworkflowsv1.GroupVersion) } else { err = render.List(cmd, workflows, os.Stdout) ui.PrintOnError("Rendering list", err) @@ -50,7 +50,7 @@ func NewGetTestWorkflowsCmd() *cobra.Command { ui.ExitOnError("getting test workflow in namespace "+namespace, err) if crdOnly { - ui.PrintCRD(mappers.MapTestWorkflowAPIToKube(workflow), "TestWorkflow", testworkflowsv1.GroupVersion) + ui.PrintCRD(testworkflows.MapTestWorkflowAPIToKube(workflow), "TestWorkflow", testworkflowsv1.GroupVersion) } else { err = render.Obj(cmd, workflow, os.Stdout, renderer.TestWorkflowRenderer) ui.ExitOnError("rendering obj", err) diff --git a/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go b/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go index c156ac1c85b..f0e01812f39 100644 --- a/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go +++ b/cmd/kubectl-testkube/commands/testworkflowtemplates/create.go @@ -9,7 +9,7 @@ import ( testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" common2 "github.com/kubeshop/testkube/internal/common" - "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" "github.com/kubeshop/testkube/pkg/ui" ) @@ -66,11 +66,11 @@ func NewCreateTestWorkflowTemplateCmd() *cobra.Command { if !update { ui.Failf("Test workflow template with name '%s' already exists in namespace %s, use --update flag for upsert", obj.Name, namespace) } - _, err = client.UpdateTestWorkflowTemplate(mappers.MapTestWorkflowTemplateKubeToAPI(*obj)) + _, err = client.UpdateTestWorkflowTemplate(testworkflows.MapTestWorkflowTemplateKubeToAPI(*obj)) ui.ExitOnError("updating test workflow template "+obj.Name+" in namespace "+obj.Namespace, err) ui.Success("Test workflow template updated", namespace, "/", obj.Name) } else { - _, err = client.CreateTestWorkflowTemplate(mappers.MapTestWorkflowTemplateKubeToAPI(*obj)) + _, err = client.CreateTestWorkflowTemplate(testworkflows.MapTestWorkflowTemplateKubeToAPI(*obj)) ui.ExitOnError("creating test workflow "+obj.Name+" in namespace "+obj.Namespace, err) ui.Success("Test workflow template created", namespace, "/", obj.Name) } diff --git a/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go b/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go index 752f60d5c50..ad1dc996cfe 100644 --- a/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go +++ b/cmd/kubectl-testkube/commands/testworkflowtemplates/get.go @@ -10,7 +10,7 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflowtemplates/renderer" - "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" "github.com/kubeshop/testkube/pkg/ui" ) @@ -37,7 +37,7 @@ func NewGetTestWorkflowTemplatesCmd() *cobra.Command { ui.ExitOnError("getting all test workflow templates in namespace "+namespace, err) if crdOnly { - ui.PrintCRDs(mappers.MapTemplateListAPIToKube(templates).Items, "TestWorkflowTemplate", testworkflowsv1.GroupVersion) + ui.PrintCRDs(testworkflows.MapTemplateListAPIToKube(templates).Items, "TestWorkflowTemplate", testworkflowsv1.GroupVersion) } else { err = render.List(cmd, templates, os.Stdout) ui.PrintOnError("Rendering list", err) @@ -50,7 +50,7 @@ func NewGetTestWorkflowTemplatesCmd() *cobra.Command { ui.ExitOnError("getting test workflow in namespace "+namespace, err) if crdOnly { - ui.PrintCRD(mappers.MapTestWorkflowTemplateAPIToKube(template), "TestWorkflowTemplate", testworkflowsv1.GroupVersion) + ui.PrintCRD(testworkflows.MapTestWorkflowTemplateAPIToKube(template), "TestWorkflowTemplate", testworkflowsv1.GroupVersion) } else { err = render.Obj(cmd, template, os.Stdout, renderer.TestWorkflowTemplateRenderer) ui.ExitOnError("rendering obj", err) diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index 3f07acd8231..a9bd4b92f3c 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -19,7 +19,7 @@ import ( testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - mappers2 "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + mappers2 "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" ) func (s *apiTCL) ListTestWorkflowsHandler() fiber.Handler { diff --git a/pkg/tcl/apitcl/v1/testworkflowtemplates.go b/pkg/tcl/apitcl/v1/testworkflowtemplates.go index 56754ab2738..7fd846a19fb 100644 --- a/pkg/tcl/apitcl/v1/testworkflowtemplates.go +++ b/pkg/tcl/apitcl/v1/testworkflowtemplates.go @@ -19,7 +19,7 @@ import ( testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - mappers2 "github.com/kubeshop/testkube/pkg/tcl/workflowstcl/mappers" + mappers2 "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" ) func (s *apiTCL) ListTestWorkflowTemplatesHandler() fiber.Handler { diff --git a/pkg/tcl/workflowstcl/mappers/kube_openapi.go b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go similarity index 99% rename from pkg/tcl/workflowstcl/mappers/kube_openapi.go rename to pkg/tcl/mapperstcl/testworkflows/kube_openapi.go index a1f65c145a2..9f8b740e470 100644 --- a/pkg/tcl/workflowstcl/mappers/kube_openapi.go +++ b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go @@ -6,7 +6,7 @@ // // https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt -package mappers +package testworkflows import ( corev1 "k8s.io/api/core/v1" diff --git a/pkg/tcl/workflowstcl/mappers/mappers_test.go b/pkg/tcl/mapperstcl/testworkflows/mappers_test.go similarity index 99% rename from pkg/tcl/workflowstcl/mappers/mappers_test.go rename to pkg/tcl/mapperstcl/testworkflows/mappers_test.go index dbe86308720..df0c37b3754 100644 --- a/pkg/tcl/workflowstcl/mappers/mappers_test.go +++ b/pkg/tcl/mapperstcl/testworkflows/mappers_test.go @@ -6,7 +6,7 @@ // // https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt -package mappers +package testworkflows import ( "testing" diff --git a/pkg/tcl/workflowstcl/mappers/openapi_kube.go b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go similarity index 99% rename from pkg/tcl/workflowstcl/mappers/openapi_kube.go rename to pkg/tcl/mapperstcl/testworkflows/openapi_kube.go index 7cfa1def6a6..b0b7455101f 100644 --- a/pkg/tcl/workflowstcl/mappers/openapi_kube.go +++ b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go @@ -6,7 +6,7 @@ // // https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt -package mappers +package testworkflows import ( "strconv" From 1d07d3a196e55571081cd7d42c670da41ff1dcb8 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Fri, 23 Feb 2024 14:24:52 +0100 Subject: [PATCH 122/234] fix: small fixes (#5059) --- pkg/crd/templates/testsuite.tmpl | 6 +++--- pkg/tcl/testsuitestcl/steps.go | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/crd/templates/testsuite.tmpl b/pkg/crd/templates/testsuite.tmpl index 54e4c4c12d8..9b73acfebf0 100644 --- a/pkg/crd/templates/testsuite.tmpl +++ b/pkg/crd/templates/testsuite.tmpl @@ -83,7 +83,7 @@ spec: {{- if ne (len .ExecutionRequest.Args) 0 }} args: {{- range .ExecutionRequest.Args }} - - {{ . | quote }} + - "{{ . }}" {{- end }} {{- end }} {{- if .ExecutionRequest.ArgsMode }} @@ -214,7 +214,7 @@ spec: {{- if ne (len .ExecutionRequest.Args) 0 }} args: {{- range .ExecutionRequest.Args }} - - {{ . | quote }} + - "{{ . }}" {{- end }} {{- end }} {{- if .ExecutionRequest.ArgsMode }} @@ -345,7 +345,7 @@ spec: {{- if ne (len .ExecutionRequest.Args) 0 }} args: {{- range .ExecutionRequest.Args }} - - {{ . | quote }} + - "{{ . }}" {{- end }} {{- end }} {{- if .ExecutionRequest.ArgsMode }} diff --git a/pkg/tcl/testsuitestcl/steps.go b/pkg/tcl/testsuitestcl/steps.go index e56a87585f7..e027ebdd9a6 100644 --- a/pkg/tcl/testsuitestcl/steps.go +++ b/pkg/tcl/testsuitestcl/steps.go @@ -18,6 +18,9 @@ import ( // MergeStepRequest inherits step request fields with execution request func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, executionRequest testkube.ExecutionRequest) testkube.ExecutionRequest { + if stepRequest == nil { + return executionRequest + } if stepRequest.ExecutionLabels != nil { executionRequest.ExecutionLabels = stepRequest.ExecutionLabels } From d6bd9668dab2fa2e7fd9112491be4add45fd750e Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Fri, 23 Feb 2024 15:02:18 +0100 Subject: [PATCH 123/234] fix: fix pointer issue (#5060) --- cmd/api-server/main.go | 2 +- pkg/tcl/checktcl/subscription.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 5c56fe25cab..2f39e2a9fb5 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -546,7 +546,7 @@ func main() { // Check Pro/Enterprise subscription subscriptionChecker, err := checktcl.NewSubscriptionChecker(ctx, *proContext, grpcClient, grpcConn) ui.WarnOnError("Creating subscription checker", err) - api.WithSubscriptionChecker(*subscriptionChecker) + api.WithSubscriptionChecker(subscriptionChecker) agentHandle, err := agent.NewAgent( log.DefaultLogger, diff --git a/pkg/tcl/checktcl/subscription.go b/pkg/tcl/checktcl/subscription.go index 0759e3b9061..b6f1135b576 100644 --- a/pkg/tcl/checktcl/subscription.go +++ b/pkg/tcl/checktcl/subscription.go @@ -27,18 +27,18 @@ type SubscriptionChecker struct { } // NewSubscriptionChecker creates a new subscription checker using the agent token -func NewSubscriptionChecker(ctx context.Context, proContext config.ProContext, cloudClient cloud.TestKubeCloudAPIClient, grpcConn *grpc.ClientConn) (*SubscriptionChecker, error) { +func NewSubscriptionChecker(ctx context.Context, proContext config.ProContext, cloudClient cloud.TestKubeCloudAPIClient, grpcConn *grpc.ClientConn) (SubscriptionChecker, error) { executor := executor.NewCloudGRPCExecutor(cloudClient, grpcConn, proContext.APIKey) req := GetOrganizationPlanRequest{} response, err := executor.Execute(ctx, cloudconfig.CmdConfigGetOrganizationPlan, req) if err != nil { - return nil, err + return SubscriptionChecker{}, err } var commandResponse GetOrganizationPlanResponse if err := json.Unmarshal(response, &commandResponse); err != nil { - return nil, err + return SubscriptionChecker{}, err } subscription := OrganizationPlan{ @@ -47,7 +47,7 @@ func NewSubscriptionChecker(ctx context.Context, proContext config.ProContext, c PlanStatus: PlanStatus(commandResponse.PlanStatus), } - return &SubscriptionChecker{proContext: proContext, orgPlan: &subscription}, nil + return SubscriptionChecker{proContext: proContext, orgPlan: &subscription}, nil } // GetCurrentOrganizationPlan returns current organization plan From fa5d0c3572b690f5755d7dac47cfd8e3e2f2af56 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 23 Feb 2024 17:09:55 +0300 Subject: [PATCH 124/234] feat: validate pro subscription --- cmd/api-server/main.go | 3 +++ internal/app/api/v1/executions_test.go | 2 +- pkg/executor/client/mock_executor.go | 8 +++--- pkg/scheduler/service.go | 9 +++++++ pkg/scheduler/test_scheduler.go | 37 ++++++++++++++++++++++---- pkg/tcl/schedulertcl/test_scheduler.go | 14 +++++++--- 6 files changed, 60 insertions(+), 13 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 20bedc61eaf..af177351b22 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -541,10 +541,13 @@ func main() { } api.WithProContext(proContext) + // Check Pro/Enterprise subscription subscriptionChecker, err := checktcl.NewSubscriptionChecker(ctx, *proContext, grpcClient, grpcConn) ui.WarnOnError("Creating subscription checker", err) + api.WithSubscriptionChecker(*subscriptionChecker) + sched.WithSubscriptionChecker(subscriptionChecker) agentHandle, err := agent.NewAgent( log.DefaultLogger, diff --git a/internal/app/api/v1/executions_test.go b/internal/app/api/v1/executions_test.go index 7325eb232c3..4e6f68125ed 100644 --- a/internal/app/api/v1/executions_test.go +++ b/internal/app/api/v1/executions_test.go @@ -297,7 +297,7 @@ func (e MockExecutor) Abort(ctx context.Context, execution *testkube.Execution) panic("not implemented") } -func (e MockExecutor) Logs(ctx context.Context, id string) (chan output.Output, error) { +func (e MockExecutor) Logs(ctx context.Context, id, namespace string) (chan output.Output, error) { if e.LogsFn == nil { panic("not implemented") } diff --git a/pkg/executor/client/mock_executor.go b/pkg/executor/client/mock_executor.go index 9e066b38b8f..100f0a44938 100644 --- a/pkg/executor/client/mock_executor.go +++ b/pkg/executor/client/mock_executor.go @@ -67,16 +67,16 @@ func (mr *MockExecutorMockRecorder) Execute(arg0, arg1, arg2 interface{}) *gomoc } // Logs mocks base method. -func (m *MockExecutor) Logs(arg0 context.Context, arg1 string) (chan output.Output, error) { +func (m *MockExecutor) Logs(arg0 context.Context, arg1, arg2 string) (chan output.Output, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Logs", arg0, arg1) + ret := m.ctrl.Call(m, "Logs", arg0, arg1, arg2) ret0, _ := ret[0].(chan output.Output) ret1, _ := ret[1].(error) return ret0, ret1 } // Logs indicates an expected call of Logs. -func (mr *MockExecutorMockRecorder) Logs(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockExecutorMockRecorder) Logs(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logs", reflect.TypeOf((*MockExecutor)(nil).Logs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logs", reflect.TypeOf((*MockExecutor)(nil).Logs), arg0, arg1, arg2) } diff --git a/pkg/scheduler/service.go b/pkg/scheduler/service.go index 69818463630..5e2b3f8d54c 100644 --- a/pkg/scheduler/service.go +++ b/pkg/scheduler/service.go @@ -20,6 +20,7 @@ import ( "github.com/kubeshop/testkube/pkg/repository/result" "github.com/kubeshop/testkube/pkg/repository/testresult" "github.com/kubeshop/testkube/pkg/secret" + "github.com/kubeshop/testkube/pkg/tcl/checktcl" ) type Scheduler struct { @@ -42,6 +43,7 @@ type Scheduler struct { dashboardURI string featureFlags featureflags.FeatureFlags logsStream logsclient.Stream + subscriptionChecker *checktcl.SubscriptionChecker } func NewScheduler( @@ -87,3 +89,10 @@ func NewScheduler( logsStream: logsStream, } } + +// WithSubscriptionChecker sets subscription checker for the Scheduler +// This is used to check if Pro/Enterprise subscription is valid +func (s *Scheduler) WithSubscriptionChecker(subscriptionChecker *checktcl.SubscriptionChecker) *Scheduler { + s.subscriptionChecker = subscriptionChecker + return s +} diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 174ab812052..5aa476ba17d 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -17,6 +17,7 @@ import ( "github.com/kubeshop/testkube/pkg/executor/client" "github.com/kubeshop/testkube/pkg/logs/events" testsmapper "github.com/kubeshop/testkube/pkg/mapper/tests" + "github.com/kubeshop/testkube/pkg/tcl/checktcl" "github.com/kubeshop/testkube/pkg/tcl/schedulertcl" "github.com/kubeshop/testkube/pkg/workerpool" ) @@ -73,11 +74,15 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request // merge available data into execution options test spec, executor spec, request, test id options, err := s.getExecuteOptions(test.Namespace, test.Name, request) if err != nil { - return s.handleExecutionError(ctx, execution, "can't get current secret uuid: %w", err) + return s.handleExecutionError(ctx, execution, "can't get execute options: %w", err) } // store execution in storage, can be fetched from API now - execution = newExecutionFromExecutionOptions(options) + execution, err = newExecutionFromExecutionOptions(s.subscriptionChecker, options) + if err != nil { + return s.handleExecutionError(ctx, execution, "can't get new execution: %w", err) + } + options.ID = execution.Id s.events.Notify(testkube.NewEventStartTest(&execution)) @@ -237,7 +242,7 @@ func (s *Scheduler) createSecretsReferences(execution *testkube.Execution) (err return nil } -func newExecutionFromExecutionOptions(options client.ExecuteOptions) testkube.Execution { +func newExecutionFromExecutionOptions(subscriptionChecker *checktcl.SubscriptionChecker, options client.ExecuteOptions) (testkube.Execution, error) { execution := testkube.NewExecution( options.Request.Id, options.Namespace, @@ -272,7 +277,19 @@ func newExecutionFromExecutionOptions(options client.ExecuteOptions) testkube.Ex execution.SlavePodRequest = options.Request.SlavePodRequest // Pro edition only (tcl protected code) - return schedulertcl.NewExecutionFromExecutionOptions(options, execution) + if schedulertcl.HasExecutionNamespace(options.Request) { + ok, err := subscriptionChecker.IsOrgPlanActive() + if err != nil { + return execution, fmt.Errorf("execution namespace is a pro feature: %w", err) + } + if !ok { + return execution, fmt.Errorf("execution namespace is not available: inactive subscription plan") + } + + execution = schedulertcl.NewExecutionFromExecutionOptions(options.Request, execution) + } + + return execution, nil } func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.ExecutionRequest) (options client.ExecuteOptions, err error) { @@ -401,7 +418,17 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe } // Pro edition only (tcl protected code) - request = schedulertcl.GetExecuteOptions(test.ExecutionRequest, request) + if schedulertcl.HasExecutionNamespace(request) { + ok, err := s.subscriptionChecker.IsOrgPlanActive() + if err != nil { + return options, fmt.Errorf("execution namespace is a pro feature: %w", err) + } + if !ok { + return options, fmt.Errorf("execution namespace is not available: inactive subscription plan") + } + + request = schedulertcl.GetExecuteOptions(test.ExecutionRequest, request) + } } // get executor from kubernetes CRs diff --git a/pkg/tcl/schedulertcl/test_scheduler.go b/pkg/tcl/schedulertcl/test_scheduler.go index 2837cc90104..61640c5342f 100644 --- a/pkg/tcl/schedulertcl/test_scheduler.go +++ b/pkg/tcl/schedulertcl/test_scheduler.go @@ -10,12 +10,15 @@ package schedulertcl import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/executor/client" ) // NewExecutionFromExecutionOptions creates new execution from execution options -func NewExecutionFromExecutionOptions(options client.ExecuteOptions, execution testkube.Execution) testkube.Execution { - execution.ExecutionNamespace = options.Request.ExecutionNamespace +func NewExecutionFromExecutionOptions(request testkube.ExecutionRequest, execution testkube.Execution) testkube.Execution { + execution.ExecutionNamespace = request.ExecutionNamespace + if execution.ExecutionNamespace != "" { + execution.TestNamespace = execution.ExecutionNamespace + } + return execution } @@ -32,3 +35,8 @@ func GetExecuteOptions(sourceRequest *testkube.ExecutionRequest, return destinationRequest } + +// HasExecutionNamespace checks whether execution has execution namespace +func HasExecutionNamespace(request testkube.ExecutionRequest) bool { + return request.ExecutionNamespace != "" +} From 97584dbc7d1047dbcfb2adb4633d6ddb77c2c305 Mon Sep 17 00:00:00 2001 From: Catalin <20538711+devcatalin@users.noreply.github.com> Date: Mon, 26 Feb 2024 12:17:18 +0200 Subject: [PATCH 125/234] docs: azure troubleshooting (#5064) --- docs/docs/articles/azure-troubleshooting.md | 50 +++++++++++++++++++++ docs/docs/articles/azure.md | 3 ++ docs/sidebars.js | 1 + 3 files changed, 54 insertions(+) create mode 100644 docs/docs/articles/azure-troubleshooting.md diff --git a/docs/docs/articles/azure-troubleshooting.md b/docs/docs/articles/azure-troubleshooting.md new file mode 100644 index 00000000000..f36ab6722fe --- /dev/null +++ b/docs/docs/articles/azure-troubleshooting.md @@ -0,0 +1,50 @@ +# Azure DevOps Troubleshooting + +## Testkube CLI and Git Integration issue + +When integrating Testkube with Azure DevOps, a common issue that users might encounter involves the --git flags in the Testkube CLI. This problem manifests as the process becoming stuck without displaying any error messages, ultimately leading to a timeout. This document provides a solution to circumvent this issue, ensuring a smoother integration and execution of tests within Azure DevOps pipelines. + +To avoid this issue, it is recommended to use the Git CLI directly for cloning the necessary repositories before executing Testkube CLI commands that reference the local copies of the test files or directories. This approach bypasses the complications associated with the --git flags in Testkube CLI within Azure DevOps environments. + +### Example Workflow Adjustment + +#### Before Adjustment (Issue Prone): +```yaml +trigger: +- main + +pool: + vmImage: 'ubuntu-latest' + +stages: +- stage: Test + jobs: + - job: RunTestkube + steps: + - task: SetupTestkube@1 + - script: | + testkube create test --name test-name --test-content-type git-file --git-uri --git-path test-path + testkube run test test-name + displayName: Run Testkube Test +``` + +#### After Adjustment (Recommended Solution): +```yaml +trigger: +- main + +pool: + vmImage: 'ubuntu-latest' + +stages: +- stage: Test + jobs: + - job: RunTestkube + steps: + - task: SetupTestkube@1 + - script: | + git clone + testkube create test --name test-name -f test-path + testkube run test test-name + displayName: Run Testkube Test +``` diff --git a/docs/docs/articles/azure.md b/docs/docs/articles/azure.md index ca714276e44..98899041359 100644 --- a/docs/docs/articles/azure.md +++ b/docs/docs/articles/azure.md @@ -8,6 +8,9 @@ The Azure DevOps integration offers a versatile solution for managing your pipel Install the Testkube CLI extension using the following url: [https://marketplace.visualstudio.com/items?itemName=Testkube.testkubecli](https://marketplace.visualstudio.com/items?itemName=Testkube.testkubecli) +#### Troubleshooting +For solutions to common issues, such as the `--git` flags causing timeouts, please refer to our [Troubleshooting article](./azure-troubleshooting.md). + ## Testkube Pro ### How to configure Testkube CLI action for Testkube Pro and run a test diff --git a/docs/sidebars.js b/docs/sidebars.js index 641bf7940c3..9f970ad543a 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -105,6 +105,7 @@ const sidebars = { "articles/gitlab", "articles/jenkins", "articles/jenkins-ui", + "articles/azure", "articles/circleci", "articles/run-tests-with-github-actions", "articles/testkube-cli-docker", From e94424f256d7aa344d751dc640d192ffb779e34d Mon Sep 17 00:00:00 2001 From: Emil Davtyan Date: Mon, 26 Feb 2024 11:57:59 +0100 Subject: [PATCH 126/234] feat: send app/build information in telemetry context. (#5065) --- pkg/telemetry/sender_sio.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/telemetry/sender_sio.go b/pkg/telemetry/sender_sio.go index 7a26a91076d..619fb12ed70 100644 --- a/pkg/telemetry/sender_sio.go +++ b/pkg/telemetry/sender_sio.go @@ -19,6 +19,8 @@ const ProEnvVariableName = "TESTKUBE_PRO_API_KEY" var SegmentioKey = "jELokNFNcLeQhxdpGF47PcxCtOLpwVuu" var CloudSegmentioKey = "" +const AppBuild string = "oss" + func StdLogger() analytics.Logger { return stdLogger{} } @@ -69,6 +71,13 @@ func mapEvent(userID string, event Event) analytics.Track { Event: event.Name, UserId: userID, Properties: mapProperties(event.Params), + Context: &analytics.Context{ + App: analytics.AppInfo{ + Name: event.Params.AppName, + Version: event.Params.AppVersion, + Build: AppBuild, + }, + }, } } From e293cbc182a2948c3ad509669173d84e6e06a206 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 26 Feb 2024 12:43:31 +0100 Subject: [PATCH 127/234] feat(TKC-1465): add expressions language for TestWorkflows (#5057) * feat(TKC-1465): add basic expressions language and engine * chore(TKC-1465): rename and expose newString/newstringValue as NewValue/NewString * feat(TKC-1465): add shellquote function in expressions standard lib * feat(TKC-1465): allow to pass multiple values to the shellquote function * feat: handle circular expressions with "call stack exceeded" error * chore: rename Simplify() to Resolve() * chore: make singleton for "none" value, as a *static(nil) * feat(TKC-1465): add expression utilities for trimming and working with YAML * feat(TKC-1465): add list/join/split functions for Expressions standard library * feat(TKC-1465): allow literal \n character in the direct JSON strings * feat(TKC-1465): add `bool()` casting function to Expressions standard library * chore(TKC-1465): extract CastToString helper * chore(TKC-1465): reformat expressions stdlib definition to include ReturnType * feat(TKC-1465): replace StringAwareExpression.WillBeString() with Expression.Type() --- pkg/tcl/expressionstcl/accessor.go | 70 ++++ pkg/tcl/expressionstcl/call.go | 143 ++++++++ pkg/tcl/expressionstcl/conditional.go | 109 ++++++ pkg/tcl/expressionstcl/convert.go | 164 +++++++++ pkg/tcl/expressionstcl/expression.go | 51 +++ pkg/tcl/expressionstcl/finalizer.go | 33 ++ pkg/tcl/expressionstcl/machine.go | 90 +++++ pkg/tcl/expressionstcl/math.go | 298 ++++++++++++++++ pkg/tcl/expressionstcl/mock_expression.go | 171 ++++++++++ pkg/tcl/expressionstcl/mock_machine.go | 85 +++++ pkg/tcl/expressionstcl/mock_machinecore.go | 71 ++++ pkg/tcl/expressionstcl/mock_staticvalue.go | 373 +++++++++++++++++++++ pkg/tcl/expressionstcl/negative.go | 72 ++++ pkg/tcl/expressionstcl/parse.go | 221 ++++++++++++ pkg/tcl/expressionstcl/parse_test.go | 238 +++++++++++++ pkg/tcl/expressionstcl/static.go | 160 +++++++++ pkg/tcl/expressionstcl/static_test.go | 256 ++++++++++++++ pkg/tcl/expressionstcl/stdlib.go | 232 +++++++++++++ pkg/tcl/expressionstcl/tokenize.go | 107 ++++++ pkg/tcl/expressionstcl/tokenize_test.go | 74 ++++ pkg/tcl/expressionstcl/tokens.go | 56 ++++ pkg/tcl/expressionstcl/typechecking.go | 58 ++++ pkg/tcl/expressionstcl/utils.go | 28 ++ 23 files changed, 3160 insertions(+) create mode 100644 pkg/tcl/expressionstcl/accessor.go create mode 100644 pkg/tcl/expressionstcl/call.go create mode 100644 pkg/tcl/expressionstcl/conditional.go create mode 100644 pkg/tcl/expressionstcl/convert.go create mode 100644 pkg/tcl/expressionstcl/expression.go create mode 100644 pkg/tcl/expressionstcl/finalizer.go create mode 100644 pkg/tcl/expressionstcl/machine.go create mode 100644 pkg/tcl/expressionstcl/math.go create mode 100644 pkg/tcl/expressionstcl/mock_expression.go create mode 100644 pkg/tcl/expressionstcl/mock_machine.go create mode 100644 pkg/tcl/expressionstcl/mock_machinecore.go create mode 100644 pkg/tcl/expressionstcl/mock_staticvalue.go create mode 100644 pkg/tcl/expressionstcl/negative.go create mode 100644 pkg/tcl/expressionstcl/parse.go create mode 100644 pkg/tcl/expressionstcl/parse_test.go create mode 100644 pkg/tcl/expressionstcl/static.go create mode 100644 pkg/tcl/expressionstcl/static_test.go create mode 100644 pkg/tcl/expressionstcl/stdlib.go create mode 100644 pkg/tcl/expressionstcl/tokenize.go create mode 100644 pkg/tcl/expressionstcl/tokenize_test.go create mode 100644 pkg/tcl/expressionstcl/tokens.go create mode 100644 pkg/tcl/expressionstcl/typechecking.go create mode 100644 pkg/tcl/expressionstcl/utils.go diff --git a/pkg/tcl/expressionstcl/accessor.go b/pkg/tcl/expressionstcl/accessor.go new file mode 100644 index 00000000000..94ef84a0a1d --- /dev/null +++ b/pkg/tcl/expressionstcl/accessor.go @@ -0,0 +1,70 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" +) + +type accessor struct { + name string +} + +func newAccessor(name string) Expression { + return &accessor{name: name} +} + +func (s *accessor) Type() Type { + return TypeUnknown +} + +func (s *accessor) String() string { + return s.name +} + +func (s *accessor) SafeString() string { + return s.String() +} + +func (s *accessor) Template() string { + return "{{" + s.String() + "}}" +} + +func (s *accessor) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { + if m == nil { + return s, false, nil + } + + for i := range m { + result, ok, err := m[i].Get(s.name) + if err != nil { + return nil, false, fmt.Errorf("error while accessing %s: %s", s.String(), err.Error()) + } + if ok { + return result, true, nil + } + } + return s, false, nil +} + +func (s *accessor) Resolve(m ...MachineCore) (v Expression, err error) { + return deepResolve(s, m...) +} + +func (s *accessor) Static() StaticValue { + return nil +} + +func (s *accessor) Accessors() map[string]struct{} { + return map[string]struct{}{s.name: {}} +} + +func (s *accessor) Functions() map[string]struct{} { + return nil +} diff --git a/pkg/tcl/expressionstcl/call.go b/pkg/tcl/expressionstcl/call.go new file mode 100644 index 00000000000..c385be70fde --- /dev/null +++ b/pkg/tcl/expressionstcl/call.go @@ -0,0 +1,143 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" + "maps" + "strings" +) + +const stringCastStdFn = "string" + +type call struct { + name string + args []Expression +} + +func newCall(name string, args []Expression) Expression { + for i := range args { + if args[i] == nil { + args[i] = None + } + } + return &call{name: name, args: args} +} + +func CastToString(v Expression) Expression { + if v.Static() != nil { + return NewStringValue(v.Static().Value()) + } else if v.Type() == TypeString { + return v + } + return newCall(stringCastStdFn, []Expression{v}) +} + +func (s *call) Type() Type { + if IsStdFunction(s.name) { + return GetStdFunctionReturnType(s.name) + } + return TypeUnknown +} + +func (s *call) String() string { + args := make([]string, len(s.args)) + for i, arg := range s.args { + args[i] = arg.String() + } + return fmt.Sprintf("%s(%s)", s.name, strings.Join(args, ",")) +} + +func (s *call) SafeString() string { + return s.String() +} + +func (s *call) Template() string { + if s.name == stringCastStdFn { + args := make([]string, len(s.args)) + for i, a := range s.args { + args[i] = a.Template() + } + return strings.Join(args, "") + } + return "{{" + s.String() + "}}" +} + +func (s *call) isResolved() bool { + for i := range s.args { + if s.args[i].Static() == nil { + return false + } + } + return true +} + +func (s *call) resolvedArgs() []StaticValue { + v := make([]StaticValue, len(s.args)) + for i, vv := range s.args { + v[i] = vv.Static() + } + return v +} + +func (s *call) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { + var ch bool + for i := range s.args { + s.args[i], ch, err = s.args[i].SafeResolve(m...) + changed = changed || ch + if err != nil { + return nil, changed, err + } + } + if s.isResolved() { + args := s.resolvedArgs() + result, ok, err := StdLibMachine.Call(s.name, args...) + if ok { + if err != nil { + return nil, true, fmt.Errorf("error while calling %s: %s", s.String(), err.Error()) + } + return result, true, nil + } + for i := range m { + result, ok, err = m[i].Call(s.name, args...) + if err != nil { + return nil, true, fmt.Errorf("error while calling %s: %s", s.String(), err.Error()) + } + if ok { + return result, true, nil + } + } + } + return s, changed, nil +} + +func (s *call) Resolve(m ...MachineCore) (v Expression, err error) { + return deepResolve(s, m...) +} + +func (s *call) Static() StaticValue { + return nil +} + +func (s *call) Accessors() map[string]struct{} { + result := make(map[string]struct{}) + for i := range s.args { + maps.Copy(result, s.args[i].Accessors()) + } + return result +} + +func (s *call) Functions() map[string]struct{} { + result := make(map[string]struct{}) + for i := range s.args { + maps.Copy(result, s.args[i].Functions()) + } + result[s.name] = struct{}{} + return result +} diff --git a/pkg/tcl/expressionstcl/conditional.go b/pkg/tcl/expressionstcl/conditional.go new file mode 100644 index 00000000000..ada1e96558c --- /dev/null +++ b/pkg/tcl/expressionstcl/conditional.go @@ -0,0 +1,109 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" + "maps" +) + +type conditional struct { + condition Expression + truthy Expression + falsy Expression +} + +func newConditional(condition, truthy, falsy Expression) Expression { + if condition == nil { + condition = None + } + if truthy == nil { + truthy = None + } + if falsy == nil { + falsy = None + } + return &conditional{condition: condition, truthy: truthy, falsy: falsy} +} + +func (s *conditional) Type() Type { + r1 := s.truthy.Type() + r2 := s.falsy.Type() + if r1 == r2 { + return r1 + } + return TypeUnknown +} + +func (s *conditional) String() string { + return fmt.Sprintf("%s ? %s : %s", s.condition.String(), s.truthy.String(), s.falsy.String()) +} + +func (s *conditional) SafeString() string { + return "(" + s.String() + ")" +} + +func (s *conditional) Template() string { + return "{{" + s.String() + "}}" +} + +func (s *conditional) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { + var ch bool + s.condition, ch, err = s.condition.SafeResolve(m...) + changed = changed || ch + if err != nil { + return nil, changed, err + } + if s.condition.Static() != nil { + var b bool + b, err = s.condition.Static().BoolValue() + if err != nil { + return nil, true, err + } + if b { + return s.truthy, true, err + } + return s.falsy, true, err + } + s.truthy, ch, err = s.truthy.SafeResolve(m...) + changed = changed || ch + if err != nil { + return nil, changed, err + } + s.falsy, ch, err = s.falsy.SafeResolve(m...) + changed = changed || ch + if err != nil { + return nil, changed, err + } + return s, changed, nil +} + +func (s *conditional) Resolve(m ...MachineCore) (v Expression, err error) { + return deepResolve(s, m...) +} + +func (s *conditional) Static() StaticValue { + return nil +} + +func (s *conditional) Accessors() map[string]struct{} { + result := make(map[string]struct{}) + maps.Copy(result, s.condition.Accessors()) + maps.Copy(result, s.truthy.Accessors()) + maps.Copy(result, s.falsy.Accessors()) + return result +} + +func (s *conditional) Functions() map[string]struct{} { + result := make(map[string]struct{}) + maps.Copy(result, s.condition.Functions()) + maps.Copy(result, s.truthy.Functions()) + maps.Copy(result, s.falsy.Functions()) + return result +} diff --git a/pkg/tcl/expressionstcl/convert.go b/pkg/tcl/expressionstcl/convert.go new file mode 100644 index 00000000000..8b4d522c980 --- /dev/null +++ b/pkg/tcl/expressionstcl/convert.go @@ -0,0 +1,164 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" +) + +func toString(s interface{}) (string, error) { + // Fast track + v, ok := s.(string) + if ok { + return v, nil + } + if isNone(s) { + return "", nil + } + // Convert + if isNumber(s) { + return fmt.Sprintf("%v", s), nil + } + if isSlice(s) { + var err error + value := reflect.ValueOf(s) + results := make([]string, value.Len()) + for i := 0; i < value.Len(); i++ { + results[i], err = toString(value.Index(i).Interface()) + if err != nil { + err = fmt.Errorf("error while converting '%v' slice item: %v", value.Index(i), err) + return "", err + } + } + return strings.Join(results, ","), nil + } + b, err := json.Marshal(s) + if err != nil { + return "", fmt.Errorf("error while converting '%v' map to JSON: %v", s, err) + } + r := string(b) + if isMap(s) && r == "null" { + return "{}", nil + } + return r, nil +} + +func toFloat(s interface{}) (float64, error) { + // Fast track + if v, ok := s.(float64); ok { + return v, nil + } + if isNone(s) { + return 0, nil + } + // Convert + str, err := toString(s) + if err != nil { + return 0, err + } + v, err := strconv.ParseFloat(str, 64) + if err != nil { + return 0, fmt.Errorf("error while converting value to number: %v: %v", s, err) + } + return v, nil +} + +func toInt(s interface{}) (int64, error) { + // Fast track + if v, ok := s.(int64); ok { + return v, nil + } + if v, ok := s.(int); ok { + return int64(v), nil + } + if isNone(s) { + return 0, nil + } + // Convert + v, err := toFloat(s) + return int64(v), err +} + +func toBool(s interface{}) (bool, error) { + // Fast track + if v, ok := s.(bool); ok { + return v, nil + } + if isNone(s) { + return false, nil + } + if isMap(s) || isSlice(s) { + return reflect.ValueOf(s).Len() > 0, nil + } + // Convert + value, err := toString(s) + if err != nil { + return false, fmt.Errorf("error while converting value to bool: %v: %v", value, err) + } + return !(value == "" || value == "false" || value == "0" || value == "off"), nil +} + +func toMap(s interface{}) (map[string]interface{}, error) { + // Fast track + if v, ok := s.(map[string]interface{}); ok { + return v, nil + } + if isNone(s) { + return nil, nil + } + // Convert + if isMap(s) { + value := reflect.ValueOf(s) + res := make(map[string]interface{}, value.Len()) + for _, k := range value.MapKeys() { + kk, err := toString(k.Interface()) + if err != nil { + return nil, fmt.Errorf("error while converting map key to string: %v: %v", k, err) + } + res[kk] = value.MapIndex(k).Interface() + } + return res, nil + } + if isSlice(s) { + value := reflect.ValueOf(s) + res := make(map[string]interface{}, value.Len()) + for i := 0; i < value.Len(); i++ { + res[strconv.Itoa(i)] = value.Index(i).Interface() + } + return res, nil + } + return nil, fmt.Errorf("error while converting value to map: %v", s) +} + +func toSlice(s interface{}) ([]interface{}, error) { + // Fast track + if v, ok := s.([]interface{}); ok { + return v, nil + } + if isNone(s) { + return nil, nil + } + // Convert + if isSlice(s) { + value := reflect.ValueOf(s) + res := make([]interface{}, value.Len()) + for i := 0; i < value.Len(); i++ { + res[i] = value.Index(i).Interface() + } + return res, nil + } + if isMap(s) { + return nil, fmt.Errorf("error while converting map to slice: %v", s) + } + return nil, fmt.Errorf("error while converting value to slice: %v", s) +} diff --git a/pkg/tcl/expressionstcl/expression.go b/pkg/tcl/expressionstcl/expression.go new file mode 100644 index 00000000000..8facbde0df2 --- /dev/null +++ b/pkg/tcl/expressionstcl/expression.go @@ -0,0 +1,51 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +//go:generate mockgen -destination=./mock_expression.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" Expression +type Expression interface { + String() string + SafeString() string + Template() string + Type() Type + SafeResolve(...MachineCore) (Expression, bool, error) + Resolve(...MachineCore) (Expression, error) + Static() StaticValue + Accessors() map[string]struct{} + Functions() map[string]struct{} +} + +type Type string + +const ( + TypeUnknown Type = "" + TypeBool Type = "bool" + TypeString Type = "string" + TypeFloat64 Type = "float64" + TypeInt64 Type = "int64" +) + +//go:generate mockgen -destination=./mock_staticvalue.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" StaticValue +type StaticValue interface { + Expression + IsNone() bool + IsString() bool + IsBool() bool + IsInt() bool + IsNumber() bool + IsMap() bool + IsSlice() bool + Value() interface{} + BoolValue() (bool, error) + IntValue() (int64, error) + FloatValue() (float64, error) + StringValue() (string, error) + MapValue() (map[string]interface{}, error) + SliceValue() ([]interface{}, error) +} diff --git a/pkg/tcl/expressionstcl/finalizer.go b/pkg/tcl/expressionstcl/finalizer.go new file mode 100644 index 00000000000..1a895d27c01 --- /dev/null +++ b/pkg/tcl/expressionstcl/finalizer.go @@ -0,0 +1,33 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" +) + +type finalizer struct { + machine MachineCore +} + +func (f *finalizer) Get(name string) (Expression, bool, error) { + v, ok, err := f.machine.Get(name) + if !ok && err == nil { + return None, true, nil + } + return v, ok, err +} + +func (f *finalizer) Call(name string, args ...StaticValue) (Expression, bool, error) { + v, ok, err := f.machine.Call(name, args...) + if !ok && err == nil { + return nil, true, fmt.Errorf(`"%s" function not resolved`, name) + } + return v, ok, err +} diff --git a/pkg/tcl/expressionstcl/machine.go b/pkg/tcl/expressionstcl/machine.go new file mode 100644 index 00000000000..f01f2e2e3ce --- /dev/null +++ b/pkg/tcl/expressionstcl/machine.go @@ -0,0 +1,90 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +//go:generate mockgen -destination=./mock_machinecore.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" MachineCore +type MachineCore interface { + Get(name string) (Expression, bool, error) + Call(name string, args ...StaticValue) (Expression, bool, error) +} + +//go:generate mockgen -destination=./mock_machine.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" Machine +type Machine interface { + MachineCore + Finalizer() MachineCore +} + +type MachineAccessor = func(name string) (interface{}, bool) +type MachineFn = func(values ...StaticValue) (interface{}, bool, error) + +type machine struct { + accessors []MachineAccessor + functions map[string]MachineFn + finalizer *finalizer +} + +func NewMachine() *machine { + m := &machine{ + accessors: make([]MachineAccessor, 0), + functions: make(map[string]MachineFn), + } + m.finalizer = &finalizer{machine: m} + return m +} + +func (m *machine) Register(name string, value interface{}) *machine { + return m.RegisterAccessor(func(n string) (interface{}, bool) { + if n == name { + return value, true + } + return nil, false + }) +} + +func (m *machine) RegisterAccessor(fn MachineAccessor) *machine { + m.accessors = append(m.accessors, fn) + return m +} + +func (m *machine) RegisterFunction(name string, fn MachineFn) *machine { + m.functions[name] = fn + return m +} + +func (m *machine) Get(name string) (Expression, bool, error) { + for i := range m.accessors { + r, ok := m.accessors[i](name) + if ok { + if v, ok := r.(Expression); ok { + return v, true, nil + } + return NewValue(r), true, nil + } + } + return nil, false, nil +} + +func (m *machine) Call(name string, args ...StaticValue) (Expression, bool, error) { + fn, ok := m.functions[name] + if !ok { + return nil, false, nil + } + r, ok, err := fn(args...) + if !ok || err != nil { + return nil, ok, err + } + if v, ok := r.(Expression); ok { + return v, true, nil + } + return NewValue(r), true, nil +} + +func (m *machine) Finalizer() MachineCore { + return m.finalizer +} diff --git a/pkg/tcl/expressionstcl/math.go b/pkg/tcl/expressionstcl/math.go new file mode 100644 index 00000000000..e792f05107a --- /dev/null +++ b/pkg/tcl/expressionstcl/math.go @@ -0,0 +1,298 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "errors" + "fmt" + "maps" + math2 "math" +) + +type operator string + +const ( + operatorEquals operator = "=" + operatorEqualsAlias operator = "==" + operatorNotEquals operator = "!=" + operatorNotEqualsAlias operator = "<>" + operatorGt operator = ">" + operatorGte operator = ">=" + operatorLt operator = "<" + operatorLte operator = "<=" + operatorAnd operator = "&&" + operatorOr operator = "||" + operatorAdd operator = "+" + operatorSubtract operator = "-" + operatorModulo operator = "%" + operatorDivide operator = "/" + operatorMultiply operator = "*" + operatorPower operator = "**" +) + +func getOperatorPriority(op operator) int { + switch op { + case operatorAnd, operatorOr: + return 0 + case operatorEquals, operatorEqualsAlias, operatorNotEquals, operatorNotEqualsAlias, + operatorGt, operatorGte, operatorLt, operatorLte: + return 1 + case operatorAdd, operatorSubtract: + return 2 + case operatorMultiply, operatorDivide, operatorModulo: + return 3 + case operatorPower: + return 4 + } + panic("unknown operator: " + op) +} + +type math struct { + operator operator + left Expression + right Expression +} + +func newMath(operator operator, left Expression, right Expression) Expression { + if left == nil { + left = None + } + if right == nil { + right = None + } + return &math{operator: operator, left: left, right: right} +} + +func runOp[T interface{}, U interface{}](v1 StaticValue, v2 StaticValue, mapper func(value StaticValue) (T, error), op func(s1, s2 T) U) (StaticValue, error) { + s1, err1 := mapper(v1) + if err1 != nil { + return nil, err1 + } + s2, err2 := mapper(v2) + if err2 != nil { + return nil, err2 + } + return NewValue(op(s1, s2)), nil +} + +func staticString(v StaticValue) (string, error) { + return v.StringValue() +} + +func staticFloat(v StaticValue) (float64, error) { + return v.FloatValue() +} + +func staticBool(v StaticValue) (bool, error) { + return v.BoolValue() +} + +func (s *math) performMath(v1 StaticValue, v2 StaticValue) (StaticValue, error) { + switch s.operator { + case operatorEquals, operatorEqualsAlias: + return runOp(v1, v2, staticString, func(s1, s2 string) bool { + return s1 == s2 + }) + case operatorNotEquals, operatorNotEqualsAlias: + return runOp(v1, v2, staticString, func(s1, s2 string) bool { + return s1 != s2 + }) + case operatorGt: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) bool { + return s1 > s2 + }) + case operatorLt: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) bool { + return s1 < s2 + }) + case operatorGte: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) bool { + return s1 >= s2 + }) + case operatorLte: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) bool { + return s1 <= s2 + }) + case operatorAnd: + return runOp(v1, v2, staticBool, func(s1, s2 bool) interface{} { + if s1 { + return v2.Value() + } + return v1.Value() + }) + case operatorOr: + return runOp(v1, v2, staticBool, func(s1, s2 bool) interface{} { + if s1 { + return v1.Value() + } + return v2.Value() + }) + case operatorAdd: + if v1.IsString() || v2.IsString() { + return runOp(v1, v2, staticString, func(s1, s2 string) string { + return s1 + s2 + }) + } + return runOp(v1, v2, staticFloat, func(s1, s2 float64) float64 { + return s1 + s2 + }) + case operatorSubtract: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) float64 { + return s1 - s2 + }) + case operatorModulo: + divideByZero := false + res, err := runOp(v1, v2, staticFloat, func(s1, s2 float64) float64 { + if s2 == 0 { + divideByZero = true + return 0 + } + return math2.Mod(s1, s2) + }) + if divideByZero { + return nil, errors.New("cannot modulo by zero") + } + return res, err + case operatorDivide: + divideByZero := false + res, err := runOp(v1, v2, staticFloat, func(s1, s2 float64) float64 { + if s2 == 0 { + divideByZero = true + return 0 + } + return s1 / s2 + }) + if divideByZero { + return nil, errors.New("cannot divide by zero") + } + return res, err + case operatorMultiply: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) float64 { + return s1 * s2 + }) + case operatorPower: + return runOp(v1, v2, staticFloat, func(s1, s2 float64) float64 { + return math2.Pow(s1, s2) + }) + default: + } + return nil, fmt.Errorf("unknown math operator: %s", s.operator) +} + +func (s *math) Type() Type { + l := s.left.Type() + r := s.right.Type() + switch s.operator { + case operatorAnd, operatorOr: + if l == r { + return l + } + return TypeUnknown + case operatorPower, operatorModulo, operatorSubtract, operatorMultiply, operatorDivide: + return TypeFloat64 + case operatorAdd: + if l == TypeString || r == TypeString { + return TypeString + } + return TypeFloat64 + case operatorEquals, operatorNotEquals, operatorEqualsAlias, operatorNotEqualsAlias, operatorGt, operatorLt, operatorGte, operatorLte: + return TypeBool + default: + return TypeUnknown + } +} + +func (s *math) itemString(v Expression) string { + if vv, ok := v.(*math); ok { + if getOperatorPriority(vv.operator) >= getOperatorPriority(s.operator) { + return v.String() + } + } + return v.SafeString() +} + +func (s *math) String() string { + return s.itemString(s.left) + string(s.operator) + s.itemString(s.right) +} + +func (s *math) SafeString() string { + return "(" + s.String() + ")" +} + +func (s *math) Template() string { + // Simplify the template when it is possible + if s.operator == operatorAdd && s.Type() == TypeString { + return s.left.Template() + s.right.Template() + } + return "{{" + s.String() + "}}" +} + +func (s *math) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { + var ch bool + s.left, ch, err = s.left.SafeResolve(m...) + changed = changed || ch + if err != nil { + return + } + + // Fast track for cutting dead paths + if s.left.Static() != nil { + if s.operator == operatorAnd { + b, err := s.left.Static().BoolValue() + if err == nil && !b { + return s.left, true, nil + } else if err == nil { + return s.right, true, nil + } + } else if s.operator == operatorOr { + b, err := s.left.Static().BoolValue() + if err == nil && b { + return s.left, true, nil + } else if err == nil { + return s.right, true, nil + } + } + } + + s.right, ch, err = s.right.SafeResolve(m...) + changed = changed || ch + if err != nil { + return + } + if s.left.Static() != nil && s.right.Static() != nil { + res, err := s.performMath(s.left.Static(), s.right.Static()) + if err != nil { + return nil, changed, fmt.Errorf("error while performing math: %s: %s", s.String(), err) + } + return res, true, nil + } + return s, changed, nil +} + +func (s *math) Resolve(m ...MachineCore) (v Expression, err error) { + return deepResolve(s, m...) +} + +func (s *math) Static() StaticValue { + return nil +} + +func (s *math) Accessors() map[string]struct{} { + result := make(map[string]struct{}) + maps.Copy(result, s.left.Accessors()) + maps.Copy(result, s.right.Accessors()) + return result +} + +func (s *math) Functions() map[string]struct{} { + result := make(map[string]struct{}) + maps.Copy(result, s.left.Functions()) + maps.Copy(result, s.right.Functions()) + return result +} diff --git a/pkg/tcl/expressionstcl/mock_expression.go b/pkg/tcl/expressionstcl/mock_expression.go new file mode 100644 index 00000000000..437f40b39bc --- /dev/null +++ b/pkg/tcl/expressionstcl/mock_expression.go @@ -0,0 +1,171 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/expressionstcl (interfaces: Expression) + +// Package expressionstcl is a generated GoMock package. +package expressionstcl + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockExpression is a mock of Expression interface. +type MockExpression struct { + ctrl *gomock.Controller + recorder *MockExpressionMockRecorder +} + +// MockExpressionMockRecorder is the mock recorder for MockExpression. +type MockExpressionMockRecorder struct { + mock *MockExpression +} + +// NewMockExpression creates a new mock instance. +func NewMockExpression(ctrl *gomock.Controller) *MockExpression { + mock := &MockExpression{ctrl: ctrl} + mock.recorder = &MockExpressionMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExpression) EXPECT() *MockExpressionMockRecorder { + return m.recorder +} + +// Accessors mocks base method. +func (m *MockExpression) Accessors() map[string]struct{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Accessors") + ret0, _ := ret[0].(map[string]struct{}) + return ret0 +} + +// Accessors indicates an expected call of Accessors. +func (mr *MockExpressionMockRecorder) Accessors() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Accessors", reflect.TypeOf((*MockExpression)(nil).Accessors)) +} + +// Functions mocks base method. +func (m *MockExpression) Functions() map[string]struct{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Functions") + ret0, _ := ret[0].(map[string]struct{}) + return ret0 +} + +// Functions indicates an expected call of Functions. +func (mr *MockExpressionMockRecorder) Functions() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Functions", reflect.TypeOf((*MockExpression)(nil).Functions)) +} + +// Resolve mocks base method. +func (m *MockExpression) Resolve(arg0 ...MachineCore) (Expression, error) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Resolve", varargs...) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockExpressionMockRecorder) Resolve(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockExpression)(nil).Resolve), arg0...) +} + +// SafeResolve mocks base method. +func (m *MockExpression) SafeResolve(arg0 ...MachineCore) (Expression, bool, error) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SafeResolve", varargs...) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// SafeResolve indicates an expected call of SafeResolve. +func (mr *MockExpressionMockRecorder) SafeResolve(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeResolve", reflect.TypeOf((*MockExpression)(nil).SafeResolve), arg0...) +} + +// SafeString mocks base method. +func (m *MockExpression) SafeString() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SafeString") + ret0, _ := ret[0].(string) + return ret0 +} + +// SafeString indicates an expected call of SafeString. +func (mr *MockExpressionMockRecorder) SafeString() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeString", reflect.TypeOf((*MockExpression)(nil).SafeString)) +} + +// Static mocks base method. +func (m *MockExpression) Static() StaticValue { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Static") + ret0, _ := ret[0].(StaticValue) + return ret0 +} + +// Static indicates an expected call of Static. +func (mr *MockExpressionMockRecorder) Static() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Static", reflect.TypeOf((*MockExpression)(nil).Static)) +} + +// String mocks base method. +func (m *MockExpression) String() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "String") + ret0, _ := ret[0].(string) + return ret0 +} + +// String indicates an expected call of String. +func (mr *MockExpressionMockRecorder) String() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockExpression)(nil).String)) +} + +// Template mocks base method. +func (m *MockExpression) Template() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Template") + ret0, _ := ret[0].(string) + return ret0 +} + +// Template indicates an expected call of Template. +func (mr *MockExpressionMockRecorder) Template() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Template", reflect.TypeOf((*MockExpression)(nil).Template)) +} + +// Type mocks base method. +func (m *MockExpression) Type() Type { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Type") + ret0, _ := ret[0].(Type) + return ret0 +} + +// Type indicates an expected call of Type. +func (mr *MockExpressionMockRecorder) Type() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockExpression)(nil).Type)) +} diff --git a/pkg/tcl/expressionstcl/mock_machine.go b/pkg/tcl/expressionstcl/mock_machine.go new file mode 100644 index 00000000000..516098c2336 --- /dev/null +++ b/pkg/tcl/expressionstcl/mock_machine.go @@ -0,0 +1,85 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/expressionstcl (interfaces: Machine) + +// Package expressionstcl is a generated GoMock package. +package expressionstcl + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockMachine is a mock of Machine interface. +type MockMachine struct { + ctrl *gomock.Controller + recorder *MockMachineMockRecorder +} + +// MockMachineMockRecorder is the mock recorder for MockMachine. +type MockMachineMockRecorder struct { + mock *MockMachine +} + +// NewMockMachine creates a new mock instance. +func NewMockMachine(ctrl *gomock.Controller) *MockMachine { + mock := &MockMachine{ctrl: ctrl} + mock.recorder = &MockMachineMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMachine) EXPECT() *MockMachineMockRecorder { + return m.recorder +} + +// Call mocks base method. +func (m *MockMachine) Call(arg0 string, arg1 ...StaticValue) (Expression, bool, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Call", varargs...) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Call indicates an expected call of Call. +func (mr *MockMachineMockRecorder) Call(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockMachine)(nil).Call), varargs...) +} + +// Finalizer mocks base method. +func (m *MockMachine) Finalizer() MachineCore { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Finalizer") + ret0, _ := ret[0].(MachineCore) + return ret0 +} + +// Finalizer indicates an expected call of Finalizer. +func (mr *MockMachineMockRecorder) Finalizer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finalizer", reflect.TypeOf((*MockMachine)(nil).Finalizer)) +} + +// Get mocks base method. +func (m *MockMachine) Get(arg0 string) (Expression, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Get indicates an expected call of Get. +func (mr *MockMachineMockRecorder) Get(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockMachine)(nil).Get), arg0) +} diff --git a/pkg/tcl/expressionstcl/mock_machinecore.go b/pkg/tcl/expressionstcl/mock_machinecore.go new file mode 100644 index 00000000000..d7a02f5775b --- /dev/null +++ b/pkg/tcl/expressionstcl/mock_machinecore.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/expressionstcl (interfaces: MachineCore) + +// Package expressionstcl is a generated GoMock package. +package expressionstcl + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockMachineCore is a mock of MachineCore interface. +type MockMachineCore struct { + ctrl *gomock.Controller + recorder *MockMachineCoreMockRecorder +} + +// MockMachineCoreMockRecorder is the mock recorder for MockMachineCore. +type MockMachineCoreMockRecorder struct { + mock *MockMachineCore +} + +// NewMockMachineCore creates a new mock instance. +func NewMockMachineCore(ctrl *gomock.Controller) *MockMachineCore { + mock := &MockMachineCore{ctrl: ctrl} + mock.recorder = &MockMachineCoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMachineCore) EXPECT() *MockMachineCoreMockRecorder { + return m.recorder +} + +// Call mocks base method. +func (m *MockMachineCore) Call(arg0 string, arg1 ...StaticValue) (Expression, bool, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Call", varargs...) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Call indicates an expected call of Call. +func (mr *MockMachineCoreMockRecorder) Call(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockMachineCore)(nil).Call), varargs...) +} + +// Get mocks base method. +func (m *MockMachineCore) Get(arg0 string) (Expression, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Get indicates an expected call of Get. +func (mr *MockMachineCoreMockRecorder) Get(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockMachineCore)(nil).Get), arg0) +} diff --git a/pkg/tcl/expressionstcl/mock_staticvalue.go b/pkg/tcl/expressionstcl/mock_staticvalue.go new file mode 100644 index 00000000000..c6f16791537 --- /dev/null +++ b/pkg/tcl/expressionstcl/mock_staticvalue.go @@ -0,0 +1,373 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/expressionstcl (interfaces: StaticValue) + +// Package expressionstcl is a generated GoMock package. +package expressionstcl + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockStaticValue is a mock of StaticValue interface. +type MockStaticValue struct { + ctrl *gomock.Controller + recorder *MockStaticValueMockRecorder +} + +// MockStaticValueMockRecorder is the mock recorder for MockStaticValue. +type MockStaticValueMockRecorder struct { + mock *MockStaticValue +} + +// NewMockStaticValue creates a new mock instance. +func NewMockStaticValue(ctrl *gomock.Controller) *MockStaticValue { + mock := &MockStaticValue{ctrl: ctrl} + mock.recorder = &MockStaticValueMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStaticValue) EXPECT() *MockStaticValueMockRecorder { + return m.recorder +} + +// Accessors mocks base method. +func (m *MockStaticValue) Accessors() map[string]struct{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Accessors") + ret0, _ := ret[0].(map[string]struct{}) + return ret0 +} + +// Accessors indicates an expected call of Accessors. +func (mr *MockStaticValueMockRecorder) Accessors() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Accessors", reflect.TypeOf((*MockStaticValue)(nil).Accessors)) +} + +// BoolValue mocks base method. +func (m *MockStaticValue) BoolValue() (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BoolValue") + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BoolValue indicates an expected call of BoolValue. +func (mr *MockStaticValueMockRecorder) BoolValue() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BoolValue", reflect.TypeOf((*MockStaticValue)(nil).BoolValue)) +} + +// FloatValue mocks base method. +func (m *MockStaticValue) FloatValue() (float64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FloatValue") + ret0, _ := ret[0].(float64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FloatValue indicates an expected call of FloatValue. +func (mr *MockStaticValueMockRecorder) FloatValue() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FloatValue", reflect.TypeOf((*MockStaticValue)(nil).FloatValue)) +} + +// Functions mocks base method. +func (m *MockStaticValue) Functions() map[string]struct{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Functions") + ret0, _ := ret[0].(map[string]struct{}) + return ret0 +} + +// Functions indicates an expected call of Functions. +func (mr *MockStaticValueMockRecorder) Functions() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Functions", reflect.TypeOf((*MockStaticValue)(nil).Functions)) +} + +// IntValue mocks base method. +func (m *MockStaticValue) IntValue() (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IntValue") + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IntValue indicates an expected call of IntValue. +func (mr *MockStaticValueMockRecorder) IntValue() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IntValue", reflect.TypeOf((*MockStaticValue)(nil).IntValue)) +} + +// IsBool mocks base method. +func (m *MockStaticValue) IsBool() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsBool") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsBool indicates an expected call of IsBool. +func (mr *MockStaticValueMockRecorder) IsBool() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsBool", reflect.TypeOf((*MockStaticValue)(nil).IsBool)) +} + +// IsInt mocks base method. +func (m *MockStaticValue) IsInt() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsInt") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsInt indicates an expected call of IsInt. +func (mr *MockStaticValueMockRecorder) IsInt() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsInt", reflect.TypeOf((*MockStaticValue)(nil).IsInt)) +} + +// IsMap mocks base method. +func (m *MockStaticValue) IsMap() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsMap") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsMap indicates an expected call of IsMap. +func (mr *MockStaticValueMockRecorder) IsMap() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsMap", reflect.TypeOf((*MockStaticValue)(nil).IsMap)) +} + +// IsNone mocks base method. +func (m *MockStaticValue) IsNone() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsNone") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsNone indicates an expected call of IsNone. +func (mr *MockStaticValueMockRecorder) IsNone() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNone", reflect.TypeOf((*MockStaticValue)(nil).IsNone)) +} + +// IsNumber mocks base method. +func (m *MockStaticValue) IsNumber() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsNumber") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsNumber indicates an expected call of IsNumber. +func (mr *MockStaticValueMockRecorder) IsNumber() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNumber", reflect.TypeOf((*MockStaticValue)(nil).IsNumber)) +} + +// IsSlice mocks base method. +func (m *MockStaticValue) IsSlice() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsSlice") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsSlice indicates an expected call of IsSlice. +func (mr *MockStaticValueMockRecorder) IsSlice() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSlice", reflect.TypeOf((*MockStaticValue)(nil).IsSlice)) +} + +// IsString mocks base method. +func (m *MockStaticValue) IsString() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsString") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsString indicates an expected call of IsString. +func (mr *MockStaticValueMockRecorder) IsString() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsString", reflect.TypeOf((*MockStaticValue)(nil).IsString)) +} + +// MapValue mocks base method. +func (m *MockStaticValue) MapValue() (map[string]interface{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MapValue") + ret0, _ := ret[0].(map[string]interface{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MapValue indicates an expected call of MapValue. +func (mr *MockStaticValueMockRecorder) MapValue() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MapValue", reflect.TypeOf((*MockStaticValue)(nil).MapValue)) +} + +// Resolve mocks base method. +func (m *MockStaticValue) Resolve(arg0 ...MachineCore) (Expression, error) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Resolve", varargs...) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockStaticValueMockRecorder) Resolve(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockStaticValue)(nil).Resolve), arg0...) +} + +// SafeResolve mocks base method. +func (m *MockStaticValue) SafeResolve(arg0 ...MachineCore) (Expression, bool, error) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SafeResolve", varargs...) + ret0, _ := ret[0].(Expression) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// SafeResolve indicates an expected call of SafeResolve. +func (mr *MockStaticValueMockRecorder) SafeResolve(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeResolve", reflect.TypeOf((*MockStaticValue)(nil).SafeResolve), arg0...) +} + +// SafeString mocks base method. +func (m *MockStaticValue) SafeString() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SafeString") + ret0, _ := ret[0].(string) + return ret0 +} + +// SafeString indicates an expected call of SafeString. +func (mr *MockStaticValueMockRecorder) SafeString() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeString", reflect.TypeOf((*MockStaticValue)(nil).SafeString)) +} + +// SliceValue mocks base method. +func (m *MockStaticValue) SliceValue() ([]interface{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SliceValue") + ret0, _ := ret[0].([]interface{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SliceValue indicates an expected call of SliceValue. +func (mr *MockStaticValueMockRecorder) SliceValue() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SliceValue", reflect.TypeOf((*MockStaticValue)(nil).SliceValue)) +} + +// Static mocks base method. +func (m *MockStaticValue) Static() StaticValue { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Static") + ret0, _ := ret[0].(StaticValue) + return ret0 +} + +// Static indicates an expected call of Static. +func (mr *MockStaticValueMockRecorder) Static() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Static", reflect.TypeOf((*MockStaticValue)(nil).Static)) +} + +// String mocks base method. +func (m *MockStaticValue) String() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "String") + ret0, _ := ret[0].(string) + return ret0 +} + +// String indicates an expected call of String. +func (mr *MockStaticValueMockRecorder) String() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockStaticValue)(nil).String)) +} + +// StringValue mocks base method. +func (m *MockStaticValue) StringValue() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StringValue") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StringValue indicates an expected call of StringValue. +func (mr *MockStaticValueMockRecorder) StringValue() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StringValue", reflect.TypeOf((*MockStaticValue)(nil).StringValue)) +} + +// Template mocks base method. +func (m *MockStaticValue) Template() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Template") + ret0, _ := ret[0].(string) + return ret0 +} + +// Template indicates an expected call of Template. +func (mr *MockStaticValueMockRecorder) Template() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Template", reflect.TypeOf((*MockStaticValue)(nil).Template)) +} + +// Type mocks base method. +func (m *MockStaticValue) Type() Type { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Type") + ret0, _ := ret[0].(Type) + return ret0 +} + +// Type indicates an expected call of Type. +func (mr *MockStaticValueMockRecorder) Type() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockStaticValue)(nil).Type)) +} + +// Value mocks base method. +func (m *MockStaticValue) Value() interface{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Value") + ret0, _ := ret[0].(interface{}) + return ret0 +} + +// Value indicates an expected call of Value. +func (mr *MockStaticValueMockRecorder) Value() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Value", reflect.TypeOf((*MockStaticValue)(nil).Value)) +} diff --git a/pkg/tcl/expressionstcl/negative.go b/pkg/tcl/expressionstcl/negative.go new file mode 100644 index 00000000000..50326cf433f --- /dev/null +++ b/pkg/tcl/expressionstcl/negative.go @@ -0,0 +1,72 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import "fmt" + +type negative struct { + expr Expression +} + +func newNegative(expr Expression) Expression { + if expr == nil { + expr = None + } + return &negative{expr: expr} +} + +func (s *negative) Type() Type { + return TypeBool +} + +func (s *negative) String() string { + return fmt.Sprintf("!%s", s.expr.SafeString()) +} + +func (s *negative) SafeString() string { + return s.String() +} + +func (s *negative) Template() string { + return "{{" + s.String() + "}}" +} + +func (s *negative) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { + s.expr, changed, err = s.expr.SafeResolve(m...) + if err != nil { + return nil, changed, err + } + st := s.expr.Static() + if st == nil { + return s, changed, nil + } + + vv, err := st.BoolValue() + if err != nil { + return nil, changed, err + } + return NewValue(!vv), changed, nil +} + +func (s *negative) Resolve(m ...MachineCore) (v Expression, err error) { + return deepResolve(s, m...) +} + +func (s *negative) Static() StaticValue { + // FIXME: it should get environment to call + return nil +} + +func (s *negative) Accessors() map[string]struct{} { + return s.expr.Accessors() +} + +func (s *negative) Functions() map[string]struct{} { + return s.expr.Functions() +} diff --git a/pkg/tcl/expressionstcl/parse.go b/pkg/tcl/expressionstcl/parse.go new file mode 100644 index 00000000000..e7f244d92af --- /dev/null +++ b/pkg/tcl/expressionstcl/parse.go @@ -0,0 +1,221 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +func parseNextExpression(t []token, priority int) (e Expression, i int, err error) { + e, i, err = getNextSegment(t) + if err != nil { + return + } + + for { + // End of the expression + if len(t) == i { + return e, i, nil + } + + switch t[i].Type { + case tokenTypeTernary: + i += 1 + te, ti, terr := parseNextExpression(t[i:], 0) + i += ti + if terr != nil { + return nil, i, terr + } + if len(t) == i { + return nil, i, fmt.Errorf("premature end of expression: expected ternary separator") + } + if t[i].Type != tokenTypeTernarySeparator { + return nil, i, fmt.Errorf("expression syntax error: expected ternary separator: found %v", t[i]) + } + i++ + fe, fi, ferr := parseNextExpression(t[i:], 0) + i += fi + if ferr != nil { + return nil, i, ferr + } + e = newConditional(e, te, fe) + case tokenTypeMath: + op := operator(t[i].Value.(string)) + nextPriority := getOperatorPriority(op) + if priority >= nextPriority { + return e, i, nil + } + i += 1 + ne, ni, nerr := parseNextExpression(t[i:], nextPriority) + i += ni + if nerr != nil { + return nil, i, nerr + } + e = newMath(op, e, ne) + default: + return e, i, err + } + } +} + +func getNextSegment(t []token) (e Expression, i int, err error) { + if len(t) == 0 { + return nil, 0, errors.New("premature end of expression") + } + + // Parentheses - (a(b) + c) + if t[0].Type == tokenTypeOpen { + e, i, err = parseNextExpression(t[1:], -1) + i++ + if err != nil { + return nil, i, err + } + if len(t) <= i || t[i].Type != tokenTypeClose { + return nil, i, fmt.Errorf("syntax error: expected parentheses close") + } + return e, i + 1, err + } + + // Static value - "abc", 444, {"a": 10}, true, [45, 3] + if t[0].Type == tokenTypeJson { + return NewValue(t[0].Value), 1, nil + } + + // Negation - !expr + if t[0].Type == tokenTypeNot { + e, i, err = parseNextExpression(t[1:], -1) + if err != nil { + return nil, 0, err + } + return newNegative(e), i + 1, nil + } + + // Call - abc(a, b, c) + if t[0].Type == tokenTypeAccessor && len(t) > 1 && t[1].Type == tokenTypeOpen { + args := make([]Expression, 0) + index := 2 + for { + // Ensure there is another token (for call close or next argument) + if len(t) <= index { + return nil, 2, errors.New("premature end of expression: missing call close") + } + + // Close the call + if t[index].Type == tokenTypeClose { + break + } + + // Ensure comma between arguments + if len(args) != 0 { + if t[index].Type != tokenTypeComma { + return nil, 2, errors.New("expression syntax error: expected comma or call close") + } + index++ + } + next, l, err := parseNextExpression(t[index:], -1) + index += l + if err != nil { + return nil, index, err + } + args = append(args, next) + } + return newCall(t[0].Value.(string), args), index + 1, nil + } + + // Accessor - abc + if t[0].Type == tokenTypeAccessor { + return newAccessor(t[0].Value.(string)), 1, nil + } + + return nil, 0, fmt.Errorf("unexpected token in expression: %v", t) +} + +func parse(t []token) (e Expression, err error) { + if len(t) == 0 { + return None, nil + } + e, l, err := parseNextExpression(t, -1) + if err != nil { + return nil, err + } + if l < len(t) { + return nil, fmt.Errorf("unexpected token after end of expression: %v", t[l]) + } + return e, nil +} + +func Compile(exp string) (Expression, error) { + t, _, e := tokenize(exp, 0) + if e != nil { + return nil, fmt.Errorf("tokenizer error: %v", e) + } + v, e := parse(t) + if e != nil { + return nil, fmt.Errorf("parser error: %v", e) + } + return v.Resolve() +} + +func MustCompile(exp string) Expression { + v, err := Compile(exp) + if err != nil { + panic(err) + } + return v +} + +var endExprRe = regexp.MustCompile(`^\s*}}`) + +func CompileTemplate(tpl string) (Expression, error) { + var e Expression + + offset := 0 + for index := strings.Index(tpl[offset:], "{{"); index != -1; index = strings.Index(tpl[offset:], "{{") { + if index != 0 { + e = newMath(operatorAdd, e, NewStringValue(tpl[offset:offset+index])) + } + offset += index + 2 + tokens, i, err := tokenize(tpl, offset) + offset = i + if err == nil { + return nil, errors.New("template error: expression not closed") + } + if !endExprRe.MatchString(tpl[offset:]) || !strings.Contains(err.Error(), "unknown character") { + return nil, fmt.Errorf("tokenizer error: %v", err) + } + offset += len(endExprRe.FindString(tpl[offset:])) + if len(tokens) == 0 { + continue + } + v, err := parse(tokens) + if err != nil { + return nil, fmt.Errorf("parser error: %v", e) + } + v, err = v.Resolve() + if err != nil { + return nil, fmt.Errorf("expression error: %v", e) + } + e = newMath(operatorAdd, e, CastToString(v)) + } + if offset < len(tpl) { + e = newMath(operatorAdd, e, NewStringValue(tpl[offset:])) + } + return e.Resolve() +} + +func MustCompileTemplate(tpl string) Expression { + v, err := CompileTemplate(tpl) + if err != nil { + panic(err) + } + return v +} diff --git a/pkg/tcl/expressionstcl/parse_test.go b/pkg/tcl/expressionstcl/parse_test.go new file mode 100644 index 00000000000..c820521ecdd --- /dev/null +++ b/pkg/tcl/expressionstcl/parse_test.go @@ -0,0 +1,238 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCompileBasic(t *testing.T) { + assert.Equal(t, "value", must(MustCompile(`"value"`).Static().StringValue())) +} + +func TestCompileTernary(t *testing.T) { + assert.Equal(t, "value", must(MustCompile(`true ? "value" : "another"`).Static().StringValue())) + assert.Equal(t, "another", must(MustCompile(`false ? "value" : "another"`).Static().StringValue())) + assert.Equal(t, "xyz", must(MustCompile(`false ? "value" : true ? "xyz" :"another"`).Static().StringValue())) + assert.Equal(t, "xyz", must(MustCompile(`false ? "value" : (true ? "xyz" :"another")`).Static().StringValue())) + assert.Equal(t, 5.78, must(MustCompile(`false ? 3 : (true ? 5.78 : 2)`).Static().FloatValue())) +} + +func TestCompileMath(t *testing.T) { + assert.Equal(t, 5.0, must(MustCompile(`2 + 3`).Static().FloatValue())) + assert.Equal(t, 0.6, must(MustCompile(`3 / 5`).Static().FloatValue())) + assert.Equal(t, true, must(MustCompile(`3 <> 5`).Static().BoolValue())) + assert.Equal(t, true, must(MustCompile(`3 != 5`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`3 == 5`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`3 = 5`).Static().BoolValue())) +} + +func TestCompileMathOperationsPrecedence(t *testing.T) { + assert.Equal(t, 7.0, must(MustCompile(`1 + 2 * 3`).Static().FloatValue())) + assert.Equal(t, 11.0, must(MustCompile(`1 + (2 * 3) + 4`).Static().FloatValue())) + assert.Equal(t, 11.0, must(MustCompile(`1 + 2 * 3 + 4`).Static().FloatValue())) + assert.Equal(t, 30.0, must(MustCompile(`1 + 2 * 3 * 4 + 5`).Static().FloatValue())) + assert.Equal(t, true, must(MustCompile(`1 + 2 * 3 * 4 + 5 <> 3`).Static().BoolValue())) + + assert.Equal(t, false, must(MustCompile(`1 + 2 * 3 * 4 + 5 == 3`).Static().BoolValue())) + assert.Equal(t, true, must(MustCompile(`1 + 2 * 3 * 4 + 5 = 30`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`1 + 2 * 3 * 4 + 5 <> 30`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`1 + 2 * 3 * 4 + 5 <> 20 + 10`).Static().BoolValue())) + assert.Equal(t, true, must(MustCompile(`1 + 2 * 3 * 4 + 5 = 20 + 10`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`1 + 2 * 3 * 4 + 5 <> 20 + 10`).Static().BoolValue())) + assert.Equal(t, true, must(MustCompile(`1 + 2 * 3 * 4 + 5 = 2 + 3 * 6 + 10`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`1 + 2 * 3 * 4 + 5 <> 2 + 3 * 6 + 10`).Static().BoolValue())) + assert.Equal(t, 8.0, must(MustCompile(`5 + 3 / 3 * 3`).Static().FloatValue())) + assert.Equal(t, true, must(MustCompile(`5 + 3 / 3 * 3 = 8`).Static().BoolValue())) + assert.Equal(t, 8.0, must(MustCompile(`5 + 3 * 3 / 3`).Static().FloatValue())) + assert.Equal(t, true, must(MustCompile(`5 + 3 * 3 / 3 = 8`).Static().BoolValue())) + assert.Equal(t, true, must(MustCompile(`5 + 3 * 3 / 3 = 2 + 3 * 2`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`5 + 3 * 3 / 3 = 3 + 3 * 2`).Static().BoolValue())) + + assert.Equal(t, false, must(MustCompile(`true && false || false && true`).Static().BoolValue())) + assert.Equal(t, true, must(MustCompile(`true && false || true`).Static().BoolValue())) + assert.Equal(t, int64(0), must(MustCompile(`1 && 0 && 2`).Static().IntValue())) + assert.Equal(t, int64(2), must(MustCompile(`1 && 0 || 2`).Static().IntValue())) + assert.Equal(t, int64(1), must(MustCompile(`1 || 0 || 2`).Static().IntValue())) + + assert.Equal(t, true, must(MustCompile(`10 > 2 && 5 <= 5`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`10 > 2 && 5 < 5`).Static().BoolValue())) + assert.Error(t, errOnly(Compile(`10 > 2 > 3`))) + + assert.Equal(t, 817.0, must(MustCompile(`1 + 2 * 3 ** 4 * 5 + 6`).Static().FloatValue())) + assert.Equal(t, 4.5, must(MustCompile(`72 / 2 ** 4`).Static().FloatValue())) + assert.InDelta(t, 3.6, must(MustCompile(`3 * 5.2 % 4`).Static().FloatValue()), 0.00001) + + assert.Equal(t, true, must(MustCompile(`!0 && 500`).Static().BoolValue())) + assert.Equal(t, false, must(MustCompile(`!5 && 500`).Static().BoolValue())) + + assert.Equal(t, "A+B*(C+D)/E*F+G<>H**I*J**K", MustCompile(`A + B * (C + D) / E * F + G <> H ** I * J ** K`).String()) +} + +func TestBuildTemplate(t *testing.T) { + assert.Equal(t, "abc", MustCompile(`"abc"`).Template()) + assert.Equal(t, "abcdef", MustCompile(`"abc" + "def"`).Template()) + assert.Equal(t, "abc9", MustCompile(`"abc" + 9`).Template()) + assert.Equal(t, "abc{{env.xyz}}", MustCompile(`"abc" + env.xyz`).Template()) + assert.Equal(t, "{{env.xyz}}abc", MustCompile(`env.xyz + "abc"`).Template()) + assert.Equal(t, "{{env.xyz+env.abc}}abc", MustCompile(`env.xyz + env.abc + "abc"`).Template()) + assert.Equal(t, "{{env.xyz+env.abc}}abc", MustCompile(`env.xyz + env.abc + "abc"`).Template()) + assert.Equal(t, "{{3+env.xyz+env.abc}}", MustCompile(`3 + env.xyz + env.abc`).Template()) + assert.Equal(t, "3{{env.xyz}}{{env.abc}}", MustCompile(`string(3) + env.xyz + env.abc`).Template()) + assert.Equal(t, "3{{env.xyz+env.abc}}", MustCompile(`string(3) + (env.xyz + env.abc)`).Template()) + assert.Equal(t, "3{{env.xyz}}{{env.abc}}", MustCompile(`"3" + env.xyz + env.abc`).Template()) + assert.Equal(t, "3{{env.xyz+env.abc}}", MustCompile(`"3" + (env.xyz + env.abc)`).Template()) +} + +func TestCompileTemplate(t *testing.T) { + assert.Equal(t, `"abc"`, MustCompileTemplate(`abc`).String()) + assert.Equal(t, `"abcxyz5"`, MustCompileTemplate(`abc{{ "xyz" }}{{ 5 }}`).String()) + assert.Equal(t, `"abc50"`, MustCompileTemplate(`abc{{ 5 + 45 }}`).String()) + assert.Equal(t, `"abc50def"`, MustCompileTemplate(`abc{{ 5 + 45 }}def`).String()) + assert.Equal(t, `"abc50def"+string(env.abc*5)+"20"`, MustCompileTemplate(`abc{{ 5 + 45 }}def{{env.abc * 5}}20`).String()) + + assert.Equal(t, `abc50def`, must(MustCompileTemplate(`abc{{ 5 + 45 }}def`).Static().StringValue())) +} + +func TestCompilePartialResolution(t *testing.T) { + vm := NewMachine(). + Register("someint", 555). + Register("somestring", "foo"). + RegisterAccessor(func(name string) (interface{}, bool) { + if strings.HasPrefix(name, "env.") { + return "[placeholder:" + name[4:] + "]", true + } + return nil, false + }). + RegisterAccessor(func(name string) (interface{}, bool) { + if strings.HasPrefix(name, "secrets.") { + return MustCompile("secret(" + name[8:] + ")"), true + } + return nil, false + }). + RegisterFunction("mainEndpoint", func(values ...StaticValue) (interface{}, bool, error) { + if len(values) != 0 { + return nil, true, errors.New("the mainEndpoint should have no parameters") + } + return MustCompile(`env.apiUrl`), true, nil + }) + + assert.Equal(t, `555`, must(MustCompile(`someint`).Resolve(vm)).String()) + assert.Equal(t, `"[placeholder:name]"`, must(MustCompile(`env.name`).Resolve(vm)).String()) + assert.Equal(t, `secret(name)`, must(MustCompile(`secrets.name`).Resolve(vm)).String()) + assert.Equal(t, `"[placeholder:apiUrl]"`, must(MustCompile(`mainEndpoint()`).Resolve(vm)).String()) +} + +func TestCompileResolution(t *testing.T) { + vm := NewMachine(). + Register("someint", 555). + Register("somestring", "foo"). + RegisterAccessor(func(name string) (interface{}, bool) { + if strings.HasPrefix(name, "env.") { + return "[placeholder:" + name[4:] + "]", true + } + return nil, false + }). + RegisterAccessor(func(name string) (interface{}, bool) { + if strings.HasPrefix(name, "secrets.") { + return MustCompile("secret(" + name[8:] + ")"), true + } + return nil, false + }). + RegisterFunction("mainEndpoint", func(values ...StaticValue) (interface{}, bool, error) { + if len(values) != 0 { + return nil, true, errors.New("the mainEndpoint should have no parameters") + } + return MustCompile(`env.apiUrl`), true, nil + }). + Finalizer() + + assert.Equal(t, `555`, must(MustCompile(`someint`).Resolve(vm)).String()) + assert.Equal(t, `"[placeholder:name]"`, must(MustCompile(`env.name`).Resolve(vm)).String()) + assert.Error(t, errOnly(MustCompile(`secrets.name`).Resolve(vm))) + assert.Equal(t, `"[placeholder:apiUrl]"`, must(MustCompile(`mainEndpoint()`).Resolve(vm)).String()) +} + +func TestCircularResolution(t *testing.T) { + vm := NewMachine(). + RegisterFunction("one", func(values ...StaticValue) (interface{}, bool, error) { + return MustCompile("two()"), true, nil + }). + RegisterFunction("two", func(values ...StaticValue) (interface{}, bool, error) { + return MustCompile("one()"), true, nil + }). + RegisterFunction("self", func(values ...StaticValue) (interface{}, bool, error) { + return MustCompile("self()"), true, nil + }). + Finalizer() + + assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`one()`).Resolve(vm))), "call stack exceeded") + assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`self()`).Resolve(vm))), "call stack exceeded") +} + +func TestCompileMultilineString(t *testing.T) { + assert.Equal(t, `"\nabc\ndef\n"`, MustCompile(`" +abc +def +"`).String()) +} + +func TestCompileStandardLib(t *testing.T) { + assert.Equal(t, `false`, MustCompile(`bool(0)`).String()) + assert.Equal(t, `true`, MustCompile(`bool(500)`).String()) + assert.Equal(t, `"500"`, MustCompile(`string(500)`).String()) + assert.Equal(t, `500`, MustCompile(`int(500)`).String()) + assert.Equal(t, `500`, MustCompile(`int(500.888)`).String()) + assert.Equal(t, `500`, MustCompile(`int("500")`).String()) + assert.Equal(t, `500.44`, MustCompile(`float("500.44")`).String()) + assert.Equal(t, `500`, MustCompile(`json("500")`).String()) + assert.Equal(t, `{"a":500}`, MustCompile(`json("{\"a\": 500}")`).String()) + assert.Equal(t, `"{\"a\":500}"`, MustCompile(`tojson({"a": 500})`).String()) + assert.Equal(t, `"500.8"`, MustCompile(`tojson(500.8)`).String()) + assert.Equal(t, `"\"500.8\""`, MustCompile(`tojson("500.8")`).String()) + assert.Equal(t, `"abc"`, MustCompile(`shellquote("abc")`).String()) + assert.Equal(t, `"'a b c'"`, MustCompile(`shellquote("a b c")`).String()) + assert.Equal(t, `"'a b c' 'd e f'"`, MustCompile(`shellquote("a b c", "d e f")`).String()) + assert.Equal(t, `"''"`, MustCompile(`shellquote(null)`).String()) + assert.Equal(t, `"abc d"`, MustCompile(`trim(" abc d \n ")`).String()) + assert.Equal(t, `"abc"`, MustCompile(`yaml("\"abc\"")`).String()) + assert.Equal(t, `{"foo":{"bar":"baz"}}`, MustCompile(`yaml("foo:\n bar: 'baz'")`).String()) + assert.Equal(t, `"foo:\n bar: baz\n"`, MustCompile(`toyaml({"foo":{"bar":"baz"}})`).String()) + assert.Equal(t, `{"a":["b","v"]}`, MustCompile(`yaml(" +a: +- b +- v +")`).String()) + assert.Equal(t, `["a",10,["a",4]]`, MustCompile(`list("a", 10, ["a", 4])`).String()) + assert.Equal(t, `"a,10,a,4"`, MustCompile(`join(["a",10,["a",4]])`).String()) + assert.Equal(t, `"a---10---a,4"`, MustCompile(`join(["a",10,["a",4]], "---")`).String()) + assert.Equal(t, `[""]`, MustCompile(`split(null)`).String()) + assert.Equal(t, `["a","b","c"]`, MustCompile(`split("a,b,c")`).String()) + assert.Equal(t, `["a","b","c"]`, MustCompile(`split("a---b---c", "---")`).String()) +} + +func TestCompileDetectAccessors(t *testing.T) { + assert.Equal(t, map[string]struct{}{"something": {}}, MustCompile(`something`).Accessors()) + assert.Equal(t, map[string]struct{}{"something": {}, "other": {}, "another": {}}, MustCompile(`calling(something, 5 * (other + 3), !another)`).Accessors()) +} + +func TestCompileDetectFunctions(t *testing.T) { + assert.Equal(t, map[string]struct{}(nil), MustCompile(`something`).Functions()) + assert.Equal(t, map[string]struct{}{"calling": {}, "something": {}, "string": {}, "a": {}}, MustCompile(`calling(something(), 45 + 2 + 10 + string(abc * a(c)))`).Functions()) +} + +func TestCompileImmutableNone(t *testing.T) { + assert.Same(t, None, NewValue(noneValue)) + assert.Same(t, NewValue(noneValue), NewValue(noneValue)) +} diff --git a/pkg/tcl/expressionstcl/static.go b/pkg/tcl/expressionstcl/static.go new file mode 100644 index 00000000000..0582abf9f9c --- /dev/null +++ b/pkg/tcl/expressionstcl/static.go @@ -0,0 +1,160 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "encoding/json" + "strings" +) + +type static struct { + value interface{} +} + +var none *static +var None StaticValue = none + +func NewValue(value interface{}) StaticValue { + if value == noneValue { + return None + } + return &static{value: value} +} + +func NewStringValue(value interface{}) StaticValue { + v, _ := toString(value) + return NewValue(v) +} + +func (s *static) Type() Type { + switch s.value.(type) { + case int64: + return TypeInt64 + case float64: + return TypeFloat64 + case string: + return TypeString + case bool: + return TypeBool + default: + return TypeUnknown + } +} + +func (s *static) String() string { + if s.IsNone() { + return "null" + } + b, _ := json.Marshal(s.value) + if len(b) == 0 { + return "null" + } + r := string(b) + if s.IsMap() && r == "null" { + return "{}" + } + if s.IsSlice() && r == "null" { + return "[]" + } + return r +} + +func (s *static) SafeString() string { + return s.String() +} + +func (s *static) Template() string { + if s.IsNone() { + return "" + } + v, _ := s.StringValue() + if strings.Contains(v, "{{") { + return "{{" + s.String() + "}}" + } + return v +} + +func (s *static) SafeResolve(_ ...MachineCore) (Expression, bool, error) { + return s, false, nil +} + +func (s *static) Resolve(_ ...MachineCore) (Expression, error) { + return s, nil +} + +func (s *static) Static() StaticValue { + return s +} + +func (s *static) IsNone() bool { + return s == nil +} + +func (s *static) IsString() bool { + return !s.IsNone() && isString(s.value) +} + +func (s *static) IsBool() bool { + return !s.IsNone() && isBool(s.value) +} + +func (s *static) IsInt() bool { + return !s.IsNone() && isInt(s.value) +} + +func (s *static) IsNumber() bool { + return !s.IsNone() && isNumber(s.value) +} + +func (s *static) IsMap() bool { + return !s.IsNone() && isMap(s.value) +} + +func (s *static) IsSlice() bool { + return !s.IsNone() && isSlice(s.value) +} + +func (s *static) Value() interface{} { + if s.IsNone() { + return noneValue + } + return s.value +} + +func (s *static) StringValue() (string, error) { + return toString(s.Value()) +} + +func (s *static) BoolValue() (bool, error) { + return toBool(s.Value()) +} + +func (s *static) IntValue() (int64, error) { + return toInt(s.Value()) +} + +func (s *static) FloatValue() (float64, error) { + return toFloat(s.Value()) +} + +func (s *static) MapValue() (map[string]interface{}, error) { + return toMap(s.Value()) +} + +func (s *static) SliceValue() ([]interface{}, error) { + return toSlice(s.Value()) +} + +func (s *static) Accessors() map[string]struct{} { + return nil +} + +func (s *static) Functions() map[string]struct{} { + return nil +} diff --git a/pkg/tcl/expressionstcl/static_test.go b/pkg/tcl/expressionstcl/static_test.go new file mode 100644 index 00000000000..40daf184185 --- /dev/null +++ b/pkg/tcl/expressionstcl/static_test.go @@ -0,0 +1,256 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func must[T interface{}](v T, e error) T { + if e != nil { + panic(e) + } + return v +} + +func errOnly(_ interface{}, e error) error { + return e +} + +func TestStaticBool(t *testing.T) { + // Types + assert.Equal(t, "false", NewValue(false).String()) + assert.Equal(t, "true", NewValue(true).String()) + assert.Equal(t, true, NewValue(false).IsBool()) + assert.Equal(t, true, NewValue(true).IsBool()) + assert.Equal(t, false, NewValue(false).IsNone()) + assert.Equal(t, false, NewValue(true).IsNone()) + assert.Equal(t, false, NewValue(true).IsInt()) + assert.Equal(t, false, NewValue(true).IsNumber()) + assert.Equal(t, false, NewValue(true).IsString()) + assert.Equal(t, false, NewValue(true).IsMap()) + assert.Equal(t, false, NewValue(true).IsSlice()) + + // Conversion + assert.Equal(t, false, must(NewValue(false).BoolValue())) + assert.Equal(t, true, must(NewValue(true).BoolValue())) + assert.Error(t, errOnly(NewValue(true).IntValue())) + assert.Error(t, errOnly(NewValue(true).FloatValue())) + assert.Equal(t, "true", must(NewValue(true).StringValue())) + assert.Equal(t, "false", must(NewValue(false).StringValue())) + assert.Error(t, errOnly(NewValue(true).MapValue())) + assert.Error(t, errOnly(NewValue(true).SliceValue())) +} + +func TestStaticInt(t *testing.T) { + // Types + assert.Equal(t, "0", NewValue(0).String()) + assert.Equal(t, "1", NewValue(1).String()) + assert.Equal(t, false, NewValue(0).IsBool()) + assert.Equal(t, false, NewValue(1).IsBool()) + assert.Equal(t, false, NewValue(0).IsNone()) + assert.Equal(t, false, NewValue(1).IsNone()) + assert.Equal(t, true, NewValue(1).IsInt()) + assert.Equal(t, true, NewValue(1).IsNumber()) + assert.Equal(t, false, NewValue(1).IsString()) + assert.Equal(t, false, NewValue(1).IsMap()) + assert.Equal(t, false, NewValue(1).IsSlice()) + + // Conversion + assert.Equal(t, false, must(NewValue(0).BoolValue())) + assert.Equal(t, true, must(NewValue(1).BoolValue())) + assert.Equal(t, int64(1), must(NewValue(1).IntValue())) + assert.Equal(t, 1.0, must(NewValue(1).FloatValue())) + assert.Equal(t, "1", must(NewValue(1).StringValue())) + assert.Error(t, errOnly(NewValue(1).MapValue())) + assert.Error(t, errOnly(NewValue(1).SliceValue())) +} + +func TestStaticFloat(t *testing.T) { + // Types + assert.Equal(t, "0", NewValue(0.0).String()) + assert.Equal(t, "1.5", NewValue(1.5).String()) + assert.Equal(t, false, NewValue(0.0).IsBool()) + assert.Equal(t, false, NewValue(1.0).IsBool()) + assert.Equal(t, false, NewValue(1.5).IsBool()) + assert.Equal(t, false, NewValue(0.0).IsNone()) + assert.Equal(t, false, NewValue(1.0).IsNone()) + assert.Equal(t, false, NewValue(1.5).IsNone()) + assert.Equal(t, true, NewValue(1.0).IsInt()) + assert.Equal(t, false, NewValue(1.8).IsInt()) + assert.Equal(t, true, NewValue(1.5).IsNumber()) + assert.Equal(t, false, NewValue(1.7).IsString()) + assert.Equal(t, false, NewValue(1.7).IsMap()) + assert.Equal(t, false, NewValue(1.3).IsSlice()) + + // Conversion + assert.Equal(t, false, must(NewValue(0.0).BoolValue())) + assert.Equal(t, true, must(NewValue(0.5).BoolValue())) + assert.Equal(t, true, must(NewValue(1.0).BoolValue())) + assert.Equal(t, true, must(NewValue(1.5).BoolValue())) + assert.Equal(t, int64(1), must(NewValue(1.8).IntValue())) + assert.Equal(t, 1.8, must(NewValue(1.8).FloatValue())) + assert.Equal(t, "1.877778", must(NewValue(1.877778).StringValue())) + assert.Equal(t, "1.88", must(NewValue(1.88).StringValue())) + assert.Error(t, errOnly(NewValue(1.8).MapValue())) + assert.Error(t, errOnly(NewValue(1.8).SliceValue())) +} + +func TestStaticString(t *testing.T) { + // Types + assert.Equal(t, `""`, NewValue("").String()) + assert.Equal(t, `"value"`, NewValue("value").String()) + assert.Equal(t, `"v\"alue"`, NewValue("v\"alue").String()) + assert.Equal(t, false, NewValue("").IsBool()) + assert.Equal(t, false, NewValue("value").IsBool()) + assert.Equal(t, false, NewValue("").IsNone()) + assert.Equal(t, false, NewValue("value").IsNone()) + assert.Equal(t, false, NewValue("5").IsInt()) + assert.Equal(t, false, NewValue("value").IsInt()) + assert.Equal(t, false, NewValue("5").IsNumber()) + assert.Equal(t, false, NewValue("value").IsNumber()) + assert.Equal(t, true, NewValue("").IsString()) + assert.Equal(t, true, NewValue("value").IsString()) + assert.Equal(t, false, NewValue("value").IsMap()) + assert.Equal(t, false, NewValue("value").IsSlice()) + + // Conversion + assert.Equal(t, false, must(NewValue("").BoolValue())) + assert.Equal(t, false, must(NewValue("0").BoolValue())) + assert.Equal(t, false, must(NewValue("off").BoolValue())) + assert.Equal(t, false, must(NewValue("false").BoolValue())) + assert.Equal(t, true, must(NewValue("False").BoolValue())) + assert.Equal(t, true, must(NewValue("true").BoolValue())) + assert.Equal(t, true, must(NewValue("on").BoolValue())) + assert.Equal(t, true, must(NewValue("1").BoolValue())) + assert.Equal(t, true, must(NewValue("something").BoolValue())) + assert.Equal(t, int64(1), must(NewValue("1").IntValue())) + assert.Equal(t, int64(1), must(NewValue("1.5").IntValue())) + assert.Error(t, errOnly(NewValue("").IntValue())) + assert.Error(t, errOnly(NewValue("5 apples").IntValue())) + assert.Equal(t, 1.0, must(NewValue("1").FloatValue())) + assert.Equal(t, 1.5, must(NewValue("1.5").FloatValue())) + assert.Error(t, errOnly(NewValue("").FloatValue())) + assert.Error(t, errOnly(NewValue("5 apples").FloatValue())) + assert.Equal(t, "", must(NewValue("").StringValue())) + assert.Equal(t, "value", must(NewValue("value").StringValue())) + assert.Equal(t, `v"alu\e`, must(NewValue(`v"alu\e`).StringValue())) + assert.Error(t, errOnly(NewValue("").MapValue())) + assert.Error(t, errOnly(NewValue("v").MapValue())) + assert.Error(t, errOnly(NewValue("").SliceValue())) + assert.Error(t, errOnly(NewValue("v").SliceValue())) +} + +func TestStaticMap(t *testing.T) { + // Types + assert.Equal(t, "{}", NewValue(map[string]interface{}(nil)).String()) + assert.Equal(t, "{}", NewValue(map[string]string(nil)).String()) + assert.Equal(t, `{"a":"b"}`, NewValue(map[string]string{"a": "b"}).String()) + assert.Equal(t, `{"3":"b"}`, NewValue(map[int]string{3: "b"}).String()) + assert.Equal(t, false, NewValue(map[string]interface{}(nil)).IsBool()) + assert.Equal(t, false, NewValue(map[string]interface{}{}).IsBool()) + assert.Equal(t, false, NewValue(map[string]interface{}{"a": "b"}).IsBool()) + assert.Equal(t, false, NewValue(map[string]interface{}(nil)).IsNone()) + assert.Equal(t, false, NewValue(map[string]interface{}{}).IsNone()) + assert.Equal(t, false, NewValue(map[string]interface{}{"a": "b"}).IsNone()) + assert.Equal(t, false, NewValue(map[int]interface{}{3: "3"}).IsInt()) + assert.Equal(t, false, NewValue(map[int]interface{}{3: "3"}).IsNumber()) + assert.Equal(t, false, NewValue(map[int]interface{}{3: "3"}).IsString()) + assert.Equal(t, true, NewValue(map[string]interface{}(nil)).IsMap()) + assert.Equal(t, true, NewValue(map[string]interface{}{}).IsMap()) + assert.Equal(t, true, NewValue(map[string]interface{}{"a": "b"}).IsMap()) + assert.Equal(t, false, NewValue(map[string]interface{}(nil)).IsSlice()) + assert.Equal(t, false, NewValue(map[string]interface{}{}).IsSlice()) + assert.Equal(t, false, NewValue(map[string]interface{}{"a": "b"}).IsSlice()) + + // Conversion + assert.Equal(t, false, must(NewValue(map[string]string{}).BoolValue())) + assert.Equal(t, false, must(NewValue(map[string]string(nil)).BoolValue())) + assert.Equal(t, true, must(NewValue(map[string]string{"a": "b"}).BoolValue())) + assert.Error(t, errOnly(NewValue(map[string]string(nil)).IntValue())) + assert.Error(t, errOnly(NewValue(map[string]string{}).IntValue())) + assert.Error(t, errOnly(NewValue(map[string]string{"a": "b"}).IntValue())) + assert.Error(t, errOnly(NewValue(map[string]string(nil)).FloatValue())) + assert.Error(t, errOnly(NewValue(map[string]string{}).FloatValue())) + assert.Error(t, errOnly(NewValue(map[string]string{"a": "b"}).FloatValue())) + assert.Equal(t, "{}", must(NewValue(map[string]string(nil)).StringValue())) + assert.Equal(t, "{}", must(NewValue(map[string]string{}).StringValue())) + assert.Equal(t, `{"a":"b"}`, must(NewValue(map[string]string{"a": "b"}).StringValue())) + assert.Equal(t, map[string]interface{}{}, must(NewValue(map[string]string(nil)).MapValue())) + assert.Equal(t, map[string]interface{}{}, must(NewValue(map[string]string{}).MapValue())) + assert.Equal(t, map[string]interface{}{"a": "b"}, must(NewValue(map[string]string{"a": "b"}).MapValue())) + assert.Error(t, errOnly(NewValue(map[string]string(nil)).SliceValue())) + assert.Error(t, errOnly(NewValue(map[int]string{}).SliceValue())) + assert.Error(t, errOnly(NewValue(map[int]string{3: "a"}).SliceValue())) +} + +func TestStaticSlice(t *testing.T) { + // Types + assert.Equal(t, "[]", NewValue([]interface{}(nil)).String()) + assert.Equal(t, "[]", NewValue([]string(nil)).String()) + assert.Equal(t, `["a","b"]`, NewValue([]string{"a", "b"}).String()) + assert.Equal(t, `[3]`, NewValue([]int{3}).String()) + assert.Equal(t, false, NewValue([]interface{}(nil)).IsBool()) + assert.Equal(t, false, NewValue([]interface{}{}).IsBool()) + assert.Equal(t, false, NewValue([]interface{}{"a", "b"}).IsBool()) + assert.Equal(t, false, NewValue([]interface{}(nil)).IsNone()) + assert.Equal(t, false, NewValue([]interface{}{}).IsNone()) + assert.Equal(t, false, NewValue([]interface{}{"a", "b"}).IsNone()) + assert.Equal(t, false, NewValue([]interface{}{3: "3"}).IsInt()) + assert.Equal(t, false, NewValue([]interface{}{3: "3"}).IsNumber()) + assert.Equal(t, false, NewValue([]interface{}{3: "3"}).IsString()) + assert.Equal(t, false, NewValue([]interface{}(nil)).IsMap()) + assert.Equal(t, false, NewValue([]interface{}{}).IsMap()) + assert.Equal(t, false, NewValue([]interface{}{"a", "b"}).IsMap()) + assert.Equal(t, true, NewValue([]interface{}(nil)).IsSlice()) + assert.Equal(t, true, NewValue([]interface{}{}).IsSlice()) + assert.Equal(t, true, NewValue([]interface{}{"a", "b"}).IsSlice()) + + // Conversion + assert.Equal(t, false, must(NewValue([]string{}).BoolValue())) + assert.Equal(t, false, must(NewValue([]string(nil)).BoolValue())) + assert.Equal(t, true, must(NewValue([]string{"a", "b"}).BoolValue())) + assert.Error(t, errOnly(NewValue([]string(nil)).IntValue())) + assert.Error(t, errOnly(NewValue([]string{}).IntValue())) + assert.Error(t, errOnly(NewValue([]string{"a", "b"}).IntValue())) + assert.Error(t, errOnly(NewValue([]string(nil)).FloatValue())) + assert.Error(t, errOnly(NewValue([]string{}).FloatValue())) + assert.Error(t, errOnly(NewValue([]string{"a", "b"}).FloatValue())) + assert.Equal(t, "", must(NewValue([]string(nil)).StringValue())) + assert.Equal(t, "", must(NewValue([]string{}).StringValue())) + assert.Equal(t, `a,b`, must(NewValue([]string{"a", "b"}).StringValue())) + assert.Equal(t, map[string]interface{}{}, must(NewValue([]string(nil)).MapValue())) + assert.Equal(t, map[string]interface{}{}, must(NewValue([]string{}).MapValue())) + assert.Equal(t, map[string]interface{}{"0": "a", "1": "b"}, must(NewValue([]string{"a", "b"}).MapValue())) + assert.Equal(t, []interface{}{}, must(NewValue([]string(nil)).SliceValue())) + assert.Equal(t, []interface{}{}, must(NewValue([]string{}).SliceValue())) + assert.Equal(t, slice("a"), must(NewValue([]string{"a"}).SliceValue())) +} + +func TestStaticNone(t *testing.T) { + // Types + assert.Equal(t, "null", None.String()) + assert.Equal(t, false, None.IsBool()) + assert.Equal(t, true, None.IsNone()) + assert.Equal(t, false, None.IsInt()) + assert.Equal(t, false, None.IsNumber()) + assert.Equal(t, false, None.IsString()) + assert.Equal(t, false, None.IsMap()) + assert.Equal(t, false, None.IsSlice()) + + // Conversion + assert.Equal(t, false, must(None.BoolValue())) + assert.Equal(t, int64(0), must(None.IntValue())) + assert.Equal(t, 0.0, must(None.FloatValue())) + assert.Equal(t, "", must(None.StringValue())) + assert.Equal(t, map[string]interface{}(nil), must(None.MapValue())) + assert.Equal(t, []interface{}(nil), must(None.SliceValue())) +} diff --git a/pkg/tcl/expressionstcl/stdlib.go b/pkg/tcl/expressionstcl/stdlib.go new file mode 100644 index 00000000000..37f91bb7a44 --- /dev/null +++ b/pkg/tcl/expressionstcl/stdlib.go @@ -0,0 +1,232 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kballard/go-shellquote" + "gopkg.in/yaml.v3" +) + +type StdFunction struct { + ReturnType Type + Handler func(...StaticValue) (Expression, error) +} + +type stdMachine struct{} + +var StdLibMachine = &stdMachine{} + +var stdFunctions = map[string]StdFunction{ + "string": { + ReturnType: TypeString, + Handler: func(value ...StaticValue) (Expression, error) { + str := "" + for i := range value { + next, _ := value[i].StringValue() + str += next + } + return NewValue(str), nil + }, + }, + "list": { + Handler: func(value ...StaticValue) (Expression, error) { + v := make([]interface{}, len(value)) + for i := range value { + v[i] = value[i].Value() + } + return NewValue(v), nil + }, + }, + "join": { + ReturnType: TypeString, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) == 0 || len(value) > 2 { + return nil, fmt.Errorf(`"join" function expects 1-2 arguments, %d provided`, len(value)) + } + if value[0].IsNone() { + return value[0], nil + } + if !value[0].IsSlice() { + return nil, fmt.Errorf(`"join" function expects a slice as 1st argument: %v provided`, value[0].Value()) + } + slice, err := value[0].SliceValue() + if err != nil { + return nil, fmt.Errorf(`"join" function error: reading slice: %s`, err.Error()) + } + v := make([]string, len(slice)) + for i := range slice { + v[i], _ = toString(slice[i]) + } + separator := "," + if len(value) == 2 { + separator, _ = value[1].StringValue() + } + return NewValue(strings.Join(v, separator)), nil + }, + }, + "split": { + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) == 0 || len(value) > 2 { + return nil, fmt.Errorf(`"split" function expects 1-2 arguments, %d provided`, len(value)) + } + str, _ := value[0].StringValue() + separator := "," + if len(value) == 2 { + separator, _ = value[1].StringValue() + } + return NewValue(strings.Split(str, separator)), nil + }, + }, + "int": { + ReturnType: TypeInt64, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"int" function expects 1 argument, %d provided`, len(value)) + } + v, err := value[0].IntValue() + if err != nil { + return nil, err + } + return NewValue(v), nil + }, + }, + "bool": { + ReturnType: TypeBool, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"bool" function expects 1 argument, %d provided`, len(value)) + } + v, err := value[0].BoolValue() + if err != nil { + return nil, err + } + return NewValue(v), nil + }, + }, + "float": { + ReturnType: TypeFloat64, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"float" function expects 1 argument, %d provided`, len(value)) + } + v, err := value[0].FloatValue() + if err != nil { + return nil, err + } + return NewValue(v), nil + }, + }, + "tojson": { + ReturnType: TypeString, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"tojson" function expects 1 argument, %d provided`, len(value)) + } + b, err := json.Marshal(value[0].Value()) + if err != nil { + return nil, fmt.Errorf(`"tojson" function had problem marshalling: %s`, err.Error()) + } + return NewValue(string(b)), nil + }, + }, + "json": { + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"json" function expects 1 argument, %d provided`, len(value)) + } + if !value[0].IsString() { + return nil, fmt.Errorf(`"json" function argument should be a string`) + } + var v interface{} + err := json.Unmarshal([]byte(value[0].Value().(string)), &v) + if err != nil { + return nil, fmt.Errorf(`"json" function had problem unmarshalling: %s`, err.Error()) + } + return NewValue(v), nil + }, + }, + "toyaml": { + ReturnType: TypeString, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"toyaml" function expects 1 argument, %d provided`, len(value)) + } + b, err := yaml.Marshal(value[0].Value()) + if err != nil { + return nil, fmt.Errorf(`"toyaml" function had problem marshalling: %s`, err.Error()) + } + return NewValue(string(b)), nil + }, + }, + "yaml": { + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"yaml" function expects 1 argument, %d provided`, len(value)) + } + if !value[0].IsString() { + return nil, fmt.Errorf(`"yaml" function argument should be a string`) + } + var v interface{} + err := yaml.Unmarshal([]byte(value[0].Value().(string)), &v) + if err != nil { + return nil, fmt.Errorf(`"yaml" function had problem unmarshalling: %s`, err.Error()) + } + return NewValue(v), nil + }, + }, + "shellquote": { + ReturnType: TypeString, + Handler: func(value ...StaticValue) (Expression, error) { + args := make([]string, len(value)) + for i := range value { + args[i], _ = value[i].StringValue() + } + return NewValue(shellquote.Join(args...)), nil + }, + }, + "trim": { + ReturnType: TypeString, + Handler: func(value ...StaticValue) (Expression, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`"trim" function expects 1 argument, %d provided`, len(value)) + } + if !value[0].IsString() { + return nil, fmt.Errorf(`"trim" function argument should be a string`) + } + str, _ := value[0].StringValue() + return NewValue(strings.TrimSpace(str)), nil + }, + }, +} + +func IsStdFunction(name string) bool { + _, ok := stdFunctions[name] + return ok +} + +func GetStdFunctionReturnType(name string) Type { + return stdFunctions[name].ReturnType +} + +func (*stdMachine) Get(name string) (Expression, bool, error) { + return nil, false, nil +} + +func (*stdMachine) Call(name string, args ...StaticValue) (Expression, bool, error) { + fn, ok := stdFunctions[name] + if ok { + exp, err := fn.Handler(args...) + return exp, true, err + } + return nil, false, nil +} diff --git a/pkg/tcl/expressionstcl/tokenize.go b/pkg/tcl/expressionstcl/tokenize.go new file mode 100644 index 00000000000..ae2e2b2c4f1 --- /dev/null +++ b/pkg/tcl/expressionstcl/tokenize.go @@ -0,0 +1,107 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "regexp" +) + +var mathOperatorRe = regexp.MustCompile(`^(?:!=|<>|==|>=|<=|&&|\*\*|\|\||[+\-*/><=%])`) +var noneRe = regexp.MustCompile(`^null(?:[^a-zA-Z\d_.]|$)`) +var jsonValueRe = regexp.MustCompile(`^(?:["{\[\d]|((?:true|false)(?:[^a-zA-Z\d_.]|$)))`) +var accessorRe = regexp.MustCompile(`^[a-zA-Z\d_](?:[a-zA-Z\d_.]*[a-zA-Z\d_])?`) +var spaceRe = regexp.MustCompile(`^\s+`) + +func tokenizeNext(exp string, i int) (token, int, error) { + for i < len(exp) { + switch true { + case exp[i] == ',': + return tokenComma, i + 1, nil + case exp[i] == '(': + return tokenOpen, i + 1, nil + case exp[i] == ')': + return tokenClose, i + 1, nil + case exp[i] == ':': + return tokenTernarySeparator, i + 1, nil + case mathOperatorRe.MatchString(exp[i:]): + op := mathOperatorRe.FindString(exp[i:]) + return tokenMath(op), i + len(op), nil + case exp[i] == '?': + return tokenTernary, i + 1, nil + case exp[i] == '!': + return tokenNot, i + 1, nil + case spaceRe.MatchString(exp[i:]): + space := spaceRe.FindString(exp[i:]) + i += len(space) + case noneRe.MatchString(exp[i:]): + return tokenJson(noneValue), i + 4, nil + case jsonValueRe.MatchString(exp[i:]): + // Allow multi-line string with literal \n + // TODO: Optimize, and allow deeper in the tree + appended := 0 + if exp[i] == '"' { + inside := true + for index := i + 1; inside && index < len(exp); index++ { + if exp[index] == '\\' { + index++ + } else if exp[index] == '"' { + inside = false + } else if exp[index] == '\n' { + exp = exp[0:index] + "\\n" + exp[index+1:] + appended++ + } else if exp[index] == '\t' { + exp = exp[0:index] + "\\t" + exp[index+1:] + appended++ + } + } + } + decoder := json.NewDecoder(bytes.NewBuffer([]byte(exp[i:]))) + var val interface{} + err := decoder.Decode(&val) + if err != nil { + return token{}, i, fmt.Errorf("error while decoding JSON from index %d in expression: %s: %s", i, exp, err.Error()) + } + return tokenJson(val), i + int(decoder.InputOffset()) - appended, nil + case accessorRe.MatchString(exp[i:]): + acc := accessorRe.FindString(exp[i:]) + return tokenAccessor(acc), i + len(acc), nil + default: + return token{}, i, fmt.Errorf("unknown character at index %d in expression: %s", i, exp) + } + } + return token{}, 0, io.EOF +} + +func tokenize(exp string, index int) (tokens []token, i int, err error) { + tokens = make([]token, 0) + var t token + for i = index; i < len(exp); { + t, i, err = tokenizeNext(exp, i) + if err != nil { + if err == io.EOF { + return tokens, i, nil + } + return tokens, i, err + } + tokens = append(tokens, t) + } + return +} + +func mustTokenize(exp string) []token { + tokens, _, err := tokenize(exp, 0) + if err != nil { + panic(err) + } + return tokens +} diff --git a/pkg/tcl/expressionstcl/tokenize_test.go b/pkg/tcl/expressionstcl/tokenize_test.go new file mode 100644 index 00000000000..638c8c64784 --- /dev/null +++ b/pkg/tcl/expressionstcl/tokenize_test.go @@ -0,0 +1,74 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func slice(v ...interface{}) []interface{} { + return v +} + +func TestTokenizeSimple(t *testing.T) { + operators := []string{"&&", "||", "!=", "<>", "==", "=", "+", "-", "*", ">", "<", "<=", ">=", "%", "**"} + for _, op := range operators { + assert.Equal(t, []token{tokenAccessor("a"), tokenMath(op), tokenAccessor("b")}, mustTokenize("a"+op+"b")) + } + assert.Equal(t, []token{tokenNot, tokenAccessor("abc")}, mustTokenize(`!abc`)) + assert.Equal(t, []token{tokenAccessor("a"), tokenTernary, tokenAccessor("b"), tokenTernarySeparator, tokenAccessor("c")}, mustTokenize(`a ? b : c`)) + assert.Equal(t, []token{tokenOpen, tokenAccessor("a"), tokenClose}, mustTokenize(`(a)`)) + assert.Equal(t, []token{tokenAccessor("a"), tokenOpen, tokenAccessor("b"), tokenComma, tokenJson(true), tokenClose}, mustTokenize(`a(b, true)`)) + assert.Equal(t, []token{tokenJson(noneValue)}, mustTokenize("null")) + assert.Equal(t, []token{tokenJson(noneValue), tokenMath("+"), tokenJson(4.0)}, mustTokenize("null + 4")) +} + +func TestTokenizeJson(t *testing.T) { + assert.Equal(t, []token{tokenJson(1.0), tokenMath("+"), tokenJson(255.0)}, mustTokenize(`1 + 255`)) + assert.Equal(t, []token{tokenJson(1.6), tokenMath("+"), tokenJson(255.0)}, mustTokenize(`1.6 + 255`)) + assert.Equal(t, []token{tokenJson("abc"), tokenMath("+"), tokenJson("d")}, mustTokenize(`"abc" + "d"`)) + assert.Equal(t, []token{tokenJson(map[string]interface{}{"key1": "value1", "key2": "value2"})}, mustTokenize(`{"key1": "value1", "key2": "value2"}`)) + assert.Equal(t, []token{tokenJson(slice("a", "b"))}, mustTokenize(`["a", "b"]`)) + assert.Equal(t, []token{tokenJson(true)}, mustTokenize(`true`)) + assert.Equal(t, []token{tokenJson(false)}, mustTokenize(`false`)) +} + +func TestTokenizeComplex(t *testing.T) { + want := []token{ + tokenAccessor("env.value"), tokenMath("&&"), tokenOpen, tokenAccessor("env.alternative"), tokenMath("+"), tokenJson("cd"), tokenClose, tokenMath("=="), tokenJson("abc"), + tokenTernary, tokenJson(10.5), + tokenTernarySeparator, tokenNot, tokenAccessor("ignored"), tokenTernary, tokenOpen, tokenJson(14.0), tokenMath("+"), tokenJson(3.1), tokenMath("*"), tokenJson(5.0), tokenClose, + tokenTernarySeparator, tokenAccessor("transform"), tokenOpen, tokenJson("a"), tokenComma, + tokenJson(map[string]interface{}{"x": "y"}), tokenComma, tokenJson(slice("z")), tokenClose, + } + assert.Equal(t, want, mustTokenize(` + env.value && (env.alternative + "cd") == "abc" + ? 10.5 + : !ignored ? (14 + 3.1 * 5) + : transform("a", + {"x": "y"}, ["z"]) + `)) +} + +func TestTokenizeInvalidAccessor(t *testing.T) { + tokens, _, err := tokenize(`abc.`, 0) + assert.Error(t, err) + assert.Equal(t, []token{tokenAccessor("abc")}, tokens) +} + +func TestTokenizeInvalidJson(t *testing.T) { + tokens, _, err := tokenize(`{"abc": "d"`, 0) + tokens2, _, err2 := tokenize(`{"abc": d}`, 0) + assert.Error(t, err) + assert.Equal(t, []token{}, tokens) + assert.Error(t, err2) + assert.Equal(t, []token{}, tokens2) +} diff --git a/pkg/tcl/expressionstcl/tokens.go b/pkg/tcl/expressionstcl/tokens.go new file mode 100644 index 00000000000..82b670574b9 --- /dev/null +++ b/pkg/tcl/expressionstcl/tokens.go @@ -0,0 +1,56 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +type tokenType uint8 + +const ( + // Primitives + tokenTypeAccessor tokenType = iota + tokenTypeJson + + // Math + tokenTypeNot + tokenTypeMath + tokenTypeOpen + tokenTypeClose + + // Logical + tokenTypeTernary + tokenTypeTernarySeparator + + // Functions + tokenTypeComma +) + +type token struct { + Type tokenType + Value interface{} +} + +var ( + tokenNot = token{Type: tokenTypeNot} + tokenOpen = token{Type: tokenTypeOpen} + tokenClose = token{Type: tokenTypeClose} + tokenTernary = token{Type: tokenTypeTernary} + tokenTernarySeparator = token{Type: tokenTypeTernarySeparator} + tokenComma = token{Type: tokenTypeComma} +) + +func tokenMath(op string) token { + return token{Type: tokenTypeMath, Value: op} +} + +func tokenJson(value interface{}) token { + return token{Type: tokenTypeJson, Value: value} +} + +func tokenAccessor(value interface{}) token { + return token{Type: tokenTypeAccessor, Value: value} +} diff --git a/pkg/tcl/expressionstcl/typechecking.go b/pkg/tcl/expressionstcl/typechecking.go new file mode 100644 index 00000000000..9e05b329454 --- /dev/null +++ b/pkg/tcl/expressionstcl/typechecking.go @@ -0,0 +1,58 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import "reflect" + +type noneType struct{} + +var noneValue noneType + +func isInt(s interface{}) bool { + switch s.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case float32: + return s.(float32) == float32(int32(s.(float32))) + case float64: + return s.(float64) == float64(int64(s.(float64))) + } + return false +} + +func isString(s interface{}) bool { + _, ok := s.(string) + return ok +} + +func isBool(s interface{}) bool { + _, ok := s.(bool) + return ok +} + +func isNone(s interface{}) bool { + _, ok := s.(noneType) + return ok +} + +func isNumber(s interface{}) bool { + switch s.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return true + } + return false +} + +func isMap(s interface{}) bool { + return reflect.ValueOf(s).Kind() == reflect.Map +} + +func isSlice(s interface{}) bool { + return reflect.ValueOf(s).Kind() == reflect.Slice +} diff --git a/pkg/tcl/expressionstcl/utils.go b/pkg/tcl/expressionstcl/utils.go new file mode 100644 index 00000000000..5c31a8864e2 --- /dev/null +++ b/pkg/tcl/expressionstcl/utils.go @@ -0,0 +1,28 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" +) + +const maxCallStack = 10_000 + +func deepResolve(expr Expression, machines ...MachineCore) (Expression, error) { + i := 1 + expr, changed, err := expr.SafeResolve(machines...) + for changed && err == nil && expr.Static() == nil { + if i > maxCallStack { + return expr, fmt.Errorf("maximum call stack exceeded while resolving expression: %s", expr.String()) + } + expr, changed, err = expr.SafeResolve(machines...) + i++ + } + return expr, err +} From 1d460a72b5488d5ac61d04f72658be2adb81345d Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 26 Feb 2024 16:26:39 +0300 Subject: [PATCH 128/234] fix: change namespace usage in secret and config map clients --- cmd/api-server/main.go | 3 +-- pkg/configmap/client.go | 11 ++++++++--- pkg/configmap/mock_client.go | 13 +++++++++---- pkg/event/kind/cdevent/listener.go | 7 ++++++- pkg/reconciler/reconciler.go | 8 +++----- pkg/scheduler/test_scheduler.go | 4 ++-- pkg/secret/client.go | 11 ++++++++--- pkg/secret/mock_client.go | 13 +++++++++---- 8 files changed, 46 insertions(+), 24 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 50caa67fa30..09b85876c13 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -609,8 +609,7 @@ func main() { resultsRepository, testResultsRepository, executorsClient, - log.DefaultLogger, - cfg.TestkubeNamespace) + log.DefaultLogger) g.Go(func() error { return reconcilerClient.Run(ctx) }) diff --git a/pkg/configmap/client.go b/pkg/configmap/client.go index c14ecd9c78c..68a432d7db5 100644 --- a/pkg/configmap/client.go +++ b/pkg/configmap/client.go @@ -15,7 +15,7 @@ import ( //go:generate mockgen -destination=./mock_client.go -package=configmap "github.com/kubeshop/testkube/pkg/configmap" Interface type Interface interface { - Get(ctx context.Context, id string) (map[string]string, error) + Get(ctx context.Context, id string, namespace ...string) (map[string]string, error) Create(ctx context.Context, id string, stringData map[string]string) error Apply(ctx context.Context, id string, stringData map[string]string) error Update(ctx context.Context, id string, stringData map[string]string) error @@ -55,8 +55,13 @@ func (c *Client) Create(ctx context.Context, id string, stringData map[string]st } // Get is a method to retrieve an existing configmap -func (c *Client) Get(ctx context.Context, id string) (map[string]string, error) { - configMapsClient := c.ClientSet.CoreV1().ConfigMaps(c.Namespace) +func (c *Client) Get(ctx context.Context, id string, namespace ...string) (map[string]string, error) { + ns := c.Namespace + if len(namespace) != 0 { + ns = namespace[0] + } + + configMapsClient := c.ClientSet.CoreV1().ConfigMaps(ns) configMapSpec, err := configMapsClient.Get(ctx, id, metav1.GetOptions{}) if err != nil { diff --git a/pkg/configmap/mock_client.go b/pkg/configmap/mock_client.go index b97d70bb80d..69c69266959 100644 --- a/pkg/configmap/mock_client.go +++ b/pkg/configmap/mock_client.go @@ -63,18 +63,23 @@ func (mr *MockInterfaceMockRecorder) Create(arg0, arg1, arg2 interface{}) *gomoc } // Get mocks base method. -func (m *MockInterface) Get(arg0 context.Context, arg1 string) (map[string]string, error) { +func (m *MockInterface) Get(arg0 context.Context, arg1 string, arg2 ...string) (map[string]string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1) + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(map[string]string) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockInterfaceMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockInterfaceMockRecorder) Get(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInterface)(nil).Get), arg0, arg1) + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInterface)(nil).Get), varargs...) } // Update mocks base method. diff --git a/pkg/event/kind/cdevent/listener.go b/pkg/event/kind/cdevent/listener.go index ec82d80df5d..e4493887024 100644 --- a/pkg/event/kind/cdevent/listener.go +++ b/pkg/event/kind/cdevent/listener.go @@ -62,7 +62,12 @@ func (l *CDEventListener) Metadata() map[string]string { func (l *CDEventListener) Notify(event testkube.Event) (result testkube.EventResult) { // Create the base event - ev, err := cde.MapTestkubeEventToCDEvent(event, l.clusterID, l.defaultNamespace, l.dashboardURI) + namespace := l.defaultNamespace + if event.TestExecution != nil { + namespace = event.TestExecution.TestNamespace + } + + ev, err := cde.MapTestkubeEventToCDEvent(event, l.clusterID, namespace, l.dashboardURI) if err != nil { return testkube.NewFailedEventResult(event.Id, err) } diff --git a/pkg/reconciler/reconciler.go b/pkg/reconciler/reconciler.go index 6766014f789..2881e947950 100644 --- a/pkg/reconciler/reconciler.go +++ b/pkg/reconciler/reconciler.go @@ -34,18 +34,16 @@ type Client struct { testResultRepository testresult.Repository executorsClient *executorsclientv1.ExecutorsClient logger *zap.SugaredLogger - namespace string } func NewClient(k8sclient kubernetes.Interface, resultRepository result.Repository, testResultRepository testresult.Repository, - executorsClient *executorsclientv1.ExecutorsClient, logger *zap.SugaredLogger, namespace string) *Client { + executorsClient *executorsclientv1.ExecutorsClient, logger *zap.SugaredLogger) *Client { return &Client{ k8sclient: k8sclient, resultRepository: resultRepository, testResultRepository: testResultRepository, executorsClient: executorsClient, logger: logger, - namespace: namespace, } } @@ -95,7 +93,7 @@ OuterLoop: errMessage := errTestAbnoramallyTerminated.Error() id := execution.Id - pods, err := executor.GetJobPods(ctx, client.k8sclient.CoreV1().Pods(client.namespace), id, 1, 10) + pods, err := executor.GetJobPods(ctx, client.k8sclient.CoreV1().Pods(execution.TestNamespace), id, 1, 10) if err == nil { ExecutorLoop: for _, pod := range pods.Items { @@ -120,7 +118,7 @@ OuterLoop: if supportArtifacts && execution.ArtifactRequest != nil && execution.ArtifactRequest.StorageClassName != "" { id = execution.Id + "-scraper" - pods, err = executor.GetJobPods(ctx, client.k8sclient.CoreV1().Pods(client.namespace), id, 1, 10) + pods, err = executor.GetJobPods(ctx, client.k8sclient.CoreV1().Pods(execution.TestNamespace), id, 1, 10) if err == nil { ScraperLoop: for _, pod := range pods.Items { diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index c2b03ed894f..9bdb561984b 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -466,7 +466,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe continue } - data, err := s.configMapClient.Get(context.Background(), configMap.Reference.Name) + data, err := s.configMapClient.Get(context.Background(), configMap.Reference.Name, namespace) if err != nil { return options, errors.Errorf("can't get config map: %v", err) } @@ -486,7 +486,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe continue } - data, err := s.secretClient.Get(secret.Reference.Name) + data, err := s.secretClient.Get(secret.Reference.Name, namespace) if err != nil { return options, errors.Errorf("can't get secret: %v", err) } diff --git a/pkg/secret/client.go b/pkg/secret/client.go index 43948c1c1ad..866a60a7896 100644 --- a/pkg/secret/client.go +++ b/pkg/secret/client.go @@ -18,7 +18,7 @@ const testkubeTestSecretLabel = "tests-secrets" //go:generate mockgen -destination=./mock_client.go -package=secret "github.com/kubeshop/testkube/pkg/secret" Interface type Interface interface { - Get(id string) (map[string]string, error) + Get(id string, namespace ...string) (map[string]string, error) GetObject(id string) (*v1.Secret, error) List(all bool) (map[string]map[string]string, error) Create(id string, labels, stringData map[string]string) error @@ -50,8 +50,13 @@ func NewClient(namespace string) (*Client, error) { } // Get is a method to retrieve an existing secret -func (c *Client) Get(id string) (map[string]string, error) { - secretsClient := c.ClientSet.CoreV1().Secrets(c.Namespace) +func (c *Client) Get(id string, namespace ...string) (map[string]string, error) { + ns := c.Namespace + if len(namespace) != 0 { + ns = namespace[0] + } + + secretsClient := c.ClientSet.CoreV1().Secrets(ns) ctx := context.Background() secretSpec, err := secretsClient.Get(ctx, id, metav1.GetOptions{}) diff --git a/pkg/secret/mock_client.go b/pkg/secret/mock_client.go index 41559e44496..6d578613785 100644 --- a/pkg/secret/mock_client.go +++ b/pkg/secret/mock_client.go @@ -91,18 +91,23 @@ func (mr *MockInterfaceMockRecorder) DeleteAll(arg0 interface{}) *gomock.Call { } // Get mocks base method. -func (m *MockInterface) Get(arg0 string) (map[string]string, error) { +func (m *MockInterface) Get(arg0 string, arg1 ...string) (map[string]string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0) + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) ret0, _ := ret[0].(map[string]string) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockInterfaceMockRecorder) Get(arg0 interface{}) *gomock.Call { +func (mr *MockInterfaceMockRecorder) Get(arg0 interface{}, arg1 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInterface)(nil).Get), arg0) + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockInterface)(nil).Get), varargs...) } // GetObject mocks base method. From b6860000b5573628d79d2e2300e58d884876513e Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 26 Feb 2024 16:41:22 +0300 Subject: [PATCH 129/234] fix: unit test --- pkg/executor/containerexecutor/containerexecutor_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 6a03af091ae..94eac444f2e 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -66,7 +66,7 @@ func TestExecuteSync(t *testing.T) { executorsClient: FakeExecutorsClient{}, } - execution := &testkube.Execution{Id: "1"} + execution := &testkube.Execution{Id: "1", TestNamespace: "default"} options := client.ExecuteOptions{ ImagePullSecretNames: []string{"secret-name1"}, Sync: true, From 1006618750e33c524838cdadd963cd89d1920fbb Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 26 Feb 2024 16:48:24 +0300 Subject: [PATCH 130/234] docs: cli commands --- docs/docs/cli/testkube_create.md | 2 + docs/docs/cli/testkube_create_test.md | 1 + docs/docs/cli/testkube_create_testworkflow.md | 33 +++++++++++++++++ .../testkube_create_testworkflowtemplate.md | 33 +++++++++++++++++ docs/docs/cli/testkube_delete.md | 2 + docs/docs/cli/testkube_delete_testworkflow.md | 31 ++++++++++++++++ .../testkube_delete_testworkflowtemplate.md | 31 ++++++++++++++++ docs/docs/cli/testkube_generate_tests-crds.md | 1 + docs/docs/cli/testkube_get.md | 2 + docs/docs/cli/testkube_get_testworkflow.md | 37 +++++++++++++++++++ .../cli/testkube_get_testworkflowtemplate.md | 37 +++++++++++++++++++ docs/docs/cli/testkube_run_test.md | 1 + docs/docs/cli/testkube_update_test.md | 1 + pkg/scheduler/test_scheduler_test.go | 2 +- 14 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 docs/docs/cli/testkube_create_testworkflow.md create mode 100644 docs/docs/cli/testkube_create_testworkflowtemplate.md create mode 100644 docs/docs/cli/testkube_delete_testworkflow.md create mode 100644 docs/docs/cli/testkube_delete_testworkflowtemplate.md create mode 100644 docs/docs/cli/testkube_get_testworkflow.md create mode 100644 docs/docs/cli/testkube_get_testworkflowtemplate.md diff --git a/docs/docs/cli/testkube_create.md b/docs/docs/cli/testkube_create.md index 8ff3f3ddcfe..26dd58c808d 100644 --- a/docs/docs/cli/testkube_create.md +++ b/docs/docs/cli/testkube_create.md @@ -32,5 +32,7 @@ testkube create [flags] * [testkube create test](testkube_create_test.md) - Create new Test * [testkube create testsource](testkube_create_testsource.md) - Create new TestSource * [testkube create testsuite](testkube_create_testsuite.md) - Create new TestSuite +* [testkube create testworkflow](testkube_create_testworkflow.md) - Create test workflow +* [testkube create testworkflowtemplate](testkube_create_testworkflowtemplate.md) - Create test workflow template * [testkube create webhook](testkube_create_webhook.md) - Create new Webhook diff --git a/docs/docs/cli/testkube_create_test.md b/docs/docs/cli/testkube_create_test.md index bfa7019fbdb..036cdabe48a 100644 --- a/docs/docs/cli/testkube_create_test.md +++ b/docs/docs/cli/testkube_create_test.md @@ -29,6 +29,7 @@ testkube create test [flags] --env stringToString envs in a form of name1=val1 passed to executor (default []) --execute-postrun-script-before-scraping whether to execute postrun scipt before scraping or not (prebuilt executor only) --execution-name string execution name, if empty will be autogenerated + --execution-namespace string namespace for test execution (Pro edition only) --executor-args stringArray executor binary additional arguments -f, --file string test file - will be read from stdin if not specified --git-auth-type string auth type for git requests one of basic|header (default "basic") diff --git a/docs/docs/cli/testkube_create_testworkflow.md b/docs/docs/cli/testkube_create_testworkflow.md new file mode 100644 index 00000000000..bdf9a07fae9 --- /dev/null +++ b/docs/docs/cli/testkube_create_testworkflow.md @@ -0,0 +1,33 @@ +## testkube create testworkflow + +Create test workflow + +``` +testkube create testworkflow [flags] +``` + +### Options + +``` + -f, --file string file path to get the test workflow specification + -h, --help help for testworkflow + --name string test workflow name + --update update, if test workflow already exists +``` + +### Options inherited from parent commands + +``` + -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -c, --client string client used for connecting to Testkube API one of proxy|direct (default "proxy") + --crd-only generate only crd + --insecure insecure connection for direct client + --namespace string Kubernetes namespace, default value read from config if set (default "testkube") + --oauth-enabled enable oauth + --verbose show additional debug messages +``` + +### SEE ALSO + +* [testkube create](testkube_create.md) - Create resource + diff --git a/docs/docs/cli/testkube_create_testworkflowtemplate.md b/docs/docs/cli/testkube_create_testworkflowtemplate.md new file mode 100644 index 00000000000..ffd31e3f39d --- /dev/null +++ b/docs/docs/cli/testkube_create_testworkflowtemplate.md @@ -0,0 +1,33 @@ +## testkube create testworkflowtemplate + +Create test workflow template + +``` +testkube create testworkflowtemplate [flags] +``` + +### Options + +``` + -f, --file string file path to get the test workflow template specification + -h, --help help for testworkflowtemplate + --name string test workflow template name + --update update, if test workflow template already exists +``` + +### Options inherited from parent commands + +``` + -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -c, --client string client used for connecting to Testkube API one of proxy|direct (default "proxy") + --crd-only generate only crd + --insecure insecure connection for direct client + --namespace string Kubernetes namespace, default value read from config if set (default "testkube") + --oauth-enabled enable oauth + --verbose show additional debug messages +``` + +### SEE ALSO + +* [testkube create](testkube_create.md) - Create resource + diff --git a/docs/docs/cli/testkube_delete.md b/docs/docs/cli/testkube_delete.md index 7cdc4c177fb..c4d9bd7cff3 100644 --- a/docs/docs/cli/testkube_delete.md +++ b/docs/docs/cli/testkube_delete.md @@ -31,5 +31,7 @@ testkube delete [flags] * [testkube delete test](testkube_delete_test.md) - Delete Test * [testkube delete testsource](testkube_delete_testsource.md) - Delete test source * [testkube delete testsuite](testkube_delete_testsuite.md) - Delete test suite +* [testkube delete testworkflow](testkube_delete_testworkflow.md) - Delete test workflows +* [testkube delete testworkflowtemplate](testkube_delete_testworkflowtemplate.md) - Delete test workflow templates * [testkube delete webhook](testkube_delete_webhook.md) - Delete webhook diff --git a/docs/docs/cli/testkube_delete_testworkflow.md b/docs/docs/cli/testkube_delete_testworkflow.md new file mode 100644 index 00000000000..250eb3fc9d2 --- /dev/null +++ b/docs/docs/cli/testkube_delete_testworkflow.md @@ -0,0 +1,31 @@ +## testkube delete testworkflow + +Delete test workflows + +``` +testkube delete testworkflow [name] [flags] +``` + +### Options + +``` + --all Delete all test workflows + -h, --help help for testworkflow + -l, --label strings label key value pair: --label key1=value1 +``` + +### Options inherited from parent commands + +``` + -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -c, --client string Client used for connecting to testkube API one of proxy|direct (default "proxy") + --insecure insecure connection for direct client + --namespace string Kubernetes namespace, default value read from config if set (default "testkube") + --oauth-enabled enable oauth + --verbose should I show additional debug messages +``` + +### SEE ALSO + +* [testkube delete](testkube_delete.md) - Delete resources + diff --git a/docs/docs/cli/testkube_delete_testworkflowtemplate.md b/docs/docs/cli/testkube_delete_testworkflowtemplate.md new file mode 100644 index 00000000000..4e5751a36d2 --- /dev/null +++ b/docs/docs/cli/testkube_delete_testworkflowtemplate.md @@ -0,0 +1,31 @@ +## testkube delete testworkflowtemplate + +Delete test workflow templates + +``` +testkube delete testworkflowtemplate [name] [flags] +``` + +### Options + +``` + --all Delete all test workflow templates + -h, --help help for testworkflowtemplate + -l, --label strings label key value pair: --label key1=value1 +``` + +### Options inherited from parent commands + +``` + -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -c, --client string Client used for connecting to testkube API one of proxy|direct (default "proxy") + --insecure insecure connection for direct client + --namespace string Kubernetes namespace, default value read from config if set (default "testkube") + --oauth-enabled enable oauth + --verbose should I show additional debug messages +``` + +### SEE ALSO + +* [testkube delete](testkube_delete.md) - Delete resources + diff --git a/docs/docs/cli/testkube_generate_tests-crds.md b/docs/docs/cli/testkube_generate_tests-crds.md index 66b4a4c1836..6e5fc384204 100644 --- a/docs/docs/cli/testkube_generate_tests-crds.md +++ b/docs/docs/cli/testkube_generate_tests-crds.md @@ -29,6 +29,7 @@ testkube generate tests-crds [flags] --env stringToString envs in a form of name1=val1 passed to executor (default []) --execute-postrun-script-before-scraping whether to execute postrun scipt before scraping or not (prebuilt executor only) --execution-name string execution name, if empty will be autogenerated + --execution-namespace string namespace for test execution (Pro edition only) --executor-args stringArray executor binary additional arguments -h, --help help for tests-crds --http-proxy string http proxy for executor containers diff --git a/docs/docs/cli/testkube_get.md b/docs/docs/cli/testkube_get.md index c5d70315948..4eeab707ed3 100644 --- a/docs/docs/cli/testkube_get.md +++ b/docs/docs/cli/testkube_get.md @@ -41,5 +41,7 @@ testkube get [flags] * [testkube get testsource](testkube_get_testsource.md) - Get test source details * [testkube get testsuite](testkube_get_testsuite.md) - Get test suite by name * [testkube get testsuiteexecution](testkube_get_testsuiteexecution.md) - Gets TestSuite Execution details +* [testkube get testworkflow](testkube_get_testworkflow.md) - Get all available test workflows +* [testkube get testworkflowtemplate](testkube_get_testworkflowtemplate.md) - Get all available test workflow templates * [testkube get webhook](testkube_get_webhook.md) - Get webhook details diff --git a/docs/docs/cli/testkube_get_testworkflow.md b/docs/docs/cli/testkube_get_testworkflow.md new file mode 100644 index 00000000000..97b882b99e1 --- /dev/null +++ b/docs/docs/cli/testkube_get_testworkflow.md @@ -0,0 +1,37 @@ +## testkube get testworkflow + +Get all available test workflows + +### Synopsis + +Getting all available test workflows from given namespace - if no namespace given "testkube" namespace is used + +``` +testkube get testworkflow [name] [flags] +``` + +### Options + +``` + --crd-only show only test workflow crd + -h, --help help for testworkflow + -l, --label strings label key value pair: --label key1=value1 +``` + +### Options inherited from parent commands + +``` + -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -c, --client string client used for connecting to Testkube API one of proxy|direct (default "proxy") + --go-template string go template to render (default "{{.}}") + --insecure insecure connection for direct client + --namespace string Kubernetes namespace, default value read from config if set (default "testkube") + --oauth-enabled enable oauth + -o, --output string output type can be one of json|yaml|pretty|go-template (default "pretty") + --verbose show additional debug messages +``` + +### SEE ALSO + +* [testkube get](testkube_get.md) - Get resources + diff --git a/docs/docs/cli/testkube_get_testworkflowtemplate.md b/docs/docs/cli/testkube_get_testworkflowtemplate.md new file mode 100644 index 00000000000..249f8284ba3 --- /dev/null +++ b/docs/docs/cli/testkube_get_testworkflowtemplate.md @@ -0,0 +1,37 @@ +## testkube get testworkflowtemplate + +Get all available test workflow templates + +### Synopsis + +Getting all available test workflow templates from given namespace - if no namespace given "testkube" namespace is used + +``` +testkube get testworkflowtemplate [name] [flags] +``` + +### Options + +``` + --crd-only show only test workflow template crd + -h, --help help for testworkflowtemplate + -l, --label strings label key value pair: --label key1=value1 +``` + +### Options inherited from parent commands + +``` + -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -c, --client string client used for connecting to Testkube API one of proxy|direct (default "proxy") + --go-template string go template to render (default "{{.}}") + --insecure insecure connection for direct client + --namespace string Kubernetes namespace, default value read from config if set (default "testkube") + --oauth-enabled enable oauth + -o, --output string output type can be one of json|yaml|pretty|go-template (default "pretty") + --verbose show additional debug messages +``` + +### SEE ALSO + +* [testkube get](testkube_get.md) - Get resources + diff --git a/docs/docs/cli/testkube_run_test.md b/docs/docs/cli/testkube_run_test.md index b2052f7b9db..76a2cf97ea8 100644 --- a/docs/docs/cli/testkube_run_test.md +++ b/docs/docs/cli/testkube_run_test.md @@ -30,6 +30,7 @@ testkube run test [flags] --download-dir string download dir (default "artifacts") --execute-postrun-script-before-scraping whether to execute postrun scipt before scraping or not (prebuilt executor only) --execution-label stringToString execution-label key value pair: --execution-label key1=value1 (default []) + --execution-namespace string namespace for test execution (Pro edition only) --format string data format for storing files, one of folder|archive (default "folder") --git-branch string if uri is git repository we can set additional branch parameter --git-commit string if uri is git repository we can use commit id (sha) parameter diff --git a/docs/docs/cli/testkube_update_test.md b/docs/docs/cli/testkube_update_test.md index 16654161f10..07bc96831ee 100644 --- a/docs/docs/cli/testkube_update_test.md +++ b/docs/docs/cli/testkube_update_test.md @@ -29,6 +29,7 @@ testkube update test [flags] --env stringToString envs in a form of name1=val1 passed to executor (default []) --execute-postrun-script-before-scraping whether to execute postrun scipt before scraping or not (prebuilt executor only) --execution-name string execution name, if empty will be autogenerated + --execution-namespace string namespace for test execution (Pro edition only) --executor-args stringArray executor binary additional arguments -f, --file string test file - will try to read content from stdin if not specified --git-auth-type string auth type for git requests one of basic|header (default "basic") diff --git a/pkg/scheduler/test_scheduler_test.go b/pkg/scheduler/test_scheduler_test.go index 85fc5dddc21..dbc3bc69a4d 100644 --- a/pkg/scheduler/test_scheduler_test.go +++ b/pkg/scheduler/test_scheduler_test.go @@ -112,7 +112,7 @@ func TestGetExecuteOptions(t *testing.T) { mockTestsClient.EXPECT().Get("id").Return(&mockTest, nil).Times(1) mockExecutorsClient.EXPECT().GetByType(mockExecutorTypes).Return(&mockExecutor, nil) - mockConfigMapClient.EXPECT().Get(gomock.Any(), "configmap").Times(1) + mockConfigMapClient.EXPECT().Get(gomock.Any(), "configmap", "namespace").Times(1) req := testkube.ExecutionRequest{ Name: "id-1", From 32d4c18f159f90fc5c8f10b9107f842de27b1299 Mon Sep 17 00:00:00 2001 From: Bogdan Hanea Date: Mon, 26 Feb 2024 16:12:02 +0200 Subject: [PATCH 131/234] feat: enhance init command telemetry (#5026) --- cmd/kubectl-testkube/commands/cloud/init.go | 26 ++++++++++------ cmd/kubectl-testkube/commands/pro/init.go | 33 ++++++++++++++------- pkg/telemetry/payload.go | 1 + pkg/telemetry/sender_sio.go | 3 +- pkg/telemetry/telemetry.go | 3 +- 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/cmd/kubectl-testkube/commands/cloud/init.go b/cmd/kubectl-testkube/commands/cloud/init.go index c42b46d3e83..c976585c25c 100644 --- a/cmd/kubectl-testkube/commands/cloud/init.go +++ b/cmd/kubectl-testkube/commands/cloud/init.go @@ -1,6 +1,8 @@ package cloud import ( + "fmt" + "github.com/spf13/cobra" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" @@ -43,23 +45,27 @@ func NewInitCmd() *cobra.Command { ui.NL() currentContext, err := common.GetCurrentKubernetesContext() - sendErrTelemetry(cmd, cfg, "k8s_context") - ui.ExitOnError("getting current context", err) + if err != nil { + sendErrTelemetry(cmd, cfg, "k8s_context", err) + ui.ExitOnError("getting current context", err) + } ui.Alert("Current kubectl context:", currentContext) ui.NL() ok := ui.Confirm("Do you want to continue?") if !ok { ui.Errf("Testkube installation cancelled") - sendErrTelemetry(cmd, cfg, "user_cancel") + sendErrTelemetry(cmd, cfg, "user_cancel", err) return } } spinner := ui.NewSpinner("Installing Testkube") err = common.HelmUpgradeOrInstallTestkubeCloud(options, cfg, false) - sendErrTelemetry(cmd, cfg, "helm_install") - ui.ExitOnError("Installing Testkube", err) + if err != nil { + sendErrTelemetry(cmd, cfg, "helm_install", err) + ui.ExitOnError("Installing Testkube", err) + } spinner.Success() ui.NL() @@ -68,11 +74,11 @@ func NewInitCmd() *cobra.Command { var token, refreshToken string if !common.IsUserLoggedIn(cfg, options) { token, refreshToken, err = common.LoginUser(options.Master.URIs.Auth) - sendErrTelemetry(cmd, cfg, "login") + sendErrTelemetry(cmd, cfg, "login", err) ui.ExitOnError("user login", err) } err = common.PopulateLoginDataToContext(options.Master.OrgId, options.Master.EnvId, token, refreshToken, options, cfg) - sendErrTelemetry(cmd, cfg, "setting_context") + sendErrTelemetry(cmd, cfg, "setting_context", err) ui.ExitOnError("Setting cloud environment context", err) ui.Info(" Happy Testing! 🚀") @@ -88,10 +94,12 @@ func NewInitCmd() *cobra.Command { return cmd } -func sendErrTelemetry(cmd *cobra.Command, clientCfg config.Data, errType string) { +func sendErrTelemetry(cmd *cobra.Command, clientCfg config.Data, errType string, errorLogs error) { + var errorStackTrace string + errorStackTrace = fmt.Sprintf("%+v", errorLogs) if clientCfg.TelemetryEnabled { ui.Debug("collecting anonymous telemetry data, you can disable it by calling `kubectl testkube disable telemetry`") - out, err := telemetry.SendCmdErrorEvent(cmd, common.Version, errType) + out, err := telemetry.SendCmdErrorEvent(cmd, common.Version, errType, errorStackTrace) if ui.Verbose && err != nil { ui.Err(err) } diff --git a/cmd/kubectl-testkube/commands/pro/init.go b/cmd/kubectl-testkube/commands/pro/init.go index b886cd2bdd6..f4b5dfdf774 100644 --- a/cmd/kubectl-testkube/commands/pro/init.go +++ b/cmd/kubectl-testkube/commands/pro/init.go @@ -1,6 +1,8 @@ package pro import ( + "fmt" + "github.com/spf13/cobra" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" @@ -38,23 +40,28 @@ func NewInitCmd() *cobra.Command { ui.NL() currentContext, err := common.GetCurrentKubernetesContext() - sendErrTelemetry(cmd, cfg, "k8s_context") - ui.ExitOnError("getting current context", err) + + if err != nil { + sendErrTelemetry(cmd, cfg, "k8s_context", err) + ui.ExitOnError("getting current context", err) + } ui.Alert("Current kubectl context:", currentContext) ui.NL() ok := ui.Confirm("Do you want to continue?") if !ok { ui.Errf("Testkube installation cancelled") - sendErrTelemetry(cmd, cfg, "user_cancel") + sendErrTelemetry(cmd, cfg, "user_cancel", err) return } } spinner := ui.NewSpinner("Installing Testkube") err = common.HelmUpgradeOrInstallTestkubeCloud(options, cfg, false) - sendErrTelemetry(cmd, cfg, "helm_install") - ui.ExitOnError("Installing Testkube", err) + if err != nil { + sendErrTelemetry(cmd, cfg, "helm_install", err) + ui.ExitOnError("Installing Testkube", err) + } spinner.Success() ui.NL() @@ -63,13 +70,14 @@ func NewInitCmd() *cobra.Command { var token, refreshToken string if !common.IsUserLoggedIn(cfg, options) { token, refreshToken, err = common.LoginUser(options.Master.URIs.Auth) - sendErrTelemetry(cmd, cfg, "login") + sendErrTelemetry(cmd, cfg, "login", err) ui.ExitOnError("user login", err) } err = common.PopulateLoginDataToContext(options.Master.OrgId, options.Master.EnvId, token, refreshToken, options, cfg) - sendErrTelemetry(cmd, cfg, "setting_context") - ui.ExitOnError("Setting Pro environment context", err) - + if err != nil { + sendErrTelemetry(cmd, cfg, "setting_context", err) + ui.ExitOnError("Setting Pro environment context", err) + } ui.Info(" Happy Testing! 🚀") ui.NL() }, @@ -84,13 +92,16 @@ func NewInitCmd() *cobra.Command { return cmd } -func sendErrTelemetry(cmd *cobra.Command, clientCfg config.Data, errType string) { +func sendErrTelemetry(cmd *cobra.Command, clientCfg config.Data, errType string, errorLogs error) { + var errorStackTrace string + errorStackTrace = fmt.Sprintf("%+v", errorLogs) if clientCfg.TelemetryEnabled { ui.Debug("collecting anonymous telemetry data, you can disable it by calling `kubectl testkube disable telemetry`") - out, err := telemetry.SendCmdErrorEvent(cmd, common.Version, errType) + out, err := telemetry.SendCmdErrorEvent(cmd, common.Version, errType, errorStackTrace) if ui.Verbose && err != nil { ui.Err(err) } + ui.Debug("telemetry send event response", out) } } diff --git a/pkg/telemetry/payload.go b/pkg/telemetry/payload.go index 4ba46da0efd..ddde292e62b 100644 --- a/pkg/telemetry/payload.go +++ b/pkg/telemetry/payload.go @@ -31,6 +31,7 @@ type Params struct { ClusterType string `json:"cluster_type,omitempty"` Error string `json:"error,omitempty"` ErrorType string `json:"error_type,omitempty"` + ErrorStackTrace string `json:"error_stacktrace,omitempty"` } type Event struct { diff --git a/pkg/telemetry/sender_sio.go b/pkg/telemetry/sender_sio.go index 619fb12ed70..1218dfd223a 100644 --- a/pkg/telemetry/sender_sio.go +++ b/pkg/telemetry/sender_sio.go @@ -95,7 +95,8 @@ func mapProperties(params Params) analytics.Properties { Set("cloudEnvironmentId", params.Context.EnvironmentId). Set("machineId", params.MachineID). Set("clusterType", params.ClusterType). - Set("errorType", params.ErrorType) + Set("errorType", params.ErrorType). + Set("errorStackTrace", params.ErrorStackTrace) if params.DataSource != "" { properties = properties.Set("dataSource", params.DataSource) diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index e6a35418644..62907969bee 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -46,7 +46,7 @@ func SendCmdEvent(cmd *cobra.Command, version string) (string, error) { return sendData(senders, payload) } -func SendCmdErrorEvent(cmd *cobra.Command, version, errType string) (string, error) { +func SendCmdErrorEvent(cmd *cobra.Command, version, errType string, errorStackTrace string) (string, error) { // get all sub-commands passed to cli command := strings.TrimPrefix(cmd.CommandPath(), "kubectl-testkube ") if command == "" { @@ -72,6 +72,7 @@ func SendCmdErrorEvent(cmd *cobra.Command, version, errType string) (string, err Context: getCurrentContext(), ClusterType: GetClusterType(), ErrorType: errType, + ErrorStackTrace: errorStackTrace, }, }}, } From eeeffde4b68fe3af3cf2df1b0e398a8d32a3cd08 Mon Sep 17 00:00:00 2001 From: Julianne Fermi Date: Mon, 26 Feb 2024 06:40:11 -0800 Subject: [PATCH 132/234] Enterprise Getting Started Doc (#5062) --- docs/docs/img/enterprise-download-form.png | Bin 0 -> 701357 bytes docs/docs/img/enterprise-download.png | Bin 0 -> 399826 bytes .../articles/testkube-enterprise.md | 13 +++++++++++++ docs/sidebars.js | 5 ++++- 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 docs/docs/img/enterprise-download-form.png create mode 100644 docs/docs/img/enterprise-download.png create mode 100644 docs/docs/testkube-enterprise/articles/testkube-enterprise.md diff --git a/docs/docs/img/enterprise-download-form.png b/docs/docs/img/enterprise-download-form.png new file mode 100644 index 0000000000000000000000000000000000000000..365321f5f5037aca9eb2974127086eae8b14a87f GIT binary patch literal 701357 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≺hY|if`kVDJsnEEN_8n^O9t)jj<08wp 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

H^;> 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

Lw|ayr-W~MWw;FBIP%AqGngO;!2;8eF?3H+ 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/GR^s>S?-36c})iCT7ZhbuYE)pSf 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

F~_$V+VFr_~TORsF4eS#eY(jDw%>d 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

(E<1x(QT87U|6*TmdPIrdzA@T% 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 z zUfXA4BJ<@{5uq5K4OWXQMUi8q34 zouf|IO#CipB-M`J`?%XNBO|sQjp1NKeqUfi&oQIHb=VHyi{2&k&sEoq=TM9p zuW`(S>;7Yg5nL;IjFC(eXP;b!luIUuHr{bB%c+mgr`?$KJTU)6 zAm>sGr=bpsBFWY#L`y6YBreHbi1)l=wsBd5Gf8iMPD50Cam0c(hDEZ2)}c(}2??Le~_#EG3Ka*X=x zN??8Q%|t79g{k)=6v9?+~W47G;>ZYW19bi|HBW-a=bN@|^&WC&KEUe1^WT|5?B`w3IAM3Pz z+o>3!G|#tN^PQPuV+mnFZDFcHCW>Sym1o0~P0V$Si63>0d z%9DOqrD_W>&%CL(f3zyN>uL6L3ukUEdv)19kzjo*Ga;UGSKzn(~(=Enx_EjZ4hKG&j2*X~p0nOL;oW+*o+O9-S=_F7Bduk|*n_ z`%OK%zGpS)F%$nF0TM7!omyJ^x##T?i;gN@ONi^^pGH4QkghtduW_jU!CX*wVx2N; zKPCSCLQI3~VfszfAV5j}6^s77P)0?quQaVGr)^F;eX6bQK6HZ19{13hL?hv@n0KZP zgkIsK2<>-wS`161;YK)pxq(8z)5gQ@=`m4TSERhkDi4Q=)p0}ON(tzZ6)u&RhG6a@ zE+~{pimSW5oc-<{a8#W;ewKed4iYv&O@1Rg99#8PNsq|b3|3Gfn_0m2)!M~!3r8Sr z1Vn0r!^}PNHHgs!7V)ekz$Ur)36KKQf16*(8!H;X>uDe#%ZiV#32!XMK?u$ulgI7Q zFOQx|sB-Fwshd7hb1%becZEE`2H+60u`=h5-Jv&1~J2e)m zH5R2NQ<`S3RY&10184pEH?4wL34`<>+RHJQ7L@~orK;SwjCp*QS=Jj8Gx?4)QY|P` z&dQUIXLPG_?fc7joH_q^r0wocM|DTj*1T5-wjy;t?zongE;P*VE()p4S4?nbJBx(O zB>uhg{@Q}T#cz#Y2b65QBB_{mdTaE`6GVtP)m>DR9TF?<4X?8)iVz?V(G+t_K!YgISdiQ3xUYSl+ z&LDE4SlYCyQ}-Q$U?PR>$~UX9HYy@>7`G5_mxs5H(`;k>EeyhD2Ww=td6`uemxpn? z30ZQMlU9BAlN00$Vk@GLT*7g6Bf<|uXgydMFG2I?nJQrlpJ2Vfla`KHqzAjn5JZZ@ z_TZMIT3}aNT#2MV@9!xw3MQ{!?nZwNcj+H=*Z+A7BIRD`N?oQy*}duQ

~|xRYC@ zeQw#j4{Gm}X8~^OAs~T26^w z$%> zJlE_#M{29TMxJ_5W9g%XY}88OXNYidji6Sw{PwtSI7J7b34POI&E(*|h3* z6_IB#RgEIM5!)SGc#6s@~v+WFOPJTnS{ZP zkX7&*p_aQEf>8E5ycMKu<56yj9r(Cldx-6io%FAP%1^(WDxbtQX!CG6?Rz|lcIWe0 z^ui)3^3*?9+TYiP(xCRA(J67x()GZQb%qh-6?R!lYsZ7H=%~6^rQxH~W3hSG)u!OP z>^D6f9W`Ze-LWF*?q+U)NsDC2=GSqp$q{Z%Xb%YAMg*;GT;oBHy7`mY1aa;LH1mYg zaSendV|su1W<3t>t~(biz7_)wncQnLI7|4FVB)|m;{Z3u&g-7xF|QAN+s!KqrAiPL z4LZ-NwPoULHd-1aZ_uDt7^fcI12@HEkYvsq==4e|Asa0&4KEorA|XI9d$l_e&e$|y zT&3xENBEJ0P}X#zL@i56mi&;E=pX06-#7fj@uQpd3>rs1pB7RJvCx@xJuViOml zt;Hyc#&7D^Q} z;&#`*Z8|L9cH9JB$saY~*NBR{dmWrF9mdG{ZD3?jx%()H2%lf{AKxFr6_s#7Uf+uq z%(f2eopy-k=%$O}DH(fYb;ZWNf4tAa${T^9ANSA)=p%<1@-`+myPht5Z2O`;{6!P! z7Y9*xrOBy>BsJ7Ng~7o6qybBKwqGZqNd#==ZULLbookhtv5r}40enhLGav54J;0B< zlAy2I6)7PWP$l-FO45&gO7N{GoD8zAXrxd$kp!5DOfht(m@Ps^6MY>@?82dxYhDmF zPMp1G6!&}bTc~5^4-biE^TVb00AEU&Z_Yn?V}AUR(39>%Z-R70gjbb zCT_H;9*Zs0E2(nZk}RC8w-uT(mVM(&IqdR09vZ!`*I2yan5h+)**JJDm4u|*?qMse zQ!J2s?22u%DU$jtv`VCRS=Qw$*;E5F#hB5q4+B%EBi!)6s%HzzDB!d;Izgh?f3DG+ zm^#((>2uedM>~%4CwBXEoG5Q`dyppf$H{(RjY-b4`xM9b0BRBf($aqr)|nRHHvn7F zB{1R%;FRgsWmS*>o5-B#782}umOo|eNcSBl72WX#e^a<9VexzQzs{HPG>|2b&8v}@ zmJ7VKe_TH6Zs3w;TGA@^mP`Lj-Dokz*-Ns#%Zt^PR2=&V1DPymFYScCzuiB-azlXr zeSptGPSGZg5KDEZ?7g8%e(Af+SLNc+s%17e$BjbLBP3Oj8dHGi5d{)kR?pR!b+$j5 z6Rp$5I;x@)BnZ`rO%@rcQz|h1AJUUI)U$d(Eo&hLVkZke`bdlE)#dpfb>m7YZZdMq#%kqz5tN&?8`k8(5~AJ&KUn} zGKwKvhfeSh2=lLZ@8I3?s{i)VWMjwmYuWO=Rqbc<-AJ)dqYYeCwf{;101*f$`5wD& z4MWE600(CT`mK$6#;GSM%PRef!&8$7UCg*d%WoSDgL^=?E0>UY8^be{EaIA#OuE{IrWC!&Pc=Ji@OEQiks=Lsl=55g=% zR%|xWuVjiK^C#6dnwb_(E=4orfv`l3n1RjSg&Cd1Y?-1SI)1W5$Dl*4!qKdCZ;z9$ zhZhWv>v#=*G2joVK4-i+{L*JwWD?>&8=hsCYfkzUtJO;4JcK)&-$q)tni5ukB4_YY zPY=G^-}bLa=Fb;Rp113Lvmc6%*;z=zpdBl+Wpts$Dy{ybTkOwwuO{Csb&-Y4j5+YQ z3d3wmjO&mHNyjX$iL+v@_ceBT!}_tWvP#d3I(26g4yhn)@64W8(4j7&fN#TLB;Yt2 zRrW;L@2ok;fwJI7GJR=$_r0@&63~{N)rA4~lNEEuDA^!*UoS|oEAPpUG&P&+l4%Yk z!b|grJ8*`BxmA}w|7ICCTQ284J*`hzxu!0s$6*m&a%o2K7V>M-+ru(ej`W9Wsr1Y) zQqbXp3PvBx*A|ChR9h~{@EqgNCdZ|9$tx7+3EKyQNd$y%X(@V$Nl{e4qLi$puh+<3 zbx}{z;n~E|^bHG2@fLIa`}zDA4r({0_xnJj$kf1JXkp{JJ8!e`tR)h((D(6lqwXCM z+3b#kj!QIcHS+z7EN6vKvwug3mSOkUD+(^F^L`8oyH06W%_8S}J$*LRe2prS+t#qg z_7b3F4?9X3L-@3eN(u1?9w;;%yg_n2n=tnvZe(VL?4@HbSG1h)F+wZ|MU^V*l_5`+ zTB5-B&&$&4I?eea3x=|fOZuix!D^hBiZx|jat%0bYZ9=y=Lk7tgpc3dC&G|(l2a}c zy=Q@>gq$0VJ<)q$v|Q2|?L^=4Jf;rP)SVGj2}=-H5P` zK&-yn=J(mN@Tq^<*M_n9?PlQ$c0&CcUw=kE^7m>OX|8uD-Qs99_iV~9!W6Kf)2-U^ zoVj|Y1;&%t7@%fm$CZB0+PMnJ7E!7;hq>p81a-Zr)=jvKg~2L@)}0`nMC5o>IW%GQiUPY!127EU2F_Qi0@P45CNq?SeTM5m@FcR zL@l33izz~aKfDT73dmT7gfSm0NNaifQI(gXJEBR)!LxNK-_Y}Sj~5WmBO^G+sGPc{ zF8slp(PXfSu^_b>?M1#yp0EGwNRUKjLOa2JFRi2PEj_I{soL8E$7veX{UGM%^Kw&k zr!EowUmn$eP6A=sdogLmrxJFzO$P?%C8YBy8Q-yEI5p6^AV}9Sc*z8DrxBK)Za`lH zBdWQHlOE&|(J%IFdYuZcpw^>+daYXetqt3D3ruf5Cl@+M2+qu8k)>96(!gz~!XP)Q zPl0AVE1vDSsr;wtZ9O>wor$Hd2lAaz8{8{-$cr3!hjuhuP`~<%NY8V0$I7{B)Jw}e zThj^?Tk-Z^zYE-J zuSUAdXs9yfyfeIz$>)6diP7G*i`Z5-ggwVHS_Ty_c~#n+>a_R|Vwn;!o>#;s7W9D_ ziP!Kxd56VmJq_^FKow4;C*#(Kn6sY{54Be|Gd(8a)ppW?h$J3c)@84RLzNIDn7+G8 zrXAc<`Z_^t8st5-wlpMDB}EbH+X(5GIBlGD=OMS}Dl764cB#GYYb+prCCd$XQg<_K zNf(^?p;$tF)b_yyNOZnolFH(~8+1lDA@PzO7){Oj$!CS*>4)s7#M^=nK_*)JjUb9M zRE=iqTf_gkr*4yochVfC8i~4#`gY^WRj2OzmoKXhF<*@{@)6v9Z8twntr4g) zuu(a!0;cbr(*abrc6*<0fDI{Mmm83?-fv1-t^*0c(q~ zeHOr#qCHng;$pur(;(xAf$swQ$S;wT92TU?NBigKBgSk+R&@pz6WA!C+F3 z7{Ve;4YUzv)DS<7pl)Ep&SIz0+{Syjun$_uc29{Ms`kq4kcavH(yQ?)yqk2?1X<^1 zHou-=jrpjQoUO|lH(;w4WY%Oph%(0GISUpx;0;YDUr*!swB+UJEIG>RQ&=$nHb$eO zZiB^B!+Y9upyz_gG*?d_8TQkBV9C;B_R#Pjx{!a}rkRrC3s}CBU3R^EcXCXHgzs>4 zkB&=+7m+0o%OKUn2`#0uJ?X)@uZ!&XeXCAW@^U>O!po5CQWBfq0fUCK*jd1GftjP_ ztq1LQM~mPSS&iMBPZa zqVncLDz?%zh8Kpw*k)em^}(<=Z<`Mh=D^4oKy~$090jT!k|Y+9*%CG08T@bvuxdqy zPuewYQ0?#l%R$Fg6v_-k7ZViJEHN?P!8{P85;;xJxByz4`Q@KDLML}3I9f@rO4aQQ z|HGZ(Vet~xK7CA@_jO8CX)|7RGG#!9ax=MXw%}*;dO}r6RH=99L#hq?N$DZ_5%m&o z8Vk1kxsi#wleN#uuIj$2v(^h@L-2Th-ehHUv&P#zfp;-V?2gG!rl=A(=vEa4BFptI3gM1Cwl(>^fgM7iuwFedt3eN z?RodS2wp7-MZjVrm`hZDG%vi7; z>{NbhiKq$25mNp9-Q~JBPN=qKIV}@1DPF&oLfQp`GwZ;*odZY9Hrx`tX;P~86JsEZ za*slTJF-&k?Ti~$Myjz|SfpDodC)eyznOXRiMGpO$m_W+Sx?1RUAiyI-Bz!WD~IDL z$cmr4ku6pT>jsrPOxx4Ng|sy(VU)a*x$#>51nHa*BiGxqT5C~W)TBp?!_?lUXd6EM zy(|hTu*pTq2|pM<&ntg19MtQlindGE!Wk=a2l9M$@G8k$Sn|cpzWqYp6(ct&kPt9> zq9083f@xP~b!huV+kn`a3c?I+weN@ce|nHqOrEPQ%-t1Cf#piV zso6RlO!BMiih*1Af@dYLwaf7d(m`p<)q17G0NY@bbJG?R{mbD44@BD&@`YE!HhAPK zZ@8^rZ$|EDaRlH_J@?00n)>c1GxF?Xm=Z31P5vs&z5tYt?0ZP6h)mPh*M+`Q)vs-q zmDs##iNys7`0$0d>Y@m zne9FFFygV<@%0zr{ft+y&NUb;@vw!3B#RA~S6FLmP6MEyf27*KgcP3PlpgK}mB-$n ztHUV6ea@`BB*^F4@v=UIREg})CcF3Z9*$8Ord8N;u&O}4xAu}OOJnnqTV`1bgG$KN zqJp(q6qw(NtCO+PlHIklK5XVV_0e#>z7^9Ebrbi2e&OW}QO_>wWy4i;Krj(Hf}?sQz;$`ftt1@op>EyGyI|Pvoa~w=y#KVRIT*nHdgvouzvuu_Vo(Qclhj2bf!?~Va-;G?#J0{;j@cRz(lZ#5yzS zqia*>3bhH#enSwyv(lyy7{eQjvOInA2R~Sexnva(+QW_hyU0i^GwqMI*^`;Yy7P+F zG=`EQ{k!}b6FFs`=|pyQd1qFc)v8&~$VT*@N!Ce5kfnBDD?@lOcXQCf5vKz}3oQQR z5~T^eR}4P;{QAc$Lm4&EiR$95bq`0iG)@VdW{&H1N@rI1ekeKd0hZ3Qr`S?syOXbT zPduI{rR7xred6G6&+6X~(;uUFa!p@JU9zys0dCtRYn<&MnHOz0qv~~k)C5lfe|N{` z_)?OT$gi?_9MeU#KLiJ)tD`aU-dZy8T}8}IOUGmn;cGmz7e=X}8~5#ZX76;M#Sfg) zSa+8($oD6AQq*rJ4yLFuz;u(V`zx{K+UP|l7$3X^)@?9k%8-x?V&QOOO%Gw%`%32?DK}1Ma7x^KQQQ~#q9_D zrzXW6>|I`qM`tLB_LF~Sj6PBK9ePIxXX5W3OtXa`_=<1^8m40)+^9(-NrIS_fp(8$ zUEXFEMGhIKWR20G*IN>!V*2w~RAhjO?dmrR2IfVqaLvN0ZGo|}h6x3$Sp`Gi&!ll# zR4?;jK?a3fp-bO{z73zja9n%|rGohmi^8r1%B^Tm53X%1v3~M)Y@MYFHr05&tT*-7 z&vY=9i*i$H+`s(VioQ)jjB9BN$Yp+7xp!^%?%}^02>;)|&FR#O`vRNnH(x4-&(4cy zm?k@JDk@y_E(A;|mTL@GRvKD=G@L*V@O>-_BIElHW=7X>`AKGLiUHgDJaLrpOE093 zBD|hRhQCI0z`S-Va~+6b*};x1W&5_j37`fA`ksECnv%&k-&R~)T>61Z)=qLt+rs#V znpr_bADF+_O>$gD3gG>@FA{gRzi)X)B=9Q!=Y*z>Tp2Letu$~?w3}d7$dqLUissSe zly7t8S91v~4Rcp`mMtW%NzU6eHEMwb&D}YPX9i_zWi3w$VlqHNHjv#)p;WRGKf&MA znbP3^xnO0$E9qo4LcS6rK*8US>N?^%oNRx0(n-G1IW7HZf z?;@a8w?kq{P%#+8+?rp~fxm6@>jR=+rJ^N-|G4m3-V#g?u9S`02&P zq@DGi9m+>B`7iu0ZZd+OS=Ccp3LtmZe4V*eo|)sSojXYzESb9!3E2yA*fF)^(Y$HJ zYl68Q4=73TSD6XSGR+W?5N|A9Dk-bA=Qo_s0KdE>=0F^#5F!DQv!-Ed;sKHY=AZWN z!6JAE2WbT%oy;Y};sjep&-ev2P3?HES8lL(FE@TGchs%C+p1HhTS+VunMQ4Qs}5z4 zt0KBtDtTPD7bJh|y%LcLI$x;$p+E*}-}t&`|eK{A6Y%tAEGHoKASTU)E~SHk?I7(wxao=tDln zPrJ9bBBzFGdH#`Pia_m!i#tg}C%&C7&{O->JmZ)7bHJrAk*~Lt>tQpWERxe1GK%Z1 z>m~d9ij8T8MX$d2!IT@O-XB<8(_3G;ig$AS`&oY+YbEyZ$y0JiZLvlH+wO#z>eOYU zI=9QzfgWA5%o7cs&F|cfkRHmj(QC$3vwQ{h=<_DXwP@Qc6Gq&P=unVk{~h`74nkVJ z33h#|NRif*X#6I3Z3)A_5B~fIo=A5D{fo+)Uu}xv%G@1Vn}U?Ia}j9{S+30rfvw5Z zPZo>0m~y9)?L<$*z5aD8{`vW)>@L0#(2m7FBbG9P^I)ueg&zG|c+tN@&pzma;iWR$7M{4|^gSPJ;?g zM(6rR4Ol3@EprU~p?`Uefz{EQ55{r7Mikn6qx}8_NP$5ZZdwP9ladun_yc?wnlKN_ z4_2t!&RZGl00d2B0@K$?S1`J%Vx4J40@u~c)HLe~Oz}P~fbg_Y{+r!2`c`HY9caZB zFj@T!OWNg3w|qe`sc92D4IIkK7gUN9T=rAqQwqjM=dEHk*15)58>+At=UMjyDd5JxB;lx7-kAmY3K-??FtU);JZ{q zWgO*o+^!5xfS>`$d2R6)l zjUaocR?qwoO|qW&pEvbea#k!;X!A!Q4x7%djOcZD;6|0^qcy(IAP)GgaGhHtKb zOa7k(+YzaCP|=ziFIf3J!q^A_p2>e?lldF=aT9qQ3b~HEDEa}*v5K}MNqoQ@qC@Bn zb5Xpy>=c;)F$X^NZXIT!*J535JWM_(f4C@720y$EBCQHGKJMlvU-cRF1#`~Fa3iNq z9k_1rti!BcJ%>gT=?>jMRh38kRA48+>{K(%G(FfCOde|wo)m}Lbme3v&5RfmwkAK^ zZYYIaP3ecm0^6@&8P=D))xavu&H{uNv;8qIliSt2TIXzKcQ`XuabMrhQJa;*4-+0A8 zT3EYH3ngi~Y-cjXvrzSuIV9kR`Kk=*H;;4v2^qHz@uRYAI8!bI?r^!PxMuZWM~T#q7$Y1iCoiv>5`%Z;Hn{tH=JjyTJB=0R2T6o*!-s2 zTF2?2_?$63kK>6mQ83I7tFF@Er?9pV5*?NnZENNVSVul8)>e#Q@M+}`v%|b|ByDP@ z$6{tBXGiOLff4uYd*B*=w?`jm${+GdAOI${lVqRySg1@k98C#l^Z6IvPrqSoMrcQV z<*~1!Z#}7f(hOY@%_wEa*Q)2?Wy?ip3=|*J%Q96l!z=ttWem(S#3nz58&698j;M=DlJSP;T_~*WW2;6>%aO-Fx;h$v0cUz`x*Z&QicdLi_^Ks1>BD!xB*tB(_g+%)B>_u}SqAd_YH1z>RBe~Yqot^PLkOjwut zlQ%lLFgfBA&amv7V9YkTKfqHqR=~Z{^9vfe0^(}|O2ozai^dB)|ECd{ZLtEEEoixN zhbVfIuMtzooQb4=yV$m2NR;!j1w7DXCFr@G#4i|^&l)~G;ujg3rWdjWcD#4}^&ra4 zYC?&^s&#$P?YwNP(H-nq{lFt#!$M#sh*i=58o2Rt45%H+DbB<6+jTuz~|xL4rG5aD-Q$k_KWyYD=b~ zp`TY^xgfYtcz-@x`V#YFKtL)P%aU-Wc;O0(+>CG`QF`$!Fpb=L^0r{A%^T6Nx!oP@ zw{_w?6Dg$K`vv2Y5CY*7^aT;zS$fYKKmOBSs%)p}%=%6VE1!J_q5C#URfkUXz~N1$ zx1Z%5Nj-2}%C~RzR&iXUl|?Vp`O<=5hlc&_P7^PPjH<&r>gSjDqVN_v5SaG~=PSt- zmnZy9nx+m7mW1oR@1Spx7N)Kf&`WzHldi#uN5o&9IKx$udHO)^P1Q}_CC|d7ic7AE793|80DFg(dZX& zl+S(ekLSg#(9%-q&!sE0QjX#1mEF6xM=U|z;HE`au;qbnZgaJe7 z#LQ4v(5H{wc%RO{S}m0nO$i42 z+s6k9G?~;@UZCc|CwJAx<2H=wmUhWhsnnwVG9WUR;4bG{mb$}_!5$rmz)UG@i1MJz zM4yY!y`QeqGuG5#7qsGrs`vb#>cNNAmV)G=q+mygBDS3sFs_`Zji5jT_-Dy(3;JGod>fm!%WtXN*JRSw^ z8CneFzt2A?qiq)1g(@-J27UpvwrL(!pldKcxZsoHht zXs(M-P}=KZxoJDOf5H!GID{g83q2!(pmME-Rf-P04S-@C$g4lEAR>@3`A$zTglyo#|#C?O!JdT zJCYE$u#zu*#B)0FHRK#WJXE=<3xC|%H^FDt#NQ{6 zoPwT-s7pD@yN0uN_}skNhPmc%Z`fV#AQ{WZI0~-Z-yF#EIjrEk`F%ifsT6F8)O5T^ zo$v4lX*k6%4j&rY>`-cd&dlrK;kSpM5&n(?zrT~?ugcf6g#1Gt{KV7~7ol8x&s^&R zd-XtaX>C%Bq-2*X%ahd;&KP$-xA2;qC7sx3$@RZ^&v)6^Te1q@J~*E8+MG1GK`h-j zHw*``+}Y3sZa7aa13BY2))y?aRWF&!bi4J}D>CF`DWMPScjP{WI9!u1WAo~?nIDMX z2HqrbzQ7SM&{n@paayj~>GwX#4?WSY9_PKDtyuSTa%Vx~J)%C@EOX}^tapq)b&>3- z*FA1G`1+BJsVG(Rfo?jx|AXW+9O0b?bLG)_+Bh|Ro_})}|FN6|!xXIz;z_)X<2=+M z3UzmL@603k4qeKB90Wxw8=8(~t&1`E-51ah5~vyeiNOdno~3%3NA?}fhYQ%pL^uIg zC&ksewI~gIQI=DL_EwBPcBU=BV$QkmC3)arB997%yzndnS@{7gw+JafaMZED_Jx~+ zqu?#*UYKIFwfi4)zkQps-09RO%VPd=i9Vp5tl;N5qH_H$VDq3-^*OIyb(NLzj_Ec* z!R>=0^qV_?Yu^WP5U@slQZ3f?6t_D67VdJ;P}TB;DR{)PZclkK=!D5tHc&G3u|L%= z^=_5p6eJb=1A^|<=eTf&TQQt&U<@{31c&r7!!7OxpYKXI%47TzkWDMm@kyTZZwx%? z0{x)urkF=$wwG#Nz%{J#8~MNP=RZoup6Lf%%K2kawm=tX5NG}(uORw*=Ak{CVxHl$} ztX}9;{8qL$8Jz*+w~DCV8CIJ&%xzrBV|S9XhazEnx5{~~|nYv9xS`(T3Y0k{q02H7BaDF}E`#+ZYaU7?d`mAAL|)88KuZ*P4N zXZ*FdUqoY|V0meZW};xVnF9Nz64NOk^jY~yZI^=Y6|Kg9vJ$>Z=lai(9Bh1kvYZRn z#AH$7dgt$~?bVM@F~TwWap1Z^vfQ*LKo;xyWdomDOs6g)l5f+w08G;xN8hLDh--tOY*l&n zN6-D6E*Fg_2y-fbDIVD246?^+RUHuNI6*&Jm8i`J<_UY0mcINs0#+{JOHJUY?SoFr zHR;+C^!~pr$Gw2d#$#hK>$9WBB`I&(5wQisbG=m2UnuY56WH(K%j{yF=G3uUv@8QJ}x|-a|LueEzj#4Bf-8 zd49gZCGdSBcL0dTW&0hpM}{HkEy&B-g~u1JhFc4R`>dkPn-9QM#iTU-DV(uZ3uo8! z;1qThp$-G^t~+$wYmn<<0+^E_vmSga(iD6lO~WwO%-~1zI8Rsu-(8cdDx-YzMix>A zudbE?MuP*+Grg2Fq7cuR)~=AERE_-6FwW{MuFcneEIhYxE}TiEb)Ov!JQghkypn!W zJ@_F*hP8x281?csRCcXvEFb%l_^qx7yTECMbU!Z>PmCPo|0izFS#k7=`CkJ@oGGNmq1 zSXQp3KSil)mKjdmmG=!dTARvQKK)HWP|wtbXVdwD)3H^>9s9q%0FL0Nq1GgIUJV4- zwNnlY(8Of3uFAqjQHtr6_E%+NBhU~*o3aGA>#a;lvz0Z{leM^$K>?i|?SZHRm6EA%6GuN?vliI+E5~xK*;z5UNR*X%Q(A$G~nYE23zM4 zYB*c|eo49f2Un@{oUm-&%~fXG&hhQMP|V8GkjRQ;GvuECz^MJDy!zMcDKwZ}wVX*6 z$)Q5TH>PKHgA8{(KX^~9LqAK|xZ(KVy?)A_MH%>Oo*N-9WyV##%CaDs+PzWpIUSMC z^$}6vVW@dk)MNWK9@?tN{EW5y9y~q>HfU)PJ#A=p|1+- zP!D^Q4Atbuh>)@cUx(m_7^W$L6&*l~VY|dus3elWwT^oU&RfWM_DQYzeG21ZG0HW_ zE2#V)m-`^8sw1R(KE%YiG^-hjRq9QwO#6qQ?PY7 zv@1gM^EO7=o6=8cpo{)41N4Z+N@Y*$ZYg$5CYDQ8ER^_4aq}{a29xx1D5Dj{KJw8Z z;wM5r6mOFp!`k-GV=U4>%X4I@2@CC{Y=9`3?S`$PHG@jJ%)O+o`Bp>1?ohjjo$9V`ABC5-$&Qy* z>A~F={B1Zb>KWrcqeZEE&(DFSB+UQcSD!5Sw^a+B_d<*AIiad5lmYPy83>&_3I%$Y zCeB&z?U4#3bt)YyK3usb_oJHaI}v=5-O-|z+UGAqqvogdOHOOKH2y~-_n$9@=#k!C zbO!UCrhntv6RY>r*}8ZVIa9Y0U7YH;EtIu-6g&|1r*`asN%ohfL(6u27m1uhN$t=*>bbV_vHseEoOAD z3_a3+uJ~V3>=dhYgb)$4+QfhFS}U$~-yC##UM zcUtemAFCz?-~fqIkX*D>s&bR-d}4qwYvae|Rbp+C8@q2^FzdJyocrfZwc=SQY8QGT zHm{Dr%`U6b>o5j&hUz4$_X*kGQ){t$EhOs*0gn>UNa44bD-dK6%M-&=^EF+@hAlDaN>J|ich-M(fMgHp&HlzucYIgi{N zNB)D0HNEPGN|cM5Yz8gd(ev7<4fEE$FWgBrxU&l{Z-m9?+PMKfFZn8h7rE?!!6%t&~C3zPfQLKDg z%T5bbju+BOl!=4J&40B-9N8T`_2tQnHFOu+!k3{fmapwdcb{zVv>`ofWOlRXWnF7{ z9M9&m4TXqsU4_YGdiE5BLeRQrmJh6Kc*`tG--sVvEKfq}1p2Sy_N4E!rsG#)_tp$9wR^zsA({Y}?*p9mjE z@={f>OD=+z6NXuSi8vc8d^sOT9$5wy8V2H}6X;wN(3gJPz|ST{97f(gm3y(n;*L$E zrbR6qv&f3HsvEADDI2So(@*-Y%)9q#i(+G6S0=Qeylenx+J9V^S>;@TweGUs>7>mt z-Xh8+hlmBfh(h-iq+H3L&4t-WCkCap{>TVw>)H)WfgW0nW(EtMuk%B%-#KRm_V6VK zGS+6V&GtFofE9@#6ss)Zr#q4^m8#R^hAMYO4uML9?U(j45^zlem|9@I$D&cT3kH#` zoj6pzojyg3kGMcauoPG}l9e?r)~VWPQ~3S1=V zSFCjvxn`zk0OLs}0Kejya;B!+uhyWBdk%lPVt_Mgqs{sO9|fva4|)JSoBO$VKDkfp zWdhaf1|>oW@e**()==v`be{_9-y0>`MQMtENnkY!sRqnD8y?HsybHvi;PXl+IwxT$ z0zsSjV@*N%h;-wA|9kB*ko$8XUdDr=i4I#5KM{3LsBh^ia~7NJ6^~y)D{6l9NU1FU z`jxH3(mBIxmGJ5x7LH={qw@)Z)O3Vfs{UUwT-Ym=CP|UoxSN_ZE#l=8%AEkRE?i4F z!^@K!zjz2^J_e}k6#(uDf?pEwZpZvb2VddtKIh+`D9~v496(%TlTjC+p%<#m98@=V zKO0wx&HScYleM$+QSe_vP+$N{ zFsuw;Fw2&%PJQgE4SuZIzPx|;DC|mlA{xi_od1f*VV2Ang#bH;$6=qI+iv?cEOkn< zvX`+=)K2lRPvp@%ma3I@L^br6u+5~$@Q_fc>>K&Y4#>U6*)l+gbszUiF@Z;mRzO$P zvP@z1x}%m)B7`^ZE$QE_mtm;*S!r0W%A(iiPg=&t0_oNLMCgZ%N8S;HN1+t}@&P$= z)4WcSsSj0H3tOHh9XWpNaup}5y8;9^=B>yg{XPx+{!06=^yWd|dd9;m@0yHA9rru- z0-64~4OJW#zXQM~56?zxSaJMBE)Dp2i$?a26kgZUd z{vIFB?sZf-rS}2^q$(cdhK*x;4#LVB^z3gb3=ppTZ$H?qY}~*JR6_IIi;&?L*(ET^ zLXmRD67qQ=%mXLKO)v+7<97Mp-4xHEmVs*q0|ejf!V{!@H}%%J^Ya8#iFz;kcoL!ExvoB za!Z;~WRJfx4}H|DPP6z68-9?&UmF|ZpsXV(_~<%ctqh)@Gs*qlX`103F)-NvNbq>U z&|?$$=VETf%NQg>&oSiruhJd3s-M-`@8QFHQam{Y$$PIsqB^J11Ni>J#Okv@1zyy| z=HQv}0rUR~N)k}XO|YK!u?~)1rwrFQSjn7&02eiuW9b8ULU^Kciv& z^Zp;jH2?FWKho;7zQb8RZL5D_c@Bg7CXiB0H;TbLq#rcs$#4F~e#b z!>j7a?)No1vUZjTXtT{C)j9Rt+)JcxyUGzNmyQ&IW${Hw{y(_w)7#W;} zf*)+vHMs(v%nH^e2zM@!B8|8N)UR=O1Uw>r1JI#={Zr#QF_Mtvc5PAu`h&HZc<`g- zF{lq=Tm(Y1h!bL&%N_KHzDhFj92R4SdPGdhifPrQxD?(ehfA&AOMsPbGH&&%qX!g4 zeD$K&i_}F)_)&_@ThJwcg{ZwdoaVQSPgqpF{JemHfC{H;JZPj;X9R;^+x^bY8lVVj zJDKr0u;&Z;8lJU#fGS>l7lc`R?t$)g#+9J5o*FFLwqz?az9+N!U)5gRTzed zPCyxFme$yx;Z@;_;zoh^s}$&=|@89DKc8qxNkqfW39+~6^lNzB|({m*|w?b~O-o78yz4<1+ z_SW#48;|kc!Cd{9pLTEMD6gM(3fvDr|0*tcu>i6&JwoYl2_IJ@_F43KrRcG5+FY$g z5Ar5@K6z%iA74cQCpYdfhf2iluifAr0M2;x)IbW`;JZUW_$KMl z6|jjPVIiS-aSN25m}d%f?{v0pUf_B5d+1fTSM0=Wpz`dZqm+T%a`-dLxmuH!<1R}+ z=hMw>5>?V_Z8wg^wHOgpuGfgh%4IyQivTR!7*>TOupjlX4)TI<1q7zoFb+&FEtumu zoCR3r#s!u@DviqWO@_cWyJF{eT)D)Dyo)QRjYMiN<4|ZkQO%DL6yk#acx#)_|9v6- zP*@%Rv+Ik4&sE+kK1ne$UuAwdSS(+7|p)&l*1#D zGb+usLVl>v@&>}jkK)8RoW>vp0$;c+#(1x5KN0(5UyldmBW3WBH@qHHI_UjJG$#f( z^@8(zxXHWSrA?z4r5RbYp!30&q8wXglk@gaLYE-3ZF=mV<$T+5@z6V1Ie??>_!<3? zpKJ!=%rz*JE9_RNcQAZ%u;F#%5IlWM3}OwUq;{6aD7sKjx;~*NK%&4fNY_jDczcDO zOaGu?#B{}8kH=5O4814K4Bi9fL2$)6Y?!1Ac?YZjBY1TVduG;OwQ&4Uo88f@Pi z*V$@o<)J+!>I8Int}4qp?&Hv_6|QV-RvA{fQVc;L0&&TmYXi!P^onuc*x~U$_=0l_ z|7%#z8jAO#t)>mwgi@*8K_kE?R_zQh^T`v>nh-s9hoCp-nPTU+m9mEXJ|+0*A$40j7JkLsZd2+2c32Kv;oksO%Qoeg#h2 zQDrvhwe1nex-emx{(?Yd1jWz?6?CL!>dI<&L>f13*K^CLfkd8o^1R@kT(TZa8Tw)L z*4H7!iU+}MVjur&=<@>w{49ZHq+B6k^4b(6F{9UkMs}KVVEe~kg809)9RKGUKS;1T z>tJTeq5FHBGFM}7iB!DR5kJ_B z?6x~>OQM-7*D|G%GvrWoQSh^mn>R@78QE28z_V=x-Tm(wL9)xaR%1#o3MxMH?9{O> zm52{&>sE&QqJq`P>1We*aJ!nWBM|-v6Z@f15&`WU-$$zO#(C{)B!gb`o^FRLrSd0e zcQ6Nj`xm8?6o2y6_Zu#>dj(hy2S6u$djb@8%;r;XOJ=|Jim@m{4vWp)U{u0H6-b)X z0~FjSq;#n?nwv0iXl42yggIneBCsf}I2Aip(99TM?oG+8+)UkMpevQ2nrt*7Y6SDG3x)_A*k5{ko8T93S37QIq) zx_yFj_d{FJW?E+99d?Gp9D$=D_L@#&nR=Fcq^}k=Nqhh;8OeWN<#3SL(W^9^cNUq8 zL)all*@pi?}dCvWf!ULyV^1eWljow0aJh!W67Qf+A z?l>k1Bs7u3B95b9Ou8k|^a`}Q#tlCRY^HeA$G+5s0{Y$CT)ngWtW_5jAvmT~%Hsj$ z0wAy`KRL`Dzy%_mxtxhDyRC&uR=I<+URsqqAX3_hnr`tU)azFCG%e~e;FDvzxe75k z&jk>@XmXZRV`rQ~+?b{8*GxQjIo){OI-78;LdJdBR9$I$Z$<6yq^ds3=cf|A_fDaV z4{FeAuV(b8{@5)UVB=did`hzbU}c7m zQs@J`?>w@96!H>@T3^2z2~*u-yMOu-25G$&O>2#MSc6(WCevO&7dbA0*tQZ$_KFQn zFYAaA(_PJ}A@(kZ#f&l2Gf53Xt@H7rzsNKKQ&&4V)5MiLwq5S4DOp9NtL%SWN!7TH z?5^UP+|iErTDx{mHQhX$kgoBEX2!0TxH~xHL`#&Z@X|?y{*c^W8!mTHHPK4fCeHX| zsEc|Rr#?+bxWLKbTtgpm-I>&?Zmwf=L)-9@WJgZspBE&i|9NLHeCaP<=mT$QkpyIa zF5WpJeR^S{lX%quwG@r5L|OVw{|B+Y#0n43M7#5x#Za!T>$`vshf6=nJ2x8Up=o`2 zYZP?BFK@jp!z#`}Mf9qxBUTZn5-!;-?KRcvOSW@A>E|qHPPM3<7H^)Qmx(Mz5dhyF zIRjmmiBToS;R#3zeGciejTDh?X@p5LGlWx=Y-nM1YXxC`oko>qR6RCAnnWnJz{71B z2fHQ@Xg3%*-U#hQ~mty6`LAYFu=nai|xelU(twp z-wh)S%z-Mq@Qj(2cXHY8m)z87_S%i>e7(~$?7-!)Vei8G&aB@7-_oZ}okqr5o~Or~ zr|GVeg2qx$QA{+Mr4Dk(>30q&Hv4hfxb1h}fPBz5<7~Mr2tfr^^W3I5S}iBj^st7$ zp!j_Ut=!qXF}*h7Us^rWSPvVkJTfYCb6SqJunKnHKvxr)LCV5{02_{fK)J<&Scn(L zS_IdO({MhyUI%Wck|7BGY6?2rpzB9~;A&AZt_=5pF6!6Jeye#ykYOH!$SZcZ3YvpP zC7`M_d;AkIsP3qPfC0H!1IW(FoqD4f5K2?{!E~zLZyzKf!8y?Iqc|L0k(z!-@eGf4 z?@txZAn!>~vwQ+r4JSg)bY9b8dw8D3hC;~zR<*q3+~O$hA}2+phX}ppnFvUsgLhyA zRdI{_JO$4Ms68m_#@jGrSuf}enniWX)M53+5eUNa&q{Vvhe@UEb1|m8(!YFn}n+ z^9)g4g*9PC%rgLs`spM|Qb3n#;2`f<8OU(fR)@))g&cx{&9-h1nsB;;f;SijNZ?|v z--9_g73AS0y`+{s?Ba&<~h z26!ZdH4j?-(9rG~L9XxpzGo9q(2^mVb}sOlwlFbP-)jq#xUR=CZ6;WE;I-5rvEY`{ zuW+|+Xk{?@=Gc54e*o!Uh^#Vv_fOmIJfm|C9iD3UHOt_E0|#<={H<4hxHQw*>Se#@ zJ?y+qIY(NAUR@6g?iMLUxz>nBX$%N|FYjKVlh?t~j&6`yFU<)0&(3-OKCMYf9`Clq zw^Op-yskKzy1Bky6$_@j`^g$o&hXGBxN$tqo|_R%gcTtqI0b0gSQBl;ec|SOwGePD zk?P8$xQY-7bTYdCm@5|&Ovb~7oLg65_%~cG2R@^nq7Ek8`>g-8S4vAkfLuR4cIl-i ztlYX)nDlF=fiIu@Bt-hkE%P2^nGI!r^Z0-lR767<4AxgK8H3K)l#Q?lA)FNkVm`L! zP8ZxREl}9x1bA#5t#k&0PP`1Mq#Oex#~e7nyxns(C&q8od%p9Ndk_xNo^gF+KBUKI zH&tBz;&_fiz|ptuE!*F8dmKo~iQs4nszJqD5OO_{YG*t`-AG92JgQ=>-VaVC`U-hvGmq zC#jQ?kGZ8Zs0SFdhlCGn)xR*rO{V2a5Dl)RH*p=t21j?#R9fW4p6~rpSxnCRdBYje z8%-><~=_t*&Sj6@U z^adNJFb97f>$n6#U-5M6)f>bnCRG;3SZ`%KMkKvqhWmr=6H!Gf*^4Y5$&c=DzhAS_ zW4MhW#150MPgsHZUv&)**(=yAC7sIw=YlK6Q)JvletO}xjy`LG4Ut!T;RXHCvpJmR-4`JRO)pGn{u&|Rm0J2T!f#p`N=m@$ z_4`;mTGxL!y6d?GHpE#;24A9rX?r{-f=m1p0UpO7&p~}UV>s$0MrlPJg1Ke}*#Nay zfK~O<)5*j4cyMO@9^AIJbMfz=;eD&7LXR=WhFIN!hA3#d5;NEQF+U1Wv`QAv zJL?s(FWYqTF1WD$@&^jN^1Ib~K?lQ2&3t-~xvK|wB@U8C1wYKW2IQ)zL>yP%hX?h&D;7q4xL|KXY<9nSnC;2Ot z5YHcCYMq6yBCJltWB)+W0*YUPbcfh&;s9Q=EPJ&M@p81Ia4eR+MVj#10&mmY(~=tw z@@RjiBCmNE+4O6H9_W<7N-Zov)Mm>Nb;B4gKz=a+3U^G>P&q98rNCW?Y{jIGJ2Z?; z;an(FL8azuJ)VNui}SM|XHY`M7qAD%le1O*d z@)itInAF-=K7a^HljHK{Nf7q-wyX3zX>WO71){W=2WP3we%T;D++!2N8bIQ)eVlO0 z6BiqlI!nw5L2d(<5*`=z%h%0zK7AR$2}3dLO8NTRtP=jVYm)tpiIDOJNNMO~4;J)& zWHH;@756=wa!gkR(#-Dtxgq51n=cL7LjBdxB3O!~CmoYwgnl?$zh+2Lp6R8~hBRM! zF`Z;Qot7ZRKn~dG00C$(Z=Xb@23~2xBnPYrbSv}C>{-~ZnXeH#a-m1RgONKVn%6-` zAuG3nJE_X7uqM+Unn(1-5*@J+J7EK~NVG1!ggS!>vg`*H{p1ej8@wMWX-$kfS$@OeIn5|G-GA9wMe6B-S}AlK1vbv17Ylt~%ZI-izHtxb+HDzPa0V(NdrI zb38X%N`^#VfB0MIaDp~3pFB#<5O25OdNAK+paLz@ zg~zj)&%E%ufc4-<^jErB%W1{&NmUxDOuQ;`I!v}m4@4quc~y^Gyx1>4;zX2g(A88p*J*;{EUMcOUz zF3nqOB%)N|!*D(-gHWc$vS&6u@Gp1Un(_x}CV9VCGd4eGq9JpfyWO=5rZK2~ zdI83l7J;=nySB^)mm72$s2ZYVo`=8$h3hzWY$6$+Ctl|Lmo7PS@fEo-yStaThALY*6i6%B&J;iVe(N-BKvlL~fMhtT2i9Tp%8AzhCa@VZz;t9LCo-WPsQ9Z94Ew0V+xfz>%(5+`?`i)Y#+zhz#PE{fsZ{y?}o zylUC-tEbzgfW4{0+I>;FTt;6rd0Te(NXY^>2LEuheOTE1M)H;7?DbTv2FXqFSqX#l zR>9Q!33HdZo`UF!!v&W42Wt?V+1`;6x2JV9yI@@EZO%cC>Ts!DfbeL4ja zQ$0RIEUTHVN@IlUY#!rH%2N`%p~wv7z>$a83!N#R`-Q8xnhC^Cm5H=aw1f2%2Az~L zi%?1*w~v9(pCnSe%>TaGz%Y>+nm;?$S9+E#X)r%O+-^&p&UJlIf{L(d5T1%{Vq`v0PNUo7ZwhfU}rTBZV(bJdg zt?T6%inq;$E;p8>V6LHw(TxsGChq$Co4xG zX0>#?7Ml_s*|w0|5cO}3uh(Ibf<@X5qJa3X*k`Ig^tuFN8tixk|N8I`uiz@G926oA zCPGHh9c|j?2`3eKCQ*!p|KXNi5PPm)&WY4#RD)7MbjsRX4n}P``?s_|HBQ*C3%$KSnfq?ys$kRGFVD#V0 zKbd!bDUVWv$_&asx@u>~5Hrk$f_e5o&lp?;ODqCv{iRj?k@$+72gU!m>a%@@8=wAQ z^W-S_RqtLiq$=xc`kF;hhYg*P?9AVPd;j5D@PsVPeXzd1;mW-2^x&nd%}IAzaCni5 zNJz$Dz8KO|R_-0yslR6ufu<*gco6zZXDALp*AIuN&8EvF zy&YHn=A6#@zW__NM>ELbYJ1PKr|9`C?q5h-5F2g^(sgL6i*RMxM{4z-ZiNM>4>Xh}RCXCCzp<5?2tH9svj2mUhVA*|-4wrH<&8_N#QY4a zpU>A$-PBX8e~9=o&egTPsmlH=-p#|{it^(UMS14p^%yk;HOa9QdGiiMzQla*W!NrY0kZZ%~I{|(b<{r{hWSURyi7{q@<-$ zx5~u1&5B!@KfP464a->8E)S!%#=%fjnxEw)~W^zQAYA zG1z0yVBEbvo$#*Yb{;^2uPTA*t$#vOe{~_qilg8?uTV`UTP}&(&tMR@C)m`j%-r$m+js1)j4_MMh-9Qd zdDPD-d<|DgXGL+~i&ea>uI3u3sd~hD{D@+c^|A9RwW|kr z(OdHO+65tFN2B`(wez*?#zzW?!H5UT@4em)K-ic>s5#l06u$kxUKx@d=h$kmeSGJY zZwR?t`g2hywz@OxWZ1WXyCGvu%jublkPP;G+Zv2?UCFa8JZ&j?nZsH$%Luate*umqC zg7THlr!V}>Ep?}8OP`vrq<09wy!8n59UiZfbo@M$9W1+zbQ?d2Lj-L^|MFq_+r#iQ zoM_p5&5f9rdi8zI)se7#^E+*Q@+ji4V;*&Wv9-&lE=*8Fv2eV(6uN@{U08Rr{RQ0hg}wx}e((__?TPK0@nB6*|L^_A_S0WJp24eVLvGl55RiSI2n=`d;NQEXMlTVGq0O zJ!D+%ySU*BDT;q|O89^~(J@$e@RD>2G2F>o1+Ok5^>%lt5M<vX{aALnXjt8l@*upLr3t_vnS|%1QLRqb4M+$V_BK z+DpN^n*x zb_asKXE`~I*It9&!L@2Xx4SI=f+M0bOQn}5#5?1d6zR+7t9!(3nSom6gAOY1S`2_`xu1-;?Ff=Db{JNfK!aD?VUV>Jl6LeGk8{Lcph z`-+V7_33fC;e`XEnrh#=qWYksHO;))RZi2pgDTI?qV5^0hs`b~PlABBwDh^^uig;o zkFnvv$}=?~Bnt(V5_xucoLvk<%R|XebyTqscVtu*) zvm8NhpO)c19~God z#hlXz;OAEkBdopF|2}#1j}!3ko1F5SR}QKMvE$zBt2oH|+!i=>(c$A4?8mC*c7pb+ z?spdoKtV6W?V%_b#wJrhZZ%mA^8hvP<_v|%X3*E>2$e=3=_Ncbjqhf{9LZy~_IDg- z@9zMg{d-U(7tTXc6@LOvb7NrM6mN9(1dkM7W8wo{1L*>By@Ua$-Hi^5uZ>yuo4A{6WOEJ#w5VOiV;oNibx?n_lY;?n*{^q~ckN#tK zWcFKBZJ1IVJ{*JK+KC&hUf8 zB@A|Ws0h*9{rM2w$JM(JYbj*wW|K`w{aK&IlDI6juRRnu4^y9t5K#)TctdovUfnxr zs?M}*+h$er1X;{cINI;H%%Vm-W27+9=tlj>4s!F|f~zCU?6c4+q@`rtnb1CCYlQb8 zQ|0Kes$hU)?e7cc7uP-cgxq~Lg_e@f-WHJ*g#<(xeukGTjRkhu*U^H4=ff>A)uZC} zATd{crmoLMFY*G4HA%fB1z*NLV2Ff$i|iFGe{UP1=)i8EoUOtjiD)07tA=mBty5DN~^p4&SP6K2LKAfR387|?dZ54@T+N)=s+7k5+LQnyDf^1aQx>Q{QE0@ zi}#tQ;9)wMo1cSF9>!9e=Ab@E(~}R10K%V}m*8D-mmeUXcNwPyJi2_DSM+g0%8D@q z9a6AvovG+{Vc>z6{z0t#3R4}0Mj2a8M7CTtEUc^|E{9@Q&3eRjxA^xf*{vk$jmT$zov|25({Evd-=Y)5guzMUzM+GLaZ2@7ow~6#=a4Ty`&!+T zs0IVlLqD8D>L&G{tsN{ggv{+^kj9e)k(J>Ib*f@$;NOjM_zI3$723i4zldYW*hX&$7{>^V&;FItErx3mD}Hl zAFK@(z`kQ8nCpOy0UDq6J@Zyr)sAeB^U1-+ccAONg~bTo94{S=!};sDu`0p+YWOX+ zySZQIPTK8SVaE^gYGPOE-laYMUdq$0VP{Sm;)?e6Rp+V;XwwC8$ z>lre$F@}x_1d{p zeD3A?ZvOi?_m9+wTAy7u<6~DF?WNP9vR_7@i|w>bf3N`l`T?-rGTXK+{8X5(Ij^(x z!w^EJ9s@Nheqg|5*>d186gwCAm*`H}zu_A(ANN&VGjjVvEIniBKXVLKk;9PL9jAST9vpV7;G>jjMjyy62Idi>F<7`v&>V ze}Dq}Ek0(RU=2I$SP+AFFI7*JM;@%+Der|}AY&QIIqP37j}|7?Pnx|g*k+&QRWL11 zHyDz4c^K5>BmS(($8-8&X;FHkk*ZSGKW_f*^Uo4$mhq@h2(C&BZQ9Yg?|*w1v+!ix zhO1q!iH1F?h|N&0{%T!JWydzFtH5-v&)T>I)>spDv9cR|gfj>-PHskE250S7vAMA>UTbioRc~|NIynq6MF) zd{68ZwTb<0e7gp|q3#m=aZ~*C*m|ObJ#8Y43pzQEeK>4O3mzbB+AZd~yUDsAAs&S+ z=*qY8zuC-7yyHS@_Ybq7Wcpb^IN8mms^_}SH9iHMkp7XE%e7T=aWDX*A6Th__WWKR zJ$TzG@25deburNg8i9v)K=hMV7?pC;Lg#P=%O1L&3A)9CIW;)T^%%7Gofi{#9Z@UU z!3f_Io;`{f11AN?pPbom0SHZq&`L8P&$2U~UFvLKV+Zg%RSi5BqT?WGtT?AwHES`j z{Jh7S;L7dCW74)2pB-iLL(x7vz#8=)=oa5UYkKnzOgtcPLSRf(g6N&z-UWi9W?(nw z(u2L{+W{W)2R=Yo?pGA4BF4ElHsgG>J@dRLRaVx74`*xswBZb>J}I!yySqS3`W;v$ zRulBt_{Ny%vDlx+c(4$7t{VF|@@zx85=EZ_nI=}SGYiD4*&b)e;qF_7vI11;KoTf}Ula~Dy3nB>8yW#ChLDLDnKl8vH~ zXC?G0fRkMO^Hv`2(isRDDYp%+b$yQ)z5`1RUB@^HE>P*GdMJ3HM4BY_R#+;^4bzs|49V*vS%bdr<&K81yA%MoNxO45|Cd z8^;s@X3aDv_ANen$JiYTL_hi@^mm^yzhdwkTCIC0Dh=$GjmH=;VZ`Z@*1TlqOytVcO6QlDbP!DUB8Tzh}7+*0>U*|AHJ#4trTB=CYis!kMF*EHV1A3b!&Di~KMN zweB{Jg~aFUr3hHhsjL2V#KjSsP_v(^4k}INjVq*ln9AL3yR;=~kmMe6R9AwMe7(E0 zh}?9G8-}g59_43 zW#`q*3q^l?9-UU++HODna^`?saW39Ik+*S}hUc;DJ6CM8tW-pd?UF2HZdbSX%7{qu zm6sgtTW}1oww`q+^IK0q$c}ivMSi{;YtlLbT8l);z@9e%zT!4|^1tn|e!>UHs-)k6 z{+PR-2ai45P&y&T-v+C8eGBXZY*i#KgsDCtif#s;O9tHF+&wu0+S*?8r=GX*a9Gmo;EHK$XlJ4!3fy9f8!0|cWD>Srq!*C+4Jo3%nU7_2bi%G( z$0ub>6uof!n!gCntl?hMHByP~S5Lh+VCge}Ih)IT?Er*0LpZJGL45T`_wr#wda_?R zaV7_WA~@kK&@k&zJ#cmQdD-O3L7c2e*s)L5KebM9wymK`)yn9d|D|MX+F!=Y?90P* zx!i;j8xvKfb~f^&XsqnBRxlO+#D*anzoiPO!xhBcY zXSkK^yTC^9u2XUN!PuEQ8jl*65C4(4P$exoOdNeQy@3A(Ybd~5d~LD}WT1-0hev>x z_znn)Wv55Yt{y>Q89Jq*a6lHuksZIeio)^Rk*13=lBle5fm?dIHC%CK6mzj6yB}8G zF70n8ulZ87v51=&qiFRrYRZbw4v49#?UJ#Mce9 z`p(7=tjJejMt&BAL0)^Ye-IYFBu3LY^0j{Pfa#=ZP>uRD+9Gb~gtUkWad#wd+gO^1 z0)q~Qv@7}JX2oBo#)>7U_OJP^<5b@CKkKedpZ#E@P{nscE48p2pH8-J0xyaG9c z_T?VF@I}ruKibIjom726?8ldvk#5DF+h>7Uk@!e?SKs%#1By(DP4X4RJC5}fHWOZl zhjMq+R^S@hp~+j%SLI!6!m{r}K;DxloT8;TYB~ty{K0R5Ls7+y$mtD3$u3K2X;IrT z0x0@qw_n6CTOO?fEAnqm>jw2Viy2OGoYQVDoKJaeQ(0ONfHM@(QMt{v6eQ-ai(O|* zvR%tzcQE>TCp(!kD#oMshm+qy>KZB9M&nC19VGJ*S1@lc;Kkv^v&JC37kuVQ4^zdu zyoYe!V7>I)-H9ptejl1`MnQkP8OY&QNt_-^pN>#I7DS?rJ-ZWQlEFCNRXv_IQI2K2iYS_p|Y~g=&WP zT}3gEOf*04-_<#c!g*CHB|c4i{+kEy54(9@%w4|d35`_>B~04@1aN`PFFL(pGV79m z<6wN}Ea6O3(#VFhI^P(@?)Uh;nDo()6Vd)Uv@<>&cluTqw3HP2Z*rMmNobN{!$uKy zYg0%JTF}+1e+ne7nR|`e(eRA%6MILukL!^xeYUlk9KaP=5GL6wG{_s%BD6dgGqJ5S z_WC|lv|b`NeVNfUWEEkVr~mbr_42E$4Zats(sPt*r$xcnwS^X6Rd_Q8zf0u=eo4+m|uimmNkrQ}XOm~33^DWr%)Ii~j zeK#0wm4|o!Lv{@RVf+HXLK5<)v7~RSXQp-oErA)CrQ0s%q8Y=T2Q`CSp9 zcD1-N!Fj@Zj^_Mm+tWntuUAVuQlyQU=lDk$by5bY;E@wp#G|3)i6MK#0H)AeY}%S*JggqGpT7Y7Tje-@ z&rX0F?=9Bu(k3>Qt;*!*-AD4U750{7sBJSp2Dni8+Cq8E@48pUe(a(I&H=pPX*GEU zcHY|Udm2=*h(K|^J%Gw$DUruJyvgr1CmO&E#l4pxi>T~wM1q*3->%&r%XgNwu;^#} zV06p8vef{4%D$>0v5A2I`n>KAK!)nuf!tF)gCWmVbw;YWDyCcB^~ppYP}jU^5Ww+U zXk+H3nGJl$a?$7}4RFys1qOP5$88D0(TpUfy{T|#?&rRPs6@`^FZN~sh&`ULowqY* zbI`sbs%KuGD>VL~`TEOfw0KU*EC@alN^8d7@fP^E_nng}QgDJ8 z{bQ)T$MQe9eL8$s(Ai;dIL%JZ2c`S@Sw z;*I8YOJjWQSodYz+`iK2mHrT{{p?%_@O0bY1*QP~Hcn$fp}%JeR?kf)T#}9TZ0S{` zoi!d~pBv^+RIa%6Vb|`k5YFBo=e|o|GwlGXPyTzMB&D7|k9OvM3vq9|jLw{5NgWXv z=a{Fj(s)E(#OWIJU+ah^db+WcJBPX#Uad=<_marO?Y6IW^|-07{Iqpm z$UcvIAdI{#7$dCXJtuPl4u4eiqC`dR!Cq60!*gh#`=md_d(; z;mWd_jB33!zJVu}Fq#I8u$DVO6&PtxO#z9A{8$z{X&T=Yh`^O zP7HQ_*aH3`&gQY_-lDY_?hI={IjzZuSUvzi_&QxbEEY>USe+ftIYvT)zh&q)`k)pl z`NsswZu^2^k9g<1pgn^$rPqypR#gf%ca4yMS)poTh{7~1!+McC* zS!|h^t4;UdHzbveiB8r+1gpHBY;#-l`&Y^U1X+K*$t#@4t>M-AdzX47cA6=%)ue?h z{;dA?oy?Y_=XKv6f>4h6rq)lg<@XI*uuqNYU-$4tn=Z=^5MH_8^3-fcNZ=av2B~Q> zKz_cTO1t+>Upw~ZLVvvpTyvPq_O{Mdjt#|st9!rR;mAeowcUdh#8v-(V zqM^^X!iC03^t6x)uDJLKvh8i!!RvhhI(aGqJf*UWj(dshA&wxww`we14t4}@s;&4k zZgoQAPr`bk0H`DBb;T0Bj@V0@44d)-ue4XmZ`)c(1B-m8(gsMH;JGUKtgJOO^CTB0 z^4hZOV}M=#+m-0S76Gi9I(`H2x&VQtrjWXp+0Qaa7l4s0-ut@=ZE_kQu*6E--8o$g ze9`o7Q0HL>(*W&xSZi&@Csx6ZWc?zslJSpx|2#$8J!XushHtPHBh{yeQZ_jhd^ZZI z1nN%FyZUBvU%K;nhB3iX!43!iROHEe&`?MHVk<5vZ}&&Lv9l**d+BaB|GrL{4#p$e z8epYp;PIu&Lbfq7+eJmpgjA#V2L1EDTId4&3b4Xzg`Qgg3?5A?T~{+n?EnSx*R??P zZF|q=2za931IUtN|`G7Z5PuwBh z3hHzUGI@Q!V)blBa$FR6RC8GTK8))UJ2IG|+XFB(#!A$Iw+vnS<^DLIX5&DqR>D8d1RE8$4Ec zu|!4WP`$G|Zm}zL?e}JkUzTEOdHG`-Tj7P|!7<$jNK1;)X@=IleyfF#unl@v`Lvwp z37L<5%8Ml&vVv;q%BnE~M5P!0D*OG}3HkelARW#O=x*OA;_HZ6^WH9(DhpdcG5P?Q zoc!<&*eXQKP&`nfYj;Hg(b=5d0`m#om?Cj0j}x^10Scs%@kEEjp8h3!Dl#GzKLtp#pGrXN3=~Gz&Ik#bMbKu$4 zs{nOBWm`CyS*%wBEDy5UM#`Vk)ym-Xa`J`|gHL$~`8(9fJbVV!bQ;p>Ww6B;9#j!lY4(&sVu*$KQ|M5;@MEQwX z+bg-T9{x`NcJb70)I9SMvm%VZ_Ai~L!mtBcaHLb<1NBL^92?c zm^2{6?4@_-LXti#_cR?pk!-&p285%!z%5AV(poB;)Cvqf^BWGgZdGM>*liMK$-AMr z@w)eX&rs$oV{9jyYtC5;-=;JEXuA5byt+hwwpGnhae6Q2$=gVr9J zQVI#wTb&?+DF#~$F9yfO4DSXaUwm}gjL791T*;k#R{pNn@(9gl4J=bqOvu-1XwXl- z&w@BU1~#m8srrj0>v@b= zq8*IJOdSFypfrPhHvdP2EGkMvg$Ui5Bp~l|Jg9@y_!dNa6c*=l#Gqyt%6lT&^IKfs z5w@AH`nV4fSYy9g!scX+JDHLlet-?}&Ga3Rx7;llK*lk=WE@!kMkw>pWD^jZs9gik zidz}!U}~~o>pIGl=9wUdUc;^lOQ?Gmb7eA1pke;}D2G{QtYe)x)7H0;q;9g>HQ49J z&)>O$90_S4Im2lzUu`F};8+LA%tK~xA^#5YWnSV+CXZ>q5>7Bn8S1xpop=!nU_n1D zT$)ci{a?8oWZx`A{y^&{-{HmdO)WqHCre{c_6!WbgMm$^3v0N=@k5jVPnDW>fhqZ6 z6g{HjUWhHpgVln@HM}0+I;)X#m!}jn>TIs`N<1)$f6@}}vA$xS-#!Vlvn8$%x?PPrSTYxJ#xQwfZ0;a) zFS}Mj9eyqt^tGt=-w0!Z3Ydm6l_?^ARGzllzJ`n)vRb3IPv|-*O+6JQa%zZ?FL>3* zuK5g{Ybx7WkT7foX7S2qs&<3MAN3~nWyh>XrSX3u`rc}1c2r`U_Hu7X$lFFfD7DmV zl?Zj-RSn)ed|i*566G1cx~{4ABvt z6_<@`xv&r|HN9u9pqZ-;2iRKP)nRN>n(D1f<|ARz@hSD)hdCP>36HO1R|ptf!iVsg zI@-BW+;SVhMx6G77n0HOM4?m!NjNXkuEtYfb%bYG%8Tv+P7|^XAjvX2Cp_a8h@PH%PVuEI0S>q8=()#hUvYvC81QS{%F6FHQrcXoT ztoKKaXSbje$pbr-0%9jYsaaK&Se9e~d+w+iPp14fxDEzb6`?+r6e-Ev_KW_+p!H)v z+TjE29a)1FiK1Yu51UO3<}LBL-r!zTcW|*p44VVu(3n$$MrU5i73Llf&@_48bV=k& zD-*Q#ihL`}+4Vw)kj*0Cso9MEXQqCKjG`Tgw&xqIUIO-%uLOS>CXj`jIdOfsXjcfYVyc{;EeQbwpj z@cfc2ZdY?v(k@+!%Q-pRVycqCUz%wOBG`bRF7QRrQyCDKOaQn7Rt!K^pcfc+h&|-6 zHHTSn0ABLHz?cgkYP%0Zf14=rKu=avByR6(ZTHAaz?bJQ+MdVi#tzN(<*|uOV7qh$~ zvDYDhgLwkvTMvo>apGNw@Yv1i{$f-4_I6MlV=xr;;&qUQ z`+l~Vik@R7)bG5@voI*e(!USNKvZ}W&l9mtIzpF+ugc@BkFY(h+X46Xx;DU93{&z# zuA2Yw!O>}PEZr)z?l1E;1yC!I8jo?pec^T>%~De9X*tHiEi&Z71&4M)P(t9>9+9Sb z-d6YJ=I=y9wgXtRE8Lr0KhiGk9zJLqbuDd!r=zgYxW zzdk>;w`ac0mG8K2y4X#a4uS^nK2kN;jr0n!^>qhob?L2<2Z=kNB$)? z@_SFzO!v^S;Cc3?WoCV}AAaH!>m0U^3e_)THmSfvW&5v(>Ufc2TtW4p4_MGBC$0e5 zRZT(nx6GSd5Z?NT$cKu3$ERBQ$@W^n6nWrjm4@D`UgNc2B>Y$l8ni-y`6M#s(R6WY z0z^mOfwmxGd=lW!dC5j{W8h~S4mPE$uDEv?-A10xf|rL{R!2*-hq}hvR!K)8gS()! ztY@GlxyqIQ)t@uh>jX5+#>x`zn+A6Pa#1-;m=UtC2IIt1NFBlS6Dxk)iUd5}CNK$s z;hjFrh8+|tI6&jJ>d~9W{xc^BA|KtL0$yUMA zWK-BlO>A5ipRW1x>_HNTvST8OK`9=yyT76aSvbzrqP+2nc@|q-%o_oKfcs#|0w8l; z!hM#DC;JJTRcfhaiIrT#mJalcam%nJulW4yv=-V^fmii%(w_C7KHIQ4-<07$JyIs6 zaIml%UOd*(>)e#2}1$G+&kLwK`Dn0-!WeqW#1Khl*!Re!6I9kgVOY5Em zp+~RTrAe@`_{y%w0!)M7Ib)qhAqV_Rd{y%wwWlkrgU>C3v0AE>7_cE~UtPDE)WD96 z6&9u?JWwUh#r8;H`Aad8v{dt=*@LHvU(_TL|D>J1ij?_j@vcAXdLT>+p7vDpt4h!p zIn9ClYiF-nHEk0|91KdS!wdT{?UM)LS|0_~mz##0Zp5-?ub4|S{*$)7it^F8z~KsH zH(aMgSiJv8Jcuo9r8U`!L zA)c3R*mwe~!NBI0GWgSA6OxH1XvI?4d_YZo7DSv^0|-cmTr4h(<^Twfv$CCq2k%(Q z3LM}TKo(M10=keF1xicujA3Tf1~2^+%)!w30>R}g?h}qdd#G`VUByQEtg*-> zo2t%B13-Rz-I22*E)C=~an}IM-y-N-CupV0L^h&-07`6Le#v_d`8iNPCBi|1rd>4i zThdWG#o>=5<$^Z^z8-@TFF7`8SiEpP7>l0akh>7)ggy4bn2VdfM`-Wfig`-EK>&{n zLjk&YknEEp1BB>OBI{-F06ya5BINbKc2_`?j@zUkW;<-c^C>+p&I?d;XTLUGCRW1M zl&~}xMUlpxlRg8o2^8q{y#?7df$T(*&Qn>E(SKplf`LxS<_t>(*@1)p;ATobTKnel zxKJaajvZRkfWcCX6B->s1!flPnpB`*7<~llF3kty_BY(dA&1PjCrEhTH2yB47G$zw ze#CG-t%P~REmB!HqI3D?u{=(C+G<1O`g@HK>VxCBOh;p_P-M6mPNI;-PM-1zz03V165+ zna&bh$j<4#H;3RnWF>S#JPTs*rzY#@_<>bpUAt`})2uUQS(RPRHK|c!S(~jX1)huK z27wmt%KGhZc!hD;zUXf}5`Z(RNLI~dNJe-_eL-v0<=>~N@}{QunFsL%5dCaRr`#@9|6=D$gqAxLJsnof@l`=C(rlRO zTi*!VM1J4unuBOZrmS|_^VQ{@K2IsWimL9zd|~{2X*^34Mv3GbL$!uMKJA6clckwk zb7QpigFhNAqpZ}QeXqDBs&jbCa-;F_9rzQ9_GiXAHZ5~&Vk^c;a9iF=oIzxFT*X_# zg@0t6F!%*hqa>eFx(zm+Il9J581U9pzdc0QOM1b$b=$W{_n9rNAjzi2J4efWiuth= zqHp#&MUZpHpR?_$*Cw5dTZbV}y;!U4*Nqt@4}hvezVIT#yTiRhZ!pML+*R)(m;%+x zkw3_C9(R2zifnm3!Ffo=a)k9Rm29g8b5VB%r@Rcie-X&i6*btCy*EYu&aKqq`F*g_ z20z4F#I?+uw?(Ncq1HxH0mi|6mARe4b@y(twArX37_pI72$)2nbPWqATbvdmw# z&1q?}G{ZA#4)IR2quFB(_&takYZ;fiQe(!44JnMq*UEb8FyaiDqL{< zIDAq6Y3A&my|;37OPHY(=T(Dm*wg{(*TviZWD!O(P%KMrB$#{zx5vWt{4kks_xyI+-4grmtFHZXlj!__O* z*~(OZ?Bhsm{wU+nS8QwJ%~Mhq-6;Z1PQS zMCTaWHi%txDNg|Jk0*{ZoF+x<7=Y~Zgww_NX;_1A@DD8VYz489b3BN<4k`n;=jqF>yQ4{rr7 z$tqzLb10%X=lJMUOL;NpU|4EAUs@!VkbiILx{1QIPJa{)3}g!L45kd z!5lR$=T6w+*on{8(}VonJ1y=XP#skVb(Z;JYcl6>$X=bpWj=SF5PRuChg*h7UPF2> zkLno-4Xq94!9LK0jOSE&ja7>EcQQ708?1gvKc46+xk!*C@J__zECg|M$@$u$yYG@a zZxD;T{;?`%MLOwclamvncKu|<;m4*ZY4n`5FX~GQ>Pvso*;r8(!42nppMcVVoeK02 zLhLL~de2e?zD*1dSKidRd-iKgps;oz_4Pw~RIs z;O%3^BoNY=J!z&P)?&mV1mW%(lXjt7J=mxJFzjq3%Ylic-!-Q<4^M5`+iNK+)Zp(dZb- zwypeK_Mjx_>5$2Ui8;yW zy|&Vuh*fU5h8N%QRmh!PDufN`0@# zmZHo2csgbv1*MTKy&r_`Spk7jtV7A!pz+zD#@2XHx`8?(+{Kjo|xdXUDsK+>I)CI+e#PGu6?L#W?Ir&6+G6`g9*A`X0wZtCSo+ zE@@rPs~=GGz*I>ef>nzG4^I0Y^Px&I!85ITe%9k)TqhIf1@?(~nwTXltg*(bz2XH1 zEO}{dGXvw^lSZb~#$e^kTtyO1yCH46)E9jhbbQ$*uC249yS7fd`bke4NE6ZR&||)A z%n=hNeQCB3vcP3rC1JbkeO>H>0ls1({D>MetB@R91lh2Sm8*Ap0!PQ}@IP#UZj+)L z+be|Hw?d12k$KtRqhStLVtrR)tAn!Fj;2oA4mFeJNHH_gKBzq?y4%<{_UD$kXa`Ss zV(Y3rbYGs2pi3U=n9)Vvx!{T7f-V##&g)lO!izEOU|#x45cDXB&(0FMv{9FRh(g4@ zB@+H)MKK08^yg7gSYFQev-TATdTY@CB_?!{OEgJ4y$~8SMdTdAL9Bc1eaxvSGF-)U z+ZPELNMkkbE_P6~p5C%?-Zf7;`Ajb6H8i8!s&vZ`hFm>vsi;qX|Lo$w_P2jueShwH z(zx}faX!|T-seVqTVLAh;MgQ!ge^nVlAH#{V99v}u8xOlxr>nM&k3e>b(OC5&DyN4 zWk1{qs!I!jtKn!TEb;EL!MSII=Lsi*8sKN(QDh_7Z67v9DMG&-XHE5WUHi9P_UH=H z^7W445s0)mf+CqSY3hEebU_4zmZJQqu(M&vtn3z|@~VnbuQJ3vb?@tQn=A}= z>KQ$eV13_+5ALMNyH`Hro5o;qjW(Zx#dbL;@e(=5-w%>kGAv*Fa6J_)!t{m}`90PR zDkeXVr&*>L!tLD^UxdIy29}MimF5F4{o9TCKW}?`h2qm%=J?d9!H4d$5Tvw5PWpJ| zn5hJ!s94&`Kx?B>b}63FnNVi>2@cqWr6H*8Ms)7+4a|HBef)(xe=Mor?@7_a>2Thz z>nsGso;*4%6N z&yq6L{gGC~)qmeqtst#{+L)Vo68RY{UDHdA#?&&et1! z#bZ!`@vrtV>jkle%Wb{^_w|bQ*q?>$Z0LL8|Jy45D>C}+OZRhEY&d9xx%1L(j@BC0 zhA36;E>TN*7=2juQ8|{(&PzoN(WpNm^CG%7MvzkQp`ozU66acEk!7;GWf@RTNY3f-G{e5(HasL^ZeV2c@(4G!MJ~ z{f-qGMhn)jHuVE|BKj1h zj1y_H-2Zm}et$|UFZk^l_xjH0B;NUopLNVb&*G$=G=F+Hr<2%S{~eL(elICg z;q0FKwTP0}Rr5t>K}92l+6>1|3@7HIqo+x(vq~R_ZWN3lEd_)q)3v8;zpT(9P>;{xHv=MN2xJ+!e=EjA4-4zd~RML)_D5NNNO7q>g* z3yV{0B>2?^T`Pc7WxXpl?lrj{v~fIGekbDY5x#%Z0hD}opoGUEfC2E?413j1Iw|Tj zY5Vs~7=HDa{IRCCrHDF^ZT8)g-;W~DtMoOtb~Bg zIf)xb4L4XC_Q-ohchK*48VIDWpZA5o_VBr~2p>g712A z(iW4w$i`BF;No2H!{{u;Dql>;n^sg^8{3{Hy`)r}V@XhFQEB^KIA=xNK2l>c1Tx+w zY$e!^2bT)5->{)%NI;C`OOEft^8N4p;*R{eXr?8}+9bD^a-l|>JjWwwukt02tvx*0 zJNI#TH|-RXR{zn+Y2Xa)i;aTCo0zt#VO7{jw`J|AeP^allrf896^K?+njS5yj#3W26>3b~Vz;pqypcILh~Sq@F{rtC zbdbiSQ~4s-I-EPxu=V?+pka?1lQr>vpAs%D^{hWX+h3c}A8Y8GpNJwevOyzdV(X19 zsp0y;cw!`v_q?tDGs_a;(&sigI0M==>fdJf0?mr*Ry_^XOpEF}b@zrNJX;sk=55u! zvqO9}a<8=Pef{Ck`Tw!@-ce0&Tf49Y#R@h=sVZWjh)8ciML{Vd(xfFSLV(bbngm2t zl&VNkN}?j25GkQ2ph$;AS^|XLNeH1O0Yczr@AsVhzGrW~eeW3e8~&wZn6Z9y&H2nZ zpEcLBQtVm|@T*&T8E&8Oht7nW6whRou3YmKNsuI(~Hxc zzN$NYjMx5u;`m>FJV1|UOst%VcQdxzk&UW9+Gp)i+O%#&?MZPOiJT$_ocu#K9Xs z6-^I8v5rad{T*dOjo)2I*DtMJVp`&atV*UWbabL#xzB*3!xJB6cp%;C-FE9K z6sgUVB%?b!x>fF%+~b^oxXr56yq$9Ytp)JkyX*1xIe9k;Q0ZD74nIs+tlwa<<$?Rq)3RaMGd3e{cT(G&(mzdxu|CPS1lfbDL%AJfzkbc}u+0^}U>1Q3D|Fs6 zn!D&#tzy(edJnRFYOEX*Zeoi+vGy>9l-(}n4G&tkP;)r+9|gky{6?+=2Q2gGV(F#+ z?ojH<>rYz|sMz4Wzx%zr7d)33t@`Ko^z>^#fQz>0%wCz^XatVln9jPP$vkxgHgf#v z$l9BkRBf~9_TJIs$41unFE0Ee#>)N58veC}1i3xIME@xHg8Mr79gPMUhk1zO7qPIU zsfP>EdoJSywKkU_y)X?AiAe%`c6wGxd3iJLCk%#@%K-?pM(@%=1yR!fh^(^5v!TP{ z5d=YRf-Ng)eX10SKsom?r;BB|q9z272OGQ~&4b?v{H0bJZjBk3~LM`4;$m3zeu+ZF zl!beyTYR|$;1>$CK%u_0d%%mdK#(UWTu{UV2#e%uk!Gc2N02DYi;XF=TNYI2QwM9> zZjW|<2Ez#U$W<&IT&9gIOLFI9)(zfH!1n6_FFzU)fL*xuMuwv1*xPP-N_7Ty2x7KP{Bzsc`-(uZ zqs?JSDsK&KUij?%P{k(%mGkz=YDx84f>)c9YZL4)p!to5lXW5mxBKFM!^X&u{Z0h3 zUkh0*a6S$yTvW&fa_kzR$rTUX8^%*dxao}#z2`?R$U_dghSu%mO$&eH`S1VxANc@% z`w`~tinT9u>7)?~Z=v3*^+T9wnhBjYg^RA;)?+@Eo>@CKN_nUqUKTyzGdGD`h*b1e zwETx0@Vr*y_yTmu-J`lHn#1v?G4zDq8%5|d-&>Y#+zq2povuAgvQdeY*F>t$R3DVP zu?-MiACWzJRrHJS0d4*1*T+ilyB&5n_z>nPSm$wVU%H$?c6iJMgH~)yF*JhVU!lXY zey&ToV+hn?74r{Q>w1O$9h3h1ldFw(<&d=I-?q-@lPJMtC)y&&4b^%p?u4SC`oaOg z?$fvUDBWp%BL1J$xT_Q1c6B>;weqOj72%hPHdIHl4gHCI6J?>rn5M)*xVT-<`&)|P zX~>Ds#f;{be+q{kyJC$;2F7tKm6*;v`bizqiM z*(W~SCahL@_N!{eNBpd{e^l^mZIj&k5U&5_ojd!1(lRxvnOW3X3$-Pmc+!7XnJi<>$p5o_%@Uan= z!JBZFaTG6LVgLRGw&brm_Qi(cS}-Uj7Y;z(gOu%2@e{`NfMh++8ErV;4a>sc7@8yY zD}9J5@`uuTuO#vTT6 zq}YiijR>w@&(pEhIhD@4ep??4oZ9NAU1^NK&m-b{#2gZS#u?A4wG0t$(C4qE$xKiF zzT&{gLMp7QRrf4?FP_+%t5Yf3_s>C@a%RDdQQA7>KlGd=VO(m$(h9+KSg!ib5Bu9H zL$5e4<`$_-Nw&ExxZ#ED&*6KL^rCyDW8*joNOtsV5^m8?iR@xSn zLZ4s%?e#u8RP6{ZeO4(_Jrgrs_o=J2P_OV5l?x=UvOl^lVQ0ZUsvJk=3g7E93;{5OS*VQPq%EY$xr~bf?X(c>PPtRY2Xe_pDm9IQJqiJwT zd%Hy~CpG)mpn4sWEn-!sPr zQP{J&G3Or$*AlV_fQ=gKD7AlZ^XnsD_I(5TQG6k>8c83CZWlGl6a8iJKm?LGj~24a zDR_LAD>MBAEMomCW=<^q)4cLha_M3Hk01B-}f36gD18iS_N)(G$|v&@beq;a%9lmAMi*GAb`8k!)zC@>{TU z1OGqmvZu$c&Z84Z@_OX0p=H)#1Af%TA6U^J!+wKl!gyUc$lN`W*hAgpzhR=p)vBtg7CRkX$`+ zHT5h^HY>n)F=ZSE z(Ng<;dH(@H@|N5oOGOE2%J4POJ`^rG!HT{dIyX!9ZA{;=%JRql5|+u8mCpz+7fc_0 zTKE5(8Gc*@6sS)fhyEf$=3`9373EMb+L)?(1d17t!aMi7;8o6aZ~ERwVOIhGr{TrL zW^h;SJ&T*cloRihhy@aFYZN00+5q;#7G438$N2P*2c7eJBmH>*R=y>lnt>J}Y_pUW zOzvw7E~%k65}EyZh^*Ag-1OXJD)@9R+AsS3V3OY3o{O;!%(>Gbr7VUIz+0YA?Ows? z4e;@Lp6z@e_Jqg#xa1JC7V;6DZ2Gck|iHetkEy?*~S~g{5rn z2(9>6sVN_$y`bmismJv`>s=$mihT~o32cXW|DFsux{KuD9lXw5Iy;S#Tl~&htX876 zgJ?J#Kf0~yZ`+xdmJ6pFZavFtsLt2GOE=oF`@Ka{!tjmnb@y+#;OeuxN;4e5dE|20 z^*EG7hZW+|_u&&%UOItDs_yB`app$Vrn^ex+nX>Dw=#Q^QHP(_T>^u&wY1=no8p51 zu+-KltTOMDjZF<}CqV1>eIsA>K6jm2KI;P~MB* zd-nu24Vpz4hW9>@IwvE!s>@R^B4s=92P}bN)R|m-mr8zBJM}Q1@fwHbR%Rz2>|R@2 z8LivW5ck`3G>-TE$Mv1%`6ei<<*fF7qm*AQE4YeQ$vR@hje9$8+y{M{#|_2*-g>6v za@3zseV9}5ZkuW^6wZ@;uMzM`IDIPj<8ozTyv9p%CCyc7aA>AHRMLFT9FW$~-*Kg` z`bOP-C7oB1DM2%L##>Dqak5!ALK;)-FZZ8jn!i`~g0ByhHLlIKRp- zYA~as+Mbr>vzJfZU*3M(R)+WJN9xqv9Y2*(QH;om&b|lWz;mTtK zkg}H}>JcOQ#k-HyV=j8Pn|L3$;S;*W+MwZwrRq=pWAOPMe}`BGBRRu93hE3*8Db^# zV-T^_2YZ?jhRaydw;#5I(k-j##FoJl@27EE)|J!*w`W`*DOBfH@PO?q_~*467avvW z`;}HQupQ>k)FRq)>lQ}PUNYRzL@y_IVwFWl=M7L4AlMMlc`P#)@GflzWy z(kayWqDa!vCahrYnD%PaId%{UxTVboqy=dhi(%IlLJ@Y1<))g%ML;XW_TEfueAzG7 znW5=8%h3;aS1ngpZI`5A*YA2FsI`76Q$J29b3ZV{hg!W7b~iew`wjSUFeZ+KV!B@j?x_S8idS4}XGn zD@gZ4CNc`u<7SFFU^(>Jlq5$*UWBZT<3E_`{_#uvsv%vcT8DhD-4b%gI_}1m0ACS25u@t;J0p$ zgfE9=U0h4OzTVEn1U2<_eUF&TZ@*{(y}w2ouR>s}USI2&SoK4Z1uht%2Bwy*ixO@r zB*Y4-&I~+yymPyhQ^NC2%@j!7gb7y7Up2=pYBm1?q&x~I@ zucW~aPS(-8S4w@*2(}s88|%|+m5iO%WrQ0PuM2}kyl2+x7{7gx2FRT!{{iL$pgj2D zi!ikT*gP4uULVNYNh(FNyqeT#d4p+#)RoZL5pt>zP&k+u8 zXSRO@<-GoNKE>Qi&= zN%n-r)^#?HVh_eS>iX~3i#<$`niRGR z#}bKyoIF@?IpE%tpOA&roEI@K#-u*XS6@4nHR# fb6FCN{K7WP3*P4CmwGVty#8 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%0Acyj_Z> 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 z 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%0AePM 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%0AHIz!!NSe`rqM4k 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®^&gr2b|d^_2lYI3v<>W zH%@OLvPKlSBdGYeGzod_d(IXa4NT{mb~cOcVoWAKWpA65p}N=maB<^9QWp!(*pj}~ z{*lVBkp^SB7?y=DYW)5lutLZc-*s!pYMe >wJ4e6c^gmb7_oM^e=o5m5gDdUb#k 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 Date: Tue, 27 Feb 2024 09:29:25 +0100 Subject: [PATCH 133/234] docs: [TKC-1208] update licenses folder (#5063) * docs: update licenses * docs: review fixes * docs: update license conditions --- LICENSE | 29 ++++++++--------------------- licenses/MIT.txt | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 licenses/MIT.txt diff --git a/LICENSE b/LICENSE index 8761fc91e87..d37673fb9db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,8 @@ -MIT License - -Copyright (c) 2022 Kubeshop - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Source code in this repository is variously licensed under the Testkube +Community License (TCL) and the MIT license. + +Source code in a given file is licensed under the applicable license +for that source code. Source code is licensed under the MIT license +unless otherwise indicated in the header referenced at the beginning +of the file or specified by a LICENSE file in the same containing +folder as the file. diff --git a/licenses/MIT.txt b/licenses/MIT.txt new file mode 100644 index 00000000000..3ad02909079 --- /dev/null +++ b/licenses/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Kubeshop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From d9b2c094ee2e575ba97d73833445b0b9f1a9c99c Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 27 Feb 2024 10:39:52 +0100 Subject: [PATCH 134/234] feat(TKC-1465): expressions improvements - resolving structs, improved finalizer (#5067) * fix(TKC-1465): recognition of 'none' static value * feat(TKC-1465): add helpers to compile and and resolve the expression immediately * feat(TKC-1465): add mechanism to allow to resolve expressions in the objects via tags * feat(TKC-1465): escape "{{" nicely * chore(TKC-1465): avoid unnecessary string computation while resolving struct * chore(TKC-1465): rename Resolve to SimplifyStruct * feat(TKC-1465): add option to provide extended accessor in the expressions machine * feat(TKC-1465): simplify expression finalizers, add expression machine utils --- pkg/tcl/expressionstcl/accessor.go | 4 +- pkg/tcl/expressionstcl/call.go | 4 +- pkg/tcl/expressionstcl/conditional.go | 4 +- pkg/tcl/expressionstcl/expression.go | 4 +- pkg/tcl/expressionstcl/finalizer.go | 67 +++++++-- pkg/tcl/expressionstcl/generic.go | 167 +++++++++++++++++++++ pkg/tcl/expressionstcl/generic_test.go | 150 ++++++++++++++++++ pkg/tcl/expressionstcl/machine.go | 38 +++-- pkg/tcl/expressionstcl/machineutils.go | 65 ++++++++ pkg/tcl/expressionstcl/math.go | 4 +- pkg/tcl/expressionstcl/mock_expression.go | 4 +- pkg/tcl/expressionstcl/mock_machine.go | 14 -- pkg/tcl/expressionstcl/mock_machinecore.go | 71 --------- pkg/tcl/expressionstcl/mock_staticvalue.go | 4 +- pkg/tcl/expressionstcl/negative.go | 4 +- pkg/tcl/expressionstcl/parse.go | 20 +++ pkg/tcl/expressionstcl/parse_test.go | 22 +-- pkg/tcl/expressionstcl/static.go | 12 +- pkg/tcl/expressionstcl/utils.go | 2 +- 19 files changed, 512 insertions(+), 148 deletions(-) create mode 100644 pkg/tcl/expressionstcl/generic.go create mode 100644 pkg/tcl/expressionstcl/generic_test.go create mode 100644 pkg/tcl/expressionstcl/machineutils.go delete mode 100644 pkg/tcl/expressionstcl/mock_machinecore.go diff --git a/pkg/tcl/expressionstcl/accessor.go b/pkg/tcl/expressionstcl/accessor.go index 94ef84a0a1d..1e1cadf0818 100644 --- a/pkg/tcl/expressionstcl/accessor.go +++ b/pkg/tcl/expressionstcl/accessor.go @@ -36,7 +36,7 @@ func (s *accessor) Template() string { return "{{" + s.String() + "}}" } -func (s *accessor) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { +func (s *accessor) SafeResolve(m ...Machine) (v Expression, changed bool, err error) { if m == nil { return s, false, nil } @@ -53,7 +53,7 @@ func (s *accessor) SafeResolve(m ...MachineCore) (v Expression, changed bool, er return s, false, nil } -func (s *accessor) Resolve(m ...MachineCore) (v Expression, err error) { +func (s *accessor) Resolve(m ...Machine) (v Expression, err error) { return deepResolve(s, m...) } diff --git a/pkg/tcl/expressionstcl/call.go b/pkg/tcl/expressionstcl/call.go index c385be70fde..f25eaf632a7 100644 --- a/pkg/tcl/expressionstcl/call.go +++ b/pkg/tcl/expressionstcl/call.go @@ -86,7 +86,7 @@ func (s *call) resolvedArgs() []StaticValue { return v } -func (s *call) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { +func (s *call) SafeResolve(m ...Machine) (v Expression, changed bool, err error) { var ch bool for i := range s.args { s.args[i], ch, err = s.args[i].SafeResolve(m...) @@ -117,7 +117,7 @@ func (s *call) SafeResolve(m ...MachineCore) (v Expression, changed bool, err er return s, changed, nil } -func (s *call) Resolve(m ...MachineCore) (v Expression, err error) { +func (s *call) Resolve(m ...Machine) (v Expression, err error) { return deepResolve(s, m...) } diff --git a/pkg/tcl/expressionstcl/conditional.go b/pkg/tcl/expressionstcl/conditional.go index ada1e96558c..69b6ff94b3d 100644 --- a/pkg/tcl/expressionstcl/conditional.go +++ b/pkg/tcl/expressionstcl/conditional.go @@ -53,7 +53,7 @@ func (s *conditional) Template() string { return "{{" + s.String() + "}}" } -func (s *conditional) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { +func (s *conditional) SafeResolve(m ...Machine) (v Expression, changed bool, err error) { var ch bool s.condition, ch, err = s.condition.SafeResolve(m...) changed = changed || ch @@ -84,7 +84,7 @@ func (s *conditional) SafeResolve(m ...MachineCore) (v Expression, changed bool, return s, changed, nil } -func (s *conditional) Resolve(m ...MachineCore) (v Expression, err error) { +func (s *conditional) Resolve(m ...Machine) (v Expression, err error) { return deepResolve(s, m...) } diff --git a/pkg/tcl/expressionstcl/expression.go b/pkg/tcl/expressionstcl/expression.go index 8facbde0df2..516d5c235f1 100644 --- a/pkg/tcl/expressionstcl/expression.go +++ b/pkg/tcl/expressionstcl/expression.go @@ -14,8 +14,8 @@ type Expression interface { SafeString() string Template() string Type() Type - SafeResolve(...MachineCore) (Expression, bool, error) - Resolve(...MachineCore) (Expression, error) + SafeResolve(...Machine) (Expression, bool, error) + Resolve(...Machine) (Expression, error) Static() StaticValue Accessors() map[string]struct{} Functions() map[string]struct{} diff --git a/pkg/tcl/expressionstcl/finalizer.go b/pkg/tcl/expressionstcl/finalizer.go index 1a895d27c01..b96b5929882 100644 --- a/pkg/tcl/expressionstcl/finalizer.go +++ b/pkg/tcl/expressionstcl/finalizer.go @@ -9,25 +9,72 @@ package expressionstcl import ( - "fmt" + "errors" ) type finalizer struct { - machine MachineCore + handler FinalizerFn +} + +type finalizerItem struct { + function bool + name string +} + +type FinalizerItem interface { + Name() string + IsFunction() bool +} + +type FinalizerResult int8 + +const ( + FinalizerResultFail FinalizerResult = -1 + FinalizerResultNone FinalizerResult = 0 + FinalizerResultPreserve FinalizerResult = 1 +) + +type FinalizerFn = func(item FinalizerItem) FinalizerResult + +func NewFinalizer(fn FinalizerFn) Machine { + return &finalizer{handler: fn} } func (f *finalizer) Get(name string) (Expression, bool, error) { - v, ok, err := f.machine.Get(name) - if !ok && err == nil { + result := f.handler(finalizerItem{name: name}) + if result == FinalizerResultFail { + return nil, true, errors.New("unknown variable") + } else if result == FinalizerResultNone { return None, true, nil } - return v, ok, err + return nil, false, nil } -func (f *finalizer) Call(name string, args ...StaticValue) (Expression, bool, error) { - v, ok, err := f.machine.Call(name, args...) - if !ok && err == nil { - return nil, true, fmt.Errorf(`"%s" function not resolved`, name) +func (f *finalizer) Call(name string, _ ...StaticValue) (Expression, bool, error) { + result := f.handler(finalizerItem{function: true, name: name}) + if result == FinalizerResultFail { + return nil, true, errors.New("unknown function") + } else if result == FinalizerResultNone { + return None, true, nil } - return v, ok, err + return nil, false, nil +} + +func (f finalizerItem) IsFunction() bool { + return f.function +} + +func (f finalizerItem) Name() string { + return f.name } + +func FinalizerFailFn(_ FinalizerItem) FinalizerResult { + return FinalizerResultFail +} + +func FinalizerNoneFn(_ FinalizerItem) FinalizerResult { + return FinalizerResultNone +} + +var FinalizerFail = NewFinalizer(FinalizerFailFn) +var FinalizerNone = NewFinalizer(FinalizerNoneFn) diff --git a/pkg/tcl/expressionstcl/generic.go b/pkg/tcl/expressionstcl/generic.go new file mode 100644 index 00000000000..c0e74e3438f --- /dev/null +++ b/pkg/tcl/expressionstcl/generic.go @@ -0,0 +1,167 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" + "reflect" + "strings" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/util/intstr" +) + +type tagData struct { + key string + value string +} + +func parseTag(tag string) tagData { + s := strings.Split(tag, ",") + if len(s) > 1 { + return tagData{key: s[0], value: s[1]} + } + return tagData{value: s[0]} +} + +var unrecognizedErr = errors.New("unsupported value passed for resolving expressions") + +func clone(v reflect.Value) reflect.Value { + if v.Kind() == reflect.String { + s := v.String() + return reflect.ValueOf(&s).Elem() + } else if v.Kind() == reflect.Struct { + r := reflect.New(v.Type()).Elem() + for i := 0; i < r.NumField(); i++ { + r.Field(i).Set(v.Field(i)) + } + return r + } + return v +} + +func resolve(v reflect.Value, t tagData, m []Machine) (err error) { + if t.key == "" && t.value == "" { + return + } + + ptr := v + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return + } + ptr = v + v = v.Elem() + } + + if v.IsZero() || !v.IsValid() || (v.Kind() == reflect.Slice || v.Kind() == reflect.Map) && v.IsNil() { + return + } + + switch v.Kind() { + case reflect.Struct: + // TODO: Cache the tags for structs for better performance + vv, ok := v.Interface().(intstr.IntOrString) + if ok { + if vv.Type == intstr.String { + return resolve(v.FieldByName("StrVal"), t, m) + } + } else if t.value == "include" { + tt := v.Type() + for i := 0; i < tt.NumField(); i++ { + f := tt.Field(i) + tag := parseTag(f.Tag.Get("expr")) + value := v.FieldByName(f.Name) + err = resolve(value, tag, m) + if err != nil { + return errors.Wrap(err, f.Name) + } + } + } + return + case reflect.Slice: + if t.value == "" { + return nil + } + for i := 0; i < v.Len(); i++ { + err := resolve(v.Index(i), t, m) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("%d", i)) + } + } + return + case reflect.Map: + if t.value == "" && t.key == "" { + return nil + } + for _, k := range v.MapKeys() { + if t.value != "" { + // It's not possible to get a pointer to map element, + // so we need to copy it and reassign + item := clone(v.MapIndex(k)) + err = resolve(item, t, m) + v.SetMapIndex(k, item) + if err != nil { + return errors.Wrap(err, k.String()) + } + } + if t.key != "" { + key := clone(k) + err = resolve(key, tagData{value: t.key}, m) + if !key.Equal(k) { + item := clone(v.MapIndex(k)) + v.SetMapIndex(k, reflect.Value{}) + v.SetMapIndex(key, item) + } + if err != nil { + return errors.Wrap(err, "key("+k.String()+")") + } + } + } + return + case reflect.String: + if t.value == "expression" { + var expr Expression + expr, err = CompileAndResolve(v.String(), m...) + if err != nil { + return err + } + vv := expr.String() + if ptr.Kind() == reflect.String { + v.SetString(vv) + } else { + ptr.Set(reflect.ValueOf(&vv)) + } + } else if t.value == "template" && !IsTemplateStringWithoutExpressions(v.String()) { + var expr Expression + expr, err = CompileAndResolveTemplate(v.String(), m...) + if err != nil { + return err + } + vv := expr.Template() + if ptr.Kind() == reflect.String { + v.SetString(vv) + } else { + ptr.Set(reflect.ValueOf(&vv)) + } + } + return + } + + // Fail for unrecognized values + return unrecognizedErr +} + +func SimplifyStruct(t interface{}, m ...Machine) error { + v := reflect.ValueOf(t) + if v.Kind() != reflect.Pointer { + return errors.New("pointer needs to be passed to Resolve function") + } + return resolve(v, tagData{value: "include"}, m) +} diff --git a/pkg/tcl/expressionstcl/generic_test.go b/pkg/tcl/expressionstcl/generic_test.go new file mode 100644 index 00000000000..29f6c16bc59 --- /dev/null +++ b/pkg/tcl/expressionstcl/generic_test.go @@ -0,0 +1,150 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/kubeshop/testkube/internal/common" +) + +type testObj2 struct { + Expr string `expr:"expression"` + Dummy string +} + +type testObj struct { + Expr string `expr:"expression"` + Tmpl string `expr:"template"` + ExprPtr *string `expr:"expression"` + TmplPtr *string `expr:"template"` + IntExpr intstr.IntOrString `expr:"expression"` + IntTmpl intstr.IntOrString `expr:"template"` + IntExprPtr *intstr.IntOrString `expr:"expression"` + IntTmplPtr *intstr.IntOrString `expr:"template"` + Obj testObj2 `expr:"include"` + ObjPtr *testObj2 `expr:"include"` + SliceExprStr []string `expr:"expression"` + SliceExprStrPtr *[]string `expr:"expression"` + SliceExprObj []testObj2 `expr:"include"` + MapKeyVal map[string]string `expr:"template,template"` + MapValIntTmpl map[string]intstr.IntOrString `expr:"template"` + MapKeyTmpl map[string]string `expr:"template,"` + MapValTmpl map[string]string `expr:"template"` + MapTmplExpr map[string]string `expr:"template,expression"` + Dummy string + DummyPtr *string + DummyObj testObj2 + DummyObjPtr *testObj2 +} + +var testMachine = NewMachine(). + Register("dummy", "test"). + Register("ten", 10) + +func TestGenericString(t *testing.T) { + obj := testObj{ + Expr: "5 + 3 + ten", + Tmpl: "{{ 10 + 3 }}{{ ten }}", + ExprPtr: common.Ptr("1 + 2 + ten"), + TmplPtr: common.Ptr("{{ 4 + 3 }}{{ ten }}"), + Dummy: "5 + 3 + ten", + DummyPtr: common.Ptr("5 + 3 + ten"), + } + err := SimplifyStruct(&obj, testMachine) + assert.NoError(t, err) + assert.Equal(t, "18", obj.Expr) + assert.Equal(t, "1310", obj.Tmpl) + assert.Equal(t, common.Ptr("13"), obj.ExprPtr) + assert.Equal(t, common.Ptr("710"), obj.TmplPtr) + assert.Equal(t, "5 + 3 + ten", obj.Dummy) + assert.Equal(t, common.Ptr("5 + 3 + ten"), obj.DummyPtr) +} + +func TestGenericIntOrString(t *testing.T) { + obj := testObj{ + IntExpr: intstr.IntOrString{Type: intstr.String, StrVal: "5 + 3 + ten"}, + IntTmpl: intstr.IntOrString{Type: intstr.String, StrVal: "{{ 10 + 3 }}{{ ten }}"}, + IntExprPtr: &intstr.IntOrString{Type: intstr.String, StrVal: "1 + 2 + ten"}, + IntTmplPtr: &intstr.IntOrString{Type: intstr.String, StrVal: "{{ 4 + 3 }}{{ ten }}"}, + } + err := SimplifyStruct(&obj, testMachine) + assert.NoError(t, err) + assert.Equal(t, "18", obj.IntExpr.String()) + assert.Equal(t, "1310", obj.IntTmpl.String()) + assert.Equal(t, "13", obj.IntExprPtr.String()) + assert.Equal(t, "710", obj.IntTmplPtr.String()) +} + +func TestGenericSlice(t *testing.T) { + obj := testObj{ + SliceExprStr: []string{"200 + 100", "100 + 200", "ten", "abc"}, + SliceExprStrPtr: &[]string{"200 + 100", "100 + 200", "ten", "abc"}, + SliceExprObj: []testObj2{{Expr: "10 + 5", Dummy: "3 + 2"}}, + } + err := SimplifyStruct(&obj, testMachine) + assert.NoError(t, err) + assert.Equal(t, []string{"300", "300", "10", "abc"}, obj.SliceExprStr) + assert.Equal(t, &[]string{"300", "300", "10", "abc"}, obj.SliceExprStrPtr) + assert.Equal(t, []testObj2{{Expr: "15", Dummy: "3 + 2"}}, obj.SliceExprObj) +} + +func TestGenericMap(t *testing.T) { + obj := testObj{ + MapKeyVal: map[string]string{"{{ 10 + 3 }}2": "{{ 3 + 5 }}"}, + MapKeyTmpl: map[string]string{"{{ 10 + 3 }}2": "{{ 3 + 5 }}"}, + MapValTmpl: map[string]string{"{{ 10 + 3 }}2": "{{ 3 + 5 }}"}, + MapValIntTmpl: map[string]intstr.IntOrString{"{{ 10 + 3 }}2": {Type: intstr.String, StrVal: "{{ 3 + 5 }}"}}, + MapTmplExpr: map[string]string{"{{ 10 + 3 }}2": "3 + 5"}, + } + err := SimplifyStruct(&obj, testMachine) + assert.NoError(t, err) + assert.Equal(t, map[string]string{"132": "8"}, obj.MapKeyVal) + assert.Equal(t, map[string]string{"132": "{{ 3 + 5 }}"}, obj.MapKeyTmpl) + assert.Equal(t, map[string]string{"{{ 10 + 3 }}2": "8"}, obj.MapValTmpl) + assert.Equal(t, map[string]intstr.IntOrString{"{{ 10 + 3 }}2": {Type: intstr.String, StrVal: "8"}}, obj.MapValIntTmpl) + assert.Equal(t, map[string]string{"132": "8"}, obj.MapTmplExpr) +} + +func TestNestedObject(t *testing.T) { + obj := testObj{ + Obj: testObj2{Expr: "10 + 5", Dummy: "3 + 2"}, + ObjPtr: &testObj2{Expr: "10 + 8", Dummy: "33 + 2"}, + DummyObj: testObj2{Expr: "10 + 8", Dummy: "333 + 2"}, + DummyObjPtr: &testObj2{Expr: "10 + 8", Dummy: "3333 + 2"}, + } + err := SimplifyStruct(&obj, testMachine) + assert.NoError(t, err) + assert.Equal(t, testObj2{Expr: "15", Dummy: "3 + 2"}, obj.Obj) + assert.Equal(t, &testObj2{Expr: "18", Dummy: "33 + 2"}, obj.ObjPtr) + assert.Equal(t, testObj2{Expr: "10 + 8", Dummy: "333 + 2"}, obj.DummyObj) + assert.Equal(t, &testObj2{Expr: "10 + 8", Dummy: "3333 + 2"}, obj.DummyObjPtr) +} + +func TestGenericNotMutateStringPointer(t *testing.T) { + ptr := common.Ptr("200 + 10") + obj := testObj{ + ExprPtr: ptr, + } + _ = SimplifyStruct(&obj, testMachine) + assert.Equal(t, common.Ptr("200 + 10"), ptr) +} + +func TestGenericCompileError(t *testing.T) { + got := testObj{ + Tmpl: "{{ 1 + 2 }}{{ 3", + } + err := SimplifyStruct(&got) + + assert.Contains(t, fmt.Sprintf("%v", err), "Tmpl: template error") +} diff --git a/pkg/tcl/expressionstcl/machine.go b/pkg/tcl/expressionstcl/machine.go index f01f2e2e3ce..5a174008e02 100644 --- a/pkg/tcl/expressionstcl/machine.go +++ b/pkg/tcl/expressionstcl/machine.go @@ -8,34 +8,26 @@ package expressionstcl -//go:generate mockgen -destination=./mock_machinecore.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" MachineCore -type MachineCore interface { - Get(name string) (Expression, bool, error) - Call(name string, args ...StaticValue) (Expression, bool, error) -} - //go:generate mockgen -destination=./mock_machine.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" Machine type Machine interface { - MachineCore - Finalizer() MachineCore + Get(name string) (Expression, bool, error) + Call(name string, args ...StaticValue) (Expression, bool, error) } +type MachineAccessorExt = func(name string) (interface{}, bool, error) type MachineAccessor = func(name string) (interface{}, bool) type MachineFn = func(values ...StaticValue) (interface{}, bool, error) type machine struct { - accessors []MachineAccessor + accessors []MachineAccessorExt functions map[string]MachineFn - finalizer *finalizer } func NewMachine() *machine { - m := &machine{ - accessors: make([]MachineAccessor, 0), + return &machine{ + accessors: make([]MachineAccessorExt, 0), functions: make(map[string]MachineFn), } - m.finalizer = &finalizer{machine: m} - return m } func (m *machine) Register(name string, value interface{}) *machine { @@ -47,11 +39,18 @@ func (m *machine) Register(name string, value interface{}) *machine { }) } -func (m *machine) RegisterAccessor(fn MachineAccessor) *machine { +func (m *machine) RegisterAccessorExt(fn MachineAccessorExt) *machine { m.accessors = append(m.accessors, fn) return m } +func (m *machine) RegisterAccessor(fn MachineAccessor) *machine { + return m.RegisterAccessorExt(func(name string) (interface{}, bool, error) { + v, ok := fn(name) + return v, ok, nil + }) +} + func (m *machine) RegisterFunction(name string, fn MachineFn) *machine { m.functions[name] = fn return m @@ -59,7 +58,10 @@ func (m *machine) RegisterFunction(name string, fn MachineFn) *machine { func (m *machine) Get(name string) (Expression, bool, error) { for i := range m.accessors { - r, ok := m.accessors[i](name) + r, ok, err := m.accessors[i](name) + if err != nil { + return nil, true, err + } if ok { if v, ok := r.(Expression); ok { return v, true, nil @@ -84,7 +86,3 @@ func (m *machine) Call(name string, args ...StaticValue) (Expression, bool, erro } return NewValue(r), true, nil } - -func (m *machine) Finalizer() MachineCore { - return m.finalizer -} diff --git a/pkg/tcl/expressionstcl/machineutils.go b/pkg/tcl/expressionstcl/machineutils.go new file mode 100644 index 00000000000..b9d7ddcdc60 --- /dev/null +++ b/pkg/tcl/expressionstcl/machineutils.go @@ -0,0 +1,65 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package expressionstcl + +import "strings" + +type limitedMachine struct { + prefix string + machine Machine +} + +func PrefixMachine(prefix string, machine Machine) Machine { + return &limitedMachine{ + prefix: prefix, + machine: machine, + } +} + +func (m *limitedMachine) Get(name string) (Expression, bool, error) { + if strings.HasPrefix(name, m.prefix) { + return m.machine.Get(name) + } + return nil, false, nil +} + +func (m *limitedMachine) Call(name string, args ...StaticValue) (Expression, bool, error) { + if strings.HasPrefix(name, m.prefix) { + return m.machine.Call(name, args...) + } + return nil, false, nil +} + +type combinedMachine struct { + machines []Machine +} + +func CombinedMachines(machines ...Machine) Machine { + return &combinedMachine{machines: machines} +} + +func (m *combinedMachine) Get(name string) (Expression, bool, error) { + for i := range m.machines { + v, ok, err := m.machines[i].Get(name) + if err != nil || ok { + return v, ok, err + } + } + return nil, false, nil +} + +func (m *combinedMachine) Call(name string, args ...StaticValue) (Expression, bool, error) { + for i := range m.machines { + v, ok, err := m.machines[i].Call(name, args...) + if err != nil || ok { + return v, ok, err + } + } + return nil, false, nil +} diff --git a/pkg/tcl/expressionstcl/math.go b/pkg/tcl/expressionstcl/math.go index e792f05107a..bf71a86acb9 100644 --- a/pkg/tcl/expressionstcl/math.go +++ b/pkg/tcl/expressionstcl/math.go @@ -233,7 +233,7 @@ func (s *math) Template() string { return "{{" + s.String() + "}}" } -func (s *math) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { +func (s *math) SafeResolve(m ...Machine) (v Expression, changed bool, err error) { var ch bool s.left, ch, err = s.left.SafeResolve(m...) changed = changed || ch @@ -275,7 +275,7 @@ func (s *math) SafeResolve(m ...MachineCore) (v Expression, changed bool, err er return s, changed, nil } -func (s *math) Resolve(m ...MachineCore) (v Expression, err error) { +func (s *math) Resolve(m ...Machine) (v Expression, err error) { return deepResolve(s, m...) } diff --git a/pkg/tcl/expressionstcl/mock_expression.go b/pkg/tcl/expressionstcl/mock_expression.go index 437f40b39bc..c8689efa401 100644 --- a/pkg/tcl/expressionstcl/mock_expression.go +++ b/pkg/tcl/expressionstcl/mock_expression.go @@ -62,7 +62,7 @@ func (mr *MockExpressionMockRecorder) Functions() *gomock.Call { } // Resolve mocks base method. -func (m *MockExpression) Resolve(arg0 ...MachineCore) (Expression, error) { +func (m *MockExpression) Resolve(arg0 ...Machine) (Expression, error) { m.ctrl.T.Helper() varargs := []interface{}{} for _, a := range arg0 { @@ -81,7 +81,7 @@ func (mr *MockExpressionMockRecorder) Resolve(arg0 ...interface{}) *gomock.Call } // SafeResolve mocks base method. -func (m *MockExpression) SafeResolve(arg0 ...MachineCore) (Expression, bool, error) { +func (m *MockExpression) SafeResolve(arg0 ...Machine) (Expression, bool, error) { m.ctrl.T.Helper() varargs := []interface{}{} for _, a := range arg0 { diff --git a/pkg/tcl/expressionstcl/mock_machine.go b/pkg/tcl/expressionstcl/mock_machine.go index 516098c2336..407256ba5ab 100644 --- a/pkg/tcl/expressionstcl/mock_machine.go +++ b/pkg/tcl/expressionstcl/mock_machine.go @@ -54,20 +54,6 @@ func (mr *MockMachineMockRecorder) Call(arg0 interface{}, arg1 ...interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockMachine)(nil).Call), varargs...) } -// Finalizer mocks base method. -func (m *MockMachine) Finalizer() MachineCore { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Finalizer") - ret0, _ := ret[0].(MachineCore) - return ret0 -} - -// Finalizer indicates an expected call of Finalizer. -func (mr *MockMachineMockRecorder) Finalizer() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finalizer", reflect.TypeOf((*MockMachine)(nil).Finalizer)) -} - // Get mocks base method. func (m *MockMachine) Get(arg0 string) (Expression, bool, error) { m.ctrl.T.Helper() diff --git a/pkg/tcl/expressionstcl/mock_machinecore.go b/pkg/tcl/expressionstcl/mock_machinecore.go deleted file mode 100644 index d7a02f5775b..00000000000 --- a/pkg/tcl/expressionstcl/mock_machinecore.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/kubeshop/testkube/pkg/tcl/expressionstcl (interfaces: MachineCore) - -// Package expressionstcl is a generated GoMock package. -package expressionstcl - -import ( - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockMachineCore is a mock of MachineCore interface. -type MockMachineCore struct { - ctrl *gomock.Controller - recorder *MockMachineCoreMockRecorder -} - -// MockMachineCoreMockRecorder is the mock recorder for MockMachineCore. -type MockMachineCoreMockRecorder struct { - mock *MockMachineCore -} - -// NewMockMachineCore creates a new mock instance. -func NewMockMachineCore(ctrl *gomock.Controller) *MockMachineCore { - mock := &MockMachineCore{ctrl: ctrl} - mock.recorder = &MockMachineCoreMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockMachineCore) EXPECT() *MockMachineCoreMockRecorder { - return m.recorder -} - -// Call mocks base method. -func (m *MockMachineCore) Call(arg0 string, arg1 ...StaticValue) (Expression, bool, error) { - m.ctrl.T.Helper() - varargs := []interface{}{arg0} - for _, a := range arg1 { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Call", varargs...) - ret0, _ := ret[0].(Expression) - ret1, _ := ret[1].(bool) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Call indicates an expected call of Call. -func (mr *MockMachineCoreMockRecorder) Call(arg0 interface{}, arg1 ...interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockMachineCore)(nil).Call), varargs...) -} - -// Get mocks base method. -func (m *MockMachineCore) Get(arg0 string) (Expression, bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0) - ret0, _ := ret[0].(Expression) - ret1, _ := ret[1].(bool) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Get indicates an expected call of Get. -func (mr *MockMachineCoreMockRecorder) Get(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockMachineCore)(nil).Get), arg0) -} diff --git a/pkg/tcl/expressionstcl/mock_staticvalue.go b/pkg/tcl/expressionstcl/mock_staticvalue.go index c6f16791537..eed6881bfc7 100644 --- a/pkg/tcl/expressionstcl/mock_staticvalue.go +++ b/pkg/tcl/expressionstcl/mock_staticvalue.go @@ -220,7 +220,7 @@ func (mr *MockStaticValueMockRecorder) MapValue() *gomock.Call { } // Resolve mocks base method. -func (m *MockStaticValue) Resolve(arg0 ...MachineCore) (Expression, error) { +func (m *MockStaticValue) Resolve(arg0 ...Machine) (Expression, error) { m.ctrl.T.Helper() varargs := []interface{}{} for _, a := range arg0 { @@ -239,7 +239,7 @@ func (mr *MockStaticValueMockRecorder) Resolve(arg0 ...interface{}) *gomock.Call } // SafeResolve mocks base method. -func (m *MockStaticValue) SafeResolve(arg0 ...MachineCore) (Expression, bool, error) { +func (m *MockStaticValue) SafeResolve(arg0 ...Machine) (Expression, bool, error) { m.ctrl.T.Helper() varargs := []interface{}{} for _, a := range arg0 { diff --git a/pkg/tcl/expressionstcl/negative.go b/pkg/tcl/expressionstcl/negative.go index 50326cf433f..da67b4a4a9d 100644 --- a/pkg/tcl/expressionstcl/negative.go +++ b/pkg/tcl/expressionstcl/negative.go @@ -37,7 +37,7 @@ func (s *negative) Template() string { return "{{" + s.String() + "}}" } -func (s *negative) SafeResolve(m ...MachineCore) (v Expression, changed bool, err error) { +func (s *negative) SafeResolve(m ...Machine) (v Expression, changed bool, err error) { s.expr, changed, err = s.expr.SafeResolve(m...) if err != nil { return nil, changed, err @@ -54,7 +54,7 @@ func (s *negative) SafeResolve(m ...MachineCore) (v Expression, changed bool, er return NewValue(!vv), changed, nil } -func (s *negative) Resolve(m ...MachineCore) (v Expression, err error) { +func (s *negative) Resolve(m ...Machine) (v Expression, err error) { return deepResolve(s, m...) } diff --git a/pkg/tcl/expressionstcl/parse.go b/pkg/tcl/expressionstcl/parse.go index e7f244d92af..b30b9506da7 100644 --- a/pkg/tcl/expressionstcl/parse.go +++ b/pkg/tcl/expressionstcl/parse.go @@ -219,3 +219,23 @@ func MustCompileTemplate(tpl string) Expression { } return v } + +func CompileAndResolve(exp string, m ...Machine) (Expression, error) { + e, err := Compile(exp) + if err != nil { + return e, err + } + return e.Resolve(m...) +} + +func CompileAndResolveTemplate(tpl string, m ...Machine) (Expression, error) { + e, err := CompileTemplate(tpl) + if err != nil { + return e, err + } + return e.Resolve(m...) +} + +func IsTemplateStringWithoutExpressions(tpl string) bool { + return !strings.Contains(tpl, "{{") +} diff --git a/pkg/tcl/expressionstcl/parse_test.go b/pkg/tcl/expressionstcl/parse_test.go index c820521ecdd..1c3d41cf846 100644 --- a/pkg/tcl/expressionstcl/parse_test.go +++ b/pkg/tcl/expressionstcl/parse_test.go @@ -155,13 +155,12 @@ func TestCompileResolution(t *testing.T) { return nil, true, errors.New("the mainEndpoint should have no parameters") } return MustCompile(`env.apiUrl`), true, nil - }). - Finalizer() + }) - assert.Equal(t, `555`, must(MustCompile(`someint`).Resolve(vm)).String()) - assert.Equal(t, `"[placeholder:name]"`, must(MustCompile(`env.name`).Resolve(vm)).String()) - assert.Error(t, errOnly(MustCompile(`secrets.name`).Resolve(vm))) - assert.Equal(t, `"[placeholder:apiUrl]"`, must(MustCompile(`mainEndpoint()`).Resolve(vm)).String()) + assert.Equal(t, `555`, must(MustCompile(`someint`).Resolve(vm, FinalizerFail)).String()) + assert.Equal(t, `"[placeholder:name]"`, must(MustCompile(`env.name`).Resolve(vm, FinalizerFail)).String()) + assert.Error(t, errOnly(MustCompile(`secrets.name`).Resolve(vm, FinalizerFail))) + assert.Equal(t, `"[placeholder:apiUrl]"`, must(MustCompile(`mainEndpoint()`).Resolve(vm, FinalizerFail)).String()) } func TestCircularResolution(t *testing.T) { @@ -174,11 +173,10 @@ func TestCircularResolution(t *testing.T) { }). RegisterFunction("self", func(values ...StaticValue) (interface{}, bool, error) { return MustCompile("self()"), true, nil - }). - Finalizer() + }) - assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`one()`).Resolve(vm))), "call stack exceeded") - assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`self()`).Resolve(vm))), "call stack exceeded") + assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`one()`).Resolve(vm, FinalizerFail))), "call stack exceeded") + assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`self()`).Resolve(vm, FinalizerFail))), "call stack exceeded") } func TestCompileMultilineString(t *testing.T) { @@ -188,6 +186,10 @@ def "`).String()) } +func TestCompileEscapeTemplate(t *testing.T) { + assert.Equal(t, `foo{{"{{"}}barbaz{{"{{"}}`, MustCompileTemplate(`foo{{"{{bar"}}baz{{"{{"}}`).Template()) +} + func TestCompileStandardLib(t *testing.T) { assert.Equal(t, `false`, MustCompile(`bool(0)`).String()) assert.Equal(t, `true`, MustCompile(`bool(500)`).String()) diff --git a/pkg/tcl/expressionstcl/static.go b/pkg/tcl/expressionstcl/static.go index 0582abf9f9c..e8ecc3ffdcf 100644 --- a/pkg/tcl/expressionstcl/static.go +++ b/pkg/tcl/expressionstcl/static.go @@ -33,6 +33,9 @@ func NewStringValue(value interface{}) StaticValue { } func (s *static) Type() Type { + if s == nil { + return TypeUnknown + } switch s.value.(type) { case int64: return TypeInt64 @@ -74,17 +77,14 @@ func (s *static) Template() string { return "" } v, _ := s.StringValue() - if strings.Contains(v, "{{") { - return "{{" + s.String() + "}}" - } - return v + return strings.ReplaceAll(v, "{{", "{{\"{{\"}}") } -func (s *static) SafeResolve(_ ...MachineCore) (Expression, bool, error) { +func (s *static) SafeResolve(_ ...Machine) (Expression, bool, error) { return s, false, nil } -func (s *static) Resolve(_ ...MachineCore) (Expression, error) { +func (s *static) Resolve(_ ...Machine) (Expression, error) { return s, nil } diff --git a/pkg/tcl/expressionstcl/utils.go b/pkg/tcl/expressionstcl/utils.go index 5c31a8864e2..991e2db1037 100644 --- a/pkg/tcl/expressionstcl/utils.go +++ b/pkg/tcl/expressionstcl/utils.go @@ -14,7 +14,7 @@ import ( const maxCallStack = 10_000 -func deepResolve(expr Expression, machines ...MachineCore) (Expression, error) { +func deepResolve(expr Expression, machines ...Machine) (Expression, error) { i := 1 expr, changed, err := expr.SafeResolve(machines...) for changed && err == nil && expr.Static() == nil { From bf751e1a64cd57ebd5ee18f525743d165b22ad7b Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Tue, 27 Feb 2024 12:47:26 +0100 Subject: [PATCH 135/234] feat: Container executor - k6 with report (#5070) * k6 executor tests - container - report * k6 container - artifacts enabled * k6 container - env name fixed * k6 container - K6_WEB_DASHBOARD env added --- .../executor-smoke/crd/k6.yaml | 35 +++++++++++++++++++ test/executors/container-executor-k6.yaml | 14 +++++++- .../executor-container-k6-smoke-tests.yaml | 3 ++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/test/container-executor/executor-smoke/crd/k6.yaml b/test/container-executor/executor-smoke/crd/k6.yaml index ef3c4eb4149..94ab99f69e6 100644 --- a/test/container-executor/executor-smoke/crd/k6.yaml +++ b/test/container-executor/executor-smoke/crd/k6.yaml @@ -39,3 +39,38 @@ spec: args: ["run", "k6-smoke-test-without-envs.js"] jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 64Mi\n cpu: 128m\n" activeDeadlineSeconds: 180 +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: container-executor-k6-smoke-report + labels: + core-tests: executors +spec: + type: container-executor-k6-0.49.0/test # 0.49.0 or higher is required for report + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube + branch: main + path: test/k6/executor-tests/k6-smoke-test-without-envs.js + workingDir: test/k6/executor-tests + executionRequest: + args: ["run", "k6-smoke-test-without-envs.js"] + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 64Mi\n cpu: 128m\n" + activeDeadlineSeconds: 180 + variables: + K6_WEB_DASHBOARD: + name: K6_WEB_DASHBOARD + value: "true" + type: basic + K6_WEB_DASHBOARD_EXPORT: + name: K6_WEB_DASHBOARD_EXPORT + value: "/data/artifacts/k6-test-report.html" + type: basic + artifactRequest: + storageClassName: standard + volumeMountPath: /data/artifacts + dirs: + - ./ diff --git a/test/executors/container-executor-k6.yaml b/test/executors/container-executor-k6.yaml index 88d1b6969b3..8ba41a02471 100644 --- a/test/executors/container-executor-k6.yaml +++ b/test/executors/container-executor-k6.yaml @@ -6,4 +6,16 @@ spec: image: grafana/k6:0.43.1 executor_type: container types: - - container-executor-k6-0.43.1/test \ No newline at end of file + - container-executor-k6-0.43.1/test +--- +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: container-executor-k6-0.49.0 +spec: + image: grafana/k6:0.49.0 + executor_type: container + types: + - container-executor-k6-0.49.0/test + features: + - artifacts diff --git a/test/suites/executor-container-k6-smoke-tests.yaml b/test/suites/executor-container-k6-smoke-tests.yaml index be78b84cc9e..9e0dc75a857 100644 --- a/test/suites/executor-container-k6-smoke-tests.yaml +++ b/test/suites/executor-container-k6-smoke-tests.yaml @@ -13,3 +13,6 @@ spec: - stopOnFailure: false execute: - test: container-executor-k6-smoke-git-file + - stopOnFailure: false + execute: + - test: container-executor-k6-smoke-report From 68efc4c6eeb813c405e59b3ca66a6efe51ee96fb Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Tue, 27 Feb 2024 12:52:06 +0100 Subject: [PATCH 136/234] feat: Executor tests - jmeterd RUNNER_ARTIFACTS_DIR (#5032) * executor tests - expected failures extended - pre/post-run script for container executor * executor tests - expected failures testsuite extended * executor tests - expected failures - failed test, passed pre/post-run scripts * empty lines added * executor tests - RUNNER_ARTIFACTS_DIR --- test/jmeter/executor-tests/crd/special-cases.yaml | 6 +++--- test/suites/special-cases/jmeter-special-cases.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/jmeter/executor-tests/crd/special-cases.yaml b/test/jmeter/executor-tests/crd/special-cases.yaml index 424f1f84f1f..5204ea3303a 100644 --- a/test/jmeter/executor-tests/crd/special-cases.yaml +++ b/test/jmeter/executor-tests/crd/special-cases.yaml @@ -541,7 +541,7 @@ spec: apiVersion: tests.testkube.io/v3 kind: Test metadata: - name: jmeterd-executor-smoke-output-dir + name: jmeterd-executor-smoke-runner-artifacts-dir labels: core-tests: special-cases-jmeter spec: @@ -575,7 +575,7 @@ spec: cpu: 500m memory: 512Mi variables: - OUTPUT_DIR: - name: OUTPUT_DIR + RUNNER_ARTIFACTS_DIR: + name: RUNNER_ARTIFACTS_DIR value: "/data/artifacts-custom" type: basic diff --git a/test/suites/special-cases/jmeter-special-cases.yaml b/test/suites/special-cases/jmeter-special-cases.yaml index 0a7ea19a47f..d40e7678b6b 100644 --- a/test/suites/special-cases/jmeter-special-cases.yaml +++ b/test/suites/special-cases/jmeter-special-cases.yaml @@ -42,4 +42,4 @@ spec: - test: jmeterd-executor-smoke-args-override-workingdir - stopOnFailure: false execute: - - test: jmeterd-executor-smoke-output-dir + - test: jmeterd-executor-smoke-runner-artifacts-dir From 5c75fea7e6eef80f05b8ec951daa3a0389b50a57 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 27 Feb 2024 15:14:52 +0300 Subject: [PATCH 137/234] fix: additional nil check --- pkg/tcl/mappertcl/testexecutions/mapper.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/tcl/mappertcl/testexecutions/mapper.go b/pkg/tcl/mappertcl/testexecutions/mapper.go index 1f90da61d2f..49208cd8dca 100644 --- a/pkg/tcl/mappertcl/testexecutions/mapper.go +++ b/pkg/tcl/mappertcl/testexecutions/mapper.go @@ -20,6 +20,9 @@ func MapAPIToCRD(sourceRequest *testkube.Execution, return destinationRequest } - destinationRequest.LatestExecution.ExecutionNamespace = sourceRequest.ExecutionNamespace + if destinationRequest.LatestExecution != nil { + destinationRequest.LatestExecution.ExecutionNamespace = sourceRequest.ExecutionNamespace + } + return destinationRequest } From 39e389031967fefdd78c6a72e2d5ca2a4b9ec0f0 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Tue, 27 Feb 2024 13:45:34 +0100 Subject: [PATCH 138/234] feat: Workflow tests, preofficial traits (#5051) * workflow tests - k6 * workflow tests - cypress * workflow tests - playwright * workflow tests - postman * workflow tests - gradle * workflow tests - maven * workflow tests - jmeter * workflow tests - soapui * workflow tests - cypress and playwright tests for official/preofficial traits * workflow tests - cypress pre-official trait * Workflows/Traits renamed * postman-workflow-smoke-preofficial-trait * test-workflow-templates - pre-official - k6, postman * workflow tests - postman * empty lines added --- .../executor-tests/crd-workflow/smoke.yaml | 100 +++++++++++++++++ .../executor-smoke/crd-workflow/smoke.yaml | 55 +++++++++ .../executor-tests/crd-workflow/smoke.yaml | 28 +++++ .../k6/executor-tests/crd-workflow/smoke.yaml | 91 +++++++++++++++ .../executor-smoke/crd-workflow/smoke.yaml | 28 +++++ .../executor-tests/crd-workflow/smoke.yaml | 70 ++++++++++++ .../executor-tests/crd-workflow/smoke.yaml | 104 ++++++++++++++++++ .../executor-smoke/crd-workflow/smoke.yaml | 24 ++++ test/test-workflow-templates/cypress.yaml | 27 +++++ test/test-workflow-templates/k6.yaml | 19 ++++ test/test-workflow-templates/postman.yaml | 19 ++++ 11 files changed, 565 insertions(+) create mode 100644 test/cypress/executor-tests/crd-workflow/smoke.yaml create mode 100644 test/gradle/executor-smoke/crd-workflow/smoke.yaml create mode 100644 test/jmeter/executor-tests/crd-workflow/smoke.yaml create mode 100644 test/k6/executor-tests/crd-workflow/smoke.yaml create mode 100644 test/maven/executor-smoke/crd-workflow/smoke.yaml create mode 100644 test/playwright/executor-tests/crd-workflow/smoke.yaml create mode 100644 test/postman/executor-tests/crd-workflow/smoke.yaml create mode 100644 test/soapui/executor-smoke/crd-workflow/smoke.yaml create mode 100644 test/test-workflow-templates/cypress.yaml create mode 100644 test/test-workflow-templates/k6.yaml create mode 100644 test/test-workflow-templates/postman.yaml diff --git a/test/cypress/executor-tests/crd-workflow/smoke.yaml b/test/cypress/executor-tests/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..de03bf7c2fb --- /dev/null +++ b/test/cypress/executor-tests/crd-workflow/smoke.yaml @@ -0,0 +1,100 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: cypress-workflow-smoke-13 + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-13 + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + steps: + - name: Run tests + run: + image: cypress/included:13.6.4 + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - '{"screenshotsFolder":"/data/artifacts/screenshots","videosFolder":"/data/artifacts/videos"}' + env: + - name: CYPRESS_CUSTOM_ENV + value: CYPRESS_CUSTOM_ENV_value + - name: Saving artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '*' +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: cypress-workflow-smoke-13-negative + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-13 + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + steps: + - name: Run tests + run: + image: cypress/included:13.6.4 + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - '{"screenshotsFolder":"/data/artifacts/screenshots","videosFolder":"/data/artifacts/videos"}' + negative: true + - name: Saving artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: cypress-workflow-smoke-13-preofficial-trait + labels: + core-tests: workflows +spec: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + env: + - name: CYPRESS_CUSTOM_ENV # currently only possible on this level + value: "CYPRESS_CUSTOM_ENV_value" + steps: + - name: Checkout + checkout: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-13 + - name: Run from trait + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + trait: + name: pre-official/cypress + config: + version: 13.5.0 + params: "--env NON_CYPRESS_ENV=NON_CYPRESS_ENV_value --config '{\"screenshotsFolder\":\"/data/artifacts/screenshots\",\"videosFolder\":\"/data/artifacts/videos\"}'" diff --git a/test/gradle/executor-smoke/crd-workflow/smoke.yaml b/test/gradle/executor-smoke/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..ba098d217f3 --- /dev/null +++ b/test/gradle/executor-smoke/crd-workflow/smoke.yaml @@ -0,0 +1,55 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: gradle-workflow-smoke-jdk11 + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - contrib/executor/gradle/examples/hello-gradle + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle + steps: + - name: Run tests + run: + image: gradle:8.5.0-jdk11 + command: + - gradle + - --no-daemon + - test + env: + - name: TESTKUBE_GRADLE + value: "true" +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: gradle-workflow-smoke-jdk11-default-command # TODO: recheck if it's fixed - the step passes without being executed + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - contrib/executor/gradle/examples/hello-gradle + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle + steps: + - name: Run tests + run: + image: gradle:8.5.0-jdk11 + env: + - name: TESTKUBE_GRADLE + value: "true" diff --git a/test/jmeter/executor-tests/crd-workflow/smoke.yaml b/test/jmeter/executor-tests/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..260654194b6 --- /dev/null +++ b/test/jmeter/executor-tests/crd-workflow/smoke.yaml @@ -0,0 +1,28 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: jmeter-workflow-smoke + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/jmeter/executor-tests/jmeter-executor-smoke.jmx + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/test/jmeter/executor-tests + steps: + - name: Run tests + run: + image: justb4/jmeter:5.5 + command: + - jmeter + args: + - -n + - -t + - jmeter-executor-smoke.jmx diff --git a/test/k6/executor-tests/crd-workflow/smoke.yaml b/test/k6/executor-tests/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..dbbf129101b --- /dev/null +++ b/test/k6/executor-tests/crd-workflow/smoke.yaml @@ -0,0 +1,91 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: k6-workflow-smoke + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/k6/executor-tests/k6-smoke-test.js + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + steps: + - name: Run test + run: + image: grafana/k6:0.43.1 + args: + - run + - k6-smoke-test.js + - -e + - K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value + env: + - name: K6_SYSTEM_ENV + value: K6_SYSTEM_ENV_value +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: k6-workflow-smoke-preofficial-trait + labels: + core-tests: workflows +spec: + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + env: + - name: K6_SYSTEM_ENV # currently only possible on this level + value: K6_SYSTEM_ENV_value + steps: + - name: Checkout + checkout: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/k6/executor-tests/k6-smoke-test.js + - name: Run from trait + workingDir: /data/repo/test/k6/executor-tests + trait: + name: pre-official/k6 + config: + version: 0.48.0 + params: "k6-smoke-test.js -e K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value" +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: k6-workflow-smoke-preofficial-trait-without-checkout-step + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/k6/executor-tests/k6-smoke-test.js + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + env: + - name: K6_SYSTEM_ENV # currently only possible on this level + value: K6_SYSTEM_ENV_value + steps: + - name: Run from trait + workingDir: /data/repo/test/k6/executor-tests + trait: + name: pre-official/k6 + config: + version: 0.48.0 + params: "k6-smoke-test.js -e K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value" diff --git a/test/maven/executor-smoke/crd-workflow/smoke.yaml b/test/maven/executor-smoke/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..b2f7be34ae4 --- /dev/null +++ b/test/maven/executor-smoke/crd-workflow/smoke.yaml @@ -0,0 +1,28 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: maven-workflow-smoke-jdk11 + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - contrib/executor/maven/examples/hello-maven + resources: + requests: + cpu: 256m + memory: 256Mi + workingDir: /data/repo/contrib/executor/maven/examples/hello-maven + steps: + - name: Run tests + run: + image: maven:3.9.6-eclipse-temurin-11-focal + command: + - "mvn" + - "test" + env: + - name: TESTKUBE_MAVEN + value: "true" diff --git a/test/playwright/executor-tests/crd-workflow/smoke.yaml b/test/playwright/executor-tests/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..55517a678a7 --- /dev/null +++ b/test/playwright/executor-tests/crd-workflow/smoke.yaml @@ -0,0 +1,70 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: playwright-workflow-smoke-v1.32.3 + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/playwright/executor-tests/playwright-project + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + steps: + - name: Install dependencies + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - npm + args: + - ci + - name: Run tests + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - "npx" + args: + - "--yes" + - "playwright@1.32.3" + - "test" + - "--output" + - "/data/artifacts" + - name: Save artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '*' +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: playwright-workflow-smoke-official-trait + labels: + core-tests: workflows +spec: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + steps: + - name: Checkout + checkout: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/playwright/executor-tests/playwright-project + - name: Run from trait + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + trait: + name: official/playwright + config: + # params: --workers 4 + tag: v1.32.3-jammy diff --git a/test/postman/executor-tests/crd-workflow/smoke.yaml b/test/postman/executor-tests/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..3815199ab30 --- /dev/null +++ b/test/postman/executor-tests/crd-workflow/smoke.yaml @@ -0,0 +1,104 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: postman-workflow-smoke + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/postman/executor-tests/postman-executor-smoke.postman_collection.json + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests + steps: + - name: Run test + run: + image: postman/newman:6-alpine + args: + - run + - postman-executor-smoke.postman_collection.json + - "--env-var" + - "TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value" +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: postman-workflow-smoke-without-envs + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/postman/executor-tests/postman-executor-smoke-without-envs.postman_collection.json + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests + steps: + - name: Run test + run: + image: postman/newman:6-alpine + args: + - run + - postman-executor-smoke-without-envs.postman_collection.json +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: postman-workflow-smoke-preofficial-trait + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/postman/executor-tests/postman-executor-smoke.postman_collection.json + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests + steps: + - name: Run from trait + workingDir: /data/repo/test/postman/executor-tests + trait: + name: pre-official/postman + config: + params: "postman-executor-smoke.postman_collection.json --env-var TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value" +--- +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: postman-workflow-smoke-preofficial-trait-without-envs + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/postman/executor-tests/postman-executor-smoke-without-envs.postman_collection.json + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests + steps: + - name: Run from trait + trait: + name: pre-official/postman + config: + params: "ppostman-executor-smoke-without-envs.postman_collection.json" diff --git a/test/soapui/executor-smoke/crd-workflow/smoke.yaml b/test/soapui/executor-smoke/crd-workflow/smoke.yaml new file mode 100644 index 00000000000..afd8b558e11 --- /dev/null +++ b/test/soapui/executor-smoke/crd-workflow/smoke.yaml @@ -0,0 +1,24 @@ +apiVersion: workflows.testkube.io/v1beta1 +kind: Workflow +metadata: + name: soapui-workflow-smoke + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/soapui/executor-smoke/soapui-smoke-test.xml + resources: + requests: + cpu: 512m + memory: 256Mi + steps: + - name: Run tests + run: + image: smartbear/soapuios-testrunner:5.7.2 # workingDir can't be used because of entrypoint script + env: + - name: COMMAND_LINE + value: "/data/repo/test/soapui/executor-smoke/soapui-smoke-test.xml" diff --git a/test/test-workflow-templates/cypress.yaml b/test/test-workflow-templates/cypress.yaml new file mode 100644 index 00000000000..55dd705af4b --- /dev/null +++ b/test/test-workflow-templates/cypress.yaml @@ -0,0 +1,27 @@ +kind: WorkflowTemplate +apiVersion: workflows.testkube.io/v1beta1 +metadata: + name: pre-official--cypress +spec: + config: + dependencies_command: + description: Command to install dependencies + type: string + default: npm install + version: + description: Cypress version to use + type: string + default: 13.6.4 + params: + description: Additional params for the cypress run command + type: string + default: "" + steps: + - name: Install dependencies + run: + image: cypress/included:{{ config.version }} + shell: '{{ config.dependencies_command }}' + - name: Run Cypress tests + run: + image: cypress/included:{{ config.version }} + shell: npx cypress run {{ config.params }} diff --git a/test/test-workflow-templates/k6.yaml b/test/test-workflow-templates/k6.yaml new file mode 100644 index 00000000000..49f663846da --- /dev/null +++ b/test/test-workflow-templates/k6.yaml @@ -0,0 +1,19 @@ +kind: Trait +apiVersion: workflows.testkube.io/v1beta1 +metadata: + name: pre-official--k6 +spec: + config: + version: + description: k6 version to use + type: string + default: 0.49.0 + params: + description: Additional params for the k6 run command + type: string + default: "" + steps: + - name: Run k6 tests + run: + image: grafana/k6:{{ config.version }} + shell: k6 run {{ config.params }} diff --git a/test/test-workflow-templates/postman.yaml b/test/test-workflow-templates/postman.yaml new file mode 100644 index 00000000000..f74b9307ca7 --- /dev/null +++ b/test/test-workflow-templates/postman.yaml @@ -0,0 +1,19 @@ +kind: Trait +apiVersion: workflows.testkube.io/v1beta1 +metadata: + name: pre-official--postman +spec: + config: + version: + description: Postman version to use + type: string + default: 6-alpine + params: + description: Additional params for the Postman (Newman) run command + type: string + default: "" + steps: + - name: Run Postman tests + run: + image: postman/newman:{{ config.version }} + shell: newman run {{ config.params }} From c69263b27860ca1f4f49d93cbda4409091d0c679 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 27 Feb 2024 15:56:58 +0100 Subject: [PATCH 139/234] feat(TKC-1466): resolve TestWorkflow with templates (#5075) * feat(TKC-1466): update testkube-operator to include expressions in TestWorkflow CRDs * feat(TKC-1466): add helpers to list templates used in the TestWorkflow * feat(TKC-1466): add helpers to apply configuration to TestWorkflow and TestWorkflowTemplate * feat(TKC-1466): cast config values to proper types * fix(TKC-1465): support negative numbers in expressions language * chore(TKC-1466): add unit tests for casting the config parameters * feat(TKC-1466): add helpers to merge TestWorkflowTemplate into TestWorkflow * chore(TKC-1466): delete unused utilities to decouple TestWorkflow steps * fix(TKC-1466): use default config values * fix(TKC-1466): convert TestWorkflow's description between API/CRD * fix(TKC-1465): support compiling empty templates * chore(TKC-1466): simplify nested steps when possible * fix(TKC-1466): avoid TestWorkflow config overflow to template * chore(TKC-1466): simplify nested steps after applying templates to TestWorkflow * feat(TKC-1466): add API endpoint to preview resolved TestWorkflow --- api/v1/testkube.yaml | 55 ++ go.mod | 4 +- go.sum | 10 +- pkg/tcl/apitcl/v1/server.go | 2 + pkg/tcl/apitcl/v1/testworkflows.go | 51 ++ pkg/tcl/expressionstcl/call.go | 11 - pkg/tcl/expressionstcl/machineutils.go | 9 + pkg/tcl/expressionstcl/parse.go | 12 + pkg/tcl/expressionstcl/parse_test.go | 5 + pkg/tcl/expressionstcl/stdlib.go | 37 ++ .../mapperstcl/testworkflows/kube_openapi.go | 4 +- .../mapperstcl/testworkflows/mappers_test.go | 6 +- .../mapperstcl/testworkflows/openapi_kube.go | 16 +- .../testworkflowresolver/analyze.go | 58 ++ .../testworkflowresolver/analyze_test.go | 85 +++ .../testworkflowresolver/apply.go | 220 +++++++ .../testworkflowresolver/apply_test.go | 546 ++++++++++++++++++ .../testworkflowresolver/config.go | 86 +++ .../testworkflowresolver/config_test.go | 251 ++++++++ .../testworkflowresolver/merge.go | 120 ++++ 20 files changed, 1556 insertions(+), 32 deletions(-) create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/analyze_test.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/config.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/config_test.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 4ec5f968f91..d4b525584cd 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3427,6 +3427,61 @@ paths: type: array items: $ref: "#/components/schemas/Problem" + /preview-test-workflow: + post: + tags: + - test-workflows + - api + - pro + summary: Preview test workflow + description: Preview test workflow after including templates inside + operationId: previewTestWorkflow + requestBody: + description: test workflow body + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + responses: + 200: + description: resolved test workflow + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + 400: + description: "problem with body parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" /test-workflows/{id}: get: tags: diff --git a/go.mod b/go.mod index 27e67d7a167..28bb1395ad9 100644 --- a/go.mod +++ b/go.mod @@ -24,9 +24,10 @@ require ( github.com/gookit/color v1.5.3 github.com/gorilla/websocket v1.5.0 github.com/joshdk/go-junit v1.0.0 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223085500-6396dbe900f3 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240227083425-1852257ea4a7 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 @@ -95,7 +96,6 @@ require ( github.com/itchyny/gojq v0.12.14 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.2.3 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index 9639388ee87..67fa1b84f46 100644 --- a/go.sum +++ b/go.sum @@ -356,12 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3 h1:R6xdH//ctWpE18U1GYwzNvq1HLiT9LUJogXkfyKDDGo= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240118132107-2c37729871a3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80 h1:4hnZi3dMBmpz4SxE9PrsJTG2JA/P5h+4PMrSXjUEEbA= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240221134011-c1770f0bea80/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223085500-6396dbe900f3 h1:6DXb2h8gfC5rULKLaOibXOzh9AeKV3t0HkeuNYtyD0U= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223085500-6396dbe900f3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240227083425-1852257ea4a7 h1:10NRQjeD4qZpX2bxRbAbVXCjJdsH+u9LZRFDhp+bKj4= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240227083425-1852257ea4a7/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= @@ -962,8 +958,6 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index db50437838c..5bc8be52baa 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -83,6 +83,8 @@ func (s *apiTCL) AppendRoutes() { testWorkflows.Put("/:id", s.pro(s.UpdateTestWorkflowHandler())) testWorkflows.Delete("/:id", s.pro(s.DeleteTestWorkflowHandler())) + root.Post("/preview-test-workflow", s.pro(s.PreviewTestWorkflowHandler())) + testWorkflowTemplates := root.Group("/test-workflow-templates") testWorkflowTemplates.Get("/", s.pro(s.ListTestWorkflowTemplatesHandler())) testWorkflowTemplates.Post("/", s.pro(s.CreateTestWorkflowTemplateHandler())) diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index a9bd4b92f3c..f8980cbe4b1 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -20,6 +20,7 @@ import ( "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/api/v1/testkube" mappers2 "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowresolver" ) func (s *apiTCL) ListTestWorkflowsHandler() fiber.Handler { @@ -185,6 +186,56 @@ func (s *apiTCL) UpdateTestWorkflowHandler() fiber.Handler { } } +func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { + errPrefix := "failed to resolve test workflow" + return func(c *fiber.Ctx) (err error) { + // Deserialize resource + obj := new(testworkflowsv1.TestWorkflow) + if HasYAML(c) { + err = common.DeserializeCRD(obj, c.Body()) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + } else { + var v *testkube.TestWorkflow + err = c.BodyParser(&v) + if err != nil { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + obj = mappers2.MapAPIToKube(v) + } + + // Validate resource + if obj == nil { + return s.BadRequest(c, errPrefix, "invalid body", errors.New("name is required")) + } + obj.Namespace = s.Namespace + + // Fetch the templates + tpls := testworkflowresolver.ListTemplates(obj) + tplsMap := make(map[string]testworkflowsv1.TestWorkflowTemplate, len(tpls)) + for name := range tpls { + tpl, err := s.TestWorkflowTemplatesClient.Get(name) + if err != nil { + return s.BadRequest(c, errPrefix, "fetching error", err) + } + tplsMap[name] = *tpl + } + + // Resolve the TestWorkflow + err = testworkflowresolver.ApplyTemplates(obj, tplsMap) + if err != nil { + return s.BadRequest(c, errPrefix, "resolving error", err) + } + + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, obj) + if err != nil { + return s.InternalError(c, errPrefix, "serialization problem", err) + } + return + } +} + func (s *apiTCL) getFilteredTestWorkflowList(c *fiber.Ctx) (*testworkflowsv1.TestWorkflowList, error) { crWorkflows, err := s.TestWorkflowsClient.List(c.Query("selector")) if err != nil { diff --git a/pkg/tcl/expressionstcl/call.go b/pkg/tcl/expressionstcl/call.go index f25eaf632a7..5f2492fc936 100644 --- a/pkg/tcl/expressionstcl/call.go +++ b/pkg/tcl/expressionstcl/call.go @@ -14,8 +14,6 @@ import ( "strings" ) -const stringCastStdFn = "string" - type call struct { name string args []Expression @@ -30,15 +28,6 @@ func newCall(name string, args []Expression) Expression { return &call{name: name, args: args} } -func CastToString(v Expression) Expression { - if v.Static() != nil { - return NewStringValue(v.Static().Value()) - } else if v.Type() == TypeString { - return v - } - return newCall(stringCastStdFn, []Expression{v}) -} - func (s *call) Type() Type { if IsStdFunction(s.name) { return GetStdFunctionReturnType(s.name) diff --git a/pkg/tcl/expressionstcl/machineutils.go b/pkg/tcl/expressionstcl/machineutils.go index b9d7ddcdc60..d0b9a21d59d 100644 --- a/pkg/tcl/expressionstcl/machineutils.go +++ b/pkg/tcl/expressionstcl/machineutils.go @@ -63,3 +63,12 @@ func (m *combinedMachine) Call(name string, args ...StaticValue) (Expression, bo } return nil, false, nil } + +func ReplacePrefixMachine(from string, to string) Machine { + return NewMachine().RegisterAccessor(func(name string) (interface{}, bool) { + if strings.HasPrefix(name, from) { + return newAccessor(to + name[len(from):]), true + } + return nil, false + }) +} diff --git a/pkg/tcl/expressionstcl/parse.go b/pkg/tcl/expressionstcl/parse.go index b30b9506da7..ea933b5884b 100644 --- a/pkg/tcl/expressionstcl/parse.go +++ b/pkg/tcl/expressionstcl/parse.go @@ -99,6 +99,15 @@ func getNextSegment(t []token) (e Expression, i int, err error) { return newNegative(e), i + 1, nil } + // Negative numbers - -5 + if t[0].Type == tokenTypeMath && operator(t[0].Value.(string)) == operatorSubtract { + e, i, err = parseNextExpression(t[1:], -1) + if err != nil { + return nil, 0, err + } + return newMath(operatorSubtract, NewValue(0), e), i + 1, nil + } + // Call - abc(a, b, c) if t[0].Type == tokenTypeAccessor && len(t) > 1 && t[1].Type == tokenTypeOpen { args := make([]Expression, 0) @@ -209,6 +218,9 @@ func CompileTemplate(tpl string) (Expression, error) { if offset < len(tpl) { e = newMath(operatorAdd, e, NewStringValue(tpl[offset:])) } + if e == nil { + return NewStringValue(""), nil + } return e.Resolve() } diff --git a/pkg/tcl/expressionstcl/parse_test.go b/pkg/tcl/expressionstcl/parse_test.go index 1c3d41cf846..0cacf644622 100644 --- a/pkg/tcl/expressionstcl/parse_test.go +++ b/pkg/tcl/expressionstcl/parse_test.go @@ -96,6 +96,7 @@ func TestBuildTemplate(t *testing.T) { } func TestCompileTemplate(t *testing.T) { + assert.Equal(t, `""`, MustCompileTemplate(``).String()) assert.Equal(t, `"abc"`, MustCompileTemplate(`abc`).String()) assert.Equal(t, `"abcxyz5"`, MustCompileTemplate(`abc{{ "xyz" }}{{ 5 }}`).String()) assert.Equal(t, `"abc50"`, MustCompileTemplate(`abc{{ 5 + 45 }}`).String()) @@ -179,6 +180,10 @@ func TestCircularResolution(t *testing.T) { assert.Contains(t, fmt.Sprintf("%v", errOnly(MustCompile(`self()`).Resolve(vm, FinalizerFail))), "call stack exceeded") } +func TestMinusNumber(t *testing.T) { + assert.Equal(t, -4.0, must(MustCompile("-4").Static().FloatValue())) +} + func TestCompileMultilineString(t *testing.T) { assert.Equal(t, `"\nabc\ndef\n"`, MustCompile(`" abc diff --git a/pkg/tcl/expressionstcl/stdlib.go b/pkg/tcl/expressionstcl/stdlib.go index 37f91bb7a44..fb6b6cb74df 100644 --- a/pkg/tcl/expressionstcl/stdlib.go +++ b/pkg/tcl/expressionstcl/stdlib.go @@ -209,6 +209,43 @@ var stdFunctions = map[string]StdFunction{ }, } +const ( + stringCastStdFn = "string" + boolCastStdFn = "bool" + intCastStdFn = "int" + floatCastStdFn = "float" +) + +func CastToString(v Expression) Expression { + if v.Static() != nil { + return NewStringValue(v.Static().Value()) + } else if v.Type() == TypeString { + return v + } + return newCall(stringCastStdFn, []Expression{v}) +} + +func CastToBool(v Expression) Expression { + if v.Type() == TypeBool { + return v + } + return newCall(boolCastStdFn, []Expression{v}) +} + +func CastToInt(v Expression) Expression { + if v.Type() == TypeInt64 { + return v + } + return newCall(intCastStdFn, []Expression{v}) +} + +func CastToFloat(v Expression) Expression { + if v.Type() == TypeFloat64 { + return v + } + return newCall(intCastStdFn, []Expression{v}) +} + func IsStdFunction(name string) bool { _, ok := stdFunctions[name] return ok diff --git a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go index 9f8b740e470..d57c046ea07 100644 --- a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go +++ b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go @@ -65,7 +65,7 @@ func MapInt32ToBoxedInteger(v *int32) *testkube.BoxedInteger { return &testkube.BoxedInteger{Value: *v} } -func MapEnvVarKubeToAPI(v corev1.EnvVar) testkube.EnvVar { +func MapEnvVarKubeToAPI(v testworkflowsv1.EnvVar) testkube.EnvVar { return testkube.EnvVar{ Name: v.Name, Value: v.Value, @@ -433,6 +433,7 @@ func MapTestWorkflowKubeToAPI(w testworkflowsv1.TestWorkflow) testkube.TestWorkf Labels: w.Labels, Annotations: w.Annotations, Created: w.CreationTimestamp.Time, + Description: w.Description, Spec: common.Ptr(MapSpecKubeToAPI(w.Spec)), } } @@ -444,6 +445,7 @@ func MapTestWorkflowTemplateKubeToAPI(w testworkflowsv1.TestWorkflowTemplate) te Labels: w.Labels, Annotations: w.Annotations, Created: w.CreationTimestamp.Time, + Description: w.Description, Spec: common.Ptr(MapTemplateSpecKubeToAPI(w.Spec)), } } diff --git a/pkg/tcl/mapperstcl/testworkflows/mappers_test.go b/pkg/tcl/mapperstcl/testworkflows/mappers_test.go index df0c37b3754..7b01a39f9d9 100644 --- a/pkg/tcl/mapperstcl/testworkflows/mappers_test.go +++ b/pkg/tcl/mapperstcl/testworkflows/mappers_test.go @@ -26,7 +26,7 @@ var ( WorkingDir: common.Ptr("/wd"), Image: "some-image", ImagePullPolicy: "IfNotPresent", - Env: []corev1.EnvVar{ + Env: []testworkflowsv1.EnvVar{ {Name: "some-naaame", Value: "some-value"}, {Name: "some-naaame", ValueFrom: &corev1.EnvVarSource{ FieldRef: &corev1.ObjectFieldSelector{ @@ -197,7 +197,7 @@ var ( WorkingDir: common.Ptr("/abc"), Image: "im-g", ImagePullPolicy: "IfNotPresent", - Env: []corev1.EnvVar{ + Env: []testworkflowsv1.EnvVar{ {Name: "abc", Value: "230"}, }, EnvFrom: []corev1.EnvFromSource{ @@ -224,7 +224,7 @@ var ( WorkingDir: common.Ptr("/aaaa"), Image: "ssss", ImagePullPolicy: "Never", - Env: []corev1.EnvVar{{Name: "xyz", Value: "bar"}}, + Env: []testworkflowsv1.EnvVar{{Name: "xyz", Value: "bar"}}, Command: common.Ptr([]string{"ab"}), Args: common.Ptr([]string{"abrgs"}), Resources: &testworkflowsv1.Resources{ diff --git a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go index b0b7455101f..9908fcb8337 100644 --- a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go +++ b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go @@ -71,8 +71,8 @@ func MapBoxedIntegerToInt32(v *testkube.BoxedInteger) *int32 { return &v.Value } -func MapEnvVarAPIToKube(v testkube.EnvVar) corev1.EnvVar { - return corev1.EnvVar{ +func MapEnvVarAPIToKube(v testkube.EnvVar) testworkflowsv1.EnvVar { + return testworkflowsv1.EnvVar{ Name: v.Name, Value: v.Value, ValueFrom: common.MapPtr(v.ValueFrom, MapEnvVarSourceAPIToKube), @@ -369,7 +369,7 @@ func MapStepArtifactsAPIToKube(v testkube.TestWorkflowStepArtifacts) testworkflo func MapRetryPolicyAPIToKube(v testkube.TestWorkflowRetryPolicy) testworkflowsv1.RetryPolicy { return testworkflowsv1.RetryPolicy{ Count: v.Count, - Until: testworkflowsv1.Expression(v.Until), + Until: v.Until, } } @@ -377,7 +377,7 @@ func MapStepAPIToKube(v testkube.TestWorkflowStep) testworkflowsv1.Step { return testworkflowsv1.Step{ StepBase: testworkflowsv1.StepBase{ Name: v.Name, - Condition: testworkflowsv1.Expression(v.Condition), + Condition: v.Condition, Negative: v.Negative, Optional: v.Optional, VirtualGroup: v.VirtualGroup, @@ -402,7 +402,7 @@ func MapIndependentStepAPIToKube(v testkube.TestWorkflowIndependentStep) testwor return testworkflowsv1.IndependentStep{ StepBase: testworkflowsv1.StepBase{ Name: v.Name, - Condition: testworkflowsv1.Expression(v.Condition), + Condition: v.Condition, Negative: v.Negative, Optional: v.Optional, VirtualGroup: v.VirtualGroup, @@ -465,7 +465,8 @@ func MapTestWorkflowAPIToKube(w testkube.TestWorkflow) testworkflowsv1.TestWorkf Annotations: w.Annotations, CreationTimestamp: metav1.Time{Time: w.Created}, }, - Spec: common.ResolvePtr(common.MapPtr(w.Spec, MapSpecAPIToKube), testworkflowsv1.TestWorkflowSpec{}), + Description: w.Description, + Spec: common.ResolvePtr(common.MapPtr(w.Spec, MapSpecAPIToKube), testworkflowsv1.TestWorkflowSpec{}), } } @@ -482,7 +483,8 @@ func MapTestWorkflowTemplateAPIToKube(w testkube.TestWorkflowTemplate) testworkf Annotations: w.Annotations, CreationTimestamp: metav1.Time{Time: w.Created}, }, - Spec: common.ResolvePtr(common.MapPtr(w.Spec, MapTemplateSpecAPIToKube), testworkflowsv1.TestWorkflowTemplateSpec{}), + Description: w.Description, + Spec: common.ResolvePtr(common.MapPtr(w.Spec, MapTemplateSpecAPIToKube), testworkflowsv1.TestWorkflowTemplateSpec{}), } } diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go new file mode 100644 index 00000000000..8fc5aa36347 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go @@ -0,0 +1,58 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "maps" + "strings" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" +) + +func GetInternalTemplateName(name string) string { + return strings.ReplaceAll(name, "/", "--") +} + +func GetDisplayTemplateName(name string) string { + return strings.ReplaceAll(name, "--", "/") +} + +func listStepTemplates(cr testworkflowsv1.Step) map[string]struct{} { + v := make(map[string]struct{}) + if cr.Template != nil { + v[GetInternalTemplateName(cr.Template.Name)] = struct{}{} + } + for i := range cr.Use { + v[GetInternalTemplateName(cr.Use[i].Name)] = struct{}{} + } + for i := range cr.Steps { + maps.Copy(v, listStepTemplates(cr.Steps[i])) + } + return v +} + +func ListTemplates(cr *testworkflowsv1.TestWorkflow) map[string]struct{} { + if cr == nil { + return nil + } + v := make(map[string]struct{}) + for i := range cr.Spec.Use { + v[GetInternalTemplateName(cr.Spec.Use[i].Name)] = struct{}{} + } + for i := range cr.Spec.Setup { + maps.Copy(v, listStepTemplates(cr.Spec.Setup[i])) + } + for i := range cr.Spec.Steps { + maps.Copy(v, listStepTemplates(cr.Spec.Steps[i])) + } + for i := range cr.Spec.After { + maps.Copy(v, listStepTemplates(cr.Spec.After[i])) + } + return v +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze_test.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze_test.go new file mode 100644 index 00000000000..9056fa24325 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze_test.go @@ -0,0 +1,85 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" +) + +var ( + refList = []testworkflowsv1.TemplateRef{ + {Name: "official/something"}, + {Name: "official--another"}, + {Name: "official/another"}, + {Name: "something"}, + } + refListWant = map[string]struct{}{"official--something": {}, "official--another": {}, "something": {}} + refList2 = []testworkflowsv1.TemplateRef{ + {Name: "official/something"}, + {Name: "another"}, + } + refList2Want = map[string]struct{}{"official--something": {}, "another": {}} + refList1Plus2Want = map[string]struct{}{"official--something": {}, "official--another": {}, "something": {}, "another": {}} +) + +func TestGetInternalTemplateName(t *testing.T) { + assert.Equal(t, "keep-same-name", GetInternalTemplateName("keep-same-name")) + assert.Equal(t, "some--namespace", GetInternalTemplateName("some--namespace")) + assert.Equal(t, "some--namespace", GetInternalTemplateName("some/namespace")) + assert.Equal(t, "some--namespace--multiple", GetInternalTemplateName("some--namespace--multiple")) + assert.Equal(t, "some--namespace--multiple", GetInternalTemplateName("some/namespace--multiple")) + assert.Equal(t, "some--namespace--multiple", GetInternalTemplateName("some/namespace/multiple")) +} + +func TestGetDisplayTemplateName(t *testing.T) { + assert.Equal(t, "keep-same-name", GetDisplayTemplateName("keep-same-name")) + assert.Equal(t, "some/namespace", GetDisplayTemplateName("some--namespace")) + assert.Equal(t, "some/namespace", GetDisplayTemplateName("some/namespace")) + assert.Equal(t, "some/namespace/multiple", GetDisplayTemplateName("some--namespace--multiple")) + assert.Equal(t, "some/namespace/multiple", GetDisplayTemplateName("some/namespace--multiple")) + assert.Equal(t, "some/namespace/multiple", GetDisplayTemplateName("some/namespace/multiple")) +} + +func TestListTemplates(t *testing.T) { + assert.Equal(t, map[string]struct{}(nil), ListTemplates(nil)) + assert.Equal(t, map[string]struct{}{}, ListTemplates(&testworkflowsv1.TestWorkflow{})) + assert.Equal(t, refListWant, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{Use: refList}, + })) + assert.Equal(t, refListWant, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{Setup: []testworkflowsv1.Step{{Use: refList}}}, + })) + assert.Equal(t, refListWant, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{Steps: []testworkflowsv1.Step{{Use: refList}}}, + })) + assert.Equal(t, refListWant, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{After: []testworkflowsv1.Step{{Use: refList}}}, + })) + assert.Equal(t, map[string]struct{}{"official--something": {}}, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{After: []testworkflowsv1.Step{{Template: &refList[0]}}}, + })) + assert.Equal(t, refListWant, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{After: []testworkflowsv1.Step{ + {Steps: []testworkflowsv1.Step{{Use: refList}}}}}, + })) + assert.Equal(t, refList1Plus2Want, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Setup: []testworkflowsv1.Step{{Steps: []testworkflowsv1.Step{{Use: refList}}}}, + After: []testworkflowsv1.Step{{Steps: []testworkflowsv1.Step{{Use: refList2}}}}, + }})) + assert.Equal(t, refList2Want, ListTemplates(&testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Setup: []testworkflowsv1.Step{{Steps: []testworkflowsv1.Step{{Use: refList2}}}}, + After: []testworkflowsv1.Step{{Steps: []testworkflowsv1.Step{{Template: &refList2[0]}}}}, + }})) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go new file mode 100644 index 00000000000..c8ccf4b46d0 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go @@ -0,0 +1,220 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "fmt" + "reflect" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/util/intstr" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/rand" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +func buildTemplate(template testworkflowsv1.TestWorkflowTemplate, cfg map[string]intstr.IntOrString) (testworkflowsv1.TestWorkflowTemplate, error) { + v, err := ApplyWorkflowTemplateConfig(template.DeepCopy(), cfg) + if err != nil { + return template, err + } + return *v, err +} + +func getTemplate(name string, templates map[string]testworkflowsv1.TestWorkflowTemplate) (tpl testworkflowsv1.TestWorkflowTemplate, err error) { + key := GetInternalTemplateName(name) + tpl, ok := templates[key] + if ok { + return tpl, nil + } + key = GetDisplayTemplateName(key) + tpl, ok = templates[key] + if ok { + return tpl, nil + } + return tpl, fmt.Errorf(`template "%s" not found`, name) +} + +func getConfiguredTemplate(name string, cfg map[string]intstr.IntOrString, templates map[string]testworkflowsv1.TestWorkflowTemplate) (tpl testworkflowsv1.TestWorkflowTemplate, err error) { + tpl, err = getTemplate(name, templates) + if err != nil { + return tpl, err + } + return buildTemplate(tpl, cfg) +} + +func InjectTemplate(workflow *testworkflowsv1.TestWorkflow, template testworkflowsv1.TestWorkflowTemplate) error { + if workflow == nil { + return nil + } + // Apply top-level configuration + workflow.Spec.Pod = MergePodConfig(template.Spec.Pod, workflow.Spec.Pod) + workflow.Spec.Job = MergeJobConfig(template.Spec.Job, workflow.Spec.Job) + + // Apply basic configuration + workflow.Spec.Content = MergeContent(template.Spec.Content, workflow.Spec.Content) + workflow.Spec.Container = MergeContainerConfig(template.Spec.Container, workflow.Spec.Container) + + // Include the steps from the template + setup := common.MapSlice(template.Spec.Setup, ConvertIndependentStepToStep) + workflow.Spec.Setup = append(setup, workflow.Spec.Setup...) + steps := common.MapSlice(template.Spec.Steps, ConvertIndependentStepToStep) + workflow.Spec.Steps = append(steps, workflow.Spec.Steps...) + after := common.MapSlice(template.Spec.After, ConvertIndependentStepToStep) + workflow.Spec.After = append(workflow.Spec.After, after...) + return nil +} + +func InjectStepTemplate(step *testworkflowsv1.Step, template testworkflowsv1.TestWorkflowTemplate) error { + if step == nil { + return nil + } + + // Apply basic configuration + step.Content = MergeContent(template.Spec.Content, step.Content) + step.Container = MergeContainerConfig(template.Spec.Container, step.Container) + + // Fast-track when the template doesn't contain any steps to run + if len(template.Spec.Setup) == 0 && len(template.Spec.Steps) == 0 && len(template.Spec.After) == 0 { + return nil + } + + // Decouple sub-steps from the template + setup := common.MapSlice(append(template.Spec.Setup, template.Spec.Steps...), ConvertIndependentStepToStep) + after := common.MapSlice(template.Spec.After, ConvertIndependentStepToStep) + + step.Steps = append(setup, append(step.Steps, after...)...) + + return nil +} + +func applyTemplatesToStep(step testworkflowsv1.Step, templates map[string]testworkflowsv1.TestWorkflowTemplate) (testworkflowsv1.Step, error) { + // Apply regular templates + for i, ref := range step.Use { + tpl, err := getConfiguredTemplate(ref.Name, ref.Config, templates) + if err != nil { + return step, errors.Wrap(err, fmt.Sprintf(".use[%d]: resolving template", i)) + } + err = InjectStepTemplate(&step, tpl) + if err != nil { + return step, errors.Wrap(err, fmt.Sprintf(".use[%d]: injecting template", i)) + } + } + step.Use = nil + + // Apply alternative template syntax + if step.Template != nil { + tpl, err := getConfiguredTemplate(step.Template.Name, step.Template.Config, templates) + if err != nil { + return step, errors.Wrap(err, ".template: resolving template") + } + isolate := testworkflowsv1.Step{} + err = InjectStepTemplate(&isolate, tpl) + if err != nil { + return step, errors.Wrap(err, ".template: injecting template") + } + + if len(isolate.Steps) > 0 { + if isolate.Container == nil && isolate.Content == nil && isolate.WorkingDir == nil { + step.Steps = append(isolate.Steps, step.Steps...) + } else { + step.Steps = append([]testworkflowsv1.Step{isolate}, step.Steps...) + } + } + + step.Template = nil + } + + // Resolve templates in the sub-steps + var err error + for i := range step.Steps { + step.Steps[i], err = applyTemplatesToStep(step.Steps[i], templates) + if err != nil { + return step, errors.Wrap(err, fmt.Sprintf(".steps[%d]", i)) + } + } + + return step, nil +} + +func FlattenStepList(steps []testworkflowsv1.Step) []testworkflowsv1.Step { + changed := false + result := make([]testworkflowsv1.Step, 0, len(steps)) + for _, step := range steps { + sub := step.Steps + step.Steps = nil + if reflect.ValueOf(step).IsZero() { + changed = true + result = append(result, sub...) + } else { + step.Steps = sub + result = append(result, step) + } + } + if !changed { + return steps + } + return result +} + +func ApplyTemplates(workflow *testworkflowsv1.TestWorkflow, templates map[string]testworkflowsv1.TestWorkflowTemplate) error { + if workflow == nil { + return nil + } + + // Encapsulate TestWorkflow configuration to not pass it into templates accidentally + random := rand.String(10) + err := expressionstcl.SimplifyStruct(workflow, expressionstcl.ReplacePrefixMachine("config.", random+".")) + if err != nil { + return err + } + defer expressionstcl.SimplifyStruct(workflow, expressionstcl.ReplacePrefixMachine(random+".", "config.")) + + // Apply top-level templates + for i, ref := range workflow.Spec.Use { + tpl, err := getConfiguredTemplate(ref.Name, ref.Config, templates) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("spec.use[%d]: resolving template", i)) + } + err = InjectTemplate(workflow, tpl) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("spec.use[%d]: injecting template", i)) + } + } + workflow.Spec.Use = nil + + // Apply templates on the step level + for i := range workflow.Spec.Setup { + workflow.Spec.Setup[i], err = applyTemplatesToStep(workflow.Spec.Setup[i], templates) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("spec.setup[%d]", i)) + } + } + for i := range workflow.Spec.Steps { + workflow.Spec.Steps[i], err = applyTemplatesToStep(workflow.Spec.Steps[i], templates) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("spec.steps[%d]", i)) + } + } + for i := range workflow.Spec.After { + workflow.Spec.After[i], err = applyTemplatesToStep(workflow.Spec.After[i], templates) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("spec.after[%d]", i)) + } + } + + // Simplify the lists + workflow.Spec.Setup = FlattenStepList(workflow.Spec.Setup) + workflow.Spec.Steps = FlattenStepList(workflow.Spec.Steps) + workflow.Spec.After = FlattenStepList(workflow.Spec.After) + + return nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go new file mode 100644 index 00000000000..7a459f26f8c --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go @@ -0,0 +1,546 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" +) + +var ( + tplPod = testworkflowsv1.TestWorkflowTemplate{ + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + Labels: map[string]string{ + "v1": "v2", + }, + }, + }, + }, + } + tplPodConfig = testworkflowsv1.TestWorkflowTemplate{ + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "department": {Type: testworkflowsv1.ParameterTypeString}, + }, + Pod: &testworkflowsv1.PodConfig{ + Labels: map[string]string{ + "department": "{{config.department}}", + }, + }, + }, + }, + } + tplEnv = testworkflowsv1.TestWorkflowTemplate{ + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Container: &testworkflowsv1.ContainerConfig{ + Env: []testworkflowsv1.EnvVar{ + {Name: "test", Value: "the"}, + }, + }, + }, + }, + } + tplSteps = testworkflowsv1.TestWorkflowTemplate{ + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + Setup: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "setup-tpl-test"}}, + }, + Steps: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "steps-tpl-test"}}, + }, + After: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "after-tpl-test"}}, + }, + }, + } + tplStepsEnv = testworkflowsv1.TestWorkflowTemplate{ + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Container: &testworkflowsv1.ContainerConfig{ + Env: []testworkflowsv1.EnvVar{ + {Name: "test", Value: "the"}, + }, + }, + }, + Setup: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "setup-tpl-test"}}, + }, + Steps: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "steps-tpl-test"}}, + }, + After: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "after-tpl-test"}}, + }, + }, + } + tplStepsConfig = testworkflowsv1.TestWorkflowTemplate{ + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "index": {Type: testworkflowsv1.ParameterTypeInteger}, + }, + }, + Setup: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "setup-tpl-test-{{ config.index }}"}}, + }, + Steps: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "steps-tpl-test-{{ config.index }}"}}, + }, + After: []testworkflowsv1.IndependentStep{ + {StepBase: testworkflowsv1.StepBase{Name: "after-tpl-test-{{ config.index }}"}}, + }, + }, + } + templates = map[string]testworkflowsv1.TestWorkflowTemplate{ + "pod": tplPod, + "podConfig": tplPodConfig, + "env": tplEnv, + "steps": tplSteps, + "stepsEnv": tplStepsEnv, + "stepsConfig": tplStepsConfig, + } + tplPodRef = testworkflowsv1.TemplateRef{Name: "pod"} + tplPodConfigRef = testworkflowsv1.TemplateRef{ + Name: "podConfig", + Config: map[string]intstr.IntOrString{ + "department": {Type: intstr.String, StrVal: "test-department"}, + }, + } + tplPodConfigRefEmpty = testworkflowsv1.TemplateRef{Name: "podConfig"} + tplEnvRef = testworkflowsv1.TemplateRef{Name: "env"} + tplStepsRef = testworkflowsv1.TemplateRef{Name: "steps"} + tplStepsEnvRef = testworkflowsv1.TemplateRef{Name: "stepsEnv"} + tplStepsConfigRef = testworkflowsv1.TemplateRef{Name: "stepsConfig", Config: map[string]intstr.IntOrString{ + "index": {Type: intstr.Int, IntVal: 20}, + }} + tplStepsConfigRefStringInvalid = testworkflowsv1.TemplateRef{Name: "stepsConfig", Config: map[string]intstr.IntOrString{ + "index": {Type: intstr.String, StrVal: "text"}, + }} + tplStepsConfigRefStringValid = testworkflowsv1.TemplateRef{Name: "stepsConfig", Config: map[string]intstr.IntOrString{ + "index": {Type: intstr.String, StrVal: "10"}, + }} + workflowPod = testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + Labels: map[string]string{ + "the": "value", + }, + }, + }, + }, + } + workflowPodConfig = testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "department": {Type: testworkflowsv1.ParameterTypeString}, + }, + Pod: &testworkflowsv1.PodConfig{ + Labels: map[string]string{ + "department": "{{config.department}}", + }, + }, + }, + }, + } + workflowSteps = testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Setup: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "setup-tpl"}}, + }, + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "steps-tpl"}}, + }, + After: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "after-tpl"}}, + }, + }, + } + basicStep = testworkflowsv1.Step{ + StepBase: testworkflowsv1.StepBase{ + Name: "basic", + Shell: "shell-command", + Container: &testworkflowsv1.ContainerConfig{ + Env: []testworkflowsv1.EnvVar{ + {Name: "XYZ", Value: "some-value"}, + }, + }, + }, + } + advancedStep = testworkflowsv1.Step{ + StepBase: testworkflowsv1.StepBase{ + Name: "basic", + Condition: "always", + Delay: "5s", + Shell: "another-shell-command", + Container: &testworkflowsv1.ContainerConfig{ + Env: []testworkflowsv1.EnvVar{ + {Name: "XYZ", Value: "some-value"}, + }, + }, + Artifacts: &testworkflowsv1.StepArtifacts{ + Paths: []string{"a", "b", "c"}, + }, + }, + Steps: []testworkflowsv1.Step{ + basicStep, + }, + } +) + +func TestApplyTemplatesMissingTemplate(t *testing.T) { + wf := workflowSteps.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{{Name: "unknown"}} + err := ApplyTemplates(wf, templates) + + assert.Error(t, err) + assert.Equal(t, err.Error(), `spec.use[0]: resolving template: template "unknown" not found`) +} + +func TestApplyTemplatesMissingConfig(t *testing.T) { + wf := workflowSteps.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplPodConfigRefEmpty} + err := ApplyTemplates(wf, templates) + + assert.Error(t, err) + assert.Contains(t, err.Error(), `spec.use[0]: resolving template:`) + assert.Contains(t, err.Error(), `config.department: unknown variable`) +} + +func TestApplyTemplatesInvalidConfig(t *testing.T) { + wf := workflowSteps.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplStepsConfigRefStringInvalid} + err := ApplyTemplates(wf, templates) + + assert.Error(t, err) + assert.Contains(t, err.Error(), `spec.use[0]: resolving template: config.index`) + assert.Contains(t, err.Error(), `error while converting value to number`) +} + +func TestApplyTemplatesConfig(t *testing.T) { + wf := workflowPod.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplPodConfigRef} + err := ApplyTemplates(wf, templates) + + want := workflowPod.DeepCopy() + want.Spec.Pod.Labels["department"] = "test-department" + + assert.NoError(t, err) + assert.Equal(t, want, wf) +} + +func TestApplyTemplatesNoConfigMismatchNoOverride(t *testing.T) { + wf := workflowPodConfig.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplPodConfigRef} + err := ApplyTemplates(wf, templates) + + want := workflowPodConfig.DeepCopy() + want.Spec.Pod.Labels["department"] = "{{config.department}}" + + assert.NoError(t, err) + assert.Equal(t, want, wf) +} + +func TestApplyTemplatesMergeTopLevelSteps(t *testing.T) { + wf := workflowSteps.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplStepsRef} + err := ApplyTemplates(wf, templates) + + want := workflowSteps.DeepCopy() + want.Spec.Setup = []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + want.Spec.Setup[0], + } + want.Spec.Steps = []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + want.Spec.Steps[0], + } + want.Spec.After = []testworkflowsv1.Step{ + want.Spec.After[0], + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + } + + assert.NoError(t, err) + assert.Equal(t, want, wf) +} + +func TestApplyTemplatesMergeMultipleTopLevelSteps(t *testing.T) { + wf := workflowSteps.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplStepsRef, tplStepsConfigRef} + err := ApplyTemplates(wf, templates) + + want := workflowSteps.DeepCopy() + want.Spec.Setup = []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + want.Spec.Setup[0], + } + want.Spec.Setup[0].Name = "setup-tpl-test-20" + want.Spec.Steps = []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + want.Spec.Steps[0], + } + want.Spec.Steps[0].Name = "steps-tpl-test-20" + want.Spec.After = []testworkflowsv1.Step{ + want.Spec.After[0], + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), + } + want.Spec.After[2].Name = "after-tpl-test-20" + + assert.NoError(t, err) + assert.Equal(t, want, wf) +} + +func TestApplyTemplatesMergeMultipleConfigurable(t *testing.T) { + wf := workflowSteps.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{tplStepsConfigRefStringValid, tplStepsConfigRef} + err := ApplyTemplates(wf, templates) + + want := workflowSteps.DeepCopy() + want.Spec.Setup = []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), + want.Spec.Setup[0], + } + want.Spec.Setup[0].Name = "setup-tpl-test-20" + want.Spec.Setup[1].Name = "setup-tpl-test-10" + want.Spec.Steps = []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), + want.Spec.Steps[0], + } + want.Spec.Steps[0].Name = "steps-tpl-test-20" + want.Spec.Steps[1].Name = "steps-tpl-test-10" + want.Spec.After = []testworkflowsv1.Step{ + want.Spec.After[0], + ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), + } + want.Spec.After[1].Name = "after-tpl-test-10" + want.Spec.After[2].Name = "after-tpl-test-20" + + assert.NoError(t, err) + assert.Equal(t, want, wf) +} + +func TestApplyTemplatesStepBasic(t *testing.T) { + s := *basicStep.DeepCopy() + s.Use = []testworkflowsv1.TemplateRef{tplEnvRef} + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + want.Container.Env = append(tplEnv.Spec.Container.Env, want.Container.Env...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepIgnorePod(t *testing.T) { + s := *basicStep.DeepCopy() + s.Use = []testworkflowsv1.TemplateRef{tplPodRef} + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepBasicIsolatedIgnore(t *testing.T) { + s := *basicStep.DeepCopy() + s.Template = &tplEnvRef + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepBasicIsolated(t *testing.T) { + s := *basicStep.DeepCopy() + s.Template = &tplStepsRef + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + }, want.Steps...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepBasicIsolatedWrapped(t *testing.T) { + s := *basicStep.DeepCopy() + s.Template = &tplStepsEnvRef + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{{ + StepBase: testworkflowsv1.StepBase{ + Container: tplStepsEnv.Spec.Container, + }, + Steps: []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsEnv.Spec.Setup[0]), + ConvertIndependentStepToStep(tplStepsEnv.Spec.Steps[0]), + ConvertIndependentStepToStep(tplStepsEnv.Spec.After[0]), + }, + }}, want.Steps...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepBasicSteps(t *testing.T) { + s := *basicStep.DeepCopy() + s.Use = []testworkflowsv1.TemplateRef{tplStepsRef} + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + }, append(want.Steps, []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + }...)...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepBasicMultipleSteps(t *testing.T) { + s := *basicStep.DeepCopy() + s.Use = []testworkflowsv1.TemplateRef{tplStepsRef, tplStepsConfigRef} + s, err := applyTemplatesToStep(s, templates) + + want := *basicStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + }, append(want.Steps, []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), + }...)...) + want.Steps[0].Name = "setup-tpl-test-20" + want.Steps[1].Name = "steps-tpl-test-20" + want.Steps[5].Name = "after-tpl-test-20" + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepAdvancedIsolated(t *testing.T) { + s := *advancedStep.DeepCopy() + s.Template = &tplStepsRef + s, err := applyTemplatesToStep(s, templates) + + want := *advancedStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + }, want.Steps...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepAdvancedIsolatedWrapped(t *testing.T) { + s := *advancedStep.DeepCopy() + s.Template = &tplStepsEnvRef + s, err := applyTemplatesToStep(s, templates) + + want := *advancedStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{{ + StepBase: testworkflowsv1.StepBase{ + Container: tplStepsEnv.Spec.Container, + }, + Steps: []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsEnv.Spec.Setup[0]), + ConvertIndependentStepToStep(tplStepsEnv.Spec.Steps[0]), + ConvertIndependentStepToStep(tplStepsEnv.Spec.After[0]), + }, + }}, want.Steps...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepAdvancedSteps(t *testing.T) { + s := *advancedStep.DeepCopy() + s.Use = []testworkflowsv1.TemplateRef{tplStepsRef} + s, err := applyTemplatesToStep(s, templates) + + want := *advancedStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + }, append(want.Steps, []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + }...)...) + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesStepAdvancedMultipleSteps(t *testing.T) { + s := *advancedStep.DeepCopy() + s.Use = []testworkflowsv1.TemplateRef{tplStepsRef, tplStepsConfigRef} + s, err := applyTemplatesToStep(s, templates) + + want := *advancedStep.DeepCopy() + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), + }, append(want.Steps, []testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplSteps.Spec.After[0]), + ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), + }...)...) + want.Steps[0].Name = "setup-tpl-test-20" + want.Steps[1].Name = "steps-tpl-test-20" + want.Steps[6].Name = "after-tpl-test-20" + + assert.NoError(t, err) + assert.Equal(t, want, s) +} + +func TestApplyTemplatesConfigOverflow(t *testing.T) { + wf := workflowPod.DeepCopy() + wf.Spec.Use = []testworkflowsv1.TemplateRef{{ + Name: "podConfig", + Config: map[string]intstr.IntOrString{ + "department": {Type: intstr.String, StrVal: "{{config.value}}"}, + }, + }} + err := ApplyTemplates(wf, templates) + + want := workflowPod.DeepCopy() + want.Spec.Pod.Labels["department"] = "{{config.value}}" + + assert.NoError(t, err) + assert.Equal(t, want, wf) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go new file mode 100644 index 00000000000..7645aba5fa4 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go @@ -0,0 +1,86 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "strconv" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/util/intstr" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +var configFinalizer = expressionstcl.PrefixMachine("config.", expressionstcl.FinalizerFail) + +func castParameter(value intstr.IntOrString, schema testworkflowsv1.ParameterSchema) (expressionstcl.Expression, error) { + v := value.StrVal + if value.Type == intstr.Int { + v = strconv.Itoa(int(value.IntVal)) + } + expr, err := expressionstcl.CompileTemplate(v) + if err != nil { + return nil, err + } + switch schema.Type { + case testworkflowsv1.ParameterTypeBoolean: + return expressionstcl.CastToBool(expr).Resolve() + case testworkflowsv1.ParameterTypeInteger: + return expressionstcl.CastToInt(expr).Resolve() + case testworkflowsv1.ParameterTypeNumber: + return expressionstcl.CastToFloat(expr).Resolve() + } + return expressionstcl.CastToString(expr).Resolve() +} + +func createConfigMachine(cfg map[string]intstr.IntOrString, schema map[string]testworkflowsv1.ParameterSchema) (expressionstcl.Machine, error) { + machine := expressionstcl.NewMachine() + for k, v := range cfg { + expr, err := castParameter(v, schema[k]) + if err != nil { + return nil, errors.Wrap(err, "config."+k) + } + machine.Register("config."+k, expr) + } + for k := range schema { + if schema[k].Default != nil { + expr, err := castParameter(*schema[k].Default, schema[k]) + if err != nil { + return nil, errors.Wrap(err, "config."+k) + } + machine.Register("config."+k, expr) + } + } + return machine, nil +} + +func ApplyWorkflowConfig(t *testworkflowsv1.TestWorkflow, cfg map[string]intstr.IntOrString) (*testworkflowsv1.TestWorkflow, error) { + if t == nil { + return t, nil + } + machine, err := createConfigMachine(cfg, t.Spec.Config) + if err != nil { + return nil, err + } + err = expressionstcl.SimplifyStruct(&t, machine, configFinalizer) + return t, err +} + +func ApplyWorkflowTemplateConfig(t *testworkflowsv1.TestWorkflowTemplate, cfg map[string]intstr.IntOrString) (*testworkflowsv1.TestWorkflowTemplate, error) { + if t == nil { + return t, nil + } + machine, err := createConfigMachine(cfg, t.Spec.Config) + if err != nil { + return nil, err + } + err = expressionstcl.SimplifyStruct(&t, machine, configFinalizer) + return t, err +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/config_test.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/config_test.go new file mode 100644 index 00000000000..e50705e9a30 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/config_test.go @@ -0,0 +1,251 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" +) + +func TestApplyConfigTestWorkflow(t *testing.T) { + cfg := map[string]intstr.IntOrString{ + "foo": {Type: intstr.Int, IntVal: 30}, + "bar": {Type: intstr.String, StrVal: "some value"}, + "baz": {Type: intstr.String, StrVal: "some {{ 30 }} value"}, + "foobar": {Type: intstr.String, StrVal: "some {{ unknown(300) }} value"}, + } + want := &testworkflowsv1.TestWorkflow{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra 30", + Labels: map[string]string{ + "some value-key": "some 30 value", + "other": "{{value}}", + }, + }, + }, + Steps: []testworkflowsv1.Step{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("some {{unknown(300)}} value {{another(500)}}"), + }, + }, + }, + }, + }, + } + got, err := ApplyWorkflowConfig(&testworkflowsv1.TestWorkflow{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra {{config.foo}}", + Labels: map[string]string{ + "{{config.bar}}-key": "{{config.baz}}", + "other": "{{value}}", + }, + }, + }, + Steps: []testworkflowsv1.Step{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("{{config.foobar}} {{another(500)}}"), + }, + }, + }, + }, + }, + }, cfg) + + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestApplyMissingConfig(t *testing.T) { + cfg := map[string]intstr.IntOrString{ + "foo": {Type: intstr.Int, IntVal: 30}, + "bar": {Type: intstr.String, StrVal: "some value"}, + "foobar": {Type: intstr.String, StrVal: "some {{ unknown(300) }} value"}, + } + _, err := ApplyWorkflowConfig(&testworkflowsv1.TestWorkflow{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra {{config.foo}}", + Labels: map[string]string{ + "{{config.bar}}-key": "{{config.baz}}", + }, + }, + }, + Steps: []testworkflowsv1.Step{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("{{config.foobar}} {{another(500)}}"), + }, + }, + }, + }, + }, + }, cfg) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "Spec: TestWorkflowSpecBase: Pod: Labels: {{config.bar}}-key") + assert.Contains(t, err.Error(), "error while accessing config.baz: unknown variable") +} + +func TestApplyConfigDefaults(t *testing.T) { + cfg := map[string]intstr.IntOrString{ + "foo": {Type: intstr.Int, IntVal: 30}, + "bar": {Type: intstr.String, StrVal: "some value"}, + "foobar": {Type: intstr.String, StrVal: "some {{ unknown(300) }} value"}, + } + want := &testworkflowsv1.TestWorkflow{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "baz": {Default: &intstr.IntOrString{Type: intstr.String, StrVal: "something"}}, + }, + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra 30", + Labels: map[string]string{ + "some value-key": "something", + }, + }, + }, + Steps: []testworkflowsv1.Step{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("some {{unknown(300)}} value {{another(500)}}"), + }, + }, + }, + }, + }, + } + got, err := ApplyWorkflowConfig(&testworkflowsv1.TestWorkflow{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "baz": {Default: &intstr.IntOrString{Type: intstr.String, StrVal: "something"}}, + }, + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra {{config.foo}}", + Labels: map[string]string{ + "{{config.bar}}-key": "{{config.baz}}", + }, + }, + }, + Steps: []testworkflowsv1.Step{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("{{config.foobar}} {{another(500)}}"), + }, + }, + }, + }, + }, + }, cfg) + + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestInvalidInteger(t *testing.T) { + cfg := map[string]intstr.IntOrString{ + "foo": {Type: intstr.String, StrVal: "some value"}, + } + _, err := ApplyWorkflowConfig(&testworkflowsv1.TestWorkflow{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Config: map[string]testworkflowsv1.ParameterSchema{ + "foo": {Type: testworkflowsv1.ParameterTypeInteger}, + }, + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "{{config.foo}}", + }, + }, + }, + }, cfg) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "config.foo: error") + assert.Contains(t, err.Error(), "error while converting value to number") +} + +func TestApplyConfigTestWorkflowTemplate(t *testing.T) { + cfg := map[string]intstr.IntOrString{ + "foo": {Type: intstr.Int, IntVal: 30}, + "bar": {Type: intstr.String, StrVal: "some value"}, + "baz": {Type: intstr.String, StrVal: "some {{ 30 }} value"}, + "foobar": {Type: intstr.String, StrVal: "some {{ unknown(300) }} value"}, + } + want := &testworkflowsv1.TestWorkflowTemplate{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra 30", + Labels: map[string]string{ + "some value-key": "some 30 value", + }, + }, + }, + Steps: []testworkflowsv1.IndependentStep{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("some {{unknown(300)}} value {{another(500)}}"), + }, + }, + }, + }, + }, + } + got, err := ApplyWorkflowTemplateConfig(&testworkflowsv1.TestWorkflowTemplate{ + Description: "{{some description here }}", + Spec: testworkflowsv1.TestWorkflowTemplateSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Pod: &testworkflowsv1.PodConfig{ + ServiceAccountName: "abra {{config.foo}}", + Labels: map[string]string{ + "{{config.bar}}-key": "{{config.baz}}", + }, + }, + }, + Steps: []testworkflowsv1.IndependentStep{ + { + StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr("{{config.foobar}} {{another(500)}}"), + }, + }, + }, + }, + }, + }, cfg) + + assert.NoError(t, err) + assert.Equal(t, want, got) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go new file mode 100644 index 00000000000..97489e9cef2 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go @@ -0,0 +1,120 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowresolver + +import ( + "maps" + + corev1 "k8s.io/api/core/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" +) + +func MergePodConfig(dst, include *testworkflowsv1.PodConfig) *testworkflowsv1.PodConfig { + if dst == nil { + return include + } else if include == nil { + return dst + } + maps.Copy(dst.Labels, include.Labels) + maps.Copy(dst.Annotations, include.Annotations) + maps.Copy(dst.NodeSelector, include.NodeSelector) + dst.ImagePullSecrets = append(dst.ImagePullSecrets, include.ImagePullSecrets...) + if include.ServiceAccountName != "" { + dst.ServiceAccountName = include.ServiceAccountName + } + return dst +} + +func MergeJobConfig(dst, include *testworkflowsv1.JobConfig) *testworkflowsv1.JobConfig { + if dst == nil { + return include + } else if include == nil { + return dst + } + maps.Copy(dst.Labels, include.Labels) + maps.Copy(dst.Annotations, include.Annotations) + return dst +} + +func MergeContentGit(dst, include *testworkflowsv1.ContentGit) *testworkflowsv1.ContentGit { + if dst == nil { + return include + } else if include == nil { + return dst + } + return include +} + +func MergeSecurityContext(dst, include *corev1.SecurityContext) *corev1.SecurityContext { + if dst == nil { + return include + } else if include == nil { + return dst + } + return include +} + +func MergeContent(dst, include *testworkflowsv1.Content) *testworkflowsv1.Content { + if dst == nil { + return include + } else if include == nil { + return dst + } + dst.Files = append(dst.Files, include.Files...) + dst.Git = MergeContentGit(dst.Git, include.Git) + return dst +} + +func MergeResources(dst, include *testworkflowsv1.Resources) *testworkflowsv1.Resources { + if dst == nil { + return include + } else if include == nil { + return dst + } + maps.Copy(dst.Requests, include.Requests) + maps.Copy(dst.Limits, include.Limits) + return dst +} + +func MergeContainerConfig(dst, include *testworkflowsv1.ContainerConfig) *testworkflowsv1.ContainerConfig { + if dst == nil { + return include + } else if include == nil { + return dst + } + if include.WorkingDir != nil { + dst.WorkingDir = include.WorkingDir + } + if include.ImagePullPolicy != "" { + dst.ImagePullPolicy = include.ImagePullPolicy + } + dst.Env = append(dst.Env, include.Env...) + dst.EnvFrom = append(dst.EnvFrom, include.EnvFrom...) + if include.Image != "" { + dst.Image = include.Image + dst.Command = include.Command + dst.Args = include.Args + } else if include.Command != nil { + dst.Command = include.Command + dst.Args = include.Args + } else if include.Args != nil { + dst.Args = include.Args + } + dst.Resources = MergeResources(dst.Resources, include.Resources) + dst.SecurityContext = MergeSecurityContext(dst.SecurityContext, include.SecurityContext) + return dst +} + +func ConvertIndependentStepToStep(step testworkflowsv1.IndependentStep) (res testworkflowsv1.Step) { + res.StepBase = step.StepBase + res.Steps = common.MapSlice(step.Steps, ConvertIndependentStepToStep) + return res +} From 36ef09c696a315b8644cc8b5fdc78fa8c1508490 Mon Sep 17 00:00:00 2001 From: Julianne Fermi Date: Tue, 27 Feb 2024 08:08:10 -0800 Subject: [PATCH 140/234] docs: Add Licensing FAQ (#5034) * Add Licensing FAQ * Delete duplicate FAQ file --- docs/docs/articles/testkube-licensing-FAQ.md | 65 ++++++++++++++++++++ docs/sidebars.js | 7 +++ 2 files changed, 72 insertions(+) create mode 100644 docs/docs/articles/testkube-licensing-FAQ.md diff --git a/docs/docs/articles/testkube-licensing-FAQ.md b/docs/docs/articles/testkube-licensing-FAQ.md new file mode 100644 index 00000000000..0ba56683639 --- /dev/null +++ b/docs/docs/articles/testkube-licensing-FAQ.md @@ -0,0 +1,65 @@ +# Testkube Licensing FAQ + +Testkube's software licensing is designed to be transparent and to support both open source and commercial use cases. This document aims to address common questions related to our licensing model. + +## Licenses + +Testkube software is distributed under two primary licenses: +- **MIT License (MIT)**: A permissive open-source license that allows for broad freedom in usage and modification. +- **Testkube Community License (TCL)**: A custom license designed to protect the Testkube community and ecosystem, covering specific advanced features. + +## Testkube Core + +Testkube Core is free to use. Most core features are licensed under the MIT license, but some core features are subject to the TCL. + +## Testkube Pro + +Testkube Pro features require a paid license from Testkube (see [pricing](https://testkube.io/pricing)) and are licensed under the Testkube Community License. + +:::note +You can find any feature's license by checking the code's file header in the Testkube repository. +::: + +### What is the TCL License? + +The Testkube Community License (TCL) is a custom license created by Testkube to cover certain aspects of the Testkube software. It was inspired by the [CockroachDB Community License](https://www.cockroachlabs.com/docs/stable/licensing-faqs#ccl) and designed to ensure that advanced features and proprietary extensions remain available and maintained for the community while allowing Testkube to sustain its development through commercial offerings. + +### Why does Testkube have a dual-licensing scheme with MIT / TCL? + +Testkube uses a dual license model to balance open source community participation with the ability to fund continued development. Core functionality is available under the permissive MIT license, while advanced features require a commercial license. This allows the community to benefit from an open source project while providing a sustainability model. + +### How does the TCL license apply to Testkube Core? + +Testkube core functionality is available under the MIT license, allowing free usage, modification and distribution. However, advanced pro features are covered under the more restrictive TCL. Contributions back to Testkube Core are welcomed, but modifications to TCL-licensed components may require reaching out to Testkube first. + +### Can I use Testkube Core for free? + +Yes, Testkube Core can be used for free. The majority of Testkube's core functionalities are available under the MIT license, which allows for free usage, modification, and distribution. + +### Does the TCL license restrict my usage of Testkube Core? + +No, the TCL license only applies to specific advanced features marked as "Pro" in the codebase. It does not restrict usage of the MIT-licensed open source components. + +### Can I make changes to Testkube Core for my own usage? + +Yes, you are free to make changes to Testkube Core components licensed under the MIT license for your own use. For components under the TCL, you must adhere to the terms of that license, which include restrictions on redistribution or commercial use, for this we advise you to reach out to us first. + +### Can I make contributions back to Testkube Core? + +Yes! Contributions are welcomed, whether bug fixes, enhancements or documentation. As long as you retain the existing MIT license, contributions can be made freely. + +## Feature Licensing + +The table below shows how certain core and pro features in the GitHub repository are licensed: + +| Feature | Core/MIT | Pro/TCL | +| :--- | :----: | :---: | +| Tests | x | | +| Basic Testsuites | x | | +| Triggers | x | | +| Executors | x | | +| Webhooks | x | | +| Sources | x | | +| Test Workflows | | x | +| Adv Testsuites | | x | + diff --git a/docs/sidebars.js b/docs/sidebars.js index 6382f868877..5dedd21521e 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -246,6 +246,13 @@ const sidebars = { }, ], }, + { + type: "category", + label: "FAQs", + items: [ + "articles/testkube-licensing-FAQ", + ], + }, ], // But you can create a sidebar manually From 7f2c566313621fb77fc1428d5ba50daba27c913e Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 28 Feb 2024 13:28:21 +0100 Subject: [PATCH 141/234] fix: panic on empty bool env (#5018) * fix: panic on empty bool env * fix: test --- pkg/executor/common.go | 2 +- pkg/executor/containerexecutor/containerexecutor_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/executor/common.go b/pkg/executor/common.go index 0a281c8dca2..00b934d258b 100644 --- a/pkg/executor/common.go +++ b/pkg/executor/common.go @@ -45,7 +45,7 @@ const ( var RunnerEnvVars = []corev1.EnvVar{ { Name: "DEBUG", - Value: os.Getenv("DEBUG"), + Value: getOr("DEBUG", "false"), }, { Name: "RUNNER_ENDPOINT", diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index f11ac56f194..2e5a34bedae 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -130,7 +130,7 @@ func TestNewExecutorJobSpecWithArgs(t *testing.T) { assert.NotNil(t, spec) wantEnvs := []corev1.EnvVar{ - {Name: "DEBUG", Value: ""}, + {Name: "DEBUG", Value: "false"}, {Name: "RUNNER_ENDPOINT", Value: ""}, {Name: "RUNNER_ACCESSKEYID", Value: ""}, {Name: "RUNNER_SECRETACCESSKEY", Value: ""}, From 2c1c3638f1ff029fa9c57868ab595c81860326a4 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Wed, 28 Feb 2024 15:02:06 +0100 Subject: [PATCH 142/234] fix: fix subscription check and templating (#5079) --- cmd/api-server/main.go | 49 ++++++++++--------- internal/app/api/v1/server.go | 2 + .../model_test_suite_base_extended.go | 48 ++++++++++++++++++ pkg/crd/templates/testsuite.tmpl | 6 +-- pkg/tcl/checktcl/organization_plan.go | 16 ++++++ pkg/tcl/checktcl/subscription.go | 22 ++++----- pkg/tcl/checktcl/subscription_test.go | 26 +++++----- 7 files changed, 118 insertions(+), 51 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 2f39e2a9fb5..e10329ce566 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -489,6 +489,27 @@ func main() { ui.ExitOnError("Creating slack loader", err) } + proContext := config.ProContext{ + APIKey: cfg.TestkubeProAPIKey, + URL: cfg.TestkubeProURL, + LogsPath: cfg.TestkubeProLogsPath, + TLSInsecure: cfg.TestkubeProTLSInsecure, + WorkerCount: cfg.TestkubeProWorkerCount, + LogStreamWorkerCount: cfg.TestkubeProLogStreamWorkerCount, + SkipVerify: cfg.TestkubeProSkipVerify, + EnvID: cfg.TestkubeProEnvID, + OrgID: cfg.TestkubeProOrgID, + Migrate: cfg.TestkubeProMigrate, + ConnectionTimeout: cfg.TestkubeProConnectionTimeout, + } + + // Check Pro/Enterprise subscription + var subscriptionChecker checktcl.SubscriptionChecker + if mode == common.ModeAgent { + subscriptionChecker, err = checktcl.NewSubscriptionChecker(ctx, proContext, grpcClient, grpcConn) + ui.ExitOnError("Failed creating subscription checker", err) + } + api := apiv1.NewTestkubeAPI( cfg.TestkubeNamespace, resultsRepository, @@ -523,31 +544,12 @@ func main() { logsStream, logGrpcClient, cfg.DisableSecretCreation, + subscriptionChecker, ) - var proContext *config.ProContext if mode == common.ModeAgent { log.DefaultLogger.Info("starting agent service") - proContext = &config.ProContext{ - APIKey: cfg.TestkubeProAPIKey, - URL: cfg.TestkubeProURL, - LogsPath: cfg.TestkubeProLogsPath, - TLSInsecure: cfg.TestkubeProTLSInsecure, - WorkerCount: cfg.TestkubeProWorkerCount, - LogStreamWorkerCount: cfg.TestkubeProLogStreamWorkerCount, - SkipVerify: cfg.TestkubeProSkipVerify, - EnvID: cfg.TestkubeProEnvID, - OrgID: cfg.TestkubeProOrgID, - Migrate: cfg.TestkubeProMigrate, - ConnectionTimeout: cfg.TestkubeProConnectionTimeout, - } - - api.WithProContext(proContext) - // Check Pro/Enterprise subscription - subscriptionChecker, err := checktcl.NewSubscriptionChecker(ctx, *proContext, grpcClient, grpcConn) - ui.WarnOnError("Creating subscription checker", err) - api.WithSubscriptionChecker(subscriptionChecker) - + api.WithProContext(&proContext) agentHandle, err := agent.NewAgent( log.DefaultLogger, api.Mux.Handler(), @@ -557,7 +559,7 @@ func main() { cfg.TestkubeClusterName, envs, features, - *proContext, + proContext, ) if err != nil { ui.ExitOnError("Starting agent", err) @@ -573,10 +575,9 @@ func main() { } // Apply Pro server enhancements - apitclv1.NewApiTCL(api, proContext, kubeClient).AppendRoutes() + apitclv1.NewApiTCL(api, &proContext, kubeClient).AppendRoutes() api.InitEvents() - if !cfg.DisableTestTriggers { triggerService := triggers.NewService( sched, diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 84948f8b5ff..dfecbd0141e 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -95,6 +95,7 @@ func NewTestkubeAPI( logsStream logsclient.Stream, logGrpcClient logsclient.StreamGetter, disableSecretCreation bool, + subscriptionChecker checktcl.SubscriptionChecker, ) TestkubeAPI { var httpConfig server.Config @@ -143,6 +144,7 @@ func NewTestkubeAPI( logsStream: logsStream, logGrpcClient: logGrpcClient, disableSecretCreation: disableSecretCreation, + SubscriptionChecker: subscriptionChecker, } // will be reused in websockets handler diff --git a/pkg/api/v1/testkube/model_test_suite_base_extended.go b/pkg/api/v1/testkube/model_test_suite_base_extended.go index 298fdc6ac0f..07f98351c06 100644 --- a/pkg/api/v1/testkube/model_test_suite_base_extended.go +++ b/pkg/api/v1/testkube/model_test_suite_base_extended.go @@ -79,4 +79,52 @@ func (t *TestSuite) QuoteTestSuiteTextFields() { } } } + for i := range t.Before { + for j := range t.Before[i].Execute { + if t.Before[i].Execute[j].ExecutionRequest != nil { + t.Before[i].Execute[j].ExecutionRequest.QuoteTestSuiteStepExecutionRequestTextFields() + } + } + } + for i := range t.After { + for j := range t.After[i].Execute { + if t.After[i].Execute[j].ExecutionRequest != nil { + t.After[i].Execute[j].ExecutionRequest.QuoteTestSuiteStepExecutionRequestTextFields() + } + } + } + for i := range t.Steps { + for j := range t.Steps[i].Execute { + if t.Steps[i].Execute[j].ExecutionRequest != nil { + t.Steps[i].Execute[j].ExecutionRequest.QuoteTestSuiteStepExecutionRequestTextFields() + } + } + } +} + +func (request *TestSuiteStepExecutionRequest) QuoteTestSuiteStepExecutionRequestTextFields() { + for i := range request.Args { + if request.Args[i] != "" { + request.Args[i] = fmt.Sprintf("%q", request.Args[i]) + } + } + + for i := range request.Command { + if request.Command[i] != "" { + request.Command[i] = fmt.Sprintf("%q", request.Command[i]) + } + } + + var fields = []*string{ + &request.JobTemplate, + &request.CronJobTemplate, + &request.ScraperTemplate, + &request.PvcTemplate, + } + + for _, field := range fields { + if *field != "" { + *field = fmt.Sprintf("%q", *field) + } + } } diff --git a/pkg/crd/templates/testsuite.tmpl b/pkg/crd/templates/testsuite.tmpl index 9b73acfebf0..cf473739cb2 100644 --- a/pkg/crd/templates/testsuite.tmpl +++ b/pkg/crd/templates/testsuite.tmpl @@ -83,7 +83,7 @@ spec: {{- if ne (len .ExecutionRequest.Args) 0 }} args: {{- range .ExecutionRequest.Args }} - - "{{ . }}" + - {{ . }} {{- end }} {{- end }} {{- if .ExecutionRequest.ArgsMode }} @@ -214,7 +214,7 @@ spec: {{- if ne (len .ExecutionRequest.Args) 0 }} args: {{- range .ExecutionRequest.Args }} - - "{{ . }}" + - {{ . }} {{- end }} {{- end }} {{- if .ExecutionRequest.ArgsMode }} @@ -345,7 +345,7 @@ spec: {{- if ne (len .ExecutionRequest.Args) 0 }} args: {{- range .ExecutionRequest.Args }} - - "{{ . }}" + - {{ . }} {{- end }} {{- end }} {{- if .ExecutionRequest.ArgsMode }} diff --git a/pkg/tcl/checktcl/organization_plan.go b/pkg/tcl/checktcl/organization_plan.go index 8d38df6ec6f..f43c65524e1 100644 --- a/pkg/tcl/checktcl/organization_plan.go +++ b/pkg/tcl/checktcl/organization_plan.go @@ -42,6 +42,22 @@ type OrganizationPlan struct { PlanStatus PlanStatus `json:"planStatus"` } +func (p OrganizationPlan) IsEnterprise() bool { + return p.TestkubeMode == OrganizationPlanTestkubeModeEnterprise +} + +func (p OrganizationPlan) IsPro() bool { + return p.TestkubeMode == OrganizationPlanTestkubeModePro +} + +func (p OrganizationPlan) IsActive() bool { + return p.PlanStatus == PlanStatusActive +} + +func (p OrganizationPlan) IsEmpty() bool { + return p.PlanStatus == "" && p.TestkubeMode == "" && !p.IsTrial +} + type GetOrganizationPlanRequest struct{} type GetOrganizationPlanResponse struct { TestkubeMode string diff --git a/pkg/tcl/checktcl/subscription.go b/pkg/tcl/checktcl/subscription.go index b6f1135b576..0a79a3805cc 100644 --- a/pkg/tcl/checktcl/subscription.go +++ b/pkg/tcl/checktcl/subscription.go @@ -23,7 +23,7 @@ import ( type SubscriptionChecker struct { proContext config.ProContext - orgPlan *OrganizationPlan + orgPlan OrganizationPlan } // NewSubscriptionChecker creates a new subscription checker using the agent token @@ -47,37 +47,37 @@ func NewSubscriptionChecker(ctx context.Context, proContext config.ProContext, c PlanStatus: PlanStatus(commandResponse.PlanStatus), } - return SubscriptionChecker{proContext: proContext, orgPlan: &subscription}, nil + return SubscriptionChecker{proContext: proContext, orgPlan: subscription}, nil } // GetCurrentOrganizationPlan returns current organization plan -func (c *SubscriptionChecker) GetCurrentOrganizationPlan() (*OrganizationPlan, error) { - if c.orgPlan == nil { - return nil, errors.New("organization plan is not set") +func (c *SubscriptionChecker) GetCurrentOrganizationPlan() (OrganizationPlan, error) { + if c.orgPlan.IsEmpty() { + return OrganizationPlan{}, errors.New("organization plan is not set") } return c.orgPlan, nil } // IsOrgPlanEnterprise checks if organization plan is enterprise func (c *SubscriptionChecker) IsOrgPlanEnterprise() (bool, error) { - if c.orgPlan == nil { + if c.orgPlan.IsEmpty() { return false, errors.New("organization plan is not set") } - return c.orgPlan.TestkubeMode == OrganizationPlanTestkubeModeEnterprise, nil + return c.orgPlan.IsEnterprise(), nil } // IsOrgPlanCloud checks if organization plan is cloud func (c *SubscriptionChecker) IsOrgPlanPro() (bool, error) { - if c.orgPlan == nil { + if c.orgPlan.IsEmpty() { return false, errors.New("organization plan is not set") } - return c.orgPlan.TestkubeMode == OrganizationPlanTestkubeModePro, nil + return c.orgPlan.IsPro(), nil } // IsOrgPlanActive checks if organization plan is active func (c *SubscriptionChecker) IsOrgPlanActive() (bool, error) { - if c.orgPlan == nil { + if c.orgPlan.IsEmpty() { return false, errors.New("organization plan is not set") } - return c.orgPlan.PlanStatus == PlanStatusActive, nil + return c.orgPlan.IsActive(), nil } diff --git a/pkg/tcl/checktcl/subscription_test.go b/pkg/tcl/checktcl/subscription_test.go index fa662477a34..8e4b8a9e1be 100644 --- a/pkg/tcl/checktcl/subscription_test.go +++ b/pkg/tcl/checktcl/subscription_test.go @@ -16,8 +16,8 @@ import ( func TestSubscriptionChecker_GetCurrentOrganizationPlan(t *testing.T) { tests := []struct { name string - orgPlan *OrganizationPlan - want *OrganizationPlan + orgPlan OrganizationPlan + want OrganizationPlan wantErr bool }{ { @@ -26,12 +26,12 @@ func TestSubscriptionChecker_GetCurrentOrganizationPlan(t *testing.T) { }, { name: "Org plan exists", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ TestkubeMode: OrganizationPlanTestkubeModeEnterprise, IsTrial: false, PlanStatus: PlanStatusActive, }, - want: &OrganizationPlan{ + want: OrganizationPlan{ TestkubeMode: OrganizationPlanTestkubeModeEnterprise, IsTrial: false, PlanStatus: PlanStatusActive, @@ -59,7 +59,7 @@ func TestSubscriptionChecker_GetCurrentOrganizationPlan(t *testing.T) { func TestSubscriptionChecker_IsOrgPlanEnterprise(t *testing.T) { tests := []struct { name string - orgPlan *OrganizationPlan + orgPlan OrganizationPlan want bool wantErr bool }{ @@ -69,7 +69,7 @@ func TestSubscriptionChecker_IsOrgPlanEnterprise(t *testing.T) { }, { name: "enterprise org plan", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ TestkubeMode: OrganizationPlanTestkubeModeEnterprise, }, want: true, @@ -77,7 +77,7 @@ func TestSubscriptionChecker_IsOrgPlanEnterprise(t *testing.T) { }, { name: "pro org plan", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ TestkubeMode: OrganizationPlanTestkubeModePro, }, want: false, @@ -104,7 +104,7 @@ func TestSubscriptionChecker_IsOrgPlanEnterprise(t *testing.T) { func TestSubscriptionChecker_IsOrgPlanPro(t *testing.T) { tests := []struct { name string - orgPlan *OrganizationPlan + orgPlan OrganizationPlan want bool wantErr bool }{ @@ -114,7 +114,7 @@ func TestSubscriptionChecker_IsOrgPlanPro(t *testing.T) { }, { name: "enterprise org plan", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ TestkubeMode: OrganizationPlanTestkubeModeEnterprise, }, want: false, @@ -122,7 +122,7 @@ func TestSubscriptionChecker_IsOrgPlanPro(t *testing.T) { }, { name: "pro org plan", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ TestkubeMode: OrganizationPlanTestkubeModePro, }, want: true, @@ -149,7 +149,7 @@ func TestSubscriptionChecker_IsOrgPlanPro(t *testing.T) { func TestSubscriptionChecker_IsOrgPlanActive(t *testing.T) { tests := []struct { name string - orgPlan *OrganizationPlan + orgPlan OrganizationPlan want bool wantErr bool }{ @@ -159,7 +159,7 @@ func TestSubscriptionChecker_IsOrgPlanActive(t *testing.T) { }, { name: "active org plan", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ PlanStatus: PlanStatusActive, }, want: true, @@ -167,7 +167,7 @@ func TestSubscriptionChecker_IsOrgPlanActive(t *testing.T) { }, { name: "inactive org plan", - orgPlan: &OrganizationPlan{ + orgPlan: OrganizationPlan{ PlanStatus: PlanStatusUnpaid, }, want: false, From 6d00e0a159bfa1661afd194e4e163264c9ced0fd Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 28 Feb 2024 17:54:05 +0300 Subject: [PATCH 143/234] fix: multiple service accounts --- cmd/api-server/main.go | 22 ++++++++++++++++-- .../commands/tests/renderer/execution_obj.go | 1 + internal/config/config.go | 1 + pkg/executor/client/job.go | 16 ++++++++----- .../containerexecutor/containerexecutor.go | 12 ++++++---- .../containerexecutor_test.go | 6 ++--- pkg/executor/containerexecutor/tmpl.go | 4 ++-- pkg/scheduler/test_scheduler.go | 4 ++-- pkg/tcl/schedulertcl/test_scheduler.go | 23 ++++++++++++++++++- 9 files changed, 69 insertions(+), 20 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 09b85876c13..1aeb87a3878 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -18,6 +18,7 @@ import ( "github.com/kubeshop/testkube/pkg/imageinspector" apitclv1 "github.com/kubeshop/testkube/pkg/tcl/apitcl/v1" "github.com/kubeshop/testkube/pkg/tcl/checktcl" + "github.com/kubeshop/testkube/pkg/tcl/schedulertcl" "go.mongodb.org/mongo-driver/mongo" "google.golang.org/grpc" @@ -389,11 +390,28 @@ func main() { ui.ExitOnError("Creating job templates", err) } + serviceAccountNames := map[string]string{ + cfg.TestkubeNamespace: cfg.JobServiceAccountName, + } + + // Pro edition only (tcl protected code) + if cfg.TestkubeExecutionNamespaces != "" { + ok, err := subscriptionChecker.IsOrgPlanActive() + if err != nil { + ui.ExitOnError("execution namespace is a pro feature", err) + } + if !ok { + ui.ExitOnError("execution namespace is not available", fmt.Errorf("inactive subscription plan")) + } + + serviceAccountNames = schedulertcl.GetServiceAccountNamesFromConfig(serviceAccountNames, cfg.TestkubeExecutionNamespaces) + } + executor, err := client.NewJobExecutor( resultsRepository, images, jobTemplates, - cfg.JobServiceAccountName, + serviceAccountNames, metrics, eventsEmitter, configMapConfig, @@ -438,7 +456,7 @@ func main() { images, containerTemplates, inspector, - cfg.JobServiceAccountName, + serviceAccountNames, metrics, eventsEmitter, configMapConfig, diff --git a/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go b/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go index 7db9284c400..b4a6c895a1e 100644 --- a/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go +++ b/cmd/kubectl-testkube/commands/tests/renderer/execution_obj.go @@ -22,6 +22,7 @@ func ExecutionRenderer(client client.Client, ui *ui.UI, obj interface{}) error { ui.Warn("Number: ", fmt.Sprintf("%d", execution.Number)) } ui.Warn("Test name: ", execution.TestName) + ui.Warn("Test namespace: ", execution.TestNamespace) ui.Warn("Type: ", execution.TestType) if execution.ExecutionResult != nil && execution.ExecutionResult.Status != nil { ui.Warn("Status: ", string(*execution.ExecutionResult.Status)) diff --git a/internal/config/config.go b/internal/config/config.go index 4d5cb549ad2..38f5c964955 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -97,6 +97,7 @@ type Config struct { LogServerKeyFile string `envconfig:"LOG_SERVER_KEY_FILE" default:""` LogServerCAFile string `envconfig:"LOG_SERVER_CA_FILE" default:""` DisableSecretCreation bool `envconfig:"DISABLE_SECRET_CREATION" default:"false"` + TestkubeExecutionNamespaces string `envconfig:"TESTKUBE_EXECUTION_NAMESPACES" default:""` // DEPRECATED: Use TestkubeProAPIKey instead TestkubeCloudAPIKey string `envconfig:"TESTKUBE_CLOUD_API_KEY" default:""` diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index 1022db5dfdd..139df974860 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -79,7 +79,7 @@ func NewJobExecutor( repo result.Repository, images executor.Images, templates executor.Templates, - serviceAccountName string, + serviceAccountNames map[string]string, metrics ExecutionCounter, emiter *event.Emitter, configMap config.Repository, @@ -97,13 +97,17 @@ func NewJobExecutor( logsStream logsclient.Stream, features featureflags.FeatureFlags, ) (client *JobExecutor, err error) { + if serviceAccountNames == nil { + serviceAccountNames = make(map[string]string) + } + return &JobExecutor{ ClientSet: clientset, Repository: repo, Log: log.DefaultLogger, images: images, templates: templates, - serviceAccountName: serviceAccountName, + serviceAccountNames: serviceAccountNames, metrics: metrics, Emitter: emiter, configMap: configMap, @@ -134,7 +138,7 @@ type JobExecutor struct { Cmd string images executor.Images templates executor.Templates - serviceAccountName string + serviceAccountNames map[string]string metrics ExecutionCounter Emitter *event.Emitter configMap config.Repository @@ -320,7 +324,7 @@ func (c *JobExecutor) MonitorJobForTimeout(ctx context.Context, jobName, namespa func (c *JobExecutor) CreateJob(ctx context.Context, execution testkube.Execution, options ExecuteOptions) error { jobs := c.ClientSet.BatchV1().Jobs(execution.TestNamespace) jobOptions, err := NewJobOptions(c.Log, c.templatesClient, c.images, c.templates, - c.serviceAccountName, c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) + c.serviceAccountNames, c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) if err != nil { return err } @@ -881,7 +885,7 @@ func NewJobSpec(log *zap.SugaredLogger, options JobOptions) (*batchv1.Job, error } func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface, images executor.Images, - templates executor.Templates, serviceAccountName, registry, clusterID, apiURI string, + templates executor.Templates, serviceAccountNames map[string]string, registry, clusterID, apiURI string, execution testkube.Execution, options ExecuteOptions, natsURI string, debug bool) (jobOptions JobOptions, err error) { jsn, err := json.Marshal(execution) if err != nil { @@ -935,7 +939,7 @@ func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface } jobOptions.Variables = execution.Variables - jobOptions.ServiceAccountName = serviceAccountName + jobOptions.ServiceAccountName = serviceAccountNames[execution.TestNamespace] jobOptions.Registry = registry jobOptions.ClusterID = clusterID diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index 18dff65ffa0..a4e8f669256 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -61,7 +61,7 @@ func NewContainerExecutor( images executor.Images, templates executor.Templates, imageInspector imageinspector.Inspector, - serviceAccountName string, + serviceAccountNames map[string]string, metrics ExecutionCounter, emiter EventEmitter, configMap config.Repository, @@ -84,6 +84,10 @@ func NewContainerExecutor( return client, err } + if serviceAccountNames == nil { + serviceAccountNames = make(map[string]string) + } + return &ContainerExecutor{ clientSet: clientSet, repository: repo, @@ -92,7 +96,7 @@ func NewContainerExecutor( templates: templates, imageInspector: imageInspector, configMap: configMap, - serviceAccountName: serviceAccountName, + serviceAccountNames: serviceAccountNames, metrics: metrics, emitter: emiter, testsClient: testsClient, @@ -126,7 +130,7 @@ type ContainerExecutor struct { metrics ExecutionCounter emitter EventEmitter configMap config.Repository - serviceAccountName string + serviceAccountNames map[string]string testsClient testsv3.Interface executorsClient executorsclientv1.Interface testExecutionsClient testexecutionsv1.Interface @@ -300,7 +304,7 @@ func (c *ContainerExecutor) createJob(ctx context.Context, execution testkube.Ex } jobOptions, err := NewJobOptions(c.log, c.templatesClient, c.images, c.templates, inspector, - c.serviceAccountName, c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) + c.serviceAccountNames, c.registry, c.clusterID, c.apiURI, execution, options, c.natsURI, c.debug) if err != nil { return nil, err } diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 94eac444f2e..ab2b9063183 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -210,7 +210,7 @@ func TestNewExecutorJobSpecWithWorkingDirRelative(t *testing.T) { executor.Images{}, executor.Templates{}, mockInspector, - "", + map[string]string{}, "", "", "", @@ -257,7 +257,7 @@ func TestNewExecutorJobSpecWithWorkingDirAbsolute(t *testing.T) { executor.Images{}, executor.Templates{}, mockInspector, - "", + map[string]string{}, "", "", "", @@ -303,7 +303,7 @@ func TestNewExecutorJobSpecWithoutWorkingDir(t *testing.T) { executor.Images{}, executor.Templates{}, mockInspector, - "", + map[string]string{}, "", "", "", diff --git a/pkg/executor/containerexecutor/tmpl.go b/pkg/executor/containerexecutor/tmpl.go index e1ce0822867..4898e105921 100644 --- a/pkg/executor/containerexecutor/tmpl.go +++ b/pkg/executor/containerexecutor/tmpl.go @@ -206,7 +206,7 @@ func NewScraperJobSpec(log *zap.SugaredLogger, options *JobOptions) (*batchv1.Jo // TODO extract JobOptions for both container and job executor to common package in separate PR // NewJobOptions provides job options for templates func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface, images executor.Images, - templates executor.Templates, inspector imageinspector.Inspector, serviceAccountName, registry, clusterID, apiURI string, + templates executor.Templates, inspector imageinspector.Inspector, serviceAccountNames map[string]string, registry, clusterID, apiURI string, execution testkube.Execution, options client.ExecuteOptions, natsUri string, debug bool) (*JobOptions, error) { jobOptions := NewJobOptionsFromExecutionOptions(options) if execution.PreRunScript != "" || execution.PostRunScript != "" { @@ -306,7 +306,7 @@ func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface } jobOptions.Variables = execution.Variables - jobOptions.ServiceAccountName = serviceAccountName + jobOptions.ServiceAccountName = serviceAccountNames[execution.TestNamespace] jobOptions.Registry = registry jobOptions.ClusterID = clusterID jobOptions.APIURI = apiURI diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 9bdb561984b..48d59510c16 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -277,7 +277,7 @@ func newExecutionFromExecutionOptions(subscriptionChecker checktcl.SubscriptionC execution.SlavePodRequest = options.Request.SlavePodRequest // Pro edition only (tcl protected code) - if schedulertcl.HasExecutionNamespace(options.Request) { + if schedulertcl.HasExecutionNamespace(&options.Request) { ok, err := subscriptionChecker.IsOrgPlanActive() if err != nil { return execution, fmt.Errorf("execution namespace is a pro feature: %w", err) @@ -418,7 +418,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe } // Pro edition only (tcl protected code) - if schedulertcl.HasExecutionNamespace(request) { + if schedulertcl.HasExecutionNamespace(test.ExecutionRequest) { ok, err := s.subscriptionChecker.IsOrgPlanActive() if err != nil { return options, fmt.Errorf("execution namespace is a pro feature: %w", err) diff --git a/pkg/tcl/schedulertcl/test_scheduler.go b/pkg/tcl/schedulertcl/test_scheduler.go index 61640c5342f..ad96edf5ff7 100644 --- a/pkg/tcl/schedulertcl/test_scheduler.go +++ b/pkg/tcl/schedulertcl/test_scheduler.go @@ -9,6 +9,8 @@ package schedulertcl import ( + "strings" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) @@ -37,6 +39,25 @@ func GetExecuteOptions(sourceRequest *testkube.ExecutionRequest, } // HasExecutionNamespace checks whether execution has execution namespace -func HasExecutionNamespace(request testkube.ExecutionRequest) bool { +func HasExecutionNamespace(request *testkube.ExecutionRequest) bool { return request.ExecutionNamespace != "" } + +// GetServiceAccountNamesFromConfig returns service account names from config +func GetServiceAccountNamesFromConfig(serviceAccountNames map[string]string, config string) map[string]string { + if serviceAccountNames == nil { + serviceAccountNames = make(map[string]string) + } + + items := strings.Split(config, ",") + for _, item := range items { + elements := strings.Split(item, "=") + if len(elements) != 2 { + continue + } + + serviceAccountNames[elements[0]] = elements[1] + } + + return serviceAccountNames +} From 4362b7c58a2fe7ab046c996b2a417b78830a0f2e Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 28 Feb 2024 18:03:34 +0300 Subject: [PATCH 144/234] fix: dep update --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 38ef07c912d..e63367ab3d5 100644 --- a/go.mod +++ b/go.mod @@ -24,9 +24,10 @@ require ( github.com/gookit/color v1.5.3 github.com/gorilla/websocket v1.5.0 github.com/joshdk/go-junit v1.0.0 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223125321-31fd5c4c87c7 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240228150136-4ca5e27ff12b github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 @@ -95,7 +96,6 @@ require ( github.com/itchyny/gojq v0.12.14 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.2.3 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index 6898b621f53..c151bd96284 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223125321-31fd5c4c87c7 h1:RAig1Q+dKwvC29vyIxPvHPsm9fDsauqkqgAkRy7T074= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240223125321-31fd5c4c87c7/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240228150136-4ca5e27ff12b h1:Z8peqji/1wu/yyLGqHn2iPvdWWhIHVxCGMiALvtuVGk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240228150136-4ca5e27ff12b/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= From 470d89a99a839c8ab3318f68ecfd590406ef9c61 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 28 Feb 2024 20:37:47 +0300 Subject: [PATCH 145/234] fix: add active enterprise check --- cmd/api-server/main.go | 9 +--- pkg/scheduler/test_scheduler.go | 17 +++----- pkg/secret/client.go | 11 +++-- pkg/secret/mock_client.go | 13 ++++-- pkg/tcl/checktcl/subscription.go | 19 +++++++++ pkg/tcl/checktcl/subscription_test.go | 59 +++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 26 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index be8677e82e2..c7105d9eba0 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -417,13 +417,8 @@ func main() { // Pro edition only (tcl protected code) if cfg.TestkubeExecutionNamespaces != "" { - ok, err := subscriptionChecker.IsOrgPlanActive() - if err != nil { - ui.ExitOnError("execution namespace is a pro feature", err) - } - if !ok { - ui.ExitOnError("execution namespace is not available", fmt.Errorf("inactive subscription plan")) - } + err := subscriptionChecker.IsActiveOrgPlanEnterpriseForFeature("execution namespace") + ui.ExitOnError("Subscription checking", err) serviceAccountNames = schedulertcl.GetServiceAccountNamesFromConfig(serviceAccountNames, cfg.TestkubeExecutionNamespaces) } diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 48d59510c16..a7a481366d4 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -236,6 +236,7 @@ func (s *Scheduler) createSecretsReferences(execution *testkube.Execution) (err secretName, labels, secrets, + execution.TestNamespace, ) } @@ -278,12 +279,8 @@ func newExecutionFromExecutionOptions(subscriptionChecker checktcl.SubscriptionC // Pro edition only (tcl protected code) if schedulertcl.HasExecutionNamespace(&options.Request) { - ok, err := subscriptionChecker.IsOrgPlanActive() - if err != nil { - return execution, fmt.Errorf("execution namespace is a pro feature: %w", err) - } - if !ok { - return execution, fmt.Errorf("execution namespace is not available: inactive subscription plan") + if err := subscriptionChecker.IsActiveOrgPlanEnterpriseForFeature("execution namespace"); err != nil { + return execution, err } execution = schedulertcl.NewExecutionFromExecutionOptions(options.Request, execution) @@ -419,12 +416,8 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe // Pro edition only (tcl protected code) if schedulertcl.HasExecutionNamespace(test.ExecutionRequest) { - ok, err := s.subscriptionChecker.IsOrgPlanActive() - if err != nil { - return options, fmt.Errorf("execution namespace is a pro feature: %w", err) - } - if !ok { - return options, fmt.Errorf("execution namespace is not available: inactive subscription plan") + if err = s.subscriptionChecker.IsActiveOrgPlanEnterpriseForFeature("execution namespace"); err != nil { + return options, err } request = schedulertcl.GetExecuteOptions(test.ExecutionRequest, request) diff --git a/pkg/secret/client.go b/pkg/secret/client.go index 866a60a7896..7358a896be3 100644 --- a/pkg/secret/client.go +++ b/pkg/secret/client.go @@ -21,7 +21,7 @@ type Interface interface { Get(id string, namespace ...string) (map[string]string, error) GetObject(id string) (*v1.Secret, error) List(all bool) (map[string]map[string]string, error) - Create(id string, labels, stringData map[string]string) error + Create(id string, labels, stringData map[string]string, namespace ...string) error Apply(id string, labels, stringData map[string]string) error Update(id string, labels, stringData map[string]string) error Delete(id string) error @@ -115,8 +115,13 @@ func (c *Client) List(all bool) (map[string]map[string]string, error) { } // Create is a method to create new secret -func (c *Client) Create(id string, labels, stringData map[string]string) error { - secretsClient := c.ClientSet.CoreV1().Secrets(c.Namespace) +func (c *Client) Create(id string, labels, stringData map[string]string, namespace ...string) error { + ns := c.Namespace + if len(namespace) != 0 { + ns = namespace[0] + } + + secretsClient := c.ClientSet.CoreV1().Secrets(ns) ctx := context.Background() secretSpec := NewSpec(id, c.Namespace, labels, stringData) diff --git a/pkg/secret/mock_client.go b/pkg/secret/mock_client.go index 6d578613785..90bbe750c35 100644 --- a/pkg/secret/mock_client.go +++ b/pkg/secret/mock_client.go @@ -49,17 +49,22 @@ func (mr *MockInterfaceMockRecorder) Apply(arg0, arg1, arg2 interface{}) *gomock } // Create mocks base method. -func (m *MockInterface) Create(arg0 string, arg1, arg2 map[string]string) error { +func (m *MockInterface) Create(arg0 string, arg1, arg2 map[string]string, arg3 ...string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. -func (mr *MockInterfaceMockRecorder) Create(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockInterfaceMockRecorder) Create(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockInterface)(nil).Create), arg0, arg1, arg2) + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockInterface)(nil).Create), varargs...) } // Delete mocks base method. diff --git a/pkg/tcl/checktcl/subscription.go b/pkg/tcl/checktcl/subscription.go index 0a79a3805cc..cb426e96b80 100644 --- a/pkg/tcl/checktcl/subscription.go +++ b/pkg/tcl/checktcl/subscription.go @@ -11,6 +11,7 @@ package checktcl import ( "context" "encoding/json" + "fmt" "github.com/pkg/errors" "google.golang.org/grpc" @@ -81,3 +82,21 @@ func (c *SubscriptionChecker) IsOrgPlanActive() (bool, error) { } return c.orgPlan.IsActive(), nil } + +// IsActiveOrgPlanEnterpriseForFeature checks if organization plan is active and enterprise for feature +func (c *SubscriptionChecker) IsActiveOrgPlanEnterpriseForFeature(featureName string) error { + plan, err := c.GetCurrentOrganizationPlan() + if err != nil { + return errors.Wrap(err, fmt.Sprintf("%s is a commercial feature", featureName)) + } + + if !plan.IsActive() { + return errors.New(fmt.Sprintf("%s is not available: inactive subscription plan", featureName)) + } + + if !plan.IsEnterprise() { + return errors.New(fmt.Sprintf("%s is not allowed: wrong subscription plan", featureName)) + } + + return nil +} diff --git a/pkg/tcl/checktcl/subscription_test.go b/pkg/tcl/checktcl/subscription_test.go index 8e4b8a9e1be..80182a7080a 100644 --- a/pkg/tcl/checktcl/subscription_test.go +++ b/pkg/tcl/checktcl/subscription_test.go @@ -9,8 +9,11 @@ package checktcl import ( + "fmt" "reflect" "testing" + + "github.com/stretchr/testify/assert" ) func TestSubscriptionChecker_GetCurrentOrganizationPlan(t *testing.T) { @@ -190,3 +193,59 @@ func TestSubscriptionChecker_IsOrgPlanActive(t *testing.T) { }) } } + +func TestSubscriptionChecker_IsActiveOrgPlanEnterpriseForFeature(t *testing.T) { + featureName := "feature" + tests := []struct { + name string + orgPlan OrganizationPlan + err error + }{ + { + name: "enterprise active org plan", + orgPlan: OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModeEnterprise, + IsTrial: false, + PlanStatus: PlanStatusActive, + }, + err: nil, + }, + { + name: "no org plan", + err: fmt.Errorf("%s is a commercial feature: organization plan is not set", featureName), + }, + { + name: "enterprise inactive org plan", + orgPlan: OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModeEnterprise, + IsTrial: false, + PlanStatus: PlanStatusUnpaid, + }, + err: fmt.Errorf("%s is not available: inactive subscription plan", featureName), + }, + { + name: "non enterprise actibe org plan", + orgPlan: OrganizationPlan{ + TestkubeMode: OrganizationPlanTestkubeModePro, + IsTrial: false, + PlanStatus: PlanStatusActive, + }, + err: fmt.Errorf("%s is not allowed: wrong subscription plan", featureName), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &SubscriptionChecker{ + orgPlan: tt.orgPlan, + } + + err := c.IsActiveOrgPlanEnterpriseForFeature(featureName) + if tt.err != nil { + assert.EqualError(t, err, tt.err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} From 915e90f04a48f168af9292680514167c49e02ff9 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 28 Feb 2024 20:40:41 +0300 Subject: [PATCH 146/234] fix: error check --- cmd/api-server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index c7105d9eba0..b67096a5e78 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -417,7 +417,7 @@ func main() { // Pro edition only (tcl protected code) if cfg.TestkubeExecutionNamespaces != "" { - err := subscriptionChecker.IsActiveOrgPlanEnterpriseForFeature("execution namespace") + err = subscriptionChecker.IsActiveOrgPlanEnterpriseForFeature("execution namespace") ui.ExitOnError("Subscription checking", err) serviceAccountNames = schedulertcl.GetServiceAccountNamesFromConfig(serviceAccountNames, cfg.TestkubeExecutionNamespaces) From 42ac0a320ec617bf306afc580709bfc71988499e Mon Sep 17 00:00:00 2001 From: Catalin <20538711+devcatalin@users.noreply.github.com> Date: Thu, 29 Feb 2024 12:46:38 +0200 Subject: [PATCH 147/234] Docs: information about testkube-run-action being deprecated (#5088) * Update run-tests-with-github-actions.md * docs: update sidebar --- .../articles/run-tests-with-github-actions.md | 28 ++++++++++++++++++- docs/sidebars.js | 17 +++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/docs/articles/run-tests-with-github-actions.md b/docs/docs/articles/run-tests-with-github-actions.md index eccde46c9af..29ddda7e6af 100644 --- a/docs/docs/articles/run-tests-with-github-actions.md +++ b/docs/docs/articles/run-tests-with-github-actions.md @@ -1,5 +1,31 @@ # Run Tests with GitHub Actions -**If you need more control over your flow or to access a private cluster, use [Testkube Action](https://github.com/marketplace/actions/testkube-action) instead.** +**The `kubeshop/testkube-run-action` has been deprecated and won't receive further updates. Use the [Testkube Action](https://github.com/marketplace/actions/testkube-action) instead.** + +# Migrate from testkube-run-action to setup-testkube + +1. Change the `uses` property from `kubeshop/testkube-run-action@v1` to `kubeshop/setuo-testkube@v1`. + +```yaml +uses: kubeshop/setuo-testkube@v1 +``` +2. Remove any usage of Test or Test Suite args from the `with` block. +3. Use shell scripts to run testkube CLI commands directly: +```yaml +steps: + # Setup Testkube + - uses: kubeshop/setup-testkube@v1 + # Pro and Enterprise args are still available + with: + organization: ${{ secrets.TkOrganization }} + environment: ${{ secrets.TkEnvironment }} + token: ${{ secrets.TkToken }} + # Use CLI with a shell script + - run: | + # Run one or multiple testkube CLI commands, passing any arguments you need + testkube run test some-test-name -f +``` + +# Deprecated usage information: **Run on Testkube** is a GitHub Action for running tests on the Testkube platform. diff --git a/docs/sidebars.js b/docs/sidebars.js index 5dedd21521e..e16bccd7a97 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -101,13 +101,26 @@ const sidebars = { id: "articles/cicd-overview", }, items: [ - "articles/github-actions", + { + type: "category", + label: "Github Actions", + link: { + type: "doc", + id: "articles/github-actions" + }, + items: [ + { + type: "doc", + id: "articles/run-tests-with-github-actions", + label: "Migrate from testkube-run-action" + } + ] + }, "articles/gitlab", "articles/jenkins", "articles/jenkins-ui", "articles/azure", "articles/circleci", - "articles/run-tests-with-github-actions", "articles/testkube-cli-docker", { type: "category", From 9d4495fcc384e8b215a352578fa0c2540858bdd8 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 29 Feb 2024 16:29:23 +0300 Subject: [PATCH 148/234] fix: support execution namespace secret vars --- pkg/scheduler/test_scheduler.go | 10 +++++----- pkg/scheduler/test_scheduler_test.go | 2 +- pkg/secret/client.go | 2 +- pkg/tcl/schedulertcl/test_scheduler.go | 7 ++++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index a7a481366d4..49f5f82c724 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -72,7 +72,7 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request request.TestSecretUUID = secretUUID // merge available data into execution options test spec, executor spec, request, test id - options, err := s.getExecuteOptions(test.Namespace, test.Name, request) + options, err := s.getExecuteOptions(test.Name, request) if err != nil { return s.handleExecutionError(ctx, execution, "can't get execute options: %w", err) } @@ -289,7 +289,7 @@ func newExecutionFromExecutionOptions(subscriptionChecker checktcl.SubscriptionC return execution, nil } -func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.ExecutionRequest) (options client.ExecuteOptions, err error) { +func (s *Scheduler) getExecuteOptions(id string, request testkube.ExecutionRequest) (options client.ExecuteOptions, err error) { // get test content from kubernetes CRs testCR, err := s.testsClient.Get(id) if err != nil { @@ -459,7 +459,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe continue } - data, err := s.configMapClient.Get(context.Background(), configMap.Reference.Name, namespace) + data, err := s.configMapClient.Get(context.Background(), configMap.Reference.Name, request.Namespace) if err != nil { return options, errors.Errorf("can't get config map: %v", err) } @@ -479,7 +479,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe continue } - data, err := s.secretClient.Get(secret.Reference.Name, namespace) + data, err := s.secretClient.Get(secret.Reference.Name, request.Namespace) if err != nil { return options, errors.Errorf("can't get secret: %v", err) } @@ -513,7 +513,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe return client.ExecuteOptions{ TestName: id, - Namespace: namespace, + Namespace: request.Namespace, TestSpec: testCR.Spec, ExecutorName: executorCR.ObjectMeta.Name, ExecutorSpec: executorCR.Spec, diff --git a/pkg/scheduler/test_scheduler_test.go b/pkg/scheduler/test_scheduler_test.go index dbc3bc69a4d..eb143e51fea 100644 --- a/pkg/scheduler/test_scheduler_test.go +++ b/pkg/scheduler/test_scheduler_test.go @@ -176,7 +176,7 @@ func TestGetExecuteOptions(t *testing.T) { SlavePodRequest: &testkube.PodRequest{}, } - got, err := sc.getExecuteOptions("namespace", "id", req) + got, err := sc.getExecuteOptions("id", req) assert.NoError(t, err) want := client.ExecuteOptions{ diff --git a/pkg/secret/client.go b/pkg/secret/client.go index 7358a896be3..3f3b6ff9b6f 100644 --- a/pkg/secret/client.go +++ b/pkg/secret/client.go @@ -124,7 +124,7 @@ func (c *Client) Create(id string, labels, stringData map[string]string, namespa secretsClient := c.ClientSet.CoreV1().Secrets(ns) ctx := context.Background() - secretSpec := NewSpec(id, c.Namespace, labels, stringData) + secretSpec := NewSpec(id, ns, labels, stringData) if _, err := secretsClient.Create(ctx, secretSpec, metav1.CreateOptions{}); err != nil { return err } diff --git a/pkg/tcl/schedulertcl/test_scheduler.go b/pkg/tcl/schedulertcl/test_scheduler.go index ad96edf5ff7..4d019a9b67f 100644 --- a/pkg/tcl/schedulertcl/test_scheduler.go +++ b/pkg/tcl/schedulertcl/test_scheduler.go @@ -17,9 +17,6 @@ import ( // NewExecutionFromExecutionOptions creates new execution from execution options func NewExecutionFromExecutionOptions(request testkube.ExecutionRequest, execution testkube.Execution) testkube.Execution { execution.ExecutionNamespace = request.ExecutionNamespace - if execution.ExecutionNamespace != "" { - execution.TestNamespace = execution.ExecutionNamespace - } return execution } @@ -35,6 +32,10 @@ func GetExecuteOptions(sourceRequest *testkube.ExecutionRequest, destinationRequest.ExecutionNamespace = sourceRequest.ExecutionNamespace } + if destinationRequest.ExecutionNamespace != "" { + destinationRequest.Namespace = destinationRequest.ExecutionNamespace + } + return destinationRequest } From 9a6a35ca01a364866693752d10f0275b168a0184 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 29 Feb 2024 19:18:13 +0300 Subject: [PATCH 149/234] fix: default namespace --- pkg/scheduler/test_scheduler.go | 5 +++-- pkg/scheduler/test_scheduler_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 49f5f82c724..f49df1fe8bd 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -72,7 +72,7 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request request.TestSecretUUID = secretUUID // merge available data into execution options test spec, executor spec, request, test id - options, err := s.getExecuteOptions(test.Name, request) + options, err := s.getExecuteOptions(test.Namespace, test.Name, request) if err != nil { return s.handleExecutionError(ctx, execution, "can't get execute options: %w", err) } @@ -289,7 +289,7 @@ func newExecutionFromExecutionOptions(subscriptionChecker checktcl.SubscriptionC return execution, nil } -func (s *Scheduler) getExecuteOptions(id string, request testkube.ExecutionRequest) (options client.ExecuteOptions, err error) { +func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.ExecutionRequest) (options client.ExecuteOptions, err error) { // get test content from kubernetes CRs testCR, err := s.testsClient.Get(id) if err != nil { @@ -315,6 +315,7 @@ func (s *Scheduler) getExecuteOptions(id string, request testkube.ExecutionReque test := testsmapper.MapTestCRToAPI(*testCR) + request.Namespace = namespace if test.ExecutionRequest != nil { // Test variables lowest priority, then test suite, then test suite execution / test execution request.Variables = mergeVariables(test.ExecutionRequest.Variables, request.Variables) diff --git a/pkg/scheduler/test_scheduler_test.go b/pkg/scheduler/test_scheduler_test.go index eb143e51fea..dbc3bc69a4d 100644 --- a/pkg/scheduler/test_scheduler_test.go +++ b/pkg/scheduler/test_scheduler_test.go @@ -176,7 +176,7 @@ func TestGetExecuteOptions(t *testing.T) { SlavePodRequest: &testkube.PodRequest{}, } - got, err := sc.getExecuteOptions("id", req) + got, err := sc.getExecuteOptions("namespace", "id", req) assert.NoError(t, err) want := client.ExecuteOptions{ From 63e9f64db61c885f1f314db965ffb70e19ba6186 Mon Sep 17 00:00:00 2001 From: Abe Leon <85521358+abeleon-m1@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:54:42 -0600 Subject: [PATCH 150/234] Update testkube-dependencies.md --- docs/docs/articles/testkube-dependencies.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/articles/testkube-dependencies.md b/docs/docs/articles/testkube-dependencies.md index 526b23c26c8..77ca55da70b 100644 --- a/docs/docs/articles/testkube-dependencies.md +++ b/docs/docs/articles/testkube-dependencies.md @@ -43,6 +43,8 @@ The keys of the fields can be modified. To set these variables on helm-charts le ### Amazon DocumentDB +Warning: DocumentDB will not be supported in future releases. This is compatible with older releases of TestKube. + Testkube supports using [Amazon DocumentDB](https://aws.amazon.com/documentdb/), the managed version on MongoDB on AWS, as its database. Configuring it without TLS enabled is straightforward: add the connection string, and make sure the features that are not supported by DocumentDB are disabled. The parameters in the [helm-charts](https://github.com/kubeshop/helm-charts/blob/main/charts/testkube-api/values.yaml) are: ```bash From 1b2a5d2d9bd27bcbbfc50dc8e8f8004934b4462b Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Thu, 29 Feb 2024 20:42:43 +0200 Subject: [PATCH 151/234] add installation of simplified Testkube Enterprise to the docs --- .../articles/usage-guide.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/docs/testkube-enterprise/articles/usage-guide.md b/docs/docs/testkube-enterprise/articles/usage-guide.md index fa04802c7c5..eaedf563c71 100644 --- a/docs/docs/testkube-enterprise/articles/usage-guide.md +++ b/docs/docs/testkube-enterprise/articles/usage-guide.md @@ -3,6 +3,8 @@ - [Testkube Enterprise Helm Chart Installation and Usage Guide](#testkube-enterprise-helm-chart-installation-and-usage-guide) + - [Installation of Testkube Enterprise and an Agent in the same cluster](#installation-of-testkube-enterprise-and-an-agent-in-the-same-cluster) + - [Installation of Testkube Enterprise and an Agent in multiple clusters](#installation-of-testkube-enterprise-and-an-agent-in-multiple-clusters) - [Prerequisites](#prerequisites) - [Configuration](#configuration) - [Docker images](#docker-images) @@ -34,6 +36,27 @@ Welcome to the Testkube Enterprise Helm chart installation and usage guide. This comprehensive guide provides step-by-step instructions for installing and utilizing the Testkube Enterprise Helm chart. Testkube Enterprise is a cutting-edge Kubernetes-native testing platform designed to optimize your testing and quality assurance processes with enterprise-grade features. +## Installation of Testkube Enterprise and an Agent in the same cluster + +It is possible to deploy an instance of Testkube Enteprise and connect an Agent to it in the same k8s cluster without exposing endpoints to the outside world. +This way gives you a more customizable set-up and allows you to have a working environment in just a few minutes. It can be deployed locally or in any other k8s cluster. + +For this you will need a `kubectl` and a connection to a cluster. +Simply run ` bash <(curl -sSLf https://download.testkube.io)` and enter a license key, the script will do the rest. + +:::note +The script will do a port-forward to the following ports: `8080`, `8090`, `5556` in the background mode. Please make sure they are available. + +If you close a terminal you may do a port-forward with the following commands: +kubectl port-forward svc/testkube-enterprise-ui 8080:8080 --namespace testkube-enterprise & +kubectl port-forward svc/testkube-enterprise-api 8090:8088 --namespace testkube-enterprise & +kubectl port-forward svc/testkube-enterprise-dex 5556:5556 --namespace testkube-enterprise & +::: + +The installation will take about 4-5 min, once it is completed you will have Testkube Enterprise available at http://localhost:8080. We use Dex for authentication, so once you open the URL you will see a login page - use `admin@example.com` and `password` as a username and a password respectively. +And Voila! Now you can create tests, testsuites and explore the power of Testkube! + +## Installation of Testkube Enterprise and an Agent in multiple clusters ## Prerequisites Before you proceed with the installation, please ensure that you have the following prerequisites in place: From 766cf12a44fcca3c3a4f6790e7b31654bf258f50 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Thu, 29 Feb 2024 20:47:36 +0200 Subject: [PATCH 152/234] add namespaces --- docs/docs/testkube-enterprise/articles/usage-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/testkube-enterprise/articles/usage-guide.md b/docs/docs/testkube-enterprise/articles/usage-guide.md index eaedf563c71..0057133fb0d 100644 --- a/docs/docs/testkube-enterprise/articles/usage-guide.md +++ b/docs/docs/testkube-enterprise/articles/usage-guide.md @@ -53,7 +53,7 @@ kubectl port-forward svc/testkube-enterprise-api 8090:8088 --namespace testkube- kubectl port-forward svc/testkube-enterprise-dex 5556:5556 --namespace testkube-enterprise & ::: -The installation will take about 4-5 min, once it is completed you will have Testkube Enterprise available at http://localhost:8080. We use Dex for authentication, so once you open the URL you will see a login page - use `admin@example.com` and `password` as a username and a password respectively. +The installation will take about 4-5 min, once it is completed you will have the Testkube Enterprise available at http://localhost:8080 in `testkube-enterprise` namespace and the Testkube Agent in `testkube` namespace. We use Dex for authentication, so once you open the URL you will see a login page - use `admin@example.com` and `password` as a username and a password respectively. And Voila! Now you can create tests, testsuites and explore the power of Testkube! ## Installation of Testkube Enterprise and an Agent in multiple clusters From 4260761bd9b7f9d89da951328c0a2b9a2ffee453 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Thu, 29 Feb 2024 20:48:52 +0200 Subject: [PATCH 153/234] add minor fix --- docs/docs/testkube-enterprise/articles/usage-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/testkube-enterprise/articles/usage-guide.md b/docs/docs/testkube-enterprise/articles/usage-guide.md index 0057133fb0d..d0d24556fad 100644 --- a/docs/docs/testkube-enterprise/articles/usage-guide.md +++ b/docs/docs/testkube-enterprise/articles/usage-guide.md @@ -54,7 +54,7 @@ kubectl port-forward svc/testkube-enterprise-dex 5556:5556 --namespace testkube- ::: The installation will take about 4-5 min, once it is completed you will have the Testkube Enterprise available at http://localhost:8080 in `testkube-enterprise` namespace and the Testkube Agent in `testkube` namespace. We use Dex for authentication, so once you open the URL you will see a login page - use `admin@example.com` and `password` as a username and a password respectively. -And Voila! Now you can create tests, testsuites and explore the power of Testkube! +And Voila! Now you can create tests, testsuites in both CLI and UI and explore the power of Testkube! ## Installation of Testkube Enterprise and an Agent in multiple clusters ## Prerequisites From 750def338a0e1d2cb4a68056007456b89a7ac2fe Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Thu, 29 Feb 2024 21:01:53 +0200 Subject: [PATCH 154/234] minor changes in enterprise docs --- .../articles/usage-guide.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/docs/testkube-enterprise/articles/usage-guide.md b/docs/docs/testkube-enterprise/articles/usage-guide.md index d0d24556fad..269224880df 100644 --- a/docs/docs/testkube-enterprise/articles/usage-guide.md +++ b/docs/docs/testkube-enterprise/articles/usage-guide.md @@ -41,21 +41,29 @@ Testkube Enterprise is a cutting-edge Kubernetes-native testing platform designe It is possible to deploy an instance of Testkube Enteprise and connect an Agent to it in the same k8s cluster without exposing endpoints to the outside world. This way gives you a more customizable set-up and allows you to have a working environment in just a few minutes. It can be deployed locally or in any other k8s cluster. -For this you will need a `kubectl` and a connection to a cluster. -Simply run ` bash <(curl -sSLf https://download.testkube.io)` and enter a license key, the script will do the rest. +For this you will need: +- kubectl +- connection to a running k8s cluster + +Simply run `bash <(curl -sSLf https://download.testkube.io)` and enter a license key, the script will do the rest. :::note The script will do a port-forward to the following ports: `8080`, `8090`, `5556` in the background mode. Please make sure they are available. +::: + +The installation will take about 4-5 min, once it is completed you will have the Testkube Enterprise deployed in `testkube-enterprise` namespace and the Testkube Agent in `testkube` namespace. The UI is available at http://localhost:8080. We use Dex for authentication, so once you open the URL you will see a login page - use `admin@example.com` and `password` as a username and a password respectively. + +Voila! Now you can create tests, testsuites in both CLI and UI and explore the power of Testkube! +:::note If you close a terminal you may do a port-forward with the following commands: +```shell kubectl port-forward svc/testkube-enterprise-ui 8080:8080 --namespace testkube-enterprise & kubectl port-forward svc/testkube-enterprise-api 8090:8088 --namespace testkube-enterprise & kubectl port-forward svc/testkube-enterprise-dex 5556:5556 --namespace testkube-enterprise & +``` ::: -The installation will take about 4-5 min, once it is completed you will have the Testkube Enterprise available at http://localhost:8080 in `testkube-enterprise` namespace and the Testkube Agent in `testkube` namespace. We use Dex for authentication, so once you open the URL you will see a login page - use `admin@example.com` and `password` as a username and a password respectively. -And Voila! Now you can create tests, testsuites in both CLI and UI and explore the power of Testkube! - ## Installation of Testkube Enterprise and an Agent in multiple clusters ## Prerequisites From 838f47ef98323a6f431d26c3cf42e5560346157e Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 1 Mar 2024 10:27:07 +0300 Subject: [PATCH 155/234] feat: git secrest for execution namespace --- cmd/api-server/main.go | 1 + pkg/executor/client/job.go | 7 +++++- pkg/executor/containerexecutor/tmpl.go | 7 +++++- pkg/scheduler/service.go | 3 +++ pkg/scheduler/test_scheduler.go | 34 +++++++++++++++++++++++--- pkg/triggers/executor_test.go | 1 + pkg/triggers/service_test.go | 1 + 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index b67096a5e78..2e2abdef7cd 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -514,6 +514,7 @@ func main() { cfg.TestkubeDashboardURI, features, logsStream, + cfg.TestkubeNamespace, ) slackLoader, err := newSlackLoader(cfg, envs) diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index 139df974860..524fdf842c3 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -939,7 +939,12 @@ func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface } jobOptions.Variables = execution.Variables - jobOptions.ServiceAccountName = serviceAccountNames[execution.TestNamespace] + serviceAccountName, ok := serviceAccountNames[execution.TestNamespace] + if !ok { + return jobOptions, fmt.Errorf("not supported namespace %s", execution.TestNamespace) + } + + jobOptions.ServiceAccountName = serviceAccountName jobOptions.Registry = registry jobOptions.ClusterID = clusterID diff --git a/pkg/executor/containerexecutor/tmpl.go b/pkg/executor/containerexecutor/tmpl.go index 4898e105921..2ba9bb0866b 100644 --- a/pkg/executor/containerexecutor/tmpl.go +++ b/pkg/executor/containerexecutor/tmpl.go @@ -306,7 +306,12 @@ func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface } jobOptions.Variables = execution.Variables - jobOptions.ServiceAccountName = serviceAccountNames[execution.TestNamespace] + serviceAccountName, ok := serviceAccountNames[execution.TestNamespace] + if !ok { + return jobOptions, fmt.Errorf("not supported namespace %s", execution.TestNamespace) + } + + jobOptions.ServiceAccountName = serviceAccountName jobOptions.Registry = registry jobOptions.ClusterID = clusterID jobOptions.APIURI = apiURI diff --git a/pkg/scheduler/service.go b/pkg/scheduler/service.go index 964e4f7b64b..a7e4550417c 100644 --- a/pkg/scheduler/service.go +++ b/pkg/scheduler/service.go @@ -44,6 +44,7 @@ type Scheduler struct { featureFlags featureflags.FeatureFlags logsStream logsclient.Stream subscriptionChecker checktcl.SubscriptionChecker + namespace string } func NewScheduler( @@ -66,6 +67,7 @@ func NewScheduler( dashboardURI string, featureFlags featureflags.FeatureFlags, logsStream logsclient.Stream, + namespace string, ) *Scheduler { return &Scheduler{ metrics: metrics, @@ -87,6 +89,7 @@ func NewScheduler( dashboardURI: dashboardURI, featureFlags: featureFlags, logsStream: logsStream, + namespace: namespace, } } diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index f49df1fe8bd..78706d95a8b 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -11,6 +11,7 @@ import ( testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" testsourcev1 "github.com/kubeshop/testkube-operator/api/testsource/v1" + "github.com/kubeshop/testkube-operator/pkg/secret" "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor" @@ -23,7 +24,8 @@ import ( ) const ( - containerType = "container" + containerType = "container" + gitCredentialPrefix = "git_credential_" ) func (s *Scheduler) PrepareTestRequests(work []testsv3.Test, request testkube.ExecutionRequest) []workerpool.Request[ @@ -87,7 +89,7 @@ func (s *Scheduler) executeTest(ctx context.Context, test testkube.Test, request s.events.Notify(testkube.NewEventStartTest(&execution)) - if err := s.createSecretsReferences(&execution); err != nil { + if err := s.createSecretsReferences(&execution, &options); err != nil { return s.handleExecutionError(ctx, execution, "can't create secret variables `Secret` references: %w", err) } @@ -201,7 +203,7 @@ func (s *Scheduler) getNextExecutionNumber(testName string) int32 { } // createSecretsReferences strips secrets from text and store it inside model as reference to secret -func (s *Scheduler) createSecretsReferences(execution *testkube.Execution) (err error) { +func (s *Scheduler) createSecretsReferences(execution *testkube.Execution, options *client.ExecuteOptions) (err error) { secrets := map[string]string{} secretName := execution.Id + "-vars" @@ -229,6 +231,32 @@ func (s *Scheduler) createSecretsReferences(execution *testkube.Execution) (err } } + secretRefs := []*testkube.SecretRef{options.UsernameSecret, options.TokenSecret} + for _, secretRef := range secretRefs { + if secretRef == nil { + continue + } + + if execution.TestNamespace == s.namespace || (secretRef.Name != secret.GetMetadataName(execution.TestName, client.SecretTest) && + secretRef.Name != secret.GetMetadataName(execution.TestName, client.SecretSource)) { + continue + } + + data, err := s.secretClient.Get(secretRef.Name) + if err != nil { + return err + } + + value, ok := data[secretRef.Key] + if !ok { + return fmt.Errorf("secret key %s not found for secret %s", secretRef.Key, secretRef.Name) + } + + secrets[gitCredentialPrefix+secretRef.Key] = value + secretRef.Name = secretName + secretRef.Key = gitCredentialPrefix + secretRef.Key + } + labels := map[string]string{"executionID": execution.Id, "testName": execution.TestName} if len(secrets) > 0 { diff --git a/pkg/triggers/executor_test.go b/pkg/triggers/executor_test.go index 3c136a7e615..907d5b39c19 100644 --- a/pkg/triggers/executor_test.go +++ b/pkg/triggers/executor_test.go @@ -126,6 +126,7 @@ func TestExecute(t *testing.T) { "", featureflags.FeatureFlags{}, mockLogsStream, + "", ) s := &Service{ triggerStatus: make(map[statusKey]*triggerStatus), diff --git a/pkg/triggers/service_test.go b/pkg/triggers/service_test.go index b0b65fa04b6..290beb0ce5b 100644 --- a/pkg/triggers/service_test.go +++ b/pkg/triggers/service_test.go @@ -139,6 +139,7 @@ func TestService_Run(t *testing.T) { "", featureflags.FeatureFlags{}, mockLogsStream, + "", ) mockLeaseBackend := NewMockLeaseBackend(mockCtrl) From d878dba41df1069b9b2846bdbf102873c44af7db Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 1 Mar 2024 10:42:07 +0300 Subject: [PATCH 156/234] fix: unit test --- .../containerexecutor_test.go | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 9b271d2f138..a6420afc95b 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -31,14 +31,15 @@ func TestExecuteAsync(t *testing.T) { t.Parallel() ce := ContainerExecutor{ - clientSet: getFakeClient("1"), - log: logger(), - repository: FakeResultRepository{}, - metrics: FakeMetricCounter{}, - emitter: FakeEmitter{}, - configMap: FakeConfigRepository{}, - testsClient: FakeTestsClient{}, - executorsClient: FakeExecutorsClient{}, + clientSet: getFakeClient("1"), + log: logger(), + repository: FakeResultRepository{}, + metrics: FakeMetricCounter{}, + emitter: FakeEmitter{}, + configMap: FakeConfigRepository{}, + testsClient: FakeTestsClient{}, + executorsClient: FakeExecutorsClient{}, + serviceAccountNames: map[string]string{"": ""}, } execution := &testkube.Execution{Id: "1"} @@ -56,14 +57,15 @@ func TestExecuteSync(t *testing.T) { t.Parallel() ce := ContainerExecutor{ - clientSet: getFakeClient("1"), - log: logger(), - repository: FakeResultRepository{}, - metrics: FakeMetricCounter{}, - emitter: FakeEmitter{}, - configMap: FakeConfigRepository{}, - testsClient: FakeTestsClient{}, - executorsClient: FakeExecutorsClient{}, + clientSet: getFakeClient("1"), + log: logger(), + repository: FakeResultRepository{}, + metrics: FakeMetricCounter{}, + emitter: FakeEmitter{}, + configMap: FakeConfigRepository{}, + testsClient: FakeTestsClient{}, + executorsClient: FakeExecutorsClient{}, + serviceAccountNames: map[string]string{"default": ""}, } execution := &testkube.Execution{Id: "1", TestNamespace: "default"} From 8e2b21ac040a6518e635c010e9109bc20f0f49cf Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Fri, 1 Mar 2024 10:59:28 +0100 Subject: [PATCH 157/234] fix: logging adjustments (#5087) --- cmd/logs/main.go | 9 +++++++-- pkg/logs/client/stream.go | 2 -- pkg/logs/config/logs_config.go | 4 ++++ pkg/logs/events.go | 6 ++++-- pkg/logs/service.go | 8 ++++++++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cmd/logs/main.go b/cmd/logs/main.go index 15a67f9889c..9ee57d6c353 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -88,9 +88,11 @@ func main() { svc := logs.NewLogsService(nc, js, state, logStream). WithHttpAddress(cfg.HttpAddress). WithGrpcAddress(cfg.GrpcAddress). - WithLogsRepositoryFactory(repository.NewJsMinioFactory(minioClient, cfg.StorageBucket, logStream)) + WithLogsRepositoryFactory(repository.NewJsMinioFactory(minioClient, cfg.StorageBucket, logStream)). + WithMessageTracing(cfg.TraceMessages) - if cfg.Debug { + // quite noisy in logs - will echo all messages incoming from logs + if cfg.AttachDebugAdapter { svc.AddAdapter(adapter.NewDebugAdapter()) } @@ -99,6 +101,8 @@ func main() { log.Fatalw("error getting tls credentials", "error", err) } + log.Infow("starting logs service", "mode", mode) + // add given log adapter depends from mode switch mode { @@ -108,6 +112,7 @@ func main() { defer grpcConn.Close() grpcClient := pb.NewCloudLogsServiceClient(grpcConn) cloudAdapter := adapter.NewCloudAdapter(grpcClient, cfg.TestkubeProAPIKey) + log.Infow("cloud adapter created", "endpoint", cfg.TestkubeProURL) svc.AddAdapter(cloudAdapter) case common.ModeStandalone: diff --git a/pkg/logs/client/stream.go b/pkg/logs/client/stream.go index 2de949a287a..dc0bb3c5516 100644 --- a/pkg/logs/client/stream.go +++ b/pkg/logs/client/stream.go @@ -120,8 +120,6 @@ func (c NatsLogStream) Get(ctx context.Context, id string) (chan events.LogRespo } func (c NatsLogStream) handleJetstreamMessage(log *zap.SugaredLogger, ch chan events.LogResponse, msg jetstream.Msg) (finish bool) { - log.Debugw("got message", "data", string(msg.Data())) - // deliver to subscriber logChunk := events.Log{} err := json.Unmarshal(msg.Data(), &logChunk) diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index 89b820cdb68..69daefe16e3 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -9,6 +9,10 @@ import ( type Config struct { Debug bool `envconfig:"DEBUG" default:"false"` + // Debug variables + AttachDebugAdapter bool `envconfig:"ATTACH_DEBUG_ADAPTER" default:"false"` + TraceMessages bool `envconfig:"TRACE_MESSAGES" default:"false"` + TestkubeProAPIKey string `envconfig:"TESTKUBE_PRO_API_KEY" default:""` TestkubeProURL string `envconfig:"TESTKUBE_PRO_URL" default:""` TestkubeProLogsPath string `envconfig:"TESTKUBE_PRO_LOGS_PATH" default:"/logs"` diff --git a/pkg/logs/events.go b/pkg/logs/events.go index 4ddfe1fa194..f79d7896e93 100644 --- a/pkg/logs/events.go +++ b/pkg/logs/events.go @@ -72,7 +72,9 @@ func (ls *LogsService) handleMessage(ctx context.Context, a adapter.Adapter, id log := ls.log.With("id", id, "adapter", a.Name()) return func(msg jetstream.Msg) { - log.Debugw("got message", "data", string(msg.Data())) + if ls.traceMessages { + log.Debugw("got message", "data", string(msg.Data())) + } // deliver to subscriber logChunk := events.Log{} @@ -174,7 +176,7 @@ func (ls *LogsService) handleStop(ctx context.Context, group string) func(msg *n event = events.Trigger{} ) - ls.log.Debugw("got stop event") + ls.log.Debugw("got stop event", "data", string(msg.Data)) err := json.Unmarshal(msg.Data, &event) if err != nil { diff --git a/pkg/logs/service.go b/pkg/logs/service.go index 5b617266a9c..bca2730b11a 100644 --- a/pkg/logs/service.go +++ b/pkg/logs/service.go @@ -86,6 +86,9 @@ type LogsService struct { // stop wait time for messages cool down stopPauseInterval time.Duration + + // trace incoming messages + traceMessages bool } // AddAdapter adds new adapter to logs service adapters will be configred based on given mode @@ -164,6 +167,11 @@ func (ls *LogsService) WithHttpAddress(address string) *LogsService { return ls } +func (ls *LogsService) WithMessageTracing(enabled bool) *LogsService { + ls.traceMessages = enabled + return ls +} + func (ls *LogsService) WithGrpcAddress(address string) *LogsService { ls.grpcAddress = address return ls From b21101759825e76517fa49a595cb51668322433e Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Fri, 1 Mar 2024 11:03:30 +0100 Subject: [PATCH 158/234] docs: test suite steps style fixes (#5093) --- .../articles/running-parallel-tests-with-test-suite.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md index 170fa08e597..0e0109167cf 100644 --- a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md +++ b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md @@ -35,7 +35,7 @@ Test Suite Steps can be of two types: 1. Tests: tests to be run. 2. Delays: time delays to wait in between tests. -Similarly to running a Test, running a Test Suite Step based on a test allows for specific execution request parameters to be overwritten. Step level parameters overwrite Test Suite level parameters, which in turn overwrite Test level parameters. The Step level parameters are configurable only via CRDs at the moment. +Similar to running a Test, running a Test Suite Step based on a test allows for specific execution request parameters to be overwritten. Step level parameters overwrite Test Suite level parameters, which in turn overwrite Test level parameters. The Step level parameters are configurable only via CRDs at the moment. For details on which parameters are available in the CRDs, please consult the table below: @@ -81,7 +81,7 @@ For details on which parameters are available in the CRDs, please consult the ta | labels | | ✓ | | | timeout | | ✓ | | -Similarly to Tests and Test Suites, Test Suite Steps can also have a field of type `executionRequest` like in the example below: +Similar to Tests and Test Suites, Test Suite Steps can also have a field of type `executionRequest` like in the example below: ```bash apiVersion: tests.testkube.io/v3 From c0768572adeccf86867111e390bebbacecc04d98 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 1 Mar 2024 13:42:27 +0300 Subject: [PATCH 159/234] docs: execution namespace --- docs/docs/articles/creating-tests.md | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/docs/articles/creating-tests.md b/docs/docs/articles/creating-tests.md index ce5fd28d5e7..2aa4e4eae23 100644 --- a/docs/docs/articles/creating-tests.md +++ b/docs/docs/articles/creating-tests.md @@ -599,6 +599,43 @@ parameters when you create or run the test using the `--variable-configmap` and testkube create test --file test/postman/LocalHealth.postman_collection.json --name var-test --type postman/collection --variable-configmap your_configmap --variable-secret your_secret ``` +### Run the Test in a Different Execution Namespace + +When you need to run the test in a namespace different from the Testkube installation one, you can use a special Test CRD field `executionNamespace`, for example: + +```yaml +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeter-smoke-test + namespace: testkube +spec: + type: jmeter/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + executionRequest: + executionNamespace: default +``` + +You need to define execution namespaces in your helm chart values. It's possible to generate all required RBAC or just manually supply them. + +```yaml + executionNamespaces: [] + # -- Namespace for test execution + # - namespace: default + # -- Whether to genrate RBAC for testkube api server or use manually provided + # generateAPIServerRBAC: true + # -- Job service account name for test jobs + # jobServiceAccountName: tests-job-default + # -- Whether to genrate RBAC for test job or use manually provided + # generateTestJobRBAC: true +``` + ## Summary Tests are the main abstractions over test suites in Testkube, they can be created with different sources and used by executors to run on top of a particular test framework. From 562090ea095c284808ead71b08b8627cc8807b06 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 1 Mar 2024 15:42:49 +0300 Subject: [PATCH 160/234] docs: typo --- docs/docs/articles/creating-tests.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/articles/creating-tests.md b/docs/docs/articles/creating-tests.md index 2aa4e4eae23..5b32532c8ab 100644 --- a/docs/docs/articles/creating-tests.md +++ b/docs/docs/articles/creating-tests.md @@ -628,11 +628,11 @@ You need to define execution namespaces in your helm chart values. It's possible executionNamespaces: [] # -- Namespace for test execution # - namespace: default - # -- Whether to genrate RBAC for testkube api server or use manually provided + # -- Whether to generate RBAC for testkube api server or use manually provided # generateAPIServerRBAC: true # -- Job service account name for test jobs # jobServiceAccountName: tests-job-default - # -- Whether to genrate RBAC for test job or use manually provided + # -- Whether to generate RBAC for test job or use manually provided # generateTestJobRBAC: true ``` From 39c14b0a6ce28ce3694c1fa2771c4cf9fe5ce1c6 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Fri, 1 Mar 2024 15:44:51 +0300 Subject: [PATCH 161/234] fix: dep update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e63367ab3d5..11c172cd6fb 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240228150136-4ca5e27ff12b + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301103958-c1e3dd2bfec8 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index c151bd96284..ab02d4bc032 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240228150136-4ca5e27ff12b h1:Z8peqji/1wu/yyLGqHn2iPvdWWhIHVxCGMiALvtuVGk= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240228150136-4ca5e27ff12b/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301103958-c1e3dd2bfec8 h1:nnm52168fhDU/3AKxHSWeRgZq8Iqph4Bn/Z2yclx0HU= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301103958-c1e3dd2bfec8/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= From 615b2750d1b1ecacd217861906861a8f9dc8e4df Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 1 Mar 2024 17:51:14 +0100 Subject: [PATCH 162/234] feat(TKC-1580): prepare Init Process for TestWorkflow containers (#5090) * feat(TKC-1465): add helpers to immediately evaluate Template/Expression * feat(TKC-1465): add option to force simplifying templates in unknown structs * fix(TKC-1465): template evaluation typo * fix(TKC-1465): simplifying private properties in structs * fix(TKC-1465): deep simplify structs * fix(TKC-1465): EvalExpression typo * fix(TKC-1465): Negation precedence * fix(TKC-1465): add negative number test * feat(TKC-1465): predict logical operations paths * feat(TKC-1580): prepare initial Init Process for TestWorkflow containers * feat(TKC-1580): distinguish hints of outputs in TestWorkflow containers * chore(TKC-1580): delete unused code * chore(TKC-1580): extract commons for critical errors in init process * feat(TKC-1465): add Escape helper * feat(TKC-1580): make conditions order irrelevant * fix(TKC-1580): compute init status with execution status too * fix(TKC-1580): compute conditions correctly * fix: image inspector with ConfigMap * feat(TKC-1465): add option to finalize structs with expression language --- .../docker-build-api-executors-tag.yaml | 55 +++++ .github/workflows/docker-build-develop.yaml | 57 +++++ .github/workflows/docker-build-release.yaml | 57 +++++ build/testworkflow-init/Dockerfile | 6 + cmd/tcl/README.md | 7 + cmd/tcl/testworkflow-init/data/config.go | 33 +++ cmd/tcl/testworkflow-init/data/emit.go | 34 +++ cmd/tcl/testworkflow-init/data/expressions.go | 130 +++++++++++ cmd/tcl/testworkflow-init/data/state.go | 167 ++++++++++++++ cmd/tcl/testworkflow-init/data/step.go | 22 ++ cmd/tcl/testworkflow-init/data/types.go | 105 +++++++++ cmd/tcl/testworkflow-init/data/utils.go | 61 ++++++ cmd/tcl/testworkflow-init/main.go | 206 ++++++++++++++++++ cmd/tcl/testworkflow-init/output/constants.go | 16 ++ cmd/tcl/testworkflow-init/output/output.go | 29 +++ cmd/tcl/testworkflow-init/run/run.go | 89 ++++++++ ...eleaser-docker-build-testworkflow-init.yml | 89 ++++++++ internal/config/config.go | 4 +- pkg/imageinspector/configmapstorage.go | 3 + pkg/imageinspector/serialization.go | 5 +- pkg/tcl/expressionstcl/generic.go | 138 +++++++++--- pkg/tcl/expressionstcl/generic_test.go | 88 +++++++- pkg/tcl/expressionstcl/math.go | 21 ++ pkg/tcl/expressionstcl/parse.go | 3 +- pkg/tcl/expressionstcl/parse_test.go | 16 ++ pkg/tcl/expressionstcl/utils.go | 36 +++ .../testworkflowresolver/apply.go | 4 +- .../testworkflowresolver/config.go | 4 +- 28 files changed, 1436 insertions(+), 49 deletions(-) create mode 100644 build/testworkflow-init/Dockerfile create mode 100644 cmd/tcl/README.md create mode 100644 cmd/tcl/testworkflow-init/data/config.go create mode 100644 cmd/tcl/testworkflow-init/data/emit.go create mode 100644 cmd/tcl/testworkflow-init/data/expressions.go create mode 100644 cmd/tcl/testworkflow-init/data/state.go create mode 100644 cmd/tcl/testworkflow-init/data/step.go create mode 100644 cmd/tcl/testworkflow-init/data/types.go create mode 100644 cmd/tcl/testworkflow-init/data/utils.go create mode 100644 cmd/tcl/testworkflow-init/main.go create mode 100644 cmd/tcl/testworkflow-init/output/constants.go create mode 100644 cmd/tcl/testworkflow-init/output/output.go create mode 100644 cmd/tcl/testworkflow-init/run/run.go create mode 100644 goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml diff --git a/.github/workflows/docker-build-api-executors-tag.yaml b/.github/workflows/docker-build-api-executors-tag.yaml index 07bc3616a21..baad3d09371 100644 --- a/.github/workflows/docker-build-api-executors-tag.yaml +++ b/.github/workflows/docker-build-api-executors-tag.yaml @@ -78,6 +78,61 @@ jobs: DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + testworkflow-init: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: sigstore/cosign-installer@v3.0.5 + - uses: anchore/sbom-action/download-syft@v0.14.2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set-up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Go Cache + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: testkube-tw-init-go-${{ hashFiles('**/go.sum') }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Release + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release -f goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + ANALYTICS_TRACKING_ID: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_ID}} + ANALYTICS_API_KEY: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_SECRET}} + SLACK_BOT_CLIENT_ID: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_ID}} + SLACK_BOT_CLIENT_SECRET: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_SECRET}} + SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_SEGMENTIO_KEY}} + CLOUD_SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_CLOUD_SEGMENTIO_KEY}} + DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" + DOCKER_BUILDX_CACHE_FROM: "type=gha" + DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" + ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + single_executor: strategy: matrix: diff --git a/.github/workflows/docker-build-develop.yaml b/.github/workflows/docker-build-develop.yaml index 99db6d7fb9c..460fe5f7c27 100644 --- a/.github/workflows/docker-build-develop.yaml +++ b/.github/workflows/docker-build-develop.yaml @@ -67,6 +67,63 @@ jobs: run: | docker push kubeshop/testkube-api-server:${{ steps.commit.outputs.short }} + testworkflow-init: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Set-up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Go Cache + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: testkube-tw-init-go-${{ hashFiles('**/go.sum') }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - id: commit + uses: prompt/actions-commit-hash@v3 + + - name: Release + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release -f goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml --snapshot + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + ANALYTICS_TRACKING_ID: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_ID}} + ANALYTICS_API_KEY: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_SECRET}} + SLACK_BOT_CLIENT_ID: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_ID}} + SLACK_BOT_CLIENT_SECRET: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_SECRET}} + SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_SEGMENTIO_KEY}} + CLOUD_SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_CLOUD_SEGMENTIO_KEY}} + DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" + DOCKER_BUILDX_CACHE_FROM: "type=gha" + DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" + ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + IMAGE_TAG_SHA: true + + - name: Push Docker images + run: | + docker push kubeshop/testkube-tw-init:${{ steps.commit.outputs.short }} + single_executor: strategy: matrix: diff --git a/.github/workflows/docker-build-release.yaml b/.github/workflows/docker-build-release.yaml index efbd76e5ad1..3df9902da72 100644 --- a/.github/workflows/docker-build-release.yaml +++ b/.github/workflows/docker-build-release.yaml @@ -68,6 +68,63 @@ jobs: run: | docker push kubeshop/testkube-api-server:${{ steps.commit.outputs.short }} + testworkflow-init: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Set-up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Go Cache + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: testkube-tw-init-go-${{ hashFiles('**/go.sum') }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - id: commit + uses: prompt/actions-commit-hash@v3 + + - name: Release + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release -f goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml --snapshot + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + ANALYTICS_TRACKING_ID: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_ID}} + ANALYTICS_API_KEY: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_SECRET}} + SLACK_BOT_CLIENT_ID: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_ID}} + SLACK_BOT_CLIENT_SECRET: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_SECRET}} + SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_SEGMENTIO_KEY}} + CLOUD_SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_CLOUD_SEGMENTIO_KEY}} + DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" + DOCKER_BUILDX_CACHE_FROM: "type=gha" + DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" + ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + IMAGE_TAG_SHA: true + + - name: Push Docker images + run: | + docker push kubeshop/testkube-tw-init:${{ steps.commit.outputs.short }} + single_executor: strategy: matrix: diff --git a/build/testworkflow-init/Dockerfile b/build/testworkflow-init/Dockerfile new file mode 100644 index 00000000000..40f6a3192d7 --- /dev/null +++ b/build/testworkflow-init/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG ALPINE_IMAGE +FROM ${ALPINE_IMAGE} +COPY testworkflow-init /init +USER 1001 +ENTRYPOINT ["/init"] diff --git a/cmd/tcl/README.md b/cmd/tcl/README.md new file mode 100644 index 00000000000..25ca004f001 --- /dev/null +++ b/cmd/tcl/README.md @@ -0,0 +1,7 @@ +# Testkube - TCL Package + +This folder contains special code with the Testkube Community license. + +## License + +The code in this folder is licensed under the Testkube Community License. Please see the [LICENSE](../../licenses/TCL.txt) file for more information. diff --git a/cmd/tcl/testworkflow-init/data/config.go b/cmd/tcl/testworkflow-init/data/config.go new file mode 100644 index 00000000000..eb08893ec4c --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/config.go @@ -0,0 +1,33 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import ( + "os" +) + +type config struct { + Negative bool + Debug bool + RetryCount int + RetryUntil string + + Resulting []Rule +} + +var Config = &config{ + Debug: os.Getenv("DEBUG") == "1", +} + +func LoadConfig(config map[string]string) { + Config.Debug = getBool(config, "debug", Config.Debug) + Config.RetryCount = getInt(config, "retryCount", 1) + Config.RetryUntil = getStr(config, "retryUntil", "self.passed") + Config.Negative = getBool(config, "negative", false) +} diff --git a/cmd/tcl/testworkflow-init/data/emit.go b/cmd/tcl/testworkflow-init/data/emit.go new file mode 100644 index 00000000000..7f280494e4e --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/emit.go @@ -0,0 +1,34 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import ( + "encoding/json" + "fmt" +) + +func EmitOutput(ref string, name string, value interface{}) { + j, err := json.Marshal(value) + if err != nil { + panic(fmt.Sprintf("error while marshalling reference: %v", err)) + } + fmt.Printf("\n;;%s;%s:%s;\n", ref, name, string(j)) +} + +func EmitHint(ref string, name string) { + fmt.Printf("\n;;;%s;%s;\n", ref, name) +} + +func EmitHintDetails(ref string, name string, value interface{}) { + j, err := json.Marshal(value) + if err != nil { + panic(fmt.Sprintf("error while marshalling reference: %v", err)) + } + fmt.Printf("\n;;;%s;%s:%s;\n", ref, name, string(j)) +} diff --git a/cmd/tcl/testworkflow-init/data/expressions.go b/cmd/tcl/testworkflow-init/data/expressions.go new file mode 100644 index 00000000000..5febf92e390 --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/expressions.go @@ -0,0 +1,130 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import ( + "fmt" + "os" + "strings" + + "github.com/pkg/errors" + + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +var aliases = map[string]string{ + "always": `true`, + "never": `false`, + + "error": `failed`, + "success": `passed`, + + "self.error": `self.failed`, + "self.success": `self.passed`, + + "passed": `!status`, + "failed": `bool(status) && status != "skipped"`, + + "self.passed": `!self.status`, + "self.failed": `bool(self.status) && self.status != "skipped"`, +} + +var LocalMachine = expressionstcl.NewMachine(). + Register("status", expressionstcl.MustCompile("self.status")) + +var RefMachine = expressionstcl.NewMachine(). + RegisterAccessor(func(name string) (interface{}, bool) { + if name == "_ref" { + return Step.Ref, true + } + return nil, false + }) + +var AliasMachine = expressionstcl.NewMachine(). + RegisterAccessorExt(func(name string) (interface{}, bool, error) { + alias, ok := aliases[name] + if !ok { + return nil, false, nil + } + expr, err := expressionstcl.Compile(alias) + if err != nil { + return expr, false, err + } + expr, err = expr.Resolve(RefMachine) + return expr, true, err + }) + +var StateMachine = expressionstcl.NewMachine(). + RegisterAccessor(func(name string) (interface{}, bool) { + if name == "status" { + return State.GetStatus(), true + } else if name == "self.status" { + return State.GetSelfStatus(), true + } + return nil, false + }). + RegisterAccessorExt(func(name string) (interface{}, bool, error) { + if strings.HasPrefix(name, "output.") { + return State.GetOutput(name[7:]) + } + return nil, false, nil + }) + +var EnvMachine = expressionstcl.NewMachine(). + RegisterAccessor(func(name string) (interface{}, bool) { + if strings.HasPrefix(name, "env.") { + return os.Getenv(name[4:]), true + } + return nil, false + }) + +var RefSuccessMachine = expressionstcl.NewMachine(). + RegisterAccessor(func(ref string) (interface{}, bool) { + s := State.GetStep(ref) + return s.Status == StepStatusPassed || s.Status == StepStatusSkipped, s.HasStatus + }) + +var RefStatusMachine = expressionstcl.NewMachine(). + RegisterAccessor(func(ref string) (interface{}, bool) { + return string(State.GetStep(ref).Status), true + }) + +var FileMachine = expressionstcl.NewMachine(). + RegisterFunction("file", func(values ...expressionstcl.StaticValue) (interface{}, bool, error) { + if len(values) != 1 { + return nil, true, errors.New("file() function takes a single argument") + } + if !values[0].IsString() { + return nil, true, fmt.Errorf("file() function expects a string argument, provided: %v", values[0].String()) + } + filePath, _ := values[0].StringValue() + file, err := os.ReadFile(filePath) + if err != nil { + return nil, true, fmt.Errorf("reading file(%s): %s", filePath, err.Error()) + } + return string(file), true, nil + }) + +func Template(tpl string, m ...expressionstcl.Machine) (string, error) { + m = append(m, AliasMachine, EnvMachine, StateMachine, FileMachine) + return expressionstcl.EvalTemplate(tpl, m...) +} + +func Expression(expr string, m ...expressionstcl.Machine) (expressionstcl.StaticValue, error) { + m = append(m, AliasMachine, EnvMachine, StateMachine, FileMachine) + return expressionstcl.EvalExpression(expr, m...) +} + +func RefSuccessExpression(expr string) (expressionstcl.StaticValue, error) { + return expressionstcl.EvalExpression(expr, RefSuccessMachine) +} + +func RefStatusExpression(expr string) (expressionstcl.StaticValue, error) { + return expressionstcl.EvalExpression(expr, RefStatusMachine) +} diff --git a/cmd/tcl/testworkflow-init/data/state.go b/cmd/tcl/testworkflow-init/data/state.go new file mode 100644 index 00000000000..34071861aa8 --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/state.go @@ -0,0 +1,167 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import ( + "bytes" + "encoding/gob" + "fmt" + "os" + "path/filepath" + + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +const ( + defaultInternalPath = "/.tktw" + defaultTerminationLogPath = "/dev/termination-log" +) + +type state struct { + Status TestWorkflowStatus `json:"status"` + Steps map[string]*StepInfo `json:"steps"` + Output map[string]string `json:"output"` +} + +var State = &state{ + Steps: map[string]*StepInfo{}, + Output: map[string]string{}, +} + +func (s *state) GetStep(ref string) *StepInfo { + _, ok := State.Steps[ref] + if !ok { + State.Steps[ref] = &StepInfo{Ref: ref} + } + return State.Steps[ref] +} + +func (s *state) GetOutput(name string) (expressionstcl.Expression, bool, error) { + v, ok := s.Output[name] + if !ok { + return expressionstcl.None, false, nil + } + expr, err := expressionstcl.Compile(v) + return expr, true, err +} + +func (s *state) GetSelfStatus() string { + if Step.Executed { + return string(Step.Status) + } + v := s.GetStep(Step.Ref) + if v.Status != StepStatusPassed { + return string(v.Status) + } + return string(Step.Status) +} + +func (s *state) GetStatus() string { + if Step.Executed { + return string(Step.Status) + } + if Step.InitStatus == "" { + return string(s.Status) + } + v, err := RefStatusExpression(Step.InitStatus) + if err != nil { + return string(s.Status) + } + str, _ := v.Static().StringValue() + if str == "" { + return string(s.Status) + } + return str +} + +func readState(filePath string) { + b, err := os.ReadFile(filePath) + if err != nil { + if !os.IsNotExist(err) { + panic(err) + } + return + } + if len(b) == 0 { + return + } + err = gob.NewDecoder(bytes.NewBuffer(b)).Decode(&State) + if err != nil { + panic(err) + } +} + +func persistState(filePath string) { + b := bytes.Buffer{} + err := gob.NewEncoder(&b).Encode(State) + if err != nil { + panic(err) + } + + err = os.WriteFile(filePath, b.Bytes(), 0777) + if err != nil { + panic(err) + } +} + +func recomputeStatuses() { + // Read current status + status := StepStatus(State.GetSelfStatus()) + + // Update own status + State.GetStep(Step.Ref).SetStatus(status) + + // Update expected failure statuses + Iterate(Config.Resulting, func(r Rule) bool { + v, err := RefSuccessExpression(r.Expr) + if err != nil { + return false + } + vv, _ := v.Static().BoolValue() + if !vv { + for _, ref := range r.Refs { + if ref == "" { + State.Status = TestWorkflowStatusFailed + } else { + State.GetStep(ref).SetStatus(StepStatusFailed) + } + } + } + return true + }) +} + +func persistStatus(filePath string) { + // Persist container termination result + res := fmt.Sprintf(`%s,%d`, State.GetStep(Step.Ref).Status, Step.ExitCode) + err := os.WriteFile(filePath, []byte(res), 0755) + if err != nil { + panic(err) + } +} + +func LoadState() { + readState(filepath.Join(defaultInternalPath, "state")) +} + +func Finish() { + // Persist step information and shared data + recomputeStatuses() + persistStatus(defaultTerminationLogPath) + persistState(filepath.Join(defaultInternalPath, "state")) + + // Kill the sub-process + if Step.Cmd != nil && Step.Cmd.Process != nil { + _ = Step.Cmd.Process.Kill() + } + + // The init process needs to finish with zero exit code, + // to continue with the next container. + os.Exit(0) +} diff --git a/cmd/tcl/testworkflow-init/data/step.go b/cmd/tcl/testworkflow-init/data/step.go new file mode 100644 index 00000000000..531d51b63f6 --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/step.go @@ -0,0 +1,22 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import "os/exec" + +type step struct { + Ref string + Cmd *exec.Cmd + Status StepStatus + ExitCode uint8 + Executed bool + InitStatus string +} + +var Step = &step{} diff --git a/cmd/tcl/testworkflow-init/data/types.go b/cmd/tcl/testworkflow-init/data/types.go new file mode 100644 index 00000000000..e7895751131 --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/types.go @@ -0,0 +1,105 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import ( + "strings" + "time" +) + +type TestWorkflowStatus string + +const ( + TestWorkflowStatusPassed TestWorkflowStatus = "" + TestWorkflowStatusFailed TestWorkflowStatus = "failed" + TestWorkflowStatusAborted TestWorkflowStatus = "aborted" +) + +type StepStatus string + +const ( + StepStatusPassed StepStatus = "" + StepStatusTimeout StepStatus = "timeout" + StepStatusFailed StepStatus = "failed" + StepStatusAborted StepStatus = "aborted" + StepStatusSkipped StepStatus = "skipped" +) + +type Rule struct { + Expr string + Refs []string +} + +type Timeout struct { + Ref string + Duration string +} + +type StepInfo struct { + Ref string `json:"ref"` + Status StepStatus `json:"status"` + HasStatus bool `json:"hasStatus"` + StartTime time.Time `json:"startTime"` + TimeoutAt time.Time `json:"timeoutAt"` + Iteration uint64 `json:"iteration"` +} + +func (s *StepInfo) Start(t time.Time) { + if s.StartTime.IsZero() { + s.StartTime = t + s.Iteration = 1 + EmitHint(s.Ref, "start") + } +} + +func (s *StepInfo) Next() { + if s.StartTime.IsZero() { + s.Start(time.Now()) + } else { + s.Iteration++ + EmitHintDetails(s.Ref, "iteration", s.Iteration) + } +} + +func (s *StepInfo) Skip(t time.Time) { + if s.Status != StepStatusSkipped { + s.StartTime = t + s.Iteration = 0 + s.SetStatus(StepStatusSkipped) + } +} + +func (s *StepInfo) SetTimeoutDuration(t time.Time, duration string) error { + if !s.TimeoutAt.IsZero() { + return nil + } + s.Start(t) + v, err := Template(duration) + if err != nil { + return err + } + d, err := time.ParseDuration(strings.ReplaceAll(v, " ", "")) + if err != nil { + return err + } + s.TimeoutAt = s.StartTime.Add(d) + return nil +} + +func (s *StepInfo) SetStatus(status StepStatus) { + if !s.HasStatus || s.Status == StepStatusPassed { + s.Status = status + s.HasStatus = true + if status == StepStatusPassed { + EmitHintDetails(s.Ref, "status", "passed") + } else { + EmitHintDetails(s.Ref, "status", status) + } + } +} diff --git a/cmd/tcl/testworkflow-init/data/utils.go b/cmd/tcl/testworkflow-init/data/utils.go new file mode 100644 index 00000000000..c0c5f7fb541 --- /dev/null +++ b/cmd/tcl/testworkflow-init/data/utils.go @@ -0,0 +1,61 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package data + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +func getStr(config map[string]string, key string, defaultValue string) string { + val, ok := config[key] + if !ok { + return defaultValue + } + return val +} + +func getInt(config map[string]string, key string, defaultValue int) int { + str := getStr(config, key, "") + if str == "" { + return defaultValue + } + val, err := strconv.Atoi(str) + if err != nil { + fmt.Printf("invalid '%s' provided: '%s': %v\n", key, str, err) + os.Exit(155) + } + return val +} + +func getBool(config map[string]string, key string, defaultValue bool) bool { + str := getStr(config, key, "") + if str == "" { + return defaultValue + } + return strings.ToLower(str) == "true" || str == "1" +} + +// Iterate over all items, all the time, until no more is done +func Iterate[T any](v []T, fn func(T) bool) { + result := v + for { + l := len(result) + for i := 0; i < len(result); i++ { + if fn(result[i]) { + result = append(result[0:i], result[i+1:]...) + } + } + if len(result) == l { + return + } + } +} diff --git a/cmd/tcl/testworkflow-init/main.go b/cmd/tcl/testworkflow-init/main.go new file mode 100644 index 00000000000..d17c3eb3b87 --- /dev/null +++ b/cmd/tcl/testworkflow-init/main.go @@ -0,0 +1,206 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package main + +import ( + "fmt" + "os" + "os/signal" + "slices" + "strings" + "syscall" + "time" + + "github.com/kballard/go-shellquote" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/output" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/run" +) + +func main() { + if len(os.Args) < 2 { + output.Failf(output.CodeInputError, "missing step reference") + } + data.Step.Ref = os.Args[1] + + now := time.Now() + + // Load shared state + data.LoadState() + + // Initialize space for parsing args + config := map[string]string{} + computed := []string(nil) + conditions := []data.Rule(nil) + resulting := []data.Rule(nil) + timeouts := []data.Timeout(nil) + args := []string(nil) + + // Read arguments into the base data + for i := 2; i < len(os.Args); i += 2 { + if i+1 == len(os.Args) { + break + } + switch os.Args[i] { + case "--": + args = os.Args[i+1:] + i = len(os.Args) + case "-i", "--init": + data.Step.InitStatus = os.Args[i+1] + case "-c", "--cond": + v := strings.SplitN(os.Args[i+1], "=", 2) + refs := strings.Split(v[0], ",") + if len(v) == 2 { + conditions = append(conditions, data.Rule{Expr: v[1], Refs: refs}) + } else { + conditions = append(conditions, data.Rule{Expr: "true", Refs: refs}) + } + case "-r", "--result": + v := strings.SplitN(os.Args[i+1], "=", 2) + refs := strings.Split(v[0], ",") + if len(v) == 2 { + resulting = append(resulting, data.Rule{Expr: v[1], Refs: refs}) + } else { + resulting = append(resulting, data.Rule{Expr: "true", Refs: refs}) + } + case "-t", "--timeout": + v := strings.SplitN(os.Args[i+1], "=", 2) + if len(v) == 2 { + timeouts = append(timeouts, data.Timeout{Ref: v[0], Duration: v[1]}) + } else { + timeouts = append(timeouts, data.Timeout{Ref: v[0], Duration: ""}) + } + case "-e", "--env": + computed = append(computed, strings.Split(os.Args[i+1], ",")...) + default: + config[strings.TrimLeft(os.Args[i], "-")] = os.Args[i+1] + } + } + + // Compute environment variables + for _, name := range computed { + initial := os.Getenv(name) + value, err := data.Template(initial) + if err != nil { + output.Failf(output.CodeInputError, `resolving "%s" environment variable: %s: %s`, name, initial, err.Error()) + } + _ = os.Setenv(name, value) + } + + // Compute conditional steps - ignore errors initially, as the may be dependent on themselves + data.Iterate(conditions, func(c data.Rule) bool { + expr, err := data.Expression(c.Expr) + if err != nil { + return false + } + v, _ := expr.BoolValue() + if !v { + for _, r := range c.Refs { + data.State.GetStep(r).Skip(now) + } + } + return true + }) + + // Fail invalid conditional steps + for _, c := range conditions { + _, err := data.Expression(c.Expr) + if err != nil { + output.Failf(output.CodeInputError, "broken condition for refs: %s: %s: %s", strings.Join(c.Refs, ", "), c.Expr, err.Error()) + } + } + + // Start all acknowledged steps + for _, f := range resulting { + for _, r := range f.Refs { + if r != "" { + data.State.GetStep(r).Start(now) + } + } + } + for _, t := range timeouts { + if t.Ref != "" { + data.State.GetStep(t.Ref).Start(now) + } + } + data.State.GetStep(data.Step.Ref).Start(now) + + // Register timeouts + for _, t := range timeouts { + err := data.State.GetStep(t.Ref).SetTimeoutDuration(now, t.Duration) + if err != nil { + output.Failf(output.CodeInputError, "broken timeout for ref: %s: %s: %s", t.Ref, t.Duration, err.Error()) + } + } + + // Save the resulting conditions + data.Config.Resulting = resulting + + // Don't call further if the step is already skipped + if data.State.GetStep(data.Step.Ref).Status == data.StepStatusSkipped { + if data.Config.Debug { + fmt.Printf("Skipped.\n") + } + data.Finish() + } + + // Load the rest of the configuration + for k, v := range config { + value, err := data.Template(v) + if err != nil { + output.Failf(output.CodeInputError, `resolving "%s" param: %s: %s`, k, v, err.Error()) + } + data.LoadConfig(map[string]string{k: value}) + } + + // Compute templates in the cmd/args + original := slices.Clone(args) + var err error + for i := range args { + args[i], err = data.Template(args[i]) + if err != nil { + output.Failf(output.CodeInputError, `resolving command: %s: %s`, shellquote.Join(original...), err.Error()) + } + } + + // Fail when there is nothing to run + if len(args) == 0 { + output.Failf(output.CodeNoCommand, "missing command to run") + } + + // Handle aborting + stopSignal := make(chan os.Signal, 1) + signal.Notify(stopSignal, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-stopSignal + fmt.Println("The task was aborted.") + data.Step.Status = data.StepStatusAborted + data.Step.ExitCode = output.CodeAborted + data.Finish() + }() + + // Handle timeouts + for _, t := range timeouts { + go func(ref string) { + time.Sleep(data.State.GetStep(ref).TimeoutAt.Sub(time.Now())) + fmt.Printf("Timed out.\n") + data.State.GetStep(ref).SetStatus(data.StepStatusTimeout) + data.Step.Status = data.StepStatusTimeout + data.Step.ExitCode = output.CodeTimeout + data.Finish() + }(t.Ref) + } + + // Start the task + data.Step.Executed = true + run.Run(args[0], args[1:]) + + os.Exit(0) +} diff --git a/cmd/tcl/testworkflow-init/output/constants.go b/cmd/tcl/testworkflow-init/output/constants.go new file mode 100644 index 00000000000..ef95dbdc271 --- /dev/null +++ b/cmd/tcl/testworkflow-init/output/constants.go @@ -0,0 +1,16 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package output + +const ( + CodeTimeout uint8 = 124 + CodeAborted uint8 = 137 + CodeInputError uint8 = 155 + CodeNoCommand uint8 = 189 +) diff --git a/cmd/tcl/testworkflow-init/output/output.go b/cmd/tcl/testworkflow-init/output/output.go new file mode 100644 index 00000000000..8c2e911e331 --- /dev/null +++ b/cmd/tcl/testworkflow-init/output/output.go @@ -0,0 +1,29 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package output + +import ( + "fmt" + "os" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" +) + +func Failf(exitCode uint8, message string, args ...interface{}) { + // Print message + fmt.Printf(message+"\n", args...) + + // Kill the sub-process + if data.Step.Cmd != nil && data.Step.Cmd.Process != nil { + _ = data.Step.Cmd.Process.Kill() + } + + // Exit + os.Exit(int(exitCode)) +} diff --git a/cmd/tcl/testworkflow-init/run/run.go b/cmd/tcl/testworkflow-init/run/run.go new file mode 100644 index 00000000000..9a743b6e051 --- /dev/null +++ b/cmd/tcl/testworkflow-init/run/run.go @@ -0,0 +1,89 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package run + +import ( + "fmt" + "os" + "os/exec" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" +) + +func getProcessStatus(err error) (bool, uint8) { + if err == nil { + return true, 0 + } + if e, ok := err.(*exec.ExitError); ok { + if e.ProcessState != nil { + return false, uint8(e.ProcessState.ExitCode()) + } + return false, 1 + } + fmt.Println(err.Error()) + return false, 1 +} + +// TODO: Obfuscate Stdout/Stderr streams +func createCommand(cmd string, args ...string) (c *exec.Cmd) { + c = exec.Command(cmd, args...) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + return +} + +func execute(cmd string, args ...string) { + data.Step.Cmd = createCommand(cmd, args...) + success, exitCode := getProcessStatus(data.Step.Cmd.Run()) + data.Step.ExitCode = exitCode + + actualSuccess := success + if data.Config.Negative { + actualSuccess = !success + } + + if actualSuccess { + data.Step.Status = data.StepStatusPassed + } else { + data.Step.Status = data.StepStatusFailed + } + + if data.Config.Negative { + fmt.Printf("Expected to fail: finished with exit code %d.\n", exitCode) + } else if data.Config.Debug { + fmt.Printf("Exit code: %d.\n", exitCode) + } +} + +func Run(cmd string, args []string) { + // Instantiate the command and run + execute(cmd, args...) + + // Retry if it's expected + // TODO: Support nested retries + step := data.State.GetStep(data.Step.Ref) + for step.Iteration <= uint64(data.Config.RetryCount) { + expr, err := data.Expression(data.Config.RetryUntil, data.LocalMachine) + if err != nil { + fmt.Printf("Failed to execute retry condition: %s: %s\n", data.Config.RetryUntil, err.Error()) + data.Finish() + } + v, _ := expr.BoolValue() + if v { + break + } + step.Next() + fmt.Printf("\nExit code: %d • Retrying: attempt #%d (of %d):\n", data.Step.ExitCode, step.Iteration-1, data.Config.RetryCount) + execute(cmd, args...) + } + + // Finish + data.Finish() +} diff --git a/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml b/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml new file mode 100644 index 00000000000..bac839d82f4 --- /dev/null +++ b/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml @@ -0,0 +1,89 @@ +project_name: testkube-tw-init + +env: + # Goreleaser always uses the docker buildx builder with name "default"; see + # https://github.com/goreleaser/goreleaser/pull/3199 + # To use a builder other than "default", set this variable. + # Necessary for, e.g., GitHub actions cache integration. + - DOCKER_REPO={{ if index .Env "DOCKER_REPO" }}{{ .Env.DOCKER_REPO }}{{ else }}kubeshop{{ end }} + - DOCKER_BUILDX_BUILDER={{ if index .Env "DOCKER_BUILDX_BUILDER" }}{{ .Env.DOCKER_BUILDX_BUILDER }}{{ else }}default{{ end }} + # Setup to enable Docker to use, e.g., the GitHub actions cache; see + # https://docs.docker.com/build/building/cache/backends/ + # https://github.com/moby/buildkit#export-cache + - DOCKER_BUILDX_CACHE_FROM={{ if index .Env "DOCKER_BUILDX_CACHE_FROM" }}{{ .Env.DOCKER_BUILDX_CACHE_FROM }}{{ else }}type=registry{{ end }} + - DOCKER_BUILDX_CACHE_TO={{ if index .Env "DOCKER_BUILDX_CACHE_TO" }}{{ .Env.DOCKER_BUILDX_CACHE_TO }}{{ else }}type=inline{{ end }} + - DOCKER_IMAGE_TAG={{ if index .Env "DOCKER_IMAGE_TAG" }}{{ .Env.DOCKER_IMAGE_TAG }}{{ else }}{{ end }} +builds: + - id: "linux" + main: ./cmd/tcl/testworkflow-init + binary: testworkflow-init + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" + ldflags: -X github.com/kubeshop/testkube/pkg/version.Version={{ .Version }} + -X github.com/kubeshop/testkube/pkg/version.Commit={{ .FullCommit }} + -s -w +dockers: + - dockerfile: ./build/testworkflow-init/Dockerfile + use: buildx + goos: linux + goarch: amd64 + image_templates: + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" + - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Env.DOCKER_IMAGE_TAG }}-amd64{{ end }}" + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.created={{ .Date}}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--builder={{ .Env.DOCKER_BUILDX_BUILDER }}" + - "--cache-to={{ .Env.DOCKER_BUILDX_CACHE_TO }}" + - "--cache-from={{ .Env.DOCKER_BUILDX_CACHE_FROM }}" + - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" + + - dockerfile: ./build/testworkflow-init/Dockerfile + use: buildx + goos: linux + goarch: arm64 + image_templates: + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" + - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8{{ end }}" + build_flag_templates: + - "--platform=linux/arm64/v8" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--builder={{ .Env.DOCKER_BUILDX_BUILDER }}" + - "--cache-to={{ .Env.DOCKER_BUILDX_CACHE_TO }}" + - "--cache-from={{ .Env.DOCKER_BUILDX_CACHE_FROM }}" + - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" + +docker_manifests: + - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}{{ end }}" + image_templates: + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" + - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:latest{{ end }}" + image_templates: + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" + + +release: + disable: true + +docker_signs: + - cmd: cosign + artifacts: all + output: true + args: + - "sign" + - "${artifact}" + - "--yes" diff --git a/internal/config/config.go b/internal/config/config.go index 38f5c964955..b19ba6b4c9b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,8 +88,8 @@ type Config struct { EnableSecretsEndpoint bool `envconfig:"ENABLE_SECRETS_ENDPOINT" default:"false"` DisableMongoMigrations bool `envconfig:"DISABLE_MONGO_MIGRATIONS" default:"false"` Debug bool `envconfig:"DEBUG" default:"false"` - EnableImageDataPersistentCache bool `envconfig:"ENABLE_IMAGE_DATA_PERSISTENT_CACHE" default:"false"` - ImageDataPersistentCacheKey string `envconfig:"IMAGE_DATA_PERSISTENT_CACHE_KEY" default:"testkube-image-cache"` + EnableImageDataPersistentCache bool `envconfig:"TESTKUBE_ENABLE_IMAGE_DATA_PERSISTENT_CACHE" default:"false"` + ImageDataPersistentCacheKey string `envconfig:"TESTKUBE_IMAGE_DATA_PERSISTENT_CACHE_KEY" default:"testkube-image-cache"` LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` LogServerSecure bool `envconfig:"LOG_SERVER_SECURE" default:"false"` LogServerSkipVerify bool `envconfig:"LOG_SERVER_SKIP_VERIFY" default:"false"` diff --git a/pkg/imageinspector/configmapstorage.go b/pkg/imageinspector/configmapstorage.go index 84ae09dd33b..6e66e615644 100644 --- a/pkg/imageinspector/configmapstorage.go +++ b/pkg/imageinspector/configmapstorage.go @@ -35,6 +35,9 @@ func (c *configmapStorage) fetch(ctx context.Context) (map[string]string, error) if err != nil && !k8serrors.IsNotFound(err) { return nil, errors.Wrap(err, "getting configmap cache") } + if cache == nil { + cache = map[string]string{} + } return cache, nil } diff --git a/pkg/imageinspector/serialization.go b/pkg/imageinspector/serialization.go index 749f992ffda..9a319d7f6d0 100644 --- a/pkg/imageinspector/serialization.go +++ b/pkg/imageinspector/serialization.go @@ -4,14 +4,15 @@ import ( "encoding/json" "fmt" "regexp" + "strings" ) type Hash string -var hashKeyRe = regexp.MustCompile("[^a-zA-Z0-9-_/]") +var hashKeyRe = regexp.MustCompile("[^a-zA-Z0-9-_]") func hash(registry, image string) Hash { - return Hash(hashKeyRe.ReplaceAllString(fmt.Sprintf("%s/%s", registry, image), "_-")) + return Hash(hashKeyRe.ReplaceAllString(strings.ReplaceAll(fmt.Sprintf("%s/%s", registry, image), "/", "."), "_-")) } func marshalInfo(v Info) (string, error) { diff --git a/pkg/tcl/expressionstcl/generic.go b/pkg/tcl/expressionstcl/generic.go index c0e74e3438f..1271e32de94 100644 --- a/pkg/tcl/expressionstcl/generic.go +++ b/pkg/tcl/expressionstcl/generic.go @@ -30,8 +30,6 @@ func parseTag(tag string) tagData { return tagData{value: s[0]} } -var unrecognizedErr = errors.New("unsupported value passed for resolving expressions") - func clone(v reflect.Value) reflect.Value { if v.Kind() == reflect.String { s := v.String() @@ -46,8 +44,11 @@ func clone(v reflect.Value) reflect.Value { return v } -func resolve(v reflect.Value, t tagData, m []Machine) (err error) { - if t.key == "" && t.value == "" { +func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Machine) (changed bool, err error) { + if t.value == "force" { + force = true + } + if t.key == "" && t.value == "" && !force { return } @@ -70,57 +71,79 @@ func resolve(v reflect.Value, t tagData, m []Machine) (err error) { vv, ok := v.Interface().(intstr.IntOrString) if ok { if vv.Type == intstr.String { - return resolve(v.FieldByName("StrVal"), t, m) + return resolve(v.FieldByName("StrVal"), t, m, force, finalizer) } - } else if t.value == "include" { + } else if t.value == "include" || force { tt := v.Type() for i := 0; i < tt.NumField(); i++ { f := tt.Field(i) - tag := parseTag(f.Tag.Get("expr")) + tagStr := f.Tag.Get("expr") + tag := parseTag(tagStr) + if !f.IsExported() { + if tagStr != "" && tagStr != "-" { + return changed, errors.New(f.Name + ": private property marked with `expr` clause") + } + continue + } value := v.FieldByName(f.Name) - err = resolve(value, tag, m) + var ch bool + ch, err = resolve(value, tag, m, force, finalizer) + if ch { + changed = true + } if err != nil { - return errors.Wrap(err, f.Name) + return changed, errors.Wrap(err, f.Name) } } } return case reflect.Slice: - if t.value == "" { - return nil + if t.value == "" && !force { + return changed, nil } for i := 0; i < v.Len(); i++ { - err := resolve(v.Index(i), t, m) + ch, err := resolve(v.Index(i), t, m, force, finalizer) + if ch { + changed = true + } if err != nil { - return errors.Wrap(err, fmt.Sprintf("%d", i)) + return changed, errors.Wrap(err, fmt.Sprintf("%d", i)) } } return case reflect.Map: - if t.value == "" && t.key == "" { - return nil + if t.value == "" && t.key == "" && !force { + return changed, nil } for _, k := range v.MapKeys() { - if t.value != "" { + if t.value != "" || force { // It's not possible to get a pointer to map element, // so we need to copy it and reassign item := clone(v.MapIndex(k)) - err = resolve(item, t, m) + var ch bool + ch, err = resolve(item, t, m, force, finalizer) + if ch { + changed = true + } v.SetMapIndex(k, item) if err != nil { - return errors.Wrap(err, k.String()) + return changed, errors.Wrap(err, k.String()) } } - if t.key != "" { + if t.key != "" || force { key := clone(k) - err = resolve(key, tagData{value: t.key}, m) + var ch bool + ch, err = resolve(key, tagData{value: t.key}, m, force, finalizer) + if ch { + changed = true + } if !key.Equal(k) { item := clone(v.MapIndex(k)) v.SetMapIndex(k, reflect.Value{}) v.SetMapIndex(key, item) } if err != nil { - return errors.Wrap(err, "key("+k.String()+")") + return changed, errors.Wrap(err, "key("+k.String()+")") } } } @@ -128,23 +151,47 @@ func resolve(v reflect.Value, t tagData, m []Machine) (err error) { case reflect.String: if t.value == "expression" { var expr Expression - expr, err = CompileAndResolve(v.String(), m...) + str := v.String() + expr, err = CompileAndResolve(str, m...) if err != nil { - return err + return changed, err } - vv := expr.String() + var vv string + if finalizer != nil { + expr2, err := expr.Resolve(finalizer) + if err != nil { + vv = expr.String() + } else { + vv, _ = expr2.Static().StringValue() + } + } else { + vv = expr.String() + } + changed = vv != str if ptr.Kind() == reflect.String { v.SetString(vv) } else { ptr.Set(reflect.ValueOf(&vv)) } - } else if t.value == "template" && !IsTemplateStringWithoutExpressions(v.String()) { + } else if (t.value == "template" && !IsTemplateStringWithoutExpressions(v.String())) || force { var expr Expression - expr, err = CompileAndResolveTemplate(v.String(), m...) + str := v.String() + expr, err = CompileAndResolveTemplate(str, m...) if err != nil { - return err + return changed, err } - vv := expr.Template() + var vv string + if finalizer != nil { + expr2, err := expr.Resolve(finalizer) + if err != nil { + vv = expr.String() + } else { + vv, _ = expr2.Static().StringValue() + } + } else { + vv = expr.Template() + } + changed = vv != str if ptr.Kind() == reflect.String { v.SetString(vv) } else { @@ -154,14 +201,39 @@ func resolve(v reflect.Value, t tagData, m []Machine) (err error) { return } - // Fail for unrecognized values - return unrecognizedErr + // Ignore unrecognized values + return } -func SimplifyStruct(t interface{}, m ...Machine) error { +func simplify(t interface{}, tag tagData, finalizer Machine, m ...Machine) error { v := reflect.ValueOf(t) if v.Kind() != reflect.Pointer { - return errors.New("pointer needs to be passed to Resolve function") + return errors.New("pointer needs to be passed to Simplify function") + } + changed, err := resolve(v, tag, m, false, finalizer) + i := 1 + for changed && err == nil { + if i > maxCallStack { + return fmt.Errorf("maximum call stack exceeded while simplifying struct") + } + changed, err = resolve(v, tag, m, false, finalizer) + i++ } - return resolve(v, tagData{value: "include"}, m) + return err +} + +func Simplify(t interface{}, m ...Machine) error { + return simplify(t, tagData{value: "include"}, nil, m...) +} + +func SimplifyForce(t interface{}, m ...Machine) error { + return simplify(t, tagData{value: "force"}, nil, m...) +} + +func Finalize(t interface{}, m ...Machine) error { + return simplify(t, tagData{value: "include"}, FinalizerNone, m...) +} + +func FinalizeForce(t interface{}, m ...Machine) error { + return simplify(t, tagData{value: "force"}, FinalizerNone, m...) } diff --git a/pkg/tcl/expressionstcl/generic_test.go b/pkg/tcl/expressionstcl/generic_test.go index 29f6c16bc59..1e23300c087 100644 --- a/pkg/tcl/expressionstcl/generic_test.go +++ b/pkg/tcl/expressionstcl/generic_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" "github.com/kubeshop/testkube/internal/common" @@ -48,6 +49,11 @@ type testObj struct { DummyObjPtr *testObj2 } +type testObjNested struct { + Value corev1.Volume `expr:"force"` + Dummy corev1.Volume +} + var testMachine = NewMachine(). Register("dummy", "test"). Register("ten", 10) @@ -61,7 +67,7 @@ func TestGenericString(t *testing.T) { Dummy: "5 + 3 + ten", DummyPtr: common.Ptr("5 + 3 + ten"), } - err := SimplifyStruct(&obj, testMachine) + err := Simplify(&obj, testMachine) assert.NoError(t, err) assert.Equal(t, "18", obj.Expr) assert.Equal(t, "1310", obj.Tmpl) @@ -78,7 +84,7 @@ func TestGenericIntOrString(t *testing.T) { IntExprPtr: &intstr.IntOrString{Type: intstr.String, StrVal: "1 + 2 + ten"}, IntTmplPtr: &intstr.IntOrString{Type: intstr.String, StrVal: "{{ 4 + 3 }}{{ ten }}"}, } - err := SimplifyStruct(&obj, testMachine) + err := Simplify(&obj, testMachine) assert.NoError(t, err) assert.Equal(t, "18", obj.IntExpr.String()) assert.Equal(t, "1310", obj.IntTmpl.String()) @@ -92,7 +98,7 @@ func TestGenericSlice(t *testing.T) { SliceExprStrPtr: &[]string{"200 + 100", "100 + 200", "ten", "abc"}, SliceExprObj: []testObj2{{Expr: "10 + 5", Dummy: "3 + 2"}}, } - err := SimplifyStruct(&obj, testMachine) + err := Simplify(&obj, testMachine) assert.NoError(t, err) assert.Equal(t, []string{"300", "300", "10", "abc"}, obj.SliceExprStr) assert.Equal(t, &[]string{"300", "300", "10", "abc"}, obj.SliceExprStrPtr) @@ -107,7 +113,7 @@ func TestGenericMap(t *testing.T) { MapValIntTmpl: map[string]intstr.IntOrString{"{{ 10 + 3 }}2": {Type: intstr.String, StrVal: "{{ 3 + 5 }}"}}, MapTmplExpr: map[string]string{"{{ 10 + 3 }}2": "3 + 5"}, } - err := SimplifyStruct(&obj, testMachine) + err := Simplify(&obj, testMachine) assert.NoError(t, err) assert.Equal(t, map[string]string{"132": "8"}, obj.MapKeyVal) assert.Equal(t, map[string]string{"132": "{{ 3 + 5 }}"}, obj.MapKeyTmpl) @@ -123,7 +129,7 @@ func TestNestedObject(t *testing.T) { DummyObj: testObj2{Expr: "10 + 8", Dummy: "333 + 2"}, DummyObjPtr: &testObj2{Expr: "10 + 8", Dummy: "3333 + 2"}, } - err := SimplifyStruct(&obj, testMachine) + err := Simplify(&obj, testMachine) assert.NoError(t, err) assert.Equal(t, testObj2{Expr: "15", Dummy: "3 + 2"}, obj.Obj) assert.Equal(t, &testObj2{Expr: "18", Dummy: "33 + 2"}, obj.ObjPtr) @@ -136,7 +142,7 @@ func TestGenericNotMutateStringPointer(t *testing.T) { obj := testObj{ ExprPtr: ptr, } - _ = SimplifyStruct(&obj, testMachine) + _ = Simplify(&obj, testMachine) assert.Equal(t, common.Ptr("200 + 10"), ptr) } @@ -144,7 +150,75 @@ func TestGenericCompileError(t *testing.T) { got := testObj{ Tmpl: "{{ 1 + 2 }}{{ 3", } - err := SimplifyStruct(&got) + err := Simplify(&got) assert.Contains(t, fmt.Sprintf("%v", err), "Tmpl: template error") } + +func TestGenericForceSimplify(t *testing.T) { + got := corev1.Volume{ + Name: "{{ 3 + 2 }}{{ 5 }}", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "{{ 4433 }}"}, + }, + }, + } + err := SimplifyForce(&got) + + want := corev1.Volume{ + Name: "55", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "4433"}, + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestGenericForceSimplifyNested(t *testing.T) { + got := testObjNested{ + Value: corev1.Volume{ + Name: "{{ 3 + 2 }}{{ 5 }}", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "{{ 4433 }}"}, + }, + }, + }, + Dummy: corev1.Volume{ + Name: "{{ 3 + 2 }}{{ 5 }}", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "{{ 4433 }}"}, + }, + }, + }, + } + err := Simplify(&got) + + want := testObjNested{ + Value: corev1.Volume{ + Name: "55", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "4433"}, + }, + }, + }, + Dummy: corev1.Volume{ + Name: "{{ 3 + 2 }}{{ 5 }}", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "{{ 4433 }}"}, + }, + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, got) +} diff --git a/pkg/tcl/expressionstcl/math.go b/pkg/tcl/expressionstcl/math.go index bf71a86acb9..5a638d6ceef 100644 --- a/pkg/tcl/expressionstcl/math.go +++ b/pkg/tcl/expressionstcl/math.go @@ -265,6 +265,27 @@ func (s *math) SafeResolve(m ...Machine) (v Expression, changed bool, err error) if err != nil { return } + + // Fast track for cutting dead paths + t := s.left.Type() + if s.left.Static() == nil && s.right.Static() != nil && t != TypeUnknown && t == s.right.Type() && t == TypeBool { + if s.operator == operatorAnd { + b, err := s.right.Static().BoolValue() + if err == nil && !b { + return s.right, true, nil + } else if err == nil { + return s.left, true, nil + } + } else if s.operator == operatorOr { + b, err := s.right.Static().BoolValue() + if err == nil && b { + return s.right, true, nil + } else if err == nil { + return s.left, true, nil + } + } + } + if s.left.Static() != nil && s.right.Static() != nil { res, err := s.performMath(s.left.Static(), s.right.Static()) if err != nil { diff --git a/pkg/tcl/expressionstcl/parse.go b/pkg/tcl/expressionstcl/parse.go index ea933b5884b..224c12975b7 100644 --- a/pkg/tcl/expressionstcl/parse.go +++ b/pkg/tcl/expressionstcl/parse.go @@ -11,6 +11,7 @@ package expressionstcl import ( "errors" "fmt" + math2 "math" "regexp" "strings" ) @@ -92,7 +93,7 @@ func getNextSegment(t []token) (e Expression, i int, err error) { // Negation - !expr if t[0].Type == tokenTypeNot { - e, i, err = parseNextExpression(t[1:], -1) + e, i, err = parseNextExpression(t[1:], math2.MaxInt) if err != nil { return nil, 0, err } diff --git a/pkg/tcl/expressionstcl/parse_test.go b/pkg/tcl/expressionstcl/parse_test.go index 0cacf644622..fd16bab9c2e 100644 --- a/pkg/tcl/expressionstcl/parse_test.go +++ b/pkg/tcl/expressionstcl/parse_test.go @@ -38,6 +38,22 @@ func TestCompileMath(t *testing.T) { assert.Equal(t, false, must(MustCompile(`3 = 5`).Static().BoolValue())) } +func TestCompileLogical(t *testing.T) { + assert.Equal(t, "true", MustCompile(`!(false && r1)`).String()) + assert.Equal(t, "false", MustCompile(`!true && r1`).String()) + assert.Equal(t, "r1", MustCompile(`true && r1`).String()) + assert.Equal(t, "r1", MustCompile(`!true || r1`).String()) + assert.Equal(t, "true", MustCompile(`true || r1`).String()) + assert.Equal(t, "11", MustCompile(`5 - -3 * 2`).String()) + assert.Equal(t, "r1&&false", MustCompile(`r1 && false`).String()) + assert.Equal(t, "bool(r1)", MustCompile(`bool(r1) && true`).String()) + assert.Equal(t, "false", MustCompile(`bool(r1) && false`).String()) + assert.Equal(t, "r1||false", MustCompile(`r1 || false`).String()) + assert.Equal(t, "bool(r1)", MustCompile(`bool(r1) || false`).String()) + assert.Equal(t, "r1||true", MustCompile(`r1 || true`).String()) + assert.Equal(t, "true", MustCompile(`bool(r1) || true`).String()) +} + func TestCompileMathOperationsPrecedence(t *testing.T) { assert.Equal(t, 7.0, must(MustCompile(`1 + 2 * 3`).Static().FloatValue())) assert.Equal(t, 11.0, must(MustCompile(`1 + (2 * 3) + 4`).Static().FloatValue())) diff --git a/pkg/tcl/expressionstcl/utils.go b/pkg/tcl/expressionstcl/utils.go index 991e2db1037..2b02878f565 100644 --- a/pkg/tcl/expressionstcl/utils.go +++ b/pkg/tcl/expressionstcl/utils.go @@ -10,6 +10,8 @@ package expressionstcl import ( "fmt" + + "github.com/pkg/errors" ) const maxCallStack = 10_000 @@ -26,3 +28,37 @@ func deepResolve(expr Expression, machines ...Machine) (Expression, error) { } return expr, err } + +func EvalTemplate(tpl string, machines ...Machine) (string, error) { + expr, err := CompileTemplate(tpl) + if err != nil { + return "", errors.Wrap(err, "compiling") + } + expr, err = expr.Resolve(machines...) + if err != nil { + return "", errors.Wrap(err, "resolving") + } + if expr.Static() == nil { + return "", fmt.Errorf("template should be static: %s", expr.Template()) + } + return expr.Static().StringValue() +} + +func EvalExpression(str string, machines ...Machine) (StaticValue, error) { + expr, err := Compile(str) + if err != nil { + return nil, errors.Wrap(err, "compiling") + } + expr, err = expr.Resolve(machines...) + if err != nil { + return nil, errors.Wrap(err, "resolving") + } + if expr.Static() == nil { + return nil, fmt.Errorf("expression should be static: %s", expr.String()) + } + return expr.Static(), nil +} + +func Escape(str string) string { + return NewStringValue(str).Template() +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go index c8ccf4b46d0..59f6bc0b10c 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go @@ -172,11 +172,11 @@ func ApplyTemplates(workflow *testworkflowsv1.TestWorkflow, templates map[string // Encapsulate TestWorkflow configuration to not pass it into templates accidentally random := rand.String(10) - err := expressionstcl.SimplifyStruct(workflow, expressionstcl.ReplacePrefixMachine("config.", random+".")) + err := expressionstcl.Simplify(workflow, expressionstcl.ReplacePrefixMachine("config.", random+".")) if err != nil { return err } - defer expressionstcl.SimplifyStruct(workflow, expressionstcl.ReplacePrefixMachine(random+".", "config.")) + defer expressionstcl.Simplify(workflow, expressionstcl.ReplacePrefixMachine(random+".", "config.")) // Apply top-level templates for i, ref := range workflow.Spec.Use { diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go index 7645aba5fa4..27a010735c9 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/config.go @@ -69,7 +69,7 @@ func ApplyWorkflowConfig(t *testworkflowsv1.TestWorkflow, cfg map[string]intstr. if err != nil { return nil, err } - err = expressionstcl.SimplifyStruct(&t, machine, configFinalizer) + err = expressionstcl.Simplify(&t, machine, configFinalizer) return t, err } @@ -81,6 +81,6 @@ func ApplyWorkflowTemplateConfig(t *testworkflowsv1.TestWorkflowTemplate, cfg ma if err != nil { return nil, err } - err = expressionstcl.SimplifyStruct(&t, machine, configFinalizer) + err = expressionstcl.Simplify(&t, machine, configFinalizer) return t, err } From 9bfec74824ab1729c9cbc10e3d56eeaff5d24718 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 1 Mar 2024 18:08:41 +0100 Subject: [PATCH 163/234] fix(TKC-1580): adjust GoReleaser configuration for TestWorkflow Init Process (#5095) --- ...eleaser-docker-build-testworkflow-init.yml | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml b/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml index bac839d82f4..6aecfd57039 100644 --- a/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml +++ b/goreleaser_files/.goreleaser-docker-build-testworkflow-init.yml @@ -12,7 +12,8 @@ env: # https://github.com/moby/buildkit#export-cache - DOCKER_BUILDX_CACHE_FROM={{ if index .Env "DOCKER_BUILDX_CACHE_FROM" }}{{ .Env.DOCKER_BUILDX_CACHE_FROM }}{{ else }}type=registry{{ end }} - DOCKER_BUILDX_CACHE_TO={{ if index .Env "DOCKER_BUILDX_CACHE_TO" }}{{ .Env.DOCKER_BUILDX_CACHE_TO }}{{ else }}type=inline{{ end }} - - DOCKER_IMAGE_TAG={{ if index .Env "DOCKER_IMAGE_TAG" }}{{ .Env.DOCKER_IMAGE_TAG }}{{ else }}{{ end }} + # Build image with commit sha tag + - IMAGE_TAG_SHA={{ if index .Env "IMAGE_TAG_SHA" }}{{ .Env.IMAGE_TAG_SHA }}{{ else }}{{ end }} builds: - id: "linux" main: ./cmd/tcl/testworkflow-init @@ -25,7 +26,8 @@ builds: - amd64 - arm64 mod_timestamp: "{{ .CommitTimestamp }}" - ldflags: -X github.com/kubeshop/testkube/pkg/version.Version={{ .Version }} + ldflags: + -X github.com/kubeshop/testkube/pkg/version.Version={{ .Version }} -X github.com/kubeshop/testkube/pkg/version.Commit={{ .FullCommit }} -s -w dockers: @@ -34,8 +36,8 @@ dockers: goos: linux goarch: amd64 image_templates: - - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" - - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Env.DOCKER_IMAGE_TAG }}-amd64{{ end }}" + - "{{ if .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .ShortCommit }}{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.title={{ .ProjectName }}" @@ -52,8 +54,7 @@ dockers: goos: linux goarch: arm64 image_templates: - - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" - - "{{ if .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Env.DOCKER_IMAGE_TAG }}-arm64v8{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" build_flag_templates: - "--platform=linux/arm64/v8" - "--label=org.opencontainers.image.created={{ .Date }}" @@ -66,14 +67,14 @@ dockers: - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" docker_manifests: - - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}{{ end }}" + - name_template: "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}{{ end }}" image_templates: - - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" - - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" - - name_template: "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:latest{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" + - name_template: "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:latest{{ end }}" image_templates: - - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" - - "{{ if not .Env.DOCKER_IMAGE_TAG }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-init:{{ .Version }}-arm64v8{{ end }}" release: @@ -87,3 +88,6 @@ docker_signs: - "sign" - "${artifact}" - "--yes" + +snapshot: + name_template: "{{ .ShortCommit }}" From da98c10d0c20f8dc9a4e110fe297134d1a78e653 Mon Sep 17 00:00:00 2001 From: Razvan Topliceanu <47887589+topliceanurazvan@users.noreply.github.com> Date: Mon, 4 Mar 2024 11:51:34 +0200 Subject: [PATCH 164/234] docs: add steps and example for configuring keyword categories (#5089) * docs: add steps and example for configuring keyword categories * Update docs/docs/testkube-pro/articles/log-highlighting.md Co-authored-by: Julianne Fermi --------- Co-authored-by: Julianne Fermi --- .../img/keyword-highlights-configuration.png | Bin 0 -> 265750 bytes .../testkube-pro/articles/log-highlighting.md | 27 ++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 docs/docs/img/keyword-highlights-configuration.png diff --git a/docs/docs/img/keyword-highlights-configuration.png b/docs/docs/img/keyword-highlights-configuration.png new file mode 100644 index 0000000000000000000000000000000000000000..ab9b898607b35624a86559808ec682af434ec032 GIT binary patch literal 265750 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%0Ae+AUr#d4A?$=Z)f-%7N0S^g`aN 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 z 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 P|2>wFf0%dQ;Q9Xt?etor literal 0 HcmV?d00001 diff --git a/docs/docs/testkube-pro/articles/log-highlighting.md b/docs/docs/testkube-pro/articles/log-highlighting.md index 9db9b20632c..5a60d300bc6 100644 --- a/docs/docs/testkube-pro/articles/log-highlighting.md +++ b/docs/docs/testkube-pro/articles/log-highlighting.md @@ -28,11 +28,34 @@ By default, all the categories are active. ![log-highlighting-filtering.png](../../img/log-highlighting-filtering.png) -There are 4 categories at the moment, represented with few keywords each: + + +## Configuring Keyword Categories + +There are 4 default categories, represented with a few keywords each: | Category | Keywords | |----------------------------|---------------------------------------------------------------------| | **Error Keywords** | Error, Exception, Fail, Critical, Fatal | | **Connection** | Connection, Disconnect, Lost, Timeout, Refused, Handshake, Retrying | | **Resource Issues** | OutOfMemory, MemoryLeak, ResourceExhausted, LimitExceeded, Quota | -| **Access & Authorization** | Denied, Unauthorized, Forbidden, Invalid, Invalid Token, Expired | \ No newline at end of file +| **Access & Authorization** | Denied, Unauthorized, Forbidden, Invalid, Invalid Token, Expired | + +To configure keyword categories for highlighting: + +1. Navigate to the Environment settings -> Keyword handling. +2. Add a new category by providing a color, a group name, and an array of keywords. +3. Save the changes. + +## Example Configuration + +![keyword-highlights-configuration.png](../../img/keyword-highlights-configuration.png) + +In the example above, there are default categories along with a new one: + +- **Custom Category** (New): + - **Color**: Green + - **Group Name**: Custom Category + - **Keywords**: [CustomKeyword1, CustomKeyword2, CustomKeyword3] + +These keywords will be highlighted in logs when the custom category is active. \ No newline at end of file From 285642ba615bc1c6371bca9106cd698aa29cfb50 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 4 Mar 2024 11:17:09 +0100 Subject: [PATCH 165/234] feat(TKC-1581): add mechanism to build Kubernetes resources for the TestWorkflow (#5096) * feat(TKC-1581): adjust schema for TestWorkflow CRD adjustments * chore(TKC-1581): update testkube-operator * feat(TKC-1581): add initial mechanism for building Kubernetes objects from TestWorkflow * feat(TKC-1581): add basic API endpoint for execution TestWorkflows * chore(TKC-1581): clean code and add tests * feat(TKC-1581): support "delay" step clause * chore(TKC-1581): move init process arg names to constants --- api/v1/testkube.yaml | 67 +- cmd/api-server/main.go | 2 +- .../testworkflow-init/constants/commands.go | 20 + cmd/tcl/testworkflow-init/main.go | 23 +- go.mod | 2 +- go.sum | 4 +- internal/common/common.go | 29 + .../model_test_workflow_execution_request.go | 16 + .../model_test_workflow_independent_step.go | 6 +- .../v1/testkube/model_test_workflow_step.go | 2 - pkg/tcl/apitcl/v1/server.go | 7 + pkg/tcl/apitcl/v1/testworkflows.go | 82 ++ pkg/tcl/expressionstcl/generic.go | 61 +- .../mapperstcl/testworkflows/kube_openapi.go | 68 +- .../mapperstcl/testworkflows/mappers_test.go | 15 +- .../mapperstcl/testworkflows/openapi_kube.go | 62 +- .../testworkflowprocessor/bundle.go | 21 + .../testworkflowprocessor/constants.go | 55 + .../testworkflowprocessor/container.go | 387 +++++++ .../testworkflowprocessor/containerstage.go | 76 ++ .../testworkflowprocessor/groupstage.go | 165 +++ .../testworkflowprocessor/initprocess.go | 208 ++++ .../testworkflowprocessor/intermediate.go | 190 ++++ .../testworkflowprocessor/mock_container.go | 468 +++++++++ .../mock_intermediate.go | 248 +++++ .../mock_internalprocessor.go | 50 + .../testworkflowprocessor/mock_processor.go | 71 ++ .../testworkflowprocessor/mock_stage.go | 344 ++++++ .../testworkflowprocessor/operations.go | 133 +++ .../testworkflowprocessor/processor.go | 282 +++++ .../testworkflowprocessor/processor_test.go | 986 ++++++++++++++++++ .../testworkflowprocessor/refcounter.go | 32 + .../testworkflowprocessor/signature.go | 45 + .../testworkflowprocessor/stage.go | 27 + .../testworkflowprocessor/stagelifecycle.go | 111 ++ .../testworkflowprocessor/stagemetadata.go | 41 + .../testworkflowprocessor/utils.go | 132 +++ .../testworkflowresolver/apply_test.go | 9 +- 38 files changed, 4418 insertions(+), 129 deletions(-) create mode 100644 cmd/tcl/testworkflow-init/constants/commands.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_execution_request.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/bundle.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/initprocess.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_intermediate.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_internalprocessor.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_processor.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/refcounter.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/stage.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index f778534da4b..e077210ffae 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3427,6 +3427,58 @@ paths: type: array items: $ref: "#/components/schemas/Problem" + /test-workflows/:id/executions: + post: + tags: + - test-workflows + - api + - pro + summary: Execute test workflow + description: Execute test workflow in the kubernetes cluster + operationId: executeTestWorkflow + requestBody: + description: test workflow configuration + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflow" + responses: + 200: + description: successful creation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflow" + text/yaml: + schema: + type: string + 400: + description: "problem with body parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" /preview-test-workflow: post: tags: @@ -6798,6 +6850,15 @@ components: spec: $ref: "#/components/schemas/TestWorkflowSpec" + TestWorkflowExecutionRequest: + type: object + properties: + name: + type: string + description: custom execution name + config: + $ref: "#/components/schemas/TestWorkflowConfigValue" + TestWorkflowTemplate: type: object properties: @@ -6901,9 +6962,6 @@ components: optional: type: boolean description: is the step optional, so the failure won't affect the TestWorkflow result - virtualGroup: - type: boolean - description: should not display it as a nested group retry: $ref: "#/components/schemas/TestWorkflowRetryPolicy" timeout: @@ -6950,9 +7008,6 @@ components: optional: type: boolean description: is the step optional, so the failure won't affect the TestWorkflow result - virtualGroup: - type: boolean - description: should not display it as a nested group use: type: array description: list of TestWorkflowTemplates to use diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 2e2abdef7cd..c534936737c 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -587,7 +587,7 @@ func main() { } // Apply Pro server enhancements - apitclv1.NewApiTCL(api, &proContext, kubeClient).AppendRoutes() + apitclv1.NewApiTCL(api, &proContext, kubeClient, inspector).AppendRoutes() api.InitEvents() if !cfg.DisableTestTriggers { diff --git a/cmd/tcl/testworkflow-init/constants/commands.go b/cmd/tcl/testworkflow-init/constants/commands.go new file mode 100644 index 00000000000..a11d172a7f9 --- /dev/null +++ b/cmd/tcl/testworkflow-init/constants/commands.go @@ -0,0 +1,20 @@ +package constants + +const ( + ArgSeparator = "--" + ArgInit = "-i" + ArgInitLong = "--init" + ArgCondition = "-c" + ArgConditionLong = "--cond" + ArgResult = "-r" + ArgResultLong = "--result" + ArgTimeout = "-t" + ArgTimeoutLong = "--timeout" + ArgComputeEnv = "-e" + ArgComputeEnvLong = "--env" + ArgNegative = "-n" + ArgNegativeLong = "--negative" + ArgDebug = "--debug" + ArgRetryUntil = "--retryUntil" // TODO: Replace when multi-level retry will be there + ArgRetryCount = "--retryCount" // TODO: Replace when multi-level retry will be there +) diff --git a/cmd/tcl/testworkflow-init/main.go b/cmd/tcl/testworkflow-init/main.go index d17c3eb3b87..d387ed452db 100644 --- a/cmd/tcl/testworkflow-init/main.go +++ b/cmd/tcl/testworkflow-init/main.go @@ -19,6 +19,7 @@ import ( "github.com/kballard/go-shellquote" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/constants" "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/output" "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/run" @@ -49,12 +50,12 @@ func main() { break } switch os.Args[i] { - case "--": + case constants.ArgSeparator: args = os.Args[i+1:] i = len(os.Args) - case "-i", "--init": + case constants.ArgInit, constants.ArgInitLong: data.Step.InitStatus = os.Args[i+1] - case "-c", "--cond": + case constants.ArgCondition, constants.ArgConditionLong: v := strings.SplitN(os.Args[i+1], "=", 2) refs := strings.Split(v[0], ",") if len(v) == 2 { @@ -62,7 +63,7 @@ func main() { } else { conditions = append(conditions, data.Rule{Expr: "true", Refs: refs}) } - case "-r", "--result": + case constants.ArgResult, constants.ArgResultLong: v := strings.SplitN(os.Args[i+1], "=", 2) refs := strings.Split(v[0], ",") if len(v) == 2 { @@ -70,17 +71,25 @@ func main() { } else { resulting = append(resulting, data.Rule{Expr: "true", Refs: refs}) } - case "-t", "--timeout": + case constants.ArgTimeout, constants.ArgTimeoutLong: v := strings.SplitN(os.Args[i+1], "=", 2) if len(v) == 2 { timeouts = append(timeouts, data.Timeout{Ref: v[0], Duration: v[1]}) } else { timeouts = append(timeouts, data.Timeout{Ref: v[0], Duration: ""}) } - case "-e", "--env": + case constants.ArgComputeEnv, constants.ArgComputeEnvLong: computed = append(computed, strings.Split(os.Args[i+1], ",")...) + case constants.ArgNegative, constants.ArgNegativeLong: + config["negative"] = os.Args[i+1] + case constants.ArgRetryCount: + config["retryCount"] = os.Args[i+1] + case constants.ArgRetryUntil: + config["retryUntil"] = os.Args[i+1] + case constants.ArgDebug: + config["debug"] = os.Args[i+1] default: - config[strings.TrimLeft(os.Args[i], "-")] = os.Args[i+1] + output.Failf(output.CodeInputError, "unknown parameter: %s", os.Args[i]) } } diff --git a/go.mod b/go.mod index 11c172cd6fb..233a59efafb 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301103958-c1e3dd2bfec8 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301224325-4909488f050d github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index ab02d4bc032..644d95ec74b 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301103958-c1e3dd2bfec8 h1:nnm52168fhDU/3AKxHSWeRgZq8Iqph4Bn/Z2yclx0HU= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301103958-c1e3dd2bfec8/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301224325-4909488f050d h1:0h5O0qM9kv9LrFYwAdoFbr6XnQrjy0hWmMvXet5PPfw= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301224325-4909488f050d/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/internal/common/common.go b/internal/common/common.go index 27b578ff324..75c627ef693 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -48,6 +48,35 @@ func MapSlice[T any, U any](s []T, fn func(T) U) []U { return result } +func FilterSlice[T any](s []T, fn func(T) bool) []T { + if len(s) == 0 { + return nil + } + result := make([]T, 0) + for i := range s { + if fn(s[i]) { + result = append(result, s[i]) + } + } + return result +} + +func UniqueSlice[T comparable](s []T) []T { + if len(s) == 0 { + return nil + } + result := make([]T, 0) + seen := map[T]struct{}{} + for i := range s { + _, ok := seen[s[i]] + if !ok { + seen[s[i]] = struct{}{} + result = append(result, s[i]) + } + } + return result +} + func MapMap[T any, U any](m map[string]T, fn func(T) U) map[string]U { if len(m) == 0 { return nil diff --git a/pkg/api/v1/testkube/model_test_workflow_execution_request.go b/pkg/api/v1/testkube/model_test_workflow_execution_request.go new file mode 100644 index 00000000000..ccd4d0db8a4 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_execution_request.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowExecutionRequest struct { + // custom execution name + Name string `json:"name,omitempty"` + Config map[string]string `json:"config,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_independent_step.go b/pkg/api/v1/testkube/model_test_workflow_independent_step.go index c2d3f8cc13d..9b7c4e1711c 100644 --- a/pkg/api/v1/testkube/model_test_workflow_independent_step.go +++ b/pkg/api/v1/testkube/model_test_workflow_independent_step.go @@ -17,10 +17,8 @@ type TestWorkflowIndependentStep struct { // is the step expected to fail Negative bool `json:"negative,omitempty"` // is the step optional, so the failure won't affect the TestWorkflow result - Optional bool `json:"optional,omitempty"` - // should not display it as a nested group - VirtualGroup bool `json:"virtualGroup,omitempty"` - Retry *TestWorkflowRetryPolicy `json:"retry,omitempty"` + Optional bool `json:"optional,omitempty"` + Retry *TestWorkflowRetryPolicy `json:"retry,omitempty"` // maximum time this step may take Timeout string `json:"timeout,omitempty"` // delay before the step diff --git a/pkg/api/v1/testkube/model_test_workflow_step.go b/pkg/api/v1/testkube/model_test_workflow_step.go index 08dd8f7d511..bac8cfd95eb 100644 --- a/pkg/api/v1/testkube/model_test_workflow_step.go +++ b/pkg/api/v1/testkube/model_test_workflow_step.go @@ -18,8 +18,6 @@ type TestWorkflowStep struct { Negative bool `json:"negative,omitempty"` // is the step optional, so the failure won't affect the TestWorkflow result Optional bool `json:"optional,omitempty"` - // should not display it as a nested group - VirtualGroup bool `json:"virtualGroup,omitempty"` // list of TestWorkflowTemplates to use Use []TestWorkflowTemplateRef `json:"use,omitempty"` Template *TestWorkflowTemplateRef `json:"template,omitempty"` diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index 5bc8be52baa..2884150bb8a 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -19,11 +19,13 @@ import ( testworkflowsv1 "github.com/kubeshop/testkube-operator/pkg/client/testworkflows/v1" apiv1 "github.com/kubeshop/testkube/internal/app/api/v1" "github.com/kubeshop/testkube/internal/config" + "github.com/kubeshop/testkube/pkg/imageinspector" ) type apiTCL struct { apiv1.TestkubeAPI ProContext *config.ProContext + ImageInspector imageinspector.Inspector TestWorkflowsClient testworkflowsv1.Interface TestWorkflowTemplatesClient testworkflowsv1.TestWorkflowTemplatesInterface } @@ -36,10 +38,12 @@ func NewApiTCL( testkubeAPI apiv1.TestkubeAPI, proContext *config.ProContext, kubeClient kubeclient.Client, + imageInspector imageinspector.Inspector, ) ApiTCL { return &apiTCL{ TestkubeAPI: testkubeAPI, ProContext: proContext, + ImageInspector: imageInspector, TestWorkflowsClient: testworkflowsv1.NewClient(kubeClient, testkubeAPI.Namespace), TestWorkflowTemplatesClient: testworkflowsv1.NewTestWorkflowTemplatesClient(kubeClient, testkubeAPI.Namespace), } @@ -83,6 +87,9 @@ func (s *apiTCL) AppendRoutes() { testWorkflows.Put("/:id", s.pro(s.UpdateTestWorkflowHandler())) testWorkflows.Delete("/:id", s.pro(s.DeleteTestWorkflowHandler())) + testWorkflowExecutions := testWorkflows.Group("/:id/executions") + testWorkflowExecutions.Post("/", s.pro(s.ExecuteTestWorkflowHandler())) + root.Post("/preview-test-workflow", s.pro(s.PreviewTestWorkflowHandler())) testWorkflowTemplates := root.Group("/test-workflow-templates") diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index f8980cbe4b1..e25893c662c 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -9,17 +9,22 @@ package v1 import ( + "context" "fmt" "net/http" "strings" "github.com/gofiber/fiber/v2" "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/rand" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" mappers2 "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowresolver" ) @@ -236,6 +241,83 @@ func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { } } +func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { + return func(c *fiber.Ctx) (err error) { + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to execute test workflow '%s'", name) + workflow, err := s.TestWorkflowsClient.Get(name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + // Load the execution request + var request testkube.TestWorkflowExecutionRequest + err = c.BodyParser(&request) + if err != nil && !errors.Is(err, fiber.ErrUnprocessableEntity) { + return s.BadRequest(c, errPrefix, "invalid body", err) + } + if request.Name == "" { + request.Name = rand.Name() + } + + machine := expressionstcl.NewMachine(). + Register("execution.id", request.Name) + + // Fetch the templates + tpls := testworkflowresolver.ListTemplates(workflow) + tplsMap := make(map[string]testworkflowsv1.TestWorkflowTemplate, len(tpls)) + for tplName := range tpls { + tpl, err := s.TestWorkflowTemplatesClient.Get(tplName) + if err != nil { + return s.BadRequest(c, errPrefix, "fetching error", err) + } + tplsMap[tplName] = *tpl + } + + // Apply the configuration + _, err = testworkflowresolver.ApplyWorkflowConfig(workflow, mappers2.MapConfigValueAPIToKube(request.Config)) + if err != nil { + return s.BadRequest(c, errPrefix, "configuration", err) + } + + // Resolve the TestWorkflow + err = testworkflowresolver.ApplyTemplates(workflow, tplsMap) + if err != nil { + return s.BadRequest(c, errPrefix, "resolving error", err) + } + + // Process the TestWorkflow + bundle, err := testworkflowprocessor.NewFullFeatured(s.ImageInspector). + Bundle(c.Context(), workflow, machine) + if err != nil { + return s.BadRequest(c, errPrefix, "processing error", err) + } + + // Deploy the resources + // TODO: rollback on failure + for _, item := range bundle.Secrets { + _, err = s.Clientset.CoreV1().Secrets(s.Namespace).Create(context.Background(), &item, metav1.CreateOptions{}) + if err != nil { + return s.BadRequest(c, errPrefix, "creating secret", err) + } + } + for _, item := range bundle.ConfigMaps { + _, err = s.Clientset.CoreV1().ConfigMaps(s.Namespace).Create(context.Background(), &item, metav1.CreateOptions{}) + if err != nil { + return s.BadRequest(c, errPrefix, "creating configmap", err) + } + } + _, err = s.Clientset.BatchV1().Jobs(s.Namespace).Create(context.Background(), &bundle.Job, metav1.CreateOptions{}) + if err != nil { + if err != nil { + return s.BadRequest(c, errPrefix, "creating job", err) + } + } + + return + } +} + func (s *apiTCL) getFilteredTestWorkflowList(c *fiber.Ctx) (*testworkflowsv1.TestWorkflowList, error) { crWorkflows, err := s.TestWorkflowsClient.List(c.Query("selector")) if err != nil { diff --git a/pkg/tcl/expressionstcl/generic.go b/pkg/tcl/expressionstcl/generic.go index 1271e32de94..ef5d895cd71 100644 --- a/pkg/tcl/expressionstcl/generic.go +++ b/pkg/tcl/expressionstcl/generic.go @@ -44,7 +44,7 @@ func clone(v reflect.Value) reflect.Value { return v } -func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Machine) (changed bool, err error) { +func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalize bool) (changed bool, err error) { if t.value == "force" { force = true } @@ -71,7 +71,7 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach vv, ok := v.Interface().(intstr.IntOrString) if ok { if vv.Type == intstr.String { - return resolve(v.FieldByName("StrVal"), t, m, force, finalizer) + return resolve(v.FieldByName("StrVal"), t, m, force, finalize) } } else if t.value == "include" || force { tt := v.Type() @@ -87,7 +87,7 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach } value := v.FieldByName(f.Name) var ch bool - ch, err = resolve(value, tag, m, force, finalizer) + ch, err = resolve(value, tag, m, force, finalize) if ch { changed = true } @@ -102,7 +102,7 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach return changed, nil } for i := 0; i < v.Len(); i++ { - ch, err := resolve(v.Index(i), t, m, force, finalizer) + ch, err := resolve(v.Index(i), t, m, force, finalize) if ch { changed = true } @@ -121,30 +121,30 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach // so we need to copy it and reassign item := clone(v.MapIndex(k)) var ch bool - ch, err = resolve(item, t, m, force, finalizer) + ch, err = resolve(item, t, m, force, finalize) if ch { changed = true } - v.SetMapIndex(k, item) if err != nil { return changed, errors.Wrap(err, k.String()) } + v.SetMapIndex(k, item) } if t.key != "" || force { key := clone(k) var ch bool - ch, err = resolve(key, tagData{value: t.key}, m, force, finalizer) + ch, err = resolve(key, tagData{value: t.key}, m, force, finalize) if ch { changed = true } + if err != nil { + return changed, errors.Wrap(err, "key("+k.String()+")") + } if !key.Equal(k) { item := clone(v.MapIndex(k)) v.SetMapIndex(k, reflect.Value{}) v.SetMapIndex(key, item) } - if err != nil { - return changed, errors.Wrap(err, "key("+k.String()+")") - } } } return @@ -157,13 +157,12 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach return changed, err } var vv string - if finalizer != nil { - expr2, err := expr.Resolve(finalizer) + if finalize { + expr2, err := expr.Resolve(FinalizerFail) if err != nil { - vv = expr.String() - } else { - vv, _ = expr2.Static().StringValue() + return changed, errors.Wrap(err, "resolving the value") } + vv, _ = expr2.Static().StringValue() } else { vv = expr.String() } @@ -181,13 +180,12 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach return changed, err } var vv string - if finalizer != nil { - expr2, err := expr.Resolve(finalizer) + if finalize { + expr2, err := expr.Resolve(FinalizerFail) if err != nil { - vv = expr.String() - } else { - vv, _ = expr2.Static().StringValue() + return changed, errors.Wrap(err, "resolving the value") } + vv, _ = expr2.Static().StringValue() } else { vv = expr.Template() } @@ -205,35 +203,44 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalizer Mach return } -func simplify(t interface{}, tag tagData, finalizer Machine, m ...Machine) error { +func simplify(t interface{}, tag tagData, m ...Machine) error { v := reflect.ValueOf(t) if v.Kind() != reflect.Pointer { return errors.New("pointer needs to be passed to Simplify function") } - changed, err := resolve(v, tag, m, false, finalizer) + changed, err := resolve(v, tag, m, false, false) i := 1 for changed && err == nil { if i > maxCallStack { return fmt.Errorf("maximum call stack exceeded while simplifying struct") } - changed, err = resolve(v, tag, m, false, finalizer) + changed, err = resolve(v, tag, m, false, false) i++ } return err } +func finalize(t interface{}, tag tagData, m ...Machine) error { + v := reflect.ValueOf(t) + if v.Kind() != reflect.Pointer { + return errors.New("pointer needs to be passed to Finalize function") + } + _, err := resolve(v, tag, m, false, true) + return err +} + func Simplify(t interface{}, m ...Machine) error { - return simplify(t, tagData{value: "include"}, nil, m...) + return simplify(t, tagData{value: "include"}, m...) } func SimplifyForce(t interface{}, m ...Machine) error { - return simplify(t, tagData{value: "force"}, nil, m...) + return simplify(t, tagData{value: "force"}, m...) } func Finalize(t interface{}, m ...Machine) error { - return simplify(t, tagData{value: "include"}, FinalizerNone, m...) + return finalize(t, tagData{value: "include"}, m...) } func FinalizeForce(t interface{}, m ...Machine) error { - return simplify(t, tagData{value: "force"}, FinalizerNone, m...) + return finalize(t, tagData{value: "force"}, m...) } diff --git a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go index d57c046ea07..2563e27f632 100644 --- a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go +++ b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go @@ -65,7 +65,7 @@ func MapInt32ToBoxedInteger(v *int32) *testkube.BoxedInteger { return &testkube.BoxedInteger{Value: *v} } -func MapEnvVarKubeToAPI(v testworkflowsv1.EnvVar) testkube.EnvVar { +func MapEnvVarKubeToAPI(v corev1.EnvVar) testkube.EnvVar { return testkube.EnvVar{ Name: v.Name, Value: v.Value, @@ -357,45 +357,43 @@ func MapRetryPolicyKubeToAPI(v testworkflowsv1.RetryPolicy) testkube.TestWorkflo func MapStepKubeToAPI(v testworkflowsv1.Step) testkube.TestWorkflowStep { return testkube.TestWorkflowStep{ - Name: v.Name, - Condition: string(v.Condition), - Negative: v.Negative, - Optional: v.Optional, - VirtualGroup: v.VirtualGroup, - Use: common.MapSlice(v.Use, MapTemplateRefKubeToAPI), - Template: common.MapPtr(v.Template, MapTemplateRefKubeToAPI), - Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), - Timeout: v.Timeout, - Delay: v.Delay, - Content: common.MapPtr(v.Content, MapContentKubeToAPI), - Shell: v.Shell, - Run: common.MapPtr(v.Run, MapStepRunKubeToAPI), - WorkingDir: MapStringToBoxedString(v.WorkingDir), - Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), - Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), - Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), - Steps: common.MapSlice(v.Steps, MapStepKubeToAPI), + Name: v.Name, + Condition: string(v.Condition), + Negative: v.Negative, + Optional: v.Optional, + Use: common.MapSlice(v.Use, MapTemplateRefKubeToAPI), + Template: common.MapPtr(v.Template, MapTemplateRefKubeToAPI), + Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentKubeToAPI), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunKubeToAPI), + WorkingDir: MapStringToBoxedString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), + Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), + Steps: common.MapSlice(v.Steps, MapStepKubeToAPI), } } func MapIndependentStepKubeToAPI(v testworkflowsv1.IndependentStep) testkube.TestWorkflowIndependentStep { return testkube.TestWorkflowIndependentStep{ - Name: v.Name, - Condition: string(v.Condition), - Negative: v.Negative, - Optional: v.Optional, - VirtualGroup: v.VirtualGroup, - Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), - Timeout: v.Timeout, - Delay: v.Delay, - Content: common.MapPtr(v.Content, MapContentKubeToAPI), - Shell: v.Shell, - Run: common.MapPtr(v.Run, MapStepRunKubeToAPI), - WorkingDir: MapStringToBoxedString(v.WorkingDir), - Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), - Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), - Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), - Steps: common.MapSlice(v.Steps, MapIndependentStepKubeToAPI), + Name: v.Name, + Condition: string(v.Condition), + Negative: v.Negative, + Optional: v.Optional, + Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentKubeToAPI), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunKubeToAPI), + WorkingDir: MapStringToBoxedString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), + Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), + Steps: common.MapSlice(v.Steps, MapIndependentStepKubeToAPI), } } diff --git a/pkg/tcl/mapperstcl/testworkflows/mappers_test.go b/pkg/tcl/mapperstcl/testworkflows/mappers_test.go index 7b01a39f9d9..f0df2a6d363 100644 --- a/pkg/tcl/mapperstcl/testworkflows/mappers_test.go +++ b/pkg/tcl/mapperstcl/testworkflows/mappers_test.go @@ -26,7 +26,7 @@ var ( WorkingDir: common.Ptr("/wd"), Image: "some-image", ImagePullPolicy: "IfNotPresent", - Env: []testworkflowsv1.EnvVar{ + Env: []corev1.EnvVar{ {Name: "some-naaame", Value: "some-value"}, {Name: "some-naaame", ValueFrom: &corev1.EnvVarSource{ FieldRef: &corev1.ObjectFieldSelector{ @@ -150,11 +150,10 @@ var ( }, } stepBase = testworkflowsv1.StepBase{ - Name: "some-name", - Condition: "some-condition", - Negative: true, - Optional: false, - VirtualGroup: false, + Name: "some-name", + Condition: "some-condition", + Negative: true, + Optional: false, Retry: &testworkflowsv1.RetryPolicy{ Count: 444, Until: "abc", @@ -197,7 +196,7 @@ var ( WorkingDir: common.Ptr("/abc"), Image: "im-g", ImagePullPolicy: "IfNotPresent", - Env: []testworkflowsv1.EnvVar{ + Env: []corev1.EnvVar{ {Name: "abc", Value: "230"}, }, EnvFrom: []corev1.EnvFromSource{ @@ -224,7 +223,7 @@ var ( WorkingDir: common.Ptr("/aaaa"), Image: "ssss", ImagePullPolicy: "Never", - Env: []testworkflowsv1.EnvVar{{Name: "xyz", Value: "bar"}}, + Env: []corev1.EnvVar{{Name: "xyz", Value: "bar"}}, Command: common.Ptr([]string{"ab"}), Args: common.Ptr([]string{"abrgs"}), Resources: &testworkflowsv1.Resources{ diff --git a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go index 9908fcb8337..b77caaf805d 100644 --- a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go +++ b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go @@ -71,8 +71,8 @@ func MapBoxedIntegerToInt32(v *testkube.BoxedInteger) *int32 { return &v.Value } -func MapEnvVarAPIToKube(v testkube.EnvVar) testworkflowsv1.EnvVar { - return testworkflowsv1.EnvVar{ +func MapEnvVarAPIToKube(v testkube.EnvVar) corev1.EnvVar { + return corev1.EnvVar{ Name: v.Name, Value: v.Value, ValueFrom: common.MapPtr(v.ValueFrom, MapEnvVarSourceAPIToKube), @@ -376,21 +376,20 @@ func MapRetryPolicyAPIToKube(v testkube.TestWorkflowRetryPolicy) testworkflowsv1 func MapStepAPIToKube(v testkube.TestWorkflowStep) testworkflowsv1.Step { return testworkflowsv1.Step{ StepBase: testworkflowsv1.StepBase{ - Name: v.Name, - Condition: v.Condition, - Negative: v.Negative, - Optional: v.Optional, - VirtualGroup: v.VirtualGroup, - Retry: common.MapPtr(v.Retry, MapRetryPolicyAPIToKube), - Timeout: v.Timeout, - Delay: v.Delay, - Content: common.MapPtr(v.Content, MapContentAPIToKube), - Shell: v.Shell, - Run: common.MapPtr(v.Run, MapStepRunAPIToKube), - WorkingDir: MapBoxedStringToString(v.WorkingDir), - Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), - Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), - Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), + Name: v.Name, + Condition: v.Condition, + Negative: v.Negative, + Optional: v.Optional, + Retry: common.MapPtr(v.Retry, MapRetryPolicyAPIToKube), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentAPIToKube), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunAPIToKube), + WorkingDir: MapBoxedStringToString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), + Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), }, Use: common.MapSlice(v.Use, MapTemplateRefAPIToKube), Template: common.MapPtr(v.Template, MapTemplateRefAPIToKube), @@ -401,21 +400,20 @@ func MapStepAPIToKube(v testkube.TestWorkflowStep) testworkflowsv1.Step { func MapIndependentStepAPIToKube(v testkube.TestWorkflowIndependentStep) testworkflowsv1.IndependentStep { return testworkflowsv1.IndependentStep{ StepBase: testworkflowsv1.StepBase{ - Name: v.Name, - Condition: v.Condition, - Negative: v.Negative, - Optional: v.Optional, - VirtualGroup: v.VirtualGroup, - Retry: common.MapPtr(v.Retry, MapRetryPolicyAPIToKube), - Timeout: v.Timeout, - Delay: v.Delay, - Content: common.MapPtr(v.Content, MapContentAPIToKube), - Shell: v.Shell, - Run: common.MapPtr(v.Run, MapStepRunAPIToKube), - WorkingDir: MapBoxedStringToString(v.WorkingDir), - Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), - Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), - Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), + Name: v.Name, + Condition: v.Condition, + Negative: v.Negative, + Optional: v.Optional, + Retry: common.MapPtr(v.Retry, MapRetryPolicyAPIToKube), + Timeout: v.Timeout, + Delay: v.Delay, + Content: common.MapPtr(v.Content, MapContentAPIToKube), + Shell: v.Shell, + Run: common.MapPtr(v.Run, MapStepRunAPIToKube), + WorkingDir: MapBoxedStringToString(v.WorkingDir), + Container: common.MapPtr(v.Container, MapContainerConfigAPIToKube), + Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), + Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), }, Steps: common.MapSlice(v.Steps, MapIndependentStepAPIToKube), } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/bundle.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/bundle.go new file mode 100644 index 00000000000..5ec144df96f --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/bundle.go @@ -0,0 +1,21 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" +) + +type Bundle struct { + Secrets []corev1.Secret + ConfigMaps []corev1.ConfigMap + Job batchv1.Job + Signature []Signature +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go new file mode 100644 index 00000000000..857203efe1e --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go @@ -0,0 +1,55 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "fmt" + "os" + "path/filepath" + + corev1 "k8s.io/api/core/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" +) + +const ( + defaultImage = "busybox:1.36.1" + defaultShell = "/bin/sh" + defaultInternalPath = "/.tktw" + defaultDataPath = "/data" + executionIdLabelName = "testworkflowid" +) + +var ( + defaultInitPath = filepath.Join(defaultInternalPath, "init") + defaultStatePath = filepath.Join(defaultInternalPath, "state") +) + +var ( + defaultInitImage = getInitImage() + defaultContainerConfig = testworkflowsv1.ContainerConfig{ + Image: defaultImage, + Env: []corev1.EnvVar{ + {Name: "CI", Value: "1"}, + }, + } +) + +func getInitImage() string { + img := os.Getenv("TESTKUBE_TW_INIT_IMAGE") + if img == "" { + version := common.Version + if version == "" || version == "dev" { + version = "latest" + } + img = fmt.Sprintf("kubeshop/testkube-tw-init:%s", version) + } + return img +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go new file mode 100644 index 00000000000..3fad824f2b3 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go @@ -0,0 +1,387 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "maps" + "path/filepath" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowresolver" +) + +type container struct { + parent *container + Cr testworkflowsv1.ContainerConfig `expr:"include"` + CrMounts []corev1.VolumeMount `expr:"force"` +} + +type ContainerComposition interface { + Root() Container + Parent() Container + CreateChild() Container + + Resolve(m ...expressionstcl.Machine) error +} + +type ContainerAccessors interface { + Env() []corev1.EnvVar + EnvFrom() []corev1.EnvFromSource + VolumeMounts() []corev1.VolumeMount + + ImagePullPolicy() corev1.PullPolicy + Image() string + Command() []string + Args() []string + WorkingDir() string + + Detach() Container + ToKubernetesTemplate() corev1.Container + + Resources() testworkflowsv1.Resources + SecurityContext() *corev1.SecurityContext +} + +type ContainerMutations[T any] interface { + AppendEnv(env ...corev1.EnvVar) T + AppendEnvMap(env map[string]string) T + AppendEnvFrom(envFrom ...corev1.EnvFromSource) T + AppendVolumeMounts(volumeMounts ...corev1.VolumeMount) T + SetImagePullPolicy(policy corev1.PullPolicy) T + SetImage(image string) T + SetCommand(command ...string) T + SetArgs(args ...string) T + SetWorkingDir(workingDir string) T // "" = default to the image + SetResources(resources testworkflowsv1.Resources) T + SetSecurityContext(sc *corev1.SecurityContext) T + + ApplyCR(cr *testworkflowsv1.ContainerConfig) T + ApplyImageData(image *imageinspector.Info) error +} + +//go:generate mockgen -destination=./mock_container.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Container +type Container interface { + ContainerComposition + ContainerAccessors + ContainerMutations[Container] +} + +func NewContainer() Container { + return &container{} +} + +func sum[T any](s1 []T, s2 []T) []T { + if len(s1) == 0 { + return s2 + } + if len(s2) == 0 { + return s1 + } + return append(append(make([]T, 0, len(s1)+len(s2)), s1...), s2...) +} + +// Composition + +func (c *container) Root() Container { + if c.parent == nil { + return c + } + return c.parent.Parent() +} + +func (c *container) Parent() Container { + return c.parent +} + +func (c *container) CreateChild() Container { + return &container{parent: c} +} + +// Getters + +func (c *container) Env() []corev1.EnvVar { + if c.parent == nil { + return c.Cr.Env + } + return sum(c.parent.Env(), c.Cr.Env) +} + +func (c *container) EnvFrom() []corev1.EnvFromSource { + if c.parent == nil { + return c.Cr.EnvFrom + } + return sum(c.parent.EnvFrom(), c.Cr.EnvFrom) +} + +func (c *container) VolumeMounts() []corev1.VolumeMount { + if c.parent == nil { + return c.CrMounts + } + return sum(c.parent.VolumeMounts(), c.CrMounts) +} + +func (c *container) ImagePullPolicy() corev1.PullPolicy { + if c.parent == nil || c.Cr.ImagePullPolicy != "" { + return c.Cr.ImagePullPolicy + } + return c.parent.ImagePullPolicy() +} + +func (c *container) Image() string { + if c.parent == nil || c.Cr.Image != "" { + return c.Cr.Image + } + return c.parent.Image() +} + +func (c *container) Command() []string { + // Do not inherit command, if the Image was replaced on this depth + if c.parent == nil || c.Cr.Command != nil || (c.Cr.Image != "" && c.Cr.Image != c.Image()) { + if c.Cr.Command == nil { + return nil + } + return *c.Cr.Command + } + return c.parent.Command() +} + +func (c *container) Args() []string { + // Do not inherit args, if the Image or Command was replaced on this depth + if c.parent == nil || (c.Cr.Args != nil && len(*c.Cr.Args) > 0) || c.Cr.Command != nil || (c.Cr.Image != "" && c.Cr.Image != c.Image()) { + if c.Cr.Args == nil { + return nil + } + return *c.Cr.Args + } + return c.parent.Args() +} + +func (c *container) WorkingDir() string { + path := "" + if c.Cr.WorkingDir != nil { + path = *c.Cr.WorkingDir + } + if c.parent == nil { + return path + } + if filepath.IsAbs(path) { + return path + } + parentPath := c.parent.WorkingDir() + if parentPath == "" { + return path + } + return filepath.Join(parentPath, path) +} + +func (c *container) Resources() (r testworkflowsv1.Resources) { + if c.parent != nil { + r = *common.Ptr(c.parent.Resources()).DeepCopy() + } + if c.Cr.Resources == nil { + return + } + if len(c.Cr.Resources.Requests) > 0 { + r.Requests = c.Cr.Resources.Requests + } + if len(c.Cr.Resources.Limits) > 0 { + r.Limits = c.Cr.Resources.Limits + } + return +} + +func (c *container) SecurityContext() *corev1.SecurityContext { + if c.Cr.SecurityContext != nil { + return c.Cr.SecurityContext + } + if c.parent == nil { + return nil + } + return c.parent.SecurityContext() +} + +// Mutations + +func (c *container) AppendEnv(env ...corev1.EnvVar) Container { + c.Cr.Env = append(c.Cr.Env, env...) + return c +} + +func (c *container) AppendEnvMap(env map[string]string) Container { + for k, v := range env { + c.Cr.Env = append(c.Cr.Env, corev1.EnvVar{Name: k, Value: v}) + } + return c +} + +func (c *container) AppendEnvFrom(envFrom ...corev1.EnvFromSource) Container { + c.Cr.EnvFrom = append(c.Cr.EnvFrom, envFrom...) + return c +} + +func (c *container) AppendVolumeMounts(volumeMounts ...corev1.VolumeMount) Container { + c.CrMounts = append(c.CrMounts, volumeMounts...) + return c +} + +func (c *container) SetImagePullPolicy(policy corev1.PullPolicy) Container { + c.Cr.ImagePullPolicy = policy + return c +} + +func (c *container) SetImage(image string) Container { + c.Cr.Image = image + return c +} + +func (c *container) SetCommand(command ...string) Container { + c.Cr.Command = &command + return c +} + +func (c *container) SetArgs(args ...string) Container { + c.Cr.Args = &args + return c +} + +func (c *container) SetWorkingDir(workingDir string) Container { + c.Cr.WorkingDir = &workingDir + return c +} + +func (c *container) SetResources(resources testworkflowsv1.Resources) Container { + c.Cr.Resources = &resources + return c +} + +func (c *container) SetSecurityContext(sc *corev1.SecurityContext) Container { + c.Cr.SecurityContext = sc + return c +} + +func (c *container) ApplyCR(config *testworkflowsv1.ContainerConfig) Container { + c.Cr = *testworkflowresolver.MergeContainerConfig(&c.Cr, config) + return c +} + +func (c *container) ToContainerConfig() testworkflowsv1.ContainerConfig { + env := slices.Clone(c.Env()) + for i := range env { + env[i] = *env[i].DeepCopy() + } + envFrom := slices.Clone(c.EnvFrom()) + for i := range envFrom { + envFrom[i] = *envFrom[i].DeepCopy() + } + return testworkflowsv1.ContainerConfig{ + WorkingDir: common.Ptr(c.WorkingDir()), + Image: c.Image(), + ImagePullPolicy: c.ImagePullPolicy(), + Env: env, + EnvFrom: envFrom, + Command: common.Ptr(slices.Clone(c.Command())), + Args: common.Ptr(slices.Clone(c.Args())), + Resources: &testworkflowsv1.Resources{ + Requests: maps.Clone(c.Resources().Requests), + Limits: maps.Clone(c.Resources().Limits), + }, + SecurityContext: c.SecurityContext().DeepCopy(), + } +} + +func (c *container) volumeMountsCopy() []corev1.VolumeMount { + volumeMounts := make([]corev1.VolumeMount, len(c.VolumeMounts())) + for i, v := range c.VolumeMounts() { + volumeMounts[i] = *v.DeepCopy() + } + return volumeMounts +} + +func (c *container) Detach() Container { + c.Cr = c.ToContainerConfig() + c.CrMounts = c.volumeMountsCopy() + c.parent = nil + return c +} + +func (c *container) ToKubernetesTemplate() corev1.Container { + cr := c.ToContainerConfig() + var command []string + if cr.Command != nil { + command = *cr.Command + } + var args []string + if cr.Args != nil { + args = *cr.Args + } + workingDir := "" + if cr.WorkingDir != nil { + workingDir = *cr.WorkingDir + } + return corev1.Container{ + Image: cr.Image, + ImagePullPolicy: cr.ImagePullPolicy, + Command: command, + Args: args, + Env: cr.Env, + EnvFrom: cr.EnvFrom, + VolumeMounts: c.volumeMountsCopy(), + WorkingDir: workingDir, + SecurityContext: cr.SecurityContext, + } +} + +func (c *container) ApplyImageData(image *imageinspector.Info) error { + if image == nil { + return nil + } + err := c.Resolve(expressionstcl.NewMachine(). + Register("image.command", image.Entrypoint). + Register("image.args", image.Cmd). + Register("image.workingDir", image.WorkingDir)) + if err != nil { + return err + } + if len(c.Command()) == 0 { + c.SetCommand(image.Entrypoint...) + if len(c.Args()) == 0 { + c.SetArgs(image.Cmd...) + } + } + return nil +} + +func (c *container) Resolve(m ...expressionstcl.Machine) error { + base := expressionstcl.NewMachine(). + RegisterAccessor(func(name string) (interface{}, bool) { + if !strings.HasPrefix(name, "env.") { + return nil, false + } + env := c.Env() + name = name[4:] + for i := range env { + if env[i].Name == name { + value, err := expressionstcl.EvalTemplate(env[i].Value) + if err == nil { + return value, true + } + break + } + } + return nil, false + }) + return expressionstcl.Simplify(c, append([]expressionstcl.Machine{base}, m...)...) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go new file mode 100644 index 00000000000..eac3c095453 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go @@ -0,0 +1,76 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "github.com/pkg/errors" + + "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +type containerStage struct { + stageMetadata `expr:"include"` + stageLifecycle `expr:"include"` + container Container `expr:"include"` +} + +type ContainerStage interface { + Stage + Container() Container +} + +func NewContainerStage(ref string, container Container) ContainerStage { + return &containerStage{ + stageMetadata: stageMetadata{ref: ref}, + container: container.CreateChild(), + } +} + +func (s *containerStage) Len() int { + return 1 +} + +func (s *containerStage) Signature() Signature { + return &signature{ + RefValue: s.ref, + NameValue: s.name, + OptionalValue: s.optional, + NegativeValue: s.negative, + ChildrenValue: nil, + } +} + +func (s *containerStage) ContainerStages() []ContainerStage { + return []ContainerStage{s} +} + +func (s *containerStage) GetImages() map[string]struct{} { + return map[string]struct{}{s.container.Image(): {}} +} + +func (s *containerStage) Flatten() []Stage { + return []Stage{s} +} + +func (s *containerStage) ApplyImages(images map[string]*imageinspector.Info) error { + return s.container.ApplyImageData(images[s.container.Image()]) +} + +func (s *containerStage) Resolve(m ...expressionstcl.Machine) error { + err := s.container.Resolve(m...) + if err != nil { + return errors.Wrap(err, "stage container") + } + return expressionstcl.Simplify(s, m...) +} + +func (s *containerStage) Container() Container { + return s.container +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go new file mode 100644 index 00000000000..7d900aa63b7 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go @@ -0,0 +1,165 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "maps" + + "github.com/pkg/errors" + + "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +type groupStage struct { + stageMetadata `expr:"include"` + stageLifecycle `expr:"include"` + children []Stage `expr:"include"` + virtual bool +} + +type GroupStage interface { + Stage + Children() []Stage + RecursiveChildren() []Stage + Add(stages ...Stage) GroupStage +} + +func NewGroupStage(ref string, virtual bool) GroupStage { + return &groupStage{ + stageMetadata: stageMetadata{ref: ref}, + virtual: virtual, + } +} + +func (s *groupStage) Len() int { + count := 0 + for _, ch := range s.children { + count += ch.Len() + } + return count +} + +func (s *groupStage) Signature() Signature { + sig := []Signature(nil) + for _, ch := range s.children { + si := ch.Signature() + _, ok := ch.(GroupStage) + // Include children directly, if the stage is virtual + if ok && si.Name() == "" && !si.Optional() && !si.Negative() { + sig = append(sig, si.Children()...) + } else { + sig = append(sig, si) + } + } + + return &signature{ + RefValue: s.ref, + NameValue: s.name, + OptionalValue: s.optional, + NegativeValue: s.negative, + ChildrenValue: sig, + } +} + +func (s *groupStage) ContainerStages() []ContainerStage { + c := []ContainerStage(nil) + for _, ch := range s.children { + c = append(c, ch.ContainerStages()...) + } + return c +} + +func (s *groupStage) Children() []Stage { + return s.children +} + +func (s *groupStage) RecursiveChildren() []Stage { + res := make([]Stage, 0) + for _, ch := range s.children { + if v, ok := ch.(GroupStage); ok { + res = append(res, v.RecursiveChildren()...) + } else { + res = append(res, ch) + } + } + return res +} + +func (s *groupStage) GetImages() map[string]struct{} { + v := make(map[string]struct{}) + for _, ch := range s.children { + maps.Copy(v, ch.GetImages()) + } + return v +} + +func (s *groupStage) Flatten() []Stage { + // Flatten children + next := []Stage(nil) + for _, ch := range s.children { + next = append(next, ch.Flatten()...) + } + s.children = next + + // Delete empty stage + if len(s.children) == 0 { + return nil + } + + // Flatten when it is completely virtual stage + if s.virtual { + return s.children + } + + // Merge stage into single one below if possible + first := s.children[0] + if len(s.children) == 1 && (s.name == "" || first.Name() == "") { + first.SetName(first.Name(), s.name) + first.AppendConditions(s.condition) + if s.negative { + first.SetNegative(!first.Negative()) + } + if s.optional { + first.SetOptional(true) + } + return []Stage{first} + } + + return []Stage{s} +} + +func (s *groupStage) Add(stages ...Stage) GroupStage { + for _, ch := range stages { + if ch != nil { + s.children = append(s.children, ch.Flatten()...) + } + } + return s +} + +func (s *groupStage) ApplyImages(images map[string]*imageinspector.Info) error { + for i := range s.children { + err := s.children[i].ApplyImages(images) + if err != nil { + return errors.Wrap(err, "applying image data") + } + } + return nil +} + +func (s *groupStage) Resolve(m ...expressionstcl.Machine) error { + for _, ch := range s.children { + err := ch.Resolve(m...) + if err != nil { + return errors.Wrap(err, "group stage container") + } + } + return expressionstcl.Simplify(s.stageMetadata, m...) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/initprocess.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/initprocess.go new file mode 100644 index 00000000000..274edb0bb91 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/initprocess.go @@ -0,0 +1,208 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "errors" + "fmt" + "maps" + "strconv" + "strings" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/constants" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +type initProcess struct { + ref string + init []string + params []string + retry map[string]testworkflowsv1.RetryPolicy + command []string + args []string + envs []string + results []string + conditions map[string][]string + negative bool + errors []error +} + +func NewInitProcess() *initProcess { + return &initProcess{ + conditions: map[string][]string{}, + retry: map[string]testworkflowsv1.RetryPolicy{}, + } +} + +func (p *initProcess) Error() error { + if len(p.errors) == 0 { + return nil + } + return errors.Join(p.errors...) +} + +func (p *initProcess) SetRef(ref string) *initProcess { + p.ref = ref + return p +} + +func (p *initProcess) Command() []string { + args := p.params + + // TODO: Support nested retries + policy, ok := p.retry[p.ref] + if ok { + args = append(args, constants.ArgRetryCount, strconv.Itoa(int(policy.Count)), constants.ArgRetryUntil, expressionstcl.Escape(policy.Until)) + } + if p.negative { + args = append(args, constants.ArgNegative, "true") + } + if len(p.init) > 0 { + args = append(args, constants.ArgInit, strings.Join(p.init, "&&")) + } + if len(p.envs) > 0 { + args = append(args, constants.ArgComputeEnv, strings.Join(p.envs, ",")) + } + if len(p.conditions) > 0 { + for k, v := range p.conditions { + args = append(args, constants.ArgCondition, fmt.Sprintf("%s=%s", strings.Join(common.UniqueSlice(v), ","), k)) + } + } + for _, r := range p.results { + args = append(args, constants.ArgResult, r) + } + return append([]string{defaultInitPath, p.ref}, append(args, constants.ArgSeparator)...) +} + +func (p *initProcess) Args() []string { + args := make([]string, 0) + if len(p.command) > 0 { + args = p.command + } + if len(p.command) > 0 || len(p.args) > 0 { + args = append(args, p.args...) + } + return args +} + +func (p *initProcess) param(args ...string) *initProcess { + p.params = append(p.params, args...) + return p +} + +func (p *initProcess) compile(expr ...string) []string { + for i, e := range expr { + res, err := expressionstcl.Compile(e) + if err == nil { + expr[i] = res.String() + } else { + p.errors = append(p.errors, fmt.Errorf("resolving expression: %s: %s", expr[i], err.Error())) + } + } + return expr +} + +func (p *initProcess) SetCommand(command ...string) *initProcess { + p.command = command + return p +} + +func (p *initProcess) SetArgs(args ...string) *initProcess { + p.args = args + return p +} + +func (p *initProcess) AddTimeout(duration string, refs ...string) *initProcess { + return p.param(constants.ArgTimeout, fmt.Sprintf("%s=%s", strings.Join(refs, ","), duration)) +} + +func (p *initProcess) SetInitialStatus(expr ...string) *initProcess { + p.init = nil + for _, v := range p.compile(expr...) { + p.init = append(p.init, v) + } + return p +} + +func (p *initProcess) PrependInitialStatus(expr ...string) *initProcess { + init := []string(nil) + for _, v := range p.compile(expr...) { + init = append(init, v) + } + p.init = append(init, p.init...) + return p +} + +func (p *initProcess) AddComputedEnvs(names ...string) *initProcess { + p.envs = append(p.envs, names...) + return p +} + +func (p *initProcess) SetNegative(negative bool) *initProcess { + p.negative = negative + return p +} + +func (p *initProcess) AddResult(condition string, refs ...string) *initProcess { + if len(refs) == 0 || condition == "" { + return p + } + p.results = append(p.results, fmt.Sprintf("%s=%s", strings.Join(refs, ","), p.compile(condition)[0])) + return p +} + +func (p *initProcess) ResetResults() *initProcess { + p.results = nil + return p +} + +func (p *initProcess) AddCondition(condition string, refs ...string) *initProcess { + if len(refs) == 0 || condition == "" { + return p + } + expr := p.compile(condition)[0] + p.conditions[expr] = append(p.conditions[expr], refs...) + return p +} + +func (p *initProcess) ResetCondition() *initProcess { + p.conditions = make(map[string][]string) + return p +} + +func (p *initProcess) AddRetryPolicy(policy testworkflowsv1.RetryPolicy, ref string) *initProcess { + if policy.Count <= 0 { + delete(p.retry, ref) + return p + } + until := policy.Until + if until == "" { + until = "passed" + } + p.retry[ref] = testworkflowsv1.RetryPolicy{Count: policy.Count, Until: until} + return p +} + +func (p *initProcess) Children(ref string) *initProcess { + return &initProcess{ + ref: ref, + params: p.params, + retry: maps.Clone(p.retry), + command: p.command, + args: p.args, + init: p.init, + envs: p.envs, + results: p.results, + conditions: maps.Clone(p.conditions), + negative: p.negative, + errors: p.errors, + } +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go new file mode 100644 index 00000000000..d8223d01961 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go @@ -0,0 +1,190 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "errors" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowresolver" +) + +const maxConfigMapFileSize = 950 * 1024 + +//go:generate mockgen -destination=./mock_intermediate.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Intermediate +type Intermediate interface { + RefCounter + + ContainerDefaults() Container + PodConfig() testworkflowsv1.PodConfig + JobConfig() testworkflowsv1.JobConfig + + ConfigMaps() []corev1.ConfigMap + Secrets() []corev1.Secret + Volumes() []corev1.Volume + + AppendJobConfig(cfg *testworkflowsv1.JobConfig) Intermediate + AppendPodConfig(cfg *testworkflowsv1.PodConfig) Intermediate + + AddConfigMap(configMap corev1.ConfigMap) Intermediate + AddSecret(secret corev1.Secret) Intermediate + AddVolume(volume corev1.Volume) Intermediate + + AddEmptyDirVolume(source *corev1.EmptyDirVolumeSource, mountPath string) corev1.VolumeMount + + AddTextFile(file string) (corev1.VolumeMount, error) + AddBinaryFile(file []byte) (corev1.VolumeMount, error) +} + +type intermediate struct { + refCounter + + // Routine + Root GroupStage `expr:"include"` + Container Container `expr:"include"` + + // Job & Pod resources & data + Pod testworkflowsv1.PodConfig `expr:"include"` + Job testworkflowsv1.JobConfig `expr:"include"` + + // Actual Kubernetes resources to use + Vols []corev1.Volume `expr:"force"` + Secs []corev1.Secret `expr:"force"` + Cfgs []corev1.ConfigMap `expr:"force"` + + // Storing files + currentConfigMapStorage *corev1.ConfigMap + estimatedConfigMapStorage int +} + +func NewIntermediate() Intermediate { + return &intermediate{ + Root: NewGroupStage("", true), + Container: NewContainer(), + } +} + +func (s *intermediate) ContainerDefaults() Container { + return s.Container +} + +func (s *intermediate) JobConfig() testworkflowsv1.JobConfig { + return s.Job +} + +func (s *intermediate) PodConfig() testworkflowsv1.PodConfig { + return s.Pod +} + +func (s *intermediate) ConfigMaps() []corev1.ConfigMap { + return s.Cfgs +} + +func (s *intermediate) Secrets() []corev1.Secret { + return s.Secs +} + +func (s *intermediate) Volumes() []corev1.Volume { + return s.Vols +} + +func (s *intermediate) AppendJobConfig(cfg *testworkflowsv1.JobConfig) Intermediate { + s.Job = *testworkflowresolver.MergeJobConfig(&s.Job, cfg) + return s +} + +func (s *intermediate) AppendPodConfig(cfg *testworkflowsv1.PodConfig) Intermediate { + s.Pod = *testworkflowresolver.MergePodConfig(&s.Pod, cfg) + return s +} + +func (s *intermediate) AddVolume(volume corev1.Volume) Intermediate { + s.Vols = append(s.Vols, volume) + return s +} + +func (s *intermediate) AddConfigMap(configMap corev1.ConfigMap) Intermediate { + s.Cfgs = append(s.Cfgs, configMap) + return s +} + +func (s *intermediate) AddSecret(secret corev1.Secret) Intermediate { + s.Secs = append(s.Secs, secret) + return s +} + +func (s *intermediate) AddEmptyDirVolume(source *corev1.EmptyDirVolumeSource, mountPath string) corev1.VolumeMount { + if source == nil { + source = &corev1.EmptyDirVolumeSource{} + } + ref := s.NextRef() + s.AddVolume(corev1.Volume{Name: ref, VolumeSource: corev1.VolumeSource{EmptyDir: source}}) + return corev1.VolumeMount{Name: ref, MountPath: mountPath} +} + +// Handling files + +func (s *intermediate) getInternalConfigMapStorage(size int) *corev1.ConfigMap { + if size > maxConfigMapFileSize { + return nil + } + if size+s.estimatedConfigMapStorage > maxConfigMapFileSize || s.currentConfigMapStorage == nil { + ref := s.NextRef() + s.Cfgs = append(s.Cfgs, corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "{{execution.id}}-" + ref}, + Immutable: common.Ptr(true), + Data: map[string]string{}, + BinaryData: map[string][]byte{}, + }) + s.currentConfigMapStorage = &s.Cfgs[len(s.Cfgs)-1] + s.Vols = append(s.Vols, corev1.Volume{ + Name: s.currentConfigMapStorage.Name + "-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: s.currentConfigMapStorage.Name}, + }, + }, + }) + } + return s.currentConfigMapStorage +} + +func (s *intermediate) AddTextFile(file string) (corev1.VolumeMount, error) { + cfg := s.getInternalConfigMapStorage(len(file)) + if cfg == nil { + return corev1.VolumeMount{}, errors.New("the maximum file size is 950KiB") + } + s.estimatedConfigMapStorage += len(file) + ref := s.NextRef() + cfg.Data[ref] = file + return corev1.VolumeMount{ + Name: cfg.Name + "-vol", + ReadOnly: true, + SubPath: ref, + }, nil +} + +func (s *intermediate) AddBinaryFile(file []byte) (corev1.VolumeMount, error) { + cfg := s.getInternalConfigMapStorage(len(file)) + if cfg == nil { + return corev1.VolumeMount{}, errors.New("the maximum file size is 950KiB") + } + s.estimatedConfigMapStorage += len(file) + ref := s.NextRef() + cfg.BinaryData[ref] = file + return corev1.VolumeMount{ + Name: cfg.Name + "-vol", + ReadOnly: true, + SubPath: ref, + }, nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go new file mode 100644 index 00000000000..13c637ffaef --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go @@ -0,0 +1,468 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor (interfaces: Container) + +// Package testworkflowprocessor is a generated GoMock package. +package testworkflowprocessor + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" + expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" + v10 "k8s.io/api/core/v1" +) + +// MockContainer is a mock of Container interface. +type MockContainer struct { + ctrl *gomock.Controller + recorder *MockContainerMockRecorder +} + +// MockContainerMockRecorder is the mock recorder for MockContainer. +type MockContainerMockRecorder struct { + mock *MockContainer +} + +// NewMockContainer creates a new mock instance. +func NewMockContainer(ctrl *gomock.Controller) *MockContainer { + mock := &MockContainer{ctrl: ctrl} + mock.recorder = &MockContainerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContainer) EXPECT() *MockContainerMockRecorder { + return m.recorder +} + +// AppendEnv mocks base method. +func (m *MockContainer) AppendEnv(arg0 ...v10.EnvVar) Container { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AppendEnv", varargs...) + ret0, _ := ret[0].(Container) + return ret0 +} + +// AppendEnv indicates an expected call of AppendEnv. +func (mr *MockContainerMockRecorder) AppendEnv(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendEnv", reflect.TypeOf((*MockContainer)(nil).AppendEnv), arg0...) +} + +// AppendEnvFrom mocks base method. +func (m *MockContainer) AppendEnvFrom(arg0 ...v10.EnvFromSource) Container { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AppendEnvFrom", varargs...) + ret0, _ := ret[0].(Container) + return ret0 +} + +// AppendEnvFrom indicates an expected call of AppendEnvFrom. +func (mr *MockContainerMockRecorder) AppendEnvFrom(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendEnvFrom", reflect.TypeOf((*MockContainer)(nil).AppendEnvFrom), arg0...) +} + +// AppendEnvMap mocks base method. +func (m *MockContainer) AppendEnvMap(arg0 map[string]string) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AppendEnvMap", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// AppendEnvMap indicates an expected call of AppendEnvMap. +func (mr *MockContainerMockRecorder) AppendEnvMap(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendEnvMap", reflect.TypeOf((*MockContainer)(nil).AppendEnvMap), arg0) +} + +// AppendVolumeMounts mocks base method. +func (m *MockContainer) AppendVolumeMounts(arg0 ...v10.VolumeMount) Container { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AppendVolumeMounts", varargs...) + ret0, _ := ret[0].(Container) + return ret0 +} + +// AppendVolumeMounts indicates an expected call of AppendVolumeMounts. +func (mr *MockContainerMockRecorder) AppendVolumeMounts(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendVolumeMounts", reflect.TypeOf((*MockContainer)(nil).AppendVolumeMounts), arg0...) +} + +// ApplyCR mocks base method. +func (m *MockContainer) ApplyCR(arg0 *v1.ContainerConfig) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyCR", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// ApplyCR indicates an expected call of ApplyCR. +func (mr *MockContainerMockRecorder) ApplyCR(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyCR", reflect.TypeOf((*MockContainer)(nil).ApplyCR), arg0) +} + +// ApplyImageData mocks base method. +func (m *MockContainer) ApplyImageData(arg0 *imageinspector.Info) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyImageData", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// ApplyImageData indicates an expected call of ApplyImageData. +func (mr *MockContainerMockRecorder) ApplyImageData(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyImageData", reflect.TypeOf((*MockContainer)(nil).ApplyImageData), arg0) +} + +// Args mocks base method. +func (m *MockContainer) Args() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Args") + ret0, _ := ret[0].([]string) + return ret0 +} + +// Args indicates an expected call of Args. +func (mr *MockContainerMockRecorder) Args() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Args", reflect.TypeOf((*MockContainer)(nil).Args)) +} + +// Command mocks base method. +func (m *MockContainer) Command() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Command") + ret0, _ := ret[0].([]string) + return ret0 +} + +// Command indicates an expected call of Command. +func (mr *MockContainerMockRecorder) Command() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Command", reflect.TypeOf((*MockContainer)(nil).Command)) +} + +// CreateChild mocks base method. +func (m *MockContainer) CreateChild() Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateChild") + ret0, _ := ret[0].(Container) + return ret0 +} + +// CreateChild indicates an expected call of CreateChild. +func (mr *MockContainerMockRecorder) CreateChild() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateChild", reflect.TypeOf((*MockContainer)(nil).CreateChild)) +} + +// Detach mocks base method. +func (m *MockContainer) Detach() Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Detach") + ret0, _ := ret[0].(Container) + return ret0 +} + +// Detach indicates an expected call of Detach. +func (mr *MockContainerMockRecorder) Detach() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Detach", reflect.TypeOf((*MockContainer)(nil).Detach)) +} + +// Env mocks base method. +func (m *MockContainer) Env() []v10.EnvVar { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Env") + ret0, _ := ret[0].([]v10.EnvVar) + return ret0 +} + +// Env indicates an expected call of Env. +func (mr *MockContainerMockRecorder) Env() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Env", reflect.TypeOf((*MockContainer)(nil).Env)) +} + +// EnvFrom mocks base method. +func (m *MockContainer) EnvFrom() []v10.EnvFromSource { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnvFrom") + ret0, _ := ret[0].([]v10.EnvFromSource) + return ret0 +} + +// EnvFrom indicates an expected call of EnvFrom. +func (mr *MockContainerMockRecorder) EnvFrom() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnvFrom", reflect.TypeOf((*MockContainer)(nil).EnvFrom)) +} + +// Image mocks base method. +func (m *MockContainer) Image() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Image") + ret0, _ := ret[0].(string) + return ret0 +} + +// Image indicates an expected call of Image. +func (mr *MockContainerMockRecorder) Image() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Image", reflect.TypeOf((*MockContainer)(nil).Image)) +} + +// ImagePullPolicy mocks base method. +func (m *MockContainer) ImagePullPolicy() v10.PullPolicy { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ImagePullPolicy") + ret0, _ := ret[0].(v10.PullPolicy) + return ret0 +} + +// ImagePullPolicy indicates an expected call of ImagePullPolicy. +func (mr *MockContainerMockRecorder) ImagePullPolicy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImagePullPolicy", reflect.TypeOf((*MockContainer)(nil).ImagePullPolicy)) +} + +// Parent mocks base method. +func (m *MockContainer) Parent() Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Parent") + ret0, _ := ret[0].(Container) + return ret0 +} + +// Parent indicates an expected call of Parent. +func (mr *MockContainerMockRecorder) Parent() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Parent", reflect.TypeOf((*MockContainer)(nil).Parent)) +} + +// Resolve mocks base method. +func (m *MockContainer) Resolve(arg0 ...expressionstcl.Machine) error { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Resolve", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockContainerMockRecorder) Resolve(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockContainer)(nil).Resolve), arg0...) +} + +// Resources mocks base method. +func (m *MockContainer) Resources() v1.Resources { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resources") + ret0, _ := ret[0].(v1.Resources) + return ret0 +} + +// Resources indicates an expected call of Resources. +func (mr *MockContainerMockRecorder) Resources() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resources", reflect.TypeOf((*MockContainer)(nil).Resources)) +} + +// Root mocks base method. +func (m *MockContainer) Root() Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Root") + ret0, _ := ret[0].(Container) + return ret0 +} + +// Root indicates an expected call of Root. +func (mr *MockContainerMockRecorder) Root() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Root", reflect.TypeOf((*MockContainer)(nil).Root)) +} + +// SecurityContext mocks base method. +func (m *MockContainer) SecurityContext() *v10.SecurityContext { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SecurityContext") + ret0, _ := ret[0].(*v10.SecurityContext) + return ret0 +} + +// SecurityContext indicates an expected call of SecurityContext. +func (mr *MockContainerMockRecorder) SecurityContext() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SecurityContext", reflect.TypeOf((*MockContainer)(nil).SecurityContext)) +} + +// SetArgs mocks base method. +func (m *MockContainer) SetArgs(arg0 ...string) Container { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SetArgs", varargs...) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetArgs indicates an expected call of SetArgs. +func (mr *MockContainerMockRecorder) SetArgs(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetArgs", reflect.TypeOf((*MockContainer)(nil).SetArgs), arg0...) +} + +// SetCommand mocks base method. +func (m *MockContainer) SetCommand(arg0 ...string) Container { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SetCommand", varargs...) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetCommand indicates an expected call of SetCommand. +func (mr *MockContainerMockRecorder) SetCommand(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCommand", reflect.TypeOf((*MockContainer)(nil).SetCommand), arg0...) +} + +// SetImage mocks base method. +func (m *MockContainer) SetImage(arg0 string) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetImage", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetImage indicates an expected call of SetImage. +func (mr *MockContainerMockRecorder) SetImage(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetImage", reflect.TypeOf((*MockContainer)(nil).SetImage), arg0) +} + +// SetImagePullPolicy mocks base method. +func (m *MockContainer) SetImagePullPolicy(arg0 v10.PullPolicy) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetImagePullPolicy", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetImagePullPolicy indicates an expected call of SetImagePullPolicy. +func (mr *MockContainerMockRecorder) SetImagePullPolicy(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetImagePullPolicy", reflect.TypeOf((*MockContainer)(nil).SetImagePullPolicy), arg0) +} + +// SetResources mocks base method. +func (m *MockContainer) SetResources(arg0 v1.Resources) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetResources", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetResources indicates an expected call of SetResources. +func (mr *MockContainerMockRecorder) SetResources(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetResources", reflect.TypeOf((*MockContainer)(nil).SetResources), arg0) +} + +// SetSecurityContext mocks base method. +func (m *MockContainer) SetSecurityContext(arg0 *v10.SecurityContext) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetSecurityContext", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetSecurityContext indicates an expected call of SetSecurityContext. +func (mr *MockContainerMockRecorder) SetSecurityContext(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSecurityContext", reflect.TypeOf((*MockContainer)(nil).SetSecurityContext), arg0) +} + +// SetWorkingDir mocks base method. +func (m *MockContainer) SetWorkingDir(arg0 string) Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetWorkingDir", arg0) + ret0, _ := ret[0].(Container) + return ret0 +} + +// SetWorkingDir indicates an expected call of SetWorkingDir. +func (mr *MockContainerMockRecorder) SetWorkingDir(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWorkingDir", reflect.TypeOf((*MockContainer)(nil).SetWorkingDir), arg0) +} + +// ToKubernetesTemplate mocks base method. +func (m *MockContainer) ToKubernetesTemplate() v10.Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ToKubernetesTemplate") + ret0, _ := ret[0].(v10.Container) + return ret0 +} + +// ToKubernetesTemplate indicates an expected call of ToKubernetesTemplate. +func (mr *MockContainerMockRecorder) ToKubernetesTemplate() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ToKubernetesTemplate", reflect.TypeOf((*MockContainer)(nil).ToKubernetesTemplate)) +} + +// VolumeMounts mocks base method. +func (m *MockContainer) VolumeMounts() []v10.VolumeMount { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VolumeMounts") + ret0, _ := ret[0].([]v10.VolumeMount) + return ret0 +} + +// VolumeMounts indicates an expected call of VolumeMounts. +func (mr *MockContainerMockRecorder) VolumeMounts() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VolumeMounts", reflect.TypeOf((*MockContainer)(nil).VolumeMounts)) +} + +// WorkingDir mocks base method. +func (m *MockContainer) WorkingDir() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WorkingDir") + ret0, _ := ret[0].(string) + return ret0 +} + +// WorkingDir indicates an expected call of WorkingDir. +func (mr *MockContainerMockRecorder) WorkingDir() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WorkingDir", reflect.TypeOf((*MockContainer)(nil).WorkingDir)) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_intermediate.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_intermediate.go new file mode 100644 index 00000000000..fef24c00cd0 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_intermediate.go @@ -0,0 +1,248 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor (interfaces: Intermediate) + +// Package testworkflowprocessor is a generated GoMock package. +package testworkflowprocessor + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + v10 "k8s.io/api/core/v1" +) + +// MockIntermediate is a mock of Intermediate interface. +type MockIntermediate struct { + ctrl *gomock.Controller + recorder *MockIntermediateMockRecorder +} + +// MockIntermediateMockRecorder is the mock recorder for MockIntermediate. +type MockIntermediateMockRecorder struct { + mock *MockIntermediate +} + +// NewMockIntermediate creates a new mock instance. +func NewMockIntermediate(ctrl *gomock.Controller) *MockIntermediate { + mock := &MockIntermediate{ctrl: ctrl} + mock.recorder = &MockIntermediateMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIntermediate) EXPECT() *MockIntermediateMockRecorder { + return m.recorder +} + +// AddBinaryFile mocks base method. +func (m *MockIntermediate) AddBinaryFile(arg0 []byte) (v10.VolumeMount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddBinaryFile", arg0) + ret0, _ := ret[0].(v10.VolumeMount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddBinaryFile indicates an expected call of AddBinaryFile. +func (mr *MockIntermediateMockRecorder) AddBinaryFile(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddBinaryFile", reflect.TypeOf((*MockIntermediate)(nil).AddBinaryFile), arg0) +} + +// AddConfigMap mocks base method. +func (m *MockIntermediate) AddConfigMap(arg0 v10.ConfigMap) Intermediate { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddConfigMap", arg0) + ret0, _ := ret[0].(Intermediate) + return ret0 +} + +// AddConfigMap indicates an expected call of AddConfigMap. +func (mr *MockIntermediateMockRecorder) AddConfigMap(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddConfigMap", reflect.TypeOf((*MockIntermediate)(nil).AddConfigMap), arg0) +} + +// AddEmptyDirVolume mocks base method. +func (m *MockIntermediate) AddEmptyDirVolume(arg0 *v10.EmptyDirVolumeSource, arg1 string) v10.VolumeMount { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddEmptyDirVolume", arg0, arg1) + ret0, _ := ret[0].(v10.VolumeMount) + return ret0 +} + +// AddEmptyDirVolume indicates an expected call of AddEmptyDirVolume. +func (mr *MockIntermediateMockRecorder) AddEmptyDirVolume(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddEmptyDirVolume", reflect.TypeOf((*MockIntermediate)(nil).AddEmptyDirVolume), arg0, arg1) +} + +// AddSecret mocks base method. +func (m *MockIntermediate) AddSecret(arg0 v10.Secret) Intermediate { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddSecret", arg0) + ret0, _ := ret[0].(Intermediate) + return ret0 +} + +// AddSecret indicates an expected call of AddSecret. +func (mr *MockIntermediateMockRecorder) AddSecret(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSecret", reflect.TypeOf((*MockIntermediate)(nil).AddSecret), arg0) +} + +// AddTextFile mocks base method. +func (m *MockIntermediate) AddTextFile(arg0 string) (v10.VolumeMount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddTextFile", arg0) + ret0, _ := ret[0].(v10.VolumeMount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddTextFile indicates an expected call of AddTextFile. +func (mr *MockIntermediateMockRecorder) AddTextFile(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTextFile", reflect.TypeOf((*MockIntermediate)(nil).AddTextFile), arg0) +} + +// AddVolume mocks base method. +func (m *MockIntermediate) AddVolume(arg0 v10.Volume) Intermediate { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddVolume", arg0) + ret0, _ := ret[0].(Intermediate) + return ret0 +} + +// AddVolume indicates an expected call of AddVolume. +func (mr *MockIntermediateMockRecorder) AddVolume(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddVolume", reflect.TypeOf((*MockIntermediate)(nil).AddVolume), arg0) +} + +// AppendJobConfig mocks base method. +func (m *MockIntermediate) AppendJobConfig(arg0 *v1.JobConfig) Intermediate { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AppendJobConfig", arg0) + ret0, _ := ret[0].(Intermediate) + return ret0 +} + +// AppendJobConfig indicates an expected call of AppendJobConfig. +func (mr *MockIntermediateMockRecorder) AppendJobConfig(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendJobConfig", reflect.TypeOf((*MockIntermediate)(nil).AppendJobConfig), arg0) +} + +// AppendPodConfig mocks base method. +func (m *MockIntermediate) AppendPodConfig(arg0 *v1.PodConfig) Intermediate { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AppendPodConfig", arg0) + ret0, _ := ret[0].(Intermediate) + return ret0 +} + +// AppendPodConfig indicates an expected call of AppendPodConfig. +func (mr *MockIntermediateMockRecorder) AppendPodConfig(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendPodConfig", reflect.TypeOf((*MockIntermediate)(nil).AppendPodConfig), arg0) +} + +// ConfigMaps mocks base method. +func (m *MockIntermediate) ConfigMaps() []v10.ConfigMap { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConfigMaps") + ret0, _ := ret[0].([]v10.ConfigMap) + return ret0 +} + +// ConfigMaps indicates an expected call of ConfigMaps. +func (mr *MockIntermediateMockRecorder) ConfigMaps() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigMaps", reflect.TypeOf((*MockIntermediate)(nil).ConfigMaps)) +} + +// ContainerDefaults mocks base method. +func (m *MockIntermediate) ContainerDefaults() Container { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContainerDefaults") + ret0, _ := ret[0].(Container) + return ret0 +} + +// ContainerDefaults indicates an expected call of ContainerDefaults. +func (mr *MockIntermediateMockRecorder) ContainerDefaults() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerDefaults", reflect.TypeOf((*MockIntermediate)(nil).ContainerDefaults)) +} + +// JobConfig mocks base method. +func (m *MockIntermediate) JobConfig() v1.JobConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "JobConfig") + ret0, _ := ret[0].(v1.JobConfig) + return ret0 +} + +// JobConfig indicates an expected call of JobConfig. +func (mr *MockIntermediateMockRecorder) JobConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "JobConfig", reflect.TypeOf((*MockIntermediate)(nil).JobConfig)) +} + +// NextRef mocks base method. +func (m *MockIntermediate) NextRef() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NextRef") + ret0, _ := ret[0].(string) + return ret0 +} + +// NextRef indicates an expected call of NextRef. +func (mr *MockIntermediateMockRecorder) NextRef() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NextRef", reflect.TypeOf((*MockIntermediate)(nil).NextRef)) +} + +// PodConfig mocks base method. +func (m *MockIntermediate) PodConfig() v1.PodConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PodConfig") + ret0, _ := ret[0].(v1.PodConfig) + return ret0 +} + +// PodConfig indicates an expected call of PodConfig. +func (mr *MockIntermediateMockRecorder) PodConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PodConfig", reflect.TypeOf((*MockIntermediate)(nil).PodConfig)) +} + +// Secrets mocks base method. +func (m *MockIntermediate) Secrets() []v10.Secret { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Secrets") + ret0, _ := ret[0].([]v10.Secret) + return ret0 +} + +// Secrets indicates an expected call of Secrets. +func (mr *MockIntermediateMockRecorder) Secrets() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Secrets", reflect.TypeOf((*MockIntermediate)(nil).Secrets)) +} + +// Volumes mocks base method. +func (m *MockIntermediate) Volumes() []v10.Volume { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Volumes") + ret0, _ := ret[0].([]v10.Volume) + return ret0 +} + +// Volumes indicates an expected call of Volumes. +func (mr *MockIntermediateMockRecorder) Volumes() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Volumes", reflect.TypeOf((*MockIntermediate)(nil).Volumes)) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_internalprocessor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_internalprocessor.go new file mode 100644 index 00000000000..4cbc3ae7ab7 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_internalprocessor.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor (interfaces: InternalProcessor) + +// Package testworkflowprocessor is a generated GoMock package. +package testworkflowprocessor + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" +) + +// MockInternalProcessor is a mock of InternalProcessor interface. +type MockInternalProcessor struct { + ctrl *gomock.Controller + recorder *MockInternalProcessorMockRecorder +} + +// MockInternalProcessorMockRecorder is the mock recorder for MockInternalProcessor. +type MockInternalProcessorMockRecorder struct { + mock *MockInternalProcessor +} + +// NewMockInternalProcessor creates a new mock instance. +func NewMockInternalProcessor(ctrl *gomock.Controller) *MockInternalProcessor { + mock := &MockInternalProcessor{ctrl: ctrl} + mock.recorder = &MockInternalProcessorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInternalProcessor) EXPECT() *MockInternalProcessorMockRecorder { + return m.recorder +} + +// Process mocks base method. +func (m *MockInternalProcessor) Process(arg0 Intermediate, arg1 Container, arg2 v1.Step) (Stage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Process", arg0, arg1, arg2) + ret0, _ := ret[0].(Stage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Process indicates an expected call of Process. +func (mr *MockInternalProcessorMockRecorder) Process(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Process", reflect.TypeOf((*MockInternalProcessor)(nil).Process), arg0, arg1, arg2) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_processor.go new file mode 100644 index 00000000000..50759cc8c93 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_processor.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor (interfaces: Processor) + +// Package testworkflowprocessor is a generated GoMock package. +package testworkflowprocessor + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +// MockProcessor is a mock of Processor interface. +type MockProcessor struct { + ctrl *gomock.Controller + recorder *MockProcessorMockRecorder +} + +// MockProcessorMockRecorder is the mock recorder for MockProcessor. +type MockProcessorMockRecorder struct { + mock *MockProcessor +} + +// NewMockProcessor creates a new mock instance. +func NewMockProcessor(ctrl *gomock.Controller) *MockProcessor { + mock := &MockProcessor{ctrl: ctrl} + mock.recorder = &MockProcessorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProcessor) EXPECT() *MockProcessorMockRecorder { + return m.recorder +} + +// Bundle mocks base method. +func (m *MockProcessor) Bundle(arg0 context.Context, arg1 *v1.TestWorkflow, arg2 ...expressionstcl.Machine) (*Bundle, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Bundle", varargs...) + ret0, _ := ret[0].(*Bundle) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Bundle indicates an expected call of Bundle. +func (mr *MockProcessorMockRecorder) Bundle(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Bundle", reflect.TypeOf((*MockProcessor)(nil).Bundle), varargs...) +} + +// Register mocks base method. +func (m *MockProcessor) Register(arg0 func(InternalProcessor, Intermediate, Container, v1.Step) (Stage, error)) Processor { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Register", arg0) + ret0, _ := ret[0].(Processor) + return ret0 +} + +// Register indicates an expected call of Register. +func (mr *MockProcessorMockRecorder) Register(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Register", reflect.TypeOf((*MockProcessor)(nil).Register), arg0) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go new file mode 100644 index 00000000000..8ca35f9022d --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go @@ -0,0 +1,344 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor (interfaces: Stage) + +// Package testworkflowprocessor is a generated GoMock package. +package testworkflowprocessor + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" + expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +// MockStage is a mock of Stage interface. +type MockStage struct { + ctrl *gomock.Controller + recorder *MockStageMockRecorder +} + +// MockStageMockRecorder is the mock recorder for MockStage. +type MockStageMockRecorder struct { + mock *MockStage +} + +// NewMockStage creates a new mock instance. +func NewMockStage(ctrl *gomock.Controller) *MockStage { + mock := &MockStage{ctrl: ctrl} + mock.recorder = &MockStageMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStage) EXPECT() *MockStageMockRecorder { + return m.recorder +} + +// AppendConditions mocks base method. +func (m *MockStage) AppendConditions(arg0 ...string) StageLifecycle { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AppendConditions", varargs...) + ret0, _ := ret[0].(StageLifecycle) + return ret0 +} + +// AppendConditions indicates an expected call of AppendConditions. +func (mr *MockStageMockRecorder) AppendConditions(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendConditions", reflect.TypeOf((*MockStage)(nil).AppendConditions), arg0...) +} + +// ApplyImages mocks base method. +func (m *MockStage) ApplyImages(arg0 map[string]*imageinspector.Info) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyImages", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// ApplyImages indicates an expected call of ApplyImages. +func (mr *MockStageMockRecorder) ApplyImages(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyImages", reflect.TypeOf((*MockStage)(nil).ApplyImages), arg0) +} + +// Condition mocks base method. +func (m *MockStage) Condition() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Condition") + ret0, _ := ret[0].(string) + return ret0 +} + +// Condition indicates an expected call of Condition. +func (mr *MockStageMockRecorder) Condition() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Condition", reflect.TypeOf((*MockStage)(nil).Condition)) +} + +// ContainerStages mocks base method. +func (m *MockStage) ContainerStages() []ContainerStage { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContainerStages") + ret0, _ := ret[0].([]ContainerStage) + return ret0 +} + +// ContainerStages indicates an expected call of ContainerStages. +func (mr *MockStageMockRecorder) ContainerStages() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerStages", reflect.TypeOf((*MockStage)(nil).ContainerStages)) +} + +// Flatten mocks base method. +func (m *MockStage) Flatten() []Stage { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Flatten") + ret0, _ := ret[0].([]Stage) + return ret0 +} + +// Flatten indicates an expected call of Flatten. +func (mr *MockStageMockRecorder) Flatten() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Flatten", reflect.TypeOf((*MockStage)(nil).Flatten)) +} + +// GetImages mocks base method. +func (m *MockStage) GetImages() map[string]struct{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetImages") + ret0, _ := ret[0].(map[string]struct{}) + return ret0 +} + +// GetImages indicates an expected call of GetImages. +func (mr *MockStageMockRecorder) GetImages() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetImages", reflect.TypeOf((*MockStage)(nil).GetImages)) +} + +// Len mocks base method. +func (m *MockStage) Len() int { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Len") + ret0, _ := ret[0].(int) + return ret0 +} + +// Len indicates an expected call of Len. +func (mr *MockStageMockRecorder) Len() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Len", reflect.TypeOf((*MockStage)(nil).Len)) +} + +// Name mocks base method. +func (m *MockStage) Name() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Name") + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockStageMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockStage)(nil).Name)) +} + +// Negative mocks base method. +func (m *MockStage) Negative() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Negative") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Negative indicates an expected call of Negative. +func (mr *MockStageMockRecorder) Negative() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Negative", reflect.TypeOf((*MockStage)(nil).Negative)) +} + +// Optional mocks base method. +func (m *MockStage) Optional() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Optional") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Optional indicates an expected call of Optional. +func (mr *MockStageMockRecorder) Optional() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Optional", reflect.TypeOf((*MockStage)(nil).Optional)) +} + +// Ref mocks base method. +func (m *MockStage) Ref() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Ref") + ret0, _ := ret[0].(string) + return ret0 +} + +// Ref indicates an expected call of Ref. +func (mr *MockStageMockRecorder) Ref() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ref", reflect.TypeOf((*MockStage)(nil).Ref)) +} + +// Resolve mocks base method. +func (m *MockStage) Resolve(arg0 ...expressionstcl.Machine) error { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Resolve", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockStageMockRecorder) Resolve(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockStage)(nil).Resolve), arg0...) +} + +// RetryPolicy mocks base method. +func (m *MockStage) RetryPolicy() v1.RetryPolicy { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RetryPolicy") + ret0, _ := ret[0].(v1.RetryPolicy) + return ret0 +} + +// RetryPolicy indicates an expected call of RetryPolicy. +func (mr *MockStageMockRecorder) RetryPolicy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryPolicy", reflect.TypeOf((*MockStage)(nil).RetryPolicy)) +} + +// SetCondition mocks base method. +func (m *MockStage) SetCondition(arg0 string) StageLifecycle { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetCondition", arg0) + ret0, _ := ret[0].(StageLifecycle) + return ret0 +} + +// SetCondition indicates an expected call of SetCondition. +func (mr *MockStageMockRecorder) SetCondition(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCondition", reflect.TypeOf((*MockStage)(nil).SetCondition), arg0) +} + +// SetName mocks base method. +func (m *MockStage) SetName(arg0 string, arg1 ...string) StageMetadata { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SetName", varargs...) + ret0, _ := ret[0].(StageMetadata) + return ret0 +} + +// SetName indicates an expected call of SetName. +func (mr *MockStageMockRecorder) SetName(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetName", reflect.TypeOf((*MockStage)(nil).SetName), varargs...) +} + +// SetNegative mocks base method. +func (m *MockStage) SetNegative(arg0 bool) StageLifecycle { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetNegative", arg0) + ret0, _ := ret[0].(StageLifecycle) + return ret0 +} + +// SetNegative indicates an expected call of SetNegative. +func (mr *MockStageMockRecorder) SetNegative(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNegative", reflect.TypeOf((*MockStage)(nil).SetNegative), arg0) +} + +// SetOptional mocks base method. +func (m *MockStage) SetOptional(arg0 bool) StageLifecycle { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetOptional", arg0) + ret0, _ := ret[0].(StageLifecycle) + return ret0 +} + +// SetOptional indicates an expected call of SetOptional. +func (mr *MockStageMockRecorder) SetOptional(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetOptional", reflect.TypeOf((*MockStage)(nil).SetOptional), arg0) +} + +// SetRetryPolicy mocks base method. +func (m *MockStage) SetRetryPolicy(arg0 v1.RetryPolicy) StageLifecycle { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetRetryPolicy", arg0) + ret0, _ := ret[0].(StageLifecycle) + return ret0 +} + +// SetRetryPolicy indicates an expected call of SetRetryPolicy. +func (mr *MockStageMockRecorder) SetRetryPolicy(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRetryPolicy", reflect.TypeOf((*MockStage)(nil).SetRetryPolicy), arg0) +} + +// SetTimeout mocks base method. +func (m *MockStage) SetTimeout(arg0 string) StageLifecycle { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetTimeout", arg0) + ret0, _ := ret[0].(StageLifecycle) + return ret0 +} + +// SetTimeout indicates an expected call of SetTimeout. +func (mr *MockStageMockRecorder) SetTimeout(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTimeout", reflect.TypeOf((*MockStage)(nil).SetTimeout), arg0) +} + +// Signature mocks base method. +func (m *MockStage) Signature() Signature { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Signature") + ret0, _ := ret[0].(Signature) + return ret0 +} + +// Signature indicates an expected call of Signature. +func (mr *MockStageMockRecorder) Signature() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Signature", reflect.TypeOf((*MockStage)(nil).Signature)) +} + +// Timeout mocks base method. +func (m *MockStage) Timeout() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Timeout") + ret0, _ := ret[0].(string) + return ret0 +} + +// Timeout indicates an expected call of Timeout. +func (mr *MockStageMockRecorder) Timeout() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Timeout", reflect.TypeOf((*MockStage)(nil).Timeout)) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go new file mode 100644 index 00000000000..928234dc926 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -0,0 +1,133 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "fmt" + "time" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" +) + +func ProcessDelay(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Delay == "" { + return nil, nil + } + t, err := time.ParseDuration(step.Delay) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("invalid duration: %s", step.Delay)) + } + shell := container.CreateChild(). + SetCommand("sleep"). + SetArgs(fmt.Sprintf("%g", t.Seconds())) + stage := NewContainerStage(layer.NextRef(), shell) + stage.SetName(fmt.Sprintf("Delay: %s", step.Delay)) + return stage, nil +} + +func ProcessShellCommand(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Shell == "" { + return nil, nil + } + shell := container.CreateChild().SetCommand(defaultShell).SetArgs("-c", step.Shell) + return NewContainerStage(layer.NextRef(), shell), nil +} + +func ProcessRunCommand(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Run == nil { + return nil, nil + } + container = container.CreateChild().ApplyCR(&step.Run.ContainerConfig) + return NewContainerStage(layer.NextRef(), container), nil +} + +func ProcessNestedSteps(p InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + group := NewGroupStage(layer.NextRef(), true) + for _, n := range step.Steps { + stage, err := p.Process(layer, container.CreateChild(), n) + if err != nil { + return nil, err + } + group.Add(stage) + } + return group, nil +} + +func ProcessContentFiles(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Content == nil { + return nil, nil + } + for _, f := range step.Content.Files { + if f.ContentFrom == nil { + vm, err := layer.AddTextFile(f.Content) + if err != nil { + return nil, fmt.Errorf("file %s: could not append: %s", f.Path, err.Error()) + } + vm.MountPath = f.Path + container.AppendVolumeMounts(vm) + continue + } + + volRef := "{{execution.id}}-" + layer.NextRef() + + if f.ContentFrom.ConfigMapKeyRef != nil { + layer.AddVolume(corev1.Volume{ + Name: volRef, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: f.ContentFrom.ConfigMapKeyRef.LocalObjectReference, + Items: []corev1.KeyToPath{{Key: f.ContentFrom.ConfigMapKeyRef.Key, Path: "file"}}, + DefaultMode: f.Mode, + Optional: f.ContentFrom.ConfigMapKeyRef.Optional, + }, + }, + }) + container.AppendVolumeMounts(corev1.VolumeMount{Name: volRef, MountPath: f.Path, SubPath: "file"}) + } else if f.ContentFrom.SecretKeyRef != nil { + layer.AddVolume(corev1.Volume{ + Name: volRef, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: f.ContentFrom.SecretKeyRef.Name, + Items: []corev1.KeyToPath{{Key: f.ContentFrom.SecretKeyRef.Key, Path: "file"}}, + DefaultMode: f.Mode, + Optional: f.ContentFrom.SecretKeyRef.Optional, + }, + }, + }) + container.AppendVolumeMounts(corev1.VolumeMount{Name: volRef, MountPath: f.Path, SubPath: "file"}) + } else if f.ContentFrom.FieldRef != nil || f.ContentFrom.ResourceFieldRef != nil { + layer.AddVolume(corev1.Volume{ + Name: volRef, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + DownwardAPI: &corev1.DownwardAPIProjection{ + Items: []corev1.DownwardAPIVolumeFile{{ + Path: "file", + FieldRef: f.ContentFrom.FieldRef, + ResourceFieldRef: f.ContentFrom.ResourceFieldRef, + Mode: f.Mode, + }}, + }, + }}, + DefaultMode: f.Mode, + }, + }, + }) + container.AppendVolumeMounts(corev1.VolumeMount{Name: volRef, MountPath: f.Path, SubPath: "file"}) + } else { + return nil, fmt.Errorf("file %s: unrecognized ContentFrom provided for file", f.Path) + } + } + return nil, nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go new file mode 100644 index 00000000000..256cbbf14d8 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -0,0 +1,282 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "context" + "encoding/json" + "fmt" + "maps" + + "github.com/pkg/errors" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +//go:generate mockgen -destination=./mock_processor.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Processor +type Processor interface { + Register(operation Operation) Processor + Bundle(ctx context.Context, workflow *testworkflowsv1.TestWorkflow, machines ...expressionstcl.Machine) (*Bundle, error) +} + +//go:generate mockgen -destination=./mock_internalprocessor.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" InternalProcessor +type InternalProcessor interface { + Process(layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) +} + +type Operation = func(processor InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) + +type processor struct { + inspector imageinspector.Inspector + operations []Operation +} + +func New(inspector imageinspector.Inspector) Processor { + return &processor{inspector: inspector} +} + +func NewFullFeatured(inspector imageinspector.Inspector) Processor { + return New(inspector). + Register(ProcessDelay). + Register(ProcessContentFiles). + Register(ProcessRunCommand). + Register(ProcessShellCommand). + Register(ProcessNestedSteps) +} + +func (p *processor) Register(operation Operation) Processor { + p.operations = append(p.operations, operation) + return p +} + +func (p *processor) process(layer Intermediate, container Container, step testworkflowsv1.Step, ref string) (Stage, error) { + // Configure defaults + container.ApplyCR(step.Container) + + // Build an initial group for the inner items + self := NewGroupStage(ref, false) + self.SetName(step.Name) + self.SetOptional(step.Optional).SetNegative(step.Negative) + if step.Condition != "" { + self.SetCondition(step.Condition) + } else { + self.SetCondition("passed") + } + + // Run operations + for _, op := range p.operations { + stage, err := op(p, layer, container, step) + if err != nil { + return nil, err + } + self.Add(stage) + } + + return self, nil +} + +func (p *processor) Process(layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + return p.process(layer, container, step, layer.NextRef()) +} + +func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWorkflow, machines ...expressionstcl.Machine) (bundle *Bundle, err error) { + // Initialize intermediate layer + layer := NewIntermediate(). + AppendPodConfig(workflow.Spec.Pod). + AppendJobConfig(workflow.Spec.Job) + layer.ContainerDefaults(). + ApplyCR(defaultContainerConfig.DeepCopy()). + ApplyCR(workflow.Spec.Container). + AppendVolumeMounts(layer.AddEmptyDirVolume(nil, defaultInternalPath)). + AppendVolumeMounts(layer.AddEmptyDirVolume(nil, defaultDataPath)) + + // Process steps + rootStep := testworkflowsv1.Step{ + StepBase: testworkflowsv1.StepBase{ + Content: workflow.Spec.Content, + Container: workflow.Spec.Container, + }, + Steps: append(workflow.Spec.Setup, append(workflow.Spec.Steps, workflow.Spec.After...)...), + } + root, err := p.process(layer, layer.ContainerDefaults(), rootStep, "") + if err != nil { + return nil, errors.Wrap(err, "processing error") + } + + // Validate if there is anything to run + if root.Len() == 0 { + return nil, errors.New("test workflow has nothing to run") + } + + // Finalize ConfigMaps + configMaps := layer.ConfigMaps() + for i := range configMaps { + AnnotateControlledBy(&configMaps[i], "{{execution.id}}") + err = expressionstcl.FinalizeForce(&configMaps[i], machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing ConfigMap") + } + } + + // Finalize Secrets + secrets := layer.Secrets() + for i := range secrets { + AnnotateControlledBy(&secrets[i], "{{execution.id}}") + err = expressionstcl.FinalizeForce(&secrets[i], machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing Secret") + } + } + + // Finalize Volumes + volumes := layer.Volumes() + for i := range volumes { + err = expressionstcl.FinalizeForce(&volumes[i], machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing Volume") + } + } + + // Resolve job & pod config + jobConfig, podConfig := layer.JobConfig(), layer.PodConfig() + err = expressionstcl.FinalizeForce(&jobConfig, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing job config") + } + err = expressionstcl.FinalizeForce(&podConfig, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing pod config") + } + + // Build signature + sig := root.Signature().Children() + + // Load the image pull secrets + pullSecretNames := make([]string, len(podConfig.ImagePullSecrets)) + for i, v := range podConfig.ImagePullSecrets { + pullSecretNames[i] = v.Name + } + + // Load the image details + imageNames := root.GetImages() + images := make(map[string]*imageinspector.Info) + for image := range imageNames { + info, err := p.inspector.Inspect(ctx, "", image, corev1.PullIfNotPresent, pullSecretNames) + if err != nil { + return nil, fmt.Errorf("resolving image error: %s: %s", image, err.Error()) + } + images[image] = info + } + err = root.ApplyImages(images) + if err != nil { + return nil, errors.Wrap(err, "applying image data") + } + + // Build list of the containers + containers, err := buildKubernetesContainers(root, NewInitProcess().SetRef(root.Ref()), images) + if err != nil { + return nil, errors.Wrap(err, "building Kubernetes containers") + } + for i := range containers { + err = expressionstcl.FinalizeForce(&containers[i].EnvFrom, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing container's envFrom") + } + err = expressionstcl.FinalizeForce(&containers[i].VolumeMounts, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing container's volumeMounts") + } + err = expressionstcl.FinalizeForce(&containers[i].Resources, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing container's resources") + } + } + + // Build pod template + podSpec := corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "{{execution.id}}-pod", + Annotations: podConfig.Annotations, + Labels: podConfig.Labels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + ImagePullSecrets: podConfig.ImagePullSecrets, + ServiceAccountName: podConfig.ServiceAccountName, + NodeSelector: podConfig.NodeSelector, + }, + } + AnnotateControlledBy(&podSpec, "{{execution.id}}") + err = expressionstcl.FinalizeForce(&podSpec, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing pod template spec") + } + initContainer := corev1.Container{ + // TODO: Resources, SecurityContext? + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: layer.ContainerDefaults().VolumeMounts(), + } + err = expressionstcl.FinalizeForce(&initContainer, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing container's resources") + } + podSpec.Spec.InitContainers = append([]corev1.Container{initContainer}, containers[:len(containers)-1]...) + podSpec.Spec.Containers = containers[len(containers)-1:] + + // Build job spec + jobSpec := batchv1.Job{ + TypeMeta: metav1.TypeMeta{ + Kind: "Job", + APIVersion: batchv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "{{execution.id}}", + Annotations: jobConfig.Annotations, + Labels: jobConfig.Labels, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: common.Ptr(int32(0)), + }, + } + AnnotateControlledBy(&jobSpec, "{{execution.id}}") + err = expressionstcl.FinalizeForce(&jobSpec, machines...) + if err != nil { + return nil, errors.Wrap(err, "finalizing job spec") + } + jobSpec.Spec.Template = podSpec + + // Build signature + sigSerialized, _ := json.Marshal(sig) + jobAnnotations := make(map[string]string) + maps.Copy(jobAnnotations, jobSpec.Annotations) + maps.Copy(jobAnnotations, map[string]string{ + "testworkflows.testkube.io/signature": string(sigSerialized), + }) + jobSpec.Annotations = jobAnnotations + + // Build bundle + bundle = &Bundle{ + ConfigMaps: configMaps, + Secrets: secrets, + Job: jobSpec, + Signature: sig, + } + return bundle, nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go new file mode 100644 index 00000000000..8063db11fff --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go @@ -0,0 +1,986 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +type dummyInspector struct{} + +func (*dummyInspector) Inspect(ctx context.Context, registry, image string, pullPolicy corev1.PullPolicy, pullSecretNames []string) (*imageinspector.Info, error) { + return &imageinspector.Info{}, nil +} + +var ( + ins = &dummyInspector{} + proc = NewFullFeatured(ins) + execMachine = expressionstcl.NewMachine(). + Register("execution.id", "dummy-id") +) + +func TestProcessEmpty(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{} + + _, err := proc.Bundle(context.Background(), wf, execMachine) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "has nothing to run") +} + +func TestProcessBasic(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + assert.NoError(t, err) + + sig := res.Signature + sigSerialized, _ := json.Marshal(sig) + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := batchv1.Job{ + TypeMeta: metav1.TypeMeta{Kind: "Job", APIVersion: "batch/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-id", + Labels: map[string]string{executionIdLabelName: "dummy-id"}, + Annotations: map[string]string{ + "testworkflows.testkube.io/signature": string(sigSerialized), + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: common.Ptr(int32(0)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-id-pod", + Labels: map[string]string{executionIdLabelName: "dummy-id"}, + Annotations: map[string]string(nil), + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + }, + Containers: []corev1.Container{ + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[0].Ref()), + "-r", fmt.Sprintf("=%s", sig[0].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + }, + }, + }, + } + + assert.Equal(t, want, res.Job) + + assert.Equal(t, 2, len(volumeMounts)) + assert.Equal(t, 2, len(volumes)) + assert.Equal(t, defaultInternalPath, volumeMounts[0].MountPath) + assert.Equal(t, defaultDataPath, volumeMounts[1].MountPath) + assert.True(t, volumeMounts[0].Name == volumes[0].Name) + assert.True(t, volumeMounts[1].Name == volumes[1].Name) +} + +func TestProcessBasicEnvReference(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{ + Container: &testworkflowsv1.ContainerConfig{ + Env: []corev1.EnvVar{ + {Name: "ZERO", Value: "foo"}, + {Name: "UNDETERMINED", Value: "{{call(abc)}}xxx"}, + {Name: "INPUT", Value: "{{env.ZERO}}bar"}, + {Name: "NEXT", Value: "foo{{env.UNDETERMINED}}{{env.LAST}}"}, + {Name: "LAST", Value: "foo{{env.INPUT}}bar"}, + }, + }, + Shell: "shell-test", + }}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + }, + Containers: []corev1.Container{ + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-e", "UNDETERMINED,NEXT", + "-c", fmt.Sprintf("%s=passed", sig[0].Ref()), + "-r", fmt.Sprintf("=%s", sig[0].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{ + {Name: "CI", Value: "1"}, + {Name: "ZERO", Value: "foo"}, + {Name: "UNDETERMINED", Value: "{{call(abc)}}xxx"}, + {Name: "INPUT", Value: "foobar"}, + {Name: "NEXT", Value: "foo{{env.UNDETERMINED}}foofoobarbar"}, + {Name: "LAST", Value: "foofoobarbar"}, + }, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessMultipleSteps(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test"}}, + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test-2"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s=passed", sig[0].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessNestedSteps(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "A", Shell: "shell-test"}}, + { + StepBase: testworkflowsv1.StepBase{Name: "B"}, + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "C", Shell: "shell-test-2"}}, + {StepBase: testworkflowsv1.StepBase{Name: "D", Shell: "shell-test-3"}}, + }, + }, + {StepBase: testworkflowsv1.StepBase{Name: "E", Shell: "shell-test-4"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s,%s,%s=passed", sig[0].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + { + Name: sig[1].Children()[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Children()[0].Ref(), + "-i", fmt.Sprintf("%s", sig[1].Ref()), + "-c", fmt.Sprintf("%s,%s,%s=passed", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + { + Name: sig[1].Children()[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Children()[1].Ref(), + "-i", fmt.Sprintf("%s", sig[1].Ref()), + "-c", fmt.Sprintf("%s=passed", sig[1].Children()[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-3"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[2].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[2].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[2].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-4"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessOptionalSteps(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "A", Shell: "shell-test"}}, + { + StepBase: testworkflowsv1.StepBase{Name: "B", Optional: true}, + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "C", Shell: "shell-test-2"}}, + {StepBase: testworkflowsv1.StepBase{Name: "D", Shell: "shell-test-3"}}, + }, + }, + {StepBase: testworkflowsv1.StepBase{Name: "E", Shell: "shell-test-4"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s,%s,%s=passed", sig[0].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[2].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + { + Name: sig[1].Children()[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Children()[0].Ref(), + "-i", fmt.Sprintf("%s", sig[1].Ref()), + "-c", fmt.Sprintf("%s,%s,%s=passed", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + { + Name: sig[1].Children()[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Children()[1].Ref(), + "-i", fmt.Sprintf("%s", sig[1].Ref()), + "-c", fmt.Sprintf("%s=passed", sig[1].Children()[1].Ref()), + "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-3"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[2].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[2].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[2].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[2].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-4"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessNegativeSteps(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "A", Shell: "shell-test"}}, + { + StepBase: testworkflowsv1.StepBase{Name: "B", Negative: true}, + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Name: "C", Shell: "shell-test-2"}}, + {StepBase: testworkflowsv1.StepBase{Name: "D", Shell: "shell-test-3"}}, + }, + }, + {StepBase: testworkflowsv1.StepBase{Name: "E", Shell: "shell-test-4"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s,%s,%s=passed", sig[0].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + { + Name: sig[1].Children()[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Children()[0].Ref(), + "-i", fmt.Sprintf("%s.v", sig[1].Ref()), + "-c", fmt.Sprintf("%s,%s,%s,%s.v=passed", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("%s=!%s.v", sig[1].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("%s.v=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + { + Name: sig[1].Children()[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Children()[1].Ref(), + "-i", fmt.Sprintf("%s.v", sig[1].Ref()), + "-c", fmt.Sprintf("%s=passed", sig[1].Children()[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "-r", fmt.Sprintf("%s=!%s.v", sig[1].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("%s.v=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-3"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[2].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[2].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[2].Ref()), + "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-4"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessNegativeContainerStep(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test"}}, + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test-2", Negative: true}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s=passed", sig[0].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Ref(), + "-n", "true", + "-c", fmt.Sprintf("%s=passed", sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessOptionalContainerStep(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test"}}, + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test-2", Optional: true}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s=passed", sig[0].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("=%s", sig[0].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.NoError(t, err) + assert.Equal(t, want, res.Job.Spec.Template.Spec) +} + +func TestProcessLocalContent(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{ + Shell: "shell-test", + Content: &testworkflowsv1.Content{ + Files: []testworkflowsv1.ContentFile{{ + Path: "/some/path", + Content: `some-{{"{{"}}content`, + }}, + }, + }}, + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test-2"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + assert.NoError(t, err) + + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + volumeMountsWithContent := res.Job.Spec.Template.Spec.InitContainers[1].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s=passed", sig[0].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMountsWithContent, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.Equal(t, want, res.Job.Spec.Template.Spec) + assert.Equal(t, 2, len(volumeMounts)) + assert.Equal(t, 3, len(volumeMountsWithContent)) + assert.Equal(t, volumeMounts, volumeMountsWithContent[:2]) + assert.Equal(t, "/some/path", volumeMountsWithContent[2].MountPath) + assert.Equal(t, 1, len(res.ConfigMaps)) + assert.Equal(t, volumeMountsWithContent[2].Name, volumes[2].Name) + assert.Equal(t, volumes[2].ConfigMap.Name, res.ConfigMaps[0].Name) + assert.Equal(t, "some-{{content", res.ConfigMaps[0].Data[volumeMountsWithContent[2].SubPath]) +} + +func TestProcessGlobalContent(t *testing.T) { + wf := &testworkflowsv1.TestWorkflow{ + Spec: testworkflowsv1.TestWorkflowSpec{ + TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ + Content: &testworkflowsv1.Content{ + Files: []testworkflowsv1.ContentFile{{ + Path: "/some/path", + Content: `some-{{"{{"}}content`, + }}, + }, + }, + Steps: []testworkflowsv1.Step{ + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test"}}, + {StepBase: testworkflowsv1.StepBase{Shell: "shell-test-2"}}, + }, + }, + } + + res, err := proc.Bundle(context.Background(), wf, execMachine) + assert.NoError(t, err) + + sig := res.Signature + + volumes := res.Job.Spec.Template.Spec.Volumes + volumeMounts := res.Job.Spec.Template.Spec.InitContainers[0].VolumeMounts + + want := corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + InitContainers: []corev1.Container{ + { + Name: "copy-init", + Image: defaultInitImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"/bin/sh", "-c"}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + VolumeMounts: volumeMounts, + }, + { + Name: sig[0].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[0].Ref(), + "-c", fmt.Sprintf("%s,%s=passed", sig[0].Ref(), sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + Containers: []corev1.Container{ + { + Name: sig[1].Ref(), + ImagePullPolicy: "", + Image: defaultImage, + Command: []string{ + "/.tktw/init", + sig[1].Ref(), + "-c", fmt.Sprintf("%s=passed", sig[1].Ref()), + "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), + "--", + }, + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: (*corev1.SecurityContext)(nil), + }, + }, + } + + assert.Equal(t, want, res.Job.Spec.Template.Spec) + assert.Equal(t, 3, len(volumeMounts)) + assert.Equal(t, "/some/path", volumeMounts[2].MountPath) + assert.Equal(t, 1, len(res.ConfigMaps)) + assert.Equal(t, volumeMounts[2].Name, volumes[2].Name) + assert.Equal(t, volumes[2].ConfigMap.Name, res.ConfigMaps[0].Name) + assert.Equal(t, "some-{{content", res.ConfigMaps[0].Data[volumeMounts[2].SubPath]) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/refcounter.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/refcounter.go new file mode 100644 index 00000000000..31cf6741bcc --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/refcounter.go @@ -0,0 +1,32 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "fmt" + "strconv" + + "k8s.io/apimachinery/pkg/util/rand" +) + +type RefCounter interface { + NextRef() string +} + +type refCounter struct { + refCount uint64 +} + +func NewRefCounter() RefCounter { + return &refCounter{} +} + +func (r *refCounter) NextRef() string { + return fmt.Sprintf("r%s%s", rand.String(5), strconv.FormatUint(r.refCount, 36)) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go new file mode 100644 index 00000000000..d8670d23564 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go @@ -0,0 +1,45 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +type Signature interface { + Ref() string + Name() string + Optional() bool + Negative() bool + Children() []Signature +} + +type signature struct { + RefValue string `json:"ref"` + NameValue string `json:"name,omitempty"` + OptionalValue bool `json:"optional,omitempty"` + NegativeValue bool `json:"negative,omitempty"` + ChildrenValue []Signature `json:"children,omitempty"` +} + +func (s *signature) Ref() string { + return s.RefValue +} + +func (s *signature) Name() string { + return s.NameValue +} + +func (s *signature) Optional() bool { + return s.OptionalValue +} + +func (s *signature) Negative() bool { + return s.NegativeValue +} + +func (s *signature) Children() []Signature { + return s.ChildrenValue +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stage.go new file mode 100644 index 00000000000..a4dcdc23df7 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stage.go @@ -0,0 +1,27 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" +) + +//go:generate mockgen -destination=./mock_stage.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Stage +type Stage interface { + StageMetadata + StageLifecycle + Len() int + Signature() Signature + Resolve(m ...expressionstcl.Machine) error + ContainerStages() []ContainerStage + GetImages() map[string]struct{} + ApplyImages(images map[string]*imageinspector.Info) error + Flatten() []Stage +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go new file mode 100644 index 00000000000..e20994d9c1b --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go @@ -0,0 +1,111 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "strings" + + testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" +) + +type Condition struct { + Refs []string + Condition string `expr:"expression"` +} + +type StageLifecycle interface { + Negative() bool + Optional() bool + Condition() string + RetryPolicy() testworkflowsv1.RetryPolicy + Timeout() string + + SetNegative(negative bool) StageLifecycle + SetOptional(optional bool) StageLifecycle + SetCondition(expr string) StageLifecycle + AppendConditions(expr ...string) StageLifecycle + SetRetryPolicy(policy testworkflowsv1.RetryPolicy) StageLifecycle + SetTimeout(tpl string) StageLifecycle +} + +type stageLifecycle struct { + negative bool + optional bool + condition string `exp:"expression"` + retry testworkflowsv1.RetryPolicy `expr:"include"` + timeout string `expr:"template"` +} + +func NewStageLifecycle() StageLifecycle { + return &stageLifecycle{} +} + +func (s *stageLifecycle) Negative() bool { + return s.negative +} + +func (s *stageLifecycle) Optional() bool { + return s.optional +} + +func (s *stageLifecycle) Condition() string { + return s.condition +} + +func (s *stageLifecycle) RetryPolicy() testworkflowsv1.RetryPolicy { + if s.retry.Count < 1 { + s.retry.Count = 0 + } + return s.retry +} + +func (s *stageLifecycle) Timeout() string { + return s.timeout +} + +func (s *stageLifecycle) SetNegative(negative bool) StageLifecycle { + s.negative = negative + return s +} + +func (s *stageLifecycle) SetOptional(optional bool) StageLifecycle { + s.optional = optional + return s +} + +func (s *stageLifecycle) SetCondition(expr string) StageLifecycle { + s.condition = expr + return s +} + +func (s *stageLifecycle) AppendConditions(expr ...string) StageLifecycle { + expr = append(expr, s.condition) + cond := []string(nil) + seen := map[string]bool{} // Assume pure accessors in condition, and preserve only unique + for _, e := range expr { + if e != "" && !seen[e] { + seen[e] = true + cond = append(cond, e) + } + } + + s.condition = strings.Join(cond, "&&") + + return s +} + +func (s *stageLifecycle) SetRetryPolicy(policy testworkflowsv1.RetryPolicy) StageLifecycle { + s.retry = policy + return s +} + +func (s *stageLifecycle) SetTimeout(tpl string) StageLifecycle { + s.timeout = tpl + return s +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go new file mode 100644 index 00000000000..86a9887bc00 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go @@ -0,0 +1,41 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +type StageMetadata interface { + Ref() string + Name() string + + SetName(name string, fallbacks ...string) StageMetadata +} + +type stageMetadata struct { + ref string + name string `expr:"template"` +} + +func NewStageMetadata(ref string) StageMetadata { + return &stageMetadata{ref: ref} +} + +func (s *stageMetadata) Ref() string { + return s.ref +} + +func (s *stageMetadata) Name() string { + return s.name +} + +func (s *stageMetadata) SetName(name string, fallbacks ...string) StageMetadata { + s.name = name + for i := 0; s.name == "" && i < len(fallbacks); i++ { + s.name = fallbacks[i] + } + return s +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go new file mode 100644 index 00000000000..3cff807b30d --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go @@ -0,0 +1,132 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowprocessor + +import ( + "fmt" + "strings" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/imageinspector" +) + +func AnnotateControlledBy(obj metav1.Object, testWorkflowId string) { + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[executionIdLabelName] = testWorkflowId + obj.SetLabels(labels) + + // Annotate Pod template in the Job + if v, ok := obj.(*batchv1.Job); ok { + AnnotateControlledBy(&v.Spec.Template, testWorkflowId) + } +} + +func getRef(stage Stage) string { + return stage.Ref() +} + +func isNotOptional(stage Stage) bool { + return !stage.Optional() +} + +func buildKubernetesContainers(stage Stage, init *initProcess, images map[string]*imageinspector.Info) (containers []corev1.Container, err error) { + if stage.Timeout() != "" { + init.AddTimeout(stage.Timeout(), stage.Ref()) + } + if stage.Ref() != "" { + init.AddCondition(stage.Condition(), stage.Ref()) + } + init.AddRetryPolicy(stage.RetryPolicy(), stage.Ref()) + + group, ok := stage.(GroupStage) + if ok { + recursiveRefs := common.MapSlice(group.RecursiveChildren(), getRef) + directRefResults := common.MapSlice(common.FilterSlice(group.Children(), isNotOptional), getRef) + + init.AddCondition(stage.Condition(), recursiveRefs...) + + if group.Negative() { + // Create virtual layer that will be put down into actual negative step + init.SetRef(stage.Ref() + ".v") + init.AddCondition(stage.Condition(), stage.Ref()+".v") + init.PrependInitialStatus(stage.Ref() + ".v") + init.AddResult("!"+stage.Ref()+".v", stage.Ref()) + } else if stage.Ref() != "" { + init.PrependInitialStatus(stage.Ref()) + } + + if group.Optional() { + init.ResetResults() + } + + if group.Negative() { + init.AddResult(strings.Join(directRefResults, "&&"), ""+stage.Ref()+".v") + } else { + init.AddResult(strings.Join(directRefResults, "&&"), ""+stage.Ref()) + } + + for i, ch := range group.Children() { + // Condition should be executed only in the first leaf + if i == 1 { + init.ResetCondition() + } + // Pass down to another group or container + sub, serr := buildKubernetesContainers(ch, init.Children(ch.Ref()), images) + if serr != nil { + return nil, fmt.Errorf("%s: %s: resolving children: %s", stage.Ref(), stage.Name(), serr.Error()) + } + containers = append(containers, sub...) + } + return + } + c, ok := stage.(ContainerStage) + if !ok { + return nil, fmt.Errorf("%s: %s: stage that is neither container nor group", stage.Ref(), stage.Name()) + } + err = c.Container().Detach().Resolve() + if err != nil { + return nil, fmt.Errorf("%s: %s: resolving container: %s", stage.Ref(), stage.Name(), err.Error()) + } + + cr := c.Container().ToKubernetesTemplate() + cr.Name = c.Ref() + + if c.Optional() { + init.ResetResults() + } + + init. + SetNegative(c.Negative()). + AddRetryPolicy(c.RetryPolicy(), c.Ref()). + SetCommand(cr.Command...). + SetArgs(cr.Args...) + + for _, env := range cr.Env { + if strings.Contains(env.Value, "{{") { + init.AddComputedEnvs(env.Name) + } + } + + if init.Error() != nil { + return nil, init.Error() + } + + cr.Command = init.Command() + cr.Args = init.Args() + + containers = []corev1.Container{cr} + return +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go index 7a459f26f8c..36155734d5e 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" @@ -47,7 +48,7 @@ var ( Spec: testworkflowsv1.TestWorkflowTemplateSpec{ TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ Container: &testworkflowsv1.ContainerConfig{ - Env: []testworkflowsv1.EnvVar{ + Env: []corev1.EnvVar{ {Name: "test", Value: "the"}, }, }, @@ -71,7 +72,7 @@ var ( Spec: testworkflowsv1.TestWorkflowTemplateSpec{ TestWorkflowSpecBase: testworkflowsv1.TestWorkflowSpecBase{ Container: &testworkflowsv1.ContainerConfig{ - Env: []testworkflowsv1.EnvVar{ + Env: []corev1.EnvVar{ {Name: "test", Value: "the"}, }, }, @@ -176,7 +177,7 @@ var ( Name: "basic", Shell: "shell-command", Container: &testworkflowsv1.ContainerConfig{ - Env: []testworkflowsv1.EnvVar{ + Env: []corev1.EnvVar{ {Name: "XYZ", Value: "some-value"}, }, }, @@ -189,7 +190,7 @@ var ( Delay: "5s", Shell: "another-shell-command", Container: &testworkflowsv1.ContainerConfig{ - Env: []testworkflowsv1.EnvVar{ + Env: []corev1.EnvVar{ {Name: "XYZ", Value: "some-value"}, }, }, From edfce288ac0f7155ed423f0801032810566d85e0 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 4 Mar 2024 13:45:51 +0100 Subject: [PATCH 166/234] feat: added GetExecution to featch exection without output (#5099) --- internal/app/api/v1/executions_test.go | 7 +++++++ pkg/cloud/data/result/result.go | 13 +++++++++++++ .../containerexecutor/containerexecutor_test.go | 4 ++++ pkg/repository/result/interface.go | 2 ++ pkg/repository/result/mock_repository.go | 15 +++++++++++++++ pkg/repository/result/mongo.go | 8 ++++++++ 6 files changed, 49 insertions(+) diff --git a/internal/app/api/v1/executions_test.go b/internal/app/api/v1/executions_test.go index 4e6f68125ed..f7c2d4095e4 100644 --- a/internal/app/api/v1/executions_test.go +++ b/internal/app/api/v1/executions_test.go @@ -205,6 +205,13 @@ func (r MockExecutionResultsRepository) Get(ctx context.Context, id string) (tes return r.GetFn(ctx, id) } +func (r MockExecutionResultsRepository) GetExecution(ctx context.Context, id string) (testkube.Execution, error) { + if r.GetFn == nil { + panic("not implemented") + } + return r.GetFn(ctx, id) +} + func (r MockExecutionResultsRepository) GetByNameAndTest(ctx context.Context, name, testName string) (testkube.Execution, error) { panic("not implemented") } diff --git a/pkg/cloud/data/result/result.go b/pkg/cloud/data/result/result.go index 43e8ca330c6..5ee06ad7aaa 100644 --- a/pkg/cloud/data/result/result.go +++ b/pkg/cloud/data/result/result.go @@ -42,6 +42,19 @@ func (r *CloudRepository) GetNextExecutionNumber(ctx context.Context, testName s return commandResponse.TestNumber, nil } +func (r *CloudRepository) GetExecution(ctx context.Context, id string) (testkube.Execution, error) { + req := GetRequest{ID: id} + response, err := r.executor.Execute(ctx, CmdResultGet, req) + if err != nil { + return testkube.Execution{}, err + } + var commandResponse GetResponse + if err := json.Unmarshal(response, &commandResponse); err != nil { + return testkube.Execution{}, err + } + return commandResponse.Execution, nil +} + func (r *CloudRepository) Get(ctx context.Context, id string) (testkube.Execution, error) { req := GetRequest{ID: id} response, err := r.executor.Execute(ctx, CmdResultGet, req) diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index a6420afc95b..9e7226dfbf1 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -468,6 +468,10 @@ func (r FakeResultRepository) Count(ctx context.Context, filter result.Filter) ( panic("implement me") } +func (FakeResultRepository) GetExecution(ctx context.Context, id string) (testkube.Execution, error) { + return testkube.Execution{}, nil +} + func (FakeResultRepository) Get(ctx context.Context, id string) (testkube.Execution, error) { return testkube.Execution{}, nil } diff --git a/pkg/repository/result/interface.go b/pkg/repository/result/interface.go index c22e1248f82..a10928e1917 100644 --- a/pkg/repository/result/interface.go +++ b/pkg/repository/result/interface.go @@ -35,6 +35,8 @@ type Repository interface { Sequences // Get gets execution result by id or name Get(ctx context.Context, id string) (testkube.Execution, error) + // Get gets execution result without output + GetExecution(ctx context.Context, id string) (testkube.Execution, error) // GetByNameAndTest gets execution result by name and test name GetByNameAndTest(ctx context.Context, name, testName string) (testkube.Execution, error) // GetLatestByTest gets latest execution result by test diff --git a/pkg/repository/result/mock_repository.go b/pkg/repository/result/mock_repository.go index 82810f2882d..a03399c72db 100644 --- a/pkg/repository/result/mock_repository.go +++ b/pkg/repository/result/mock_repository.go @@ -179,6 +179,21 @@ func (mr *MockRepositoryMockRecorder) GetByNameAndTest(arg0, arg1, arg2 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByNameAndTest", reflect.TypeOf((*MockRepository)(nil).GetByNameAndTest), arg0, arg1, arg2) } +// GetExecution mocks base method. +func (m *MockRepository) GetExecution(arg0 context.Context, arg1 string) (testkube.Execution, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExecution", arg0, arg1) + ret0, _ := ret[0].(testkube.Execution) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecution indicates an expected call of GetExecution. +func (mr *MockRepositoryMockRecorder) GetExecution(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecution", reflect.TypeOf((*MockRepository)(nil).GetExecution), arg0, arg1) +} + // GetExecutionTotals mocks base method. func (m *MockRepository) GetExecutionTotals(arg0 context.Context, arg1 bool, arg2 ...Filter) (testkube.ExecutionsTotals, error) { m.ctrl.T.Helper() diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index 50edb1c8a76..29d1b029263 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -154,6 +154,14 @@ func (r *MongoRepository) getOutputFromLogServer(ctx context.Context, result *te return output, nil } +func (r *MongoRepository) GetExecution(ctx context.Context, id string) (result testkube.Execution, err error) { + err = r.ResultsColl.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result) + if err != nil { + return + } + return *result.UnscapeDots(), err +} + func (r *MongoRepository) Get(ctx context.Context, id string) (result testkube.Execution, err error) { err = r.ResultsColl.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result) if err != nil { From fdd6039458d0e333754fa3db3b9a4e2c332757e9 Mon Sep 17 00:00:00 2001 From: Povilas Versockas Date: Mon, 4 Mar 2024 14:52:40 +0200 Subject: [PATCH 167/234] fix: [TKC-1611] execute post run script for container executors (#5097) --- contrib/executor/init/pkg/runner/runner.go | 13 +++++- .../executor/init/pkg/runner/runner_test.go | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/contrib/executor/init/pkg/runner/runner.go b/contrib/executor/init/pkg/runner/runner.go index 4c90871f661..93ef65adb56 100755 --- a/contrib/executor/init/pkg/runner/runner.go +++ b/contrib/executor/init/pkg/runner/runner.go @@ -81,27 +81,38 @@ func (r *InitRunner) Run(ctx context.Context, execution testkube.Execution) (res } shebang := "#!" + shell + "\nset -e\n" - entrypoint := shebang + // No set -e so that we can run the post-run script even if the command fails + entrypoint := "#!" + shell + "\n" command := shebang preRunScript := shebang postRunScript := shebang if execution.PreRunScript != "" { entrypoint += strconv.Quote(filepath.Join(r.Params.DataDir, preRunScriptName)) + "\n" + entrypoint += "prerun_exit_code=$?\nif [ $prerun_exit_code -ne 0 ]; then\n exit $prerun_exit_code\nfi\n" preRunScript += execution.PreRunScript } if len(execution.Command) != 0 { entrypoint += strconv.Quote(filepath.Join(r.Params.DataDir, commandScriptName)) + " $@\n" + entrypoint += "command_exit_code=$?\n" command += strings.Join(execution.Command, " ") command += " \"$@\"\n" } if execution.PostRunScript != "" { entrypoint += strconv.Quote(filepath.Join(r.Params.DataDir, postRunScriptName)) + "\n" + entrypoint += "postrun_exit_code=$?\n" postRunScript += execution.PostRunScript } + if len(execution.Command) != 0 { + entrypoint += "if [ $command_exit_code -ne 0 ]; then\n exit $command_exit_code\nfi\n" + } + + if execution.PostRunScript != "" { + entrypoint += "exit $postrun_exit_code\n" + } var scripts = []struct { dir string file string diff --git a/contrib/executor/init/pkg/runner/runner_test.go b/contrib/executor/init/pkg/runner/runner_test.go index 977c20213f7..ac06bfaed26 100755 --- a/contrib/executor/init/pkg/runner/runner_test.go +++ b/contrib/executor/init/pkg/runner/runner_test.go @@ -2,6 +2,7 @@ package runner import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -31,4 +32,45 @@ func TestRun(t *testing.T) { assert.Equal(t, result.Status, testkube.ExecutionStatusRunning) }) + t.Run("runner with pre and post run scripts should run test", func(t *testing.T) { + t.Parallel() + + params := envs.Params{DataDir: "./testdir"} + runner := NewRunner(params) + execution := testkube.NewQueuedExecution() + execution.Content = testkube.NewStringTestContent("hello I'm test content") + execution.PreRunScript = "echo \"===== pre-run script\"" + execution.Command = []string{"command.sh"} + execution.PostRunScript = "echo \"===== pre-run script\"" + + // when + result, err := runner.Run(ctx, *execution) + + // then + assert.NoError(t, err) + assert.Equal(t, result.Status, testkube.ExecutionStatusRunning) + + expected := `#!/bin/sh +"testdir/prerun.sh" +prerun_exit_code=$? +if [ $prerun_exit_code -ne 0 ]; then + exit $prerun_exit_code +fi +"testdir/command.sh" $@ +command_exit_code=$? +"testdir/postrun.sh" +postrun_exit_code=$? +if [ $command_exit_code -ne 0 ]; then + exit $command_exit_code +fi +exit $postrun_exit_code +` + + data, err := os.ReadFile("testdir/entrypoint.sh") + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + assert.Equal(t, string(data), expected) + }) + } From 2e92b185d0db93a09af7edd5bd7472cbd6a7d156 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 4 Mar 2024 20:37:05 +0300 Subject: [PATCH 168/234] fix: source for running scripts (#5015) * fix: source for running scripts * feat: source scripts field * fix: process source scripts flag * fix: template for source scripts * fix: dep updare * fix: remove source for prebuilt executors * docs: source scripts flag * fix: dep update --- api/v1/testkube.yaml | 6 ++++++ cmd/kubectl-testkube/commands/tests/common.go | 11 +++++++++++ cmd/kubectl-testkube/commands/tests/create.go | 2 ++ .../commands/tests/renderer/test_obj.go | 9 ++++++++- cmd/kubectl-testkube/commands/tests/run.go | 3 +++ contrib/executor/init/pkg/runner/runner.go | 12 ++++++++++++ docs/docs/articles/creating-tests.md | 2 ++ docs/docs/cli/testkube_create_test.md | 1 + docs/docs/cli/testkube_generate_tests-crds.md | 1 + docs/docs/cli/testkube_run_test.md | 1 + docs/docs/cli/testkube_update_test.md | 1 + .../running-parallel-tests-with-test-suite.md | 1 + go.mod | 4 ++-- go.sum | 8 ++++---- pkg/api/v1/client/interface.go | 1 + pkg/api/v1/client/test.go | 2 ++ pkg/api/v1/testkube/model_execution.go | 6 ++++-- pkg/api/v1/testkube/model_execution_request.go | 2 ++ .../v1/testkube/model_execution_update_request.go | 2 ++ pkg/crd/crd_test.go | 2 +- pkg/crd/templates/test.tmpl | 7 ++++++- pkg/mapper/testexecutions/mapper.go | 1 + pkg/mapper/tests/kube_openapi.go | 2 ++ pkg/mapper/tests/openapi_kube.go | 5 +++++ pkg/mapper/testsuiteexecutions/mapper.go | 1 + pkg/scheduler/test_scheduler.go | 5 +++++ pkg/scheduler/test_scheduler_test.go | 1 + 27 files changed, 88 insertions(+), 11 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index e077210ffae..b701e5232df 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -5004,6 +5004,9 @@ components: executePostRunScriptBeforeScraping: type: boolean description: execute post run script before scraping (prebuilt executor only) + sourceScripts: + type: boolean + description: run scripts using source command (container executor only) runningContext: $ref: "#/components/schemas/RunningContext" description: running context for the test execution @@ -5634,6 +5637,9 @@ components: executePostRunScriptBeforeScraping: type: boolean description: execute post run script before scraping (prebuilt executor only) + sourceScripts: + type: boolean + description: run scripts using source command (container executor only) scraperTemplate: type: string description: scraper template extensions diff --git a/cmd/kubectl-testkube/commands/tests/common.go b/cmd/kubectl-testkube/commands/tests/common.go index 537f93f7f72..f3f12f9f764 100644 --- a/cmd/kubectl-testkube/commands/tests/common.go +++ b/cmd/kubectl-testkube/commands/tests/common.go @@ -492,6 +492,7 @@ func newExecutionRequestFromFlags(cmd *cobra.Command) (request *testkube.Executi pvcTemplateReference := cmd.Flag("pvc-template-reference").Value.String() executionNamespace := cmd.Flag("execution-namespace").Value.String() executePostRunScriptBeforeScraping, err := cmd.Flags().GetBool("execute-postrun-script-before-scraping") + sourceScripts, err := cmd.Flags().GetBool("source-scripts") if err != nil { return nil, err } @@ -515,6 +516,7 @@ func newExecutionRequestFromFlags(cmd *cobra.Command) (request *testkube.Executi PvcTemplateReference: pvcTemplateReference, NegativeTest: negativeTest, ExecutePostRunScriptBeforeScraping: executePostRunScriptBeforeScraping, + SourceScripts: sourceScripts, ExecutionNamespace: executionNamespace, } @@ -1111,6 +1113,15 @@ func newExecutionUpdateRequestFromFlags(cmd *cobra.Command) (request *testkube.E nonEmpty = true } + if cmd.Flag("source-scripts").Changed { + sourceScripts, err := cmd.Flags().GetBool("source-scripts") + if err != nil { + return nil, err + } + request.SourceScripts = &sourceScripts + nonEmpty = true + } + artifactRequest, err := newArtifactUpdateRequestFromFlags(cmd) if err != nil { return nil, err diff --git a/cmd/kubectl-testkube/commands/tests/create.go b/cmd/kubectl-testkube/commands/tests/create.go index d7bc5e78d51..66d78acb11b 100644 --- a/cmd/kubectl-testkube/commands/tests/create.go +++ b/cmd/kubectl-testkube/commands/tests/create.go @@ -47,6 +47,7 @@ type CreateCommonFlags struct { PreRunScript string PostRunScript string ExecutePostRunScriptBeforeScraping bool + SourceScripts bool ScraperTemplate string ScraperTemplateReference string PvcTemplate string @@ -257,6 +258,7 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateCommonFlags) { cmd.Flags().StringVarP(&flags.PreRunScript, "prerun-script", "", "", "path to script to be run before test execution") cmd.Flags().StringVarP(&flags.PostRunScript, "postrun-script", "", "", "path to script to be run after test execution") cmd.Flags().BoolVarP(&flags.ExecutePostRunScriptBeforeScraping, "execute-postrun-script-before-scraping", "", false, "whether to execute postrun scipt before scraping or not (prebuilt executor only)") + cmd.Flags().BoolVarP(&flags.SourceScripts, "source-scripts", "", false, "run scripts using source command (container executor only)") cmd.Flags().StringVar(&flags.ScraperTemplate, "scraper-template", "", "scraper template file path for extensions to scraper template") cmd.Flags().StringVar(&flags.ScraperTemplateReference, "scraper-template-reference", "", "reference to scraper template to use for the test") cmd.Flags().StringVar(&flags.PvcTemplate, "pvc-template", "", "pvc template file path for extensions to pvc template") diff --git a/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go b/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go index e32a2dd74ef..1ff8d838176 100644 --- a/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go +++ b/cmd/kubectl-testkube/commands/tests/renderer/test_obj.go @@ -159,7 +159,14 @@ func TestRenderer(client client.Client, ui *ui.UI, obj interface{}) error { ui.Warn(" Post run script: ", "\n", test.ExecutionRequest.PostRunScript) } - ui.Warn(" Execute postrun script before scraping: ", fmt.Sprint(test.ExecutionRequest.ExecutePostRunScriptBeforeScraping)) + if test.ExecutionRequest.ExecutePostRunScriptBeforeScraping { + ui.Warn(" Execute postrun script before scraping: ", fmt.Sprint(test.ExecutionRequest.ExecutePostRunScriptBeforeScraping)) + } + + if test.ExecutionRequest.SourceScripts { + ui.Warn(" Source scripts: ", fmt.Sprint(test.ExecutionRequest.SourceScripts)) + } + if test.ExecutionRequest.ScraperTemplate != "" { ui.Warn(" Scraper template: ", "\n", test.ExecutionRequest.ScraperTemplate) } diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index d001d61ceba..aadaa426127 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -51,6 +51,7 @@ func NewRunTestCmd() *cobra.Command { preRunScript string postRunScript string executePostRunScriptBeforeScraping bool + sourceScripts bool scraperTemplate string scraperTemplateReference string pvcTemplate string @@ -124,6 +125,7 @@ func NewRunTestCmd() *cobra.Command { Context: runningContext, }, ExecutePostRunScriptBeforeScraping: executePostRunScriptBeforeScraping, + SourceScripts: sourceScripts, ExecutionNamespace: executionNamespace, } @@ -377,6 +379,7 @@ func NewRunTestCmd() *cobra.Command { cmd.Flags().StringVarP(&preRunScript, "prerun-script", "", "", "path to script to be run before test execution") cmd.Flags().StringVarP(&postRunScript, "postrun-script", "", "", "path to script to be run after test execution") cmd.Flags().BoolVarP(&executePostRunScriptBeforeScraping, "execute-postrun-script-before-scraping", "", false, "whether to execute postrun scipt before scraping or not (prebuilt executor only)") + cmd.Flags().BoolVarP(&sourceScripts, "source-scripts", "", false, "run scripts using source command (container executor only)") cmd.Flags().StringVar(&scraperTemplate, "scraper-template", "", "scraper template file path for extensions to scraper template") cmd.Flags().StringVar(&scraperTemplateReference, "scraper-template-reference", "", "reference to scraper template to use for the test") cmd.Flags().StringVar(&pvcTemplate, "pvc-template", "", "pvc template file path for extensions to pvc template") diff --git a/contrib/executor/init/pkg/runner/runner.go b/contrib/executor/init/pkg/runner/runner.go index 93ef65adb56..acd893dfee5 100755 --- a/contrib/executor/init/pkg/runner/runner.go +++ b/contrib/executor/init/pkg/runner/runner.go @@ -88,12 +88,20 @@ func (r *InitRunner) Run(ctx context.Context, execution testkube.Execution) (res postRunScript := shebang if execution.PreRunScript != "" { + if execution.SourceScripts { + entrypoint += ". " + } + entrypoint += strconv.Quote(filepath.Join(r.Params.DataDir, preRunScriptName)) + "\n" entrypoint += "prerun_exit_code=$?\nif [ $prerun_exit_code -ne 0 ]; then\n exit $prerun_exit_code\nfi\n" preRunScript += execution.PreRunScript } if len(execution.Command) != 0 { + if execution.SourceScripts { + entrypoint += ". " + } + entrypoint += strconv.Quote(filepath.Join(r.Params.DataDir, commandScriptName)) + " $@\n" entrypoint += "command_exit_code=$?\n" command += strings.Join(execution.Command, " ") @@ -101,6 +109,10 @@ func (r *InitRunner) Run(ctx context.Context, execution testkube.Execution) (res } if execution.PostRunScript != "" { + if execution.SourceScripts { + entrypoint += ". " + } + entrypoint += strconv.Quote(filepath.Join(r.Params.DataDir, postRunScriptName)) + "\n" entrypoint += "postrun_exit_code=$?\n" postRunScript += execution.PostRunScript diff --git a/docs/docs/articles/creating-tests.md b/docs/docs/articles/creating-tests.md index 5b32532c8ab..f863f9f14bd 100644 --- a/docs/docs/articles/creating-tests.md +++ b/docs/docs/articles/creating-tests.md @@ -517,6 +517,8 @@ Provide the script when you create or run the test using `--prerun-script` and ` testkube create test --file test/postman/LocalHealth.postman_collection.json --name script-test --type postman/collection --prerun-script pre_script.sh --postrun-script post_script.sh --secret-env SSL_CERT=your-k8s-secret ``` +For container executors you can define `--source-scripts` flag in order to run both scripts using `source` command in the same shell. + ### Adjusting Scraping Parameters For any executor type you can specify additional scraping parameters using CLI or CRD definition. For example, below we request to scrape report directories, use a custom bucket to store test artifacts and ask to avoid using separate artifact folders for each test execution diff --git a/docs/docs/cli/testkube_create_test.md b/docs/docs/cli/testkube_create_test.md index 036cdabe48a..d15c77f5a9c 100644 --- a/docs/docs/cli/testkube_create_test.md +++ b/docs/docs/cli/testkube_create_test.md @@ -72,6 +72,7 @@ testkube create test [flags] --slave-pod-template string slave pod template file path for extensions to slave pod template --slave-pod-template-reference string reference to slave pod template to use for the test --source string source name - will be used together with content parameters + --source-scripts run scripts using source command (container executor only) --test-content-type string content type of test one of string|file-uri|git --timeout int duration in seconds for test to timeout. 0 disables timeout. -t, --type string test type diff --git a/docs/docs/cli/testkube_generate_tests-crds.md b/docs/docs/cli/testkube_generate_tests-crds.md index 6e5fc384204..af583a785c0 100644 --- a/docs/docs/cli/testkube_generate_tests-crds.md +++ b/docs/docs/cli/testkube_generate_tests-crds.md @@ -58,6 +58,7 @@ testkube generate tests-crds [flags] --slave-pod-requests-memory string slave pod resource requests memory --slave-pod-template string slave pod template file path for extensions to slave pod template --slave-pod-template-reference string reference to slave pod template to use for the test + --source-scripts run scripts using source command (container executor only) --timeout int duration in seconds for test to timeout. 0 disables timeout. -t, --type string test type --upload-timeout string timeout to use when uploading files, example: 30s diff --git a/docs/docs/cli/testkube_run_test.md b/docs/docs/cli/testkube_run_test.md index 76a2cf97ea8..221039072fb 100644 --- a/docs/docs/cli/testkube_run_test.md +++ b/docs/docs/cli/testkube_run_test.md @@ -64,6 +64,7 @@ testkube run test [flags] --slave-pod-requests-memory string slave pod resource requests memory --slave-pod-template string slave pod template file path for extensions to slave pod template --slave-pod-template-reference string reference to slave pod template to use for the test + --source-scripts run scripts using source command (container executor only) --upload-timeout string timeout to use when uploading files, example: 30s -v, --variable stringToString execution variable passed to executor (default []) --variable-configmap stringArray config map name used to map all keys to basis variables diff --git a/docs/docs/cli/testkube_update_test.md b/docs/docs/cli/testkube_update_test.md index 07bc96831ee..a44238fe963 100644 --- a/docs/docs/cli/testkube_update_test.md +++ b/docs/docs/cli/testkube_update_test.md @@ -72,6 +72,7 @@ testkube update test [flags] --slave-pod-template string slave pod template file path for extensions to slave pod template --slave-pod-template-reference string reference to slave pod template to use for the test --source string source name - will be used together with content parameters + --source-scripts run scripts using source command (container executor only) --test-content-type string content type of test one of string|file-uri|git --timeout int duration in seconds for test to timeout. 0 disables timeout. -t, --type string test type diff --git a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md index 0e0109167cf..4280fed9df1 100644 --- a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md +++ b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md @@ -69,6 +69,7 @@ For details on which parameters are available in the CRDs, please consult the ta | preRunScript | ✓ | | | | postRunScript | ✓ | | | | executePostRunScriptBeforeScraping | ✓ | | | +| sourceScripts | ✓ | | | | scraperTemplate | ✓ | ✓ | ✓ | | scraperTemplateReference | ✓ | ✓ | ✓ | | pvcTemplate | ✓ | ✓ | ✓ | diff --git a/go.mod b/go.mod index 233a59efafb..29ecd38e95a 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301224325-4909488f050d + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240304172632-b7aaa27ca4d3 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 @@ -188,7 +188,7 @@ require ( golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.32.0 + google.golang.org/protobuf v1.31.0 gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 644d95ec74b..1bc912f2717 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301224325-4909488f050d h1:0h5O0qM9kv9LrFYwAdoFbr6XnQrjy0hWmMvXet5PPfw= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240301224325-4909488f050d/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240304172632-b7aaa27ca4d3 h1:f+CEysr8ya3s7p059aNKlyfEqzfAgVsBDHK/PSraF6Y= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240304172632-b7aaa27ca4d3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= @@ -958,8 +958,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index 4b025b4e8b5..78a6fd9247d 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -219,6 +219,7 @@ type ExecuteTestOptions struct { PreRunScriptContent string PostRunScriptContent string ExecutePostRunScriptBeforeScraping bool + SourceScripts bool ScraperTemplate string ScraperTemplateReference string PvcTemplate string diff --git a/pkg/api/v1/client/test.go b/pkg/api/v1/client/test.go index 86dd9e540e8..d4f9ce565b0 100644 --- a/pkg/api/v1/client/test.go +++ b/pkg/api/v1/client/test.go @@ -159,6 +159,7 @@ func (c TestClient) ExecuteTest(id, executionName string, options ExecuteTestOpt PreRunScript: options.PreRunScriptContent, PostRunScript: options.PostRunScriptContent, ExecutePostRunScriptBeforeScraping: options.ExecutePostRunScriptBeforeScraping, + SourceScripts: options.SourceScripts, ScraperTemplate: options.ScraperTemplate, ScraperTemplateReference: options.ScraperTemplateReference, PvcTemplate: options.PvcTemplate, @@ -204,6 +205,7 @@ func (c TestClient) ExecuteTests(selector string, concurrencyLevel int, options PreRunScript: options.PreRunScriptContent, PostRunScript: options.PostRunScriptContent, ExecutePostRunScriptBeforeScraping: options.ExecutePostRunScriptBeforeScraping, + SourceScripts: options.SourceScripts, ScraperTemplate: options.ScraperTemplate, ScraperTemplateReference: options.ScraperTemplateReference, PvcTemplate: options.PvcTemplate, diff --git a/pkg/api/v1/testkube/model_execution.go b/pkg/api/v1/testkube/model_execution.go index 7b8e56ceebb..87ae7711bf3 100644 --- a/pkg/api/v1/testkube/model_execution.go +++ b/pkg/api/v1/testkube/model_execution.go @@ -69,8 +69,10 @@ type Execution struct { // script to run after test execution PostRunScript string `json:"postRunScript,omitempty"` // execute post run script before scraping (prebuilt executor only) - ExecutePostRunScriptBeforeScraping bool `json:"executePostRunScriptBeforeScraping,omitempty"` - RunningContext *RunningContext `json:"runningContext,omitempty"` + ExecutePostRunScriptBeforeScraping bool `json:"executePostRunScriptBeforeScraping,omitempty"` + // run scripts using source command (container executor only) + SourceScripts bool `json:"sourceScripts,omitempty"` + RunningContext *RunningContext `json:"runningContext,omitempty"` // shell used in container executor ContainerShell string `json:"containerShell,omitempty"` // test execution name started the test execution diff --git a/pkg/api/v1/testkube/model_execution_request.go b/pkg/api/v1/testkube/model_execution_request.go index f4397799245..391cb3d31de 100644 --- a/pkg/api/v1/testkube/model_execution_request.go +++ b/pkg/api/v1/testkube/model_execution_request.go @@ -80,6 +80,8 @@ type ExecutionRequest struct { PostRunScript string `json:"postRunScript,omitempty"` // execute post run script before scraping (prebuilt executor only) ExecutePostRunScriptBeforeScraping bool `json:"executePostRunScriptBeforeScraping,omitempty"` + // run scripts using source command (container executor only) + SourceScripts bool `json:"sourceScripts,omitempty"` // scraper template extensions ScraperTemplate string `json:"scraperTemplate,omitempty"` // name of the template resource diff --git a/pkg/api/v1/testkube/model_execution_update_request.go b/pkg/api/v1/testkube/model_execution_update_request.go index 4504323d7b9..d0ae7893f36 100644 --- a/pkg/api/v1/testkube/model_execution_update_request.go +++ b/pkg/api/v1/testkube/model_execution_update_request.go @@ -80,6 +80,8 @@ type ExecutionUpdateRequest struct { PostRunScript *string `json:"postRunScript,omitempty"` // execute post run script before scraping (prebuilt executor only) ExecutePostRunScriptBeforeScraping *bool `json:"executePostRunScriptBeforeScraping,omitempty"` + // run scripts using source command (container executor only) + SourceScripts *bool `json:"sourceScripts,omitempty"` // scraper template extensions ScraperTemplate *string `json:"scraperTemplate,omitempty"` // name of the template resource diff --git a/pkg/crd/crd_test.go b/pkg/crd/crd_test.go index 890b9654c59..5db775ba338 100644 --- a/pkg/crd/crd_test.go +++ b/pkg/crd/crd_test.go @@ -113,7 +113,7 @@ func TestGenerateYAML(t *testing.T) { }) t.Run("generate test CRD yaml", func(t *testing.T) { // given - expected := "apiVersion: tests.testkube.io/v3\nkind: Test\nmetadata:\n name: name1\n namespace: namespace1\n labels:\n key1: value1\nspec:\n executionRequest:\n name: execution-name\n args:\n - -v\n - test\n image: docker.io/curlimages/curl:latest\n command:\n - curl\n imagePullSecrets:\n - name: secret-name\n negativeTest: true\n activeDeadlineSeconds: 10\n executePostRunScriptBeforeScraping: false\n" + expected := "apiVersion: tests.testkube.io/v3\nkind: Test\nmetadata:\n name: name1\n namespace: namespace1\n labels:\n key1: value1\nspec:\n executionRequest:\n name: execution-name\n args:\n - -v\n - test\n image: docker.io/curlimages/curl:latest\n command:\n - curl\n imagePullSecrets:\n - name: secret-name\n negativeTest: true\n activeDeadlineSeconds: 10\n" tests := []testkube.TestUpsertRequest{ { Name: "name1", diff --git a/pkg/crd/templates/test.tmpl b/pkg/crd/templates/test.tmpl index c279d0fefb8..666f03b9eee 100644 --- a/pkg/crd/templates/test.tmpl +++ b/pkg/crd/templates/test.tmpl @@ -82,7 +82,7 @@ spec: schedule: {{ .Schedule }} {{- end }} {{- if .ExecutionRequest }} - {{- if or (.ExecutionRequest.Name) (.ExecutionRequest.NegativeTest) (.ExecutionRequest.VariablesFile) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (ne (len .ExecutionRequest.Envs) 0) (ne (len .ExecutionRequest.SecretEnvs) 0) (.ExecutionRequest.Image) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.ImagePullSecrets) 0) (ne .ExecutionRequest.ActiveDeadlineSeconds 0) (.ExecutionRequest.ArtifactRequest) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.PreRunScript) (.ExecutionRequest.PostRunScript) (.ExecutionRequest.ExecutePostRunScriptBeforeScraping) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (ne (len .ExecutionRequest.EnvConfigMaps) 0) (ne (len .ExecutionRequest.EnvSecrets) 0) (.ExecutionRequest.SlavePodRequest) (.ExecutionRequest.ExecutionNamespace)}} + {{- if or (.ExecutionRequest.Name) (.ExecutionRequest.NegativeTest) (.ExecutionRequest.VariablesFile) (.ExecutionRequest.HttpProxy) (.ExecutionRequest.HttpsProxy) (ne (len .ExecutionRequest.Variables) 0) (ne (len .ExecutionRequest.Args) 0) (ne (len .ExecutionRequest.Envs) 0) (ne (len .ExecutionRequest.SecretEnvs) 0) (.ExecutionRequest.Image) (ne (len .ExecutionRequest.Command) 0) (.ExecutionRequest.ArgsMode) (ne (len .ExecutionRequest.ImagePullSecrets) 0) (ne .ExecutionRequest.ActiveDeadlineSeconds 0) (.ExecutionRequest.ArtifactRequest) (.ExecutionRequest.JobTemplate) (.ExecutionRequest.JobTemplateReference) (.ExecutionRequest.CronJobTemplate) (.ExecutionRequest.CronJobTemplateReference) (.ExecutionRequest.PreRunScript) (.ExecutionRequest.PostRunScript) (.ExecutionRequest.ExecutePostRunScriptBeforeScraping) (.ExecutionRequest.SourceScripts) (.ExecutionRequest.ScraperTemplate) (.ExecutionRequest.ScraperTemplateReference) (.ExecutionRequest.PvcTemplate) (.ExecutionRequest.PvcTemplateReference) (ne (len .ExecutionRequest.EnvConfigMaps) 0) (ne (len .ExecutionRequest.EnvSecrets) 0) (.ExecutionRequest.SlavePodRequest) (.ExecutionRequest.ExecutionNamespace)}} executionRequest: {{- if .ExecutionRequest.Name }} name: {{ .ExecutionRequest.Name }} @@ -222,7 +222,12 @@ spec: {{- if .ExecutionRequest.PostRunScript }} postRunScript: {{ .ExecutionRequest.PostRunScript }} {{- end }} + {{- if .ExecutionRequest.ExecutePostRunScriptBeforeScraping }} executePostRunScriptBeforeScraping: {{ .ExecutionRequest.ExecutePostRunScriptBeforeScraping }} + {{- end }} + {{- if .ExecutionRequest.SourceScripts }} + sourceScripts: {{ .ExecutionRequest.SourceScripts }} + {{- end }} {{- if .ExecutionRequest.ScraperTemplate }} scraperTemplate: {{ .ExecutionRequest.ScraperTemplate }} {{- end }} diff --git a/pkg/mapper/testexecutions/mapper.go b/pkg/mapper/testexecutions/mapper.go index 4fae363c70f..31beb807c9b 100644 --- a/pkg/mapper/testexecutions/mapper.go +++ b/pkg/mapper/testexecutions/mapper.go @@ -209,6 +209,7 @@ func MapAPIToCRD(request *testkube.Execution, generation int64) testexecutionv1. PreRunScript: request.PreRunScript, PostRunScript: request.PostRunScript, ExecutePostRunScriptBeforeScraping: request.ExecutePostRunScriptBeforeScraping, + SourceScripts: request.SourceScripts, RunningContext: runningContext, ContainerShell: request.ContainerShell, SlavePodRequest: podRequest, diff --git a/pkg/mapper/tests/kube_openapi.go b/pkg/mapper/tests/kube_openapi.go index 57ac91afab8..84fe46c1d7c 100644 --- a/pkg/mapper/tests/kube_openapi.go +++ b/pkg/mapper/tests/kube_openapi.go @@ -184,6 +184,7 @@ func MapExecutionRequestFromSpec(specExecutionRequest *testsv3.ExecutionRequest) PreRunScript: specExecutionRequest.PreRunScript, PostRunScript: specExecutionRequest.PostRunScript, ExecutePostRunScriptBeforeScraping: specExecutionRequest.ExecutePostRunScriptBeforeScraping, + SourceScripts: specExecutionRequest.SourceScripts, PvcTemplate: specExecutionRequest.PvcTemplate, PvcTemplateReference: specExecutionRequest.PvcTemplateReference, ScraperTemplate: specExecutionRequest.ScraperTemplate, @@ -522,6 +523,7 @@ func MapSpecExecutionRequestToExecutionUpdateRequest( envSecrets := MapEnvReferences(request.EnvSecrets) executionRequest.EnvSecrets = &envSecrets executionRequest.ExecutePostRunScriptBeforeScraping = &request.ExecutePostRunScriptBeforeScraping + executionRequest.SourceScripts = &request.SourceScripts // Pro edition only (tcl protected code) mappertcl.MapSpecExecutionRequestToExecutionUpdateRequest(request, executionRequest) diff --git a/pkg/mapper/tests/openapi_kube.go b/pkg/mapper/tests/openapi_kube.go index fab9532b609..64aa258734b 100644 --- a/pkg/mapper/tests/openapi_kube.go +++ b/pkg/mapper/tests/openapi_kube.go @@ -196,6 +196,7 @@ func MapExecutionRequestToSpecExecutionRequest(executionRequest *testkube.Execut PreRunScript: executionRequest.PreRunScript, PostRunScript: executionRequest.PostRunScript, ExecutePostRunScriptBeforeScraping: executionRequest.ExecutePostRunScriptBeforeScraping, + SourceScripts: executionRequest.SourceScripts, PvcTemplate: executionRequest.PvcTemplate, PvcTemplateReference: executionRequest.PvcTemplateReference, ScraperTemplate: executionRequest.ScraperTemplate, @@ -636,6 +637,10 @@ func MapExecutionUpdateRequestToSpecExecutionRequest(executionRequest *testkube. emptyExecution = false } + if executionRequest.SourceScripts != nil { + request.SourceScripts = *executionRequest.SourceScripts + } + if executionRequest.ArtifactRequest != nil { emptyArtifact := true if !(*executionRequest.ArtifactRequest == nil || (*executionRequest.ArtifactRequest).IsEmpty()) { diff --git a/pkg/mapper/testsuiteexecutions/mapper.go b/pkg/mapper/testsuiteexecutions/mapper.go index 006f017bba8..ca2fd4c462d 100644 --- a/pkg/mapper/testsuiteexecutions/mapper.go +++ b/pkg/mapper/testsuiteexecutions/mapper.go @@ -212,6 +212,7 @@ func MapExecutionCRD(request *testkube.Execution) *testsuiteexecutionv1.Executio PreRunScript: request.PreRunScript, PostRunScript: request.PostRunScript, ExecutePostRunScriptBeforeScraping: request.ExecutePostRunScriptBeforeScraping, + SourceScripts: request.SourceScripts, RunningContext: runningContext, ContainerShell: request.ContainerShell, SlavePodRequest: podRequest, diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 78706d95a8b..209e0493437 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -299,6 +299,7 @@ func newExecutionFromExecutionOptions(subscriptionChecker checktcl.SubscriptionC execution.PreRunScript = options.Request.PreRunScript execution.PostRunScript = options.Request.PostRunScript execution.ExecutePostRunScriptBeforeScraping = options.Request.ExecutePostRunScriptBeforeScraping + execution.SourceScripts = options.Request.SourceScripts execution.RunningContext = options.Request.RunningContext execution.TestExecutionName = options.Request.TestExecutionName execution.DownloadArtifactExecutionIDs = options.Request.DownloadArtifactExecutionIDs @@ -431,6 +432,10 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe request.ExecutePostRunScriptBeforeScraping = test.ExecutionRequest.ExecutePostRunScriptBeforeScraping } + if !request.SourceScripts && test.ExecutionRequest.SourceScripts { + request.SourceScripts = test.ExecutionRequest.SourceScripts + } + request.ArtifactRequest = mergeArtifacts(request.ArtifactRequest, test.ExecutionRequest.ArtifactRequest) if request.ArtifactRequest != nil && request.ArtifactRequest.VolumeMountPath == "" { request.ArtifactRequest.VolumeMountPath = filepath.Join(executor.VolumeDir, "artifacts") diff --git a/pkg/scheduler/test_scheduler_test.go b/pkg/scheduler/test_scheduler_test.go index dbc3bc69a4d..5146db27275 100644 --- a/pkg/scheduler/test_scheduler_test.go +++ b/pkg/scheduler/test_scheduler_test.go @@ -145,6 +145,7 @@ func TestGetExecuteOptions(t *testing.T) { PreRunScript: "", PostRunScript: "", ExecutePostRunScriptBeforeScraping: true, + SourceScripts: true, ScraperTemplate: "", ScraperTemplateReference: "", PvcTemplate: "", From dff20316aa5af68b0825af63f845d75ebbfa18f8 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 6 Mar 2024 09:24:07 +0100 Subject: [PATCH 169/234] chore: extracted FeatureFlags for cloud usage (#5105) * chore: extracted FeatureFlags for cloud usage * fix: added error handling for scraping --- cmd/api-server/main.go | 2 +- cmd/kubectl-testkube/commands/tests/common.go | 3 +++ internal/app/api/v1/executions_test.go | 2 +- internal/app/api/v1/server.go | 2 +- pkg/agent/agent.go | 2 +- pkg/agent/agent_test.go | 2 +- pkg/agent/events_test.go | 2 +- pkg/agent/logs_test.go | 2 +- pkg/executor/client/common.go | 2 +- pkg/executor/client/job.go | 2 +- pkg/executor/containerexecutor/containerexecutor.go | 2 +- pkg/executor/containerexecutor/containerexecutor_test.go | 2 +- {internal => pkg}/featureflags/featureflags.go | 0 {internal => pkg}/featureflags/featureflags_test.go | 0 pkg/repository/result/mongo.go | 2 +- pkg/scheduler/service.go | 2 +- pkg/triggers/executor_test.go | 2 +- pkg/triggers/service_test.go | 2 +- 18 files changed, 18 insertions(+), 15 deletions(-) rename {internal => pkg}/featureflags/featureflags.go (100%) rename {internal => pkg}/featureflags/featureflags_test.go (100%) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index c534936737c..ed5121ab160 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -40,8 +40,8 @@ import ( "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/internal/config" dbmigrations "github.com/kubeshop/testkube/internal/db-migrations" - "github.com/kubeshop/testkube/internal/featureflags" parser "github.com/kubeshop/testkube/internal/template" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/version" "github.com/kubeshop/testkube/pkg/cloud" diff --git a/cmd/kubectl-testkube/commands/tests/common.go b/cmd/kubectl-testkube/commands/tests/common.go index f3f12f9f764..54f731a35af 100644 --- a/cmd/kubectl-testkube/commands/tests/common.go +++ b/cmd/kubectl-testkube/commands/tests/common.go @@ -492,6 +492,9 @@ func newExecutionRequestFromFlags(cmd *cobra.Command) (request *testkube.Executi pvcTemplateReference := cmd.Flag("pvc-template-reference").Value.String() executionNamespace := cmd.Flag("execution-namespace").Value.String() executePostRunScriptBeforeScraping, err := cmd.Flags().GetBool("execute-postrun-script-before-scraping") + if err != nil { + return nil, err + } sourceScripts, err := cmd.Flags().GetBool("source-scripts") if err != nil { return nil, err diff --git a/internal/app/api/v1/executions_test.go b/internal/app/api/v1/executions_test.go index f7c2d4095e4..0d8be318ee7 100644 --- a/internal/app/api/v1/executions_test.go +++ b/internal/app/api/v1/executions_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/kubeshop/testkube/internal/featureflags" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/gofiber/fiber/v2" diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index dfecbd0141e..9b54d9b3882 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -38,7 +38,6 @@ import ( testsuitesclientv3 "github.com/kubeshop/testkube-operator/pkg/client/testsuites/v3" testkubeclientset "github.com/kubeshop/testkube-operator/pkg/clientset/versioned" "github.com/kubeshop/testkube/internal/app/api/metrics" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/event" "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/event/kind/cdevent" @@ -46,6 +45,7 @@ import ( "github.com/kubeshop/testkube/pkg/event/kind/webhook" ws "github.com/kubeshop/testkube/pkg/event/kind/websocket" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/featureflags" logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/oauth" "github.com/kubeshop/testkube/pkg/scheduler" diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 84676975829..017b3ffb30f 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -24,9 +24,9 @@ import ( "google.golang.org/grpc/metadata" "github.com/kubeshop/testkube/internal/config" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/cloud" + "github.com/kubeshop/testkube/pkg/featureflags" ) const ( diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 0dedd5ff6a1..32098a76f16 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -20,9 +20,9 @@ import ( "google.golang.org/grpc/metadata" "github.com/kubeshop/testkube/internal/config" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/cloud" + "github.com/kubeshop/testkube/pkg/featureflags" ) func TestCommandExecution(t *testing.T) { diff --git a/pkg/agent/events_test.go b/pkg/agent/events_test.go index dc2c716c5fc..dee9bf4ac15 100644 --- a/pkg/agent/events_test.go +++ b/pkg/agent/events_test.go @@ -18,10 +18,10 @@ import ( "google.golang.org/grpc/metadata" "github.com/kubeshop/testkube/internal/config" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/cloud" + "github.com/kubeshop/testkube/pkg/featureflags" ) func TestEventLoop(t *testing.T) { diff --git a/pkg/agent/logs_test.go b/pkg/agent/logs_test.go index c38b125e05e..486040dac6e 100644 --- a/pkg/agent/logs_test.go +++ b/pkg/agent/logs_test.go @@ -8,10 +8,10 @@ import ( "time" "github.com/kubeshop/testkube/internal/config" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/agent" "github.com/kubeshop/testkube/pkg/cloud" "github.com/kubeshop/testkube/pkg/executor/output" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/ui" diff --git a/pkg/executor/client/common.go b/pkg/executor/client/common.go index a88be2ff854..0aaf5a22e42 100644 --- a/pkg/executor/client/common.go +++ b/pkg/executor/client/common.go @@ -13,8 +13,8 @@ import ( executorv1 "github.com/kubeshop/testkube-operator/api/executor/v1" testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/utils" ) diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index 524fdf842c3..96b66cf9004 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -13,7 +13,7 @@ import ( "text/template" "time" - "github.com/kubeshop/testkube/internal/featureflags" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/repository/config" "github.com/pkg/errors" diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index a4e8f669256..5cd9a30b014 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -8,7 +8,7 @@ import ( "github.com/pkg/errors" - "github.com/kubeshop/testkube/internal/featureflags" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/imageinspector" "github.com/kubeshop/testkube/pkg/repository/config" "github.com/kubeshop/testkube/pkg/secret" diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 9e7226dfbf1..1569c254df7 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -17,10 +17,10 @@ import ( testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" templatesclientv1 "github.com/kubeshop/testkube-operator/pkg/client/templates/v1" v3 "github.com/kubeshop/testkube-operator/pkg/client/tests/v3" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/imageinspector" "github.com/kubeshop/testkube/pkg/repository/result" ) diff --git a/internal/featureflags/featureflags.go b/pkg/featureflags/featureflags.go similarity index 100% rename from internal/featureflags/featureflags.go rename to pkg/featureflags/featureflags.go diff --git a/internal/featureflags/featureflags_test.go b/pkg/featureflags/featureflags_test.go similarity index 100% rename from internal/featureflags/featureflags_test.go rename to pkg/featureflags/featureflags_test.go diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index 29d1b029263..398b838844b 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -14,8 +14,8 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" "go.uber.org/zap" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/log" logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/storage" diff --git a/pkg/scheduler/service.go b/pkg/scheduler/service.go index a7e4550417c..9503ca4aafc 100644 --- a/pkg/scheduler/service.go +++ b/pkg/scheduler/service.go @@ -12,10 +12,10 @@ import ( testsuiteexecutionsclientv1 "github.com/kubeshop/testkube-operator/pkg/client/testsuiteexecutions/v1" testsuitesv3 "github.com/kubeshop/testkube-operator/pkg/client/testsuites/v3" v1 "github.com/kubeshop/testkube/internal/app/api/metrics" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/configmap" "github.com/kubeshop/testkube/pkg/event" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/featureflags" logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/repository/result" "github.com/kubeshop/testkube/pkg/repository/testresult" diff --git a/pkg/triggers/executor_test.go b/pkg/triggers/executor_test.go index 907d5b39c19..6d898309050 100644 --- a/pkg/triggers/executor_test.go +++ b/pkg/triggers/executor_test.go @@ -17,12 +17,12 @@ import ( testsuiteexecutionsv1 "github.com/kubeshop/testkube-operator/pkg/client/testsuiteexecutions/v1" testsuitesv3 "github.com/kubeshop/testkube-operator/pkg/client/testsuites/v3" "github.com/kubeshop/testkube/internal/app/api/metrics" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/configmap" "github.com/kubeshop/testkube/pkg/event" "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/log" logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/repository/config" diff --git a/pkg/triggers/service_test.go b/pkg/triggers/service_test.go index 290beb0ce5b..c30737de587 100644 --- a/pkg/triggers/service_test.go +++ b/pkg/triggers/service_test.go @@ -21,12 +21,12 @@ import ( testsuitesv3 "github.com/kubeshop/testkube-operator/pkg/client/testsuites/v3" faketestkube "github.com/kubeshop/testkube-operator/pkg/clientset/versioned/fake" "github.com/kubeshop/testkube/internal/app/api/metrics" - "github.com/kubeshop/testkube/internal/featureflags" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/configmap" "github.com/kubeshop/testkube/pkg/event" "github.com/kubeshop/testkube/pkg/event/bus" "github.com/kubeshop/testkube/pkg/executor/client" + "github.com/kubeshop/testkube/pkg/featureflags" "github.com/kubeshop/testkube/pkg/log" logsclient "github.com/kubeshop/testkube/pkg/logs/client" "github.com/kubeshop/testkube/pkg/repository/config" From cff2899d18e65ec00747860f8f50d0bdc677faff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederik=20B=C3=BClthoff?= <232148+frederikb@users.noreply.github.com> Date: Tue, 5 Mar 2024 18:36:59 +0100 Subject: [PATCH 170/234] fix: misleading error messages for minio --- pkg/storage/minio/minio_connecter.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/storage/minio/minio_connecter.go b/pkg/storage/minio/minio_connecter.go index 309c375b715..1a2df483e12 100644 --- a/pkg/storage/minio/minio_connecter.go +++ b/pkg/storage/minio/minio_connecter.go @@ -34,11 +34,11 @@ func RootCAs(file ...string) Option { for _, f := range file { rootPEM, err := os.ReadFile(f) if err != nil || rootPEM == nil { - return fmt.Errorf("nats: error loading or parsing rootCA file: %v", err) + return fmt.Errorf("minio: error loading or parsing rootCA file: %v", err) } ok := pool.AppendCertsFromPEM(rootPEM) if !ok { - return fmt.Errorf("nats: failed to parse root certificate from %q", f) + return fmt.Errorf("minio: failed to parse root certificate from %q", f) } } if o.TlsConfig == nil { @@ -56,11 +56,11 @@ func ClientCert(certFile, keyFile string) Option { return func(o *Connecter) error { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { - return fmt.Errorf("nats: error loading client certificate: %v", err) + return fmt.Errorf("minio: error loading client certificate: %v", err) } cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) if err != nil { - return fmt.Errorf("nats: error parsing client certificate: %v", err) + return fmt.Errorf("minio: error parsing client certificate: %v", err) } if o.TlsConfig == nil { o.TlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} From 9b1ceebbc15fe0c780fcd2d1a733ae6c3becbd58 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Wed, 6 Mar 2024 13:32:37 +0100 Subject: [PATCH 171/234] feat: [TKC-1579] test suite step license change (#5108) * feat: test suite step license change * fix: go mod tidy --- go.mod | 2 +- go.sum | 4 +- internal/app/api/v1/testsuites.go | 99 ++++++++++++++++-------- pkg/mapper/testsuites/kube_openapi.go | 56 +++++++++++++- pkg/mapper/testsuites/openapi_kube.go | 49 +++++++++++- pkg/scheduler/testsuite_scheduler.go | 59 +++++++++++++- pkg/tcl/testsuitestcl/steps.go | 96 ----------------------- pkg/tcl/testsuitestcl/steps_test.go | 106 -------------------------- 8 files changed, 225 insertions(+), 246 deletions(-) delete mode 100644 pkg/tcl/testsuitestcl/steps_test.go diff --git a/go.mod b/go.mod index 29ecd38e95a..2aca9ffdf46 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240304172632-b7aaa27ca4d3 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306113133-d195552b7f0f github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 1bc912f2717..2149ba1f361 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240304172632-b7aaa27ca4d3 h1:f+CEysr8ya3s7p059aNKlyfEqzfAgVsBDHK/PSraF6Y= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240304172632-b7aaa27ca4d3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306113133-d195552b7f0f h1:BnpXUw85Rfe/MRrQ9YgzJX8N0amHCmohIO8HOLAJOl8= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306113133-d195552b7f0f/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/internal/app/api/v1/testsuites.go b/internal/app/api/v1/testsuites.go index adccbf86660..2747b2c34c7 100644 --- a/internal/app/api/v1/testsuites.go +++ b/internal/app/api/v1/testsuites.go @@ -27,7 +27,6 @@ import ( testsuitesmapper "github.com/kubeshop/testkube/pkg/mapper/testsuites" "github.com/kubeshop/testkube/pkg/repository/testresult" "github.com/kubeshop/testkube/pkg/scheduler" - "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" "github.com/kubeshop/testkube/pkg/types" "github.com/kubeshop/testkube/pkg/utils" "github.com/kubeshop/testkube/pkg/workerpool" @@ -44,16 +43,6 @@ func (s TestkubeAPI) CreateTestSuiteHandler() fiber.Handler { if err := decoder.Decode(&testSuite); err != nil { return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse yaml request: %w", errPrefix, err)) } - // Pro/Enterprise feature: step execution requests - if testsuitestcl.HasStepsExecutionRequest(testSuite) { - ok, err := s.SubscriptionChecker.IsOrgPlanActive() - if err != nil { - return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are a Pro feature: %w", errPrefix, err)) - } - if !ok { - return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are not available: inactive subscription plan", errPrefix)) - } - } errPrefix = errPrefix + " " + testSuite.Name } else { var request testkube.TestSuiteUpsertRequest @@ -126,16 +115,6 @@ func (s TestkubeAPI) UpdateTestSuiteHandler() fiber.Handler { if err := decoder.Decode(&testSuite); err != nil { return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: could not parse yaml request: %w", errPrefix, err)) } - // Pro/Enterprise feature: step execution requests - if testsuitestcl.HasStepsExecutionRequest(testSuite) { - ok, err := s.SubscriptionChecker.IsOrgPlanActive() - if err != nil { - return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are a Pro feature: %w", errPrefix, err)) - } - if !ok { - return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are not available: inactive subscription plan", errPrefix)) - } - } request = testsuitesmapper.MapTestSuiteTestCRDToUpdateRequest(&testSuite) } else { data := c.Body() @@ -583,16 +562,6 @@ func (s TestkubeAPI) ExecuteTestSuitesHandler() fiber.Handler { return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client could get test suite: %w", errPrefix, err)) } - // Pro/Enterprise feature: step execution requests - if testsuitestcl.HasStepsExecutionRequest(*testSuite) { - ok, err := s.SubscriptionChecker.IsOrgPlanActive() - if err != nil { - return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are a pro feature: %w", errPrefix, err)) - } - if !ok { - return s.Error(c, http.StatusForbidden, fmt.Errorf("%s: test suite step execution requests are not available: inactive subscription plan", errPrefix)) - } - } testSuites = append(testSuites, *testSuite) } else { testSuiteList, err := s.TestsSuitesClient.List(selector) @@ -903,3 +872,71 @@ func getExecutionsFilterFromRequest(c *fiber.Ctx) testresult.Filter { return filter } + +// MergeStepRequest inherits step request fields with execution request +func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, executionRequest testkube.ExecutionRequest) testkube.ExecutionRequest { + if stepRequest == nil { + return executionRequest + } + if stepRequest.ExecutionLabels != nil { + executionRequest.ExecutionLabels = stepRequest.ExecutionLabels + } + + if stepRequest.Variables != nil { + executionRequest.Variables = mergeVariables(executionRequest.Variables, stepRequest.Variables) + } + + if len(stepRequest.Args) != 0 { + if stepRequest.ArgsMode == string(testkube.ArgsModeTypeAppend) || stepRequest.ArgsMode == "" { + executionRequest.Args = append(executionRequest.Args, stepRequest.Args...) + } + + if stepRequest.ArgsMode == string(testkube.ArgsModeTypeOverride) || stepRequest.ArgsMode == string(testkube.ArgsModeTypeReplace) { + executionRequest.Args = stepRequest.Args + } + } + + if stepRequest.Command != nil { + executionRequest.Command = stepRequest.Command + } + executionRequest.Sync = stepRequest.Sync + executionRequest.HttpProxy = setStringField(executionRequest.HttpProxy, stepRequest.HttpProxy) + executionRequest.HttpsProxy = setStringField(executionRequest.HttpsProxy, stepRequest.HttpsProxy) + executionRequest.CronJobTemplate = setStringField(executionRequest.CronJobTemplate, stepRequest.CronJobTemplate) + executionRequest.CronJobTemplateReference = setStringField(executionRequest.CronJobTemplateReference, stepRequest.CronJobTemplateReference) + executionRequest.JobTemplate = setStringField(executionRequest.JobTemplate, stepRequest.JobTemplate) + executionRequest.JobTemplateReference = setStringField(executionRequest.JobTemplateReference, stepRequest.JobTemplateReference) + executionRequest.ScraperTemplate = setStringField(executionRequest.ScraperTemplate, stepRequest.ScraperTemplate) + executionRequest.ScraperTemplateReference = setStringField(executionRequest.ScraperTemplateReference, stepRequest.ScraperTemplateReference) + executionRequest.PvcTemplate = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplate) + executionRequest.PvcTemplateReference = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplateReference) + + if stepRequest.RunningContext != nil { + executionRequest.RunningContext = &testkube.RunningContext{ + Type_: string(stepRequest.RunningContext.Type_), + Context: stepRequest.RunningContext.Context, + } + } + + return executionRequest +} + +func setStringField(oldValue string, newValue string) string { + if newValue != "" { + return newValue + } + return oldValue +} + +func mergeVariables(vars1 map[string]testkube.Variable, vars2 map[string]testkube.Variable) map[string]testkube.Variable { + variables := map[string]testkube.Variable{} + for k, v := range vars1 { + variables[k] = v + } + + for k, v := range vars2 { + variables[k] = v + } + + return variables +} diff --git a/pkg/mapper/testsuites/kube_openapi.go b/pkg/mapper/testsuites/kube_openapi.go index 1a4da943068..4f384847350 100644 --- a/pkg/mapper/testsuites/kube_openapi.go +++ b/pkg/mapper/testsuites/kube_openapi.go @@ -6,7 +6,6 @@ import ( commonv1 "github.com/kubeshop/testkube-operator/api/common/v1" testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" ) // MapTestSuiteListKubeToAPI maps TestSuiteList CRD to list of OpenAPI spec TestSuite @@ -81,9 +80,8 @@ func mapCRStepToAPI(crstep testsuitesv3.TestSuiteStepSpec) (teststep testkube.Te switch true { case crstep.Test != "": teststep = testkube.TestSuiteStep{ - Test: crstep.Test, - // Pro/Enterprise feature: step execution requests - ExecutionRequest: testsuitestcl.MapTestStepExecutionRequestCRDToAPI(crstep.ExecutionRequest), + Test: crstep.Test, + ExecutionRequest: MapTestStepExecutionRequestCRDToAPI(crstep.ExecutionRequest), } case crstep.Delay.Duration != 0: @@ -358,3 +356,53 @@ func MapSpecExecutionRequestToExecutionUpdateRequest(request *testsuitesv3.TestS return executionRequest } + +func MapTestStepExecutionRequestCRDToAPI(request *testsuitesv3.TestSuiteStepExecutionRequest) *testkube.TestSuiteStepExecutionRequest { + if request == nil { + return nil + } + variables := map[string]testkube.Variable{} + for k, v := range request.Variables { + varType := testkube.VariableType(v.Type_) + variables[k] = testkube.Variable{ + Name: v.Name, + Value: v.Value, + Type_: &varType, + } + } + + var runningContext *testkube.RunningContext + + if request.RunningContext != nil { + runningContext = &testkube.RunningContext{ + Type_: string(request.RunningContext.Type_), + Context: request.RunningContext.Context, + } + } + + argsMode := "" + if request.ArgsMode != "" { + argsMode = string(request.ArgsMode) + } + + return &testkube.TestSuiteStepExecutionRequest{ + ExecutionLabels: request.ExecutionLabels, + Variables: variables, + Command: request.Command, + Args: request.Args, + ArgsMode: argsMode, + Sync: request.Sync, + HttpProxy: request.HttpProxy, + HttpsProxy: request.HttpsProxy, + NegativeTest: request.NegativeTest, + JobTemplate: request.JobTemplate, + JobTemplateReference: request.JobTemplateReference, + CronJobTemplate: request.CronJobTemplate, + CronJobTemplateReference: request.CronJobTemplateReference, + ScraperTemplate: request.ScraperTemplate, + ScraperTemplateReference: request.ScraperTemplateReference, + PvcTemplate: request.PvcTemplate, + PvcTemplateReference: request.PvcTemplateReference, + RunningContext: runningContext, + } +} diff --git a/pkg/mapper/testsuites/openapi_kube.go b/pkg/mapper/testsuites/openapi_kube.go index 406a942184f..4132768594b 100644 --- a/pkg/mapper/testsuites/openapi_kube.go +++ b/pkg/mapper/testsuites/openapi_kube.go @@ -5,9 +5,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "github.com/kubeshop/testkube-operator/api/common/v1" testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" "github.com/kubeshop/testkube/pkg/types" ) @@ -199,8 +199,7 @@ func mapTestStepToCRD(step testkube.TestSuiteStep) (stepSpec testsuitesv3.TestSu } case testkube.TestSuiteStepTypeExecuteTest: stepSpec.Test = step.Test - // Pro/Enterprise feature: step execution requests - stepSpec.ExecutionRequest = testsuitestcl.MapTestStepExecutionRequestCRD(step.ExecutionRequest) + stepSpec.ExecutionRequest = MapTestStepExecutionRequestCRD(step.ExecutionRequest) } return stepSpec, nil @@ -440,3 +439,47 @@ func MapExecutionToTestSuiteStatus(execution *testkube.TestSuiteExecution) (spec return specStatus } + +func MapTestStepExecutionRequestCRD(request *testkube.TestSuiteStepExecutionRequest) *testsuitesv3.TestSuiteStepExecutionRequest { + if request == nil { + return nil + } + + variables := map[string]testsuitesv3.Variable{} + for k, v := range request.Variables { + variables[k] = testsuitesv3.Variable{ + Name: v.Name, + Value: v.Value, + Type_: string(*v.Type_), + } + } + + var runningContext *v1.RunningContext + if request.RunningContext != nil { + runningContext = &v1.RunningContext{ + Type_: v1.RunningContextType(request.RunningContext.Type_), + Context: request.RunningContext.Context, + } + } + + return &testsuitesv3.TestSuiteStepExecutionRequest{ + ExecutionLabels: request.ExecutionLabels, + Variables: variables, + Args: request.Args, + ArgsMode: testsuitesv3.ArgsModeType(request.ArgsMode), + Command: request.Command, + Sync: request.Sync, + HttpProxy: request.HttpProxy, + HttpsProxy: request.HttpsProxy, + NegativeTest: request.NegativeTest, + JobTemplate: request.JobTemplate, + JobTemplateReference: request.JobTemplateReference, + CronJobTemplate: request.CronJobTemplate, + CronJobTemplateReference: request.CronJobTemplateReference, + ScraperTemplate: request.ScraperTemplate, + ScraperTemplateReference: request.ScraperTemplateReference, + PvcTemplate: request.PvcTemplate, + PvcTemplateReference: request.PvcTemplateReference, + RunningContext: runningContext, + } +} diff --git a/pkg/scheduler/testsuite_scheduler.go b/pkg/scheduler/testsuite_scheduler.go index 8e12e92545a..2699adc15da 100644 --- a/pkg/scheduler/testsuite_scheduler.go +++ b/pkg/scheduler/testsuite_scheduler.go @@ -14,7 +14,6 @@ import ( "github.com/kubeshop/testkube/pkg/event/bus" testsuiteexecutionsmapper "github.com/kubeshop/testkube/pkg/mapper/testsuiteexecutions" testsuitesmapper "github.com/kubeshop/testkube/pkg/mapper/testsuites" - "github.com/kubeshop/testkube/pkg/tcl/testsuitestcl" "github.com/kubeshop/testkube/pkg/telemetry" "github.com/kubeshop/testkube/pkg/version" @@ -510,8 +509,7 @@ func (s *Scheduler) executeTestStep(ctx context.Context, testsuiteExecution test for i := range testTuples { req.Name = fmt.Sprintf("%s-%s", testSuiteName, testTuples[i].test.Name) req.Id = testTuples[i].executionID - // Pro/Enterprise feature: step execution requests - req = testsuitestcl.MergeStepRequest(testTuples[i].stepRequest, req) + req = MergeStepRequest(testTuples[i].stepRequest, req) requests[i] = workerpool.Request[testkube.Test, testkube.ExecutionRequest, testkube.Execution]{ Object: testTuples[i].test, Options: req, @@ -625,3 +623,58 @@ func (s *Scheduler) delayWithAbortionCheck(duration time.Duration, testSuiteId s } } } + +// MergeStepRequest inherits step request fields with execution request +func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, executionRequest testkube.ExecutionRequest) testkube.ExecutionRequest { + if stepRequest == nil { + return executionRequest + } + if stepRequest.ExecutionLabels != nil { + executionRequest.ExecutionLabels = stepRequest.ExecutionLabels + } + + if stepRequest.Variables != nil { + executionRequest.Variables = mergeVariables(executionRequest.Variables, stepRequest.Variables) + } + + if len(stepRequest.Args) != 0 { + if stepRequest.ArgsMode == string(testkube.ArgsModeTypeAppend) || stepRequest.ArgsMode == "" { + executionRequest.Args = append(executionRequest.Args, stepRequest.Args...) + } + + if stepRequest.ArgsMode == string(testkube.ArgsModeTypeOverride) || stepRequest.ArgsMode == string(testkube.ArgsModeTypeReplace) { + executionRequest.Args = stepRequest.Args + } + } + + if stepRequest.Command != nil { + executionRequest.Command = stepRequest.Command + } + executionRequest.Sync = stepRequest.Sync + executionRequest.HttpProxy = setStringField(executionRequest.HttpProxy, stepRequest.HttpProxy) + executionRequest.HttpsProxy = setStringField(executionRequest.HttpsProxy, stepRequest.HttpsProxy) + executionRequest.CronJobTemplate = setStringField(executionRequest.CronJobTemplate, stepRequest.CronJobTemplate) + executionRequest.CronJobTemplateReference = setStringField(executionRequest.CronJobTemplateReference, stepRequest.CronJobTemplateReference) + executionRequest.JobTemplate = setStringField(executionRequest.JobTemplate, stepRequest.JobTemplate) + executionRequest.JobTemplateReference = setStringField(executionRequest.JobTemplateReference, stepRequest.JobTemplateReference) + executionRequest.ScraperTemplate = setStringField(executionRequest.ScraperTemplate, stepRequest.ScraperTemplate) + executionRequest.ScraperTemplateReference = setStringField(executionRequest.ScraperTemplateReference, stepRequest.ScraperTemplateReference) + executionRequest.PvcTemplate = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplate) + executionRequest.PvcTemplateReference = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplateReference) + + if stepRequest.RunningContext != nil { + executionRequest.RunningContext = &testkube.RunningContext{ + Type_: string(stepRequest.RunningContext.Type_), + Context: stepRequest.RunningContext.Context, + } + } + + return executionRequest +} + +func setStringField(oldValue string, newValue string) string { + if newValue != "" { + return newValue + } + return oldValue +} diff --git a/pkg/tcl/testsuitestcl/steps.go b/pkg/tcl/testsuitestcl/steps.go index e027ebdd9a6..5ae19760c5c 100644 --- a/pkg/tcl/testsuitestcl/steps.go +++ b/pkg/tcl/testsuitestcl/steps.go @@ -9,9 +9,7 @@ package testsuitestcl import ( - v1 "github.com/kubeshop/testkube-operator/api/common/v1" testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" - testsuitestclop "github.com/kubeshop/testkube-operator/pkg/tcl/testsuitestcl" "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) @@ -109,97 +107,3 @@ func mergeVariables(vars1 map[string]testkube.Variable, vars2 map[string]testkub return variables } - -func MapTestStepExecutionRequestCRD(request *testkube.TestSuiteStepExecutionRequest) *testsuitestclop.TestSuiteStepExecutionRequest { - if request == nil { - return nil - } - - variables := map[string]testsuitestclop.Variable{} - for k, v := range request.Variables { - variables[k] = testsuitestclop.Variable{ - Name: v.Name, - Value: v.Value, - Type_: string(*v.Type_), - } - } - - var runningContext *v1.RunningContext - if request.RunningContext != nil { - runningContext = &v1.RunningContext{ - Type_: v1.RunningContextType(request.RunningContext.Type_), - Context: request.RunningContext.Context, - } - } - - return &testsuitestclop.TestSuiteStepExecutionRequest{ - ExecutionLabels: request.ExecutionLabels, - Variables: variables, - Args: request.Args, - ArgsMode: testsuitestclop.ArgsModeType(request.ArgsMode), - Command: request.Command, - Sync: request.Sync, - HttpProxy: request.HttpProxy, - HttpsProxy: request.HttpsProxy, - NegativeTest: request.NegativeTest, - JobTemplate: request.JobTemplate, - JobTemplateReference: request.JobTemplateReference, - CronJobTemplate: request.CronJobTemplate, - CronJobTemplateReference: request.CronJobTemplateReference, - ScraperTemplate: request.ScraperTemplate, - ScraperTemplateReference: request.ScraperTemplateReference, - PvcTemplate: request.PvcTemplate, - PvcTemplateReference: request.PvcTemplateReference, - RunningContext: runningContext, - } -} - -func MapTestStepExecutionRequestCRDToAPI(request *testsuitestclop.TestSuiteStepExecutionRequest) *testkube.TestSuiteStepExecutionRequest { - if request == nil { - return nil - } - variables := map[string]testkube.Variable{} - for k, v := range request.Variables { - varType := testkube.VariableType(v.Type_) - variables[k] = testkube.Variable{ - Name: v.Name, - Value: v.Value, - Type_: &varType, - } - } - - var runningContext *testkube.RunningContext - - if request.RunningContext != nil { - runningContext = &testkube.RunningContext{ - Type_: string(request.RunningContext.Type_), - Context: request.RunningContext.Context, - } - } - - argsMode := "" - if request.ArgsMode != "" { - argsMode = string(request.ArgsMode) - } - - return &testkube.TestSuiteStepExecutionRequest{ - ExecutionLabels: request.ExecutionLabels, - Variables: variables, - Command: request.Command, - Args: request.Args, - ArgsMode: argsMode, - Sync: request.Sync, - HttpProxy: request.HttpProxy, - HttpsProxy: request.HttpsProxy, - NegativeTest: request.NegativeTest, - JobTemplate: request.JobTemplate, - JobTemplateReference: request.JobTemplateReference, - CronJobTemplate: request.CronJobTemplate, - CronJobTemplateReference: request.CronJobTemplateReference, - ScraperTemplate: request.ScraperTemplate, - ScraperTemplateReference: request.ScraperTemplateReference, - PvcTemplate: request.PvcTemplate, - PvcTemplateReference: request.PvcTemplateReference, - RunningContext: runningContext, - } -} diff --git a/pkg/tcl/testsuitestcl/steps_test.go b/pkg/tcl/testsuitestcl/steps_test.go deleted file mode 100644 index 03fe0727b8f..00000000000 --- a/pkg/tcl/testsuitestcl/steps_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2024 Kubeshop. -// -// Licensed as a Testkube Pro file under the Testkube Community -// License (the "License"); you may not use this file except in compliance with -// the License. You may obtain a copy of the License at -// -// https://github.com/kubeshop/testkube/blob/master/licenses/TCL.txt - -package testsuitestcl - -import ( - "testing" - - testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" - testsuitestclop "github.com/kubeshop/testkube-operator/pkg/tcl/testsuitestcl" -) - -func TestHasStepsExecutionRequest(t *testing.T) { - tests := []struct { - name string - testSuite testsuitesv3.TestSuite - want bool - }{ - { - name: "TestSuiteSpec with steps execution request in before", - testSuite: testsuitesv3.TestSuite{ - Spec: testsuitesv3.TestSuiteSpec{ - Before: []testsuitesv3.TestSuiteBatchStep{ - { - Execute: []testsuitesv3.TestSuiteStepSpec{ - { - ExecutionRequest: &testsuitestclop.TestSuiteStepExecutionRequest{ - Args: []string{"arg1", "arg2"}, - }, - }, - }, - }, - }, - }, - }, - want: true, - }, - { - name: "TestSuiteSpec with steps execution request in steps", - testSuite: testsuitesv3.TestSuite{ - Spec: testsuitesv3.TestSuiteSpec{ - Steps: []testsuitesv3.TestSuiteBatchStep{ - { - Execute: []testsuitesv3.TestSuiteStepSpec{ - { - ExecutionRequest: &testsuitestclop.TestSuiteStepExecutionRequest{ - Args: []string{"arg1", "arg2"}, - }, - }, - }, - }, - }, - }, - }, - want: true, - }, - { - name: "TestSuiteSpec with steps execution request in after", - testSuite: testsuitesv3.TestSuite{ - Spec: testsuitesv3.TestSuiteSpec{ - After: []testsuitesv3.TestSuiteBatchStep{ - { - Execute: []testsuitesv3.TestSuiteStepSpec{ - { - ExecutionRequest: &testsuitestclop.TestSuiteStepExecutionRequest{ - Args: []string{"arg1", "arg2"}, - }, - }, - }, - }, - }, - }, - }, - want: true, - }, - { - name: "TestSuiteSpec with no steps execution request", - testSuite: testsuitesv3.TestSuite{ - Spec: testsuitesv3.TestSuiteSpec{ - Before: []testsuitesv3.TestSuiteBatchStep{ - { - Execute: []testsuitesv3.TestSuiteStepSpec{ - { - Test: "test", - }, - }, - }, - }, - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := HasStepsExecutionRequest(tt.testSuite); got != tt.want { - t.Errorf("HasStepsExecutionRequest() = %v, want %v", got, tt.want) - } - }) - } -} From acf01265ddea6dd47e0a225cb870e325eab6dc49 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 6 Mar 2024 16:00:41 +0100 Subject: [PATCH 172/234] feat(TKC-1651): add mechanisms for controlling TestWorkflows orchestration (#5109) * feat(TKC-1651): add mechanism for distributed watcher - the tests are failing in 0,002% cases, but such failing behavior should not fail in the real application * feat(TKC-1651): TestWorkflow processor adjustments * feat(TKC-1651): generic expressions adjustments * fix: adjustments for the TestWorkflow processor and init script * fix(TKC-1651): fix race conditions and stale processes in watchers * feat(TKC-1651): add category (name fallback) for stages * feat(TKC-1651): add utilities to clean up, watch logs and events of the TestWorkflow resources * feat(TKC-1651): prepare controller for the TestWorkflow resources - add TestWorkflow's execution/result model - clean up resources - replace instruction characters to less common * feat(TKC-1651): add "run testworkflow" command to - add SSE endpoint for the TestWorkflow notifications - use it in the CLI * fix(TKC-1651): do not retry commands by default * chore: use defaultDataPath instead of literal /data for default working dir for relative volumes --- api/v1/testkube.yaml | 180 +++++++- .../commands/common/render/obj.go | 6 +- cmd/kubectl-testkube/commands/run.go | 4 +- .../renderer/testworkflowexecution_obj.go | 43 ++ .../commands/testworkflows/run.go | 189 ++++++++ cmd/tcl/testworkflow-init/data/config.go | 2 +- cmd/tcl/testworkflow-init/data/emit.go | 62 ++- cmd/tcl/testworkflow-init/data/types.go | 8 +- pkg/api/v1/client/api.go | 51 ++- pkg/api/v1/client/common.go | 32 ++ pkg/api/v1/client/direct_client.go | 24 + pkg/api/v1/client/interface.go | 7 +- pkg/api/v1/client/proxy_client.go | 21 + pkg/api/v1/client/testworkflow.go | 31 +- .../testkube/model_test_workflow_execution.go | 34 ++ ...el_test_workflow_execution_notification.go | 25 + .../v1/testkube/model_test_workflow_output.go | 19 + .../v1/testkube/model_test_workflow_result.go | 27 ++ .../model_test_workflow_result_extended.go | 204 +++++++++ .../testkube/model_test_workflow_signature.go | 24 + .../v1/testkube/model_test_workflow_status.go | 21 + .../model_test_workflow_step_result.go | 26 ++ ...odel_test_workflow_step_result_extended.go | 36 ++ .../model_test_workflow_step_status.go | 23 + pkg/tcl/apitcl/v1/server.go | 5 +- pkg/tcl/apitcl/v1/testworkflowexecutions.go | 61 +++ pkg/tcl/apitcl/v1/testworkflows.go | 81 +++- pkg/tcl/expressionstcl/generic.go | 24 +- .../testworkflowcontroller/cleanup.go | 64 +++ .../testworkflowcontroller/controller.go | 359 +++++++++++++++ .../testworkflowcontroller/logs.go | 302 ++++++++++++ .../testworkflowcontroller/notification.go | 33 ++ .../testworkflowcontroller/utils.go | 430 ++++++++++++++++++ .../testworkflowcontroller/watcher.go | 410 +++++++++++++++++ .../testworkflowcontroller/watcher_test.go | 155 +++++++ .../testworkflowprocessor/constants.go | 12 +- .../testworkflowprocessor/container.go | 35 +- .../testworkflowprocessor/containerstage.go | 1 + .../testworkflowprocessor/groupstage.go | 5 +- .../testworkflowprocessor/mock_container.go | 5 +- .../testworkflowprocessor/mock_stage.go | 41 +- .../testworkflowprocessor/operations.go | 10 +- .../testworkflowprocessor/processor.go | 29 +- .../testworkflowprocessor/processor_test.go | 50 +- .../testworkflowprocessor/signature.go | 86 ++++ .../testworkflowprocessor/stagemetadata.go | 23 +- .../testworkflowprocessor/utils.go | 12 +- .../testworkflowresolver/merge.go | 22 + 48 files changed, 3223 insertions(+), 131 deletions(-) create mode 100644 cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go create mode 100644 cmd/kubectl-testkube/commands/testworkflows/run.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_execution.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_execution_notification.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_output.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_result.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_result_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_signature.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_status.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_result.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_result_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_status.go create mode 100644 pkg/tcl/apitcl/v1/testworkflowexecutions.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/cleanup.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/notification.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher_test.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index b701e5232df..da75c911449 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3437,24 +3437,21 @@ paths: description: Execute test workflow in the kubernetes cluster operationId: executeTestWorkflow requestBody: - description: test workflow configuration + description: test workflow execution request required: true content: application/json: schema: - $ref: "#/components/schemas/TestWorkflow" + $ref: "#/components/schemas/TestWorkflowExecutionRequest" responses: 200: - description: successful creation + description: successful execution content: application/json: schema: type: array items: - $ref: "#/components/schemas/TestWorkflow" - text/yaml: - schema: - type: string + $ref: "#/components/schemas/TestWorkflowExecution" 400: description: "problem with body parsing - probably some bad input occurs" content: @@ -6865,6 +6862,175 @@ components: config: $ref: "#/components/schemas/TestWorkflowConfigValue" + TestWorkflowExecution: + type: object + properties: + id: + type: string + description: unique execution identifier + format: bson objectId + example: "62f395e004109209b50edfc1" + name: + type: string + description: execution name + example: "some-workflow-name-1" + number: + type: integer + description: sequence number for the execution + scheduledAt: + type: string + format: date-time + description: when the execution has been scheduled to run + statusAt: + type: string + format: date-time + description: when the execution result's status has changed last time (queued, passed, failed) + signature: + type: array + description: structured tree of steps + items: + $ref: "#/components/schemas/TestWorkflowSignature" + result: + $ref: "#/components/schemas/TestWorkflowResult" + output: + type: array + description: additional information from the steps, like referenced executed tests or artifacts + items: + $ref: "#/components/schemas/TestWorkflowOutput" + workflow: + $ref: "#/components/schemas/TestWorkflow" + resolvedWorkflow: + $ref: "#/components/schemas/TestWorkflow" + required: + - id + - name + - workflow + + TestWorkflowExecutionNotification: + type: object + properties: + ts: + type: string + format: date-time + description: timestamp for the notification if available + result: + $ref: "#/components/schemas/TestWorkflowResult" + ref: + type: string + description: step reference, if related to some specific step + log: + type: string + description: log content, if it's just a log. note, that it includes 30 chars timestamp + space + output: + $ref: "#/components/schemas/TestWorkflowOutput" + + TestWorkflowOutput: + type: object + properties: + ref: + type: string + description: step reference + name: + type: string + description: output kind name + value: + type: object + description: value returned + + TestWorkflowResult: + type: object + properties: + status: + $ref: "#/components/schemas/TestWorkflowStatus" + predictedStatus: + $ref: "#/components/schemas/TestWorkflowStatus" + queuedAt: + type: string + format: date-time + description: when the pod was created + startedAt: + type: string + format: date-time + description: when the pod has been successfully assigned + finishedAt: + type: string + format: date-time + description: when the pod has been completed + initialization: + $ref: "#/components/schemas/TestWorkflowStepResult" + steps: + type: object + additionalProperties: + $ref: "#/components/schemas/TestWorkflowStepResult" + required: + - status + - predictedStatus + + TestWorkflowStepResult: + type: object + properties: + errorMessage: + type: string + status: + $ref: "#/components/schemas/TestWorkflowStepStatus" + exitCode: + type: number + queuedAt: + type: string + format: date-time + description: when the container was created + startedAt: + type: string + format: date-time + description: when the container was started + finishedAt: + type: string + format: date-time + description: when the container was finished + + TestWorkflowSignature: + type: object + properties: + ref: + type: string + description: step reference + name: + type: string + description: step name + category: + type: string + description: step category, that may be used as name fallback + optional: + type: boolean + description: is the step/group meant to be optional + negative: + type: boolean + description: is the step/group meant to be negative + children: + type: array + items: + $ref: "#/components/schemas/TestWorkflowSignature" + + TestWorkflowStatus: + type: string + enum: + - queued + - running + - passed + - failed + - aborted + + TestWorkflowStepStatus: + type: string + enum: + - queued + - running + - passed + - failed + - timeout + - skipped + - aborted + TestWorkflowTemplate: type: object properties: diff --git a/cmd/kubectl-testkube/commands/common/render/obj.go b/cmd/kubectl-testkube/commands/common/render/obj.go index 4cc6093d60e..110ad14e826 100644 --- a/cmd/kubectl-testkube/commands/common/render/obj.go +++ b/cmd/kubectl-testkube/commands/common/render/obj.go @@ -10,7 +10,11 @@ import ( ) func Obj(cmd *cobra.Command, obj interface{}, w io.Writer, renderer ...CliObjRenderer) error { - outputType := OutputType(cmd.Flag("output").Value.String()) + outputFlag := cmd.Flag("output") + outputType := OutputPretty + if outputFlag != nil { + outputType = OutputType(outputFlag.Value.String()) + } switch outputType { case OutputPretty: diff --git a/cmd/kubectl-testkube/commands/run.go b/cmd/kubectl-testkube/commands/run.go index 5321340b922..ac921fc6055 100644 --- a/cmd/kubectl-testkube/commands/run.go +++ b/cmd/kubectl-testkube/commands/run.go @@ -7,6 +7,7 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/validator" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/tests" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsuites" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" "github.com/kubeshop/testkube/pkg/ui" ) @@ -15,7 +16,7 @@ func NewRunCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run ", Aliases: []string{"r", "start"}, - Short: "Runs tests or test suites", + Short: "Runs tests, test suites or test workflows", Annotations: map[string]string{cmdGroupAnnotation: cmdGroupCommands}, Run: func(cmd *cobra.Command, args []string) { err := cmd.Help() @@ -31,6 +32,7 @@ func NewRunCmd() *cobra.Command { cmd.AddCommand(tests.NewRunTestCmd()) cmd.AddCommand(testsuites.NewRunTestSuiteCmd()) + cmd.AddCommand(testworkflows.NewRunTestWorkflowCmd()) return cmd } diff --git a/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go b/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go new file mode 100644 index 00000000000..79e7e12f5ce --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go @@ -0,0 +1,43 @@ +package renderer + +import ( + "fmt" + + "github.com/kubeshop/testkube/pkg/api/v1/client" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/ui" +) + +func TestWorkflowExecutionRenderer(client client.Client, ui *ui.UI, obj interface{}) error { + execution, ok := obj.(testkube.TestWorkflowExecution) + if !ok { + return fmt.Errorf("can't use '%T' as testkube.TestWorkflowExecution in RenderObj for test workflow execution", obj) + } + + ui.Info("Test Workflow Execution:") + ui.Warn("Name: ", execution.Workflow.Name) + if execution.Id != "" { + ui.Warn("Execution ID: ", execution.Id) + ui.Warn("Execution name: ", execution.Name) + if execution.Number != 0 { + ui.Warn("Execution number: ", fmt.Sprintf("%d", execution.Number)) + } + ui.Warn("Requested at: ", execution.ScheduledAt.String()) + if execution.Result != nil && execution.Result.Status != nil { + ui.Warn("Status: ", string(*execution.Result.Status)) + if !execution.Result.QueuedAt.IsZero() { + ui.Warn("Queued at: ", execution.Result.QueuedAt.String()) + } + if !execution.Result.StartedAt.IsZero() { + ui.Warn("Started at: ", execution.Result.StartedAt.String()) + } + if !execution.Result.FinishedAt.IsZero() { + ui.Warn("Finished at: ", execution.Result.FinishedAt.String()) + ui.Warn("Duration: ", execution.Result.FinishedAt.Sub(execution.Result.QueuedAt).String()) + } + } + } + + return nil + +} diff --git a/cmd/kubectl-testkube/commands/testworkflows/run.go b/cmd/kubectl-testkube/commands/testworkflows/run.go new file mode 100644 index 00000000000..1d447e80b3b --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/run.go @@ -0,0 +1,189 @@ +package testworkflows + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows/renderer" + apiclientv1 "github.com/kubeshop/testkube/pkg/api/v1/client" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/ui" +) + +const ( + LogTimestampLength = 30 // time.RFC3339Nano without 00:00 timezone +) + +func NewRunTestWorkflowCmd() *cobra.Command { + var ( + executionName string + config map[string]string + watchEnabled bool + silentMode bool + ) + + cmd := &cobra.Command{ + Use: "testworkflow [name]", + Aliases: []string{"testworkflows", "tw"}, + Args: cobra.ExactArgs(1), + Short: "Starts test workflow execution", + + Run: func(cmd *cobra.Command, args []string) { + namespace := cmd.Flag("namespace").Value.String() + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + name := args[0] + execution, err := client.ExecuteTestWorkflow(name, testkube.TestWorkflowExecutionRequest{ + Name: executionName, + Config: config, + }) + ui.ExitOnError("execute test workflow "+name+" from namespace "+namespace, err) + err = render.Obj(cmd, execution, os.Stdout, renderer.TestWorkflowExecutionRenderer) + ui.ExitOnError("render test workflow execution", err) + + if watchEnabled { + ui.NL() + result, err := watchTestWorkflowLogs(execution.Name, execution.Signature, client) // TODO(TKC-1652): Use execution.Id when will be replaced + ui.ExitOnError("reading test workflow execution logs", err) + + // Apply the result in the execution + execution.Result = result + if result.IsFinished() { + execution.StatusAt = result.FinishedAt + } + + // Display message depending on the result + switch { + case result.Initialization.ErrorMessage != "": + ui.Warn("test workflow execution failed:\n") + ui.Errf(result.Initialization.ErrorMessage) + os.Exit(1) + case result.IsFailed(): + ui.Warn("test workflow execution failed") + os.Exit(1) + case result.IsAborted(): + ui.Warn("test workflow execution aborted") + os.Exit(1) + case result.IsPassed(): + ui.Success("test workflow execution completed with success in " + result.FinishedAt.Sub(result.QueuedAt).String()) + } + } + }, + } + + cmd.Flags().StringVarP(&executionName, "name", "n", "", "execution name, if empty will be autogenerated") + cmd.Flags().StringToStringVarP(&config, "env", "", map[string]string{}, "configuration variables in a form of name1=val1 passed to executor") + cmd.Flags().BoolVarP(&watchEnabled, "watch", "f", false, "watch for changes after start") + cmd.Flags().BoolVarP(&silentMode, "silent", "", false, "don't print intermediate test execution") + + return cmd +} + +func flattenSignatures(sig []testkube.TestWorkflowSignature) []testkube.TestWorkflowSignature { + res := make([]testkube.TestWorkflowSignature, 0) + for _, s := range sig { + if len(s.Children) == 0 { + res = append(res, s) + } else { + res = append(res, flattenSignatures(s.Children)...) + } + } + return res +} + +func printResultDifference(res1 *testkube.TestWorkflowResult, res2 *testkube.TestWorkflowResult, steps []testkube.TestWorkflowSignature) bool { + if res1 == nil || res2 == nil { + return false + } + changed := false + for i, s := range steps { + r1 := res1.Steps[s.Ref] + r2 := res2.Steps[s.Ref] + r1Status := testkube.QUEUED_TestWorkflowStepStatus + r2Status := testkube.QUEUED_TestWorkflowStepStatus + if r1.Status != nil { + r1Status = *r1.Status + } + if r2.Status != nil { + r2Status = *r2.Status + } + if r1Status == r2Status { + continue + } + name := s.Category + if s.Name != "" { + name = s.Name + } + took := r2.FinishedAt.Sub(r2.QueuedAt).Round(time.Millisecond) + changed = true + + switch r2Status { + case testkube.RUNNING_TestWorkflowStepStatus: + fmt.Print(ui.LightCyan(fmt.Sprintf("\n• (%d/%d) %s\n", i+1, len(steps), name))) + case testkube.SKIPPED_TestWorkflowStepStatus: + fmt.Print(ui.LightGray("• skipped\n")) + case testkube.PASSED_TestWorkflowStepStatus: + fmt.Print(ui.Green(fmt.Sprintf("\n• passed in %s\n", took))) + case testkube.ABORTED_TestWorkflowStepStatus: + fmt.Print(ui.Red("\n• aborted\n")) + default: + if s.Optional { + fmt.Print(ui.Yellow(fmt.Sprintf("\n• %s in %s (ignored)\n", string(r2Status), took))) + } else { + fmt.Print(ui.Red(fmt.Sprintf("\n• %s in %s\n", string(r2Status), took))) + } + } + } + + return changed +} + +func watchTestWorkflowLogs(id string, signature []testkube.TestWorkflowSignature, client apiclientv1.Client) (*testkube.TestWorkflowResult, error) { + ui.Info("Getting logs from test workflow job", id) + + notifications, err := client.GetTestWorkflowExecutionNotifications(id) + ui.ExitOnError("getting logs from executor", err) + + steps := flattenSignatures(signature) + + var result *testkube.TestWorkflowResult + var isLineBeginning = true + for l := range notifications { + if l.Output != nil { + continue + } + if l.Result != nil { + isLineBeginning = printResultDifference(result, l.Result, steps) + result = l.Result + continue + } + + // Strip timestamp + space for all new lines in the log + for len(l.Log) > 0 { + if isLineBeginning { + l.Log = l.Log[LogTimestampLength+1:] + isLineBeginning = false + } + newLineIndex := strings.Index(l.Log, "\n") + if newLineIndex == -1 { + fmt.Print(l.Log) + break + } else { + fmt.Print(l.Log[0 : newLineIndex+1]) + l.Log = l.Log[newLineIndex+1:] + isLineBeginning = true + } + } + } + + ui.NL() + + return result, err +} diff --git a/cmd/tcl/testworkflow-init/data/config.go b/cmd/tcl/testworkflow-init/data/config.go index eb08893ec4c..bbcf47feb1d 100644 --- a/cmd/tcl/testworkflow-init/data/config.go +++ b/cmd/tcl/testworkflow-init/data/config.go @@ -27,7 +27,7 @@ var Config = &config{ func LoadConfig(config map[string]string) { Config.Debug = getBool(config, "debug", Config.Debug) - Config.RetryCount = getInt(config, "retryCount", 1) + Config.RetryCount = getInt(config, "retryCount", 0) Config.RetryUntil = getStr(config, "retryUntil", "self.passed") Config.Negative = getBool(config, "negative", false) } diff --git a/cmd/tcl/testworkflow-init/data/emit.go b/cmd/tcl/testworkflow-init/data/emit.go index 7f280494e4e..83fc8e69416 100644 --- a/cmd/tcl/testworkflow-init/data/emit.go +++ b/cmd/tcl/testworkflow-init/data/emit.go @@ -11,24 +11,74 @@ package data import ( "encoding/json" "fmt" + "strings" ) -func EmitOutput(ref string, name string, value interface{}) { +const ( + InstructionPrefix = "\u0001\u0005" + HintPrefix = "\u0006" + InstructionSeparator = "\u0003" + InstructionValueSeparator = "\u0004" +) + +func SprintOutput(ref string, name string, value interface{}) string { j, err := json.Marshal(value) if err != nil { panic(fmt.Sprintf("error while marshalling reference: %v", err)) } - fmt.Printf("\n;;%s;%s:%s;\n", ref, name, string(j)) + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(InstructionPrefix) + sb.WriteString(ref) + sb.WriteString(InstructionSeparator) + sb.WriteString(name) + sb.WriteString(InstructionValueSeparator) + sb.Write(j) + sb.WriteString(InstructionSeparator) + sb.WriteString("\n") + return sb.String() } -func EmitHint(ref string, name string) { - fmt.Printf("\n;;;%s;%s;\n", ref, name) +func SprintHint(ref string, name string) string { + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(InstructionPrefix) + sb.WriteString(HintPrefix) + sb.WriteString(ref) + sb.WriteString(InstructionSeparator) + sb.WriteString(name) + sb.WriteString(InstructionSeparator) + sb.WriteString("\n") + return sb.String() } -func EmitHintDetails(ref string, name string, value interface{}) { +func SprintHintDetails(ref string, name string, value interface{}) string { j, err := json.Marshal(value) if err != nil { panic(fmt.Sprintf("error while marshalling reference: %v", err)) } - fmt.Printf("\n;;;%s;%s:%s;\n", ref, name, string(j)) + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(InstructionPrefix) + sb.WriteString(HintPrefix) + sb.WriteString(ref) + sb.WriteString(InstructionSeparator) + sb.WriteString(name) + sb.WriteString(InstructionValueSeparator) + sb.Write(j) + sb.WriteString(InstructionSeparator) + sb.WriteString("\n") + return sb.String() +} + +func PrintOutput(ref string, name string, value interface{}) { + fmt.Print(SprintOutput(ref, name, value)) +} + +func PrintHint(ref string, name string) { + fmt.Print(SprintHint(ref, name)) +} + +func PrintHintDetails(ref string, name string, value interface{}) { + fmt.Print(SprintHintDetails(ref, name, value)) } diff --git a/cmd/tcl/testworkflow-init/data/types.go b/cmd/tcl/testworkflow-init/data/types.go index e7895751131..39449f8732e 100644 --- a/cmd/tcl/testworkflow-init/data/types.go +++ b/cmd/tcl/testworkflow-init/data/types.go @@ -54,7 +54,7 @@ func (s *StepInfo) Start(t time.Time) { if s.StartTime.IsZero() { s.StartTime = t s.Iteration = 1 - EmitHint(s.Ref, "start") + PrintHint(s.Ref, "start") } } @@ -63,7 +63,7 @@ func (s *StepInfo) Next() { s.Start(time.Now()) } else { s.Iteration++ - EmitHintDetails(s.Ref, "iteration", s.Iteration) + PrintHintDetails(s.Ref, "iteration", s.Iteration) } } @@ -97,9 +97,9 @@ func (s *StepInfo) SetStatus(status StepStatus) { s.Status = status s.HasStatus = true if status == StepStatusPassed { - EmitHintDetails(s.Ref, "status", "passed") + PrintHintDetails(s.Ref, "status", "passed") } else { - EmitHintDetails(s.Ref, "status", status) + PrintHintDetails(s.Ref, "status", status) } } } diff --git a/pkg/api/v1/client/api.go b/pkg/api/v1/client/api.go index 0e45568f3ca..ec1adfdd3c9 100644 --- a/pkg/api/v1/client/api.go +++ b/pkg/api/v1/client/api.go @@ -32,13 +32,16 @@ func NewProxyAPIClient(client kubernetes.Interface, config APIConfig) APIClient NewProxyClient[testkube.TestSuiteExecutionsResult](client, config), NewProxyClient[testkube.Artifact](client, config), ), - ExecutorClient: NewExecutorClient(NewProxyClient[testkube.ExecutorDetails](client, config)), - WebhookClient: NewWebhookClient(NewProxyClient[testkube.Webhook](client, config)), - ConfigClient: NewConfigClient(NewProxyClient[testkube.Config](client, config)), - TestSourceClient: NewTestSourceClient(NewProxyClient[testkube.TestSource](client, config)), - CopyFileClient: NewCopyFileProxyClient(client, config), - TemplateClient: NewTemplateClient(NewProxyClient[testkube.Template](client, config)), - TestWorkflowClient: NewTestWorkflowClient(NewProxyClient[testkube.TestWorkflow](client, config)), + ExecutorClient: NewExecutorClient(NewProxyClient[testkube.ExecutorDetails](client, config)), + WebhookClient: NewWebhookClient(NewProxyClient[testkube.Webhook](client, config)), + ConfigClient: NewConfigClient(NewProxyClient[testkube.Config](client, config)), + TestSourceClient: NewTestSourceClient(NewProxyClient[testkube.TestSource](client, config)), + CopyFileClient: NewCopyFileProxyClient(client, config), + TemplateClient: NewTemplateClient(NewProxyClient[testkube.Template](client, config)), + TestWorkflowClient: NewTestWorkflowClient( + NewProxyClient[testkube.TestWorkflow](client, config), + NewProxyClient[testkube.TestWorkflowExecution](client, config), + ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewProxyClient[testkube.TestWorkflowTemplate](client, config)), } } @@ -64,13 +67,16 @@ func NewDirectAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, NewDirectClient[testkube.TestSuiteExecutionsResult](httpClient, apiURI, apiPathPrefix), NewDirectClient[testkube.Artifact](httpClient, apiURI, apiPathPrefix), ), - ExecutorClient: NewExecutorClient(NewDirectClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), - WebhookClient: NewWebhookClient(NewDirectClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), - ConfigClient: NewConfigClient(NewDirectClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), - TestSourceClient: NewTestSourceClient(NewDirectClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), - CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), - TemplateClient: NewTemplateClient(NewDirectClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), - TestWorkflowClient: NewTestWorkflowClient(NewDirectClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix)), + ExecutorClient: NewExecutorClient(NewDirectClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), + WebhookClient: NewWebhookClient(NewDirectClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), + ConfigClient: NewConfigClient(NewDirectClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), + TestSourceClient: NewTestSourceClient(NewDirectClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), + CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), + TemplateClient: NewTemplateClient(NewDirectClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), + TestWorkflowClient: NewTestWorkflowClient( + NewDirectClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix), + NewDirectClient[testkube.TestWorkflowExecution](httpClient, apiURI, apiPathPrefix), + ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewDirectClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } } @@ -96,13 +102,16 @@ func NewCloudAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, NewCloudClient[testkube.TestSuiteExecutionsResult](httpClient, apiURI, apiPathPrefix), NewCloudClient[testkube.Artifact](httpClient, apiURI, apiPathPrefix), ), - ExecutorClient: NewExecutorClient(NewCloudClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), - WebhookClient: NewWebhookClient(NewCloudClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), - ConfigClient: NewConfigClient(NewCloudClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), - TestSourceClient: NewTestSourceClient(NewCloudClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), - CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), - TemplateClient: NewTemplateClient(NewCloudClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), - TestWorkflowClient: NewTestWorkflowClient(NewCloudClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix)), + ExecutorClient: NewExecutorClient(NewCloudClient[testkube.ExecutorDetails](httpClient, apiURI, apiPathPrefix)), + WebhookClient: NewWebhookClient(NewCloudClient[testkube.Webhook](httpClient, apiURI, apiPathPrefix)), + ConfigClient: NewConfigClient(NewCloudClient[testkube.Config](httpClient, apiURI, apiPathPrefix)), + TestSourceClient: NewTestSourceClient(NewCloudClient[testkube.TestSource](httpClient, apiURI, apiPathPrefix)), + CopyFileClient: NewCopyFileDirectClient(httpClient, apiURI, apiPathPrefix), + TemplateClient: NewTemplateClient(NewCloudClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), + TestWorkflowClient: NewTestWorkflowClient( + NewCloudClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix).WithSSEClient(sseClient), + NewCloudClient[testkube.TestWorkflowExecution](httpClient, apiURI, apiPathPrefix), + ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewCloudClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } } diff --git a/pkg/api/v1/client/common.go b/pkg/api/v1/client/common.go index 00afd3a870a..95fdc0c14ea 100644 --- a/pkg/api/v1/client/common.go +++ b/pkg/api/v1/client/common.go @@ -7,6 +7,7 @@ import ( "fmt" "io" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/utils" @@ -91,6 +92,37 @@ func StreamToLogsChannelV2(resp io.Reader, logs chan events.Log) { } } +// StreamToTestWorkflowExecutionNotificationsChannel converts io.Reader with SSE data to channel of actual notifications +func StreamToTestWorkflowExecutionNotificationsChannel(resp io.Reader, notifications chan testkube.TestWorkflowExecutionNotification) { + reader := bufio.NewReader(resp) + + for { + b, err := utils.ReadLongLine(reader) + if err != nil { + if err != io.EOF { + fmt.Printf("Read long line error: %+v' \n", err) + } + + break + } + chunk := trimDataChunk(b) + + // ignore lines which are not JSON objects + if len(chunk) < 2 || chunk[0] != '{' { + continue + } + + out := testkube.TestWorkflowExecutionNotification{} + err = json.Unmarshal(chunk, &out) + if err != nil { + fmt.Printf("Unmarshal chunk error: %+v, json:'%s' \n", err, chunk) + continue + } + + notifications <- out + } +} + // trimDataChunk remove data: and newlines from incoming SSE data line func trimDataChunk(in []byte) []byte { prefix := []byte("data: ") diff --git a/pkg/api/v1/client/direct_client.go b/pkg/api/v1/client/direct_client.go index e7c246bc2d2..19523388717 100644 --- a/pkg/api/v1/client/direct_client.go +++ b/pkg/api/v1/client/direct_client.go @@ -12,6 +12,7 @@ import ( "github.com/pkg/errors" "golang.org/x/oauth2" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/oauth" @@ -206,6 +207,29 @@ func (t DirectClient[A]) GetLogsV2(uri string, logs chan events.Log) error { return nil } +// GetTestWorkflowExecutionNotifications returns logs stream from job pods, based on job pods logs +func (t DirectClient[A]) GetTestWorkflowExecutionNotifications(uri string, notifications chan testkube.TestWorkflowExecutionNotification) error { + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "text/event-stream") + resp, err := t.sseClient.Do(req) + if err != nil { + return err + } + + go func() { + defer close(notifications) + defer resp.Body.Close() + + StreamToTestWorkflowExecutionNotificationsChannel(resp.Body, notifications) + }() + + return nil +} + // GetFile returns file artifact func (t DirectClient[A]) GetFile(uri, fileName, destination string, params map[string][]string) (name string, err error) { req, err := http.NewRequest(http.MethodGet, uri, nil) diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index 78a6fd9247d..6799eefb4ef 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -136,6 +136,8 @@ type TestWorkflowAPI interface { CreateTestWorkflow(workflow testkube.TestWorkflow) (testkube.TestWorkflow, error) UpdateTestWorkflow(workflow testkube.TestWorkflow) (testkube.TestWorkflow, error) DeleteTestWorkflow(name string) error + ExecuteTestWorkflow(name string, request testkube.TestWorkflowExecutionRequest) (testkube.TestWorkflowExecution, error) + GetTestWorkflowExecutionNotifications(id string) (chan testkube.TestWorkflowExecutionNotification, error) } // TestWorkflowTemplateAPI describes test workflow api methods @@ -256,13 +258,13 @@ type Gettable interface { testkube.Webhook | testkube.TestWithExecution | testkube.TestSuiteWithExecution | testkube.TestWithExecutionSummary | testkube.TestSuiteWithExecutionSummary | testkube.Artifact | testkube.ServerInfo | testkube.Config | testkube.DebugInfo | testkube.TestSource | testkube.Template | - testkube.TestWorkflow | testkube.TestWorkflowTemplate + testkube.TestWorkflow | testkube.TestWorkflowTemplate | testkube.TestWorkflowExecution } // Executable is an interface of executable objects type Executable interface { testkube.Execution | testkube.TestSuiteExecution | - testkube.ExecutionsResult | testkube.TestSuiteExecutionsResult + testkube.ExecutionsResult | testkube.TestSuiteExecutionsResult | testkube.TestWorkflowExecution } // All is an interface of all objects @@ -279,5 +281,6 @@ type Transport[A All] interface { GetURI(pathTemplate string, params ...interface{}) string GetLogs(uri string, logs chan output.Output) error GetLogsV2(uri string, logs chan events.Log) error + GetTestWorkflowExecutionNotifications(uri string, notifications chan testkube.TestWorkflowExecutionNotification) error GetFile(uri, fileName, destination string, params map[string][]string) (name string, err error) } diff --git a/pkg/api/v1/client/proxy_client.go b/pkg/api/v1/client/proxy_client.go index 571072b4b57..63551e491d4 100644 --- a/pkg/api/v1/client/proxy_client.go +++ b/pkg/api/v1/client/proxy_client.go @@ -14,6 +14,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/logs/events" "github.com/kubeshop/testkube/pkg/problem" @@ -170,6 +171,26 @@ func (t ProxyClient[A]) GetLogsV2(uri string, logs chan events.Log) error { return nil } +// GetTestWorkflowExecutionNotifications returns logs stream from job pods, based on job pods logs +func (t ProxyClient[A]) GetTestWorkflowExecutionNotifications(uri string, notifications chan testkube.TestWorkflowExecutionNotification) error { + resp, err := t.getProxy(http.MethodGet). + Suffix(uri). + SetHeader("Accept", "text/event-stream"). + Stream(context.Background()) + if err != nil { + return err + } + + go func() { + defer close(notifications) + defer resp.Close() + + StreamToTestWorkflowExecutionNotificationsChannel(resp, notifications) + }() + + return nil +} + // GetFile returns file artifact func (t ProxyClient[A]) GetFile(uri, fileName, destination string, params map[string][]string) (name string, err error) { req := t.getProxy(http.MethodGet). diff --git a/pkg/api/v1/client/testworkflow.go b/pkg/api/v1/client/testworkflow.go index 6541ead170d..42cf7ff6b49 100644 --- a/pkg/api/v1/client/testworkflow.go +++ b/pkg/api/v1/client/testworkflow.go @@ -11,15 +11,18 @@ import ( // NewTestWorkflowClient creates new TestWorkflow client func NewTestWorkflowClient( testWorkflowTransport Transport[testkube.TestWorkflow], + testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution], ) TestWorkflowClient { return TestWorkflowClient{ - testWorkflowTransport: testWorkflowTransport, + testWorkflowTransport: testWorkflowTransport, + testWorkflowExecutionTransport: testWorkflowExecutionTransport, } } // TestWorkflowClient is a client for tests type TestWorkflowClient struct { - testWorkflowTransport Transport[testkube.TestWorkflow] + testWorkflowTransport Transport[testkube.TestWorkflow] + testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution] } // GetTestWorkflow returns single test by id @@ -78,3 +81,27 @@ func (c TestWorkflowClient) DeleteTestWorkflow(name string) error { uri := c.testWorkflowTransport.GetURI("/test-workflows/%s", name) return c.testWorkflowTransport.Delete(uri, "", true) } + +// ExecuteTestWorkflow starts new TestWorkflow execution +func (c TestWorkflowClient) ExecuteTestWorkflow(name string, request testkube.TestWorkflowExecutionRequest) (result testkube.TestWorkflowExecution, err error) { + if name == "" { + return result, fmt.Errorf("test workflow name '%s' is not valid", name) + } + + uri := c.testWorkflowExecutionTransport.GetURI("/test-workflows/%s/executions", name) + + body, err := json.Marshal(request) + if err != nil { + return result, err + } + + return c.testWorkflowExecutionTransport.Execute(http.MethodPost, uri, body, nil) +} + +// GetTestWorkflowExecutionNotifications returns events stream from job pods, based on job pods logs +func (c TestWorkflowClient) GetTestWorkflowExecutionNotifications(id string) (notifications chan testkube.TestWorkflowExecutionNotification, err error) { + notifications = make(chan testkube.TestWorkflowExecutionNotification) + uri := c.testWorkflowTransport.GetURI("/test-workflow-executions/%s/notifications", id) + err = c.testWorkflowTransport.GetTestWorkflowExecutionNotifications(uri, notifications) + return notifications, err +} diff --git a/pkg/api/v1/testkube/model_test_workflow_execution.go b/pkg/api/v1/testkube/model_test_workflow_execution.go new file mode 100644 index 00000000000..1ed8c872b8a --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_execution.go @@ -0,0 +1,34 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowExecution struct { + // unique execution identifier + Id string `json:"id"` + // execution name + Name string `json:"name"` + // sequence number for the execution + Number int32 `json:"number,omitempty"` + // when the execution has been scheduled to run + ScheduledAt time.Time `json:"scheduledAt,omitempty"` + // when the execution result's status has changed last time (queued, passed, failed) + StatusAt time.Time `json:"statusAt,omitempty"` + // structured tree of steps + Signature []TestWorkflowSignature `json:"signature,omitempty"` + Result *TestWorkflowResult `json:"result,omitempty"` + // additional information from the steps, like referenced executed tests or artifacts + Output []TestWorkflowOutput `json:"output,omitempty"` + Workflow *TestWorkflow `json:"workflow"` + ResolvedWorkflow *TestWorkflow `json:"resolvedWorkflow,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_execution_notification.go b/pkg/api/v1/testkube/model_test_workflow_execution_notification.go new file mode 100644 index 00000000000..97d3ee69626 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_execution_notification.go @@ -0,0 +1,25 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowExecutionNotification struct { + // timestamp for the notification if available + Ts time.Time `json:"ts,omitempty"` + Result *TestWorkflowResult `json:"result,omitempty"` + // step reference, if related to some specific step + Ref string `json:"ref,omitempty"` + // log content, if it's just a log. note, that it includes 30 chars timestamp + space + Log string `json:"log,omitempty"` + Output *TestWorkflowOutput `json:"output,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_output.go b/pkg/api/v1/testkube/model_test_workflow_output.go new file mode 100644 index 00000000000..b464d7447df --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_output.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowOutput struct { + // step reference + Ref string `json:"ref,omitempty"` + // output kind name + Name string `json:"name,omitempty"` + // value returned + Value *interface{} `json:"value,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_result.go b/pkg/api/v1/testkube/model_test_workflow_result.go new file mode 100644 index 00000000000..0a34e1fdf85 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_result.go @@ -0,0 +1,27 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowResult struct { + Status *TestWorkflowStatus `json:"status"` + PredictedStatus *TestWorkflowStatus `json:"predictedStatus"` + // when the pod was created + QueuedAt time.Time `json:"queuedAt,omitempty"` + // when the pod has been successfully assigned + StartedAt time.Time `json:"startedAt,omitempty"` + // when the pod has been completed + FinishedAt time.Time `json:"finishedAt,omitempty"` + Initialization *TestWorkflowStepResult `json:"initialization,omitempty"` + Steps map[string]TestWorkflowStepResult `json:"steps,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go new file mode 100644 index 00000000000..3addf6ff1b5 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -0,0 +1,204 @@ +package testkube + +import ( + "github.com/kubeshop/testkube/internal/common" +) + +func (r *TestWorkflowResult) IsFinished() bool { + return !r.IsStatus(QUEUED_TestWorkflowStatus) && !r.IsStatus(RUNNING_TestWorkflowStatus) +} + +func (r *TestWorkflowResult) IsStatus(s TestWorkflowStatus) bool { + if r.Status == nil { + return s == QUEUED_TestWorkflowStatus + } + return *r.Status == s +} + +func (r *TestWorkflowResult) IsQueued() bool { + return r.IsStatus(QUEUED_TestWorkflowStatus) +} + +func (r *TestWorkflowResult) IsFailed() bool { + return r.IsStatus(FAILED_TestWorkflowStatus) +} + +func (r *TestWorkflowResult) IsAborted() bool { + return r.IsStatus(ABORTED_TestWorkflowStatus) +} + +func (r *TestWorkflowResult) IsPassed() bool { + return r.IsStatus(PASSED_TestWorkflowStatus) +} + +func (r *TestWorkflowResult) IsAnyError() bool { + return r.IsFinished() && !r.IsStatus(PASSED_TestWorkflowStatus) +} + +func (r *TestWorkflowResult) Clone() *TestWorkflowResult { + if r == nil { + return nil + } + steps := make(map[string]TestWorkflowStepResult, len(r.Steps)) + for k, v := range r.Steps { + steps[k] = *v.Clone() + } + return &TestWorkflowResult{ + Status: r.Status, + PredictedStatus: r.PredictedStatus, + QueuedAt: r.QueuedAt, + StartedAt: r.StartedAt, + FinishedAt: r.FinishedAt, + Initialization: r.Initialization.Clone(), + Steps: steps, + } +} + +func getTestWorkflowStepStatus(result TestWorkflowStepResult) TestWorkflowStepStatus { + if result.Status == nil { + return QUEUED_TestWorkflowStepStatus + } + return *result.Status +} + +func (r *TestWorkflowResult) UpdateStepResult(sig []TestWorkflowSignature, ref string, result TestWorkflowStepResult) TestWorkflowStepResult { + v := r.Steps[ref] + v.Merge(result) + r.Steps[ref] = v + r.Recompute(sig) + return v +} + +func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { + // Recompute steps + for _, ch := range sig { + r.RecomputeStep(ch) + } + + // Build status on the internal failur + if getTestWorkflowStepStatus(*r.Initialization) == ABORTED_TestWorkflowStepStatus { + r.Status = common.Ptr(ABORTED_TestWorkflowStatus) + r.PredictedStatus = r.Status + return + } else if getTestWorkflowStepStatus(*r.Initialization) == FAILED_TestWorkflowStepStatus { + r.Status = common.Ptr(FAILED_TestWorkflowStatus) + r.PredictedStatus = r.Status + return + } + + // Recompute the TestWorkflow status + totalSig := TestWorkflowSignature{Children: sig} + result, _ := predictTestWorkflowStepStatus(TestWorkflowStepResult{}, totalSig, r) + status := common.Ptr(FAILED_TestWorkflowStatus) + switch result { + case ABORTED_TestWorkflowStepStatus: + status = common.Ptr(ABORTED_TestWorkflowStatus) + case PASSED_TestWorkflowStepStatus, SKIPPED_TestWorkflowStepStatus: + status = common.Ptr(PASSED_TestWorkflowStatus) + } + r.PredictedStatus = status + if !r.FinishedAt.IsZero() { + r.Status = r.PredictedStatus + } +} + +func (r *TestWorkflowResult) RecomputeStep(sig TestWorkflowSignature) { + children := sig.Children + if len(children) == 0 { + return + } + + // Compute nested steps + for _, ch := range children { + r.RecomputeStep(ch) + } + + // Simplify accessing value + v := r.Steps[sig.Ref] + defer func() { + r.Steps[sig.Ref] = v + }() + + // Compute time + v = recomputeTestWorkflowStepResult(v, sig, r) +} + +func predictTestWorkflowStepStatus(v TestWorkflowStepResult, sig TestWorkflowSignature, r *TestWorkflowResult) (TestWorkflowStepStatus, bool) { + children := sig.Children + if len(children) == 0 { + if getTestWorkflowStepStatus(v) == QUEUED_TestWorkflowStepStatus || getTestWorkflowStepStatus(v) == RUNNING_TestWorkflowStepStatus { + return PASSED_TestWorkflowStepStatus, false + } + return *v.Status, true + } + + // Compute the status + skipped := true + aborted := false + failed := false + finished := true + for _, ch := range children { + status := getTestWorkflowStepStatus(r.Steps[ch.Ref]) + if status != SKIPPED_TestWorkflowStepStatus { + skipped = false + } + if status == ABORTED_TestWorkflowStepStatus { + aborted = true + } + if !ch.Optional && (status == FAILED_TestWorkflowStepStatus || status == TIMEOUT_TestWorkflowStepStatus) { + failed = true + } + if status == QUEUED_TestWorkflowStepStatus || status == RUNNING_TestWorkflowStepStatus { + finished = false + } + } + + if getTestWorkflowStepStatus(v) == FAILED_TestWorkflowStepStatus { + return FAILED_TestWorkflowStepStatus, finished + } else if aborted { + return ABORTED_TestWorkflowStepStatus, finished + } else if (failed && !sig.Negative) || (!failed && sig.Negative) { + return FAILED_TestWorkflowStepStatus, finished + } else if skipped { + return SKIPPED_TestWorkflowStepStatus, finished + } else { + return PASSED_TestWorkflowStepStatus, finished + } +} + +func recomputeTestWorkflowStepResult(v TestWorkflowStepResult, sig TestWorkflowSignature, r *TestWorkflowResult) TestWorkflowStepResult { + children := sig.Children + if len(children) == 0 { + return v + } + + // Compute nested steps + for _, ch := range children { + r.RecomputeStep(ch) + } + + // Compute time + v.QueuedAt = r.Steps[children[0].Ref].QueuedAt + v.StartedAt = r.Steps[children[0].Ref].StartedAt + v.FinishedAt = r.Steps[children[len(children)-1].Ref].StartedAt + + // It has been already marked as failed internally from some step below + if getTestWorkflowStepStatus(v) == FAILED_TestWorkflowStepStatus { + return v + } + + // It is finished already + if !v.FinishedAt.IsZero() { + predicted, finished := predictTestWorkflowStepStatus(v, sig, r) + if finished { + v.Status = common.Ptr(predicted) + } + return v + } + + if !v.StartedAt.IsZero() { + v.Status = common.Ptr(RUNNING_TestWorkflowStepStatus) + } + + return v +} diff --git a/pkg/api/v1/testkube/model_test_workflow_signature.go b/pkg/api/v1/testkube/model_test_workflow_signature.go new file mode 100644 index 00000000000..8fd25943e29 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_signature.go @@ -0,0 +1,24 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowSignature struct { + // step reference + Ref string `json:"ref,omitempty"` + // step name + Name string `json:"name,omitempty"` + // step category, that may be used as name fallback + Category string `json:"category,omitempty"` + // is the step/group meant to be optional + Optional bool `json:"optional,omitempty"` + // is the step/group meant to be negative + Negative bool `json:"negative,omitempty"` + Children []TestWorkflowSignature `json:"children,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_status.go b/pkg/api/v1/testkube/model_test_workflow_status.go new file mode 100644 index 00000000000..b0c655faec6 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_status.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStatus string + +// List of TestWorkflowStatus +const ( + QUEUED_TestWorkflowStatus TestWorkflowStatus = "queued" + RUNNING_TestWorkflowStatus TestWorkflowStatus = "running" + PASSED_TestWorkflowStatus TestWorkflowStatus = "passed" + FAILED_TestWorkflowStatus TestWorkflowStatus = "failed" + ABORTED_TestWorkflowStatus TestWorkflowStatus = "aborted" +) diff --git a/pkg/api/v1/testkube/model_test_workflow_step_result.go b/pkg/api/v1/testkube/model_test_workflow_step_result.go new file mode 100644 index 00000000000..021ee2c8230 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_result.go @@ -0,0 +1,26 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowStepResult struct { + ErrorMessage string `json:"errorMessage,omitempty"` + Status *TestWorkflowStepStatus `json:"status,omitempty"` + ExitCode float64 `json:"exitCode,omitempty"` + // when the container was created + QueuedAt time.Time `json:"queuedAt,omitempty"` + // when the container was started + StartedAt time.Time `json:"startedAt,omitempty"` + // when the container was finished + FinishedAt time.Time `json:"finishedAt,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_step_result_extended.go new file mode 100644 index 00000000000..e207318ce35 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_result_extended.go @@ -0,0 +1,36 @@ +package testkube + +func (r *TestWorkflowStepResult) Clone() *TestWorkflowStepResult { + if r == nil { + return nil + } + return &TestWorkflowStepResult{ + ErrorMessage: r.ErrorMessage, + Status: r.Status, + ExitCode: r.ExitCode, + QueuedAt: r.QueuedAt, + StartedAt: r.StartedAt, + FinishedAt: r.FinishedAt, + } +} + +func (r *TestWorkflowStepResult) Merge(next TestWorkflowStepResult) { + if next.ErrorMessage != "" { + r.ErrorMessage = next.ErrorMessage + } + if next.Status != nil { + r.Status = next.Status + } + if next.ExitCode != 0 && (r.ExitCode == 0 || r.ExitCode == -1) { + r.ExitCode = next.ExitCode + } + if !next.QueuedAt.IsZero() { + r.QueuedAt = next.QueuedAt + } + if !next.StartedAt.IsZero() { + r.StartedAt = next.StartedAt + } + if !next.FinishedAt.IsZero() { + r.FinishedAt = next.FinishedAt + } +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_status.go b/pkg/api/v1/testkube/model_test_workflow_step_status.go new file mode 100644 index 00000000000..1c70bbb1efd --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_status.go @@ -0,0 +1,23 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowStepStatus string + +// List of TestWorkflowStepStatus +const ( + QUEUED_TestWorkflowStepStatus TestWorkflowStepStatus = "queued" + RUNNING_TestWorkflowStepStatus TestWorkflowStepStatus = "running" + PASSED_TestWorkflowStepStatus TestWorkflowStepStatus = "passed" + FAILED_TestWorkflowStepStatus TestWorkflowStepStatus = "failed" + TIMEOUT_TestWorkflowStepStatus TestWorkflowStepStatus = "timeout" + SKIPPED_TestWorkflowStepStatus TestWorkflowStepStatus = "skipped" + ABORTED_TestWorkflowStepStatus TestWorkflowStepStatus = "aborted" +) diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index 2884150bb8a..adfdab4f929 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -86,9 +86,10 @@ func (s *apiTCL) AppendRoutes() { testWorkflows.Get("/:id", s.pro(s.GetTestWorkflowHandler())) testWorkflows.Put("/:id", s.pro(s.UpdateTestWorkflowHandler())) testWorkflows.Delete("/:id", s.pro(s.DeleteTestWorkflowHandler())) + testWorkflows.Post("/:id/executions", s.pro(s.ExecuteTestWorkflowHandler())) - testWorkflowExecutions := testWorkflows.Group("/:id/executions") - testWorkflowExecutions.Post("/", s.pro(s.ExecuteTestWorkflowHandler())) + testWorkflowExecutions := root.Group("/test-workflow-executions") + testWorkflowExecutions.Get("/:id/notifications", s.pro(s.StreamTestWorkflowExecutionNotificationsHandler())) root.Post("/preview-test-workflow", s.pro(s.PreviewTestWorkflowHandler())) diff --git a/pkg/tcl/apitcl/v1/testworkflowexecutions.go b/pkg/tcl/apitcl/v1/testworkflowexecutions.go new file mode 100644 index 00000000000..eb849cf3fec --- /dev/null +++ b/pkg/tcl/apitcl/v1/testworkflowexecutions.go @@ -0,0 +1,61 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "bufio" + "encoding/json" + "fmt" + + "github.com/gofiber/fiber/v2" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowcontroller" +) + +func (s *apiTCL) StreamTestWorkflowExecutionNotificationsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + ctx := c.Context() + id := c.Params("id") + errPrefix := fmt.Sprintf("failed to stream test workflow execution notifications '%s'", id) + + // TODO: Fetch execution from database + execution := testkube.TestWorkflowExecution{ + Id: id, + } + + // Check for the logs TODO: Load from the database if possible + ctrl, err := testworkflowcontroller.New(ctx, s.Clientset, s.Namespace, execution.Id, execution.ScheduledAt) + if err != nil { + return s.BadRequest(c, errPrefix, "fetching job", err) + } + + // Initiate processing event stream + ctx.SetContentType("text/event-stream") + ctx.Response.Header.Set("Cache-Control", "no-cache") + ctx.Response.Header.Set("Connection", "keep-alive") + ctx.Response.Header.Set("Transfer-Encoding", "chunked") + + // Stream the notifications + ctx.SetBodyStreamWriter(func(w *bufio.Writer) { + _ = w.Flush() + enc := json.NewEncoder(w) + + for n := range ctrl.Watch(ctx).Stream(ctx).Channel() { + if n.Error == nil { + _ = enc.Encode(n.Value) + _, _ = fmt.Fprintf(w, "\n") + _ = w.Flush() + } + } + }) + + return nil + } +} diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index e25893c662c..83011eba690 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -13,6 +13,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/gofiber/fiber/v2" "github.com/pkg/errors" @@ -23,7 +24,8 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/rand" "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" - mappers2 "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" + testworkflowmappers "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowcontroller" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowresolver" ) @@ -35,7 +37,7 @@ func (s *apiTCL) ListTestWorkflowsHandler() fiber.Handler { if err != nil { return s.BadGateway(c, errPrefix, "client problem", err) } - err = SendResourceList(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapTestWorkflowKubeToAPI, workflows.Items...) + err = SendResourceList(c, "TestWorkflow", testworkflowsv1.GroupVersion, testworkflowmappers.MapTestWorkflowKubeToAPI, workflows.Items...) if err != nil { return s.InternalError(c, errPrefix, "serialization problem", err) } @@ -51,7 +53,7 @@ func (s *apiTCL) GetTestWorkflowHandler() fiber.Handler { if err != nil { return s.ClientError(c, errPrefix, err) } - err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, workflow) + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, testworkflowmappers.MapKubeToAPI, workflow) if err != nil { return s.InternalError(c, errPrefix, "serialization problem", err) } @@ -117,7 +119,7 @@ func (s *apiTCL) CreateTestWorkflowHandler() fiber.Handler { if err != nil { return s.BadRequest(c, errPrefix, "invalid body", err) } - obj = mappers2.MapAPIToKube(v) + obj = testworkflowmappers.MapAPIToKube(v) } // Validate resource @@ -133,7 +135,7 @@ func (s *apiTCL) CreateTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "client error", err) } - err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, obj) + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, testworkflowmappers.MapKubeToAPI, obj) if err != nil { return s.InternalError(c, errPrefix, "serialization problem", err) } @@ -159,7 +161,7 @@ func (s *apiTCL) UpdateTestWorkflowHandler() fiber.Handler { if err != nil { return s.BadRequest(c, errPrefix, "invalid body", err) } - obj = mappers2.MapAPIToKube(v) + obj = testworkflowmappers.MapAPIToKube(v) } // Read existing resource @@ -183,7 +185,7 @@ func (s *apiTCL) UpdateTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "client error", err) } - err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, obj) + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, testworkflowmappers.MapKubeToAPI, obj) if err != nil { return s.InternalError(c, errPrefix, "serialization problem", err) } @@ -207,7 +209,7 @@ func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { if err != nil { return s.BadRequest(c, errPrefix, "invalid body", err) } - obj = mappers2.MapAPIToKube(v) + obj = testworkflowmappers.MapAPIToKube(v) } // Validate resource @@ -233,7 +235,7 @@ func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "resolving error", err) } - err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, mappers2.MapKubeToAPI, obj) + err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, testworkflowmappers.MapKubeToAPI, obj) if err != nil { return s.InternalError(c, errPrefix, "serialization problem", err) } @@ -241,6 +243,7 @@ func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { } } +// TODO: Add metrics func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return func(c *fiber.Ctx) (err error) { name := c.Params("id") @@ -250,6 +253,12 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return s.ClientError(c, errPrefix, err) } + // Delete unnecessary data + delete(workflow.Annotations, "kubectl.kubernetes.io/last-applied-configuration") + + // Preserve initial workflow + initialWorkflow := workflow.DeepCopy() + // Load the execution request var request testkube.TestWorkflowExecutionRequest err = c.BodyParser(&request) @@ -261,7 +270,7 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { } machine := expressionstcl.NewMachine(). - Register("execution.id", request.Name) + Register("execution.id", request.Name) // TODO(TKC-1652): replace with actual ID // Fetch the templates tpls := testworkflowresolver.ListTemplates(workflow) @@ -275,7 +284,7 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { } // Apply the configuration - _, err = testworkflowresolver.ApplyWorkflowConfig(workflow, mappers2.MapConfigValueAPIToKube(request.Config)) + _, err = testworkflowresolver.ApplyWorkflowConfig(workflow, testworkflowmappers.MapConfigValueAPIToKube(request.Config)) if err != nil { return s.BadRequest(c, errPrefix, "configuration", err) } @@ -286,6 +295,9 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "resolving error", err) } + // Preserve resolved TestWorkflow + resolvedWorkflow := workflow.DeepCopy() + // Process the TestWorkflow bundle, err := testworkflowprocessor.NewFullFeatured(s.ImageInspector). Bundle(c.Context(), workflow, machine) @@ -293,28 +305,67 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "processing error", err) } + now := time.Now() + execution := testkube.TestWorkflowExecution{ + Id: "00000000", + Name: request.Name, + Number: 0, + ScheduledAt: now, + StatusAt: now, + Signature: testworkflowprocessor.MapSignatureListToInternal(bundle.Signature), + Result: &testkube.TestWorkflowResult{ + Status: common.Ptr(testkube.QUEUED_TestWorkflowStatus), + PredictedStatus: common.Ptr(testkube.PASSED_TestWorkflowStatus), + Initialization: &testkube.TestWorkflowStepResult{ + Status: common.Ptr(testkube.QUEUED_TestWorkflowStepStatus), + }, + Steps: map[string]testkube.TestWorkflowStepResult{}, + }, + Output: []testkube.TestWorkflowOutput{}, + Workflow: testworkflowmappers.MapKubeToAPI(initialWorkflow), + ResolvedWorkflow: testworkflowmappers.MapKubeToAPI(resolvedWorkflow), + } + // Deploy the resources - // TODO: rollback on failure for _, item := range bundle.Secrets { _, err = s.Clientset.CoreV1().Secrets(s.Namespace).Create(context.Background(), &item, metav1.CreateOptions{}) if err != nil { + // TODO: Set error message + go testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, request.Name) return s.BadRequest(c, errPrefix, "creating secret", err) } } for _, item := range bundle.ConfigMaps { _, err = s.Clientset.CoreV1().ConfigMaps(s.Namespace).Create(context.Background(), &item, metav1.CreateOptions{}) if err != nil { + // TODO: Set error message + go testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, request.Name) return s.BadRequest(c, errPrefix, "creating configmap", err) } } _, err = s.Clientset.BatchV1().Jobs(s.Namespace).Create(context.Background(), &bundle.Job, metav1.CreateOptions{}) if err != nil { + // TODO: Set error message + go testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, request.Name) + return s.BadRequest(c, errPrefix, "creating job", err) + } + + // Start to control the results + // TODO: Move it outside of the API when persistence will be there + go func() { + ctrl, err := testworkflowcontroller.New(context.Background(), s.Clientset, s.Namespace, execution.Name, execution.ScheduledAt) if err != nil { - return s.BadRequest(c, errPrefix, "creating job", err) + // TODO: Set error message + testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, execution.Name) + return } - } + for range ctrl.Watch(context.Background()).Stream(context.Background()).Channel() { + // Process results + } + testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, execution.Name) + }() - return + return c.JSON(execution) } } diff --git a/pkg/tcl/expressionstcl/generic.go b/pkg/tcl/expressionstcl/generic.go index ef5d895cd71..5c62fb1c97c 100644 --- a/pkg/tcl/expressionstcl/generic.go +++ b/pkg/tcl/expressionstcl/generic.go @@ -30,14 +30,30 @@ func parseTag(tag string) tagData { return tagData{value: s[0]} } +func hasUnexportedFields(v reflect.Value) bool { + if v.Kind() != reflect.Struct { + return false + } + t := v.Type() + for i := 0; i < t.NumField(); i++ { + if !t.Field(i).IsExported() { + return true + } + } + return false +} + func clone(v reflect.Value) reflect.Value { if v.Kind() == reflect.String { s := v.String() return reflect.ValueOf(&s).Elem() } else if v.Kind() == reflect.Struct { r := reflect.New(v.Type()).Elem() + t := v.Type() for i := 0; i < r.NumField(); i++ { - r.Field(i).Set(v.Field(i)) + if t.Field(i).IsExported() { + r.Field(i).Set(v.Field(i)) + } } return r } @@ -116,7 +132,7 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalize bool) return changed, nil } for _, k := range v.MapKeys() { - if t.value != "" || force { + if (t.value != "" || force) && !hasUnexportedFields(v.MapIndex(k)) { // It's not possible to get a pointer to map element, // so we need to copy it and reassign item := clone(v.MapIndex(k)) @@ -130,7 +146,7 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalize bool) } v.SetMapIndex(k, item) } - if t.key != "" || force { + if (t.key != "" || force) && !hasUnexportedFields(k) && !hasUnexportedFields(v.MapIndex(k)) { key := clone(k) var ch bool ch, err = resolve(key, tagData{value: t.key}, m, force, finalize) @@ -143,7 +159,7 @@ func resolve(v reflect.Value, t tagData, m []Machine, force bool, finalize bool) if !key.Equal(k) { item := clone(v.MapIndex(k)) v.SetMapIndex(k, reflect.Value{}) - v.SetMapIndex(key, item) + v.SetMapIndex(key.Convert(k.Type()), item) } } } diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/cleanup.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/cleanup.go new file mode 100644 index 00000000000..57d41ebe085 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/cleanup.go @@ -0,0 +1,64 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "context" + "errors" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" +) + +func cleanupConfigMaps(ctx context.Context, clientSet kubernetes.Interface, namespace, id string) error { + return clientSet.CoreV1().ConfigMaps(namespace).DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", testworkflowprocessor.ExecutionIdLabelName, id), + }) +} + +func cleanupSecrets(ctx context.Context, clientSet kubernetes.Interface, namespace, id string) error { + return clientSet.CoreV1().Secrets(namespace).DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", testworkflowprocessor.ExecutionIdLabelName, id), + }) +} + +func cleanupPods(ctx context.Context, clientSet kubernetes.Interface, namespace, id string) error { + return clientSet.CoreV1().Pods(namespace).DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", testworkflowprocessor.ExecutionIdLabelName, id), + }) +} + +func cleanupJobs(ctx context.Context, clientSet kubernetes.Interface, namespace, id string) error { + return clientSet.BatchV1().Jobs(namespace).DeleteCollection(ctx, metav1.DeleteOptions{ + PropagationPolicy: common.Ptr(metav1.DeletePropagationBackground), + }, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", testworkflowprocessor.ExecutionIdLabelName, id), + }) +} + +func Cleanup(ctx context.Context, clientSet kubernetes.Interface, namespace, id string) error { + var errs []error + ops := []func(context.Context, kubernetes.Interface, string, string) error{ + cleanupJobs, + cleanupPods, + cleanupConfigMaps, + cleanupSecrets, + } + for _, op := range ops { + err := op(ctx, clientSet, namespace, id) + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go new file mode 100644 index 00000000000..5eee19e7f6a --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -0,0 +1,359 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" +) + +const ( + JobRetrievalTimeout = 3 * time.Second +) + +type Controller interface { + Abort(ctx context.Context) error + Cleanup(ctx context.Context) error + Watch(ctx context.Context) Watcher[Notification] +} + +func New(parentCtx context.Context, clientSet kubernetes.Interface, namespace, id string, scheduledAt time.Time) (Controller, error) { + // Create local context for stopping all the processes + ctx, ctxCancel := context.WithCancel(parentCtx) + + // Optimistically, start watching all the resources + job := WatchJob(ctx, clientSet, namespace, id, 1) + jobEvents := WatchJobEvents(ctx, clientSet, namespace, id, -1) + pod := WatchMainPod(ctx, clientSet, namespace, id, 1) + podEvents := WatchPodEventsByPodWatcher(ctx, clientSet, namespace, pod, -1) + + // Ensure the main Job exists in the cluster, + // and obtain the signature + var sig []testworkflowprocessor.Signature + var err error + select { + case j := <-job.Any(ctx): + if j.Error != nil { + ctxCancel() + return nil, j.Error + } + sig, err = testworkflowprocessor.GetSignatureFromJSON([]byte(j.Value.Annotations[testworkflowprocessor.SignatureAnnotationName])) + if err != nil { + ctxCancel() + return nil, errors.Wrap(err, "invalid job signature") + } + case <-time.After(JobRetrievalTimeout): + ctxCancel() + return nil, ctx.Err() + } + + // Build accessible controller + return &controller{ + id: id, + namespace: namespace, + scheduledAt: scheduledAt, + signature: sig, + clientSet: clientSet, + ctx: ctx, + ctxCancel: ctxCancel, + job: job, + jobEvents: jobEvents, + pod: pod, + podEvents: podEvents, + }, nil +} + +type controller struct { + id string + namespace string + scheduledAt time.Time + signature []testworkflowprocessor.Signature + clientSet kubernetes.Interface + ctx context.Context + ctxCancel context.CancelFunc + job Watcher[*batchv1.Job] + jobEvents Watcher[*corev1.Event] + pod Watcher[*corev1.Pod] + podEvents Watcher[*corev1.Event] +} + +func (c *controller) Abort(ctx context.Context) error { + return c.Cleanup(ctx) +} + +func (c *controller) Cleanup(ctx context.Context) error { + return Cleanup(ctx, c.clientSet, c.namespace, c.id) +} + +func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { + ctx, ctxCancel := context.WithCancel(parentCtx) + w := newWatcher[Notification](ctx, 0) + + go func() { + defer w.Close() + defer ctxCancel() + + sig := make([]testkube.TestWorkflowSignature, len(c.signature)) + for i, s := range c.signature { + sig[i] = s.ToInternal() + } + + // Build initial result + result := testkube.TestWorkflowResult{ + Status: common.Ptr(testkube.QUEUED_TestWorkflowStatus), + PredictedStatus: common.Ptr(testkube.PASSED_TestWorkflowStatus), + Initialization: &testkube.TestWorkflowStepResult{ + Status: common.Ptr(testkube.QUEUED_TestWorkflowStepStatus), + }, + Steps: testworkflowprocessor.MapSignatureListToStepResults(c.signature), + } + + // Emit initial empty result + w.SendValue(Notification{Result: result.Clone()}) + + // Wait for the pod creation + for v := range WatchJobPreEvents(ctx, c.jobEvents, 0).Stream(ctx).Channel() { + if v.Error != nil { + w.SendError(v.Error) + continue + } + if v.Value.Reason == "SuccessfulCreate" { + result.QueuedAt = v.Value.CreationTimestamp.Time + } + if v.Value.Type == "Normal" { + continue + } + w.SendValue(Notification{ + Timestamp: v.Value.CreationTimestamp.Time, + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + }) + } + + // Emit the result with queue time + if result.QueuedAt.IsZero() { + w.SendError(errors.New("job is in unknown state")) + return + } + w.SendValue(Notification{Result: result.Clone()}) + + // Wait for the pod initialization + for v := range WatchPodPreEvents(ctx, c.podEvents, 0).Stream(ctx).Channel() { + if v.Error != nil { + w.SendError(v.Error) + continue + } + if v.Value.Reason == "Scheduled" { + result.StartedAt = v.Value.CreationTimestamp.Time + result.Status = common.Ptr(testkube.RUNNING_TestWorkflowStatus) + } + if v.Value.Type == "Normal" { + continue + } + w.SendValue(Notification{ + Timestamp: v.Value.CreationTimestamp.Time, + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + }) + } + + // Emit the result with start time + if result.StartedAt.IsZero() { + w.SendError(errors.New("pod is in unknown state")) + return + } + w.SendValue(Notification{Result: result.Clone()}) + + // Wait for the initialization container + for v := range WatchContainerPreEvents(ctx, c.podEvents, "tktw-init", 0).Stream(ctx).Channel() { + if v.Error != nil { + w.SendError(v.Error) + continue + } + if v.Value.Reason == "Created" { + result.Initialization.QueuedAt = v.Value.CreationTimestamp.Time + } else if v.Value.Reason == "Started" { + result.Initialization.StartedAt = v.Value.CreationTimestamp.Time + result.Initialization.Status = common.Ptr(testkube.RUNNING_TestWorkflowStepStatus) + } + if v.Value.Type == "Normal" { + continue + } + w.SendValue(Notification{ + Timestamp: v.Value.CreationTimestamp.Time, + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + }) + } + + // Emit the result with start time + if result.Initialization.StartedAt.IsZero() { + w.SendError(errors.New("init container is in unknown state")) + return + } + w.SendValue(Notification{Result: result.Clone()}) + + // Watch the initialization container logs + pod := (<-c.pod.Any(ctx)).Value + for v := range WatchContainerLogs(ctx, c.clientSet, c.podEvents, c.namespace, pod.Name, "tktw-init").Stream(ctx).Channel() { + if v.Error != nil { + w.SendError(v.Error) + continue + } + // TODO: Calibrate clock with v.Value.Hint or just first/last timestamp here + w.SendValue(Notification{ + Timestamp: v.Value.Time, + Log: fmt.Sprintf("%s %s\n", v.Value.Time.Format(time.RFC3339Nano), string(v.Value.Log)), + }) + } + + // Update the initialization container status + status, err := GetFinalContainerResult(ctx, c.pod, "tktw-init") + if err != nil { + w.SendError(err) + return + } + result.Initialization.FinishedAt = status.FinishedAt + result.Initialization.Status = common.Ptr(status.Status) + if status.Status != testkube.PASSED_TestWorkflowStepStatus { + result.Status = common.Ptr(testkube.FAILED_TestWorkflowStatus) + result.PredictedStatus = result.Status + } + w.SendValue(Notification{Result: result.Clone()}) + + // Cancel when the initialization has failed + if status.Status != testkube.PASSED_TestWorkflowStepStatus { + return + } + + // Watch each of the containers + lastTs := result.Initialization.FinishedAt + for _, container := range append(pod.Spec.InitContainers, pod.Spec.Containers...) { + // Ignore not-standard TestWorkflow containers + if _, ok := result.Steps[container.Name]; !ok { + continue + } + + // Send the step queued time + stepResult := result.Steps[container.Name] + stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ + QueuedAt: lastTs.UTC(), + }) + w.SendValue(Notification{Result: result.Clone()}) + + // Watch for the container events + for v := range WatchContainerPreEvents(ctx, c.podEvents, container.Name, 0).Stream(ctx).Channel() { + if v.Error != nil { + w.SendError(v.Error) + continue + } + if v.Value.Reason == "Created" { + stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ + QueuedAt: v.Value.CreationTimestamp.Time.UTC(), + }) + } else if v.Value.Reason == "Started" { + stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ + StartedAt: v.Value.CreationTimestamp.Time.UTC(), + Status: common.Ptr(testkube.RUNNING_TestWorkflowStepStatus), + }) + } + if v.Value.Type == "Normal" { + continue + } + w.SendValue(Notification{ + Timestamp: v.Value.CreationTimestamp.Time, + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + }) + } + + // Emit the next result + if stepResult.StartedAt.IsZero() { + w.SendError(errors.New("step container is in unknown state")) + return + } + w.SendValue(Notification{Result: result.Clone()}) + + // Watch for the container logs, outputs and statuses + // TODO: Calibrate clock with Hints + for v := range WatchContainerLogs(ctx, c.clientSet, c.podEvents, c.namespace, pod.Name, container.Name).Stream(ctx).Channel() { + if v.Error != nil { + w.SendError(v.Error) + continue + } + if v.Value.Hint != nil { + if v.Value.Hint.Name == "start" && v.Value.Hint.Ref == container.Name { + if v.Value.Time.After(stepResult.StartedAt) { + stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ + StartedAt: v.Value.Time.UTC(), + }) + } + } else if v.Value.Hint.Name == "status" { + status := testkube.TestWorkflowStepStatus(v.Value.Hint.Value.(string)) + if status == "" { + status = testkube.PASSED_TestWorkflowStepStatus + } + if _, ok := result.Steps[v.Value.Hint.Ref]; ok { + stepResult = result.UpdateStepResult(sig, v.Value.Hint.Ref, testkube.TestWorkflowStepResult{ + Status: &status, + }) + } + } + continue + } + if v.Value.Output != nil { + if _, ok := result.Steps[v.Value.Output.Ref]; ok { + w.SendValue(Notification{ + Timestamp: v.Value.Time, + Ref: v.Value.Output.Ref, + Output: v.Value.Output, + }) + } + continue + } + w.SendValue(Notification{Timestamp: v.Value.Time, Log: string(v.Value.Log)}) + } + + // Watch container status + status, err := GetFinalContainerResult(ctx, c.pod, container.Name) + if err != nil { + w.SendError(err) + return + } + stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ + FinishedAt: status.FinishedAt.UTC(), + ExitCode: float64(status.ExitCode), + Status: common.Ptr(status.Status), + }) + w.SendValue(Notification{Result: result.Clone()}) + + // Update the last timestamp + lastTs = status.FinishedAt + } + + // Read the pod finish time + for v := range c.job.Stream(ctx).Channel() { + if v.Value != nil && v.Value.Status.CompletionTime != nil { + result.FinishedAt = v.Value.Status.CompletionTime.Time + } + } + + // Compute the TestWorkflow status and dates + result.Recompute(sig) + w.SendValue(Notification{Result: result.Clone()}) + }() + + return w +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go new file mode 100644 index 00000000000..f5fe7b60a6e --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go @@ -0,0 +1,302 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "time" + + errors2 "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/utils" +) + +type Instruction struct { + Ref string + Name string + Value interface{} +} + +func (i *Instruction) ToInternal() *testkube.TestWorkflowOutput { + if i == nil { + return nil + } + value := &i.Value + if i.Value == nil { + value = nil + } + return &testkube.TestWorkflowOutput{ + Ref: i.Ref, + Name: i.Name, + Value: value, + } +} + +type Comment struct { + Time time.Time + Hint *Instruction + Output *Instruction +} + +type ContainerLog struct { + Time time.Time + Log []byte + Hint *Instruction + Output *Instruction +} + +type ContainerResult struct { + Status testkube.TestWorkflowStepStatus + ExitCode int + FinishedAt time.Time +} + +var UnknownContainerResult = ContainerResult{ + Status: testkube.ABORTED_TestWorkflowStepStatus, + ExitCode: -1, +} + +func GetContainerResult(c corev1.ContainerStatus) ContainerResult { + if c.State.Waiting != nil { + return ContainerResult{Status: testkube.QUEUED_TestWorkflowStepStatus, ExitCode: -1} + } + if c.State.Running != nil { + return ContainerResult{Status: testkube.RUNNING_TestWorkflowStepStatus, ExitCode: -1} + } + re := regexp.MustCompile(`^([^,]*),(0|[1-9]\d*)$`) + msg := c.State.Terminated.Message + match := re.FindStringSubmatch(msg) + if match == nil { + return ContainerResult{Status: testkube.ABORTED_TestWorkflowStepStatus, ExitCode: -1, FinishedAt: c.State.Terminated.FinishedAt.Time} + } + status := testkube.TestWorkflowStepStatus(match[1]) + exitCode, _ := strconv.Atoi(match[2]) + if status == "" { + status = testkube.PASSED_TestWorkflowStepStatus + } + return ContainerResult{Status: status, ExitCode: exitCode, FinishedAt: c.State.Terminated.FinishedAt.Time} +} + +func GetFinalContainerResult(ctx context.Context, pod Watcher[*corev1.Pod], containerName string) (ContainerResult, error) { + w := WatchContainerStatus(ctx, pod, containerName, 0) + stream := w.Stream(ctx) + defer w.Close() + + for c := range stream.Channel() { + if c.Error != nil { + return UnknownContainerResult, c.Error + } + if c.Value.State.Terminated == nil { + continue + } + return GetContainerResult(c.Value), nil + } + return UnknownContainerResult, nil +} + +var ErrNoStartedEvent = errors.New("started event not received") + +func WatchContainerPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string, cacheSize int) Watcher[*corev1.Event] { + w := newWatcher[*corev1.Event](ctx, cacheSize) + go func() { + events := WatchContainerEvents(ctx, podEvents, containerName, 0) + defer events.Close() + defer w.Close() + + for ev := range events.Stream(ctx).Channel() { + if ev.Error != nil { + w.SendError(ev.Error) + } else { + w.SendValue(ev.Value) + if ev.Value.Reason == "Started" { + return + } + } + } + }() + return w +} + +func WatchPodPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], cacheSize int) Watcher[*corev1.Event] { + w := newWatcher[*corev1.Event](ctx, cacheSize) + go func() { + defer w.Close() + + for ev := range podEvents.Stream(w.ctx).Channel() { + if ev.Error != nil { + w.SendError(ev.Error) + } else { + w.SendValue(ev.Value) + if ev.Value.Reason == "Scheduled" { + return + } + } + } + }() + return w +} + +func WaitUntilContainerIsStarted(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string) error { + events := WatchContainerPreEvents(ctx, podEvents, containerName, 0) + defer events.Close() + + for ev := range events.Stream(ctx).Channel() { + if ev.Error != nil { + return ev.Error + } else if ev.Value.Reason == "Started" { + return nil + } + } + return ErrNoStartedEvent +} + +func WatchContainerLogs(ctx context.Context, clientSet kubernetes.Interface, podEvents Watcher[*corev1.Event], namespace, podName, containerName string) Watcher[ContainerLog] { + w := newWatcher[ContainerLog](ctx, 0) + + go func() { + defer w.Close() + + // Wait until "Started" event, to avoid calling logs on the + err := WaitUntilContainerIsStarted(ctx, podEvents, containerName) + if err != nil { + w.SendError(err) + return + } + + // Create logs stream request + req := clientSet.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{ + Follow: true, + Timestamps: true, + Container: containerName, + }) + var stream io.ReadCloser + for { + stream, err = req.Stream(ctx) + if err != nil { + // The container is not necessarily already started when Started event is received + if !strings.Contains(err.Error(), "is waiting to start") { + w.SendError(err) + return + } + continue + } + break + } + + go func() { + <-w.Done() + _ = stream.Close() + }() + + // Parse and return the logs + reader := bufio.NewReader(stream) + var tsPrefix []byte + isNewLine := false + isStarted := false + var ts time.Time + for { + // Read next timestamp + ts, tsPrefix, err = ReadTimestamp(reader) + if err != nil { + if err != io.EOF { + w.SendError(err) + } + return + } + + // Check for the next part + line, err := utils.ReadLongLine(reader) + commentRe := regexp.MustCompile(fmt.Sprintf(`^%s(%s)?([^%s]+)%s([a-zA-Z0-9_.]+)(?:%s(.+))?%s$`, + data.InstructionPrefix, data.HintPrefix, data.InstructionSeparator, data.InstructionSeparator, data.InstructionValueSeparator, data.InstructionSeparator)) + + // Process the received line + if len(line) > 0 { + hadComment := false + // Fast check to avoid regexes + if len(line) >= 4 && string(line[:len(data.InstructionPrefix)]) == data.InstructionPrefix { + v := commentRe.FindSubmatch(line) + if v != nil { + isHint := string(v[1]) == data.HintPrefix + ref := string(v[2]) + name := string(v[3]) + result := Instruction{Ref: ref, Name: name} + log := ContainerLog{Time: ts} + if isHint { + log.Hint = &result + } else { + log.Output = &result + } + if len(v) > 4 && v[4] != nil { + err := json.Unmarshal(v[4], &result.Value) + if err == nil { + isNewLine = false + hadComment = true + w.SendValue(log) + } + } else { + isNewLine = false + hadComment = true + w.SendValue(log) + } + } + } + + // Append as regular log if expected + if !hadComment { + if isNewLine { + line = append(append([]byte("\n"), tsPrefix...), line...) + } else if !isStarted { + line = append(tsPrefix, line...) + isStarted = true + } + w.SendValue(ContainerLog{Time: ts, Log: line}) + isNewLine = true + } + } + + // Handle the error + if err != nil { + if err != io.EOF { + w.SendError(err) + } + return + } + } + }() + + return w +} + +func ReadTimestamp(reader *bufio.Reader) (time.Time, []byte, error) { + tsPrefix := make([]byte, 31) // 30 bytes for timestamp + 1 byte for space + count, err := io.ReadFull(reader, tsPrefix) + if err != nil { + return time.Time{}, nil, err + } + if count < 31 { + return time.Time{}, nil, io.EOF + } + ts, err := time.Parse(time.RFC3339Nano, string(tsPrefix[0:30])) + if err != nil { + return time.Time{}, nil, errors2.Wrap(err, "parsing timestamp") + } + return ts, tsPrefix, nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/notification.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/notification.go new file mode 100644 index 00000000000..22ae7ad2d03 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/notification.go @@ -0,0 +1,33 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "time" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +type Notification struct { + Timestamp time.Time `json:"ts"` + Result *testkube.TestWorkflowResult `json:"result,omitempty"` + Ref string `json:"ref,omitempty"` + Log string `json:"log,omitempty"` + Output *Instruction `json:"output,omitempty"` +} + +func (n *Notification) ToInternal() testkube.TestWorkflowExecutionNotification { + return testkube.TestWorkflowExecutionNotification{ + Ts: n.Timestamp, + Result: n.Result, + Ref: n.Ref, + Log: n.Log, + Output: n.Output.ToInternal(), + } +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go new file mode 100644 index 00000000000..a87850c7cf5 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go @@ -0,0 +1,430 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "context" + "fmt" + "reflect" + "regexp" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" +) + +func IsPodDone(pod *corev1.Pod) bool { + return pod.Status.Phase != corev1.PodPending && pod.Status.Phase != corev1.PodRunning +} + +func IsJobDone(job *batchv1.Job) bool { + return job.Status.Active == 0 && (job.Status.Succeeded > 0 || job.Status.Failed > 0) +} + +func WatchJob(ctx context.Context, clientSet kubernetes.Interface, namespace, name string, cacheSize int) Watcher[*batchv1.Job] { + w := newWatcher[*batchv1.Job](ctx, cacheSize) + + go func() { + defer w.Close() + selector := "metadata.name=" + name + + // Get initial pods + list, err := clientSet.BatchV1().Jobs(namespace).List(w.ctx, metav1.ListOptions{ + FieldSelector: selector, + }) + + // Expose the initial value + if err != nil { + w.SendError(err) + return + } + if len(list.Items) == 1 { + job := list.Items[0] + w.SendValue(&job) + if IsJobDone(&job) { + return + } + } + + // Start watching for changes + jobs, err := clientSet.BatchV1().Jobs(namespace).Watch(w.ctx, metav1.ListOptions{ + ResourceVersion: list.ResourceVersion, + FieldSelector: selector, + }) + if err != nil { + w.SendError(err) + return + } + defer jobs.Stop() + for { + // Prioritize checking for done + select { + case <-w.Done(): + return + default: + } + // Wait for results + select { + case <-w.Done(): + return + case event, ok := <-jobs.ResultChan(): + if !ok { + return + } + switch event.Type { + case watch.Added, watch.Modified: + job := event.Object.(*batchv1.Job) + w.SendValue(job) + if IsJobDone(job) { + return + } + case watch.Deleted: + w.SendValue(nil) + return + } + } + } + }() + + return w +} + +func WatchMainPod(ctx context.Context, clientSet kubernetes.Interface, namespace, name string, cacheSize int) Watcher[*corev1.Pod] { + return watchPod(ctx, clientSet, namespace, ListOptions{ + LabelSelector: testworkflowprocessor.ExecutionIdMainPodLabelName + "=" + name, + CacheSize: cacheSize, + }) +} + +func WatchPodByName(ctx context.Context, clientSet kubernetes.Interface, namespace, name string, cacheSize int) Watcher[*corev1.Pod] { + return watchPod(ctx, clientSet, namespace, ListOptions{ + FieldSelector: "metadata.name=" + name, + CacheSize: cacheSize, + }) +} + +func watchPod(ctx context.Context, clientSet kubernetes.Interface, namespace string, options ListOptions) Watcher[*corev1.Pod] { + w := newWatcher[*corev1.Pod](ctx, options.CacheSize) + + go func() { + defer w.Close() + + // Get initial pods + list, err := clientSet.CoreV1().Pods(namespace).List(w.ctx, metav1.ListOptions{ + FieldSelector: options.FieldSelector, + LabelSelector: options.LabelSelector, + }) + + // Expose the initial value + if err != nil { + w.SendError(err) + return + } + if len(list.Items) == 1 { + pod := list.Items[0] + w.SendValue(&pod) + if IsPodDone(&pod) { + return + } + } + + // Start watching for changes + pods, err := clientSet.CoreV1().Pods(namespace).Watch(w.ctx, metav1.ListOptions{ + ResourceVersion: list.ResourceVersion, + FieldSelector: options.FieldSelector, + LabelSelector: options.LabelSelector, + }) + if err != nil { + w.SendError(err) + return + } + defer pods.Stop() + for { + // Prioritize checking for done + select { + case <-w.Done(): + return + default: + } + // Wait for results + select { + case <-w.Done(): + return + case event, ok := <-pods.ResultChan(): + if !ok { + return + } + switch event.Type { + case watch.Added, watch.Modified: + pod := event.Object.(*corev1.Pod) + w.SendValue(pod) + if IsPodDone(pod) { + return + } + case watch.Deleted: + w.SendValue(nil) + return + } + } + } + }() + + return w +} + +type ListOptions struct { + FieldSelector string + LabelSelector string + TypeMeta metav1.TypeMeta + CacheSize int +} + +func GetEventContainerName(event *corev1.Event) string { + regex := regexp.MustCompile(`^spec\.(?:initContainers|containers)\{([^]]+)}`) + path := event.InvolvedObject.FieldPath + if regex.Match([]byte(path)) { + name := regex.ReplaceAllString(event.InvolvedObject.FieldPath, "$1") + return name + } + return "" +} + +func WatchContainerEvents(ctx context.Context, podEvents Watcher[*corev1.Event], name string, cacheSize int) Watcher[*corev1.Event] { + w := newWatcher[*corev1.Event](ctx, cacheSize) + go func() { + stream := podEvents.Stream(ctx) + defer stream.Stop() + defer w.Close() + for { + select { + case <-w.Done(): + return + case v, ok := <-stream.Channel(): + if ok { + if v.Error != nil { + w.SendError(v.Error) + } else if GetEventContainerName(v.Value) == name { + w.SendValue(v.Value) + } + } else { + return + } + } + } + }() + return w +} + +func WatchContainerStatus(ctx context.Context, pod Watcher[*corev1.Pod], containerName string, cacheSize int) Watcher[corev1.ContainerStatus] { + w := newWatcher[corev1.ContainerStatus](ctx, cacheSize) + + go func() { + stream := pod.Stream(ctx) + defer stream.Stop() + defer w.Close() + var prev corev1.ContainerStatus + for { + select { + case <-w.Done(): + return + case p := <-stream.Channel(): + if p.Error != nil { + w.SendError(p.Error) + continue + } + if p.Value == nil { + continue + } + for _, s := range append(p.Value.Status.InitContainerStatuses, p.Value.Status.ContainerStatuses...) { + if s.Name == containerName { + if !reflect.DeepEqual(s, prev) { + prev = s + w.SendValue(s) + } + break + } + } + if IsPodDone(p.Value) { + return + } + } + } + }() + + return w +} + +func WatchPodEventsByName(ctx context.Context, clientSet kubernetes.Interface, namespace, name string, cacheSize int) Watcher[*corev1.Event] { + return WatchEvents(ctx, clientSet, namespace, ListOptions{ + FieldSelector: "involvedObject.name=" + name, + TypeMeta: metav1.TypeMeta{Kind: "Pod"}, + CacheSize: cacheSize, + }) +} + +func WatchPodEventsByPodWatcher(ctx context.Context, clientSet kubernetes.Interface, namespace string, pod Watcher[*corev1.Pod], cacheSize int) Watcher[*corev1.Event] { + w := newWatcher[*corev1.Event](ctx, cacheSize) + + go func() { + v, ok := <-pod.Any(ctx) + if v.Error != nil { + w.SendError(v.Error) + w.Close() + return + } + if !ok || v.Value == nil { + w.Close() + return + } + watchEvents(clientSet, namespace, ListOptions{ + FieldSelector: "involvedObject.name=" + v.Value.Name, + TypeMeta: metav1.TypeMeta{Kind: "Pod"}, + }, w) + + // Adds missing "Started" events. + // It may have duplicated "Started", but better than no events. + // @see {@link https://github.com/kubernetes/kubernetes/issues/122904#issuecomment-1944387021} + started := map[string]bool{} + for p := range pod.Stream(ctx).Channel() { + if p.Value == nil { + return + } + for i, s := range append(p.Value.Status.InitContainerStatuses, p.Value.Status.ContainerStatuses...) { + if !started[s.Name] && (s.State.Running != nil || s.State.Terminated != nil) { + ts := metav1.Time{Time: time.Now()} + if s.State.Running != nil { + ts = s.State.Running.StartedAt + } else if s.State.Terminated != nil { + ts = s.State.Terminated.StartedAt + } + started[s.Name] = true + fieldPath := fmt.Sprintf("spec.containers{%s}", s.Name) + if i >= len(p.Value.Status.InitContainerStatuses) { + fieldPath = fmt.Sprintf("spec.initContainers{%s}", s.Name) + } + w.SendValue(&corev1.Event{ + ObjectMeta: metav1.ObjectMeta{CreationTimestamp: ts}, + FirstTimestamp: ts, + Type: "Normal", + Reason: "Started", + Message: fmt.Sprintf("Started container %s", s.Name), + InvolvedObject: corev1.ObjectReference{FieldPath: fieldPath}, + }) + } + } + } + }() + + return w +} + +func WatchJobEvents(ctx context.Context, clientSet kubernetes.Interface, namespace, name string, cacheSize int) Watcher[*corev1.Event] { + return WatchEvents(ctx, clientSet, namespace, ListOptions{ + FieldSelector: "involvedObject.name=" + name, + TypeMeta: metav1.TypeMeta{Kind: "Job"}, + CacheSize: cacheSize, + }) +} + +func WatchJobPreEvents(ctx context.Context, jobEvents Watcher[*corev1.Event], cacheSize int) Watcher[*corev1.Event] { + w := newWatcher[*corev1.Event](ctx, cacheSize) + go func() { + defer w.Close() + stream := jobEvents.Stream(ctx) + defer stream.Stop() + + for { + select { + case <-w.Done(): + return + case v := <-stream.Channel(): + if v.Error != nil { + w.SendError(v.Error) + } else { + w.SendValue(v.Value) + if v.Value.Reason == "SuccessfulCreate" { + return + } + } + } + } + }() + return w +} + +func WatchEvents(ctx context.Context, clientSet kubernetes.Interface, namespace string, options ListOptions) Watcher[*corev1.Event] { + return watchEvents(clientSet, namespace, options, newWatcher[*corev1.Event](ctx, options.CacheSize)) +} + +func watchEvents(clientSet kubernetes.Interface, namespace string, options ListOptions, w *watcher[*corev1.Event]) Watcher[*corev1.Event] { + go func() { + defer w.Close() + + // Get initial events + list, err := clientSet.CoreV1().Events(namespace).List(w.ctx, metav1.ListOptions{ + FieldSelector: options.FieldSelector, + LabelSelector: options.LabelSelector, + TypeMeta: options.TypeMeta, + }) + + // Expose the initial value + if err != nil { + w.SendError(err) + return + } + for _, event := range list.Items { + w.SendValue(&event) + } + if len(list.Items) == 1 { + event := list.Items[0] + w.SendValue(&event) + } + + // Start watching for changes + events, err := clientSet.CoreV1().Events(namespace).Watch(w.ctx, metav1.ListOptions{ + ResourceVersion: list.ResourceVersion, + FieldSelector: options.FieldSelector, + LabelSelector: options.LabelSelector, + TypeMeta: options.TypeMeta, + }) + if err != nil { + w.SendError(err) + return + } + defer events.Stop() + for { + // Prioritize checking for done + select { + case <-w.Done(): + return + default: + } + // Wait for results + select { + case <-w.Done(): + return + case event, ok := <-events.ResultChan(): + if !ok { + return + } + switch event.Type { + case watch.Added, watch.Modified: + w.SendValue(event.Object.(*corev1.Event)) + } + } + } + }() + + return w +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher.go new file mode 100644 index 00000000000..7bdac0fb40b --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher.go @@ -0,0 +1,410 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "context" + "slices" + "sync" +) + +type WatcherValue[T interface{}] struct { + Value T + Error error +} + +type Watcher[T interface{}] interface { + Next(ctx context.Context) <-chan WatcherValue[T] + Any(ctx context.Context) <-chan WatcherValue[T] + Done() <-chan struct{} + Listen(fn func(WatcherValue[T], bool)) func() + Stream(ctx context.Context) WatcherChannel[T] + Close() +} + +type watcher[T interface{}] struct { + ctx context.Context + ctxCancel context.CancelFunc + mu sync.Mutex + hasCh chan struct{} + ch chan WatcherValue[T] + listeners []*func(WatcherValue[T], bool) + paused bool + closed bool + + cacheSize int + cacheOffset int + cache []WatcherValue[T] + + readerCh chan<- struct{} +} + +func newWatcher[T interface{}](ctx context.Context, cacheSize int) *watcher[T] { + finalCtx, ctxCancel := context.WithCancel(ctx) + return &watcher[T]{ + ctx: finalCtx, + ctxCancel: ctxCancel, + hasCh: make(chan struct{}), + ch: make(chan WatcherValue[T]), + cacheSize: cacheSize, + } +} + +func (w *watcher[T]) Pause() { + w.mu.Lock() + defer w.mu.Unlock() + w.paused = true + if w.readerCh != nil { + close(w.readerCh) + w.readerCh = nil + } +} + +func (w *watcher[T]) Resume() { + w.mu.Lock() + defer w.mu.Unlock() + w.paused = false + w.recomputeReader() +} + +func (w *watcher[T]) Next(ctx context.Context) <-chan WatcherValue[T] { + ch := make(chan WatcherValue[T]) + var cancelListener func() + finalCtx, cancel := context.WithCancel(ctx) + var wg sync.WaitGroup + wg.Add(1) + go func() { + wg.Wait() + <-finalCtx.Done() + cancelListener() + }() + cancelListener = w.Listen(func(w WatcherValue[T], ok bool) { + wg.Wait() // on finished channel, the listener may be called before the lock goes down + cancelListener() + cancel() + if ok { + ch <- w + } + close(ch) + }) + wg.Done() + return ch +} + +func (w *watcher[T]) Any(ctx context.Context) <-chan WatcherValue[T] { + ch := make(chan WatcherValue[T]) + go func() { + w.mu.Lock() + if len(w.cache) > 0 { + v := w.cache[len(w.cache)-1] + w.mu.Unlock() + ch <- v + close(ch) + return + } + w.mu.Unlock() + v, ok := <-w.Next(ctx) + if ok { + ch <- v + } + close(ch) + }() + return ch +} + +func (w *watcher[T]) _send(v WatcherValue[T]) { + w.mu.Lock() + + // Handle closed stream + if w.closed { + w.mu.Unlock() + return + } + + // Save in cache + if w.cacheSize == 0 { + // Ignore cache + } else if w.cacheSize < 0 || w.cacheSize > len(w.cache) { + // Unlimited cache or still cache size + w.cache = append(w.cache, v) + } else { + // Emptying oldest entries in the cache + for i := 1; i < len(w.cache); i++ { + w.cache[i-1] = w.cache[i] + } + w.cache[len(w.cache)-1] = v + w.cacheOffset++ + } + w.mu.Unlock() + + // Ignore the panic due to the channel closed externally + defer func() { + recover() + }() + + // Emit the data to the live stream + w.hasCh <- struct{}{} + w.ch <- v +} + +func (w *watcher[T]) SendValue(value T) { + w._send(WatcherValue[T]{Value: value}) + +} + +func (w *watcher[T]) SendError(err error) { + w._send(WatcherValue[T]{Error: err}) +} + +func (w *watcher[T]) Close() { + w.mu.Lock() + if !w.closed { + w.ctxCancel() + ch := w.ch + w.closed = true + close(ch) + close(w.hasCh) + w.mu.Unlock() + } else { + w.mu.Unlock() + } +} + +func (w *watcher[T]) recomputeReader() { + if w.paused { + return + } + shouldRead := !w.closed && len(w.listeners) > 0 + if shouldRead && w.readerCh == nil { + // Start the reader + ch := make(chan struct{}) + w.readerCh = ch + go func() { + // Prioritize cancel channels + for { + select { + case <-ch: + return + default: + } + // Then wait for the results + select { + case <-ch: + return + case _, ok := <-w.hasCh: + listeners := slices.Clone(w.listeners) + if ok { + select { + case <-ch: + go func() { + defer func() { + recover() + }() + w.hasCh <- struct{}{} // replay hasCh in case it is needed in next iteration + }() + return + default: + } + } + value, ok := <-w.ch + var wg sync.WaitGroup + for _, l := range listeners { + wg.Add(1) + go func(fn func(WatcherValue[T], bool)) { + defer func() { + recover() + wg.Done() + }() + fn(value, ok) + }(*l) + } + wg.Wait() + } + } + }() + } else if !shouldRead && w.readerCh != nil { + // Stop the reader + close(w.readerCh) + w.readerCh = nil + } +} + +func (w *watcher[T]) stop(ptr *func(WatcherValue[T], bool)) { + w.mu.Lock() + defer w.mu.Unlock() + index := slices.Index(w.listeners, ptr) + if index == -1 { + return + } + // Delete the listener and stop a base channel reader if needed + *w.listeners[index] = func(value WatcherValue[T], ok bool) {} + w.listeners = append(w.listeners[0:index], w.listeners[index+1:]...) + w.recomputeReader() +} + +func (w *watcher[T]) listenUnsafe(fn func(WatcherValue[T], bool)) func() { + // Fail immediately if the watcher is already closed + if w.closed { + go func() { + fn(WatcherValue[T]{}, false) + }() + return func() {} + } + + // Append new listener and start a base channel reader if needed + ptr := &fn + w.listeners = append(w.listeners, ptr) + w.recomputeReader() + return func() { + w.stop(ptr) + } +} + +func (w *watcher[T]) Listen(fn func(WatcherValue[T], bool)) func() { + w.mu.Lock() + defer w.mu.Unlock() + return w.listenUnsafe(fn) +} + +func (w *watcher[T]) Done() <-chan struct{} { + return w.ctx.Done() +} + +func (w *watcher[T]) getAndLock(index int) (WatcherValue[T], int, bool) { + w.mu.Lock() + index -= w.cacheOffset + if index < 0 { + index = 0 + } + next := index + w.cacheOffset + 1 + + // Load value from cache + if index < len(w.cache) { + return w.cache[index], next, true + } + + // Fetch next result + return WatcherValue[T]{}, next, false +} + +func (w *watcher[T]) Stream(ctx context.Context) WatcherChannel[T] { + // Create the channel + wCh := &watcherChannel[T]{ + ch: make(chan WatcherValue[T]), + } + + // Handle context + finalCtx, cancel := context.WithCancel(ctx) + go func() { + <-finalCtx.Done() + wCh.Stop() + }() + + // Fast-track when there are no cached messages + w.mu.Lock() + if len(w.cache) == 0 { + wCh.cancel = w.listenUnsafe(func(v WatcherValue[T], ok bool) { + defer func() { + // Ignore writing to already closed channel + recover() + }() + if ok { + wCh.ch <- v + } else if wCh.ch != nil { + wCh.Stop() + cancel() + } + }) + w.mu.Unlock() + return wCh + } + w.mu.Unlock() + + // Pick cache data + go func() { + defer func() { + // Ignore writing to already closed channel + recover() + }() + + if wCh.ch == nil { + cancel() + return + } + + // Send cache data + wCh.cancel = func() { cancel() } + var value WatcherValue[T] + var ok bool + index := 0 + for value, index, ok = w.getAndLock(index); ok; value, index, ok = w.getAndLock(index) { + if wCh.ch == nil { + w.mu.Unlock() + cancel() + return + } + w.mu.Unlock() + wCh.ch <- value + } + + if wCh.ch == nil { + w.mu.Unlock() + cancel() + return + } + + // Start actually listening + wCh.cancel = w.listenUnsafe(func(v WatcherValue[T], ok bool) { + defer func() { + // Ignore writing to already closed channel + recover() + }() + if ok { + wCh.ch <- v + } else if wCh.ch != nil { + wCh.Stop() + cancel() + } + }) + w.mu.Unlock() + }() + + return wCh +} + +type WatcherChannel[T interface{}] interface { + Channel() <-chan WatcherValue[T] + Stop() +} + +type watcherChannel[T interface{}] struct { + cancel func() + ch chan WatcherValue[T] +} + +func (w *watcherChannel[T]) Channel() <-chan WatcherValue[T] { + if w.ch == nil { + ch := make(chan WatcherValue[T]) + close(ch) + return ch + } + return w.ch +} + +func (w *watcherChannel[T]) Stop() { + if w.cancel != nil { + w.cancel() + w.cancel = nil + if w.ch != nil { + ch := w.ch + w.ch = nil + close(ch) + } + } +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher_test.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher_test.go new file mode 100644 index 00000000000..1aea5e13c92 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/watcher_test.go @@ -0,0 +1,155 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowcontroller + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type test struct { + value string +} + +func queue(fn func()) { + var wg sync.WaitGroup + wg.Add(1) + go func() { + wg.Done() + fn() + }() + wg.Wait() +} + +func TestWatcherSync(t *testing.T) { + w := newWatcher[test](context.Background(), 0) + defer w.Close() + + go func() { + w.SendValue(test{value: "A"}) + w.SendValue(test{value: "B"}) + w.Close() + }() + a := <-w.Next(context.Background()) + b := <-w.Next(context.Background()) + c := <-w.Next(context.Background()) + _, ok := <-w.Next(context.Background()) + + assert.Equal(t, WatcherValue[test]{Value: test{value: "A"}}, a) + assert.Equal(t, WatcherValue[test]{Value: test{value: "B"}}, b) + assert.Equal(t, WatcherValue[test]{}, c) + assert.Equal(t, false, ok) +} + +func TestWatcherDistributed(t *testing.T) { + w := newWatcher[test](context.Background(), 0) + defer w.Close() + + queue(func() { + w.SendValue(test{value: "A"}) + w.SendValue(test{value: "B"}) + w.Close() + }) + + w.Pause() + aCh, bCh := w.Next(context.Background()), w.Next(context.Background()) + w.Resume() + a, b := <-aCh, <-bCh + + c := <-w.Next(context.Background()) + d := <-w.Next(context.Background()) + + assert.Equal(t, WatcherValue[test]{Value: test{value: "A"}}, a) + assert.Equal(t, WatcherValue[test]{Value: test{value: "A"}}, b) + assert.Equal(t, WatcherValue[test]{Value: test{value: "B"}}, c) + assert.Equal(t, WatcherValue[test]{}, d) +} + +func TestWatcherSyncAdvanced(t *testing.T) { + w := newWatcher[test](context.Background(), 0) + defer w.Close() + + go func() { + time.Sleep(500 * time.Microsecond) + w.SendValue(test{value: "A"}) + w.SendValue(test{value: "B"}) + w.Close() + }() + + aCh := w.Next(context.Background()) + w.SendValue(test{value: "A"}) + go w.SendValue(test{value: "B"}) + bCh := w.Next(context.Background()) + a, b := <-aCh, <-bCh + + assert.Equal(t, WatcherValue[test]{Value: test{value: "A"}}, a) + assert.Equal(t, WatcherValue[test]{Value: test{value: "B"}}, b) +} + +func TestWatcherPause(t *testing.T) { + w := newWatcher[test](context.Background(), 0) + defer w.Close() + + w.Pause() + aCh := w.Next(context.Background()) + queue(func() { + w.SendValue(test{value: "A"}) + }) + bCh := w.Next(context.Background()) + time.Sleep(500 * time.Microsecond) + var a, b WatcherValue[test] + select { + case a = <-aCh: + default: + } + select { + case b = <-bCh: + default: + } + + assert.Equal(t, WatcherValue[test]{}, a) + assert.Equal(t, WatcherValue[test]{}, b) +} + +func TestWatcherCache(t *testing.T) { + w := newWatcher[test](context.Background(), 2) + defer w.Close() + + a := w.Stream(context.Background()) + queue(func() { + w.SendValue(test{value: "A"}) + w.SendValue(test{value: "B"}) + w.SendValue(test{value: "C"}) + time.Sleep(500 * time.Microsecond) + w.SendValue(test{value: "D"}) + w.Close() + }) + av1 := <-a.Channel() + av2 := <-a.Channel() + av3 := <-a.Channel() + a.Stop() + + b := w.Stream(context.Background()) + bv1 := <-b.Channel() + bv2 := <-b.Channel() + bv3 := <-b.Channel() + _, ok := <-b.Channel() + + assert.Equal(t, WatcherValue[test]{Value: test{value: "A"}}, av1) + assert.Equal(t, WatcherValue[test]{Value: test{value: "B"}}, av2) + assert.Equal(t, WatcherValue[test]{Value: test{value: "C"}}, av3) + assert.Equal(t, WatcherValue[test]{Value: test{value: "B"}}, bv1) + assert.Equal(t, WatcherValue[test]{Value: test{value: "C"}}, bv2) + assert.Equal(t, WatcherValue[test]{Value: test{value: "D"}}, bv3) + assert.Equal(t, false, ok) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go index 857203efe1e..d1522e80501 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go @@ -20,11 +20,13 @@ import ( ) const ( - defaultImage = "busybox:1.36.1" - defaultShell = "/bin/sh" - defaultInternalPath = "/.tktw" - defaultDataPath = "/data" - executionIdLabelName = "testworkflowid" + defaultImage = "busybox:1.36.1" + defaultShell = "/bin/sh" + defaultInternalPath = "/.tktw" + defaultDataPath = "/data" + ExecutionIdLabelName = "testworkflowid" + ExecutionIdMainPodLabelName = "testworkflowid-main" + SignatureAnnotationName = "testworkflows.testkube.io/signature" ) var ( diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go index 3fad824f2b3..e41041b10f2 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go @@ -14,7 +14,9 @@ import ( "slices" "strings" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" + quantity "k8s.io/apimachinery/pkg/api/resource" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/internal/common" @@ -49,7 +51,7 @@ type ContainerAccessors interface { WorkingDir() string Detach() Container - ToKubernetesTemplate() corev1.Container + ToKubernetesTemplate() (corev1.Container, error) Resources() testworkflowsv1.Resources SecurityContext() *corev1.SecurityContext @@ -317,7 +319,7 @@ func (c *container) Detach() Container { return c } -func (c *container) ToKubernetesTemplate() corev1.Container { +func (c *container) ToKubernetesTemplate() (corev1.Container, error) { cr := c.ToContainerConfig() var command []string if cr.Command != nil { @@ -331,6 +333,29 @@ func (c *container) ToKubernetesTemplate() corev1.Container { if cr.WorkingDir != nil { workingDir = *cr.WorkingDir } + resources := corev1.ResourceRequirements{} + if cr.Resources != nil { + if len(cr.Resources.Requests) > 0 { + resources.Requests = make(corev1.ResourceList) + } + if len(cr.Resources.Limits) > 0 { + resources.Limits = make(corev1.ResourceList) + } + for k, v := range cr.Resources.Requests { + var err error + resources.Requests[k], err = quantity.ParseQuantity(v.String()) + if err != nil { + return corev1.Container{}, errors.Wrap(err, "parsing resources") + } + } + for k, v := range cr.Resources.Limits { + var err error + resources.Limits[k], err = quantity.ParseQuantity(v.String()) + if err != nil { + return corev1.Container{}, errors.Wrap(err, "parsing resources") + } + } + } return corev1.Container{ Image: cr.Image, ImagePullPolicy: cr.ImagePullPolicy, @@ -339,9 +364,10 @@ func (c *container) ToKubernetesTemplate() corev1.Container { Env: cr.Env, EnvFrom: cr.EnvFrom, VolumeMounts: c.volumeMountsCopy(), + Resources: resources, WorkingDir: workingDir, SecurityContext: cr.SecurityContext, - } + }, nil } func (c *container) ApplyImageData(image *imageinspector.Info) error { @@ -361,6 +387,9 @@ func (c *container) ApplyImageData(image *imageinspector.Info) error { c.SetArgs(image.Cmd...) } } + if image.WorkingDir != "" && c.WorkingDir() == "" { + c.SetWorkingDir(image.WorkingDir) + } return nil } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go index eac3c095453..5142b24f8d2 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go @@ -41,6 +41,7 @@ func (s *containerStage) Signature() Signature { return &signature{ RefValue: s.ref, NameValue: s.name, + CategoryValue: s.category, OptionalValue: s.optional, NegativeValue: s.negative, ChildrenValue: nil, diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go index 7d900aa63b7..49f44ce1bbc 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go @@ -62,6 +62,7 @@ func (s *groupStage) Signature() Signature { return &signature{ RefValue: s.ref, NameValue: s.name, + CategoryValue: s.category, OptionalValue: s.optional, NegativeValue: s.negative, ChildrenValue: sig, @@ -121,7 +122,9 @@ func (s *groupStage) Flatten() []Stage { // Merge stage into single one below if possible first := s.children[0] if len(s.children) == 1 && (s.name == "" || first.Name() == "") { - first.SetName(first.Name(), s.name) + if first.Name() == "" { + first.SetName(s.name) + } first.AppendConditions(s.condition) if s.negative { first.SetNegative(!first.Negative()) diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go index 13c637ffaef..71ea23db0a6 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_container.go @@ -426,11 +426,12 @@ func (mr *MockContainerMockRecorder) SetWorkingDir(arg0 interface{}) *gomock.Cal } // ToKubernetesTemplate mocks base method. -func (m *MockContainer) ToKubernetesTemplate() v10.Container { +func (m *MockContainer) ToKubernetesTemplate() (v10.Container, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ToKubernetesTemplate") ret0, _ := ret[0].(v10.Container) - return ret0 + ret1, _ := ret[1].(error) + return ret0, ret1 } // ToKubernetesTemplate indicates an expected call of ToKubernetesTemplate. diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go index 8ca35f9022d..5b23ca23f04 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/mock_stage.go @@ -68,6 +68,20 @@ func (mr *MockStageMockRecorder) ApplyImages(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyImages", reflect.TypeOf((*MockStage)(nil).ApplyImages), arg0) } +// Category mocks base method. +func (m *MockStage) Category() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Category") + ret0, _ := ret[0].(string) + return ret0 +} + +// Category indicates an expected call of Category. +func (mr *MockStageMockRecorder) Category() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Category", reflect.TypeOf((*MockStage)(nil).Category)) +} + // Condition mocks base method. func (m *MockStage) Condition() string { m.ctrl.T.Helper() @@ -226,6 +240,20 @@ func (mr *MockStageMockRecorder) RetryPolicy() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryPolicy", reflect.TypeOf((*MockStage)(nil).RetryPolicy)) } +// SetCategory mocks base method. +func (m *MockStage) SetCategory(arg0 string) StageMetadata { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetCategory", arg0) + ret0, _ := ret[0].(StageMetadata) + return ret0 +} + +// SetCategory indicates an expected call of SetCategory. +func (mr *MockStageMockRecorder) SetCategory(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCategory", reflect.TypeOf((*MockStage)(nil).SetCategory), arg0) +} + // SetCondition mocks base method. func (m *MockStage) SetCondition(arg0 string) StageLifecycle { m.ctrl.T.Helper() @@ -241,22 +269,17 @@ func (mr *MockStageMockRecorder) SetCondition(arg0 interface{}) *gomock.Call { } // SetName mocks base method. -func (m *MockStage) SetName(arg0 string, arg1 ...string) StageMetadata { +func (m *MockStage) SetName(arg0 string) StageMetadata { m.ctrl.T.Helper() - varargs := []interface{}{arg0} - for _, a := range arg1 { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "SetName", varargs...) + ret := m.ctrl.Call(m, "SetName", arg0) ret0, _ := ret[0].(StageMetadata) return ret0 } // SetName indicates an expected call of SetName. -func (mr *MockStageMockRecorder) SetName(arg0 interface{}, arg1 ...interface{}) *gomock.Call { +func (mr *MockStageMockRecorder) SetName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetName", reflect.TypeOf((*MockStage)(nil).SetName), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetName", reflect.TypeOf((*MockStage)(nil).SetName), arg0) } // SetNegative mocks base method. diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go index 928234dc926..3c44219775d 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -30,7 +30,7 @@ func ProcessDelay(_ InternalProcessor, layer Intermediate, container Container, SetCommand("sleep"). SetArgs(fmt.Sprintf("%g", t.Seconds())) stage := NewContainerStage(layer.NextRef(), shell) - stage.SetName(fmt.Sprintf("Delay: %s", step.Delay)) + stage.SetCategory(fmt.Sprintf("Delay: %s", step.Delay)) return stage, nil } @@ -39,7 +39,9 @@ func ProcessShellCommand(_ InternalProcessor, layer Intermediate, container Cont return nil, nil } shell := container.CreateChild().SetCommand(defaultShell).SetArgs("-c", step.Shell) - return NewContainerStage(layer.NextRef(), shell), nil + stage := NewContainerStage(layer.NextRef(), shell) + stage.SetCategory("Run shell command") + return stage, nil } func ProcessRunCommand(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { @@ -47,7 +49,9 @@ func ProcessRunCommand(_ InternalProcessor, layer Intermediate, container Contai return nil, nil } container = container.CreateChild().ApplyCR(&step.Run.ContainerConfig) - return NewContainerStage(layer.NextRef(), container), nil + stage := NewContainerStage(layer.NextRef(), container) + stage.SetCategory("Run") + return stage, nil } func ProcessNestedSteps(p InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go index 256cbbf14d8..8497f76e05e 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "maps" + "path/filepath" "github.com/pkg/errors" batchv1 "k8s.io/api/batch/v1" @@ -98,7 +99,6 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo AppendJobConfig(workflow.Spec.Job) layer.ContainerDefaults(). ApplyCR(defaultContainerConfig.DeepCopy()). - ApplyCR(workflow.Spec.Container). AppendVolumeMounts(layer.AddEmptyDirVolume(nil, defaultInternalPath)). AppendVolumeMounts(layer.AddEmptyDirVolume(nil, defaultDataPath)) @@ -149,6 +149,13 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo } } + // Append main label for the pod + layer.AppendPodConfig(&testworkflowsv1.PodConfig{ + Labels: map[string]string{ + ExecutionIdMainPodLabelName: "{{execution.id}}", + }, + }) + // Resolve job & pod config jobConfig, podConfig := layer.JobConfig(), layer.PodConfig() err = expressionstcl.FinalizeForce(&jobConfig, machines...) @@ -185,7 +192,7 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo } // Build list of the containers - containers, err := buildKubernetesContainers(root, NewInitProcess().SetRef(root.Ref()), images) + containers, err := buildKubernetesContainers(root, NewInitProcess().SetRef(root.Ref())) if err != nil { return nil, errors.Wrap(err, "building Kubernetes containers") } @@ -202,12 +209,22 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo if err != nil { return nil, errors.Wrap(err, "finalizing container's resources") } + + // Resolve relative paths in the volumeMounts relatively to the working dir + workingDir := defaultDataPath + if containers[i].WorkingDir != "" { + workingDir = containers[i].WorkingDir + } + for j := range containers[i].VolumeMounts { + if !filepath.IsAbs(containers[i].VolumeMounts[j].MountPath) { + containers[i].VolumeMounts[j].MountPath = filepath.Clean(filepath.Join(workingDir, containers[i].VolumeMounts[j].MountPath)) + } + } } // Build pod template podSpec := corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Name: "{{execution.id}}-pod", Annotations: podConfig.Annotations, Labels: podConfig.Labels, }, @@ -226,11 +243,11 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo } initContainer := corev1.Container{ // TODO: Resources, SecurityContext? - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s && (echo -n ',0' > %s && exit 0) || (echo -n 'failed,1' > %s && exit 1)", defaultInitPath, defaultStatePath, defaultStatePath, "/dev/termination-log", "/dev/termination-log")}, VolumeMounts: layer.ContainerDefaults().VolumeMounts(), } err = expressionstcl.FinalizeForce(&initContainer, machines...) @@ -267,7 +284,7 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo jobAnnotations := make(map[string]string) maps.Copy(jobAnnotations, jobSpec.Annotations) maps.Copy(jobAnnotations, map[string]string{ - "testworkflows.testkube.io/signature": string(sigSerialized), + SignatureAnnotationName: string(sigSerialized), }) jobSpec.Annotations = jobAnnotations diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go index 8063db11fff..67826dc670f 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go @@ -69,17 +69,19 @@ func TestProcessBasic(t *testing.T) { TypeMeta: metav1.TypeMeta{Kind: "Job", APIVersion: "batch/v1"}, ObjectMeta: metav1.ObjectMeta{ Name: "dummy-id", - Labels: map[string]string{executionIdLabelName: "dummy-id"}, + Labels: map[string]string{ExecutionIdLabelName: "dummy-id"}, Annotations: map[string]string{ - "testworkflows.testkube.io/signature": string(sigSerialized), + SignatureAnnotationName: string(sigSerialized), }, }, Spec: batchv1.JobSpec{ BackoffLimit: common.Ptr(int32(0)), Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Name: "dummy-id-pod", - Labels: map[string]string{executionIdLabelName: "dummy-id"}, + Labels: map[string]string{ + ExecutionIdLabelName: "dummy-id", + ExecutionIdMainPodLabelName: "dummy-id", + }, Annotations: map[string]string(nil), }, Spec: corev1.PodSpec{ @@ -87,11 +89,11 @@ func TestProcessBasic(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, }, @@ -162,11 +164,11 @@ func TestProcessBasicEnvReference(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, }, @@ -226,11 +228,11 @@ func TestProcessMultipleSteps(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -308,11 +310,11 @@ func TestProcessNestedSteps(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -432,11 +434,11 @@ func TestProcessOptionalSteps(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -554,11 +556,11 @@ func TestProcessNegativeSteps(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -673,11 +675,11 @@ func TestProcessNegativeContainerStep(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -749,11 +751,11 @@ func TestProcessOptionalContainerStep(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -834,11 +836,11 @@ func TestProcessLocalContent(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -926,11 +928,11 @@ func TestProcessGlobalContent(t *testing.T) { Volumes: volumes, InitContainers: []corev1.Container{ { - Name: "copy-init", + Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s", defaultInitPath, defaultStatePath, defaultStatePath)}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go index d8670d23564..d3eb59c6c27 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/signature.go @@ -8,17 +8,28 @@ package testworkflowprocessor +import ( + "encoding/json" + "maps" + + "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + type Signature interface { Ref() string Name() string + Category() string Optional() bool Negative() bool Children() []Signature + ToInternal() testkube.TestWorkflowSignature } type signature struct { RefValue string `json:"ref"` NameValue string `json:"name,omitempty"` + CategoryValue string `json:"category,omitempty"` OptionalValue bool `json:"optional,omitempty"` NegativeValue bool `json:"negative,omitempty"` ChildrenValue []Signature `json:"children,omitempty"` @@ -32,6 +43,10 @@ func (s *signature) Name() string { return s.NameValue } +func (s *signature) Category() string { + return s.CategoryValue +} + func (s *signature) Optional() bool { return s.OptionalValue } @@ -43,3 +58,74 @@ func (s *signature) Negative() bool { func (s *signature) Children() []Signature { return s.ChildrenValue } + +func (s *signature) ToInternal() testkube.TestWorkflowSignature { + return testkube.TestWorkflowSignature{ + Ref: s.RefValue, + Name: s.NameValue, + Category: s.CategoryValue, + Optional: s.OptionalValue, + Negative: s.NegativeValue, + Children: MapSignatureListToInternal(s.ChildrenValue), + } +} + +func MapSignatureListToInternal(v []Signature) []testkube.TestWorkflowSignature { + r := make([]testkube.TestWorkflowSignature, len(v)) + for i := range v { + r[i] = v[i].ToInternal() + } + return r +} + +func MapSignatureListToStepResults(v []Signature) map[string]testkube.TestWorkflowStepResult { + r := map[string]testkube.TestWorkflowStepResult{} + for _, s := range v { + r[s.Ref()] = testkube.TestWorkflowStepResult{ + Status: common.Ptr(testkube.QUEUED_TestWorkflowStepStatus), + } + maps.Copy(r, MapSignatureListToStepResults(s.Children())) + } + return r +} + +type rawSignature struct { + RefValue string `json:"ref"` + NameValue string `json:"name,omitempty"` + CategoryValue string `json:"category,omitempty"` + OptionalValue bool `json:"optional,omitempty"` + NegativeValue bool `json:"negative,omitempty"` + ChildrenValue []rawSignature `json:"children,omitempty"` +} + +func rawSignatureToSignature(sig rawSignature) Signature { + ch := make([]Signature, len(sig.ChildrenValue)) + for i, v := range sig.ChildrenValue { + ch[i] = rawSignatureToSignature(v) + } + return &signature{ + RefValue: sig.RefValue, + NameValue: sig.NameValue, + CategoryValue: sig.CategoryValue, + OptionalValue: sig.OptionalValue, + NegativeValue: sig.NegativeValue, + ChildrenValue: ch, + } +} + +func GetSignatureFromJSON(v []byte) ([]Signature, error) { + var sig []rawSignature + err := json.Unmarshal(v, &sig) + if err != nil { + return nil, err + } + res := make([]Signature, len(sig)) + for i := range sig { + res[i] = rawSignatureToSignature(sig[i]) + } + return res, err +} + +func GetVirtualSignature(children []Signature) Signature { + return &signature{ChildrenValue: children} +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go index 86a9887bc00..fc0d0c790b2 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go @@ -11,13 +11,16 @@ package testworkflowprocessor type StageMetadata interface { Ref() string Name() string + Category() string - SetName(name string, fallbacks ...string) StageMetadata + SetName(name string) StageMetadata + SetCategory(category string) StageMetadata } type stageMetadata struct { - ref string - name string `expr:"template"` + ref string + name string `expr:"template"` + category string `expr:"template"` } func NewStageMetadata(ref string) StageMetadata { @@ -32,10 +35,16 @@ func (s *stageMetadata) Name() string { return s.name } -func (s *stageMetadata) SetName(name string, fallbacks ...string) StageMetadata { +func (s *stageMetadata) Category() string { + return s.category +} + +func (s *stageMetadata) SetName(name string) StageMetadata { s.name = name - for i := 0; s.name == "" && i < len(fallbacks); i++ { - s.name = fallbacks[i] - } + return s +} + +func (s *stageMetadata) SetCategory(category string) StageMetadata { + s.category = category return s } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go index 3cff807b30d..64c8e31cf49 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go @@ -17,7 +17,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/kubeshop/testkube/internal/common" - "github.com/kubeshop/testkube/pkg/imageinspector" ) func AnnotateControlledBy(obj metav1.Object, testWorkflowId string) { @@ -25,7 +24,7 @@ func AnnotateControlledBy(obj metav1.Object, testWorkflowId string) { if labels == nil { labels = map[string]string{} } - labels[executionIdLabelName] = testWorkflowId + labels[ExecutionIdLabelName] = testWorkflowId obj.SetLabels(labels) // Annotate Pod template in the Job @@ -42,7 +41,7 @@ func isNotOptional(stage Stage) bool { return !stage.Optional() } -func buildKubernetesContainers(stage Stage, init *initProcess, images map[string]*imageinspector.Info) (containers []corev1.Container, err error) { +func buildKubernetesContainers(stage Stage, init *initProcess) (containers []corev1.Container, err error) { if stage.Timeout() != "" { init.AddTimeout(stage.Timeout(), stage.Ref()) } @@ -84,7 +83,7 @@ func buildKubernetesContainers(stage Stage, init *initProcess, images map[string init.ResetCondition() } // Pass down to another group or container - sub, serr := buildKubernetesContainers(ch, init.Children(ch.Ref()), images) + sub, serr := buildKubernetesContainers(ch, init.Children(ch.Ref())) if serr != nil { return nil, fmt.Errorf("%s: %s: resolving children: %s", stage.Ref(), stage.Name(), serr.Error()) } @@ -101,7 +100,10 @@ func buildKubernetesContainers(stage Stage, init *initProcess, images map[string return nil, fmt.Errorf("%s: %s: resolving container: %s", stage.Ref(), stage.Name(), err.Error()) } - cr := c.Container().ToKubernetesTemplate() + cr, err := c.Container().ToKubernetesTemplate() + if err != nil { + return nil, fmt.Errorf("%s: %s: building container template: %s", stage.Ref(), stage.Name(), err.Error()) + } cr.Name = c.Ref() if c.Optional() { diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go index 97489e9cef2..c51bbb04d95 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go @@ -12,6 +12,7 @@ import ( "maps" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/internal/common" @@ -23,8 +24,17 @@ func MergePodConfig(dst, include *testworkflowsv1.PodConfig) *testworkflowsv1.Po } else if include == nil { return dst } + if len(include.Labels) > 0 && dst.Labels == nil { + dst.Labels = map[string]string{} + } maps.Copy(dst.Labels, include.Labels) + if len(include.Annotations) > 0 && dst.Annotations == nil { + dst.Annotations = map[string]string{} + } maps.Copy(dst.Annotations, include.Annotations) + if len(include.NodeSelector) > 0 && dst.NodeSelector == nil { + dst.NodeSelector = map[string]string{} + } maps.Copy(dst.NodeSelector, include.NodeSelector) dst.ImagePullSecrets = append(dst.ImagePullSecrets, include.ImagePullSecrets...) if include.ServiceAccountName != "" { @@ -39,7 +49,13 @@ func MergeJobConfig(dst, include *testworkflowsv1.JobConfig) *testworkflowsv1.Jo } else if include == nil { return dst } + if len(include.Labels) > 0 && dst.Labels == nil { + dst.Labels = map[string]string{} + } maps.Copy(dst.Labels, include.Labels) + if len(include.Annotations) > 0 && dst.Annotations == nil { + dst.Annotations = map[string]string{} + } maps.Copy(dst.Annotations, include.Annotations) return dst } @@ -79,6 +95,12 @@ func MergeResources(dst, include *testworkflowsv1.Resources) *testworkflowsv1.Re } else if include == nil { return dst } + if dst.Requests == nil && len(include.Requests) > 0 { + dst.Requests = map[corev1.ResourceName]intstr.IntOrString{} + } + if dst.Limits == nil && len(include.Limits) > 0 { + dst.Limits = map[corev1.ResourceName]intstr.IntOrString{} + } maps.Copy(dst.Requests, include.Requests) maps.Copy(dst.Limits, include.Limits) return dst From 4a3ceb1ffd6ace391f6b593305f7a22dc6799b86 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Wed, 6 Mar 2024 17:36:01 +0100 Subject: [PATCH 173/234] docs: add test suite step params example (#5112) --- docs/docs/articles/creating-test-suites.md | 207 ++++++++++++++++++ .../running-parallel-tests-with-test-suite.md | 80 ------- 2 files changed, 207 insertions(+), 80 deletions(-) diff --git a/docs/docs/articles/creating-test-suites.md b/docs/docs/articles/creating-test-suites.md index 672aaaca33e..6aeac587f9a 100644 --- a/docs/docs/articles/creating-test-suites.md +++ b/docs/docs/articles/creating-test-suites.md @@ -107,3 +107,210 @@ spec: ``` Your `Test Suite` is defined and you can start running testing workflows. + +## Test Suite Steps + +Test Suite Steps are the individual components or actions that make up a Test Suite. They are typically a sequence of tests that are run in a specific order. There are two types of Test Suite Steps: + +Tests: These are the actual tests to be run. They could be unit tests, integration tests, functional tests, etc., depending on the context. + +Delays: These are time delays inserted between tests. They are used to wait for a certain period of time before proceeding to the next test. This can be useful in situations where you need to wait for some process to complete or some condition to be met before proceeding. + +Similar to running a Test, running a Test Suite Step based on a test allows for specific execution request parameters to be overwritten. Step level parameters overwrite Test Suite level parameters, which in turn overwrite Test level parameters. The Step level parameters are configurable only via CRDs at the moment. + +For details on which parameters are available in the CRDs, please consult the table below: + +| Parameter | Test | Test Suite | Test Step | +| ---------------------------------- | ---- | ---------- | --------- | +| name | ✓ | ✓ | | +| testSuiteName | ✓ | | | +| number | ✓ | | | +| executionLabels | ✓ | ✓ | ✓ | +| namespace | ✓ | ✓ | | +| variablesFile | ✓ | | | +| isVariablesFileUploaded | ✓ | | | +| variables | ✓ | ✓ | | +| testSecretUUID | ✓ | | | +| testSuiteSecretUUID | ✓ | | | +| args | ✓ | | ✓ | +| argsMode | ✓ | | ✓ | +| command | ✓ | | ✓ | +| image | ✓ | | | +| imagePullSecrets | ✓ | | | +| sync | ✓ | ✓ | ✓ | +| httpProxy | ✓ | ✓ | ✓ | +| httpsProxy | ✓ | ✓ | ✓ | +| negativeTest | ✓ | | | +| activeDeadlineSeconds | ✓ | | | +| artifactRequest | ✓ | | | +| jobTemplate | ✓ | ✓ | ✓ | +| jobTemplateReference | ✓ | ✓ | ✓ | +| cronJobTemplate | ✓ | ✓ | ✓ | +| cronJobTemplateReference | ✓ | ✓ | ✓ | +| preRunScript | ✓ | | | +| postRunScript | ✓ | | | +| executePostRunScriptBeforeScraping | ✓ | | | +| sourceScripts | ✓ | | | +| scraperTemplate | ✓ | ✓ | ✓ | +| scraperTemplateReference | ✓ | ✓ | ✓ | +| pvcTemplate | ✓ | ✓ | ✓ | +| pvcTemplateReference | ✓ | ✓ | ✓ | +| envConfigMaps | ✓ | | | +| envSecrets | ✓ | | | +| runningContext | ✓ | ✓ | ✓ | +| slavePodRequest | ✓ | | | +| secretUUID | | ✓ | | +| labels | | ✓ | | +| timeout | | ✓ | | + +Similar to Tests and Test Suites, Test Suite Steps can also have a field of type `executionRequest` like in the example below: + +```yaml +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: jmeter-special-cases + namespace: testkube + labels: + core-tests: special-cases +spec: + description: "jmeter and jmeterd executor - special-cases" + steps: + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-custom-envs-replication + executionRequest: + args: ["-d", "-s"] + ... + - stopOnFailure: false + execute: + - test: jmeterd-executor-smoke-env-value-in-args +``` + +The `Definition` section of each Test Suite in the Testkube UI offers the opportunity to directly edit the Test Suite CRDs. Besides that, consider also using `kubectl edit testsuite/jmeter-special-cases -n testkube` on the command line. + +### Usage Example + +An example of use case for test suite step parameters would be running the same K6 load test with different arguments and memory and CPU requirements. + +1. Create and Configure the Test + +Let's say our test CRD stored in the file `k6-test.yaml` looks the following: + +```yaml +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: k6-test-parallel + labels: + core-tests: executors + namespace: testkube +spec: + type: k6/script + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/k6/executor-tests/ + executionRequest: + args: + - k6-smoke-test-without-envs.js + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 128m\n" + activeDeadlineSeconds: 180 +``` + +We can apply this from the command line using: + +```bash +kubectl apply -f k6-test.yaml +``` + +2. Run the Test + +To run this test, execute: + +```bash +testkube run test k6-test-parallel +``` + +A new Testkube execution will be created. If you investigate the new job assigned to this execution, you will see the memory and cpu limit specified in the job template was set. Checking the arguments from the `executionRequest` is also possible with: + +```bash +kubectl testkube get execution k6-test-parallel-1 +``` + +3. Create and Configure the Test Suite + +We are content with the test created, but we need to make sure our application works with different kinds of loads. We could create a new Test with different parameters, but that would come with the overhead of having to manage and sync two instances of the same test. Creating a test suite makes test orchestration a more robust operation. + +We have the following `k6-test-suite.yaml` file: + +```yaml +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: k6-parallel + namespace: testkube +spec: + description: "k6 parallel testsuite" + steps: + - stopOnFailure: false + execute: + - test: k6-test-parallel + executionRequest: + argsMode: override + args: + - -vu + - "1" + - k6-smoke-test-without-envs.js + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 64Mi\n cpu: 128m\n" + - test: k6-test-parallel + executionRequest: + argsMode: override + args: + - -vu + - "2" + - k6-smoke-test-without-envs.js +``` + +Note that there are two steps in there running the same test. The difference is in their `executionRequest`. The first step is setting the number of virtual users to one and updating the jobTemplate to use a different memory requirement. The second test updates the VUs to 2. + +Create the test suite with the command: + +```bash +kubectl apply -f k6-test-suite.yaml +``` + +4. Run the Test Suite + +Run the test suite with: + +```bash +kubectl testkube run testsuite k6-parallel +``` + +The output of both of the test runs can be examined with: + +```bash +testkube get execution k6-parallel-k6-test-parallel-2 + +testkube get execution k6-parallel-k6-test-parallel-3 +``` + +The logs show the exact commands: + +```bash +... +🔬 Executing in directory /data/repo: + $ k6 run test/k6/executor-tests/k6-smoke-test-without-envs.js -vu 1 +... +🔬 Executing in directory /data/repo: + $ k6 run test/k6/executor-tests/k6-smoke-test-without-envs.js -vu 2 +... +``` + +The job template configuration will be visible on the job level, running `kubectl get jobs -n testkube` and `kubectl get job ${job_id} -o yaml -n testkube` should be enough to check the settings. + +Now we know how to increase the flexibility, reusability and scalability of your tests using test suites. By setting parameters on test suite step levels, we are making our testing automation more robust and easier to manage. diff --git a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md index 4280fed9df1..a336bdafbfb 100644 --- a/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md +++ b/docs/docs/testkube-pro/articles/running-parallel-tests-with-test-suite.md @@ -27,83 +27,3 @@ For this test suite, we have added 5 tests that all run in parallel: Here is an example of a Test Suite sequence with 2 tests running in parallel and, when they complete, a single test runs, then 2 addtional parallel tests: ![Test and Order of Execution](../../img/test-and-order-of-execution.png) - -## Test Suite Steps - -Test Suite Steps can be of two types: - -1. Tests: tests to be run. -2. Delays: time delays to wait in between tests. - -Similar to running a Test, running a Test Suite Step based on a test allows for specific execution request parameters to be overwritten. Step level parameters overwrite Test Suite level parameters, which in turn overwrite Test level parameters. The Step level parameters are configurable only via CRDs at the moment. - -For details on which parameters are available in the CRDs, please consult the table below: - -| Parameter | Test | Test Suite | Test Step | -| ---------------------------------- | ---- | ---------- | --------- | -| name | ✓ | ✓ | | -| testSuiteName | ✓ | | | -| number | ✓ | | | -| executionLabels | ✓ | ✓ | ✓ | -| namespace | ✓ | ✓ | | -| variablesFile | ✓ | | | -| isVariablesFileUploaded | ✓ | | | -| variables | ✓ | ✓ | | -| testSecretUUID | ✓ | | | -| testSuiteSecretUUID | ✓ | | | -| args | ✓ | | ✓ | -| argsMode | ✓ | | ✓ | -| command | ✓ | | ✓ | -| image | ✓ | | | -| imagePullSecrets | ✓ | | | -| sync | ✓ | ✓ | ✓ | -| httpProxy | ✓ | ✓ | ✓ | -| httpsProxy | ✓ | ✓ | ✓ | -| negativeTest | ✓ | | | -| activeDeadlineSeconds | ✓ | | | -| artifactRequest | ✓ | | | -| jobTemplate | ✓ | ✓ | ✓ | -| jobTemplateReference | ✓ | ✓ | ✓ | -| cronJobTemplate | ✓ | ✓ | ✓ | -| cronJobTemplateReference | ✓ | ✓ | ✓ | -| preRunScript | ✓ | | | -| postRunScript | ✓ | | | -| executePostRunScriptBeforeScraping | ✓ | | | -| sourceScripts | ✓ | | | -| scraperTemplate | ✓ | ✓ | ✓ | -| scraperTemplateReference | ✓ | ✓ | ✓ | -| pvcTemplate | ✓ | ✓ | ✓ | -| pvcTemplateReference | ✓ | ✓ | ✓ | -| envConfigMaps | ✓ | | | -| envSecrets | ✓ | | | -| runningContext | ✓ | ✓ | ✓ | -| slavePodRequest | ✓ | | | -| secretUUID | | ✓ | | -| labels | | ✓ | | -| timeout | | ✓ | | - -Similar to Tests and Test Suites, Test Suite Steps can also have a field of type `executionRequest` like in the example below: - -```bash -apiVersion: tests.testkube.io/v3 -kind: TestSuite -metadata: - name: jmeter-special-cases - namespace: testkube - labels: - core-tests: special-cases -spec: - description: "jmeter and jmeterd executor - special-cases" - steps: - - stopOnFailure: false - execute: - - test: jmeterd-executor-smoke-custom-envs-replication - executionRequest: - args: ["-d", "-s"] // <- new field - ... - - stopOnFailure: false - execute: - - test: jmeterd-executor-smoke-env-value-in-args -``` - -The `Definition` section of each Test Suite in the Testkube UI offers the opportunity to directly edit the Test Suite CRDs. Besides that, consider also using `kubectl edit testsuite/jmeter-special-cases -n testkube`. From 3d90f057ccad5282991894eee241203b88fd8764 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Wed, 6 Mar 2024 20:03:22 +0300 Subject: [PATCH 174/234] fix: dep update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2aca9ffdf46..059efe1761f 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306113133-d195552b7f0f + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306165638-d83f55e2a5e5 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 2149ba1f361..06dc12b6226 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306113133-d195552b7f0f h1:BnpXUw85Rfe/MRrQ9YgzJX8N0amHCmohIO8HOLAJOl8= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306113133-d195552b7f0f/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306165638-d83f55e2a5e5 h1:O7iMHaRA15WRQ21OzEwfOQG9pUS0/o0VPLm1mnxA9mM= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306165638-d83f55e2a5e5/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= From 82cb901cfc3a7bc9ea1a2d9fd894f34a218e72c9 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 7 Mar 2024 10:52:23 +0300 Subject: [PATCH 175/234] dep: update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 059efe1761f..bf2eac8249a 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306165638-d83f55e2a5e5 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240307074605-059fddf0f7d9 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 06dc12b6226..34c322faf85 100644 --- a/go.sum +++ b/go.sum @@ -356,8 +356,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306165638-d83f55e2a5e5 h1:O7iMHaRA15WRQ21OzEwfOQG9pUS0/o0VPLm1mnxA9mM= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240306165638-d83f55e2a5e5/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240307074605-059fddf0f7d9 h1:4NPJCYprmVs47UDLOFQX3h7LnNN54af+6s1tV2Q1ZxE= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240307074605-059fddf0f7d9/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= From 96653b4c1ef26365269c39f7407f9152609078ff Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Thu, 7 Mar 2024 09:45:49 +0100 Subject: [PATCH 176/234] fix: dont attach logs to the result when v2 enabled (#5107) --- pkg/executor/client/job.go | 2 +- pkg/executor/containerexecutor/containerexecutor.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index 96b66cf9004..9e21f81fa93 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -403,7 +403,7 @@ func (c *JobExecutor) updateResultsFromPod(ctx context.Context, pod corev1.Pod, return execution.ExecutionResult, err } - // attachLogs only for previous version of logs, they are not needed here as will be passed from other sources + // don't attach logs if logs v2 is enabled - they will be streamed through the logs service attachLogs := !c.features.LogsV2 // parse job output log (JSON stream) execution.ExecutionResult, err = output.ParseRunnerOutput(logs, attachLogs) diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index 5cd9a30b014..3b08b6ce222 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -467,7 +467,12 @@ func (c *ContainerExecutor) updateResultsFromPod( if executionResult != nil { execution.ExecutionResult = executionResult } - execution.ExecutionResult.Output = output + + // don't attach logs if logs v2 is enabled - they will be streamed through the logs service + attachLogs := !c.features.LogsV2 + if attachLogs { + execution.ExecutionResult.Output = output + } if execution.ExecutionResult.IsFailed() { errorMessage := execution.ExecutionResult.ErrorMessage From 0209089d2ee027ebd8d3843ed2ea05deebc79fa7 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 7 Mar 2024 12:35:01 +0100 Subject: [PATCH 177/234] feat(TKC-1652): persist TestWorkflow Executions in the database (#5115) * fix: handling timeouts in the TestWorkflows * fix: aborting TestWorkflows * feat(TKC-1652): persist Test Workflow Executions in DB - add API for all operations on executions - add CLI for all operations on executions - add Mongo repository - update the Mongo with actual results * feat(TKC-1652): add endpoint to stream TestWorkflow notifications via WebSocket * feat(TKC-1652): extract TestWorkflow scheduling logic * feat(TKC-1652): extract TestWorkflow scheduling logic to its Executor * feat(TKC-1652): persist logs in the Object Storage, and expose them via endpoint * fix(TKC-1652): fixes for TestWorkflow controller - critical Kubernetes error - resolving expressions in the container * feat(TKC-1652): recover TestWorkflow executions after API server restart * feat: add RegisterStringMap method for Expression's Machine * fix(TKC-1652): watching events of completed pods --- api/v1/testkube.yaml | 558 +++++++++++++++++- cmd/api-server/main.go | 7 +- cmd/kubectl-testkube/commands/abort.go | 3 + cmd/kubectl-testkube/commands/get.go | 1 + .../commands/testworkflows/abort.go | 56 ++ .../commands/testworkflows/executions.go | 58 ++ .../commands/testworkflows/get.go | 20 +- .../commands/testworkflows/run.go | 78 ++- .../commands/testworkflows/watch.go | 43 ++ cmd/kubectl-testkube/commands/watch.go | 2 + .../03_testworkflow_indexes.down.json | 14 + .../03_testworkflow_indexes.up.json | 35 ++ pkg/api/v1/client/api.go | 6 + pkg/api/v1/client/interface.go | 17 +- pkg/api/v1/client/testworkflow.go | 64 +- pkg/api/v1/testkube/model_event.go | 9 +- pkg/api/v1/testkube/model_event_extended.go | 40 ++ pkg/api/v1/testkube/model_event_resource.go | 18 +- pkg/api/v1/testkube/model_event_type.go | 31 +- .../v1/testkube/model_event_type_extended.go | 36 +- .../model_test_workflow_execution_extended.go | 40 ++ .../model_test_workflow_execution_summary.go | 29 + ...est_workflow_execution_summary_extended.go | 41 ++ .../model_test_workflow_executions_result.go | 16 + .../testkube/model_test_workflow_extended.go | 61 ++ .../v1/testkube/model_test_workflow_result.go | 4 +- .../model_test_workflow_result_extended.go | 28 +- .../model_test_workflow_result_summary.go | 27 + .../model_test_workflow_status_extended.go | 45 ++ .../model_test_workflow_step_extended.go | 29 + .../testkube/model_test_workflow_summary.go | 17 + .../model_test_workflow_summary_extended.go | 33 ++ .../model_test_workflow_with_execution.go | 15 + ...l_test_workflow_with_execution_extended.go | 28 + ...el_test_workflow_with_execution_summary.go | 15 + pkg/storage/minio/minio.go | 31 + pkg/storage/storage.go | 3 + pkg/storage/storage_mock.go | 31 + pkg/tcl/apitcl/v1/server.go | 30 +- pkg/tcl/apitcl/v1/testworkflowexecutions.go | 280 ++++++++- pkg/tcl/apitcl/v1/testworkflows.go | 121 ++-- .../apitcl/v1/testworkflowwithexecutions.go | 154 +++++ pkg/tcl/apitcl/v1/utils.go | 5 +- pkg/tcl/expressionstcl/machine.go | 15 + pkg/tcl/repositorytcl/testworkflow/filter.go | 140 +++++ .../repositorytcl/testworkflow/interface.go | 87 +++ .../testworkflow/minio_output_repository.go | 68 +++ .../testworkflow/mock_output_repository.go | 110 ++++ .../testworkflow/mock_repository.go | 274 +++++++++ pkg/tcl/repositorytcl/testworkflow/mongo.go | 428 ++++++++++++++ .../testworkflowcontroller/controller.go | 13 +- .../testworkflowcontroller/logs.go | 31 +- .../testworkflowcontroller/utils.go | 30 +- .../testworkflowexecutor/executor.go | 175 ++++++ .../testworkflowexecutor/mock_executor.go | 73 +++ .../testworkflowprocessor/containerstage.go | 6 +- .../testworkflowprocessor/groupstage.go | 19 +- .../testworkflowprocessor/processor.go | 4 +- .../testworkflowprocessor/stagelifecycle.go | 6 +- .../testworkflowprocessor/stagemetadata.go | 4 +- .../testworkflowprocessor/utils.go | 7 +- 61 files changed, 3464 insertions(+), 205 deletions(-) create mode 100644 cmd/kubectl-testkube/commands/testworkflows/abort.go create mode 100644 cmd/kubectl-testkube/commands/testworkflows/executions.go create mode 100644 cmd/kubectl-testkube/commands/testworkflows/watch.go create mode 100644 internal/db-migrations/03_testworkflow_indexes.down.json create mode 100644 internal/db-migrations/03_testworkflow_indexes.up.json create mode 100644 pkg/api/v1/testkube/model_test_workflow_execution_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_execution_summary.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_execution_summary_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_executions_result.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_result_summary.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_status_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_step_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_summary.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_summary_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_with_execution.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_with_execution_extended.go create mode 100644 pkg/api/v1/testkube/model_test_workflow_with_execution_summary.go create mode 100644 pkg/tcl/apitcl/v1/testworkflowwithexecutions.go create mode 100644 pkg/tcl/repositorytcl/testworkflow/filter.go create mode 100644 pkg/tcl/repositorytcl/testworkflow/interface.go create mode 100644 pkg/tcl/repositorytcl/testworkflow/minio_output_repository.go create mode 100644 pkg/tcl/repositorytcl/testworkflow/mock_output_repository.go create mode 100644 pkg/tcl/repositorytcl/testworkflow/mock_repository.go create mode 100644 pkg/tcl/repositorytcl/testworkflow/mongo.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go create mode 100644 pkg/tcl/testworkflowstcl/testworkflowexecutor/mock_executor.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index da75c911449..b77110ab98d 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3427,7 +3427,145 @@ paths: type: array items: $ref: "#/components/schemas/Problem" - /test-workflows/:id/executions: + /test-workflow-with-executions: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/Selector" + summary: List test workflows with latest execution + description: List test workflows from the kubernetes cluster with latest execution + operationId: listTestWorkflowWithExecutions + responses: + 200: + description: successful list operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflowWithExecutionSummary" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflow-with-executions/{id}: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + summary: Get test workflow details with latest execution + description: Get test workflow details from the kubernetes cluster with latest execution + operationId: getTestWorkflowWithExecution + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflowWithExecution" + text/yaml: + schema: + type: string + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 404: + description: "the resource has not been found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflows/{id}/executions: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + summary: List test workflow executions + description: List test workflow executions + operationId: listTestWorkflowExecutionsByTestWorkflow + responses: + 200: + description: successful list operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflowExecutionsResult" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" post: tags: - test-workflows @@ -3476,6 +3614,306 @@ paths: type: array items: $ref: "#/components/schemas/Problem" + /test-workflows/{id}/metrics: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + summary: Get test workflow metrics + description: Get metrics of test workflow executions + operationId: getTestWorkflowMetrics + responses: + 200: + description: successful list operation + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionsMetrics" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflows/{id}/executions/{executionID}: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + - $ref: "#/components/parameters/executionID" + summary: Get test workflow execution + description: Get test workflow execution details + operationId: getTestWorkflowExecutionByTestWorkflow + responses: + 200: + description: successful list operation + content: + application/json: + schema: + $ref: "#/components/schemas/TestWorkflowExecution" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflows/{id}/executions/{executionID}/abort: + post: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + - $ref: "#/components/parameters/executionID" + summary: Abort test workflow execution + description: Abort test workflow execution + operationId: abortTestWorkflowExecutionByTestWorkflow + responses: + 204: + description: "no content" + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflow-executions: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + summary: List test workflow executions + description: List test workflow executions + operationId: listTestWorkflowExecutions + responses: + 200: + description: successful list operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflowExecutionsResult" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflow-executions/{executionID}: + get: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/executionID" + summary: Get test workflow execution + description: Get test workflow execution details + operationId: getTestWorkflowExecution + responses: + 200: + description: successful list operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TestWorkflowExecution" + text/yaml: + schema: + type: string + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflow-executions/{executionID}/abort: + post: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/executionID" + summary: Abort test workflow execution + description: Abort test workflow execution + operationId: abortTestWorkflowExecution + responses: + 204: + description: "no content" + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflows/{id}/abort: + post: + tags: + - test-workflows + - api + - pro + parameters: + - $ref: "#/components/parameters/ID" + summary: Abort all test workflow executions + description: Abort all test workflow executions + operationId: abortAllTestWorkflowExecutions + responses: + 204: + description: "no content" + 400: + description: "problem with selector parsing - probably some bad input occurs" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 502: + description: problem communicating with kubernetes cluster + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" /preview-test-workflow: post: tags: @@ -6271,6 +6709,8 @@ components: $ref: "#/components/schemas/Execution" testSuiteExecution: $ref: "#/components/schemas/TestSuiteExecution" + testWorkflowExecution: + $ref: "#/components/schemas/TestWorkflowExecution" clusterName: type: string description: cluster name of event @@ -6293,6 +6733,8 @@ components: - testexecution - testsuiteexecution - testsource + - testworkflow + - testworkflowexecution EventType: type: string @@ -6307,6 +6749,11 @@ components: - end-testsuite-failed - end-testsuite-aborted - end-testsuite-timeout + - queue-testworkflow + - start-testworkflow + - end-testworkflow-success + - end-testworkflow-failed + - end-testworkflow-aborted - created - updated - deleted @@ -6862,6 +7309,38 @@ components: config: $ref: "#/components/schemas/TestWorkflowConfigValue" + TestWorkflowWithExecution: + type: object + properties: + workflow: + $ref: "#/components/schemas/TestWorkflow" + latestExecution: + $ref: "#/components/schemas/TestWorkflowExecution" + + TestWorkflowWithExecutionSummary: + type: object + properties: + workflow: + $ref: "#/components/schemas/TestWorkflow" + latestExecution: + $ref: "#/components/schemas/TestWorkflowExecutionSummary" + + TestWorkflowExecutionsResult: + type: object + properties: + totals: + $ref: "#/components/schemas/ExecutionsTotals" + filtered: + $ref: "#/components/schemas/ExecutionsTotals" + results: + type: array + items: + $ref: "#/components/schemas/TestWorkflowExecutionSummary" + required: + - totals + - filtered + - results + TestWorkflowExecution: type: object properties: @@ -6906,6 +7385,80 @@ components: - name - workflow + TestWorkflowExecutionSummary: + type: object + properties: + id: + type: string + description: unique execution identifier + format: bson objectId + example: "62f395e004109209b50edfc1" + name: + type: string + description: execution name + example: "some-workflow-name-1" + number: + type: integer + description: sequence number for the execution + scheduledAt: + type: string + format: date-time + description: when the execution has been scheduled to run + statusAt: + type: string + format: date-time + description: when the execution result's status has changed last time (queued, passed, failed) + result: + $ref: "#/components/schemas/TestWorkflowResultSummary" + workflow: + $ref: "#/components/schemas/TestWorkflowSummary" + required: + - id + - name + - workflow + + TestWorkflowSummary: + type: object + properties: + name: + type: string + namespace: + type: string + labels: + type: object + additionalProperties: + type: string + annotations: + type: object + additionalProperties: + type: string + + TestWorkflowResultSummary: + type: object + properties: + status: + $ref: "#/components/schemas/TestWorkflowStatus" + predictedStatus: + $ref: "#/components/schemas/TestWorkflowStatus" + queuedAt: + type: string + format: date-time + description: when the pod was created + startedAt: + type: string + format: date-time + description: when the pod has been successfully assigned + finishedAt: + type: string + format: date-time + description: when the pod has been completed + duration: + type: string + description: Go-formatted (human-readable) duration + required: + - status + - predictedStatus + TestWorkflowExecutionNotification: type: object properties: @@ -6956,6 +7509,9 @@ components: type: string format: date-time description: when the pod has been completed + duration: + type: string + description: Go-formatted (human-readable) duration initialization: $ref: "#/components/schemas/TestWorkflowStepResult" steps: diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index ed5121ab160..f4519f43c03 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -18,6 +18,7 @@ import ( "github.com/kubeshop/testkube/pkg/imageinspector" apitclv1 "github.com/kubeshop/testkube/pkg/tcl/apitcl/v1" "github.com/kubeshop/testkube/pkg/tcl/checktcl" + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" "github.com/kubeshop/testkube/pkg/tcl/schedulertcl" "go.mongodb.org/mongo-driver/mongo" @@ -243,6 +244,8 @@ func main() { // DI var resultsRepository result.Repository var testResultsRepository testresult.Repository + var testWorkflowResultsRepository testworkflow.Repository + var testWorkflowOutputRepository testworkflow.OutputRepository var configRepository configrepository.Repository var triggerLeaseBackend triggers.LeaseBackend var artifactStorage domainstorage.ArtifactsStorage @@ -261,6 +264,7 @@ func main() { mongoResultsRepository := result.NewMongoRepository(db, cfg.APIMongoAllowDiskUse, isDocDb, result.WithFeatureFlags(features), result.WithLogsClient(logGrpcClient)) resultsRepository = mongoResultsRepository testResultsRepository = testresult.NewMongoRepository(db, cfg.APIMongoAllowDiskUse, isDocDb) + testWorkflowResultsRepository = testworkflow.NewMongoRepository(db, cfg.APIMongoAllowDiskUse) configRepository = configrepository.NewMongoRepository(db) triggerLeaseBackend = triggers.NewMongoLeaseBackend(db) minioClient := newStorageClient(cfg) @@ -271,6 +275,7 @@ func main() { log.DefaultLogger.Errorw("Error setting expiration policy", "error", expErr) } storageClient = minioClient + testWorkflowOutputRepository = testworkflow.NewMinioOutputRepository(storageClient, cfg.LogsBucket) artifactStorage = minio.NewMinIOArtifactClient(storageClient) // init storage isMinioStorage := cfg.LogsStorage == "minio" @@ -587,7 +592,7 @@ func main() { } // Apply Pro server enhancements - apitclv1.NewApiTCL(api, &proContext, kubeClient, inspector).AppendRoutes() + apitclv1.NewApiTCL(api, &proContext, kubeClient, inspector, testWorkflowResultsRepository, testWorkflowOutputRepository).AppendRoutes() api.InitEvents() if !cfg.DisableTestTriggers { diff --git a/cmd/kubectl-testkube/commands/abort.go b/cmd/kubectl-testkube/commands/abort.go index 57156e36ddb..13d6ba59752 100644 --- a/cmd/kubectl-testkube/commands/abort.go +++ b/cmd/kubectl-testkube/commands/abort.go @@ -7,6 +7,7 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/validator" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/tests" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsuites" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" "github.com/kubeshop/testkube/pkg/ui" ) @@ -32,6 +33,8 @@ func NewAbortCmd() *cobra.Command { cmd.AddCommand(tests.NewAbortExecutionsCmd()) cmd.AddCommand(testsuites.NewAbortTestSuiteExecutionCmd()) cmd.AddCommand(testsuites.NewAbortTestSuiteExecutionsCmd()) + cmd.AddCommand(testworkflows.NewAbortTestWorkflowExecutionCmd()) + cmd.AddCommand(testworkflows.NewAbortTestWorkflowExecutionsCmd()) return cmd } diff --git a/cmd/kubectl-testkube/commands/get.go b/cmd/kubectl-testkube/commands/get.go index a5a1368bad2..3e07c70e49a 100644 --- a/cmd/kubectl-testkube/commands/get.go +++ b/cmd/kubectl-testkube/commands/get.go @@ -51,6 +51,7 @@ func NewGetCmd() *cobra.Command { cmd.AddCommand(context.NewGetContextCmd()) cmd.AddCommand(templates.NewGetTemplateCmd()) cmd.AddCommand(testworkflows.NewGetTestWorkflowsCmd()) + cmd.AddCommand(testworkflows.NewGetTestWorkflowExecutionsCmd()) cmd.AddCommand(testworkflowtemplates.NewGetTestWorkflowTemplatesCmd()) cmd.PersistentFlags().StringP("output", "o", "pretty", "output type can be one of json|yaml|pretty|go-template") diff --git a/cmd/kubectl-testkube/commands/testworkflows/abort.go b/cmd/kubectl-testkube/commands/testworkflows/abort.go new file mode 100644 index 00000000000..b1842d053b0 --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/abort.go @@ -0,0 +1,56 @@ +package testworkflows + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/validator" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewAbortTestWorkflowExecutionCmd() *cobra.Command { + return &cobra.Command{ + Use: "testworkflowexecution ", + Aliases: []string{"twe", "testworkflows-execution", "testworkflow-execution"}, + Short: "Abort test workflow execution", + Args: validator.ExecutionName, + + Run: func(cmd *cobra.Command, args []string) { + executionID := args[0] + + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + execution, err := client.GetTestWorkflowExecution(executionID) + ui.ExitOnError("get execution failed", err) + + err = client.AbortTestWorkflowExecution(execution.Workflow.Name, execution.Id) + ui.ExitOnError(fmt.Sprintf("aborting testworkflow execution %s", executionID), err) + + ui.SuccessAndExit("Succesfully aborted test workflow execution", executionID) + }, + } +} + +func NewAbortTestWorkflowExecutionsCmd() *cobra.Command { + return &cobra.Command{ + Use: "testworkflowexecutions ", + Aliases: []string{"twes", "testworkflows-executions", "testworkflow-executions"}, + Short: "Abort all test workflow executions", + Args: cobra.ExactArgs(1), + + Run: func(cmd *cobra.Command, args []string) { + testWorkflowName := args[0] + + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + err = client.AbortTestWorkflowExecutions(testWorkflowName) + ui.ExitOnError(fmt.Sprintf("aborting test workflow executions for test workflow %s", testWorkflowName), err) + + ui.SuccessAndExit("Successfully aborted all test workflow executions", testWorkflowName) + }, + } +} diff --git a/cmd/kubectl-testkube/commands/testworkflows/executions.go b/cmd/kubectl-testkube/commands/testworkflows/executions.go new file mode 100644 index 00000000000..4a6874c8c5a --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/executions.go @@ -0,0 +1,58 @@ +package testworkflows + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows/renderer" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewGetTestWorkflowExecutionsCmd() *cobra.Command { + var ( + limit int + selectors []string + testWorkflowName string + ) + + cmd := &cobra.Command{ + Use: "testworkflowexecution [executionID]", + Aliases: []string{"testworkflowexecutions", "twe", "tw-execution", "twexecution"}, + Args: cobra.MaximumNArgs(1), + Short: "Gets TestWorkflow execution details", + Long: `Gets TestWorkflow execution details by ID, or list if id is not passed`, + + Run: func(cmd *cobra.Command, args []string) { + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + if len(args) == 0 { + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + executions, err := client.ListTestWorkflowExecutions(testWorkflowName, limit, strings.Join(selectors, ",")) + ui.ExitOnError("getting test workflow executions list", err) + err = render.List(cmd, testkube.TestWorkflowExecutionSummaries(executions.Results), os.Stdout) + ui.ExitOnError("rendering list", err) + return + } + + executionID := args[0] + execution, err := client.GetTestWorkflowExecution(executionID) + ui.ExitOnError("getting recent test workflow execution data id:"+execution.Id, err) + err = render.Obj(cmd, execution, os.Stdout, renderer.TestWorkflowExecutionRenderer) + ui.ExitOnError("rendering obj", err) + }, + } + + cmd.Flags().StringVarP(&testWorkflowName, "testworkflow", "w", "", "test workflow name") + cmd.Flags().IntVar(&limit, "limit", 1000, "max number of records to return") + cmd.Flags().StringSliceVarP(&selectors, "label", "l", nil, "label key value pair: --label key1=value1") + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/testworkflows/get.go b/cmd/kubectl-testkube/commands/testworkflows/get.go index f1b7beafa4d..6f833eed05d 100644 --- a/cmd/kubectl-testkube/commands/testworkflows/get.go +++ b/cmd/kubectl-testkube/commands/testworkflows/get.go @@ -10,6 +10,8 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows/renderer" + common2 "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" "github.com/kubeshop/testkube/pkg/ui" ) @@ -33,11 +35,13 @@ func NewGetTestWorkflowsCmd() *cobra.Command { ui.ExitOnError("getting client", err) if len(args) == 0 { - workflows, err := client.ListTestWorkflows(strings.Join(selectors, ",")) + workflows, err := client.ListTestWorkflowWithExecutions(strings.Join(selectors, ",")) ui.ExitOnError("getting all test workflows in namespace "+namespace, err) if crdOnly { - ui.PrintCRDs(testworkflows.MapListAPIToKube(workflows).Items, "TestWorkflow", testworkflowsv1.GroupVersion) + ui.PrintCRDs(common2.MapSlice(workflows, func(t testkube.TestWorkflowWithExecution) testworkflowsv1.TestWorkflow { + return *testworkflows.MapAPIToKube(t.Workflow) + }), "TestWorkflow", testworkflowsv1.GroupVersion) } else { err = render.List(cmd, workflows, os.Stdout) ui.PrintOnError("Rendering list", err) @@ -46,14 +50,20 @@ func NewGetTestWorkflowsCmd() *cobra.Command { } name := args[0] - workflow, err := client.GetTestWorkflow(name) + workflow, err := client.GetTestWorkflowWithExecution(name) ui.ExitOnError("getting test workflow in namespace "+namespace, err) if crdOnly { - ui.PrintCRD(testworkflows.MapTestWorkflowAPIToKube(workflow), "TestWorkflow", testworkflowsv1.GroupVersion) + ui.PrintCRD(testworkflows.MapTestWorkflowAPIToKube(*workflow.Workflow), "TestWorkflow", testworkflowsv1.GroupVersion) } else { - err = render.Obj(cmd, workflow, os.Stdout, renderer.TestWorkflowRenderer) + err = render.Obj(cmd, *workflow.Workflow, os.Stdout, renderer.TestWorkflowRenderer) ui.ExitOnError("rendering obj", err) + + if workflow.LatestExecution != nil { + ui.NL() + err = render.Obj(cmd, *workflow.LatestExecution, os.Stdout, renderer.TestWorkflowExecutionRenderer) + ui.ExitOnError("rendering obj", err) + } } }, } diff --git a/cmd/kubectl-testkube/commands/testworkflows/run.go b/cmd/kubectl-testkube/commands/testworkflows/run.go index 1d447e80b3b..f8e81b6c3e8 100644 --- a/cmd/kubectl-testkube/commands/testworkflows/run.go +++ b/cmd/kubectl-testkube/commands/testworkflows/run.go @@ -25,7 +25,6 @@ func NewRunTestWorkflowCmd() *cobra.Command { executionName string config map[string]string watchEnabled bool - silentMode bool ) cmd := &cobra.Command{ @@ -48,44 +47,69 @@ func NewRunTestWorkflowCmd() *cobra.Command { err = render.Obj(cmd, execution, os.Stdout, renderer.TestWorkflowExecutionRenderer) ui.ExitOnError("render test workflow execution", err) + ui.NL() + var exitCode = 0 if watchEnabled { + exitCode = uiWatch(execution, client) ui.NL() - result, err := watchTestWorkflowLogs(execution.Name, execution.Signature, client) // TODO(TKC-1652): Use execution.Id when will be replaced - ui.ExitOnError("reading test workflow execution logs", err) - - // Apply the result in the execution - execution.Result = result - if result.IsFinished() { - execution.StatusAt = result.FinishedAt - } - - // Display message depending on the result - switch { - case result.Initialization.ErrorMessage != "": - ui.Warn("test workflow execution failed:\n") - ui.Errf(result.Initialization.ErrorMessage) - os.Exit(1) - case result.IsFailed(): - ui.Warn("test workflow execution failed") - os.Exit(1) - case result.IsAborted(): - ui.Warn("test workflow execution aborted") - os.Exit(1) - case result.IsPassed(): - ui.Success("test workflow execution completed with success in " + result.FinishedAt.Sub(result.QueuedAt).String()) - } + } else { + uiShellWatchExecution(execution.Id) } + + uiShellGetExecution(execution.Id) + os.Exit(exitCode) }, } cmd.Flags().StringVarP(&executionName, "name", "n", "", "execution name, if empty will be autogenerated") - cmd.Flags().StringToStringVarP(&config, "env", "", map[string]string{}, "configuration variables in a form of name1=val1 passed to executor") + cmd.Flags().StringToStringVarP(&config, "config", "", map[string]string{}, "configuration variables in a form of name1=val1 passed to executor") cmd.Flags().BoolVarP(&watchEnabled, "watch", "f", false, "watch for changes after start") - cmd.Flags().BoolVarP(&silentMode, "silent", "", false, "don't print intermediate test execution") return cmd } +func uiWatch(execution testkube.TestWorkflowExecution, client apiclientv1.Client) int { + result, err := watchTestWorkflowLogs(execution.Id, execution.Signature, client) + ui.ExitOnError("reading test workflow execution logs", err) + + // Apply the result in the execution + execution.Result = result + if result.IsFinished() { + execution.StatusAt = result.FinishedAt + } + + // Display message depending on the result + switch { + case result.Initialization.ErrorMessage != "": + ui.Warn("test workflow execution failed:\n") + ui.Errf(result.Initialization.ErrorMessage) + return 1 + case result.IsFailed(): + ui.Warn("test workflow execution failed") + return 1 + case result.IsAborted(): + ui.Warn("test workflow execution aborted") + return 1 + case result.IsPassed(): + ui.Success("test workflow execution completed with success in " + result.FinishedAt.Sub(result.QueuedAt).String()) + } + return 0 +} + +func uiShellGetExecution(id string) { + ui.ShellCommand( + "Use following command to get test workflow execution details", + "kubectl testkube get twe "+id, + ) +} + +func uiShellWatchExecution(id string) { + ui.ShellCommand( + "Watch test workflow execution until complete", + "kubectl testkube watch twe "+id, + ) +} + func flattenSignatures(sig []testkube.TestWorkflowSignature) []testkube.TestWorkflowSignature { res := make([]testkube.TestWorkflowSignature, 0) for _, s := range sig { diff --git a/cmd/kubectl-testkube/commands/testworkflows/watch.go b/cmd/kubectl-testkube/commands/testworkflows/watch.go new file mode 100644 index 00000000000..f4946dabe53 --- /dev/null +++ b/cmd/kubectl-testkube/commands/testworkflows/watch.go @@ -0,0 +1,43 @@ +package testworkflows + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/render" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/validator" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows/renderer" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewWatchTestWorkflowExecutionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "testworkflowexecution ", + Aliases: []string{"testworkflowexecutions", "twe", "tw"}, + Args: validator.ExecutionName, + Short: "Watch output from test workflow execution", + Long: `Gets test workflow execution details, until it's in success/error state, blocks until gets complete state`, + + Run: func(cmd *cobra.Command, args []string) { + client, _, err := common.GetClient(cmd) + ui.ExitOnError("getting client", err) + + executionID := args[0] + execution, err := client.GetTestWorkflowExecution(executionID) + ui.ExitOnError("get execution failed", err) + err = render.Obj(cmd, execution, os.Stdout, renderer.TestWorkflowExecutionRenderer) + ui.ExitOnError("render test workflow execution", err) + + ui.NL() + exitCode := uiWatch(execution, client) + ui.NL() + + uiShellGetExecution(execution.Id) + os.Exit(exitCode) + }, + } + + return cmd +} diff --git a/cmd/kubectl-testkube/commands/watch.go b/cmd/kubectl-testkube/commands/watch.go index db02ff293db..a2cd1ce08a2 100644 --- a/cmd/kubectl-testkube/commands/watch.go +++ b/cmd/kubectl-testkube/commands/watch.go @@ -7,6 +7,7 @@ import ( "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common/validator" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/tests" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testsuites" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/testworkflows" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" "github.com/kubeshop/testkube/pkg/ui" ) @@ -31,6 +32,7 @@ func NewWatchCmd() *cobra.Command { cmd.AddCommand(tests.NewWatchExecutionCmd()) cmd.AddCommand(testsuites.NewWatchTestSuiteExecutionCmd()) + cmd.AddCommand(testworkflows.NewWatchTestWorkflowExecutionCmd()) return cmd } diff --git a/internal/db-migrations/03_testworkflow_indexes.down.json b/internal/db-migrations/03_testworkflow_indexes.down.json new file mode 100644 index 00000000000..9cf60fefb58 --- /dev/null +++ b/internal/db-migrations/03_testworkflow_indexes.down.json @@ -0,0 +1,14 @@ +[ + { + "dropIndexes": "workflowresults", + "index": [ + "workflow.name_1_statusat-1", + "workflow.name_1_scheduledat-1", + "id_1", + "name_1", + "result.status_1", + "statusat_-1", + "scheduledat_-1" + ] + } +] \ No newline at end of file diff --git a/internal/db-migrations/03_testworkflow_indexes.up.json b/internal/db-migrations/03_testworkflow_indexes.up.json new file mode 100644 index 00000000000..6026007c98b --- /dev/null +++ b/internal/db-migrations/03_testworkflow_indexes.up.json @@ -0,0 +1,35 @@ +[ + { + "createIndexes": "workflowresults", + "indexes": [ + { + "key": {"workflow.name": 1, "statusat": -1}, + "name": "workflow.name_1_statusat-1" + }, + { + "key": {"workflow.name": 1, "scheduledat": -1}, + "name": "workflow.name_1_scheduledat-1" + }, + { + "key": {"id": 1}, + "name": "id_1" + }, + { + "key": {"name": 1}, + "name": "name_1" + }, + { + "key": {"result.status": 1}, + "name": "result.status_1" + }, + { + "key": {"statusat": -1}, + "name": "statusat_-1" + }, + { + "key": {"scheduledat": -1}, + "name": "scheduledat_-1" + } + ] + } +] \ No newline at end of file diff --git a/pkg/api/v1/client/api.go b/pkg/api/v1/client/api.go index ec1adfdd3c9..f378d783314 100644 --- a/pkg/api/v1/client/api.go +++ b/pkg/api/v1/client/api.go @@ -40,7 +40,9 @@ func NewProxyAPIClient(client kubernetes.Interface, config APIConfig) APIClient TemplateClient: NewTemplateClient(NewProxyClient[testkube.Template](client, config)), TestWorkflowClient: NewTestWorkflowClient( NewProxyClient[testkube.TestWorkflow](client, config), + NewProxyClient[testkube.TestWorkflowWithExecution](client, config), NewProxyClient[testkube.TestWorkflowExecution](client, config), + NewProxyClient[testkube.TestWorkflowExecutionsResult](client, config), ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewProxyClient[testkube.TestWorkflowTemplate](client, config)), } @@ -75,7 +77,9 @@ func NewDirectAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, TemplateClient: NewTemplateClient(NewDirectClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), TestWorkflowClient: NewTestWorkflowClient( NewDirectClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix), + NewDirectClient[testkube.TestWorkflowWithExecution](httpClient, apiURI, apiPathPrefix), NewDirectClient[testkube.TestWorkflowExecution](httpClient, apiURI, apiPathPrefix), + NewDirectClient[testkube.TestWorkflowExecutionsResult](httpClient, apiURI, apiPathPrefix), ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewDirectClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } @@ -110,7 +114,9 @@ func NewCloudAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, TemplateClient: NewTemplateClient(NewCloudClient[testkube.Template](httpClient, apiURI, apiPathPrefix)), TestWorkflowClient: NewTestWorkflowClient( NewCloudClient[testkube.TestWorkflow](httpClient, apiURI, apiPathPrefix).WithSSEClient(sseClient), + NewCloudClient[testkube.TestWorkflowWithExecution](httpClient, apiURI, apiPathPrefix), NewCloudClient[testkube.TestWorkflowExecution](httpClient, apiURI, apiPathPrefix), + NewCloudClient[testkube.TestWorkflowExecutionsResult](httpClient, apiURI, apiPathPrefix), ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewCloudClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index 6799eefb4ef..36545399bc1 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -22,6 +22,7 @@ type Client interface { CopyFileAPI TemplateAPI TestWorkflowAPI + TestWorkflowExecutionAPI TestWorkflowTemplateAPI } @@ -131,7 +132,9 @@ type TestSourceAPI interface { // TestWorkflowAPI describes test workflow api methods type TestWorkflowAPI interface { GetTestWorkflow(id string) (testkube.TestWorkflow, error) + GetTestWorkflowWithExecution(id string) (testkube.TestWorkflowWithExecution, error) ListTestWorkflows(selector string) (testkube.TestWorkflows, error) + ListTestWorkflowWithExecutions(selector string) (testkube.TestWorkflowWithExecutions, error) DeleteTestWorkflows(selector string) error CreateTestWorkflow(workflow testkube.TestWorkflow) (testkube.TestWorkflow, error) UpdateTestWorkflow(workflow testkube.TestWorkflow) (testkube.TestWorkflow, error) @@ -140,6 +143,14 @@ type TestWorkflowAPI interface { GetTestWorkflowExecutionNotifications(id string) (chan testkube.TestWorkflowExecutionNotification, error) } +// TestWorkflowExecutionAPI describes test workflow api methods +type TestWorkflowExecutionAPI interface { + GetTestWorkflowExecution(executionID string) (execution testkube.TestWorkflowExecution, err error) + ListTestWorkflowExecutions(id string, limit int, selector string) (executions testkube.TestWorkflowExecutionsResult, err error) + AbortTestWorkflowExecution(workflow string, id string) error + AbortTestWorkflowExecutions(workflow string) error +} + // TestWorkflowTemplateAPI describes test workflow api methods type TestWorkflowTemplateAPI interface { GetTestWorkflowTemplate(id string) (testkube.TestWorkflowTemplate, error) @@ -258,13 +269,13 @@ type Gettable interface { testkube.Webhook | testkube.TestWithExecution | testkube.TestSuiteWithExecution | testkube.TestWithExecutionSummary | testkube.TestSuiteWithExecutionSummary | testkube.Artifact | testkube.ServerInfo | testkube.Config | testkube.DebugInfo | testkube.TestSource | testkube.Template | - testkube.TestWorkflow | testkube.TestWorkflowTemplate | testkube.TestWorkflowExecution + testkube.TestWorkflow | testkube.TestWorkflowWithExecution | testkube.TestWorkflowTemplate | testkube.TestWorkflowExecution } // Executable is an interface of executable objects type Executable interface { - testkube.Execution | testkube.TestSuiteExecution | - testkube.ExecutionsResult | testkube.TestSuiteExecutionsResult | testkube.TestWorkflowExecution + testkube.Execution | testkube.TestSuiteExecution | testkube.TestWorkflowExecution | + testkube.ExecutionsResult | testkube.TestSuiteExecutionsResult | testkube.TestWorkflowExecutionsResult } // All is an interface of all objects diff --git a/pkg/api/v1/client/testworkflow.go b/pkg/api/v1/client/testworkflow.go index 42cf7ff6b49..a02cb58b34f 100644 --- a/pkg/api/v1/client/testworkflow.go +++ b/pkg/api/v1/client/testworkflow.go @@ -11,33 +11,52 @@ import ( // NewTestWorkflowClient creates new TestWorkflow client func NewTestWorkflowClient( testWorkflowTransport Transport[testkube.TestWorkflow], + testWorkflowWithExecutionTransport Transport[testkube.TestWorkflowWithExecution], testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution], + testWorkflowExecutionsResultTransport Transport[testkube.TestWorkflowExecutionsResult], ) TestWorkflowClient { return TestWorkflowClient{ - testWorkflowTransport: testWorkflowTransport, - testWorkflowExecutionTransport: testWorkflowExecutionTransport, + testWorkflowTransport: testWorkflowTransport, + testWorkflowWithExecutionTransport: testWorkflowWithExecutionTransport, + testWorkflowExecutionTransport: testWorkflowExecutionTransport, + testWorkflowExecutionsResultTransport: testWorkflowExecutionsResultTransport, } } -// TestWorkflowClient is a client for tests +// TestWorkflowClient is a client for test workflows type TestWorkflowClient struct { - testWorkflowTransport Transport[testkube.TestWorkflow] - testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution] + testWorkflowTransport Transport[testkube.TestWorkflow] + testWorkflowWithExecutionTransport Transport[testkube.TestWorkflowWithExecution] + testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution] + testWorkflowExecutionsResultTransport Transport[testkube.TestWorkflowExecutionsResult] } -// GetTestWorkflow returns single test by id +// GetTestWorkflow returns single test workflow by id func (c TestWorkflowClient) GetTestWorkflow(id string) (testkube.TestWorkflow, error) { uri := c.testWorkflowTransport.GetURI("/test-workflows/%s", id) return c.testWorkflowTransport.Execute(http.MethodGet, uri, nil, nil) } -// ListTestWorkflows list all tests +// GetTestWorkflowWithExecution returns single test workflow with execution by id +func (c TestWorkflowClient) GetTestWorkflowWithExecution(id string) (testkube.TestWorkflowWithExecution, error) { + uri := c.testWorkflowWithExecutionTransport.GetURI("/test-workflow-with-executions/%s", id) + return c.testWorkflowWithExecutionTransport.Execute(http.MethodGet, uri, nil, nil) +} + +// ListTestWorkflows list all test workflows func (c TestWorkflowClient) ListTestWorkflows(selector string) (testkube.TestWorkflows, error) { uri := c.testWorkflowTransport.GetURI("/test-workflows") params := map[string]string{"selector": selector} return c.testWorkflowTransport.ExecuteMultiple(http.MethodGet, uri, nil, params) } +// ListTestWorkflowWithExecutions list all test workflows with their latest executions +func (c TestWorkflowClient) ListTestWorkflowWithExecutions(selector string) (testkube.TestWorkflowWithExecutions, error) { + uri := c.testWorkflowWithExecutionTransport.GetURI("/test-workflow-with-executions") + params := map[string]string{"selector": selector} + return c.testWorkflowWithExecutionTransport.ExecuteMultiple(http.MethodGet, uri, nil, params) +} + // DeleteTestWorkflows deletes multiple test workflows by labels func (c TestWorkflowClient) DeleteTestWorkflows(selector string) error { uri := c.testWorkflowTransport.GetURI("/test-workflows") @@ -105,3 +124,34 @@ func (c TestWorkflowClient) GetTestWorkflowExecutionNotifications(id string) (no err = c.testWorkflowTransport.GetTestWorkflowExecutionNotifications(uri, notifications) return notifications, err } + +// GetTestWorkflowExecution returns single test workflow execution by id +func (c TestWorkflowClient) GetTestWorkflowExecution(id string) (testkube.TestWorkflowExecution, error) { + uri := c.testWorkflowExecutionTransport.GetURI("/test-workflow-executions/%s", id) + return c.testWorkflowExecutionTransport.Execute(http.MethodGet, uri, nil, nil) +} + +// ListTestWorkflowExecutions list test workflow executions for selected workflow +func (c TestWorkflowClient) ListTestWorkflowExecutions(id string, limit int, selector string) (testkube.TestWorkflowExecutionsResult, error) { + uri := c.testWorkflowExecutionsResultTransport.GetURI("/test-workflow-executions/") + if id != "" { + uri = c.testWorkflowExecutionsResultTransport.GetURI(fmt.Sprintf("/test-workflows/%s/executions", id)) + } + params := map[string]string{ + "selector": selector, + "pageSize": fmt.Sprintf("%d", limit), + } + return c.testWorkflowExecutionsResultTransport.Execute(http.MethodGet, uri, nil, params) +} + +// AbortTestWorkflowExecution aborts selected execution +func (c TestWorkflowClient) AbortTestWorkflowExecution(workflow, id string) error { + uri := c.testWorkflowTransport.GetURI("/test-workflows/%s/executions/%s/abort", workflow, id) + return c.testWorkflowTransport.ExecuteMethod(http.MethodPost, uri, "", false) +} + +// AbortTestWorkflowExecutions aborts all workflow executions +func (c TestWorkflowClient) AbortTestWorkflowExecutions(workflow string) error { + uri := c.testWorkflowTransport.GetURI("/test-workflows/%s/abort", workflow) + return c.testWorkflowTransport.ExecuteMethod(http.MethodPost, uri, "", false) +} diff --git a/pkg/api/v1/testkube/model_event.go b/pkg/api/v1/testkube/model_event.go index 7cdccd26fd7..1e6e58cc61f 100644 --- a/pkg/api/v1/testkube/model_event.go +++ b/pkg/api/v1/testkube/model_event.go @@ -17,10 +17,11 @@ type Event struct { StreamTopic string `json:"streamTopic,omitempty"` Resource *EventResource `json:"resource"` // ID of resource - ResourceId string `json:"resourceId"` - Type_ *EventType `json:"type"` - TestExecution *Execution `json:"testExecution,omitempty"` - TestSuiteExecution *TestSuiteExecution `json:"testSuiteExecution,omitempty"` + ResourceId string `json:"resourceId"` + Type_ *EventType `json:"type"` + TestExecution *Execution `json:"testExecution,omitempty"` + TestSuiteExecution *TestSuiteExecution `json:"testSuiteExecution,omitempty"` + TestWorkflowExecution *TestWorkflowExecution `json:"testWorkflowExecution,omitempty"` // cluster name of event ClusterName string `json:"clusterName,omitempty"` // environment variables diff --git a/pkg/api/v1/testkube/model_event_extended.go b/pkg/api/v1/testkube/model_event_extended.go index 945a8ffbbba..332e9f43fcb 100644 --- a/pkg/api/v1/testkube/model_event_extended.go +++ b/pkg/api/v1/testkube/model_event_extended.go @@ -119,6 +119,46 @@ func NewEventEndTestSuiteTimeout(execution *TestSuiteExecution) Event { } } +func NewEventQueueTestWorkflow(execution *TestWorkflowExecution) Event { + return Event{ + Id: uuid.NewString(), + Type_: EventQueueTestWorkflow, + TestWorkflowExecution: execution, + } +} + +func NewEventStartTestWorkflow(execution *TestWorkflowExecution) Event { + return Event{ + Id: uuid.NewString(), + Type_: EventStartTestWorkflow, + TestWorkflowExecution: execution, + } +} + +func NewEventEndTestWorkflowSuccess(execution *TestWorkflowExecution) Event { + return Event{ + Id: uuid.NewString(), + Type_: EventEndTestWorkflowSuccess, + TestWorkflowExecution: execution, + } +} + +func NewEventEndTestWorkflowFailed(execution *TestWorkflowExecution) Event { + return Event{ + Id: uuid.NewString(), + Type_: EventEndTestWorkflowFailed, + TestWorkflowExecution: execution, + } +} + +func NewEventEndTestWorkflowAborted(execution *TestWorkflowExecution) Event { + return Event{ + Id: uuid.NewString(), + Type_: EventEndTestWorkflowAborted, + TestWorkflowExecution: execution, + } +} + func (e Event) Type() EventType { if e.Type_ != nil { return *e.Type_ diff --git a/pkg/api/v1/testkube/model_event_resource.go b/pkg/api/v1/testkube/model_event_resource.go index 34acdae7ffc..91779782cb3 100644 --- a/pkg/api/v1/testkube/model_event_resource.go +++ b/pkg/api/v1/testkube/model_event_resource.go @@ -13,12 +13,14 @@ type EventResource string // List of EventResource const ( - TEST_EventResource EventResource = "test" - TESTSUITE_EventResource EventResource = "testsuite" - EXECUTOR_EventResource EventResource = "executor" - TRIGGER_EventResource EventResource = "trigger" - WEBHOOK_EventResource EventResource = "webhook" - TESTEXECUTION_EventResource EventResource = "testexecution" - TESTSUITEEXECUTION_EventResource EventResource = "testsuiteexecution" - TESTSOURCE_EventResource EventResource = "testsource" + TEST_EventResource EventResource = "test" + TESTSUITE_EventResource EventResource = "testsuite" + EXECUTOR_EventResource EventResource = "executor" + TRIGGER_EventResource EventResource = "trigger" + WEBHOOK_EventResource EventResource = "webhook" + TESTEXECUTION_EventResource EventResource = "testexecution" + TESTSUITEEXECUTION_EventResource EventResource = "testsuiteexecution" + TESTSOURCE_EventResource EventResource = "testsource" + TESTWORKFLOW_EventResource EventResource = "testworkflow" + TESTWORKFLOWEXECUTION_EventResource EventResource = "testworkflowexecution" ) diff --git a/pkg/api/v1/testkube/model_event_type.go b/pkg/api/v1/testkube/model_event_type.go index 3a8b78a09db..1fcd825675d 100644 --- a/pkg/api/v1/testkube/model_event_type.go +++ b/pkg/api/v1/testkube/model_event_type.go @@ -13,17 +13,22 @@ type EventType string // List of EventType const ( - START_TEST_EventType EventType = "start-test" - END_TEST_SUCCESS_EventType EventType = "end-test-success" - END_TEST_FAILED_EventType EventType = "end-test-failed" - END_TEST_ABORTED_EventType EventType = "end-test-aborted" - END_TEST_TIMEOUT_EventType EventType = "end-test-timeout" - START_TESTSUITE_EventType EventType = "start-testsuite" - END_TESTSUITE_SUCCESS_EventType EventType = "end-testsuite-success" - END_TESTSUITE_FAILED_EventType EventType = "end-testsuite-failed" - END_TESTSUITE_ABORTED_EventType EventType = "end-testsuite-aborted" - END_TESTSUITE_TIMEOUT_EventType EventType = "end-testsuite-timeout" - CREATED_EventType EventType = "created" - UPDATED_EventType EventType = "updated" - DELETED_EventType EventType = "deleted" + START_TEST_EventType EventType = "start-test" + END_TEST_SUCCESS_EventType EventType = "end-test-success" + END_TEST_FAILED_EventType EventType = "end-test-failed" + END_TEST_ABORTED_EventType EventType = "end-test-aborted" + END_TEST_TIMEOUT_EventType EventType = "end-test-timeout" + START_TESTSUITE_EventType EventType = "start-testsuite" + END_TESTSUITE_SUCCESS_EventType EventType = "end-testsuite-success" + END_TESTSUITE_FAILED_EventType EventType = "end-testsuite-failed" + END_TESTSUITE_ABORTED_EventType EventType = "end-testsuite-aborted" + END_TESTSUITE_TIMEOUT_EventType EventType = "end-testsuite-timeout" + QUEUE_TESTWORKFLOW_EventType EventType = "queue-testworkflow" + START_TESTWORKFLOW_EventType EventType = "start-testworkflow" + END_TESTWORKFLOW_SUCCESS_EventType EventType = "end-testworkflow-success" + END_TESTWORKFLOW_FAILED_EventType EventType = "end-testworkflow-failed" + END_TESTWORKFLOW_ABORTED_EventType EventType = "end-testworkflow-aborted" + CREATED_EventType EventType = "created" + UPDATED_EventType EventType = "updated" + DELETED_EventType EventType = "deleted" ) diff --git a/pkg/api/v1/testkube/model_event_type_extended.go b/pkg/api/v1/testkube/model_event_type_extended.go index c21b74d5a70..8d4e1733890 100644 --- a/pkg/api/v1/testkube/model_event_type_extended.go +++ b/pkg/api/v1/testkube/model_event_type_extended.go @@ -11,6 +11,11 @@ var AllEventTypes = []EventType{ END_TESTSUITE_FAILED_EventType, END_TESTSUITE_ABORTED_EventType, END_TESTSUITE_TIMEOUT_EventType, + QUEUE_TESTWORKFLOW_EventType, + START_TESTWORKFLOW_EventType, + END_TESTWORKFLOW_SUCCESS_EventType, + END_TESTWORKFLOW_FAILED_EventType, + END_TESTWORKFLOW_ABORTED_EventType, CREATED_EventType, DELETED_EventType, UPDATED_EventType, @@ -25,19 +30,24 @@ func EventTypePtr(t EventType) *EventType { } var ( - EventStartTest = EventTypePtr(START_TEST_EventType) - EventEndTestSuccess = EventTypePtr(END_TEST_SUCCESS_EventType) - EventEndTestFailed = EventTypePtr(END_TEST_FAILED_EventType) - EventEndTestAborted = EventTypePtr(END_TEST_ABORTED_EventType) - EventEndTestTimeout = EventTypePtr(END_TEST_TIMEOUT_EventType) - EventStartTestSuite = EventTypePtr(START_TESTSUITE_EventType) - EventEndTestSuiteSuccess = EventTypePtr(END_TESTSUITE_SUCCESS_EventType) - EventEndTestSuiteFailed = EventTypePtr(END_TESTSUITE_FAILED_EventType) - EventEndTestSuiteAborted = EventTypePtr(END_TESTSUITE_ABORTED_EventType) - EventEndTestSuiteTimeout = EventTypePtr(END_TESTSUITE_TIMEOUT_EventType) - EventCreated = EventTypePtr(CREATED_EventType) - EventDeleted = EventTypePtr(DELETED_EventType) - EventUpdated = EventTypePtr(UPDATED_EventType) + EventStartTest = EventTypePtr(START_TEST_EventType) + EventEndTestSuccess = EventTypePtr(END_TEST_SUCCESS_EventType) + EventEndTestFailed = EventTypePtr(END_TEST_FAILED_EventType) + EventEndTestAborted = EventTypePtr(END_TEST_ABORTED_EventType) + EventEndTestTimeout = EventTypePtr(END_TEST_TIMEOUT_EventType) + EventStartTestSuite = EventTypePtr(START_TESTSUITE_EventType) + EventEndTestSuiteSuccess = EventTypePtr(END_TESTSUITE_SUCCESS_EventType) + EventEndTestSuiteFailed = EventTypePtr(END_TESTSUITE_FAILED_EventType) + EventEndTestSuiteAborted = EventTypePtr(END_TESTSUITE_ABORTED_EventType) + EventEndTestSuiteTimeout = EventTypePtr(END_TESTSUITE_TIMEOUT_EventType) + EventQueueTestWorkflow = EventTypePtr(QUEUE_TESTWORKFLOW_EventType) + EventStartTestWorkflow = EventTypePtr(START_TESTWORKFLOW_EventType) + EventEndTestWorkflowSuccess = EventTypePtr(END_TESTWORKFLOW_SUCCESS_EventType) + EventEndTestWorkflowFailed = EventTypePtr(END_TESTWORKFLOW_FAILED_EventType) + EventEndTestWorkflowAborted = EventTypePtr(END_TESTWORKFLOW_ABORTED_EventType) + EventCreated = EventTypePtr(CREATED_EventType) + EventDeleted = EventTypePtr(DELETED_EventType) + EventUpdated = EventTypePtr(UPDATED_EventType) ) func EventTypesFromSlice(types []string) []EventType { diff --git a/pkg/api/v1/testkube/model_test_workflow_execution_extended.go b/pkg/api/v1/testkube/model_test_workflow_execution_extended.go new file mode 100644 index 00000000000..4257004480c --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_execution_extended.go @@ -0,0 +1,40 @@ +package testkube + +import "github.com/kubeshop/testkube/pkg/utils" + +type TestWorkflowExecutions []TestWorkflowExecution + +func (executions TestWorkflowExecutions) Table() (header []string, output [][]string) { + header = []string{"Id", "Name", "Test Workflow Name", "Status", "Labels"} + + for _, e := range executions { + status := "unknown" + if e.Result != nil && e.Result.Status != nil { + status = string(*e.Result.Status) + } + + output = append(output, []string{ + e.Id, + e.Name, + e.Workflow.Name, + status, + MapToString(e.Workflow.Labels), + }) + } + + return +} + +func (e *TestWorkflowExecution) ConvertDots(fn func(string) string) *TestWorkflowExecution { + e.Workflow.ConvertDots(fn) + e.ResolvedWorkflow.ConvertDots(fn) + return e +} + +func (e *TestWorkflowExecution) EscapeDots() *TestWorkflowExecution { + return e.ConvertDots(utils.EscapeDots) +} + +func (e *TestWorkflowExecution) UnscapeDots() *TestWorkflowExecution { + return e.ConvertDots(utils.UnescapeDots) +} diff --git a/pkg/api/v1/testkube/model_test_workflow_execution_summary.go b/pkg/api/v1/testkube/model_test_workflow_execution_summary.go new file mode 100644 index 00000000000..15e3f4c61aa --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_execution_summary.go @@ -0,0 +1,29 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowExecutionSummary struct { + // unique execution identifier + Id string `json:"id"` + // execution name + Name string `json:"name"` + // sequence number for the execution + Number int32 `json:"number,omitempty"` + // when the execution has been scheduled to run + ScheduledAt time.Time `json:"scheduledAt,omitempty"` + // when the execution result's status has changed last time (queued, passed, failed) + StatusAt time.Time `json:"statusAt,omitempty"` + Result *TestWorkflowResultSummary `json:"result,omitempty"` + Workflow *TestWorkflowSummary `json:"workflow"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_execution_summary_extended.go b/pkg/api/v1/testkube/model_test_workflow_execution_summary_extended.go new file mode 100644 index 00000000000..5f68c5367e7 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_execution_summary_extended.go @@ -0,0 +1,41 @@ +package testkube + +import ( + "github.com/kubeshop/testkube/pkg/utils" +) + +type TestWorkflowExecutionSummaries []TestWorkflowExecutionSummary + +func (executions TestWorkflowExecutionSummaries) Table() (header []string, output [][]string) { + header = []string{"Id", "Name", "Test Workflow Name", "Status", "Labels"} + + for _, e := range executions { + status := "unknown" + if e.Result != nil && e.Result.Status != nil { + status = string(*e.Result.Status) + } + + output = append(output, []string{ + e.Id, + e.Name, + e.Workflow.Name, + status, + MapToString(e.Workflow.Labels), + }) + } + + return +} + +func (e *TestWorkflowExecutionSummary) ConvertDots(fn func(string) string) *TestWorkflowExecutionSummary { + e.Workflow.ConvertDots(fn) + return e +} + +func (e *TestWorkflowExecutionSummary) EscapeDots() *TestWorkflowExecutionSummary { + return e.ConvertDots(utils.EscapeDots) +} + +func (e *TestWorkflowExecutionSummary) UnscapeDots() *TestWorkflowExecutionSummary { + return e.ConvertDots(utils.UnescapeDots) +} diff --git a/pkg/api/v1/testkube/model_test_workflow_executions_result.go b/pkg/api/v1/testkube/model_test_workflow_executions_result.go new file mode 100644 index 00000000000..c6dad65d0a5 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_executions_result.go @@ -0,0 +1,16 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowExecutionsResult struct { + Totals *ExecutionsTotals `json:"totals"` + Filtered *ExecutionsTotals `json:"filtered"` + Results []TestWorkflowExecutionSummary `json:"results"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_extended.go b/pkg/api/v1/testkube/model_test_workflow_extended.go index 3812a07e322..6b79acb1245 100644 --- a/pkg/api/v1/testkube/model_test_workflow_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_extended.go @@ -1,5 +1,7 @@ package testkube +import "github.com/kubeshop/testkube/pkg/utils" + type TestWorkflows []TestWorkflow func (t TestWorkflows) Table() (header []string, output [][]string) { @@ -15,3 +17,62 @@ func (t TestWorkflows) Table() (header []string, output [][]string) { return } + +func convertDotsInMap[T any](m map[string]T, fn func(string) string) map[string]T { + result := make(map[string]T) + for key, value := range m { + result[fn(key)] = value + } + return result +} + +func (w *TestWorkflow) ConvertDots(fn func(string) string) *TestWorkflow { + if w == nil { + return w + } + if w.Labels == nil { + w.Labels = convertDotsInMap(w.Labels, fn) + } + if w.Spec.Pod != nil { + w.Spec.Pod.Labels = convertDotsInMap(w.Spec.Pod.Labels, fn) + w.Spec.Pod.Annotations = convertDotsInMap(w.Spec.Pod.Annotations, fn) + w.Spec.Pod.NodeSelector = convertDotsInMap(w.Spec.Pod.NodeSelector, fn) + } + if w.Spec.Job != nil { + w.Spec.Job.Labels = convertDotsInMap(w.Spec.Job.Labels, fn) + w.Spec.Job.Annotations = convertDotsInMap(w.Spec.Job.Annotations, fn) + } + for i := range w.Spec.Use { + if w.Spec.Use[i].Config != nil { + w.Spec.Use[i].Config = convertDotsInMap(w.Spec.Use[i].Config, fn) + } + } + for i := range w.Spec.Setup { + w.Spec.Setup[i].ConvertDots(fn) + } + for i := range w.Spec.Steps { + w.Spec.Steps[i].ConvertDots(fn) + } + for i := range w.Spec.After { + w.Spec.After[i].ConvertDots(fn) + } + return w +} + +func (w *TestWorkflow) EscapeDots() *TestWorkflow { + return w.ConvertDots(utils.EscapeDots) +} + +func (w *TestWorkflow) UnscapeDots() *TestWorkflow { + return w.ConvertDots(utils.UnescapeDots) +} + +func (w *TestWorkflow) GetObjectRef() *ObjectRef { + return &ObjectRef{ + Name: w.Name, + Namespace: w.Namespace, + } +} + +func (w *TestWorkflow) QuoteWorkflowTextFields() { +} diff --git a/pkg/api/v1/testkube/model_test_workflow_result.go b/pkg/api/v1/testkube/model_test_workflow_result.go index 0a34e1fdf85..bd1da212995 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result.go +++ b/pkg/api/v1/testkube/model_test_workflow_result.go @@ -21,7 +21,9 @@ type TestWorkflowResult struct { // when the pod has been successfully assigned StartedAt time.Time `json:"startedAt,omitempty"` // when the pod has been completed - FinishedAt time.Time `json:"finishedAt,omitempty"` + FinishedAt time.Time `json:"finishedAt,omitempty"` + // Go-formatted (human-readable) duration + Duration string `json:"duration,omitempty"` Initialization *TestWorkflowStepResult `json:"initialization,omitempty"` Steps map[string]TestWorkflowStepResult `json:"steps,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index 3addf6ff1b5..aa4a44509bf 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -1,6 +1,8 @@ package testkube import ( + "time" + "github.com/kubeshop/testkube/internal/common" ) @@ -35,6 +37,22 @@ func (r *TestWorkflowResult) IsAnyError() bool { return r.IsFinished() && !r.IsStatus(PASSED_TestWorkflowStatus) } +func (r *TestWorkflowResult) Fatal(err error) { + r.Initialization.ErrorMessage = err.Error() + r.Status = common.Ptr(FAILED_TestWorkflowStatus) + r.PredictedStatus = r.Status + if r.Initialization.Status == nil || (*r.Initialization.Status == QUEUED_TestWorkflowStepStatus) || (*r.Initialization.Status == RUNNING_TestWorkflowStepStatus) { + r.Initialization.Status = common.Ptr(FAILED_TestWorkflowStepStatus) + } + for i := range r.Steps { + if r.Steps[i].Status == nil || (*r.Steps[i].Status == QUEUED_TestWorkflowStepStatus) || (*r.Steps[i].Status == RUNNING_TestWorkflowStepStatus) { + s := r.Steps[i] + s.Status = common.Ptr(SKIPPED_TestWorkflowStepStatus) + r.Steps[i] = s + } + } +} + func (r *TestWorkflowResult) Clone() *TestWorkflowResult { if r == nil { return nil @@ -49,6 +67,7 @@ func (r *TestWorkflowResult) Clone() *TestWorkflowResult { QueuedAt: r.QueuedAt, StartedAt: r.StartedAt, FinishedAt: r.FinishedAt, + Duration: r.Duration, Initialization: r.Initialization.Clone(), Steps: steps, } @@ -75,7 +94,12 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { r.RecomputeStep(ch) } - // Build status on the internal failur + // Compute the duration + if !r.FinishedAt.IsZero() { + r.Duration = r.FinishedAt.Sub(r.QueuedAt).Round(time.Millisecond).String() + } + + // Build status on the internal failure if getTestWorkflowStepStatus(*r.Initialization) == ABORTED_TestWorkflowStepStatus { r.Status = common.Ptr(ABORTED_TestWorkflowStatus) r.PredictedStatus = r.Status @@ -97,7 +121,7 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { status = common.Ptr(PASSED_TestWorkflowStatus) } r.PredictedStatus = status - if !r.FinishedAt.IsZero() { + if !r.FinishedAt.IsZero() || *status == ABORTED_TestWorkflowStatus { r.Status = r.PredictedStatus } } diff --git a/pkg/api/v1/testkube/model_test_workflow_result_summary.go b/pkg/api/v1/testkube/model_test_workflow_result_summary.go new file mode 100644 index 00000000000..888c04fedcb --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_result_summary.go @@ -0,0 +1,27 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +import ( + "time" +) + +type TestWorkflowResultSummary struct { + Status *TestWorkflowStatus `json:"status"` + PredictedStatus *TestWorkflowStatus `json:"predictedStatus"` + // when the pod was created + QueuedAt time.Time `json:"queuedAt,omitempty"` + // when the pod has been successfully assigned + StartedAt time.Time `json:"startedAt,omitempty"` + // when the pod has been completed + FinishedAt time.Time `json:"finishedAt,omitempty"` + // Go-formatted (human-readable) duration + Duration string `json:"duration,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_status_extended.go b/pkg/api/v1/testkube/model_test_workflow_status_extended.go new file mode 100644 index 00000000000..6a17103d1ab --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_status_extended.go @@ -0,0 +1,45 @@ +package testkube + +import ( + "fmt" + "strings" +) + +// TestWorkflowStatuses is an array of TestWorkflowStatus +type TestWorkflowStatuses []TestWorkflowStatus + +// ToMap generates map from TestWorkflowStatuses +func (statuses TestWorkflowStatuses) ToMap() map[TestWorkflowStatus]struct{} { + statusMap := map[TestWorkflowStatus]struct{}{} + for _, status := range statuses { + statusMap[status] = struct{}{} + } + + return statusMap +} + +// ParseTestWorkflowStatusList parse a list of workflow execution statuses from string +func ParseTestWorkflowStatusList(source, separator string) (statusList TestWorkflowStatuses, err error) { + statusMap := map[TestWorkflowStatus]struct{}{ + FAILED_TestWorkflowStatus: {}, + PASSED_TestWorkflowStatus: {}, + QUEUED_TestWorkflowStatus: {}, + RUNNING_TestWorkflowStatus: {}, + } + + if source == "" { + return nil, nil + } + + values := strings.Split(source, separator) + for _, value := range values { + status := TestWorkflowStatus(value) + if _, ok := statusMap[status]; ok { + statusList = append(statusList, status) + } else { + return nil, fmt.Errorf("unknown test workflow execution status %v", status) + } + } + + return statusList, nil +} diff --git a/pkg/api/v1/testkube/model_test_workflow_step_extended.go b/pkg/api/v1/testkube/model_test_workflow_step_extended.go new file mode 100644 index 00000000000..daf74da4b29 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_step_extended.go @@ -0,0 +1,29 @@ +package testkube + +import "github.com/kubeshop/testkube/pkg/utils" + +func (w *TestWorkflowStep) ConvertDots(fn func(string) string) *TestWorkflowStep { + if w == nil { + return w + } + for i := range w.Use { + if w.Use[i].Config != nil { + w.Use[i].Config = convertDotsInMap(w.Use[i].Config, fn) + } + } + if w.Template != nil && w.Template.Config != nil { + w.Template.Config = convertDotsInMap(w.Template.Config, fn) + } + for i := range w.Steps { + w.Steps[i].ConvertDots(fn) + } + return w +} + +func (w *TestWorkflowStep) EscapeDots() *TestWorkflowStep { + return w.ConvertDots(utils.EscapeDots) +} + +func (w *TestWorkflowStep) UnscapeDots() *TestWorkflowStep { + return w.ConvertDots(utils.UnescapeDots) +} diff --git a/pkg/api/v1/testkube/model_test_workflow_summary.go b/pkg/api/v1/testkube/model_test_workflow_summary.go new file mode 100644 index 00000000000..751bca8fac1 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_summary.go @@ -0,0 +1,17 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowSummary struct { + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_summary_extended.go b/pkg/api/v1/testkube/model_test_workflow_summary_extended.go new file mode 100644 index 00000000000..0a47f30a5e2 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_summary_extended.go @@ -0,0 +1,33 @@ +package testkube + +import ( + "github.com/kubeshop/testkube/pkg/utils" +) + +func (w *TestWorkflowSummary) ConvertDots(fn func(string) string) *TestWorkflowSummary { + if w == nil || w.Labels == nil { + return w + } + if w.Labels != nil { + w.Labels = convertDotsInMap(w.Labels, fn) + } + return w +} + +func (w *TestWorkflowSummary) EscapeDots() *TestWorkflowSummary { + return w.ConvertDots(utils.EscapeDots) +} + +func (w *TestWorkflowSummary) UnscapeDots() *TestWorkflowSummary { + return w.ConvertDots(utils.UnescapeDots) +} + +func (w *TestWorkflowSummary) GetObjectRef() *ObjectRef { + return &ObjectRef{ + Name: w.Name, + Namespace: w.Namespace, + } +} + +func (w *TestWorkflowSummary) QuoteWorkflowTextFields() { +} diff --git a/pkg/api/v1/testkube/model_test_workflow_with_execution.go b/pkg/api/v1/testkube/model_test_workflow_with_execution.go new file mode 100644 index 00000000000..736b4560c77 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_with_execution.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowWithExecution struct { + Workflow *TestWorkflow `json:"workflow,omitempty"` + LatestExecution *TestWorkflowExecution `json:"latestExecution,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_with_execution_extended.go b/pkg/api/v1/testkube/model_test_workflow_with_execution_extended.go new file mode 100644 index 00000000000..1ec43935143 --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_with_execution_extended.go @@ -0,0 +1,28 @@ +package testkube + +type TestWorkflowWithExecutions []TestWorkflowWithExecution + +func (t TestWorkflowWithExecutions) Table() (header []string, output [][]string) { + header = []string{"Name", "Description", "Created", "Labels", "Status", "Execution ID"} + for _, e := range t { + status := "" + executionID := "" + if e.LatestExecution != nil { + executionID = e.LatestExecution.Id + if e.LatestExecution.Result != nil && e.LatestExecution.Result.Status != nil { + status = string(*e.LatestExecution.Result.Status) + } + } + + output = append(output, []string{ + e.Workflow.Name, + e.Workflow.Description, + e.Workflow.Created.String(), + MapToString(e.Workflow.Labels), + status, + executionID, + }) + } + + return +} diff --git a/pkg/api/v1/testkube/model_test_workflow_with_execution_summary.go b/pkg/api/v1/testkube/model_test_workflow_with_execution_summary.go new file mode 100644 index 00000000000..ce1c8646aad --- /dev/null +++ b/pkg/api/v1/testkube/model_test_workflow_with_execution_summary.go @@ -0,0 +1,15 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type TestWorkflowWithExecutionSummary struct { + Workflow *TestWorkflow `json:"workflow,omitempty"` + LatestExecution *TestWorkflowExecutionSummary `json:"latestExecution,omitempty"` +} diff --git a/pkg/storage/minio/minio.go b/pkg/storage/minio/minio.go index 32dc4603ed2..ffc48e85882 100644 --- a/pkg/storage/minio/minio.go +++ b/pkg/storage/minio/minio.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "github.com/pkg/errors" @@ -632,3 +633,33 @@ func (c *Client) IsConnectionPossible(ctx context.Context) (bool, error) { return true, nil } + +func (c *Client) PresignDownloadFileFromBucket(ctx context.Context, bucket, bucketFolder, file string, expires time.Duration) (string, error) { + if err := c.Connect(); err != nil { + return "", err + } + if bucketFolder != "" { + file = strings.Trim(bucketFolder, "/") + "/" + file + } + c.Log.Debugw("presigning get object from minio", "file", file, "bucket", bucket) + url, err := c.minioClient.PresignedPutObject(ctx, bucket, file, expires) + if err != nil { + return "", err + } + return url.String(), nil +} + +func (c *Client) PresignUploadFileToBucket(ctx context.Context, bucket, bucketFolder, filePath string, expires time.Duration) (string, error) { + if err := c.Connect(); err != nil { + return "", err + } + if bucketFolder != "" { + filePath = strings.Trim(bucketFolder, "/") + "/" + filePath + } + c.Log.Debugw("presigning put object in minio", "file", filePath, "bucket", bucket) + url, err := c.minioClient.PresignedPutObject(ctx, bucket, filePath, expires) + if err != nil { + return "", err + } + return url.String(), nil +} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 803e661b0f3..40e2695a396 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -3,6 +3,7 @@ package storage import ( "context" "io" + "time" "github.com/minio/minio-go/v7" @@ -39,4 +40,6 @@ type ClientBucket interface { UploadFileToBucket(ctx context.Context, bucket, bucketFolder, filePath string, reader io.Reader, objectSize int64) error GetValidBucketName(parentType string, parentName string) string DeleteFileFromBucket(ctx context.Context, bucket, bucketFolder, file string) error + PresignDownloadFileFromBucket(ctx context.Context, bucket, bucketFolder, file string, expires time.Duration) (string, error) + PresignUploadFileToBucket(ctx context.Context, bucket, bucketFolder, filePath string, expires time.Duration) (string, error) } diff --git a/pkg/storage/storage_mock.go b/pkg/storage/storage_mock.go index ed3b80b6737..03583bc5548 100644 --- a/pkg/storage/storage_mock.go +++ b/pkg/storage/storage_mock.go @@ -8,6 +8,7 @@ import ( context "context" io "io" reflect "reflect" + time "time" gomock "github.com/golang/mock/gomock" testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" @@ -227,6 +228,36 @@ func (mr *MockClientMockRecorder) PlaceFiles(arg0, arg1, arg2 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlaceFiles", reflect.TypeOf((*MockClient)(nil).PlaceFiles), arg0, arg1, arg2) } +// PresignDownloadFileFromBucket mocks base method. +func (m *MockClient) PresignDownloadFileFromBucket(arg0 context.Context, arg1, arg2, arg3 string, arg4 time.Duration) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresignDownloadFileFromBucket", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresignDownloadFileFromBucket indicates an expected call of PresignDownloadFileFromBucket. +func (mr *MockClientMockRecorder) PresignDownloadFileFromBucket(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresignDownloadFileFromBucket", reflect.TypeOf((*MockClient)(nil).PresignDownloadFileFromBucket), arg0, arg1, arg2, arg3, arg4) +} + +// PresignUploadFileToBucket mocks base method. +func (m *MockClient) PresignUploadFileToBucket(arg0 context.Context, arg1, arg2, arg3 string, arg4 time.Duration) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresignUploadFileToBucket", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresignUploadFileToBucket indicates an expected call of PresignUploadFileToBucket. +func (mr *MockClientMockRecorder) PresignUploadFileToBucket(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresignUploadFileToBucket", reflect.TypeOf((*MockClient)(nil).PresignUploadFileToBucket), arg0, arg1, arg2, arg3, arg4) +} + // SaveFile mocks base method. func (m *MockClient) SaveFile(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index adfdab4f929..f5c640d2bee 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -9,6 +9,7 @@ package v1 import ( + "context" "errors" "fmt" "net/http" @@ -20,14 +21,19 @@ import ( apiv1 "github.com/kubeshop/testkube/internal/app/api/v1" "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/pkg/imageinspector" + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowexecutor" ) type apiTCL struct { apiv1.TestkubeAPI ProContext *config.ProContext ImageInspector imageinspector.Inspector + TestWorkflowResults testworkflow.Repository + TestWorkflowOutput testworkflow.OutputRepository TestWorkflowsClient testworkflowsv1.Interface TestWorkflowTemplatesClient testworkflowsv1.TestWorkflowTemplatesInterface + TestWorkflowExecutor testworkflowexecutor.TestWorkflowExecutor } type ApiTCL interface { @@ -39,13 +45,20 @@ func NewApiTCL( proContext *config.ProContext, kubeClient kubeclient.Client, imageInspector imageinspector.Inspector, + testWorkflowResults testworkflow.Repository, + testWorkflowOutput testworkflow.OutputRepository, ) ApiTCL { + executor := testworkflowexecutor.New(testkubeAPI.Events, testkubeAPI.Clientset, testWorkflowResults, testWorkflowOutput, testkubeAPI.Namespace) + go executor.Recover(context.Background()) return &apiTCL{ TestkubeAPI: testkubeAPI, ProContext: proContext, ImageInspector: imageInspector, + TestWorkflowResults: testWorkflowResults, + TestWorkflowOutput: testWorkflowOutput, TestWorkflowsClient: testworkflowsv1.NewClient(kubeClient, testkubeAPI.Namespace), TestWorkflowTemplatesClient: testworkflowsv1.NewTestWorkflowTemplatesClient(kubeClient, testkubeAPI.Namespace), + TestWorkflowExecutor: executor, } } @@ -86,10 +99,25 @@ func (s *apiTCL) AppendRoutes() { testWorkflows.Get("/:id", s.pro(s.GetTestWorkflowHandler())) testWorkflows.Put("/:id", s.pro(s.UpdateTestWorkflowHandler())) testWorkflows.Delete("/:id", s.pro(s.DeleteTestWorkflowHandler())) + testWorkflows.Get("/:id/executions", s.pro(s.ListTestWorkflowExecutionsHandler())) testWorkflows.Post("/:id/executions", s.pro(s.ExecuteTestWorkflowHandler())) + testWorkflows.Get("/:id/metrics", s.pro(s.GetTestWorkflowMetricsHandler())) + testWorkflows.Get("/:id/executions/:executionID", s.pro(s.GetTestWorkflowExecutionHandler())) + testWorkflows.Post("/:id/abort", s.pro(s.AbortAllTestWorkflowExecutionsHandler())) + testWorkflows.Post("/:id/executions/:executionID/abort", s.pro(s.AbortTestWorkflowExecutionHandler())) + testWorkflows.Get("/:id/executions/:executionID/logs", s.pro(s.GetTestWorkflowExecutionLogsHandler())) testWorkflowExecutions := root.Group("/test-workflow-executions") - testWorkflowExecutions.Get("/:id/notifications", s.pro(s.StreamTestWorkflowExecutionNotificationsHandler())) + testWorkflowExecutions.Get("/", s.pro(s.ListTestWorkflowExecutionsHandler())) + testWorkflowExecutions.Get("/:executionID", s.pro(s.GetTestWorkflowExecutionHandler())) + testWorkflowExecutions.Get("/:executionID/notifications", s.pro(s.StreamTestWorkflowExecutionNotificationsHandler())) + testWorkflowExecutions.Get("/:executionID/notifications/stream", s.pro(s.StreamTestWorkflowExecutionNotificationsWebSocketHandler())) + testWorkflowExecutions.Post("/:executionID/abort", s.pro(s.AbortTestWorkflowExecutionHandler())) + testWorkflowExecutions.Get("/:executionID/logs", s.pro(s.GetTestWorkflowExecutionLogsHandler())) + + testWorkflowWithExecutions := root.Group("/test-workflow-with-executions") + testWorkflowWithExecutions.Get("/", s.pro(s.ListTestWorkflowWithExecutionsHandler())) + testWorkflowWithExecutions.Get("/:id", s.pro(s.GetTestWorkflowWithExecutionHandler())) root.Post("/preview-test-workflow", s.pro(s.PreviewTestWorkflowHandler())) diff --git a/pkg/tcl/apitcl/v1/testworkflowexecutions.go b/pkg/tcl/apitcl/v1/testworkflowexecutions.go index eb849cf3fec..a47b9bcb7fd 100644 --- a/pkg/tcl/apitcl/v1/testworkflowexecutions.go +++ b/pkg/tcl/apitcl/v1/testworkflowexecutions.go @@ -10,27 +10,37 @@ package v1 import ( "bufio" + "context" "encoding/json" "fmt" + "io" + "math" + "net/http" + "strconv" "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" + "github.com/pkg/errors" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/datefilter" + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowcontroller" ) func (s *apiTCL) StreamTestWorkflowExecutionNotificationsHandler() fiber.Handler { return func(c *fiber.Ctx) error { ctx := c.Context() - id := c.Params("id") + id := c.Params("executionID") errPrefix := fmt.Sprintf("failed to stream test workflow execution notifications '%s'", id) - // TODO: Fetch execution from database - execution := testkube.TestWorkflowExecution{ - Id: id, + // Fetch execution from database + execution, err := s.TestWorkflowResults.Get(ctx, id) + if err != nil { + return s.ClientError(c, errPrefix, err) } - // Check for the logs TODO: Load from the database if possible + // Check for the logs ctrl, err := testworkflowcontroller.New(ctx, s.Clientset, s.Namespace, execution.Id, execution.ScheduledAt) if err != nil { return s.BadRequest(c, errPrefix, "fetching job", err) @@ -59,3 +69,263 @@ func (s *apiTCL) StreamTestWorkflowExecutionNotificationsHandler() fiber.Handler return nil } } + +func (s *apiTCL) StreamTestWorkflowExecutionNotificationsWebSocketHandler() fiber.Handler { + return websocket.New(func(c *websocket.Conn) { + ctx, ctxCancel := context.WithCancel(context.Background()) + id := c.Params("executionID") + + // Stop reading when the WebSocket connection is already closed + originalClose := c.CloseHandler() + c.SetCloseHandler(func(code int, text string) error { + ctxCancel() + return originalClose(code, text) + }) + defer c.Conn.Close() + + // Fetch execution from database + execution, err := s.TestWorkflowResults.Get(ctx, id) + if err != nil { + return + } + + // Check for the logs TODO: Load from the database if possible + ctrl, err := testworkflowcontroller.New(ctx, s.Clientset, s.Namespace, execution.Id, execution.ScheduledAt) + if err != nil { + return + } + + for n := range ctrl.Watch(ctx).Stream(ctx).Channel() { + if n.Error == nil { + _ = c.WriteJSON(n.Value) + } + } + }) +} + +func (s *apiTCL) ListTestWorkflowExecutionsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + errPrefix := "failed to list test workflow executions" + + filter := getWorkflowExecutionsFilterFromRequest(c) + + executions, err := s.TestWorkflowResults.GetExecutionsSummary(c.Context(), filter) + if err != nil { + return s.ClientError(c, errPrefix+": get execution results", err) + } + + executionTotals, err := s.TestWorkflowResults.GetExecutionsTotals(c.Context(), testworkflow.NewExecutionsFilter().WithName(filter.Name())) + if err != nil { + return s.ClientError(c, errPrefix+": get totals", err) + } + + filterTotals := *filter.(*testworkflow.FilterImpl) + filterTotals.WithPage(0).WithPageSize(math.MaxInt32) + filteredTotals, err := s.TestWorkflowResults.GetExecutionsTotals(c.Context(), filterTotals) + if err != nil { + return s.ClientError(c, errPrefix+": get filtered totals", err) + } + + results := testkube.TestWorkflowExecutionsResult{ + Totals: &executionTotals, + Filtered: &filteredTotals, + Results: executions, + } + return c.JSON(results) + } +} + +func (s *apiTCL) GetTestWorkflowMetricsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + workflowName := c.Params("id") + + const DefaultLimit = 0 + limit, err := strconv.Atoi(c.Query("limit", strconv.Itoa(DefaultLimit))) + if err != nil { + limit = DefaultLimit + } + + const DefaultLastDays = 7 + last, err := strconv.Atoi(c.Query("last", strconv.Itoa(DefaultLastDays))) + if err != nil { + last = DefaultLastDays + } + + metrics, err := s.TestWorkflowResults.GetTestWorkflowMetrics(c.Context(), workflowName, limit, last) + if err != nil { + return s.ClientError(c, "get metrics for workflow", err) + } + + return c.JSON(metrics) + } +} + +func (s *apiTCL) GetTestWorkflowExecutionHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + ctx := c.Context() + id := c.Params("id", "") + executionID := c.Params("executionID") + + var execution testkube.TestWorkflowExecution + var err error + if id == "" { + execution, err = s.TestWorkflowResults.Get(ctx, executionID) + } else { + execution, err = s.TestWorkflowResults.GetByNameAndTestWorkflow(ctx, executionID, id) + } + if err != nil { + return s.ClientError(c, "get execution", err) + } + + return c.JSON(execution) + } +} + +func (s *apiTCL) GetTestWorkflowExecutionLogsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + ctx := c.Context() + id := c.Params("id", "") + executionID := c.Params("executionID") + + var execution testkube.TestWorkflowExecution + var err error + if id == "" { + execution, err = s.TestWorkflowResults.Get(ctx, executionID) + } else { + execution, err = s.TestWorkflowResults.GetByNameAndTestWorkflow(ctx, executionID, id) + } + if err != nil { + return s.ClientError(c, "get execution", err) + } + + reader, err := s.TestWorkflowOutput.ReadLog(ctx, executionID, execution.Workflow.Name) + if err != nil { + return s.InternalError(c, "can't get log", executionID, err) + } + + c.Context().SetContentType(mediaTypePlainText) + _, err = io.Copy(c.Response().BodyWriter(), reader) + return err + } +} + +func (s *apiTCL) AbortTestWorkflowExecutionHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + ctx := c.Context() + name := c.Params("id") + executionID := c.Params("executionID") + errPrefix := fmt.Sprintf("failed to abort test workflow execution '%s'", executionID) + + // TODO: Fetch execution from database + execution, err := s.TestWorkflowResults.GetByNameAndTestWorkflow(ctx, executionID, name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + if execution.Result != nil && execution.Result.IsFinished() { + return s.BadRequest(c, errPrefix, "checking execution", errors.New("execution already finished")) + } + + // Obtain the controller + ctrl, err := testworkflowcontroller.New(ctx, s.Clientset, s.Namespace, execution.Id, execution.ScheduledAt) + if err != nil { + return s.BadRequest(c, errPrefix, "fetching job", err) + } + + // Abort the execution + err = ctrl.Abort(context.Background()) + if err != nil { + return s.ClientError(c, "aborting test workflow execution", err) + } + + c.Status(http.StatusNoContent) + + return nil + } +} + +func (s *apiTCL) AbortAllTestWorkflowExecutionsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + ctx := c.Context() + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to abort test workflow executions '%s'", name) + + // Fetch executions + filter := testworkflow.NewExecutionsFilter().WithName(name).WithStatus(string(testkube.RUNNING_TestWorkflowStatus)) + executions, err := s.TestWorkflowResults.GetExecutions(ctx, filter) + if err != nil { + if IsNotFound(err) { + c.Status(http.StatusNoContent) + return nil + } + return s.ClientError(c, errPrefix, err) + } + + for _, execution := range executions { + // Obtain the controller + ctrl, err := testworkflowcontroller.New(ctx, s.Clientset, s.Namespace, execution.Id, execution.ScheduledAt) + if err != nil { + return s.BadRequest(c, errPrefix, "fetching job", err) + } + + // Abort the execution + err = ctrl.Abort(context.Background()) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + } + + c.Status(http.StatusNoContent) + + return nil + } +} + +func getWorkflowExecutionsFilterFromRequest(c *fiber.Ctx) testworkflow.Filter { + filter := testworkflow.NewExecutionsFilter() + name := c.Params("id", "") + if name != "" { + filter = filter.WithName(name) + } + + textSearch := c.Query("textSearch", "") + if textSearch != "" { + filter = filter.WithTextSearch(textSearch) + } + + page, err := strconv.Atoi(c.Query("page", "")) + if err == nil { + filter = filter.WithPage(page) + } + + pageSize, err := strconv.Atoi(c.Query("pageSize", "")) + if err == nil && pageSize != 0 { + filter = filter.WithPageSize(pageSize) + } + + status := c.Query("status", "") + if status != "" { + filter = filter.WithStatus(status) + } + + last, err := strconv.Atoi(c.Query("last", "0")) + if err == nil && last != 0 { + filter = filter.WithLastNDays(last) + } + + dFilter := datefilter.NewDateFilter(c.Query("startDate", ""), c.Query("endDate", "")) + if dFilter.IsStartValid { + filter = filter.WithStartDate(dFilter.Start) + } + + if dFilter.IsEndValid { + filter = filter.WithEndDate(dFilter.End) + } + + selector := c.Query("selector") + if selector != "" { + filter = filter.WithSelector(selector) + } + + return filter +} diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index 83011eba690..f2cb770a180 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -17,15 +17,13 @@ import ( "github.com/gofiber/fiber/v2" "github.com/pkg/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "go.mongodb.org/mongo-driver/bson/primitive" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/rand" "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" testworkflowmappers "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" - "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowcontroller" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowresolver" ) @@ -70,10 +68,13 @@ func (s *apiTCL) DeleteTestWorkflowHandler() fiber.Handler { if err != nil { return s.ClientError(c, errPrefix, err) } - //skipExecutions := c.Query("skipDeleteExecutions", "") - //if skipExecutions != "true" { - // // TODO: Delete Executions - //} + skipExecutions := c.Query("skipDeleteExecutions", "") + if skipExecutions != "true" { + err = s.TestWorkflowResults.DeleteByTestWorkflow(context.Background(), name) + if err != nil { + return s.ClientError(c, "deleting executions", err) + } + } return c.SendStatus(http.StatusNoContent) } } @@ -82,23 +83,34 @@ func (s *apiTCL) DeleteTestWorkflowsHandler() fiber.Handler { errPrefix := "failed to delete test workflows" return func(c *fiber.Ctx) error { selector := c.Query("selector") - _, err := s.TestWorkflowsClient.List(selector) + workflows, err := s.TestWorkflowsClient.List(selector) if err != nil { return s.BadGateway(c, errPrefix, "client problem", err) } + // Delete err = s.TestWorkflowsClient.DeleteByLabels(selector) if err != nil { return s.ClientError(c, errPrefix, err) } - //skipExecutions := c.Query("skipDeleteExecutions", "") - //for range workflows.Items { - // s.Metrics.IncDeleteTestWorkflow(err) - // if skipExecutions != "true" { - // // TODO: Delete Executions - // } - //} + // Mark as deleted + for range workflows.Items { + s.Metrics.IncDeleteTestWorkflow(err) + } + + // Delete the executions + skipExecutions := c.Query("skipDeleteExecutions", "") + if skipExecutions != "true" { + names := common.MapSlice(workflows.Items, func(t testworkflowsv1.TestWorkflow) string { + return t.Name + }) + err = s.TestWorkflowResults.DeleteByTestWorkflows(context.Background(), names) + if err != nil { + return s.ClientError(c, "deleting executions", err) + } + } + return c.SendStatus(http.StatusNoContent) } } @@ -246,6 +258,7 @@ func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { // TODO: Add metrics func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return func(c *fiber.Ctx) (err error) { + ctx := c.Context() name := c.Params("id") errPrefix := fmt.Sprintf("failed to execute test workflow '%s'", name) workflow, err := s.TestWorkflowsClient.Get(name) @@ -265,12 +278,6 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { if err != nil && !errors.Is(err, fiber.ErrUnprocessableEntity) { return s.BadRequest(c, errPrefix, "invalid body", err) } - if request.Name == "" { - request.Name = rand.Name() - } - - machine := expressionstcl.NewMachine(). - Register("execution.id", request.Name) // TODO(TKC-1652): replace with actual ID // Fetch the templates tpls := testworkflowresolver.ListTemplates(workflow) @@ -295,6 +302,12 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "resolving error", err) } + // Build the basic Execution data + id := primitive.NewObjectID().Hex() + now := time.Now() + machine := expressionstcl.NewMachine(). + Register("execution.id", id) + // Preserve resolved TestWorkflow resolvedWorkflow := workflow.DeepCopy() @@ -305,11 +318,27 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { return s.BadRequest(c, errPrefix, "processing error", err) } - now := time.Now() + // Load execution identifier data + // TODO: Consider if that should not be shared (as now it is between Tests and Test Suites) + number, _ := s.ExecutionResults.GetNextExecutionNumber(context.Background(), workflow.Name) + executionName := request.Name + if executionName == "" { + executionName = fmt.Sprintf("%s-%d", workflow.Name, number) + } + + // Ensure it is unique name + // TODO: Consider if we shouldn't make name unique across all TestWorkflows + next, _ := s.TestWorkflowResults.GetByNameAndTestWorkflow(ctx, executionName, workflow.Name) + if next.Name == executionName { + return s.BadRequest(c, errPrefix, "execution name already exists", errors.New(executionName)) + } + + // Build Execution entity + // TODO: Consider storing "config" as well execution := testkube.TestWorkflowExecution{ - Id: "00000000", - Name: request.Name, - Number: 0, + Id: id, + Name: executionName, + Number: number, ScheduledAt: now, StatusAt: now, Signature: testworkflowprocessor.MapSignatureListToInternal(bundle.Signature), @@ -319,51 +348,19 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { Initialization: &testkube.TestWorkflowStepResult{ Status: common.Ptr(testkube.QUEUED_TestWorkflowStepStatus), }, - Steps: map[string]testkube.TestWorkflowStepResult{}, + Steps: testworkflowprocessor.MapSignatureListToStepResults(bundle.Signature), }, Output: []testkube.TestWorkflowOutput{}, Workflow: testworkflowmappers.MapKubeToAPI(initialWorkflow), ResolvedWorkflow: testworkflowmappers.MapKubeToAPI(resolvedWorkflow), } - - // Deploy the resources - for _, item := range bundle.Secrets { - _, err = s.Clientset.CoreV1().Secrets(s.Namespace).Create(context.Background(), &item, metav1.CreateOptions{}) - if err != nil { - // TODO: Set error message - go testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, request.Name) - return s.BadRequest(c, errPrefix, "creating secret", err) - } - } - for _, item := range bundle.ConfigMaps { - _, err = s.Clientset.CoreV1().ConfigMaps(s.Namespace).Create(context.Background(), &item, metav1.CreateOptions{}) - if err != nil { - // TODO: Set error message - go testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, request.Name) - return s.BadRequest(c, errPrefix, "creating configmap", err) - } - } - _, err = s.Clientset.BatchV1().Jobs(s.Namespace).Create(context.Background(), &bundle.Job, metav1.CreateOptions{}) + err = s.TestWorkflowResults.Insert(ctx, execution) if err != nil { - // TODO: Set error message - go testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, request.Name) - return s.BadRequest(c, errPrefix, "creating job", err) + return s.InternalError(c, errPrefix, "inserting execution to storage", err) } - // Start to control the results - // TODO: Move it outside of the API when persistence will be there - go func() { - ctrl, err := testworkflowcontroller.New(context.Background(), s.Clientset, s.Namespace, execution.Name, execution.ScheduledAt) - if err != nil { - // TODO: Set error message - testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, execution.Name) - return - } - for range ctrl.Watch(context.Background()).Stream(context.Background()).Channel() { - // Process results - } - testworkflowcontroller.Cleanup(context.Background(), s.Clientset, s.Namespace, execution.Name) - }() + // Schedule the execution + s.TestWorkflowExecutor.Schedule(bundle, execution) return c.JSON(execution) } diff --git a/pkg/tcl/apitcl/v1/testworkflowwithexecutions.go b/pkg/tcl/apitcl/v1/testworkflowwithexecutions.go new file mode 100644 index 00000000000..b4e34d5c348 --- /dev/null +++ b/pkg/tcl/apitcl/v1/testworkflowwithexecutions.go @@ -0,0 +1,154 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package v1 + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strconv" + + "github.com/gofiber/fiber/v2" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/repository/result" + testworkflowmappers "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" +) + +func (s *apiTCL) GetTestWorkflowWithExecutionHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + name := c.Params("id") + errPrefix := fmt.Sprintf("failed to get test workflow '%s' with execution", name) + if name == "" { + return s.Error(c, http.StatusBadRequest, errors.New(errPrefix+": id cannot be empty")) + } + crWorkflow, err := s.TestWorkflowsClient.Get(name) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + workflow := testworkflowmappers.MapKubeToAPI(crWorkflow) + + ctx := c.Context() + execution, err := s.TestWorkflowResults.GetLatestByTestWorkflow(ctx, name) + if err != nil && !IsNotFound(err) { + return s.ClientError(c, errPrefix, err) + } + + return c.JSON(testkube.TestWorkflowWithExecution{ + Workflow: workflow, + LatestExecution: execution, + }) + } +} + +func (s *apiTCL) ListTestWorkflowWithExecutionsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + errPrefix := "failed to list test workflows with executions" + crWorkflows, err := s.getFilteredTestWorkflowList(c) + if err != nil { + return s.ClientError(c, errPrefix+": get filtered workflows", err) + } + + workflows := testworkflowmappers.MapListKubeToAPI(crWorkflows) + ctx := c.Context() + results := make([]testkube.TestWorkflowWithExecutionSummary, 0, len(workflows)) + workflowNames := make([]string, len(workflows)) + for i := range workflows { + workflowNames[i] = workflows[i].Name + } + + executions, err := s.TestWorkflowResults.GetLatestByTestWorkflows(ctx, workflowNames) + if err != nil { + return s.ClientError(c, errPrefix+": getting latest executions", err) + } + executionMap := make(map[string]testkube.TestWorkflowExecutionSummary, len(executions)) + for i := range executions { + executionMap[executions[i].Workflow.Name] = executions[i] + } + + for i := range workflows { + if execution, ok := executionMap[workflows[i].Name]; ok { + results = append(results, testkube.TestWorkflowWithExecutionSummary{ + Workflow: &workflows[i], + LatestExecution: &execution, + }) + } else { + results = append(results, testkube.TestWorkflowWithExecutionSummary{ + Workflow: &workflows[i], + }) + } + } + + sort.Slice(results, func(i, j int) bool { + iTime := results[i].Workflow.Created + if results[i].LatestExecution != nil { + iTime = results[i].LatestExecution.StatusAt + } + jTime := results[j].Workflow.Created + if results[j].LatestExecution != nil { + jTime = results[j].LatestExecution.StatusAt + } + return iTime.After(jTime) + }) + + status := c.Query("status") + if status != "" { + statusList, err := testkube.ParseTestWorkflowStatusList(status, ",") + if err != nil { + return s.Error(c, http.StatusBadRequest, fmt.Errorf("%s: execution status filter invalid: %w", errPrefix, err)) + } + + statusMap := statusList.ToMap() + // filter items array + for i := len(results) - 1; i >= 0; i-- { + if results[i].LatestExecution != nil && results[i].LatestExecution.Result.Status != nil { + if _, ok := statusMap[*results[i].LatestExecution.Result.Status]; ok { + continue + } + } + + results = append(results[:i], results[i+1:]...) + } + } + + var page, pageSize int + pageParam := c.Query("page", "") + if pageParam != "" { + pageSize = result.PageDefaultLimit + page, err = strconv.Atoi(pageParam) + if err != nil { + return s.BadRequest(c, errPrefix, "workflow page filter invalid", err) + } + } + + pageSizeParam := c.Query("pageSize", "") + if pageSizeParam != "" { + pageSize, err = strconv.Atoi(pageSizeParam) + if err != nil { + return s.BadRequest(c, errPrefix, "workflow page size filter invalid", err) + } + } + + if pageParam != "" || pageSizeParam != "" { + startPos := page * pageSize + endPos := (page + 1) * pageSize + if startPos < len(results) { + if endPos > len(results) { + endPos = len(results) + } + + results = results[startPos:endPos] + } + } + + return c.JSON(results) + } +} diff --git a/pkg/tcl/apitcl/v1/utils.go b/pkg/tcl/apitcl/v1/utils.go index 16eef63188a..1e00b44ef65 100644 --- a/pkg/tcl/apitcl/v1/utils.go +++ b/pkg/tcl/apitcl/v1/utils.go @@ -21,8 +21,9 @@ import ( ) const ( - mediaTypeJSON = "application/json" - mediaTypeYAML = "text/yaml" + mediaTypeJSON = "application/json" + mediaTypeYAML = "text/yaml" + mediaTypePlainText = "text/plain" ) func ExpectsYAML(c *fiber.Ctx) bool { diff --git a/pkg/tcl/expressionstcl/machine.go b/pkg/tcl/expressionstcl/machine.go index 5a174008e02..95251ecb4af 100644 --- a/pkg/tcl/expressionstcl/machine.go +++ b/pkg/tcl/expressionstcl/machine.go @@ -8,6 +8,8 @@ package expressionstcl +import "strings" + //go:generate mockgen -destination=./mock_machine.go -package=expressionstcl "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" Machine type Machine interface { Get(name string) (Expression, bool, error) @@ -39,6 +41,19 @@ func (m *machine) Register(name string, value interface{}) *machine { }) } +func (m *machine) RegisterStringMap(prefix string, value map[string]string) *machine { + if len(prefix) > 0 { + prefix += "." + } + return m.RegisterAccessor(func(n string) (interface{}, bool) { + if !strings.HasPrefix(n, prefix) { + return nil, false + } + v, ok := value[n[len(prefix):]] + return v, ok + }) +} + func (m *machine) RegisterAccessorExt(fn MachineAccessorExt) *machine { m.accessors = append(m.accessors, fn) return m diff --git a/pkg/tcl/repositorytcl/testworkflow/filter.go b/pkg/tcl/repositorytcl/testworkflow/filter.go new file mode 100644 index 00000000000..91c90f7bd96 --- /dev/null +++ b/pkg/tcl/repositorytcl/testworkflow/filter.go @@ -0,0 +1,140 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "time" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +type FilterImpl struct { + FName string + FLastNDays int + FStartDate *time.Time + FEndDate *time.Time + FStatuses []testkube.TestWorkflowStatus + FPage int + FPageSize int + FTextSearch string + FSelector string +} + +func NewExecutionsFilter() *FilterImpl { + result := FilterImpl{FPage: 0, FPageSize: PageDefaultLimit} + return &result +} + +func (f *FilterImpl) WithName(name string) *FilterImpl { + f.FName = name + return f +} + +func (f *FilterImpl) WithLastNDays(days int) *FilterImpl { + f.FLastNDays = days + return f +} + +func (f *FilterImpl) WithStartDate(date time.Time) *FilterImpl { + f.FStartDate = &date + return f +} + +func (f *FilterImpl) WithEndDate(date time.Time) *FilterImpl { + f.FEndDate = &date + return f +} + +func (f *FilterImpl) WithStatus(status string) *FilterImpl { + statuses, err := testkube.ParseTestWorkflowStatusList(status, ",") + if err == nil { + f.FStatuses = statuses + } + return f +} + +func (f *FilterImpl) WithPage(page int) *FilterImpl { + f.FPage = page + return f +} + +func (f *FilterImpl) WithPageSize(pageSize int) *FilterImpl { + f.FPageSize = pageSize + return f +} + +func (f *FilterImpl) WithTextSearch(textSearch string) *FilterImpl { + f.FTextSearch = textSearch + return f +} + +func (f *FilterImpl) WithSelector(selector string) *FilterImpl { + f.FSelector = selector + return f +} + +func (f FilterImpl) Name() string { + return f.FName +} + +func (f FilterImpl) NameDefined() bool { + return f.FName != "" +} + +func (f FilterImpl) LastNDaysDefined() bool { + return f.FLastNDays > 0 +} + +func (f FilterImpl) LastNDays() int { + return f.FLastNDays +} + +func (f FilterImpl) StartDateDefined() bool { + return f.FStartDate != nil +} + +func (f FilterImpl) StartDate() time.Time { + return *f.FStartDate +} + +func (f FilterImpl) EndDateDefined() bool { + return f.FEndDate != nil +} + +func (f FilterImpl) EndDate() time.Time { + return *f.FEndDate +} + +func (f FilterImpl) StatusesDefined() bool { + return len(f.FStatuses) != 0 +} + +func (f FilterImpl) Statuses() []testkube.TestWorkflowStatus { + return f.FStatuses +} + +func (f FilterImpl) Page() int { + return f.FPage +} + +func (f FilterImpl) PageSize() int { + return f.FPageSize +} + +func (f FilterImpl) TextSearchDefined() bool { + return f.FTextSearch != "" +} + +func (f FilterImpl) TextSearch() string { + return f.FTextSearch +} + +func (f FilterImpl) Selector() string { + return f.FSelector +} diff --git a/pkg/tcl/repositorytcl/testworkflow/interface.go b/pkg/tcl/repositorytcl/testworkflow/interface.go new file mode 100644 index 00000000000..431c49cbf1d --- /dev/null +++ b/pkg/tcl/repositorytcl/testworkflow/interface.go @@ -0,0 +1,87 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "context" + "io" + "time" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +const PageDefaultLimit int = 100 + +type Filter interface { + Name() string + NameDefined() bool + LastNDays() int + LastNDaysDefined() bool + StartDate() time.Time + StartDateDefined() bool + EndDate() time.Time + EndDateDefined() bool + Statuses() []testkube.TestWorkflowStatus + StatusesDefined() bool + Page() int + PageSize() int + TextSearchDefined() bool + TextSearch() string + Selector() string +} + +//go:generate mockgen -destination=./mock_repository.go -package=testworkflow "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" Repository +type Repository interface { + // Get gets execution result by id or name + Get(ctx context.Context, id string) (testkube.TestWorkflowExecution, error) + // GetByNameAndTestWorkflow gets execution result by name + GetByNameAndTestWorkflow(ctx context.Context, name, workflowName string) (testkube.TestWorkflowExecution, error) + // GetLatestByTestWorkflow gets latest execution result by workflow + GetLatestByTestWorkflow(ctx context.Context, workflowName string) (*testkube.TestWorkflowExecution, error) + // GetRunning get list of executions that are still running + GetRunning(ctx context.Context) ([]testkube.TestWorkflowExecution, error) + // GetLatestByTestWorkflows gets latest execution results by workflow names + GetLatestByTestWorkflows(ctx context.Context, workflowNames []string) (executions []testkube.TestWorkflowExecutionSummary, err error) + // GetExecutionsTotals gets executions total stats using a filter, use filter with no data for all + GetExecutionsTotals(ctx context.Context, filter ...Filter) (totals testkube.ExecutionsTotals, err error) + // GetExecutions gets executions using a filter, use filter with no data for all + GetExecutions(ctx context.Context, filter Filter) ([]testkube.TestWorkflowExecution, error) + // GetExecutions gets executions using a filter, use filter with no data for all + GetExecutionsSummary(ctx context.Context, filter Filter) ([]testkube.TestWorkflowExecutionSummary, error) + // Insert inserts new execution result + Insert(ctx context.Context, result testkube.TestWorkflowExecution) error + // Update updates execution + Update(ctx context.Context, result testkube.TestWorkflowExecution) error + // UpdateResult updates execution result + UpdateResult(ctx context.Context, id string, result *testkube.TestWorkflowResult) (err error) + // UpdateOutput updates list of output references in the execution result + UpdateOutput(ctx context.Context, id string, output []testkube.TestWorkflowOutput) (err error) + // DeleteByTestWorkflow deletes execution results by workflow + DeleteByTestWorkflow(ctx context.Context, workflowName string) error + // DeleteAll deletes all execution results + DeleteAll(ctx context.Context) error + // DeleteByTestWorkflows deletes execution results by workflows + DeleteByTestWorkflows(ctx context.Context, workflowNames []string) (err error) + // GetTestWorkflowMetrics get metrics based on the TestWorkflow results + GetTestWorkflowMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) +} + +//go:generate mockgen -destination=./mock_output_repository.go -package=testworkflow "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" OutputRepository +type OutputRepository interface { + // PresignSaveLog builds presigned storage URL to save the output in Minio + PresignSaveLog(ctx context.Context, id, workflowName string) (string, error) + // PresignReadLog builds presigned storage URL to read the output from Minio + PresignReadLog(ctx context.Context, id, workflowName string) (string, error) + // SaveLog streams the output from the workflow to Minio + SaveLog(ctx context.Context, id, workflowName string, reader io.Reader) error + // ReadLog streams the output from Minio + ReadLog(ctx context.Context, id, workflowName string) (io.Reader, error) + // HasLog checks if there is an output in Minio + HasLog(ctx context.Context, id, workflowName string) (bool, error) +} diff --git a/pkg/tcl/repositorytcl/testworkflow/minio_output_repository.go b/pkg/tcl/repositorytcl/testworkflow/minio_output_repository.go new file mode 100644 index 00000000000..5802dc4b0e1 --- /dev/null +++ b/pkg/tcl/repositorytcl/testworkflow/minio_output_repository.go @@ -0,0 +1,68 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "context" + "io" + "time" + + "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/storage" +) + +var _ OutputRepository = (*MinioRepository)(nil) + +const bucketFolder = "testworkflows" + +type MinioRepository struct { + storage storage.Client + bucket string +} + +func NewMinioOutputRepository(storageClient storage.Client, bucket string) *MinioRepository { + log.DefaultLogger.Debugw("creating minio workflow output repository", "bucket", bucket) + return &MinioRepository{ + storage: storageClient, + bucket: bucket, + } +} + +// PresignSaveLog builds presigned storage URL to save the output in Cloud +func (m *MinioRepository) PresignSaveLog(ctx context.Context, id, workflowName string) (string, error) { + return m.storage.PresignUploadFileToBucket(ctx, m.bucket, bucketFolder, id, 24*time.Hour) +} + +// PresignReadLog builds presigned storage URL to read the output from Cloud +func (m *MinioRepository) PresignReadLog(ctx context.Context, id, workflowName string) (string, error) { + return m.storage.PresignDownloadFileFromBucket(ctx, m.bucket, bucketFolder, id, 15*time.Minute) +} + +func (m *MinioRepository) SaveLog(ctx context.Context, id, workflowName string, reader io.Reader) error { + log.DefaultLogger.Debugw("inserting output", "id", id, "workflowName", workflowName) + return m.storage.UploadFileToBucket(ctx, m.bucket, bucketFolder, id, reader, -1) +} + +func (m *MinioRepository) ReadLog(ctx context.Context, id, workflowName string) (io.Reader, error) { + file, _, err := m.storage.DownloadFileFromBucket(ctx, m.bucket, bucketFolder, id) + if err != nil { + return nil, err + } + return file, nil +} + +func (m *MinioRepository) HasLog(ctx context.Context, id, workflowName string) (bool, error) { + subCtx, cancel := context.WithCancel(ctx) + defer cancel() + _, _, err := m.storage.DownloadFileFromBucket(subCtx, m.bucket, bucketFolder, id) + if err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/tcl/repositorytcl/testworkflow/mock_output_repository.go b/pkg/tcl/repositorytcl/testworkflow/mock_output_repository.go new file mode 100644 index 00000000000..bc01a3f0fbb --- /dev/null +++ b/pkg/tcl/repositorytcl/testworkflow/mock_output_repository.go @@ -0,0 +1,110 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow (interfaces: OutputRepository) + +// Package testworkflow is a generated GoMock package. +package testworkflow + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockOutputRepository is a mock of OutputRepository interface. +type MockOutputRepository struct { + ctrl *gomock.Controller + recorder *MockOutputRepositoryMockRecorder +} + +// MockOutputRepositoryMockRecorder is the mock recorder for MockOutputRepository. +type MockOutputRepositoryMockRecorder struct { + mock *MockOutputRepository +} + +// NewMockOutputRepository creates a new mock instance. +func NewMockOutputRepository(ctrl *gomock.Controller) *MockOutputRepository { + mock := &MockOutputRepository{ctrl: ctrl} + mock.recorder = &MockOutputRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOutputRepository) EXPECT() *MockOutputRepositoryMockRecorder { + return m.recorder +} + +// HasLog mocks base method. +func (m *MockOutputRepository) HasLog(arg0 context.Context, arg1, arg2 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasLog", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasLog indicates an expected call of HasLog. +func (mr *MockOutputRepositoryMockRecorder) HasLog(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasLog", reflect.TypeOf((*MockOutputRepository)(nil).HasLog), arg0, arg1, arg2) +} + +// PresignReadLog mocks base method. +func (m *MockOutputRepository) PresignReadLog(arg0 context.Context, arg1, arg2 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresignReadLog", arg0, arg1, arg2) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresignReadLog indicates an expected call of PresignReadLog. +func (mr *MockOutputRepositoryMockRecorder) PresignReadLog(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresignReadLog", reflect.TypeOf((*MockOutputRepository)(nil).PresignReadLog), arg0, arg1, arg2) +} + +// PresignSaveLog mocks base method. +func (m *MockOutputRepository) PresignSaveLog(arg0 context.Context, arg1, arg2 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresignSaveLog", arg0, arg1, arg2) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresignSaveLog indicates an expected call of PresignSaveLog. +func (mr *MockOutputRepositoryMockRecorder) PresignSaveLog(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresignSaveLog", reflect.TypeOf((*MockOutputRepository)(nil).PresignSaveLog), arg0, arg1, arg2) +} + +// ReadLog mocks base method. +func (m *MockOutputRepository) ReadLog(arg0 context.Context, arg1, arg2 string) (io.Reader, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadLog", arg0, arg1, arg2) + ret0, _ := ret[0].(io.Reader) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadLog indicates an expected call of ReadLog. +func (mr *MockOutputRepositoryMockRecorder) ReadLog(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadLog", reflect.TypeOf((*MockOutputRepository)(nil).ReadLog), arg0, arg1, arg2) +} + +// SaveLog mocks base method. +func (m *MockOutputRepository) SaveLog(arg0 context.Context, arg1, arg2 string, arg3 io.Reader) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveLog", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveLog indicates an expected call of SaveLog. +func (mr *MockOutputRepositoryMockRecorder) SaveLog(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveLog", reflect.TypeOf((*MockOutputRepository)(nil).SaveLog), arg0, arg1, arg2, arg3) +} diff --git a/pkg/tcl/repositorytcl/testworkflow/mock_repository.go b/pkg/tcl/repositorytcl/testworkflow/mock_repository.go new file mode 100644 index 00000000000..d0a58a348a2 --- /dev/null +++ b/pkg/tcl/repositorytcl/testworkflow/mock_repository.go @@ -0,0 +1,274 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow (interfaces: Repository) + +// Package testworkflow is a generated GoMock package. +package testworkflow + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// DeleteAll mocks base method. +func (m *MockRepository) DeleteAll(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAll", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAll indicates an expected call of DeleteAll. +func (mr *MockRepositoryMockRecorder) DeleteAll(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAll", reflect.TypeOf((*MockRepository)(nil).DeleteAll), arg0) +} + +// DeleteByTestWorkflow mocks base method. +func (m *MockRepository) DeleteByTestWorkflow(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByTestWorkflow", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteByTestWorkflow indicates an expected call of DeleteByTestWorkflow. +func (mr *MockRepositoryMockRecorder) DeleteByTestWorkflow(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByTestWorkflow", reflect.TypeOf((*MockRepository)(nil).DeleteByTestWorkflow), arg0, arg1) +} + +// DeleteByTestWorkflows mocks base method. +func (m *MockRepository) DeleteByTestWorkflows(arg0 context.Context, arg1 []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByTestWorkflows", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteByTestWorkflows indicates an expected call of DeleteByTestWorkflows. +func (mr *MockRepositoryMockRecorder) DeleteByTestWorkflows(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByTestWorkflows", reflect.TypeOf((*MockRepository)(nil).DeleteByTestWorkflows), arg0, arg1) +} + +// Get mocks base method. +func (m *MockRepository) Get(arg0 context.Context, arg1 string) (testkube.TestWorkflowExecution, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(testkube.TestWorkflowExecution) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockRepositoryMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRepository)(nil).Get), arg0, arg1) +} + +// GetByNameAndTestWorkflow mocks base method. +func (m *MockRepository) GetByNameAndTestWorkflow(arg0 context.Context, arg1, arg2 string) (testkube.TestWorkflowExecution, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByNameAndTestWorkflow", arg0, arg1, arg2) + ret0, _ := ret[0].(testkube.TestWorkflowExecution) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByNameAndTestWorkflow indicates an expected call of GetByNameAndTestWorkflow. +func (mr *MockRepositoryMockRecorder) GetByNameAndTestWorkflow(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByNameAndTestWorkflow", reflect.TypeOf((*MockRepository)(nil).GetByNameAndTestWorkflow), arg0, arg1, arg2) +} + +// GetExecutions mocks base method. +func (m *MockRepository) GetExecutions(arg0 context.Context, arg1 Filter) ([]testkube.TestWorkflowExecution, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExecutions", arg0, arg1) + ret0, _ := ret[0].([]testkube.TestWorkflowExecution) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecutions indicates an expected call of GetExecutions. +func (mr *MockRepositoryMockRecorder) GetExecutions(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutions", reflect.TypeOf((*MockRepository)(nil).GetExecutions), arg0, arg1) +} + +// GetExecutionsSummary mocks base method. +func (m *MockRepository) GetExecutionsSummary(arg0 context.Context, arg1 Filter) ([]testkube.TestWorkflowExecutionSummary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExecutionsSummary", arg0, arg1) + ret0, _ := ret[0].([]testkube.TestWorkflowExecutionSummary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecutionsSummary indicates an expected call of GetExecutionsSummary. +func (mr *MockRepositoryMockRecorder) GetExecutionsSummary(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutionsSummary", reflect.TypeOf((*MockRepository)(nil).GetExecutionsSummary), arg0, arg1) +} + +// GetExecutionsTotals mocks base method. +func (m *MockRepository) GetExecutionsTotals(arg0 context.Context, arg1 ...Filter) (testkube.ExecutionsTotals, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetExecutionsTotals", varargs...) + ret0, _ := ret[0].(testkube.ExecutionsTotals) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExecutionsTotals indicates an expected call of GetExecutionsTotals. +func (mr *MockRepositoryMockRecorder) GetExecutionsTotals(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExecutionsTotals", reflect.TypeOf((*MockRepository)(nil).GetExecutionsTotals), varargs...) +} + +// GetLatestByTestWorkflow mocks base method. +func (m *MockRepository) GetLatestByTestWorkflow(arg0 context.Context, arg1 string) (*testkube.TestWorkflowExecution, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestByTestWorkflow", arg0, arg1) + ret0, _ := ret[0].(*testkube.TestWorkflowExecution) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestByTestWorkflow indicates an expected call of GetLatestByTestWorkflow. +func (mr *MockRepositoryMockRecorder) GetLatestByTestWorkflow(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestByTestWorkflow", reflect.TypeOf((*MockRepository)(nil).GetLatestByTestWorkflow), arg0, arg1) +} + +// GetLatestByTestWorkflows mocks base method. +func (m *MockRepository) GetLatestByTestWorkflows(arg0 context.Context, arg1 []string) ([]testkube.TestWorkflowExecutionSummary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestByTestWorkflows", arg0, arg1) + ret0, _ := ret[0].([]testkube.TestWorkflowExecutionSummary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestByTestWorkflows indicates an expected call of GetLatestByTestWorkflows. +func (mr *MockRepositoryMockRecorder) GetLatestByTestWorkflows(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestByTestWorkflows", reflect.TypeOf((*MockRepository)(nil).GetLatestByTestWorkflows), arg0, arg1) +} + +// GetRunning mocks base method. +func (m *MockRepository) GetRunning(arg0 context.Context) ([]testkube.TestWorkflowExecution, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRunning", arg0) + ret0, _ := ret[0].([]testkube.TestWorkflowExecution) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRunning indicates an expected call of GetRunning. +func (mr *MockRepositoryMockRecorder) GetRunning(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRunning", reflect.TypeOf((*MockRepository)(nil).GetRunning), arg0) +} + +// GetTestWorkflowMetrics mocks base method. +func (m *MockRepository) GetTestWorkflowMetrics(arg0 context.Context, arg1 string, arg2, arg3 int) (testkube.ExecutionsMetrics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTestWorkflowMetrics", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(testkube.ExecutionsMetrics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTestWorkflowMetrics indicates an expected call of GetTestWorkflowMetrics. +func (mr *MockRepositoryMockRecorder) GetTestWorkflowMetrics(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTestWorkflowMetrics", reflect.TypeOf((*MockRepository)(nil).GetTestWorkflowMetrics), arg0, arg1, arg2, arg3) +} + +// Insert mocks base method. +func (m *MockRepository) Insert(arg0 context.Context, arg1 testkube.TestWorkflowExecution) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insert", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockRepositoryMockRecorder) Insert(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockRepository)(nil).Insert), arg0, arg1) +} + +// Update mocks base method. +func (m *MockRepository) Update(arg0 context.Context, arg1 testkube.TestWorkflowExecution) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockRepositoryMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), arg0, arg1) +} + +// UpdateOutput mocks base method. +func (m *MockRepository) UpdateOutput(arg0 context.Context, arg1 string, arg2 []testkube.TestWorkflowOutput) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOutput", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateOutput indicates an expected call of UpdateOutput. +func (mr *MockRepositoryMockRecorder) UpdateOutput(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOutput", reflect.TypeOf((*MockRepository)(nil).UpdateOutput), arg0, arg1, arg2) +} + +// UpdateResult mocks base method. +func (m *MockRepository) UpdateResult(arg0 context.Context, arg1 string, arg2 *testkube.TestWorkflowResult) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateResult", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateResult indicates an expected call of UpdateResult. +func (mr *MockRepositoryMockRecorder) UpdateResult(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateResult", reflect.TypeOf((*MockRepository)(nil).UpdateResult), arg0, arg1, arg2) +} diff --git a/pkg/tcl/repositorytcl/testworkflow/mongo.go b/pkg/tcl/repositorytcl/testworkflow/mongo.go new file mode 100644 index 00000000000..9cfd8529ff3 --- /dev/null +++ b/pkg/tcl/repositorytcl/testworkflow/mongo.go @@ -0,0 +1,428 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "context" + "strings" + "time" + + "github.com/kubeshop/testkube/pkg/repository/common" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" +) + +var _ Repository = (*MongoRepository)(nil) + +const CollectionName = "testworkflowresults" + +func NewMongoRepository(db *mongo.Database, allowDiskUse bool, opts ...MongoRepositoryOpt) *MongoRepository { + r := &MongoRepository{ + db: db, + Coll: db.Collection(CollectionName), + allowDiskUse: allowDiskUse, + } + + for _, opt := range opts { + opt(r) + } + + return r +} + +type MongoRepository struct { + db *mongo.Database + Coll *mongo.Collection + allowDiskUse bool +} + +func WithMongoRepositoryCollection(collection *mongo.Collection) MongoRepositoryOpt { + return func(r *MongoRepository) { + r.Coll = collection + } +} + +type MongoRepositoryOpt func(*MongoRepository) + +func (r *MongoRepository) Get(ctx context.Context, id string) (result testkube.TestWorkflowExecution, err error) { + err = r.Coll.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result) + return *result.UnscapeDots(), err +} + +func (r *MongoRepository) GetByNameAndTestWorkflow(ctx context.Context, name, workflowName string) (result testkube.TestWorkflowExecution, err error) { + err = r.Coll.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": name}, bson.M{"name": name}}, "workflow.name": workflowName}).Decode(&result) + return *result.UnscapeDots(), err +} + +func (r *MongoRepository) GetLatestByTestWorkflow(ctx context.Context, workflowName string) (*testkube.TestWorkflowExecution, error) { + opts := options.Aggregate() + pipeline := []bson.M{ + {"$sort": bson.M{"statusat": -1}}, + {"$match": bson.M{"workflow.name": workflowName}}, + {"$limit": 1}, + } + cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) + if err != nil { + return nil, err + } + var items []testkube.TestWorkflowExecution + err = cursor.All(ctx, &items) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, mongo.ErrNoDocuments + } + return items[0].UnscapeDots(), err +} + +func (r *MongoRepository) GetLatestByTestWorkflows(ctx context.Context, workflowNames []string) (executions []testkube.TestWorkflowExecutionSummary, err error) { + if len(workflowNames) == 0 { + return executions, nil + } + + documents := bson.A{} + for _, workflowName := range workflowNames { + documents = append(documents, bson.M{"workflow.name": workflowName}) + } + + pipeline := []bson.M{ + {"$sort": bson.M{"statusat": -1}}, + {"$project": bson.M{ + "_id": 0, + "output": 0, + "signature": 0, + "result.steps": 0, + "result.initialization": 0, + "workflow.spec": 0, + "resolvedWorkflow": 0, + }}, + {"$match": bson.M{"$or": documents}}, + {"$group": bson.M{"_id": "$workflow.name", "execution": bson.M{"$first": "$$ROOT"}}}, + {"$replaceRoot": bson.M{"newRoot": "$execution"}}, + } + + opts := options.Aggregate() + if r.allowDiskUse { + opts.SetAllowDiskUse(r.allowDiskUse) + } + + cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) + if err != nil { + return nil, err + } + err = cursor.All(ctx, &executions) + if err != nil { + return nil, err + } + + if len(executions) == 0 { + return executions, nil + } + + for i := range executions { + executions[i].UnscapeDots() + } + return executions, nil +} + +// TODO: Add limit? +func (r *MongoRepository) GetRunning(ctx context.Context) (result []testkube.TestWorkflowExecution, err error) { + result = make([]testkube.TestWorkflowExecution, 0) + opts := &options.FindOptions{} + opts.SetSort(bson.D{{Key: "_id", Value: -1}}) + if r.allowDiskUse { + opts.SetAllowDiskUse(r.allowDiskUse) + } + + cursor, err := r.Coll.Find(ctx, bson.M{ + "$or": bson.A{ + bson.M{"result.status": testkube.RUNNING_TestWorkflowStatus}, + bson.M{"result.status": testkube.QUEUED_TestWorkflowStatus}, + }, + }, opts) + if err != nil { + return result, err + } + err = cursor.All(ctx, &result) + + for i := range result { + result[i].UnscapeDots() + } + return +} + +func (r *MongoRepository) GetExecutionsTotals(ctx context.Context, filter ...Filter) (totals testkube.ExecutionsTotals, err error) { + var result []struct { + Status string `bson:"_id"` + Count int `bson:"count"` + } + + query := bson.M{} + if len(filter) > 0 { + query, _ = composeQueryAndOpts(filter[0]) + } + + pipeline := []bson.D{{{Key: "$match", Value: query}}} + if len(filter) > 0 { + pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "statusat", Value: -1}}}}) + pipeline = append(pipeline, bson.D{{Key: "$skip", Value: int64(filter[0].Page() * filter[0].PageSize())}}) + pipeline = append(pipeline, bson.D{{Key: "$limit", Value: int64(filter[0].PageSize())}}) + } + + pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$result.status"}, + {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}) + + opts := options.Aggregate() + if r.allowDiskUse { + opts.SetAllowDiskUse(r.allowDiskUse) + } + + cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) + if err != nil { + return totals, err + } + err = cursor.All(ctx, &result) + if err != nil { + return totals, err + } + + var sum int32 + + for _, o := range result { + sum += int32(o.Count) + switch testkube.TestWorkflowStatus(o.Status) { + case testkube.QUEUED_TestWorkflowStatus: + totals.Queued = int32(o.Count) + case testkube.RUNNING_TestWorkflowStatus: + totals.Running = int32(o.Count) + case testkube.PASSED_TestWorkflowStatus: + totals.Passed = int32(o.Count) + case testkube.FAILED_TestWorkflowStatus, testkube.ABORTED_TestWorkflowStatus: + totals.Failed = int32(o.Count) + } + } + totals.Results = sum + + return +} + +func (r *MongoRepository) GetExecutions(ctx context.Context, filter Filter) (result []testkube.TestWorkflowExecution, err error) { + result = make([]testkube.TestWorkflowExecution, 0) + query, opts := composeQueryAndOpts(filter) + if r.allowDiskUse { + opts.SetAllowDiskUse(r.allowDiskUse) + } + + cursor, err := r.Coll.Find(ctx, query, opts) + if err != nil { + return + } + err = cursor.All(ctx, &result) + + for i := range result { + result[i].UnscapeDots() + } + return +} + +func (r *MongoRepository) GetExecutionsSummary(ctx context.Context, filter Filter) (result []testkube.TestWorkflowExecutionSummary, err error) { + result = make([]testkube.TestWorkflowExecutionSummary, 0) + query, opts := composeQueryAndOpts(filter) + if r.allowDiskUse { + opts.SetAllowDiskUse(r.allowDiskUse) + } + + opts = opts.SetProjection(bson.M{ + "_id": 0, + "output": 0, + "signature": 0, + "result.steps": 0, + "result.initialization": 0, + "workflow.spec": 0, + "resolvedWorkflow": 0, + }) + cursor, err := r.Coll.Find(ctx, query, opts) + if err != nil { + return + } + err = cursor.All(ctx, &result) + + for i := range result { + result[i].UnscapeDots() + } + return +} + +func (r *MongoRepository) Insert(ctx context.Context, result testkube.TestWorkflowExecution) (err error) { + result.EscapeDots() + _, err = r.Coll.InsertOne(ctx, result) + return +} + +func (r *MongoRepository) Update(ctx context.Context, result testkube.TestWorkflowExecution) (err error) { + result.EscapeDots() + _, err = r.Coll.ReplaceOne(ctx, bson.M{"id": result.Id}, result) + return +} + +func (r *MongoRepository) UpdateResult(ctx context.Context, id string, result *testkube.TestWorkflowResult) (err error) { + data := bson.M{"result": result} + if !result.FinishedAt.IsZero() { + data["statusat"] = result.FinishedAt + } + _, err = r.Coll.UpdateOne(ctx, bson.M{"id": id}, bson.M{"$set": data}) + return +} + +func (r *MongoRepository) UpdateOutput(ctx context.Context, id string, refs []testkube.TestWorkflowOutput) (err error) { + _, err = r.Coll.UpdateOne(ctx, bson.M{"id": id}, bson.M{"$set": bson.M{"output": refs}}) + return +} + +func composeQueryAndOpts(filter Filter) (bson.M, *options.FindOptions) { + query := bson.M{} + opts := options.Find() + startTimeQuery := bson.M{} + + if filter.NameDefined() { + query["workflow.name"] = filter.Name() + } + + if filter.TextSearchDefined() { + query["name"] = bson.M{"$regex": primitive.Regex{Pattern: filter.TextSearch(), Options: "i"}} + } + + if filter.LastNDaysDefined() { + startTimeQuery["$gte"] = time.Now().Add(-time.Duration(filter.LastNDays()) * 24 * time.Hour) + } + + if filter.StartDateDefined() { + startTimeQuery["$gte"] = filter.StartDate() + } + + if filter.EndDateDefined() { + startTimeQuery["$lte"] = filter.EndDate() + } + + if len(startTimeQuery) > 0 { + query["scheduledat"] = startTimeQuery + } + + if filter.StatusesDefined() { + statuses := filter.Statuses() + if len(statuses) == 1 { + query["result.status"] = statuses[0] + } else { + var conditions bson.A + for _, status := range statuses { + conditions = append(conditions, bson.M{"result.status": status}) + } + + query["$or"] = conditions + } + } + + if filter.Selector() != "" { + items := strings.Split(filter.Selector(), ",") + for _, item := range items { + elements := strings.Split(item, "=") + if len(elements) == 2 { + query["workflow.labels."+elements[0]] = elements[1] + } else if len(elements) == 1 { + query["workflow.labels."+elements[0]] = bson.M{"$exists": true} + } + } + } + + opts.SetSkip(int64(filter.Page() * filter.PageSize())) + opts.SetLimit(int64(filter.PageSize())) + opts.SetSort(bson.D{{Key: "scheduledat", Value: -1}}) + + return query, opts +} + +// DeleteByTestWorkflow deletes execution results by workflow +func (r *MongoRepository) DeleteByTestWorkflow(ctx context.Context, workflowName string) (err error) { + _, err = r.Coll.DeleteMany(ctx, bson.M{"workflow.name": workflowName}) + return +} + +// DeleteAll deletes all execution results +func (r *MongoRepository) DeleteAll(ctx context.Context) (err error) { + _, err = r.Coll.DeleteMany(ctx, bson.M{}) + return +} + +// DeleteByTestWorkflows deletes execution results by workflows +func (r *MongoRepository) DeleteByTestWorkflows(ctx context.Context, workflowNames []string) (err error) { + if len(workflowNames) == 0 { + return nil + } + + conditions := bson.A{} + for _, workflowName := range workflowNames { + conditions = append(conditions, bson.M{"workflow.name": workflowName}) + } + + filter := bson.M{"$or": conditions} + + _, err = r.Coll.DeleteMany(ctx, filter) + return +} + +// TODO: Avoid calculating for all executions in memory (same for tests/test suites) +// GetTestWorkflowMetrics returns test executions metrics +func (r *MongoRepository) GetTestWorkflowMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) { + query := bson.M{"workflow.name": name} + + if last > 0 { + query["scheduledat"] = bson.M{"$gte": time.Now().Add(-time.Duration(last) * 24 * time.Hour)} + } + + pipeline := []bson.M{ + {"$sort": bson.M{"scheduledat": -1}}, + {"$match": query}, + {"$project": bson.M{ + "status": "$result.status", + "duration": "$result.duration", + "starttime": "$scheduledat", + "name": 1, + }}, + } + + opts := options.Aggregate() + if r.allowDiskUse { + opts.SetAllowDiskUse(r.allowDiskUse) + } + + cursor, err := r.Coll.Aggregate(ctx, pipeline, opts) + if err != nil { + return metrics, err + } + + var executions []testkube.ExecutionsMetricsExecutions + err = cursor.All(ctx, &executions) + + if err != nil { + return metrics, err + } + + metrics = common.CalculateMetrics(executions) + if limit > 0 && limit < len(metrics.Executions) { + metrics.Executions = metrics.Executions[:limit] + } + + return metrics, nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go index 5eee19e7f6a..2325ea7c629 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -60,7 +60,7 @@ func New(parentCtx context.Context, clientSet kubernetes.Interface, namespace, i } case <-time.After(JobRetrievalTimeout): ctxCancel() - return nil, ctx.Err() + return nil, errors.New("timeout retrieving job") } // Build accessible controller @@ -255,11 +255,15 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { w.SendValue(Notification{Result: result.Clone()}) // Watch for the container events + lastEvTs := time.Time{} for v := range WatchContainerPreEvents(ctx, c.podEvents, container.Name, 0).Stream(ctx).Channel() { if v.Error != nil { w.SendError(v.Error) continue } + if lastEvTs.Before(v.Value.CreationTimestamp.Time) { + lastEvTs = v.Value.CreationTimestamp.Time + } if v.Value.Reason == "Created" { stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ QueuedAt: v.Value.CreationTimestamp.Time.UTC(), @@ -275,6 +279,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } w.SendValue(Notification{ Timestamp: v.Value.CreationTimestamp.Time, + Ref: container.Name, Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), }) } @@ -282,7 +287,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { // Emit the next result if stepResult.StartedAt.IsZero() { w.SendError(errors.New("step container is in unknown state")) - return + break } w.SendValue(Notification{Result: result.Clone()}) @@ -323,14 +328,14 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } continue } - w.SendValue(Notification{Timestamp: v.Value.Time, Log: string(v.Value.Log)}) + w.SendValue(Notification{Timestamp: v.Value.Time, Ref: container.Name, Log: string(v.Value.Log)}) } // Watch container status status, err := GetFinalContainerResult(ctx, c.pod, container.Name) if err != nil { w.SendError(err) - return + break } stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ FinishedAt: status.FinishedAt.UTC(), diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go index f5fe7b60a6e..fc9c84b35b1 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go @@ -208,23 +208,34 @@ func WatchContainerLogs(ctx context.Context, clientSet kubernetes.Interface, pod // Parse and return the logs reader := bufio.NewReader(stream) - var tsPrefix []byte + var tsPrefix, tmpTsPrefix []byte isNewLine := false isStarted := false - var ts time.Time + var ts, tmpTs time.Time for { + var prepend []byte + // Read next timestamp - ts, tsPrefix, err = ReadTimestamp(reader) - if err != nil { - if err != io.EOF { - w.SendError(err) - } + tmpTs, tmpTsPrefix, err = ReadTimestamp(reader) + if err == nil { + ts = tmpTs + tsPrefix = tmpTsPrefix + } else if err == io.EOF { return + } else { + // Edge case: Kubernetes may send critical errors without timestamp (like ionotify) + if len(tmpTsPrefix) > 0 { + prepend = tmpTsPrefix + } + w.SendError(err) } // Check for the next part line, err := utils.ReadLongLine(reader) - commentRe := regexp.MustCompile(fmt.Sprintf(`^%s(%s)?([^%s]+)%s([a-zA-Z0-9_.]+)(?:%s(.+))?%s$`, + if len(prepend) > 0 { + line = append(prepend, line...) + } + commentRe := regexp.MustCompile(fmt.Sprintf(`^%s(%s)?([^%s]+)%s([a-zA-Z0-9-_.]+)(?:%s([^\n]+))?%s$`, data.InstructionPrefix, data.HintPrefix, data.InstructionSeparator, data.InstructionSeparator, data.InstructionValueSeparator, data.InstructionSeparator)) // Process the received line @@ -270,6 +281,8 @@ func WatchContainerLogs(ctx context.Context, clientSet kubernetes.Interface, pod w.SendValue(ContainerLog{Time: ts, Log: line}) isNewLine = true } + } else if isStarted { + w.SendValue(ContainerLog{Time: ts, Log: append([]byte("\n"), tsPrefix...)}) } // Handle the error @@ -296,7 +309,7 @@ func ReadTimestamp(reader *bufio.Reader) (time.Time, []byte, error) { } ts, err := time.Parse(time.RFC3339Nano, string(tsPrefix[0:30])) if err != nil { - return time.Time{}, nil, errors2.Wrap(err, "parsing timestamp") + return time.Time{}, tsPrefix, errors2.Wrap(err, "parsing timestamp") } return ts, tsPrefix, nil } diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go index a87850c7cf5..74ffecf8509 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go @@ -90,7 +90,6 @@ func WatchJob(ctx context.Context, clientSet kubernetes.Interface, namespace, na return } case watch.Deleted: - w.SendValue(nil) return } } @@ -173,7 +172,6 @@ func watchPod(ctx context.Context, clientSet kubernetes.Interface, namespace str return } case watch.Deleted: - w.SendValue(nil) return } } @@ -277,29 +275,29 @@ func WatchPodEventsByPodWatcher(ctx context.Context, clientSet kubernetes.Interf w := newWatcher[*corev1.Event](ctx, cacheSize) go func() { + defer w.Close() + v, ok := <-pod.Any(ctx) if v.Error != nil { w.SendError(v.Error) - w.Close() return } if !ok || v.Value == nil { - w.Close() return } - watchEvents(clientSet, namespace, ListOptions{ + _, wch := watchEvents(clientSet, namespace, ListOptions{ FieldSelector: "involvedObject.name=" + v.Value.Name, TypeMeta: metav1.TypeMeta{Kind: "Pod"}, }, w) + // Wait for all immediate events + <-wch + // Adds missing "Started" events. // It may have duplicated "Started", but better than no events. // @see {@link https://github.com/kubernetes/kubernetes/issues/122904#issuecomment-1944387021} started := map[string]bool{} for p := range pod.Stream(ctx).Channel() { - if p.Value == nil { - return - } for i, s := range append(p.Value.Status.InitContainerStatuses, p.Value.Status.ContainerStatuses...) { if !started[s.Name] && (s.State.Running != nil || s.State.Terminated != nil) { ts := metav1.Time{Time: time.Now()} @@ -364,10 +362,12 @@ func WatchJobPreEvents(ctx context.Context, jobEvents Watcher[*corev1.Event], ca } func WatchEvents(ctx context.Context, clientSet kubernetes.Interface, namespace string, options ListOptions) Watcher[*corev1.Event] { - return watchEvents(clientSet, namespace, options, newWatcher[*corev1.Event](ctx, options.CacheSize)) + w, _ := watchEvents(clientSet, namespace, options, newWatcher[*corev1.Event](ctx, options.CacheSize)) + return w } -func watchEvents(clientSet kubernetes.Interface, namespace string, options ListOptions, w *watcher[*corev1.Event]) Watcher[*corev1.Event] { +func watchEvents(clientSet kubernetes.Interface, namespace string, options ListOptions, w *watcher[*corev1.Event]) (Watcher[*corev1.Event], chan struct{}) { + initCh := make(chan struct{}) go func() { defer w.Close() @@ -381,15 +381,13 @@ func watchEvents(clientSet kubernetes.Interface, namespace string, options ListO // Expose the initial value if err != nil { w.SendError(err) + close(initCh) return } for _, event := range list.Items { - w.SendValue(&event) - } - if len(list.Items) == 1 { - event := list.Items[0] - w.SendValue(&event) + w.SendValue(event.DeepCopy()) } + close(initCh) // Start watching for changes events, err := clientSet.CoreV1().Events(namespace).Watch(w.ctx, metav1.ListOptions{ @@ -426,5 +424,5 @@ func watchEvents(clientSet kubernetes.Interface, namespace string, options ListO } }() - return w + return w, initCh } diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go new file mode 100644 index 00000000000..47040cc9e5d --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go @@ -0,0 +1,175 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflowexecutor + +import ( + "bufio" + "context" + "io" + "sync" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/event" + "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowcontroller" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" +) + +//go:generate mockgen -destination=./mock_executor.go -package=testworkflowexecutor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowexecutor" TestWorkflowExecutor +type TestWorkflowExecutor interface { + Schedule(bundle *testworkflowprocessor.Bundle, execution testkube.TestWorkflowExecution) + Control(ctx context.Context, execution testkube.TestWorkflowExecution) + Recover(ctx context.Context) +} + +type executor struct { + emitter *event.Emitter + clientSet kubernetes.Interface + repository testworkflow.Repository + output testworkflow.OutputRepository + namespace string +} + +func New(emitter *event.Emitter, clientSet kubernetes.Interface, repository testworkflow.Repository, output testworkflow.OutputRepository, namespace string) TestWorkflowExecutor { + return &executor{ + emitter: emitter, + clientSet: clientSet, + repository: repository, + output: output, + namespace: namespace, + } +} + +func (e *executor) Schedule(bundle *testworkflowprocessor.Bundle, execution testkube.TestWorkflowExecution) { + // Inform about execution start + e.emitter.Notify(testkube.NewEventQueueTestWorkflow(&execution)) + + // Deploy required resources + err := e.Deploy(context.Background(), bundle) + if err != nil { + e.handleFatalError(execution, err) + return + } + + // Start to control the results + go e.Control(context.Background(), execution) +} + +func (e *executor) Deploy(ctx context.Context, bundle *testworkflowprocessor.Bundle) (err error) { + for _, item := range bundle.Secrets { + _, err = e.clientSet.CoreV1().Secrets(e.namespace).Create(ctx, &item, metav1.CreateOptions{}) + if err != nil { + return + } + } + for _, item := range bundle.ConfigMaps { + _, err = e.clientSet.CoreV1().ConfigMaps(e.namespace).Create(ctx, &item, metav1.CreateOptions{}) + if err != nil { + return + } + } + _, err = e.clientSet.BatchV1().Jobs(e.namespace).Create(ctx, &bundle.Job, metav1.CreateOptions{}) + return +} + +func (e *executor) handleFatalError(execution testkube.TestWorkflowExecution, err error) { + execution.Result.Fatal(err) + err = e.repository.UpdateResult(context.Background(), execution.Id, execution.Result) + if err != nil { + log.DefaultLogger.Errorf("failed to save fatal error for execution %s: %v", execution.Id, err) + } + e.emitter.Notify(testkube.NewEventEndTestWorkflowFailed(&execution)) + go testworkflowcontroller.Cleanup(context.Background(), e.clientSet, e.namespace, execution.Id) +} + +func (e *executor) Recover(ctx context.Context) { + list, err := e.repository.GetRunning(ctx) + if err != nil { + return + } + for _, execution := range list { + e.Control(context.Background(), execution) + } +} + +func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowExecution) { + ctrl, err := testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) + if err != nil { + e.handleFatalError(execution, err) + return + } + + // Prepare stream for writing log + r, writer := io.Pipe() + reader := bufio.NewReader(r) + ref := "" + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { + if v.Error != nil { + continue + } + if v.Value.Output != nil { + execution.Output = append(execution.Output, *v.Value.Output.ToInternal()) + } else if v.Value.Result != nil { + execution.Result = v.Value.Result + if execution.Result.IsFinished() { + execution.StatusAt = execution.Result.FinishedAt + } + _ = e.repository.UpdateResult(ctx, execution.Id, execution.Result) + } else { + if ref != v.Value.Ref { + ref = v.Value.Ref + _, err := writer.Write([]byte(data.SprintHint(ref, "start"))) + if err != nil { + log.DefaultLogger.Error(errors.Wrap(err, "saving log output signature")) + } + } + _, err := writer.Write([]byte(v.Value.Log)) + if err != nil { + log.DefaultLogger.Error(errors.Wrap(err, "saving log output content")) + } + } + } + + err := writer.Close() + if err != nil { + log.DefaultLogger.Error(errors.Wrap(err, "saving log output - closing stream")) + } + + // TODO: Consider AppendOutput ($push) instead + _ = e.repository.UpdateOutput(ctx, execution.Id, execution.Output) + if execution.Result.IsFinished() { + if execution.Result.IsPassed() { + e.emitter.Notify(testkube.NewEventEndTestWorkflowSuccess(&execution)) + } else if execution.Result.IsAborted() { + e.emitter.Notify(testkube.NewEventEndTestWorkflowAborted(&execution)) + } else { + e.emitter.Notify(testkube.NewEventEndTestWorkflowFailed(&execution)) + } + } + }() + + // Stream the log into Minio + err = e.output.SaveLog(context.Background(), execution.Id, execution.Workflow.Name, reader) + if err != nil { + log.DefaultLogger.Error(errors.Wrap(err, "saving log output")) + } + + wg.Wait() + testworkflowcontroller.Cleanup(ctx, e.clientSet, e.namespace, execution.Id) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/mock_executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/mock_executor.go new file mode 100644 index 00000000000..8d8c669bb03 --- /dev/null +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/mock_executor.go @@ -0,0 +1,73 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowexecutor (interfaces: TestWorkflowExecutor) + +// Package testworkflowexecutor is a generated GoMock package. +package testworkflowexecutor + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" + testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" +) + +// MockTestWorkflowExecutor is a mock of TestWorkflowExecutor interface. +type MockTestWorkflowExecutor struct { + ctrl *gomock.Controller + recorder *MockTestWorkflowExecutorMockRecorder +} + +// MockTestWorkflowExecutorMockRecorder is the mock recorder for MockTestWorkflowExecutor. +type MockTestWorkflowExecutorMockRecorder struct { + mock *MockTestWorkflowExecutor +} + +// NewMockTestWorkflowExecutor creates a new mock instance. +func NewMockTestWorkflowExecutor(ctrl *gomock.Controller) *MockTestWorkflowExecutor { + mock := &MockTestWorkflowExecutor{ctrl: ctrl} + mock.recorder = &MockTestWorkflowExecutorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTestWorkflowExecutor) EXPECT() *MockTestWorkflowExecutorMockRecorder { + return m.recorder +} + +// Control mocks base method. +func (m *MockTestWorkflowExecutor) Control(arg0 context.Context, arg1 testkube.TestWorkflowExecution) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Control", arg0, arg1) +} + +// Control indicates an expected call of Control. +func (mr *MockTestWorkflowExecutorMockRecorder) Control(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Control", reflect.TypeOf((*MockTestWorkflowExecutor)(nil).Control), arg0, arg1) +} + +// Recover mocks base method. +func (m *MockTestWorkflowExecutor) Recover(arg0 context.Context) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Recover", arg0) +} + +// Recover indicates an expected call of Recover. +func (mr *MockTestWorkflowExecutorMockRecorder) Recover(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recover", reflect.TypeOf((*MockTestWorkflowExecutor)(nil).Recover), arg0) +} + +// Schedule mocks base method. +func (m *MockTestWorkflowExecutor) Schedule(arg0 *testworkflowprocessor.Bundle, arg1 testkube.TestWorkflowExecution) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Schedule", arg0, arg1) +} + +// Schedule indicates an expected call of Schedule. +func (mr *MockTestWorkflowExecutorMockRecorder) Schedule(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Schedule", reflect.TypeOf((*MockTestWorkflowExecutor)(nil).Schedule), arg0, arg1) +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go index 5142b24f8d2..d836b84949e 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/containerstage.go @@ -16,9 +16,9 @@ import ( ) type containerStage struct { - stageMetadata `expr:"include"` - stageLifecycle `expr:"include"` - container Container `expr:"include"` + stageMetadata + stageLifecycle + container Container } type ContainerStage interface { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go index 49f44ce1bbc..72b04173993 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/groupstage.go @@ -18,10 +18,10 @@ import ( ) type groupStage struct { - stageMetadata `expr:"include"` - stageLifecycle `expr:"include"` - children []Stage `expr:"include"` - virtual bool + stageMetadata + stageLifecycle + children []Stage + virtual bool } type GroupStage interface { @@ -121,11 +121,14 @@ func (s *groupStage) Flatten() []Stage { // Merge stage into single one below if possible first := s.children[0] - if len(s.children) == 1 && (s.name == "" || first.Name() == "") { + if len(s.children) == 1 && (s.name == "" || first.Name() == "") && (s.timeout == "" || first.Timeout() == "") { if first.Name() == "" { first.SetName(s.name) } first.AppendConditions(s.condition) + if first.Timeout() == "" { + first.SetTimeout(s.timeout) + } if s.negative { first.SetNegative(!first.Negative()) } @@ -158,11 +161,11 @@ func (s *groupStage) ApplyImages(images map[string]*imageinspector.Info) error { } func (s *groupStage) Resolve(m ...expressionstcl.Machine) error { - for _, ch := range s.children { - err := ch.Resolve(m...) + for i := range s.children { + err := s.children[i].Resolve(m...) if err != nil { return errors.Wrap(err, "group stage container") } } - return expressionstcl.Simplify(s.stageMetadata, m...) + return expressionstcl.Simplify(&s.stageMetadata, m...) } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go index 8497f76e05e..8f2119b2a98 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -69,7 +69,7 @@ func (p *processor) process(layer Intermediate, container Container, step testwo // Build an initial group for the inner items self := NewGroupStage(ref, false) self.SetName(step.Name) - self.SetOptional(step.Optional).SetNegative(step.Negative) + self.SetOptional(step.Optional).SetNegative(step.Negative).SetTimeout(step.Timeout) if step.Condition != "" { self.SetCondition(step.Condition) } else { @@ -192,7 +192,7 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo } // Build list of the containers - containers, err := buildKubernetesContainers(root, NewInitProcess().SetRef(root.Ref())) + containers, err := buildKubernetesContainers(root, NewInitProcess().SetRef(root.Ref()), machines...) if err != nil { return nil, errors.Wrap(err, "building Kubernetes containers") } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go index e20994d9c1b..13b922ea77b 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagelifecycle.go @@ -37,9 +37,9 @@ type StageLifecycle interface { type stageLifecycle struct { negative bool optional bool - condition string `exp:"expression"` - retry testworkflowsv1.RetryPolicy `expr:"include"` - timeout string `expr:"template"` + condition string + retry testworkflowsv1.RetryPolicy + timeout string } func NewStageLifecycle() StageLifecycle { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go index fc0d0c790b2..032e712bb82 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/stagemetadata.go @@ -19,8 +19,8 @@ type StageMetadata interface { type stageMetadata struct { ref string - name string `expr:"template"` - category string `expr:"template"` + name string + category string } func NewStageMetadata(ref string) StageMetadata { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go index 64c8e31cf49..19842c7a4b0 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go @@ -17,6 +17,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/kubeshop/testkube/internal/common" + "github.com/kubeshop/testkube/pkg/tcl/expressionstcl" ) func AnnotateControlledBy(obj metav1.Object, testWorkflowId string) { @@ -41,7 +42,7 @@ func isNotOptional(stage Stage) bool { return !stage.Optional() } -func buildKubernetesContainers(stage Stage, init *initProcess) (containers []corev1.Container, err error) { +func buildKubernetesContainers(stage Stage, init *initProcess, machines ...expressionstcl.Machine) (containers []corev1.Container, err error) { if stage.Timeout() != "" { init.AddTimeout(stage.Timeout(), stage.Ref()) } @@ -83,7 +84,7 @@ func buildKubernetesContainers(stage Stage, init *initProcess) (containers []cor init.ResetCondition() } // Pass down to another group or container - sub, serr := buildKubernetesContainers(ch, init.Children(ch.Ref())) + sub, serr := buildKubernetesContainers(ch, init.Children(ch.Ref()), machines...) if serr != nil { return nil, fmt.Errorf("%s: %s: resolving children: %s", stage.Ref(), stage.Name(), serr.Error()) } @@ -95,7 +96,7 @@ func buildKubernetesContainers(stage Stage, init *initProcess) (containers []cor if !ok { return nil, fmt.Errorf("%s: %s: stage that is neither container nor group", stage.Ref(), stage.Name()) } - err = c.Container().Detach().Resolve() + err = c.Container().Detach().Resolve(machines...) if err != nil { return nil, fmt.Errorf("%s: %s: resolving container: %s", stage.Ref(), stage.Name(), err.Error()) } From 0e83cffaa85e5159b16dcac181f00c67f06eac02 Mon Sep 17 00:00:00 2001 From: Dejan Zele Pejchev Date: Thu, 7 Mar 2024 16:57:03 +0100 Subject: [PATCH 178/234] feat: TKC-1290: add support for custom CA certificates (#5098) * TKC-1290: add support for custom CA certificates * TKC-1290: fix failing unit tests for custom CA certificates --- cmd/api-server/main.go | 12 ++++- cmd/logs/main.go | 11 +++- config/job-container-template.yml | 10 ++++ config/job-template.yml | 21 ++++++-- internal/config/config.go | 4 ++ pkg/agent/agent.go | 54 ++++++++++++++++++- pkg/agent/agent_test.go | 2 +- pkg/agent/events_test.go | 2 +- pkg/agent/logs_test.go | 2 +- pkg/envs/variables.go | 3 ++ pkg/executor/client/common.go | 26 ++++----- pkg/executor/client/job.go | 4 ++ pkg/executor/common.go | 12 +++++ .../containerexecutor_test.go | 3 ++ pkg/executor/scraper/factory/factory.go | 11 +++- pkg/logs/adapter/cloud_test.go | 8 +-- pkg/logs/config/logs_config.go | 3 ++ pkg/scheduler/service.go | 3 ++ pkg/scheduler/test_scheduler.go | 1 + pkg/triggers/executor_test.go | 1 + pkg/triggers/service_test.go | 1 + 21 files changed, 167 insertions(+), 27 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index f4519f43c03..43c90ebb321 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -191,7 +191,16 @@ func main() { mode = common.ModeAgent } if mode == common.ModeAgent { - grpcConn, err = agent.NewGRPCConnection(ctx, cfg.TestkubeProTLSInsecure, cfg.TestkubeProSkipVerify, cfg.TestkubeProURL, log.DefaultLogger) + grpcConn, err = agent.NewGRPCConnection( + ctx, + cfg.TestkubeProTLSInsecure, + cfg.TestkubeProSkipVerify, + cfg.TestkubeProURL, + cfg.TestkubeProCertFile, + cfg.TestkubeProKeyFile, + cfg.TestkubeProCAFile, + log.DefaultLogger, + ) ui.ExitOnError("error creating gRPC connection", err) defer grpcConn.Close() @@ -520,6 +529,7 @@ func main() { features, logsStream, cfg.TestkubeNamespace, + cfg.TestkubeProTLSSecret, ) slackLoader, err := newSlackLoader(cfg, envs) diff --git a/cmd/logs/main.go b/cmd/logs/main.go index 9ee57d6c353..3bca5bdb049 100644 --- a/cmd/logs/main.go +++ b/cmd/logs/main.go @@ -107,7 +107,16 @@ func main() { switch mode { case common.ModeAgent: - grpcConn, err := agent.NewGRPCConnection(ctx, cfg.TestkubeProTLSInsecure, cfg.TestkubeProSkipVerify, cfg.TestkubeProURL+cfg.TestkubeProLogsPath, log) + grpcConn, err := agent.NewGRPCConnection( + ctx, + cfg.TestkubeProTLSInsecure, + cfg.TestkubeProSkipVerify, + cfg.TestkubeProURL+cfg.TestkubeProLogsPath, + cfg.TestkubeProCertFile, + cfg.TestkubeProKeyFile, + cfg.TestkubeProCAFile, + log, + ) ui.ExitOnError("error creating gRPC connection for logs service", err) defer grpcConn.Close() grpcClient := pb.NewCloudLogsServiceClient(grpcConn) diff --git a/config/job-container-template.yml b/config/job-container-template.yml index f3372643357..93249d45aed 100644 --- a/config/job-container-template.yml +++ b/config/job-container-template.yml @@ -77,6 +77,11 @@ spec: - name: {{ .CertificateSecret }} mountPath: /etc/certs {{- end }} + {{- if .AgentAPITLSSecret }} + - mountPath: /tmp/agent-cert + readOnly: true + name: {{ .AgentAPITLSSecret }} + {{- end }} {{- if .ArtifactRequest }} {{- if and .ArtifactRequest.VolumeMountPath .ArtifactRequest.StorageClassName }} - name: artifact-volume @@ -103,6 +108,11 @@ spec: secret: secretName: {{ .CertificateSecret }} {{- end }} + {{- if .AgentAPITLSSecret }} + - name: { { .AgentAPITLSSecret } } + secret: + secretName: {{ .AgentAPITLSSecret }} + {{- end }} {{- if .ArtifactRequest }} {{- if and .ArtifactRequest.VolumeMountPath .ArtifactRequest.StorageClassName }} - name: artifact-volume diff --git a/config/job-template.yml b/config/job-template.yml index 4525c298616..e4ca84162df 100644 --- a/config/job-template.yml +++ b/config/job-template.yml @@ -17,7 +17,7 @@ spec: image: {{ .InitImage }} {{- end }} imagePullPolicy: IfNotPresent - command: + command: - "/bin/runner" - '{{ .Jsn }}' volumeMounts: @@ -27,6 +27,11 @@ spec: - name: {{ .CertificateSecret }} mountPath: /etc/certs {{- end }} + {{- if .AgentAPITLSSecret }} + - mountPath: /tmp/agent-cert + readOnly: true + name: {{ .AgentAPITLSSecret }} + {{- end }} {{- if .ArtifactRequest }} {{- if and .ArtifactRequest.VolumeMountPath .ArtifactRequest.StorageClassName }} - name: artifact-volume @@ -46,7 +51,7 @@ spec: {{- end }} {{- end }} containers: - {{ if .Features.LogsV2 -}} + {{ if .Features.LogsV2 -}} - name: "{{ .Name }}-logs" image: {{ .Registry }}/{{ .LogSidecarImage }} env: @@ -66,7 +71,7 @@ spec: image: {{ .Image }} {{- end }} imagePullPolicy: IfNotPresent - command: + command: - "/bin/runner" - '{{ .Jsn }}' volumeMounts: @@ -76,6 +81,11 @@ spec: - name: {{ .CertificateSecret }} mountPath: /etc/certs {{- end }} + {{- if .AgentAPITLSSecret }} + - mountPath: /tmp/agent-cert + readOnly: true + name: {{ .AgentAPITLSSecret }} + {{- end }} {{- if .ArtifactRequest }} {{- if and .ArtifactRequest.VolumeMountPath .ArtifactRequest.StorageClassName }} - name: artifact-volume @@ -102,6 +112,11 @@ spec: secret: secretName: {{ .CertificateSecret }} {{- end }} + {{- if .AgentAPITLSSecret }} + - name: {{ .AgentAPITLSSecret }} + secret: + secretName: {{ .AgentAPITLSSecret }} + {{- end }} {{- if .ArtifactRequest }} {{- if and .ArtifactRequest.VolumeMountPath .ArtifactRequest.StorageClassName }} - name: artifact-volume diff --git a/internal/config/config.go b/internal/config/config.go index b19ba6b4c9b..82c4c7c6183 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,10 @@ type Config struct { TestkubeProOrgID string `envconfig:"TESTKUBE_PRO_ORG_ID" default:""` TestkubeProMigrate string `envconfig:"TESTKUBE_PRO_MIGRATE" default:"false"` TestkubeProConnectionTimeout int `envconfig:"TESTKUBE_PRO_CONNECTION_TIMEOUT" default:"10"` + TestkubeProCertFile string `envconfig:"TESTKUBE_PRO_CERT_FILE" default:""` + TestkubeProKeyFile string `envconfig:"TESTKUBE_PRO_KEY_FILE" default:""` + TestkubeProCAFile string `envconfig:"TESTKUBE_PRO_CA_FILE" default:""` + TestkubeProTLSSecret string `envconfig:"TESTKUBE_PRO_TLS_SECRET" default:""` TestkubeWatcherNamespaces string `envconfig:"TESTKUBE_WATCHER_NAMESPACES" default:""` GraphqlPort string `envconfig:"TESTKUBE_GRAPHQL_PORT" default:"8070"` TestkubeRegistry string `envconfig:"TESTKUBE_REGISTRY" default:""` diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 017b3ffb30f..7248774702e 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -3,8 +3,10 @@ package agent import ( "context" "crypto/tls" + "crypto/x509" "fmt" "math" + "os" "time" "google.golang.org/grpc/keepalive" @@ -42,13 +44,32 @@ const ( // buffer up to five messages per worker const bufferSizePerWorker = 5 -func NewGRPCConnection(ctx context.Context, isInsecure bool, skipVerify bool, server string, logger *zap.SugaredLogger) (*grpc.ClientConn, error) { +func NewGRPCConnection( + ctx context.Context, + isInsecure bool, + skipVerify bool, + server string, + certFile, keyFile, caFile string, + logger *zap.SugaredLogger, +) (*grpc.ClientConn, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - var tlsConfig *tls.Config + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} if skipVerify { tlsConfig = &tls.Config{InsecureSkipVerify: true} + } else { + if certFile != "" && keyFile != "" { + if err := clientCert(tlsConfig, certFile, keyFile); err != nil { + return nil, err + } + } + if caFile != "" { + if err := rootCAs(tlsConfig, caFile); err != nil { + return nil, err + } + } } + creds := credentials.NewTLS(tlsConfig) if isInsecure { creds = insecure.NewCredentials() @@ -76,6 +97,35 @@ func NewGRPCConnection(ctx context.Context, isInsecure bool, skipVerify bool, se ) } +func rootCAs(tlsConfig *tls.Config, file ...string) error { + pool := x509.NewCertPool() + for _, f := range file { + rootPEM, err := os.ReadFile(f) + if err != nil || rootPEM == nil { + return fmt.Errorf("agent: error loading or parsing rootCA file: %v", err) + } + ok := pool.AppendCertsFromPEM(rootPEM) + if !ok { + return fmt.Errorf("agent: failed to parse root certificate from %q", f) + } + } + tlsConfig.RootCAs = pool + return nil +} + +func clientCert(tlsConfig *tls.Config, certFile, keyFile string) error { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return fmt.Errorf("agent: error loading client certificate: %v", err) + } + cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return fmt.Errorf("agent: error parsing client certificate: %v", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + return nil +} + type Agent struct { client cloud.TestKubeCloudAPIClient handler fasthttp.RequestHandler diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 32098a76f16..2192e16c012 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -49,7 +49,7 @@ func TestCommandExecution(t *testing.T) { atomic.AddInt32(&msgCnt, 1) } - grpcConn, err := agent.NewGRPCConnection(context.Background(), true, false, url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(context.Background(), true, false, url, "", "", "", log.DefaultLogger) ui.ExitOnError("error creating gRPC connection", err) defer grpcConn.Close() diff --git a/pkg/agent/events_test.go b/pkg/agent/events_test.go index dee9bf4ac15..7d133ea4359 100644 --- a/pkg/agent/events_test.go +++ b/pkg/agent/events_test.go @@ -47,7 +47,7 @@ func TestEventLoop(t *testing.T) { logger, _ := zap.NewDevelopment() - grpcConn, err := agent.NewGRPCConnection(context.Background(), true, false, url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(context.Background(), true, false, url, "", "", "", log.DefaultLogger) ui.ExitOnError("error creating gRPC connection", err) defer grpcConn.Close() diff --git a/pkg/agent/logs_test.go b/pkg/agent/logs_test.go index 486040dac6e..3595912cbb4 100644 --- a/pkg/agent/logs_test.go +++ b/pkg/agent/logs_test.go @@ -45,7 +45,7 @@ func TestLogStream(t *testing.T) { fmt.Fprintf(ctx, "Hi there! RequestURI is %q", ctx.RequestURI()) } - grpcConn, err := agent.NewGRPCConnection(context.Background(), true, false, url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(context.Background(), true, false, url, "", "", "", log.DefaultLogger) ui.ExitOnError("error creating gRPC connection", err) defer grpcConn.Close() diff --git a/pkg/envs/variables.go b/pkg/envs/variables.go index 5c9fd06aa7f..1afb930fd50 100644 --- a/pkg/envs/variables.go +++ b/pkg/envs/variables.go @@ -48,6 +48,9 @@ type Params struct { ProAPIURL string `envconfig:"RUNNER_PRO_API_URL"` // RUNNER_PRO_API_URL ProConnectionTimeoutSec int `envconfig:"RUNNER_PRO_CONNECTION_TIMEOUT" default:"10"` // RUNNER_PRO_CONNECTION_TIMEOUT ProAPISkipVerify bool `envconfig:"RUNNER_PRO_API_SKIP_VERIFY" default:"false"` // RUNNER_PRO_API_SKIP_VERIFY + ProAPICertFile string `envconfig:"RUNNER_PRO_API_CERT_FILE"` // RUNNER_PRO_API_CERT_FILE + ProAPIKeyFile string `envconfig:"RUNNER_PRO_API_KEY_FILE"` // RUNNER_PRO_API_KEY_FILE + ProAPICAFile string `envconfig:"RUNNER_PRO_API_CA_FILE"` // RUNNER_PRO_API_CA_FILE SlavesConfigs string `envconfig:"RUNNER_SLAVES_CONFIGS"` // RUNNER_SLAVES_CONFIGS } diff --git a/pkg/executor/client/common.go b/pkg/executor/client/common.go index 0aaf5a22e42..246e87f5e46 100644 --- a/pkg/executor/client/common.go +++ b/pkg/executor/client/common.go @@ -23,18 +23,20 @@ const ( ) type ExecuteOptions struct { - ID string - TestName string - Namespace string - TestSpec testsv3.TestSpec - ExecutorName string - ExecutorSpec executorv1.ExecutorSpec - Request testkube.ExecutionRequest - Sync bool - Labels map[string]string - UsernameSecret *testkube.SecretRef - TokenSecret *testkube.SecretRef - CertificateSecret string + ID string + TestName string + Namespace string + TestSpec testsv3.TestSpec + ExecutorName string + ExecutorSpec executorv1.ExecutorSpec + Request testkube.ExecutionRequest + Sync bool + Labels map[string]string + UsernameSecret *testkube.SecretRef + TokenSecret *testkube.SecretRef + CertificateSecret string + // AgentAPITLSSecret is a secret name that contains TLS certificate for Agent (gRPC) API + AgentAPITLSSecret string ImagePullSecretNames []string Features featureflags.FeatureFlags } diff --git a/pkg/executor/client/job.go b/pkg/executor/client/job.go index 9e21f81fa93..aa9bdb03221 100644 --- a/pkg/executor/client/job.go +++ b/pkg/executor/client/job.go @@ -172,6 +172,7 @@ type JobOptions struct { UsernameSecret *testkube.SecretRef TokenSecret *testkube.SecretRef CertificateSecret string + AgentAPITLSSecret string Variables map[string]testkube.Variable ActiveDeadlineSeconds int64 ServiceAccountName string @@ -1021,6 +1022,9 @@ func NewJobOptions(log *zap.SugaredLogger, templatesClient templatesv1.Interface } } + // used for adding custom certificates for Agent (gRPC) API + jobOptions.AgentAPITLSSecret = options.AgentAPITLSSecret + return } diff --git a/pkg/executor/common.go b/pkg/executor/common.go index 00b934d258b..a76d0aa0ee6 100644 --- a/pkg/executor/common.go +++ b/pkg/executor/common.go @@ -127,6 +127,18 @@ var RunnerEnvVars = []corev1.EnvVar{ Name: "RUNNER_PRO_CONNECTION_TIMEOUT", Value: getOr("TESTKUBE_PRO_CONNECTION_TIMEOUT", "10"), }, + { + Name: "RUNNER_PRO_API_CERT_FILE", + Value: os.Getenv("TESTKUBE_PRO_CERT_FILE"), + }, + { + Name: "RUNNER_PRO_API_KEY_FILE", + Value: os.Getenv("TESTKUBE_PRO_KEY_FILE"), + }, + { + Name: "RUNNER_PRO_API_CA_FILE", + Value: os.Getenv("TESTKUBE_PRO_CA_FILE"), + }, { Name: "RUNNER_DASHBOARD_URI", Value: os.Getenv("TESTKUBE_DASHBOARD_URI"), diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index 1569c254df7..b5079748bee 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -166,6 +166,9 @@ func TestNewExecutorJobSpecWithArgs(t *testing.T) { {Name: "RUNNER_CLOUD_API_TLS_INSECURE", Value: "false"}, // DEPRECATED {Name: "RUNNER_CLOUD_API_SKIP_VERIFY", Value: "false"}, // DEPRECATED {Name: "RUNNER_CLUSTERID", Value: ""}, + {Name: "RUNNER_PRO_API_CERT_FILE", Value: ""}, + {Name: "RUNNER_PRO_API_KEY_FILE", Value: ""}, + {Name: "RUNNER_PRO_API_CA_FILE", Value: ""}, {Name: "CI", Value: "1"}, {Name: "key", Value: "value"}, {Name: "aa", Value: "bb"}, diff --git a/pkg/executor/scraper/factory/factory.go b/pkg/executor/scraper/factory/factory.go index ef29fcd942c..1b8d89bff00 100644 --- a/pkg/executor/scraper/factory/factory.go +++ b/pkg/executor/scraper/factory/factory.go @@ -102,7 +102,16 @@ func getRemoteStorageUploader(ctx context.Context, params envs.Params) (uploader output.PrintLogf( "%s Uploading artifacts using Remote Storage Uploader (timeout:%ds, agentInsecure:%v, agentSkipVerify: %v, url: %s, scraperSkipVerify: %v)", ui.IconCheckMark, params.ProConnectionTimeoutSec, params.ProAPITLSInsecure, params.ProAPISkipVerify, params.ProAPIURL, params.SkipVerify) - grpcConn, err := agent.NewGRPCConnection(ctxTimeout, params.ProAPITLSInsecure, params.ProAPISkipVerify, params.ProAPIURL, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection( + ctxTimeout, + params.ProAPITLSInsecure, + params.ProAPISkipVerify, + params.ProAPIURL, + params.ProAPICertFile, + params.ProAPIKeyFile, + params.ProAPICAFile, + log.DefaultLogger, + ) if err != nil { return nil, err } diff --git a/pkg/logs/adapter/cloud_test.go b/pkg/logs/adapter/cloud_test.go index fb639323997..54a8866c805 100644 --- a/pkg/logs/adapter/cloud_test.go +++ b/pkg/logs/adapter/cloud_test.go @@ -36,7 +36,7 @@ func TestCloudAdapter(t *testing.T) { id := "id1" // and connection - grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, "", "", "", log.DefaultLogger) assert.NoError(t, err) defer grpcConn.Close() @@ -78,7 +78,7 @@ func TestCloudAdapter(t *testing.T) { id3 := "id3" // and connection - grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, "", "", "", log.DefaultLogger) assert.NoError(t, err) defer grpcConn.Close() grpcClient := pb.NewCloudLogsServiceClient(grpcConn) @@ -127,7 +127,7 @@ func TestCloudAdapter(t *testing.T) { id := "id1M" // and grpc connetion to the server - grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, "", "", "", log.DefaultLogger) assert.NoError(t, err) defer grpcConn.Close() @@ -161,7 +161,7 @@ func TestCloudAdapter(t *testing.T) { ctx := context.Background() // and grpc connetion to the server - grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, log.DefaultLogger) + grpcConn, err := agent.NewGRPCConnection(ctx, true, true, ts.Url, "", "", "", log.DefaultLogger) assert.NoError(t, err) defer grpcConn.Close() diff --git a/pkg/logs/config/logs_config.go b/pkg/logs/config/logs_config.go index 69daefe16e3..fd03c823be6 100644 --- a/pkg/logs/config/logs_config.go +++ b/pkg/logs/config/logs_config.go @@ -17,6 +17,9 @@ type Config struct { TestkubeProURL string `envconfig:"TESTKUBE_PRO_URL" default:""` TestkubeProLogsPath string `envconfig:"TESTKUBE_PRO_LOGS_PATH" default:"/logs"` TestkubeProTLSInsecure bool `envconfig:"TESTKUBE_PRO_TLS_INSECURE" default:"false"` + TestkubeProCertFile string `envconfig:"TESTKUBE_PRO_CERT_FILE" default:""` + TestkubeProKeyFile string `envconfig:"TESTKUBE_PRO_KEY_FILE" default:""` + TestkubeProCAFile string `envconfig:"TESTKUBE_PRO_CA_FILE" default:""` TestkubeProWorkerCount int `envconfig:"TESTKUBE_PRO_WORKER_COUNT" default:"50"` TestkubeProLogStreamWorkerCount int `envconfig:"TESTKUBE_PRO_LOG_STREAM_WORKER_COUNT" default:"25"` TestkubeProSkipVerify bool `envconfig:"TESTKUBE_PRO_SKIP_VERIFY" default:"false"` diff --git a/pkg/scheduler/service.go b/pkg/scheduler/service.go index 9503ca4aafc..2b50e3007ed 100644 --- a/pkg/scheduler/service.go +++ b/pkg/scheduler/service.go @@ -45,6 +45,7 @@ type Scheduler struct { logsStream logsclient.Stream subscriptionChecker checktcl.SubscriptionChecker namespace string + agentAPITLSSecret string } func NewScheduler( @@ -68,6 +69,7 @@ func NewScheduler( featureFlags featureflags.FeatureFlags, logsStream logsclient.Stream, namespace string, + agentAPITLSSecret string, ) *Scheduler { return &Scheduler{ metrics: metrics, @@ -90,6 +92,7 @@ func NewScheduler( featureFlags: featureFlags, logsStream: logsStream, namespace: namespace, + agentAPITLSSecret: agentAPITLSSecret, } } diff --git a/pkg/scheduler/test_scheduler.go b/pkg/scheduler/test_scheduler.go index 209e0493437..657a13fca64 100644 --- a/pkg/scheduler/test_scheduler.go +++ b/pkg/scheduler/test_scheduler.go @@ -557,6 +557,7 @@ func (s *Scheduler) getExecuteOptions(namespace, id string, request testkube.Exe UsernameSecret: usernameSecret, TokenSecret: tokenSecret, CertificateSecret: certificateSecret, + AgentAPITLSSecret: s.agentAPITLSSecret, ImagePullSecretNames: imagePullSecrets, Features: s.featureFlags, }, nil diff --git a/pkg/triggers/executor_test.go b/pkg/triggers/executor_test.go index 6d898309050..ee93be8a0df 100644 --- a/pkg/triggers/executor_test.go +++ b/pkg/triggers/executor_test.go @@ -127,6 +127,7 @@ func TestExecute(t *testing.T) { featureflags.FeatureFlags{}, mockLogsStream, "", + "", ) s := &Service{ triggerStatus: make(map[statusKey]*triggerStatus), diff --git a/pkg/triggers/service_test.go b/pkg/triggers/service_test.go index c30737de587..1a7192c4ae6 100644 --- a/pkg/triggers/service_test.go +++ b/pkg/triggers/service_test.go @@ -140,6 +140,7 @@ func TestService_Run(t *testing.T) { featureflags.FeatureFlags{}, mockLogsStream, "", + "", ) mockLeaseBackend := NewMockLeaseBackend(mockCtrl) From 739b8159e8608c50e9bf4afaf798f67f2e9f31aa Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 8 Mar 2024 11:14:46 +0100 Subject: [PATCH 179/234] feat(TKC-1642): add TestWorkflow support of cloning Git, executing tests, and artifacts (#5119) * feat(TKC-1642): add CI/CD for TestWorkflow Toolkit * feat(TKC-1642): add TestWorkflow toolkit with "execute" command * feat(TKC-1642): add "content.git" support for TestWorkflows * feat(TKC-1642): add support for 'async' option in TestWorkflow "execute" step * feat(TKC-1642): add support for TestWorkflow artifacts - API, CLI and 'artifacts' step * chore(TKC-1642): rename tarStream items --- .../docker-build-api-executors-tag.yaml | 55 ++++ .github/workflows/docker-build-develop.yaml | 57 ++++ .github/workflows/docker-build-release.yaml | 57 ++++ api/v1/testkube.yaml | 142 +++++++++ build/testworkflow-toolkit/Dockerfile | 7 + cmd/api-server/main.go | 10 +- .../commands/artifacts/artifacts.go | 34 ++- cmd/kubectl-testkube/commands/download.go | 31 +- cmd/kubectl-testkube/commands/tests/common.go | 39 ++- cmd/kubectl-testkube/commands/tests/run.go | 2 +- .../commands/testsuites/common.go | 2 +- .../renderer/testworkflowexecution_obj.go | 7 + .../artifacts/cloud_uploader.go | 156 ++++++++++ .../artifacts/direct_processor.go | 32 ++ .../artifacts/direct_uploader.go | 110 +++++++ .../testworkflow-toolkit/artifacts/handler.go | 92 ++++++ .../artifacts/mimetype.go | 29 ++ .../artifacts/processor.go | 17 ++ .../artifacts/tar_processor.go | 78 +++++ .../artifacts/tar_stream.go | 93 ++++++ .../artifacts/tarcached_processor.go | 111 +++++++ .../artifacts/uploader.go | 19 ++ .../testworkflow-toolkit/artifacts/walker.go | 221 ++++++++++++++ .../commands/artifacts.go | 177 +++++++++++ .../testworkflow-toolkit/commands/clone.go | 103 +++++++ .../testworkflow-toolkit/commands/execute.go | 285 ++++++++++++++++++ cmd/tcl/testworkflow-toolkit/commands/root.go | 71 +++++ .../testworkflow-toolkit/commands/utils.go | 43 +++ cmd/tcl/testworkflow-toolkit/env/client.go | 74 +++++ cmd/tcl/testworkflow-toolkit/env/config.go | 104 +++++++ cmd/tcl/testworkflow-toolkit/main.go | 29 ++ go.mod | 2 + go.sum | 4 + ...aser-docker-build-testworkflow-toolkit.yml | 93 ++++++ internal/app/api/v1/executions.go | 12 +- internal/app/api/v1/server.go | 4 +- internal/app/api/v1/testsuites.go | 4 +- internal/app/api/v1/uploads.go | 4 +- internal/app/api/v1/uploads_test.go | 2 +- internal/common/common.go | 9 + pkg/api/v1/client/api.go | 3 + pkg/api/v1/client/interface.go | 3 + pkg/api/v1/client/testworkflow.go | 22 ++ .../model_test_workflow_result_extended.go | 2 +- pkg/cloud/data/artifact/artifacts_storage.go | 22 +- .../data/artifact/artifacts_storage_models.go | 16 +- pkg/cloud/data/artifact/scraper_model.go | 9 +- pkg/storage/artifacts.go | 4 +- pkg/storage/artifacts_mock.go | 16 +- pkg/storage/minio/artifacts_storage.go | 4 +- .../artifacts_storage_integration_test.go | 4 +- pkg/tcl/apitcl/v1/server.go | 6 + pkg/tcl/apitcl/v1/testworkflowexecutions.go | 77 +++++ pkg/tcl/apitcl/v1/testworkflows.go | 32 +- .../testworkflowcontroller/utils.go | 3 + .../testworkflowprocessor/constants.go | 13 + .../testworkflowprocessor/container.go | 30 +- .../testworkflowprocessor/operations.go | 153 ++++++++++ .../testworkflowprocessor/processor.go | 8 +- 59 files changed, 2773 insertions(+), 75 deletions(-) create mode 100644 build/testworkflow-toolkit/Dockerfile create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/direct_processor.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/direct_uploader.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/handler.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/mimetype.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/processor.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/tar_processor.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/tar_stream.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/tarcached_processor.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/uploader.go create mode 100644 cmd/tcl/testworkflow-toolkit/artifacts/walker.go create mode 100644 cmd/tcl/testworkflow-toolkit/commands/artifacts.go create mode 100644 cmd/tcl/testworkflow-toolkit/commands/clone.go create mode 100644 cmd/tcl/testworkflow-toolkit/commands/execute.go create mode 100644 cmd/tcl/testworkflow-toolkit/commands/root.go create mode 100644 cmd/tcl/testworkflow-toolkit/commands/utils.go create mode 100644 cmd/tcl/testworkflow-toolkit/env/client.go create mode 100644 cmd/tcl/testworkflow-toolkit/env/config.go create mode 100644 cmd/tcl/testworkflow-toolkit/main.go create mode 100644 goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml diff --git a/.github/workflows/docker-build-api-executors-tag.yaml b/.github/workflows/docker-build-api-executors-tag.yaml index baad3d09371..68740f23969 100644 --- a/.github/workflows/docker-build-api-executors-tag.yaml +++ b/.github/workflows/docker-build-api-executors-tag.yaml @@ -133,6 +133,61 @@ jobs: DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + testworkflow-toolkit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: sigstore/cosign-installer@v3.0.5 + - uses: anchore/sbom-action/download-syft@v0.14.2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set-up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Go Cache + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: testkube-tw-toolkit-go-${{ hashFiles('**/go.sum') }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Release + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release -f goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + ANALYTICS_TRACKING_ID: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_ID}} + ANALYTICS_API_KEY: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_SECRET}} + SLACK_BOT_CLIENT_ID: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_ID}} + SLACK_BOT_CLIENT_SECRET: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_SECRET}} + SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_SEGMENTIO_KEY}} + CLOUD_SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_CLOUD_SEGMENTIO_KEY}} + DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" + DOCKER_BUILDX_CACHE_FROM: "type=gha" + DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" + ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + single_executor: strategy: matrix: diff --git a/.github/workflows/docker-build-develop.yaml b/.github/workflows/docker-build-develop.yaml index 460fe5f7c27..8e667c8968e 100644 --- a/.github/workflows/docker-build-develop.yaml +++ b/.github/workflows/docker-build-develop.yaml @@ -124,6 +124,63 @@ jobs: run: | docker push kubeshop/testkube-tw-init:${{ steps.commit.outputs.short }} + testworkflow-toolkit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Set-up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Go Cache + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: testkube-tw-toolkit-go-${{ hashFiles('**/go.sum') }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - id: commit + uses: prompt/actions-commit-hash@v3 + + - name: Release + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release -f goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml --snapshot + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + ANALYTICS_TRACKING_ID: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_ID}} + ANALYTICS_API_KEY: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_SECRET}} + SLACK_BOT_CLIENT_ID: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_ID}} + SLACK_BOT_CLIENT_SECRET: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_SECRET}} + SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_SEGMENTIO_KEY}} + CLOUD_SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_CLOUD_SEGMENTIO_KEY}} + DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" + DOCKER_BUILDX_CACHE_FROM: "type=gha" + DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" + ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + IMAGE_TAG_SHA: true + + - name: Push Docker images + run: | + docker push kubeshop/testkube-tw-toolkit:${{ steps.commit.outputs.short }} + single_executor: strategy: matrix: diff --git a/.github/workflows/docker-build-release.yaml b/.github/workflows/docker-build-release.yaml index 3df9902da72..b5a9b6bf2d1 100644 --- a/.github/workflows/docker-build-release.yaml +++ b/.github/workflows/docker-build-release.yaml @@ -125,6 +125,63 @@ jobs: run: | docker push kubeshop/testkube-tw-init:${{ steps.commit.outputs.short }} + testworkflow-toolkit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Set-up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Go Cache + uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: testkube-tw-toolkit-go-${{ hashFiles('**/go.sum') }} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - id: commit + uses: prompt/actions-commit-hash@v3 + + - name: Release + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release -f goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml --snapshot + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + ANALYTICS_TRACKING_ID: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_ID}} + ANALYTICS_API_KEY: ${{secrets.TESTKUBE_API_GA_MEASUREMENT_SECRET}} + SLACK_BOT_CLIENT_ID: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_ID}} + SLACK_BOT_CLIENT_SECRET: ${{secrets.TESTKUBE_SLACK_BOT_CLIENT_SECRET}} + SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_SEGMENTIO_KEY}} + CLOUD_SEGMENTIO_KEY: ${{secrets.TESTKUBE_API_CLOUD_SEGMENTIO_KEY}} + DOCKER_BUILDX_BUILDER: "${{ steps.buildx.outputs.name }}" + DOCKER_BUILDX_CACHE_FROM: "type=gha" + DOCKER_BUILDX_CACHE_TO: "type=gha,mode=max" + ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }} + IMAGE_TAG_SHA: true + + - name: Push Docker images + run: | + docker push kubeshop/testkube-tw-toolkit:${{ steps.commit.outputs.short }} + single_executor: strategy: matrix: diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index b77110ab98d..95219cbe55f 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -3838,6 +3838,148 @@ paths: type: array items: $ref: "#/components/schemas/Problem" + + /test-workflow-executions/{executionID}/artifacts: + get: + parameters: + - $ref: "#/components/parameters/ID" + tags: + - test-workflows + - artifacts + - executions + - api + - pro + summary: "Get test workflow execution's artifacts by ID" + description: "Returns artifacts of the given executionID" + operationId: getTestWorkflowExecutionArtifacts + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Artifact" + 404: + description: "execution not found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 500: + description: "problem with getting execution's artifacts from storage" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + + /test-workflow-executions/{executionID}/artifacts/{filename}: + get: + parameters: + - $ref: "#/components/parameters/ID" + - $ref: "#/components/parameters/Filename" + tags: + - test-workflows + - artifacts + - executions + - api + - pro + summary: "Download test workflow artifact" + description: "Download the artifact file from the given execution" + operationId: downloadTestWorkflowArtifact + responses: + 200: + description: "successful operation" + content: + application/octet-stream: + schema: + type: string + format: binary + 404: + description: "execution not found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 500: + description: "problem with getting artifacts from storage" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + + /test-workflow-executions/{executionID}/artifact-archive: + get: + parameters: + - $ref: "#/components/parameters/ID" + - $ref: "#/components/parameters/Mask" + tags: + - test-workflows + - artifacts + - executions + - api + - pro + summary: "Download test workflow artifact archive" + description: "Download the artifact archive from the given execution" + operationId: downloadTestWorkflowArtifactArchive + responses: + 200: + description: "successful operation" + content: + application/octet-stream: + schema: + type: string + format: binary + 404: + description: "execution not found" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 402: + description: "missing Pro subscription for a commercial feature" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + 500: + description: "problem with getting artifact archive from storage" + content: + application/problem+json: + schema: + type: array + items: + $ref: "#/components/schemas/Problem" + /test-workflow-executions/{executionID}/abort: post: tags: diff --git a/build/testworkflow-toolkit/Dockerfile b/build/testworkflow-toolkit/Dockerfile new file mode 100644 index 00000000000..cbd4a996a91 --- /dev/null +++ b/build/testworkflow-toolkit/Dockerfile @@ -0,0 +1,7 @@ +# syntax=docker/dockerfile:1 +ARG ALPINE_IMAGE +FROM ${ALPINE_IMAGE} +RUN apk --no-cache add ca-certificates libssl1.1 git +COPY testworkflow-toolkit /toolkit +USER 1001 +ENTRYPOINT ["/toolkit"] diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 43c90ebb321..62bf681d5e3 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -602,7 +602,15 @@ func main() { } // Apply Pro server enhancements - apitclv1.NewApiTCL(api, &proContext, kubeClient, inspector, testWorkflowResultsRepository, testWorkflowOutputRepository).AppendRoutes() + apitclv1.NewApiTCL( + api, + &proContext, + kubeClient, + inspector, + testWorkflowResultsRepository, + testWorkflowOutputRepository, + "http://"+cfg.APIServerFullname+":"+cfg.APIServerPort, + ).AppendRoutes() api.InitEvents() if !cfg.DisableTestTriggers { diff --git a/cmd/kubectl-testkube/commands/artifacts/artifacts.go b/cmd/kubectl-testkube/commands/artifacts/artifacts.go index 83478802691..e6deec5ebfc 100644 --- a/cmd/kubectl-testkube/commands/artifacts/artifacts.go +++ b/cmd/kubectl-testkube/commands/artifacts/artifacts.go @@ -3,6 +3,7 @@ package artifacts import ( "os" + "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" @@ -19,7 +20,7 @@ func NewListArtifactsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "artifact ", Aliases: []string{"artifacts"}, - Short: "List artifacts of the given test or test suite execution name", + Short: "List artifacts of the given test, test suite or test workflow execution name", Args: validator.ExecutionName, Run: func(cmd *cobra.Command, args []string) { executionID = args[0] @@ -31,16 +32,29 @@ func NewListArtifactsCmd() *cobra.Command { var artifacts testkube.Artifacts var errArtifacts error if err == nil && execution.Id != "" { - artifacts, errArtifacts = client.GetExecutionArtifacts(executionID) - ui.ExitOnError("getting test artifacts ", errArtifacts) - } else { - _, err := client.GetTestSuiteExecution(executionID) - ui.ExitOnError("no test or test suite execution was found with the following id", err) - artifacts, errArtifacts = client.GetTestSuiteExecutionArtifacts(executionID) - ui.ExitOnError("getting test suite artifacts ", errArtifacts) + artifacts, errArtifacts = client.GetExecutionArtifacts(execution.Id) + ui.ExitOnError("getting test artifacts", errArtifacts) + ui.Table(artifacts, os.Stdout) + return } - - ui.Table(artifacts, os.Stdout) + tsExecution, err := client.GetTestSuiteExecution(executionID) + if err == nil && tsExecution.Id != "" { + artifacts, errArtifacts = client.GetTestSuiteExecutionArtifacts(tsExecution.Id) + ui.ExitOnError("getting test suite artifacts", errArtifacts) + ui.Table(artifacts, os.Stdout) + return + } + twExecution, err := client.GetTestWorkflowExecution(executionID) + if err == nil && twExecution.Id != "" { + artifacts, errArtifacts = client.GetTestWorkflowExecutionArtifacts(twExecution.Id) + ui.ExitOnError("getting test workflow artifacts", errArtifacts) + ui.Table(artifacts, os.Stdout) + return + } + if err == nil { + err = errors.New("no test, test suite or test workflow execution was found with the following id") + } + ui.Fail(err) }, } diff --git a/cmd/kubectl-testkube/commands/download.go b/cmd/kubectl-testkube/commands/download.go index b6f72524cb7..90dd47e57b0 100644 --- a/cmd/kubectl-testkube/commands/download.go +++ b/cmd/kubectl-testkube/commands/download.go @@ -90,10 +90,22 @@ func NewDownloadSingleArtifactsCmd() *cobra.Command { client, _, err := common.GetClient(cmd) ui.ExitOnError("getting client", err) - f, err := client.DownloadFile(executionID, filename, destination) - ui.ExitOnError("downloading file"+filename, err) - - ui.Info(fmt.Sprintf("File %s downloaded.\n", f)) + execution, err := client.GetExecution(executionID) + if err == nil && execution.Id != "" { + f, err := client.DownloadFile(executionID, filename, destination) + ui.ExitOnError("downloading file "+filename, err) + ui.Info(fmt.Sprintf("File %s downloaded.\n", f)) + return + } + twExecution, err := client.GetTestWorkflowExecution(executionID) + if err == nil && twExecution.Id != "" { + f, err := client.DownloadTestWorkflowArtifact(executionID, filename, destination) + ui.ExitOnError("downloading file "+filename, err) + ui.Info(fmt.Sprintf("File %s downloaded.\n", f)) + return + } + + ui.ExitOnError("retrieving execution", err) }, } @@ -119,7 +131,16 @@ func NewDownloadAllArtifactsCmd() *cobra.Command { client, _, err := common.GetClient(cmd) ui.ExitOnError("getting client", err) - tests.DownloadArtifacts(executionID, downloadDir, format, masks, client) + execution, err := client.GetExecution(executionID) + if err == nil && execution.Id != "" { + tests.DownloadTestArtifacts(executionID, downloadDir, format, masks, client) + return + } + twExecution, err := client.GetTestWorkflowExecution(executionID) + if err == nil && twExecution.Id != "" { + tests.DownloadTestWorkflowArtifacts(executionID, downloadDir, format, masks, client) + return + } }, } diff --git a/cmd/kubectl-testkube/commands/tests/common.go b/cmd/kubectl-testkube/commands/tests/common.go index 54f731a35af..2d07f7b7a05 100644 --- a/cmd/kubectl-testkube/commands/tests/common.go +++ b/cmd/kubectl-testkube/commands/tests/common.go @@ -52,11 +52,40 @@ func printExecutionDetails(execution testkube.Execution) { ui.NL() } -func DownloadArtifacts(id, dir, format string, masks []string, client apiclientv1.Client) { +func DownloadTestArtifacts(id, dir, format string, masks []string, client apiclientv1.Client) { artifacts, err := client.GetExecutionArtifacts(id) - ui.ExitOnError("getting artifacts ", err) + ui.ExitOnError("getting artifacts", err) - err = os.MkdirAll(dir, os.ModePerm) + downloadFile := func(artifact testkube.Artifact, dir string) (string, error) { + return client.DownloadFile(id, artifact.Name, dir) + } + downloadArchive := func(dir string, masks []string) (string, error) { + return client.DownloadArchive(id, dir, masks) + } + downloadArtifacts(dir, format, masks, artifacts, downloadFile, downloadArchive) +} + +func DownloadTestWorkflowArtifacts(id, dir, format string, masks []string, client apiclientv1.Client) { + artifacts, err := client.GetTestWorkflowExecutionArtifacts(id) + ui.ExitOnError("getting artifacts", err) + + downloadFile := func(artifact testkube.Artifact, dir string) (string, error) { + return client.DownloadTestWorkflowArtifact(id, artifact.Name, dir) + } + downloadArchive := func(dir string, masks []string) (string, error) { + return client.DownloadTestWorkflowArtifactArchive(id, dir, masks) + } + downloadArtifacts(dir, format, masks, artifacts, downloadFile, downloadArchive) +} + +func downloadArtifacts( + dir, format string, + masks []string, + artifacts testkube.Artifacts, + downloadFile func(artifact testkube.Artifact, dir string) (string, error), + downloadArchive func(dir string, masks []string) (string, error), +) { + err := os.MkdirAll(dir, os.ModePerm) ui.ExitOnError("creating dir "+dir, err) if len(artifacts) > 0 { @@ -91,7 +120,7 @@ func DownloadArtifacts(id, dir, format string, masks []string, client apiclientv continue } - f, err := client.DownloadFile(id, artifact.Name, dir) + f, err := downloadFile(artifact, dir) ui.ExitOnError("downloading file: "+f, err) ui.Warn(" - downloading file ", f) } @@ -106,7 +135,7 @@ func DownloadArtifacts(id, dir, format string, masks []string, client apiclientv defer close(ch) go func() { - f, err := client.DownloadArchive(id, dir, masks) + f, err := downloadArchive(dir, masks) ui.ExitOnError("downloading archive: "+f, err) ch <- f diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index aadaa426127..6f2b26c761a 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -331,7 +331,7 @@ func NewRunTestCmd() *cobra.Command { if execution.Id != "" { if watchEnabled && len(args) > 0 { if downloadArtifactsEnabled && (execution.IsPassed() || execution.IsFailed()) { - DownloadArtifacts(execution.Id, downloadDir, format, masks, client) + DownloadTestArtifacts(execution.Id, downloadDir, format, masks, client) } } diff --git a/cmd/kubectl-testkube/commands/testsuites/common.go b/cmd/kubectl-testkube/commands/testsuites/common.go index ba43ac3460d..b9c5ee63ebc 100644 --- a/cmd/kubectl-testkube/commands/testsuites/common.go +++ b/cmd/kubectl-testkube/commands/testsuites/common.go @@ -411,7 +411,7 @@ func DownloadArtifacts(id, dir, format string, masks []string, client apiclientv for _, step := range execution.Execute { if step.Execution != nil && step.Step != nil && step.Step.Test != "" { if step.Execution.IsPassed() || step.Execution.IsFailed() { - tests.DownloadArtifacts(step.Execution.Id, filepath.Join(dir, step.Execution.TestName+"-"+step.Execution.Id), format, masks, client) + tests.DownloadTestArtifacts(step.Execution.Id, filepath.Join(dir, step.Execution.TestName+"-"+step.Execution.Id), format, masks, client) } } } diff --git a/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go b/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go index 79e7e12f5ce..ecca4033f75 100644 --- a/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go +++ b/cmd/kubectl-testkube/commands/testworkflows/renderer/testworkflowexecution_obj.go @@ -3,6 +3,8 @@ package renderer import ( "fmt" + "github.com/pkg/errors" + "github.com/kubeshop/testkube/pkg/api/v1/client" "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/ui" @@ -38,6 +40,11 @@ func TestWorkflowExecutionRenderer(client client.Client, ui *ui.UI, obj interfac } } + if execution.Result != nil && execution.Result.Initialization != nil && execution.Result.Initialization.ErrorMessage != "" { + ui.NL() + ui.Err(errors.New(execution.Result.Initialization.ErrorMessage)) + } + return nil } diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go b/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go new file mode 100644 index 00000000000..541af6fc929 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go @@ -0,0 +1,156 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/pkg/errors" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" + "github.com/kubeshop/testkube/pkg/cloud/data/artifact" + cloudexecutor "github.com/kubeshop/testkube/pkg/cloud/data/executor" + "github.com/kubeshop/testkube/pkg/ui" +) + +type CloudUploaderRequestEnhancer = func(req *http.Request, path string, size int64) + +func NewCloudUploader(opts ...CloudUploaderOpt) Uploader { + uploader := &cloudUploader{ + parallelism: 1, + reqEnhancers: make([]CloudUploaderRequestEnhancer, 0), + } + for _, opt := range opts { + opt(uploader) + } + return uploader +} + +type cloudUploader struct { + client cloudexecutor.Executor + wg sync.WaitGroup + sema chan struct{} + parallelism int + error atomic.Bool + reqEnhancers []CloudUploaderRequestEnhancer +} + +func (d *cloudUploader) Start() (err error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + d.client = env.Cloud(ctx) + d.sema = make(chan struct{}, d.parallelism) + return err +} + +func (d *cloudUploader) getSignedURL(name string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + response, err := d.client.Execute(ctx, artifact.CmdScraperPutObjectSignedURL, &artifact.PutObjectSignedURLRequest{ + Object: name, + ExecutionID: env.ExecutionId(), + TestWorkflowName: env.WorkflowName(), + }) + if err != nil { + return "", err + } + var commandResponse artifact.PutObjectSignedURLResponse + if err := json.Unmarshal(response, &commandResponse); err != nil { + return "", err + } + return commandResponse.URL, nil +} + +func (d *cloudUploader) putObject(url string, path string, file io.Reader, size int64) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file) + if err != nil { + return err + } + for _, r := range d.reqEnhancers { + r(req, path, size) + } + req.ContentLength = size + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/octet-stream") + } + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + client := &http.Client{Transport: tr} + res, err := client.Do(req) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + b, _ := io.ReadAll(res.Body) + return errors.Errorf("failed saving file: status code: %d / message: %s", res.StatusCode, string(b)) + } + return nil +} + +func (d *cloudUploader) upload(path string, file io.Reader, size int64) { + url, err := d.getSignedURL(path) + if err != nil { + d.error.Store(true) + ui.Errf("%s: failed: get signed URL: %s", path, err.Error()) + return + } + err = d.putObject(url, path, file, size) + if err != nil { + d.error.Store(true) + ui.Errf("%s: failed: store file: %s", path, err.Error()) + return + } +} + +func (d *cloudUploader) Add(path string, file io.ReadCloser, size int64) error { + d.wg.Add(1) + d.sema <- struct{}{} + go func() { + d.upload(path, file, size) + _ = file.Close() + d.wg.Done() + <-d.sema + }() + return nil +} + +func (d *cloudUploader) End() error { + d.wg.Wait() + if d.error.Load() { + return fmt.Errorf("upload failed") + } + return nil +} + +type CloudUploaderOpt = func(uploader *cloudUploader) + +func WithParallelismCloud(parallelism int) CloudUploaderOpt { + return func(uploader *cloudUploader) { + if parallelism < 1 { + parallelism = 1 + } + uploader.parallelism = parallelism + } +} + +func WithRequestEnhancerCloud(enhancer CloudUploaderRequestEnhancer) CloudUploaderOpt { + return func(uploader *cloudUploader) { + uploader.reqEnhancers = append(uploader.reqEnhancers, enhancer) + } +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/direct_processor.go b/cmd/tcl/testworkflow-toolkit/artifacts/direct_processor.go new file mode 100644 index 00000000000..f9d76a309bc --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/direct_processor.go @@ -0,0 +1,32 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "io/fs" +) + +func NewDirectProcessor() Processor { + return &directProcessor{} +} + +type directProcessor struct { +} + +func (d *directProcessor) Start() error { + return nil +} + +func (d *directProcessor) Add(uploader Uploader, path string, file fs.File, stat fs.FileInfo) error { + return uploader.Add(path, file, stat.Size()) +} + +func (d *directProcessor) End() error { + return nil +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/direct_uploader.go b/cmd/tcl/testworkflow-toolkit/artifacts/direct_uploader.go new file mode 100644 index 00000000000..2dc5cdc01df --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/direct_uploader.go @@ -0,0 +1,110 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "context" + "fmt" + "io" + "sync" + "sync/atomic" + + minio2 "github.com/minio/minio-go/v7" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" + "github.com/kubeshop/testkube/pkg/storage/minio" + "github.com/kubeshop/testkube/pkg/ui" +) + +type PutObjectOptionsEnhancer = func(options *minio2.PutObjectOptions, path string, size int64) + +func NewDirectUploader(opts ...DirectUploaderOpt) Uploader { + uploader := &directUploader{ + parallelism: 1, + options: make([]PutObjectOptionsEnhancer, 0), + } + for _, opt := range opts { + opt(uploader) + } + return uploader +} + +type directUploader struct { + client *minio.Client + wg sync.WaitGroup + sema chan struct{} + parallelism int + error atomic.Bool + options []PutObjectOptionsEnhancer +} + +func (d *directUploader) Start() (err error) { + d.client, err = env.ObjectStorageClient() + d.sema = make(chan struct{}, d.parallelism) + return err +} + +func (d *directUploader) buildOptions(path string, size int64) (options minio2.PutObjectOptions) { + for _, enhance := range d.options { + enhance(&options, path, size) + } + if options.ContentType == "" { + options.ContentType = "application/octet-stream" + } + return options +} + +func (d *directUploader) upload(path string, file io.ReadCloser, size int64) { + ns := env.ExecutionId() + opts := d.buildOptions(path, size) + err := d.client.SaveFileDirect(context.Background(), ns, path, file, size, opts) + + if err != nil { + d.error.Store(true) + ui.Errf("%s: failed: %s", path, err.Error()) + return + } +} + +func (d *directUploader) Add(path string, file io.ReadCloser, size int64) error { + d.wg.Add(1) + d.sema <- struct{}{} + go func() { + d.upload(path, file, size) + _ = file.Close() + d.wg.Done() + <-d.sema + }() + return nil +} + +func (d *directUploader) End() error { + d.wg.Wait() + if d.error.Load() { + return fmt.Errorf("upload failed") + } + return nil +} + +type DirectUploaderOpt = func(uploader *directUploader) + +func WithParallelism(parallelism int) DirectUploaderOpt { + return func(uploader *directUploader) { + if parallelism < 1 { + parallelism = 1 + } + uploader.parallelism = parallelism + } +} + +func WithMinioOptionsEnhancer(fn PutObjectOptionsEnhancer) DirectUploaderOpt { + return func(uploader *directUploader) { + uploader.options = append(uploader.options, fn) + } +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/handler.go b/cmd/tcl/testworkflow-toolkit/artifacts/handler.go new file mode 100644 index 00000000000..a1708ef5be9 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/handler.go @@ -0,0 +1,92 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "fmt" + "io/fs" + "sync/atomic" + + "github.com/dustin/go-humanize" + + "github.com/kubeshop/testkube/pkg/ui" +) + +type handler struct { + uploader Uploader + processor Processor + + success atomic.Uint32 + errors atomic.Uint32 + totalSize atomic.Uint64 +} + +type Handler interface { + Start() error + Add(path string, file fs.File, stat fs.FileInfo) error + End() error +} + +func NewHandler(uploader Uploader, processor Processor) Handler { + return &handler{ + uploader: uploader, + processor: processor, + } +} + +func (h *handler) Start() (err error) { + err = h.processor.Start() + if err != nil { + return err + } + return h.uploader.Start() +} + +func (h *handler) Add(path string, file fs.File, stat fs.FileInfo) (err error) { + size := uint64(stat.Size()) + h.totalSize.Add(size) + + fmt.Printf(ui.LightGray("%s (%s)\n"), path, humanize.Bytes(uint64(stat.Size()))) + + err = h.processor.Add(h.uploader, path, file, stat) + if err == nil { + h.success.Add(1) + } else { + h.errors.Add(1) + fmt.Printf(ui.Red("%s: failed: %s"), path, err.Error()) + } + return err +} + +func (h *handler) End() (err error) { + fmt.Printf("\n") + + err = h.processor.End() + if err != nil { + go h.uploader.End() + return err + } + err = h.uploader.End() + if err != nil { + return err + } + + errs := h.errors.Load() + success := h.success.Load() + totalSize := h.totalSize.Load() + if errs == 0 && success == 0 { + fmt.Printf("No artifacts found.\n") + } else { + fmt.Printf("Found and uploaded %s files (%s).\n", ui.LightCyan(success), ui.LightCyan(humanize.Bytes(totalSize))) + } + if errs > 0 { + return fmt.Errorf(" %d problems while uploading files", errs) + } + return nil +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/mimetype.go b/cmd/tcl/testworkflow-toolkit/artifacts/mimetype.go new file mode 100644 index 00000000000..15db78e55fd --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/mimetype.go @@ -0,0 +1,29 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "path/filepath" + + "github.com/h2non/filetype" +) + +func DetectMimetype(filePath string) string { + ext := filepath.Ext(filePath) + + // Remove the dot from the file extension + if len(ext) > 0 && ext[0] == '.' { + ext = ext[1:] + } + t := filetype.GetType(ext) + if t == filetype.Unknown { + return "" + } + return t.MIME.Value +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/processor.go b/cmd/tcl/testworkflow-toolkit/artifacts/processor.go new file mode 100644 index 00000000000..f4a284b7194 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/processor.go @@ -0,0 +1,17 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import "io/fs" + +type Processor interface { + Start() error + Add(uploader Uploader, path string, file fs.File, stat fs.FileInfo) error + End() error +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/tar_processor.go b/cmd/tcl/testworkflow-toolkit/artifacts/tar_processor.go new file mode 100644 index 00000000000..00c83d79e5f --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/tar_processor.go @@ -0,0 +1,78 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "fmt" + "io/fs" + "sync" + + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewTarProcessor(name string) Processor { + return &tarProcessor{ + name: name, + } +} + +type tarProcessor struct { + name string + mu *sync.Mutex + errCh chan error + ts *tarStream +} + +func (d *tarProcessor) Start() (err error) { + d.errCh = make(chan error) + d.mu = &sync.Mutex{} + + return err +} + +func (d *tarProcessor) init(uploader Uploader) { + if d.ts != nil { + return + } + d.ts = NewTarStream() + + // Start uploading the file + go func() { + err := uploader.Add(d.name, d.ts, -1) + if err != nil { + _ = d.ts.Close() + } + d.errCh <- err + }() +} + +func (d *tarProcessor) upload(path string, file fs.File, stat fs.FileInfo) error { + defer file.Close() + return d.ts.Add(path, file, stat) +} + +func (d *tarProcessor) Add(uploader Uploader, path string, file fs.File, stat fs.FileInfo) error { + d.mu.Lock() + d.init(uploader) + defer d.mu.Unlock() + return d.upload(path, file, stat) +} + +func (d *tarProcessor) End() (err error) { + if d.ts != nil { + <-d.ts.Done() + } + err = d.ts.Close() + if err != nil { + return fmt.Errorf("problem closing writer: %w", err) + } + + fmt.Printf("Archived everything in %s archive.\n", ui.LightCyan(d.name)) + return <-d.errCh +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/tar_stream.go b/cmd/tcl/testworkflow-toolkit/artifacts/tar_stream.go new file mode 100644 index 00000000000..b7c228d3b22 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/tar_stream.go @@ -0,0 +1,93 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "io/fs" + "sync" +) + +type tarStream struct { + reader io.ReadCloser + writer io.WriteCloser + gzip io.WriteCloser + tar *tar.Writer + mu *sync.Mutex + wg sync.WaitGroup +} + +func NewTarStream() *tarStream { + reader, writer := io.Pipe() + gzip := gzip.NewWriter(writer) + tar := tar.NewWriter(gzip) + return &tarStream{ + reader: reader, + writer: writer, + gzip: gzip, + tar: tar, + mu: &sync.Mutex{}, + } +} + +func (t *tarStream) Add(path string, file fs.File, stat fs.FileInfo) error { + t.wg.Add(1) + t.mu.Lock() + defer t.mu.Unlock() + defer t.wg.Done() + + // Write file header + name := stat.Name() + header, err := tar.FileInfoHeader(stat, name) + if err != nil { + return err + } + header.Name = path + err = t.tar.WriteHeader(header) + if err != nil { + return err + } + _, err = io.Copy(t.tar, file) + return err +} + +func (t *tarStream) Read(p []byte) (n int, err error) { + return t.reader.Read(p) +} + +func (t *tarStream) Done() chan struct{} { + ch := make(chan struct{}) + go func() { + t.wg.Wait() + close(ch) + }() + return ch +} + +func (t *tarStream) Close() (err error) { + err = t.tar.Close() + if err != nil { + _ = t.gzip.Close() + _ = t.writer.Close() + return fmt.Errorf("closing tar: tar: %v", err) + } + err = t.gzip.Close() + if err != nil { + _ = t.writer.Close() + return fmt.Errorf("closing tar: gzip: %v", err) + } + err = t.writer.Close() + if err != nil { + return fmt.Errorf("closing tar: pipe: %v", err) + } + return nil +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/tarcached_processor.go b/cmd/tcl/testworkflow-toolkit/artifacts/tarcached_processor.go new file mode 100644 index 00000000000..cc93e981230 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/tarcached_processor.go @@ -0,0 +1,111 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "fmt" + "io" + "io/fs" + "os" + "sync" + + "github.com/dustin/go-humanize" + + "github.com/kubeshop/testkube/pkg/tmp" + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewTarCachedProcessor(name string, cachePath string) Processor { + if cachePath == "" { + cachePath = tmp.Name() + } + return &tarCachedProcessor{ + name: name, + cachePath: cachePath, + } +} + +type tarCachedProcessor struct { + uploader Uploader + name string + cachePath string + mu *sync.Mutex + errCh chan error + file *os.File + ts *tarStream +} + +func (d *tarCachedProcessor) Start() (err error) { + d.errCh = make(chan error) + d.mu = &sync.Mutex{} + d.file, err = os.Create(d.cachePath) + + return err +} + +func (d *tarCachedProcessor) init(uploader Uploader) { + if d.ts != nil { + return + } + d.ts = NewTarStream() + d.uploader = uploader + go func() { + _, err := io.Copy(d.file, d.ts) + d.errCh <- err + }() +} + +func (d *tarCachedProcessor) clean() { + _ = os.Remove(d.cachePath) +} + +func (d *tarCachedProcessor) upload(path string, file fs.File, stat fs.FileInfo) error { + defer file.Close() + return d.ts.Add(path, file, stat) +} + +func (d *tarCachedProcessor) Add(uploader Uploader, path string, file fs.File, stat fs.FileInfo) error { + d.mu.Lock() + d.init(uploader) + defer d.mu.Unlock() + return d.upload(path, file, stat) +} + +func (d *tarCachedProcessor) End() (err error) { + defer d.clean() + + if d.ts != nil { + <-d.ts.Done() + } + err = d.ts.Close() + if err != nil { + return fmt.Errorf("problem closing writer: %w", err) + } + err = <-d.errCh + if err != nil { + return fmt.Errorf("problem writing to disk cache: %w", err) + } + + if d.uploader == nil { + return nil + } + + file, err := os.Open(d.cachePath) + if err != nil { + return fmt.Errorf("problem reading disk cache: %w", err) + } + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("problem reading disk cache: stat: %w", err) + } + + fmt.Printf("Archived everything in %s archive (%s).\n", ui.LightCyan(d.name), ui.LightCyan(humanize.Bytes(uint64(stat.Size())))) + return d.uploader.Add(d.name, file, stat.Size()) +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/uploader.go b/cmd/tcl/testworkflow-toolkit/artifacts/uploader.go new file mode 100644 index 00000000000..d164ed89509 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/uploader.go @@ -0,0 +1,19 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "io" +) + +type Uploader interface { + Start() error + Add(path string, file io.ReadCloser, size int64) error + End() error +} diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/walker.go b/cmd/tcl/testworkflow-toolkit/artifacts/walker.go new file mode 100644 index 00000000000..8310b085cf8 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/artifacts/walker.go @@ -0,0 +1,221 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package artifacts + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +func mapSlice[T any, U any](s []T, fn func(T) U) []U { + result := make([]U, len(s)) + for i := range s { + result[i] = fn(s[i]) + } + return result +} + +func deduplicateRoots(paths []string) []string { + result := make([]string, 0) +loop: + for _, path := range paths { + for _, path2 := range paths { + if strings.HasPrefix(path, path2+"/") { + continue loop + } + } + result = append(result, path) + } + return result +} + +func findSearchRoot(pattern string) string { + path, _ := doublestar.SplitPattern(pattern + "/") + return strings.TrimRight(path, "/") +} + +// TODO: Support wildcards better: +// - /**/*.json is a part of /data +// - /data/s*me/*a*/abc.json is a part of /data/some/path/ +func isPatternIn(pattern string, dirs []string) bool { + return isPathIn(findSearchRoot(pattern), dirs) +} + +func isPathIn(path string, dirs []string) bool { + for _, dir := range dirs { + path = strings.TrimRight(path, "/") + if dir == path || strings.HasPrefix(path, dir+"/") { + return true + } + } + return false +} + +func sanitizePath(path string) (string, error) { + path, err := filepath.Abs(path) + path = strings.TrimRight(filepath.ToSlash(path), "/") + if path == "" { + path = "/" + } + return path, err +} + +func sanitizePaths(input []string) ([]string, error) { + paths := make([]string, len(input)) + for i := range input { + var err error + paths[i], err = sanitizePath(input[i]) + if err != nil { + return nil, fmt.Errorf("error while resolving path: %s: %w", input[i], err) + } + } + return paths, nil +} + +func filterPatterns(patterns, dirs []string) []string { + result := make([]string, 0) + for _, p := range patterns { + if isPatternIn(p, dirs) { + result = append(result, p) + } + } + return result +} + +func detectCommonPath(path1, path2 string) string { + if path1 == path2 { + return path1 + } + common := 0 + parts1 := strings.Split(path1, "/") + parts2 := strings.Split(path2, "/") + for i := 0; i < len(parts1) && i < len(parts2); i++ { + if parts1[i] != parts2[i] { + break + } + common++ + } + if common == 1 && parts1[0] == "" { + return "/" + } + return strings.Join(parts1[0:common], "/") +} + +func detectRoot(potential string, paths []string) string { + potential = strings.TrimRight(potential, "/") + if potential == "" { + potential = "/" + } + for _, path := range paths { + potential = detectCommonPath(potential, path) + } + return potential +} + +func CreateWalker(patterns, roots []string, root string) (Walker, error) { + var err error + + // Build absolute paths + if patterns, err = sanitizePaths(patterns); err != nil { + return nil, err + } + if roots, err = sanitizePaths(roots); err != nil { + return nil, err + } + if root, err = sanitizePath(root); err != nil { + return nil, err + } + // Include only if it is matching some mounted volumes + patterns = filterPatterns(patterns, roots) + // Detect top-level paths for searching + searchPaths := deduplicateRoots(mapSlice(patterns, findSearchRoot)) + // Detect root path for the bucket + root = detectRoot(root, searchPaths) + + return &walker{ + root: root, + searchPaths: searchPaths, + patterns: patterns, + }, nil +} + +type walker struct { + root string + searchPaths []string + patterns []string // TODO: Optimize to check only patterns matching specific searchPaths +} + +type WalkerFn = func(path string, file fs.File, err error) error + +type Walker interface { + Root() string + SearchPaths() []string + Patterns() []string + Walk(fsys fs.FS, walker WalkerFn) error +} + +func (w *walker) Root() string { + return w.root +} + +func (w *walker) SearchPaths() []string { + return w.searchPaths +} + +func (w *walker) Patterns() []string { + return w.patterns +} + +func (w *walker) matches(filePath string) bool { + for _, p := range w.patterns { + v, _ := doublestar.PathMatch(p, filePath) + if v { + return true + } + } + return false +} + +func (w *walker) walk(fsys fs.FS, path string, walker WalkerFn) error { + sanitizedPath := strings.TrimLeft(path, "/") + if sanitizedPath == "" { + sanitizedPath = "." + } + + return fs.WalkDir(fsys, sanitizedPath, func(filePath string, d fs.DirEntry, err error) error { + resolvedPath := "/" + filepath.ToSlash(filePath) + if !w.matches(resolvedPath) { + return nil + } + if err != nil { + fmt.Printf("Warning: '%s' ignored from scraping: %v\n", resolvedPath, err) + return nil + } + if d.IsDir() { + return nil + } + + file, err := fsys.Open(filePath) + return walker(strings.TrimLeft(resolvedPath[len(w.root):], "/"), file, err) + }) +} + +func (w *walker) Walk(fsys fs.FS, walker WalkerFn) (err error) { + for _, s := range w.searchPaths { + err = w.walk(fsys, s, walker) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/tcl/testworkflow-toolkit/commands/artifacts.go b/cmd/tcl/testworkflow-toolkit/commands/artifacts.go new file mode 100644 index 00000000000..3dafc314129 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/commands/artifacts.go @@ -0,0 +1,177 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package commands + +import ( + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/minio/minio-go/v7" + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/artifacts" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" + "github.com/kubeshop/testkube/pkg/ui" +) + +var directAddGzipEncoding = artifacts.WithMinioOptionsEnhancer(func(options *minio.PutObjectOptions, path string, size int64) { + options.ContentType = "application/gzip" + options.ContentEncoding = "gzip" +}) + +var directDisableMultipart = artifacts.WithMinioOptionsEnhancer(func(options *minio.PutObjectOptions, path string, size int64) { + options.DisableMultipart = true +}) + +var directDetectMimetype = artifacts.WithMinioOptionsEnhancer(func(options *minio.PutObjectOptions, path string, size int64) { + if options.ContentType == "" { + options.ContentType = artifacts.DetectMimetype(path) + } +}) + +var directUnpack = artifacts.WithMinioOptionsEnhancer(func(options *minio.PutObjectOptions, path string, size int64) { + options.UserMetadata = map[string]string{ + "X-Amz-Meta-Snowball-Auto-Extract": "true", + "X-Amz-Meta-Minio-Snowball-Prefix": env.WorkflowName() + "/" + env.ExecutionId(), + } +}) + +var cloudAddGzipEncoding = artifacts.WithRequestEnhancerCloud(func(req *http.Request, path string, size int64) { + req.Header.Set("Content-Type", "application/gzip") + req.Header.Set("Content-Encoding", "gzip") +}) + +var cloudUnpack = artifacts.WithRequestEnhancerCloud(func(req *http.Request, path string, size int64) { + req.Header.Set("X-Amz-Meta-Snowball-Auto-Extract", "true") +}) + +var cloudDetectMimetype = artifacts.WithRequestEnhancerCloud(func(req *http.Request, path string, size int64) { + if req.Header.Get("Content-Type") == "" { + contentType := artifacts.DetectMimetype(path) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if contentType == "application/gzip" && req.Header.Get("Content-Encoding") == "" { + req.Header.Set("Content-Encoding", "gzip") + } + } +}) + +func NewArtifactsCmd() *cobra.Command { + var ( + mounts []string + id string + compress string + compressCachePath string + unpack bool + ) + + cmd := &cobra.Command{ + Use: "artifacts ", + Short: "Save workflow artifacts", + Args: cobra.MinimumNArgs(1), + + Run: func(cmd *cobra.Command, paths []string) { + root, _ := os.Getwd() + walker, err := artifacts.CreateWalker(paths, mounts, root) + ui.ExitOnError("building a walker", err) + + if len(walker.Patterns()) == 0 || len(walker.SearchPaths()) == 0 { + ui.Failf("error: did not found any valid path pattern in the mounted directories") + } + + fmt.Printf("Root: %s\nPatterns:\n", ui.LightCyan(walker.Root())) + for _, p := range walker.Patterns() { + fmt.Printf("- %s\n", ui.LightMagenta(p)) + } + fmt.Printf("\n") + + // Configure uploader + var processor artifacts.Processor + var uploader artifacts.Uploader + + // Sanitize archive name + compress = strings.Trim(filepath.ToSlash(filepath.Clean(compress)), "/.") + if compress != "" { + compressLower := strings.ToLower(compress) + if strings.HasSuffix(compressLower, ".tar") { + compress += ".gz" + } else if !strings.HasSuffix(compressLower, ".tgz") && !strings.HasSuffix(compressLower, ".tar.gz") { + compress += ".tar.gz" + } + } + + // Archive + if env.CloudEnabled() { + if compress != "" { + processor = artifacts.NewTarCachedProcessor(compress, compressCachePath) + opts := []artifacts.CloudUploaderOpt{cloudAddGzipEncoding} + if unpack { + opts = append(opts, cloudUnpack) + } + uploader = artifacts.NewCloudUploader(opts...) + } else { + processor = artifacts.NewDirectProcessor() + uploader = artifacts.NewCloudUploader(artifacts.WithParallelismCloud(30), cloudDetectMimetype) + } + } else if compress != "" && unpack { + processor = artifacts.NewTarCachedProcessor(compress, compressCachePath) + uploader = artifacts.NewDirectUploader(directAddGzipEncoding, directDisableMultipart, directUnpack) + } else if compress != "" && compressCachePath != "" { + processor = artifacts.NewTarCachedProcessor(compress, compressCachePath) + uploader = artifacts.NewDirectUploader(directAddGzipEncoding, directDisableMultipart) + } else if compress != "" { + processor = artifacts.NewTarProcessor(compress) + uploader = artifacts.NewDirectUploader(directAddGzipEncoding) + } else { + processor = artifacts.NewDirectProcessor() + uploader = artifacts.NewDirectUploader(artifacts.WithParallelism(30), directDetectMimetype) + } + + handler := artifacts.NewHandler(uploader, processor) + + err = handler.Start() + ui.ExitOnError("initializing uploader", err) + + started := time.Now() + err = walker.Walk(os.DirFS("/"), func(path string, file fs.File, err error) error { + if err != nil { + fmt.Printf("Warning: '%s' has been ignored, as there was a problem reading it: %s\n", path, err.Error()) + return nil + } + + stat, err := file.Stat() + if err != nil { + fmt.Printf("Warning: '%s' has been ignored, as there was a problem reading it: %s\n", path, err.Error()) + return nil + } + return handler.Add(path, file, stat) + }) + ui.ExitOnError("reading the file system", err) + err = handler.End() + + // TODO: Emit information about artifacts + ui.ExitOnError("finishing upload", err) + fmt.Printf("Took %s.\n", time.Now().Sub(started).Truncate(time.Millisecond)) + }, + } + + cmd.Flags().StringSliceVarP(&mounts, "mount", "m", nil, "mounted volumes for limiting paths") + cmd.Flags().StringVar(&id, "id", "", "execution ID") + cmd.Flags().StringVar(&compress, "compress", "", "tgz name if should be compressed") + cmd.Flags().BoolVar(&unpack, "unpack", false, "minio only: unpack the file if compressed") + cmd.Flags().StringVar(&compressCachePath, "compress-cache", "", "local cache path for passing compressed archive through") + + return cmd +} diff --git a/cmd/tcl/testworkflow-toolkit/commands/clone.go b/cmd/tcl/testworkflow-toolkit/commands/clone.go new file mode 100644 index 00000000000..08b9df2f064 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/commands/clone.go @@ -0,0 +1,103 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package commands + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/pkg/ui" +) + +func NewCloneCmd() *cobra.Command { + var ( + paths []string + username string + token string + authType string + revision string + ) + + cmd := &cobra.Command{ + Use: "clone ", + Short: "Clone the Git repository", + Args: cobra.ExactArgs(2), + + Run: func(cmd *cobra.Command, args []string) { + uri, err := url.Parse(args[0]) + ui.ExitOnError("repository uri", err) + outputPath, err := filepath.Abs(args[1]) + ui.ExitOnError("output path", err) + + // Disable interactivity + os.Setenv("GIT_TERMINAL_PROMPT", "0") + + authArgs := make([]string, 0) + + if authType == "header" { + ui.Debug("auth type: header") + if token != "" { + authArgs = append(authArgs, "-c", fmt.Sprintf("http.extraHeader='%s'", "Authorization: Bearer "+token)) + } + if username != "" { + uri.User = url.User(username) + } + } else { + ui.Debug("auth type: token") + if username != "" && token != "" { + uri.User = url.UserPassword(username, token) + } else if username != "" { + uri.User = url.User(username) + } else if token != "" { + uri.User = url.User(token) + } + } + + // Mark directory as safe + configArgs := []string{"-c", fmt.Sprintf("safe.directory=%s", outputPath), "-c", "advice.detachedHead=false"} + + // Clone repository + if len(paths) == 0 { + ui.Debug("full checkout") + err = Run("git", "clone", configArgs, authArgs, "--depth", 1, "--verbose", uri.String(), outputPath) + ui.ExitOnError("cloning repository", err) + } else { + ui.Debug("sparse checkout") + err = Run("git", "clone", configArgs, authArgs, "--filter=blob:none", "--no-checkout", "--sparse", "--depth", 1, "--verbose", uri.String(), outputPath) + ui.ExitOnError("cloning repository", err) + err = Run("git", "-C", outputPath, configArgs, "sparse-checkout", "set", "--no-cone", paths) + ui.ExitOnError("sparse checkout repository", err) + if revision != "" { + err = Run("git", "-C", outputPath, configArgs, "fetch", authArgs, "--depth", 1, "origin", revision) + ui.ExitOnError("fetching revision", err) + err = Run("git", "-C", outputPath, configArgs, "checkout", "FETCH_HEAD") + ui.ExitOnError("checking out head", err) + // TODO: Don't do it for commits + err = Run("git", "-C", outputPath, configArgs, "checkout", "-B", revision) + ui.ExitOnError("checking out the branch", err) + } else { + err = Run("git", "-C", outputPath, configArgs, "checkout") + ui.ExitOnError("fetching head", err) + } + } + }, + } + + cmd.Flags().StringSliceVarP(&paths, "paths", "p", nil, "paths for sparse checkout") + cmd.Flags().StringVarP(&username, "username", "u", "", "") + cmd.Flags().StringVarP(&token, "token", "t", "", "") + cmd.Flags().StringVarP(&authType, "authType", "a", "basic", "allowed: basic, header") + cmd.Flags().StringVarP(&revision, "revision", "r", "", "commit hash, branch name or tag") + + return cmd +} diff --git a/cmd/tcl/testworkflow-toolkit/commands/execute.go b/cmd/tcl/testworkflow-toolkit/commands/execute.go new file mode 100644 index 00000000000..b4d381f3ede --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/commands/execute.go @@ -0,0 +1,285 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" + "github.com/kubeshop/testkube/pkg/api/v1/client" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" + "github.com/kubeshop/testkube/pkg/ui" +) + +type testExecutionDetails struct { + Id string `json:"id"` + Name string `json:"name"` + TestName string `json:"testName"` +} + +type testWorkflowExecutionDetails struct { + Id string `json:"id"` + Name string `json:"name"` + TestWorkflowName string `json:"testWorkflowName"` +} + +type executionResult struct { + Id string `json:"id"` + Status string `json:"status"` +} + +func buildTestExecution(test string, async bool) (func() error, error) { + name, req, _ := strings.Cut(test, "=") + request := testkube.ExecutionRequest{} + if req != "" { + err := json.Unmarshal([]byte(req), &request) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal execution request: %s: %s", name, req)) + } + } + if request.ExecutionLabels == nil { + request.ExecutionLabels = map[string]string{} + } + request.ExecutionLabels[testworkflowprocessor.ExecutionIdLabelName] = env.ExecutionId() + + return func() (err error) { + c := env.Testkube() + + exec, err := c.ExecuteTest(name, request.Name, client.ExecuteTestOptions{ + IsVariablesFileUploaded: request.IsVariablesFileUploaded, + ExecutionLabels: request.ExecutionLabels, + Command: request.Command, + Args: request.Args, + ArgsMode: request.ArgsMode, + Envs: request.Envs, + SecretEnvs: request.SecretEnvs, + HTTPProxy: request.HttpProxy, + HTTPSProxy: request.HttpsProxy, + Image: request.Image, + Uploads: request.Uploads, + BucketName: request.BucketName, + ArtifactRequest: request.ArtifactRequest, + JobTemplate: request.JobTemplate, + JobTemplateReference: request.JobTemplateReference, + ContentRequest: request.ContentRequest, + PreRunScriptContent: request.PreRunScript, + PostRunScriptContent: request.PostRunScript, + ExecutePostRunScriptBeforeScraping: request.ExecutePostRunScriptBeforeScraping, + SourceScripts: request.SourceScripts, + ScraperTemplate: request.ScraperTemplate, + ScraperTemplateReference: request.ScraperTemplateReference, + PvcTemplate: request.PvcTemplate, + PvcTemplateReference: request.PvcTemplateReference, + NegativeTest: request.NegativeTest, + IsNegativeTestChangedOnRun: request.IsNegativeTestChangedOnRun, + EnvConfigMaps: request.EnvConfigMaps, + EnvSecrets: request.EnvSecrets, + RunningContext: request.RunningContext, + SlavePodRequest: request.SlavePodRequest, + ExecutionNamespace: request.ExecutionNamespace, + }) + execName := exec.Name + if err != nil { + ui.Errf("failed to execute test: %s: %s", name, err) + return + } + + data.PrintOutput(env.Ref(), "test-start", &testExecutionDetails{ + Id: exec.Id, + Name: exec.Name, + TestName: exec.TestName, + }) + fmt.Printf("%s • scheduled %s\n", ui.LightCyan(execName), ui.DarkGray("("+exec.Id+")")) + + if async { + return + } + + loop: + for { + time.Sleep(time.Second) + exec, err = c.GetExecution(exec.Id) + if err != nil { + ui.Errf("error while getting execution result: %s: %s", ui.LightCyan(execName), err.Error()) + return + } + if exec.ExecutionResult != nil && exec.ExecutionResult.Status != nil { + status := *exec.ExecutionResult.Status + switch status { + case testkube.QUEUED_ExecutionStatus, testkube.RUNNING_ExecutionStatus: + continue + default: + break loop + } + } + } + + status := *exec.ExecutionResult.Status + color := ui.Green + + if status != testkube.PASSED_ExecutionStatus { + err = errors.New("test failed") + color = ui.Red + } + + data.PrintOutput(env.Ref(), "test-end", &executionResult{Id: exec.Id, Status: string(status)}) + fmt.Printf("%s • %s\n", color(execName), string(status)) + return + }, nil +} + +func buildWorkflowExecution(workflow string, async bool) (func() error, error) { + name, req, _ := strings.Cut(workflow, "=") + request := testkube.TestWorkflowExecutionRequest{} + if req != "" { + err := json.Unmarshal([]byte(req), &request) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal execution request: %s: %s", name, req)) + } + } + + return func() (err error) { + c := env.Testkube() + + exec, err := c.ExecuteTestWorkflow(name, request) + execName := exec.Name + if err != nil { + ui.Errf("failed to execute test workflow: %s: %s", name, err.Error()) + return + } + + data.PrintOutput(env.Ref(), "testworkflow-start", &testWorkflowExecutionDetails{ + Id: exec.Id, + Name: exec.Name, + TestWorkflowName: exec.Workflow.Name, + }) + fmt.Printf("%s • scheduled %s\n", ui.LightCyan(execName), ui.DarkGray("("+exec.Id+")")) + + if async { + return + } + + loop: + for { + time.Sleep(100 * time.Millisecond) + exec, err = c.GetTestWorkflowExecution(exec.Id) + if err != nil { + ui.Errf("error while getting execution result: %s: %s", ui.LightCyan(execName), err.Error()) + return + } + if exec.Result != nil && exec.Result.Status != nil { + status := *exec.Result.Status + switch status { + case testkube.QUEUED_TestWorkflowStatus, testkube.RUNNING_TestWorkflowStatus: + continue + default: + break loop + } + } + } + + status := *exec.Result.Status + color := ui.Green + + if status != testkube.PASSED_TestWorkflowStatus { + err = errors.New("test workflow failed") + color = ui.Red + } + + data.PrintOutput(env.Ref(), "testworkflow-end", &executionResult{Id: exec.Id, Status: string(status)}) + fmt.Printf("%s • %s\n", color(execName), string(status)) + return + }, nil +} + +func NewExecuteCmd() *cobra.Command { + var ( + tests []string + workflows []string + parallelism int + async bool + ) + + cmd := &cobra.Command{ + Use: "execute", + Short: "Execute other resources", + Args: cobra.ExactArgs(0), + + Run: func(cmd *cobra.Command, _ []string) { + // Calculate parallelism + if parallelism <= 0 { + parallelism = 20 + } + + // Build operations to run + operations := make([]func() error, 0) + for _, t := range tests { + fn, err := buildTestExecution(t, async) + if err != nil { + ui.Fail(err) + } + operations = append(operations, fn) + } + for _, w := range workflows { + fn, err := buildWorkflowExecution(w, async) + if err != nil { + ui.Fail(err) + } + operations = append(operations, fn) + } + + // Validate if there is anything to run + if len(operations) == 0 { + fmt.Printf("nothing to run\n") + os.Exit(0) + } + + // Create channel for execution + var wg sync.WaitGroup + wg.Add(len(operations)) + ch := make(chan struct{}, parallelism) + success := true + + // Execute all operations + for _, op := range operations { + ch <- struct{}{} + go func(op func() error) { + if op() != nil { + success = false + } + <-ch + wg.Done() + }(op) + } + wg.Wait() + + if !success { + os.Exit(1) + } + }, + } + + // TODO: Support test suites too + cmd.Flags().StringArrayVarP(&tests, "test", "t", nil, "tests to run; either test name, or test-name=json-execution-request") + cmd.Flags().StringArrayVarP(&workflows, "workflow", "w", nil, "workflows to run; either workflow name, or workflow-name=json-execution-request") + cmd.Flags().IntVarP(¶llelism, "parallelism", "p", 0, "how many items could be executed at once") + cmd.Flags().BoolVar(&async, "async", false, "should it wait for results") + + return cmd +} diff --git a/cmd/tcl/testworkflow-toolkit/commands/root.go b/cmd/tcl/testworkflow-toolkit/commands/root.go new file mode 100644 index 00000000000..52036cc4994 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/commands/root.go @@ -0,0 +1,71 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package commands + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" + + "golang.org/x/sync/errgroup" +) + +func init() { + RootCmd.AddCommand(NewCloneCmd()) + RootCmd.AddCommand(NewExecuteCmd()) + RootCmd.AddCommand(NewArtifactsCmd()) +} + +var RootCmd = &cobra.Command{ + Use: "testworkflow-toolkit", + Short: "Orchestrating Testkube workflows", + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + DisableNoDescFlag: true, + DisableDescriptions: true, + HiddenDefaultCmd: true, + }, +} + +func Execute() { + // Run services within an errgroup to propagate errors between services. + g, ctx := errgroup.WithContext(context.Background()) + + // Cancel the errgroup context on SIGINT and SIGTERM, + // which shuts everything down gracefully. + // Kill on recurring signal. + stopSignal := make(chan os.Signal, 1) + signal.Notify(stopSignal, syscall.SIGINT, syscall.SIGTERM) + g.Go(func() error { + select { + case <-ctx.Done(): + return nil + case sig := <-stopSignal: + go func() { + <-stopSignal + os.Exit(137) + }() + return errors.Errorf("received signal: %v", sig) + } + }) + + RootCmd.PersistentFlags().BoolVar(&env.UseProxyValue, "proxy", false, "use Kubernetes proxy for TK access") + + if err := RootCmd.ExecuteContext(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/tcl/testworkflow-toolkit/commands/utils.go b/cmd/tcl/testworkflow-toolkit/commands/utils.go new file mode 100644 index 00000000000..50dfd4c9060 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/commands/utils.go @@ -0,0 +1,43 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package commands + +import ( + "os" + "os/exec" + "strconv" +) + +func concat(args ...interface{}) []string { + result := make([]string, 0) + for _, a := range args { + switch a.(type) { + case string: + result = append(result, a.(string)) + case int: + result = append(result, strconv.Itoa(a.(int))) + case []string: + result = append(result, a.([]string)...) + case []interface{}: + result = append(result, concat(a.([]interface{})...)...) + } + } + return result +} + +func Comm(cmd string, args ...interface{}) *exec.Cmd { + return exec.Command(cmd, concat(args...)...) +} + +func Run(c string, args ...interface{}) error { + sub := Comm(c, args...) + sub.Stdout = os.Stdout + sub.Stderr = os.Stderr + return sub.Run() +} diff --git a/cmd/tcl/testworkflow-toolkit/env/client.go b/cmd/tcl/testworkflow-toolkit/env/client.go new file mode 100644 index 00000000000..5051de74908 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/env/client.go @@ -0,0 +1,74 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package env + +import ( + "context" + "fmt" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" + "github.com/kubeshop/testkube/pkg/agent" + "github.com/kubeshop/testkube/pkg/api/v1/client" + "github.com/kubeshop/testkube/pkg/cloud" + cloudexecutor "github.com/kubeshop/testkube/pkg/cloud/data/executor" + phttp "github.com/kubeshop/testkube/pkg/http" + "github.com/kubeshop/testkube/pkg/k8sclient" + "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/storage/minio" + "github.com/kubeshop/testkube/pkg/ui" +) + +func KubernetesConfig() *rest.Config { + c, err := rest.InClusterConfig() + if err != nil { + var fsErr error + c, fsErr = k8sclient.GetK8sClientConfig() + if fsErr != nil { + ui.Fail(fmt.Errorf("couldn't find Kubernetes config: %w and %w", err, fsErr)) + } + } + return c +} + +func Kubernetes() *kubernetes.Clientset { + c, err := kubernetes.NewForConfig(KubernetesConfig()) + if err != nil { + ui.Fail(fmt.Errorf("couldn't instantiate Kubernetes client: %w", err)) + } + return c +} + +func Testkube() client.Client { + if UseProxy() { + return client.NewProxyAPIClient(Kubernetes(), client.NewAPIConfig(Namespace(), config.APIServerName, config.APIServerPort)) + } + httpClient := phttp.NewClient(true) + sseClient := phttp.NewSSEClient(true) + return client.NewDirectAPIClient(httpClient, sseClient, fmt.Sprintf("http://%s:%d", config.APIServerName, config.APIServerPort), "") +} + +func ObjectStorageClient() (*minio.Client, error) { + cfg := Config().ObjectStorage + opts := minio.GetTLSOptions(cfg.Ssl, cfg.SkipVerify, cfg.CertFile, cfg.KeyFile, cfg.CAFile) + c := minio.NewClient(cfg.Endpoint, cfg.AccessKeyID, cfg.SecretAccessKey, cfg.Region, cfg.Token, cfg.Bucket, opts...) + return c, c.Connect() +} + +func Cloud(ctx context.Context) cloudexecutor.Executor { + cfg := Config().Cloud + grpcConn, err := agent.NewGRPCConnection(ctx, cfg.TlsInsecure, cfg.SkipVerify, cfg.Url, "", "", "", log.DefaultLogger) + if err != nil { + ui.Fail(fmt.Errorf("failed to connect with Cloud: %w", err)) + } + grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) + return cloudexecutor.NewCloudGRPCExecutor(grpcClient, grpcConn, cfg.ApiKey) +} diff --git a/cmd/tcl/testworkflow-toolkit/env/config.go b/cmd/tcl/testworkflow-toolkit/env/config.go new file mode 100644 index 00000000000..3356f24ff34 --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/env/config.go @@ -0,0 +1,104 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package env + +import ( + "github.com/kelseyhightower/envconfig" + + "github.com/kubeshop/testkube/pkg/ui" +) + +var ( + UseProxyValue = false +) + +type envObjectStorageConfig struct { + Endpoint string `envconfig:"TK_OS_ENDPOINT"` + AccessKeyID string `envconfig:"TK_OS_ACCESSKEY"` + SecretAccessKey string `envconfig:"TK_OS_SECRETKEY"` + Region string `envconfig:"TK_OS_REGION"` + Token string `envconfig:"TK_OS_TOKEN"` + Bucket string `envconfig:"TK_OS_BUCKET"` + Ssl bool `envconfig:"TK_OS_SSL" default:"false"` + SkipVerify bool `envconfig:"TK_OS_SSL_SKIP_VERIFY" default:"false"` + CertFile string `envconfig:"TK_OS_CERT_FILE"` + KeyFile string `envconfig:"TK_OS_KEY_FILE"` + CAFile string `envconfig:"TK_OS_CA_FILE"` +} + +type envCloudConfig struct { + Url string `envconfig:"TK_C_URL"` + ApiKey string `envconfig:"TK_C_KEY"` + SkipVerify bool `envconfig:"TK_C_SKIP_VERIFY" default:"false"` + TlsInsecure bool `envconfig:"TK_C_TLS_INSECURE" default:"false"` +} + +type envExecutionConfig struct { + WorkflowName string `envconfig:"TK_WF"` + Id string `envconfig:"TK_EX"` +} + +type envSystemConfig struct { + Debug string `envconfig:"DEBUG"` + Ref string `envconfig:"TK_REF"` + Namespace string `envconfig:"TK_NS"` +} + +type envConfig struct { + System envSystemConfig + ObjectStorage envObjectStorageConfig + Cloud envCloudConfig + Execution envExecutionConfig +} + +var cfg envConfig +var cfgLoaded = false + +func Config() *envConfig { + if !cfgLoaded { + err := envconfig.Process("", &cfg.System) + ui.ExitOnError("configuring environment", err) + err = envconfig.Process("", &cfg.ObjectStorage) + ui.ExitOnError("configuring environment", err) + err = envconfig.Process("", &cfg.Cloud) + ui.ExitOnError("configuring environment", err) + err = envconfig.Process("", &cfg.Execution) + ui.ExitOnError("configuring environment", err) + } + cfgLoaded = true + return &cfg +} + +func Debug() bool { + return Config().System.Debug == "1" +} + +func CloudEnabled() bool { + return Config().Cloud.ApiKey != "" +} + +func UseProxy() bool { + return UseProxyValue +} + +func Ref() string { + return Config().System.Ref +} + +func Namespace() string { + return Config().System.Namespace +} + +func WorkflowName() string { + return Config().Execution.WorkflowName +} + +func ExecutionId() string { + return Config().Execution.Id +} diff --git a/cmd/tcl/testworkflow-toolkit/main.go b/cmd/tcl/testworkflow-toolkit/main.go new file mode 100644 index 00000000000..b75648b389b --- /dev/null +++ b/cmd/tcl/testworkflow-toolkit/main.go @@ -0,0 +1,29 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package main + +import ( + "errors" + + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/commands" + "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" + "github.com/kubeshop/testkube/pkg/ui" +) + +func main() { + // Set verbosity + ui.SetVerbose(env.Debug()) + + // Validate provided data + if env.Namespace() == "" || env.Ref() == "" { + ui.Fail(errors.New("environment is misconfigured")) + } + + commands.Execute() +} diff --git a/go.mod b/go.mod index bf2eac8249a..55110cfc0fb 100644 --- a/go.mod +++ b/go.mod @@ -68,6 +68,7 @@ require ( github.com/alecthomas/chroma v0.10.0 // indirect github.com/aymanbagabas/go-osc52 v1.2.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/briandowns/spinner v1.19.0 // indirect github.com/charmbracelet/glamour v0.6.0 // indirect @@ -91,6 +92,7 @@ require ( github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.1 // indirect github.com/henvic/httpretty v0.1.0 // indirect github.com/itchyny/gojq v0.12.14 // indirect diff --git a/go.sum b/go.sum index 34c322faf85..9f39a31bd84 100644 --- a/go.sum +++ b/go.sum @@ -87,6 +87,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= @@ -298,6 +300,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A 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/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= diff --git a/goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml b/goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml new file mode 100644 index 00000000000..cec195644d7 --- /dev/null +++ b/goreleaser_files/.goreleaser-docker-build-testworkflow-toolkit.yml @@ -0,0 +1,93 @@ +project_name: testkube-tw-toolkit + +env: + # Goreleaser always uses the docker buildx builder with name "default"; see + # https://github.com/goreleaser/goreleaser/pull/3199 + # To use a builder other than "default", set this variable. + # Necessary for, e.g., GitHub actions cache integration. + - DOCKER_REPO={{ if index .Env "DOCKER_REPO" }}{{ .Env.DOCKER_REPO }}{{ else }}kubeshop{{ end }} + - DOCKER_BUILDX_BUILDER={{ if index .Env "DOCKER_BUILDX_BUILDER" }}{{ .Env.DOCKER_BUILDX_BUILDER }}{{ else }}default{{ end }} + # Setup to enable Docker to use, e.g., the GitHub actions cache; see + # https://docs.docker.com/build/building/cache/backends/ + # https://github.com/moby/buildkit#export-cache + - DOCKER_BUILDX_CACHE_FROM={{ if index .Env "DOCKER_BUILDX_CACHE_FROM" }}{{ .Env.DOCKER_BUILDX_CACHE_FROM }}{{ else }}type=registry{{ end }} + - DOCKER_BUILDX_CACHE_TO={{ if index .Env "DOCKER_BUILDX_CACHE_TO" }}{{ .Env.DOCKER_BUILDX_CACHE_TO }}{{ else }}type=inline{{ end }} + # Build image with commit sha tag + - IMAGE_TAG_SHA={{ if index .Env "IMAGE_TAG_SHA" }}{{ .Env.IMAGE_TAG_SHA }}{{ else }}{{ end }} +builds: + - id: "linux" + main: ./cmd/tcl/testworkflow-toolkit + binary: testworkflow-toolkit + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" + ldflags: + -X github.com/kubeshop/testkube/pkg/version.Version={{ .Version }} + -X github.com/kubeshop/testkube/pkg/version.Commit={{ .FullCommit }} + -s -w +dockers: + - dockerfile: ./build/testworkflow-toolkit/Dockerfile + use: buildx + goos: linux + goarch: amd64 + image_templates: + - "{{ if .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .ShortCommit }}{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}-amd64{{ end }}" + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.created={{ .Date}}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--builder={{ .Env.DOCKER_BUILDX_BUILDER }}" + - "--cache-to={{ .Env.DOCKER_BUILDX_CACHE_TO }}" + - "--cache-from={{ .Env.DOCKER_BUILDX_CACHE_FROM }}" + - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" + + - dockerfile: ./build/testworkflow-toolkit/Dockerfile + use: buildx + goos: linux + goarch: arm64 + image_templates: + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}-arm64v8{{ end }}" + build_flag_templates: + - "--platform=linux/arm64/v8" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--builder={{ .Env.DOCKER_BUILDX_BUILDER }}" + - "--cache-to={{ .Env.DOCKER_BUILDX_CACHE_TO }}" + - "--cache-from={{ .Env.DOCKER_BUILDX_CACHE_FROM }}" + - "--build-arg=ALPINE_IMAGE={{ .Env.ALPINE_IMAGE }}" + +docker_manifests: + - name_template: "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}{{ end }}" + image_templates: + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}-arm64v8{{ end }}" + - name_template: "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:latest{{ end }}" + image_templates: + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}-amd64{{ end }}" + - "{{ if not .Env.IMAGE_TAG_SHA }}{{ .Env.DOCKER_REPO }}/testkube-tw-toolkit:{{ .Version }}-arm64v8{{ end }}" + + +release: + disable: true + +docker_signs: + - cmd: cosign + artifacts: all + output: true + args: + - "sign" + - "${artifact}" + - "--yes" + +snapshot: + name_template: "{{ .ShortCommit }}" diff --git a/internal/app/api/v1/executions.go b/internal/app/api/v1/executions.go index d7c226bf4cf..0d01a3cb3d2 100644 --- a/internal/app/api/v1/executions.go +++ b/internal/app/api/v1/executions.go @@ -441,7 +441,7 @@ func (s *TestkubeAPI) GetArtifactHandler() fiber.Handler { var file io.Reader var bucket string - artifactsStorage := s.artifactsStorage + artifactsStorage := s.ArtifactsStorage folder := execution.Id if execution.ArtifactRequest != nil { bucket = execution.ArtifactRequest.StorageBucket @@ -457,7 +457,7 @@ func (s *TestkubeAPI) GetArtifactHandler() fiber.Handler { } } - file, err = artifactsStorage.DownloadFile(c.Context(), fileName, folder, execution.TestName, execution.TestSuiteName) + file, err = artifactsStorage.DownloadFile(c.Context(), fileName, folder, execution.TestName, execution.TestSuiteName, "") if err != nil { return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: could not download file: %w", errPrefix, err)) } @@ -489,7 +489,7 @@ func (s *TestkubeAPI) GetArtifactArchiveHandler() fiber.Handler { var archive io.Reader var bucket string - artifactsStorage := s.artifactsStorage + artifactsStorage := s.ArtifactsStorage folder := execution.Id if execution.ArtifactRequest != nil { bucket = execution.ArtifactRequest.StorageBucket @@ -532,7 +532,7 @@ func (s *TestkubeAPI) ListArtifactsHandler() fiber.Handler { var files []testkube.Artifact var bucket string - artifactsStorage := s.artifactsStorage + artifactsStorage := s.ArtifactsStorage folder := execution.Id if execution.ArtifactRequest != nil { bucket = execution.ArtifactRequest.StorageBucket @@ -548,7 +548,7 @@ func (s *TestkubeAPI) ListArtifactsHandler() fiber.Handler { } } - files, err = artifactsStorage.ListFiles(c.Context(), folder, execution.TestName, execution.TestSuiteName) + files, err = artifactsStorage.ListFiles(c.Context(), folder, execution.TestName, execution.TestSuiteName, "") if err != nil { return s.Error(c, http.StatusInternalServerError, fmt.Errorf("%s: storage client could not list files %w", errPrefix, err)) } @@ -724,7 +724,7 @@ func (s *TestkubeAPI) getExecutorByTestType(testType string) (client.Executor, e func (s *TestkubeAPI) getArtifactStorage(bucket string) (storage.ArtifactsStorage, error) { if s.mode == common.ModeAgent { - return s.artifactsStorage, nil + return s.ArtifactsStorage, nil } opts := minio.GetTLSOptions(s.storageParams.SSL, s.storageParams.SkipVerify, s.storageParams.CertFile, s.storageParams.KeyFile, s.storageParams.CAFile) diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 9b54d9b3882..e0ffd8ad77c 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -133,7 +133,7 @@ func NewTestkubeAPI( slackLoader: slackLoader, Storage: storage, graphqlPort: graphqlPort, - artifactsStorage: artifactsStorage, + ArtifactsStorage: artifactsStorage, TemplatesClient: templatesClient, dashboardURI: dashboardURI, helmchartVersion: helmchartVersion, @@ -194,7 +194,7 @@ type TestkubeAPI struct { Clientset kubernetes.Interface slackLoader *slack.SlackLoader graphqlPort string - artifactsStorage storage.ArtifactsStorage + ArtifactsStorage storage.ArtifactsStorage TemplatesClient *templatesclientv1.TemplatesClient dashboardURI string helmchartVersion string diff --git a/internal/app/api/v1/testsuites.go b/internal/app/api/v1/testsuites.go index 2747b2c34c7..7f8b2525134 100644 --- a/internal/app/api/v1/testsuites.go +++ b/internal/app/api/v1/testsuites.go @@ -698,7 +698,7 @@ func (s TestkubeAPI) ListTestSuiteArtifactsHandler() fiber.Handler { var stepArtifacts []testkube.Artifact var bucket string - artifactsStorage := s.artifactsStorage + artifactsStorage := s.ArtifactsStorage folder := stepResult.Execution.Id if stepResult.Execution.ArtifactRequest != nil { bucket = stepResult.Execution.ArtifactRequest.StorageBucket @@ -715,7 +715,7 @@ func (s TestkubeAPI) ListTestSuiteArtifactsHandler() fiber.Handler { } } - stepArtifacts, err = artifactsStorage.ListFiles(c.Context(), folder, stepResult.Execution.TestName, stepResult.Execution.TestSuiteName) + stepArtifacts, err = artifactsStorage.ListFiles(c.Context(), folder, stepResult.Execution.TestName, stepResult.Execution.TestSuiteName, "") if err != nil { s.Log.Warnw("can't list artifacts", "executionID", stepResult.Execution.Id, "error", err) continue diff --git a/internal/app/api/v1/uploads.go b/internal/app/api/v1/uploads.go index d0226c85460..6508fbca088 100644 --- a/internal/app/api/v1/uploads.go +++ b/internal/app/api/v1/uploads.go @@ -24,7 +24,7 @@ func (s TestkubeAPI) UploadFiles() fiber.Handler { return s.Error(c, fiber.StatusBadRequest, fmt.Errorf("%s: wrong input: filePath cannot be empty", errPrefix)) } - bucketName := s.artifactsStorage.GetValidBucketName(parentType, parentName) + bucketName := s.ArtifactsStorage.GetValidBucketName(parentType, parentName) file, err := c.FormFile("attachment") if err != nil { return s.Error(c, fiber.StatusBadRequest, fmt.Errorf("%s: unable to upload file: %w", errPrefix, err)) @@ -35,7 +35,7 @@ func (s TestkubeAPI) UploadFiles() fiber.Handler { } defer f.Close() - err = s.artifactsStorage.UploadFile(c.Context(), bucketName, filePath, f, file.Size) + err = s.ArtifactsStorage.UploadFile(c.Context(), bucketName, filePath, f, file.Size) if err != nil { return s.Error(c, fiber.StatusInternalServerError, fmt.Errorf("%s: could not save uploaded file: %w", errPrefix, err)) } diff --git a/internal/app/api/v1/uploads_test.go b/internal/app/api/v1/uploads_test.go index aa93947c4b0..6fad514363a 100644 --- a/internal/app/api/v1/uploads_test.go +++ b/internal/app/api/v1/uploads_test.go @@ -34,7 +34,7 @@ func TestTestkubeAPI_UploadCopyFiles(t *testing.T) { Mux: app, Log: log.DefaultLogger, }, - artifactsStorage: mockArtifactsStorage, + ArtifactsStorage: mockArtifactsStorage, } route := "/uploads" diff --git a/internal/common/common.go b/internal/common/common.go index 75c627ef693..a27568f5bea 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -95,3 +95,12 @@ func GetMapValue[T any, K comparable](m map[K]T, k K, def T) T { } return def } + +func GetOr(v ...string) string { + for i := range v { + if v[i] != "" { + return v[i] + } + } + return "" +} diff --git a/pkg/api/v1/client/api.go b/pkg/api/v1/client/api.go index f378d783314..bcd0c27a11e 100644 --- a/pkg/api/v1/client/api.go +++ b/pkg/api/v1/client/api.go @@ -43,6 +43,7 @@ func NewProxyAPIClient(client kubernetes.Interface, config APIConfig) APIClient NewProxyClient[testkube.TestWorkflowWithExecution](client, config), NewProxyClient[testkube.TestWorkflowExecution](client, config), NewProxyClient[testkube.TestWorkflowExecutionsResult](client, config), + NewProxyClient[testkube.Artifact](client, config), ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewProxyClient[testkube.TestWorkflowTemplate](client, config)), } @@ -80,6 +81,7 @@ func NewDirectAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, NewDirectClient[testkube.TestWorkflowWithExecution](httpClient, apiURI, apiPathPrefix), NewDirectClient[testkube.TestWorkflowExecution](httpClient, apiURI, apiPathPrefix), NewDirectClient[testkube.TestWorkflowExecutionsResult](httpClient, apiURI, apiPathPrefix), + NewDirectClient[testkube.Artifact](httpClient, apiURI, apiPathPrefix), ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewDirectClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } @@ -117,6 +119,7 @@ func NewCloudAPIClient(httpClient *http.Client, sseClient *http.Client, apiURI, NewCloudClient[testkube.TestWorkflowWithExecution](httpClient, apiURI, apiPathPrefix), NewCloudClient[testkube.TestWorkflowExecution](httpClient, apiURI, apiPathPrefix), NewCloudClient[testkube.TestWorkflowExecutionsResult](httpClient, apiURI, apiPathPrefix), + NewCloudClient[testkube.Artifact](httpClient, apiURI, apiPathPrefix), ), TestWorkflowTemplateClient: NewTestWorkflowTemplateClient(NewCloudClient[testkube.TestWorkflowTemplate](httpClient, apiURI, apiPathPrefix)), } diff --git a/pkg/api/v1/client/interface.go b/pkg/api/v1/client/interface.go index 36545399bc1..f5111ea7414 100644 --- a/pkg/api/v1/client/interface.go +++ b/pkg/api/v1/client/interface.go @@ -149,6 +149,9 @@ type TestWorkflowExecutionAPI interface { ListTestWorkflowExecutions(id string, limit int, selector string) (executions testkube.TestWorkflowExecutionsResult, err error) AbortTestWorkflowExecution(workflow string, id string) error AbortTestWorkflowExecutions(workflow string) error + GetTestWorkflowExecutionArtifacts(executionID string) (artifacts testkube.Artifacts, err error) + DownloadTestWorkflowArtifact(executionID, fileName, destination string) (artifact string, err error) + DownloadTestWorkflowArtifactArchive(executionID, destination string, masks []string) (archive string, err error) } // TestWorkflowTemplateAPI describes test workflow api methods diff --git a/pkg/api/v1/client/testworkflow.go b/pkg/api/v1/client/testworkflow.go index a02cb58b34f..dc89783b738 100644 --- a/pkg/api/v1/client/testworkflow.go +++ b/pkg/api/v1/client/testworkflow.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) @@ -14,12 +15,14 @@ func NewTestWorkflowClient( testWorkflowWithExecutionTransport Transport[testkube.TestWorkflowWithExecution], testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution], testWorkflowExecutionsResultTransport Transport[testkube.TestWorkflowExecutionsResult], + artifactTransport Transport[testkube.Artifact], ) TestWorkflowClient { return TestWorkflowClient{ testWorkflowTransport: testWorkflowTransport, testWorkflowWithExecutionTransport: testWorkflowWithExecutionTransport, testWorkflowExecutionTransport: testWorkflowExecutionTransport, testWorkflowExecutionsResultTransport: testWorkflowExecutionsResultTransport, + artifactTransport: artifactTransport, } } @@ -29,6 +32,7 @@ type TestWorkflowClient struct { testWorkflowWithExecutionTransport Transport[testkube.TestWorkflowWithExecution] testWorkflowExecutionTransport Transport[testkube.TestWorkflowExecution] testWorkflowExecutionsResultTransport Transport[testkube.TestWorkflowExecutionsResult] + artifactTransport Transport[testkube.Artifact] } // GetTestWorkflow returns single test workflow by id @@ -155,3 +159,21 @@ func (c TestWorkflowClient) AbortTestWorkflowExecutions(workflow string) error { uri := c.testWorkflowTransport.GetURI("/test-workflows/%s/abort", workflow) return c.testWorkflowTransport.ExecuteMethod(http.MethodPost, uri, "", false) } + +// GetTestWorkflowExecutionArtifacts returns execution artifacts +func (c TestWorkflowClient) GetTestWorkflowExecutionArtifacts(executionID string) (artifacts testkube.Artifacts, err error) { + uri := c.artifactTransport.GetURI("/test-workflow-executions/%s/artifacts", executionID) + return c.artifactTransport.ExecuteMultiple(http.MethodGet, uri, nil, nil) +} + +// DownloadTestWorkflowArtifact downloads file +func (c TestWorkflowClient) DownloadTestWorkflowArtifact(executionID, fileName, destination string) (artifact string, err error) { + uri := c.testWorkflowExecutionTransport.GetURI("/test-workflow-executions/%s/artifacts/%s", executionID, url.QueryEscape(fileName)) + return c.testWorkflowExecutionTransport.GetFile(uri, fileName, destination, nil) +} + +// DownloadTestWorkflowArtifactArchive downloads archive +func (c TestWorkflowClient) DownloadTestWorkflowArtifactArchive(executionID, destination string, masks []string) (archive string, err error) { + uri := c.testWorkflowExecutionTransport.GetURI("/test-workflow-executions/%s/artifact-archive", executionID) + return c.testWorkflowExecutionTransport.GetFile(uri, fmt.Sprintf("%s.tar.gz", executionID), destination, map[string][]string{"mask": masks}) +} diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index aa4a44509bf..0e36eef1dfc 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -11,7 +11,7 @@ func (r *TestWorkflowResult) IsFinished() bool { } func (r *TestWorkflowResult) IsStatus(s TestWorkflowStatus) bool { - if r.Status == nil { + if r == nil || r.Status == nil { return s == QUEUED_TestWorkflowStatus } return *r.Status == s diff --git a/pkg/cloud/data/artifact/artifacts_storage.go b/pkg/cloud/data/artifact/artifacts_storage.go index d29f94dacf3..a6410a86eb3 100644 --- a/pkg/cloud/data/artifact/artifacts_storage.go +++ b/pkg/cloud/data/artifact/artifacts_storage.go @@ -1,7 +1,7 @@ package artifact import ( - context "context" + "context" "encoding/json" "io" "net/http" @@ -25,11 +25,12 @@ func NewCloudArtifactsStorage(cloudClient cloud.TestKubeCloudAPIClient, grpcConn return &CloudArtifactsStorage{executor: executor.NewCloudGRPCExecutor(cloudClient, grpcConn, apiKey)} } -func (c *CloudArtifactsStorage) ListFiles(ctx context.Context, executionID, testName, testSuiteName string) ([]testkube.Artifact, error) { +func (c *CloudArtifactsStorage) ListFiles(ctx context.Context, executionID, testName, testSuiteName, testWorkflowName string) ([]testkube.Artifact, error) { req := ListFilesRequest{ - ExecutionID: executionID, - TestName: testName, - TestSuiteName: testSuiteName, + ExecutionID: executionID, + TestName: testName, + TestSuiteName: testSuiteName, + TestWorkflowName: testWorkflowName, } response, err := c.executor.Execute(ctx, CmdArtifactsListFiles, req) if err != nil { @@ -43,12 +44,13 @@ func (c *CloudArtifactsStorage) ListFiles(ctx context.Context, executionID, test return commandResponse.Artifacts, nil } -func (c *CloudArtifactsStorage) DownloadFile(ctx context.Context, file, executionID, testName, testSuiteName string) (io.Reader, error) { +func (c *CloudArtifactsStorage) DownloadFile(ctx context.Context, file, executionID, testName, testSuiteName, testWorkflowName string) (io.Reader, error) { req := DownloadFileRequest{ - File: file, - ExecutionID: executionID, - TestName: testName, - TestSuiteName: testSuiteName, + File: file, + ExecutionID: executionID, + TestName: testName, + TestSuiteName: testSuiteName, + TestWorkflowName: testWorkflowName, } response, err := c.executor.Execute(ctx, CmdArtifactsDownloadFile, req) if err != nil { diff --git a/pkg/cloud/data/artifact/artifacts_storage_models.go b/pkg/cloud/data/artifact/artifacts_storage_models.go index 36a9b4cff8f..54ca1661a65 100644 --- a/pkg/cloud/data/artifact/artifacts_storage_models.go +++ b/pkg/cloud/data/artifact/artifacts_storage_models.go @@ -3,9 +3,10 @@ package artifact import "github.com/kubeshop/testkube/pkg/api/v1/testkube" type ListFilesRequest struct { - ExecutionID string - TestName string - TestSuiteName string + ExecutionID string + TestName string + TestSuiteName string + TestWorkflowName string } type ListFilesResponse struct { @@ -13,10 +14,11 @@ type ListFilesResponse struct { } type DownloadFileRequest struct { - File string - ExecutionID string - TestName string - TestSuiteName string + File string + ExecutionID string + TestName string + TestSuiteName string + TestWorkflowName string } type DownloadFileResponse struct { diff --git a/pkg/cloud/data/artifact/scraper_model.go b/pkg/cloud/data/artifact/scraper_model.go index 537624c0254..22bd7ef2d3e 100644 --- a/pkg/cloud/data/artifact/scraper_model.go +++ b/pkg/cloud/data/artifact/scraper_model.go @@ -7,10 +7,11 @@ const ( ) type PutObjectSignedURLRequest struct { - Object string `json:"object"` - ExecutionID string `json:"executionId"` - TestName string `json:"testName"` - TestSuiteName string `json:"testSuiteName"` + Object string `json:"object"` + ExecutionID string `json:"executionId"` + TestName string `json:"testName"` + TestSuiteName string `json:"testSuiteName"` + TestWorkflowName string `json:"testWorkflowName"` } type PutObjectSignedURLResponse struct { diff --git a/pkg/storage/artifacts.go b/pkg/storage/artifacts.go index 3e2fe176c6b..9ce0815dc43 100644 --- a/pkg/storage/artifacts.go +++ b/pkg/storage/artifacts.go @@ -10,9 +10,9 @@ import ( //go:generate mockgen -destination=./artifacts_mock.go -package=storage "github.com/kubeshop/testkube/pkg/storage" ArtifactsStorage type ArtifactsStorage interface { // ListFiles lists available files in the configured bucket - ListFiles(ctx context.Context, executionId, testName, testSuiteName string) ([]testkube.Artifact, error) + ListFiles(ctx context.Context, executionId, testName, testSuiteName, testWorkflowName string) ([]testkube.Artifact, error) // DownloadFile downloads file from configured - DownloadFile(ctx context.Context, file, executionId, testName, testSuiteName string) (io.Reader, error) + DownloadFile(ctx context.Context, file, executionId, testName, testSuiteName, testWorkflowName string) (io.Reader, error) // DownloadArchive downloads archive from configured DownloadArchive(ctx context.Context, executionId string, masks []string) (io.Reader, error) // UploadFile uploads file to configured bucket diff --git a/pkg/storage/artifacts_mock.go b/pkg/storage/artifacts_mock.go index 0417b7e72c3..19db9eef45f 100644 --- a/pkg/storage/artifacts_mock.go +++ b/pkg/storage/artifacts_mock.go @@ -52,18 +52,18 @@ func (mr *MockArtifactsStorageMockRecorder) DownloadArchive(arg0, arg1, arg2 int } // DownloadFile mocks base method. -func (m *MockArtifactsStorage) DownloadFile(arg0 context.Context, arg1, arg2, arg3, arg4 string) (io.Reader, error) { +func (m *MockArtifactsStorage) DownloadFile(arg0 context.Context, arg1, arg2, arg3, arg4, arg5 string) (io.Reader, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DownloadFile", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "DownloadFile", arg0, arg1, arg2, arg3, arg4, arg5) ret0, _ := ret[0].(io.Reader) ret1, _ := ret[1].(error) return ret0, ret1 } // DownloadFile indicates an expected call of DownloadFile. -func (mr *MockArtifactsStorageMockRecorder) DownloadFile(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { +func (mr *MockArtifactsStorageMockRecorder) DownloadFile(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadFile", reflect.TypeOf((*MockArtifactsStorage)(nil).DownloadFile), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadFile", reflect.TypeOf((*MockArtifactsStorage)(nil).DownloadFile), arg0, arg1, arg2, arg3, arg4, arg5) } // GetValidBucketName mocks base method. @@ -81,18 +81,18 @@ func (mr *MockArtifactsStorageMockRecorder) GetValidBucketName(arg0, arg1 interf } // ListFiles mocks base method. -func (m *MockArtifactsStorage) ListFiles(arg0 context.Context, arg1, arg2, arg3 string) ([]testkube.Artifact, error) { +func (m *MockArtifactsStorage) ListFiles(arg0 context.Context, arg1, arg2, arg3, arg4 string) ([]testkube.Artifact, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListFiles", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "ListFiles", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].([]testkube.Artifact) ret1, _ := ret[1].(error) return ret0, ret1 } // ListFiles indicates an expected call of ListFiles. -func (mr *MockArtifactsStorageMockRecorder) ListFiles(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockArtifactsStorageMockRecorder) ListFiles(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFiles", reflect.TypeOf((*MockArtifactsStorage)(nil).ListFiles), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFiles", reflect.TypeOf((*MockArtifactsStorage)(nil).ListFiles), arg0, arg1, arg2, arg3, arg4) } // PlaceFiles mocks base method. diff --git a/pkg/storage/minio/artifacts_storage.go b/pkg/storage/minio/artifacts_storage.go index cd96ec4822b..36b3ec99cff 100644 --- a/pkg/storage/minio/artifacts_storage.go +++ b/pkg/storage/minio/artifacts_storage.go @@ -18,12 +18,12 @@ func NewMinIOArtifactClient(client storage.Client) *ArtifactClient { } // ListFiles lists available files in the bucket from the config -func (c *ArtifactClient) ListFiles(ctx context.Context, executionId, testName, testSuiteName string) ([]testkube.Artifact, error) { +func (c *ArtifactClient) ListFiles(ctx context.Context, executionId, testName, testSuiteName, testWorkflowName string) ([]testkube.Artifact, error) { return c.client.ListFiles(ctx, executionId) } // DownloadFile downloads file from bucket from the config -func (c *ArtifactClient) DownloadFile(ctx context.Context, file, executionId, testName, testSuiteName string) (io.Reader, error) { +func (c *ArtifactClient) DownloadFile(ctx context.Context, file, executionId, testName, testSuiteName, testWorkflowName string) (io.Reader, error) { return c.client.DownloadFile(ctx, executionId, file) } diff --git a/pkg/storage/minio/artifacts_storage_integration_test.go b/pkg/storage/minio/artifacts_storage_integration_test.go index f14f880fda1..85712de8b64 100644 --- a/pkg/storage/minio/artifacts_storage_integration_test.go +++ b/pkg/storage/minio/artifacts_storage_integration_test.go @@ -46,7 +46,7 @@ func TestArtifactClient(t *testing.T) { t.Fatalf("unable to upload file: %v", err) } // Call ListFiles - files, err := artifactClient.ListFiles(ctx, "test-execution-id-1", "", "") + files, err := artifactClient.ListFiles(ctx, "test-execution-id-1", "", "", "") assert.NoError(t, err) assert.Lenf(t, files, 1, "expected 1 file to be returned") @@ -63,7 +63,7 @@ func TestArtifactClient(t *testing.T) { t.Fatalf("unable to upload file: %v", err) } - reader, err := artifactClient.DownloadFile(ctx, "test-file", "test-execution-id-2", "", "") + reader, err := artifactClient.DownloadFile(ctx, "test-file", "test-execution-id-2", "", "", "") if err != nil { t.Fatalf("unable to download file: %v", err) } diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index f5c640d2bee..a9c88528b62 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -34,6 +34,7 @@ type apiTCL struct { TestWorkflowsClient testworkflowsv1.Interface TestWorkflowTemplatesClient testworkflowsv1.TestWorkflowTemplatesInterface TestWorkflowExecutor testworkflowexecutor.TestWorkflowExecutor + ApiUrl string } type ApiTCL interface { @@ -47,6 +48,7 @@ func NewApiTCL( imageInspector imageinspector.Inspector, testWorkflowResults testworkflow.Repository, testWorkflowOutput testworkflow.OutputRepository, + apiUrl string, ) ApiTCL { executor := testworkflowexecutor.New(testkubeAPI.Events, testkubeAPI.Clientset, testWorkflowResults, testWorkflowOutput, testkubeAPI.Namespace) go executor.Recover(context.Background()) @@ -59,6 +61,7 @@ func NewApiTCL( TestWorkflowsClient: testworkflowsv1.NewClient(kubeClient, testkubeAPI.Namespace), TestWorkflowTemplatesClient: testworkflowsv1.NewTestWorkflowTemplatesClient(kubeClient, testkubeAPI.Namespace), TestWorkflowExecutor: executor, + ApiUrl: apiUrl, } } @@ -114,6 +117,9 @@ func (s *apiTCL) AppendRoutes() { testWorkflowExecutions.Get("/:executionID/notifications/stream", s.pro(s.StreamTestWorkflowExecutionNotificationsWebSocketHandler())) testWorkflowExecutions.Post("/:executionID/abort", s.pro(s.AbortTestWorkflowExecutionHandler())) testWorkflowExecutions.Get("/:executionID/logs", s.pro(s.GetTestWorkflowExecutionLogsHandler())) + testWorkflowExecutions.Get("/:executionID/artifacts", s.pro(s.ListTestWorkflowExecutionArtifactsHandler())) + testWorkflowExecutions.Get("/:executionID/artifacts/:filename", s.pro(s.GetTestWorkflowArtifactHandler())) + testWorkflowExecutions.Get("/:executionID/artifact-archive", s.pro(s.GetTestWorkflowArtifactArchiveHandler())) testWorkflowWithExecutions := root.Group("/test-workflow-with-executions") testWorkflowWithExecutions.Get("/", s.pro(s.ListTestWorkflowWithExecutionsHandler())) diff --git a/pkg/tcl/apitcl/v1/testworkflowexecutions.go b/pkg/tcl/apitcl/v1/testworkflowexecutions.go index a47b9bcb7fd..a3a7515911f 100644 --- a/pkg/tcl/apitcl/v1/testworkflowexecutions.go +++ b/pkg/tcl/apitcl/v1/testworkflowexecutions.go @@ -16,6 +16,7 @@ import ( "io" "math" "net/http" + "net/url" "strconv" "github.com/gofiber/fiber/v2" @@ -281,6 +282,82 @@ func (s *apiTCL) AbortAllTestWorkflowExecutionsHandler() fiber.Handler { } } +func (s *apiTCL) ListTestWorkflowExecutionArtifactsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + executionID := c.Params("executionID") + errPrefix := fmt.Sprintf("failed to list artifacts for test workflow execution %s", executionID) + + execution, err := s.TestWorkflowResults.Get(c.Context(), executionID) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + files, err := s.ArtifactsStorage.ListFiles(c.Context(), execution.Id, "", "", execution.Workflow.Name) + if err != nil { + return s.InternalError(c, errPrefix, "storage client could not list test workflow files", err) + } + + return c.JSON(files) + } +} + +func (s *apiTCL) GetTestWorkflowArtifactHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + executionID := c.Params("executionID") + fileName := c.Params("filename") + errPrefix := fmt.Sprintf("failed to get artifact %s for workflow execution %s", fileName, executionID) + + // TODO fix this someday :) we don't know 15 mins before release why it's working this way + // remember about CLI client and Dashboard client too! + unescaped, err := url.QueryUnescape(fileName) + if err == nil { + fileName = unescaped + } + unescaped, err = url.QueryUnescape(fileName) + if err == nil { + fileName = unescaped + } + //// quickfix end + + execution, err := s.TestWorkflowResults.Get(c.Context(), executionID) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + file, err := s.ArtifactsStorage.DownloadFile(c.Context(), fileName, execution.Id, "", "", execution.Workflow.Name) + if err != nil { + return s.InternalError(c, errPrefix, "could not download file", err) + } + + return c.SendStream(file) + } +} + +func (s *apiTCL) GetTestWorkflowArtifactArchiveHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + executionID := c.Params("executionID") + query := c.Request().URI().QueryString() + errPrefix := fmt.Sprintf("failed to get artifact archive for test workflow execution %s", executionID) + + values, err := url.ParseQuery(string(query)) + if err != nil { + return s.BadRequest(c, errPrefix, "could not parse query string", err) + } + + execution, err := s.TestWorkflowResults.Get(c.Context(), executionID) + if err != nil { + return s.ClientError(c, errPrefix, err) + } + + archive, err := s.ArtifactsStorage.DownloadArchive(c.Context(), execution.Id, values["mask"]) + if err != nil { + return s.InternalError(c, errPrefix, "could not download workflow artifact archive", err) + } + + return c.SendStream(archive) + } +} + func getWorkflowExecutionsFilterFromRequest(c *fiber.Ctx) testworkflow.Filter { filter := testworkflow.NewExecutionsFilter() name := c.Params("id", "") diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index f2cb770a180..f35704dcfcd 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -12,6 +12,8 @@ import ( "context" "fmt" "net/http" + "os" + "strconv" "strings" "time" @@ -306,7 +308,35 @@ func (s *apiTCL) ExecuteTestWorkflowHandler() fiber.Handler { id := primitive.NewObjectID().Hex() now := time.Now() machine := expressionstcl.NewMachine(). - Register("execution.id", id) + RegisterStringMap("internal", map[string]string{ + "storage.url": os.Getenv("STORAGE_ENDPOINT"), + "storage.accessKey": os.Getenv("STORAGE_ACCESSKEYID"), + "storage.secretKey": os.Getenv("STORAGE_SECRETACCESSKEY"), + "storage.region": os.Getenv("STORAGE_REGION"), + "storage.bucket": os.Getenv("STORAGE_BUCKET"), + "storage.token": os.Getenv("STORAGE_TOKEN"), + "storage.ssl": common.GetOr(os.Getenv("STORAGE_SSL"), "false"), + "storage.skipVerify": common.GetOr(os.Getenv("STORAGE_SKIP_VERIFY"), "false"), + "storage.certFile": os.Getenv("STORAGE_CERT_FILE"), + "storage.keyFile": os.Getenv("STORAGE_KEY_FILE"), + "storage.caFile": os.Getenv("STORAGE_CA_FILE"), + + "cloud.enabled": strconv.FormatBool(os.Getenv("TESTKUBE_PRO_API_KEY") != "" || os.Getenv("TESTKUBE_CLOUD_API_KEY") != ""), + "cloud.api.key": common.GetOr(os.Getenv("TESTKUBE_PRO_API_KEY"), os.Getenv("TESTKUBE_CLOUD_API_KEY")), + "cloud.api.tlsInsecure": common.GetOr(os.Getenv("TESTKUBE_PRO_TLS_INSECURE"), os.Getenv("TESTKUBE_CLOUD_TLS_INSECURE"), "false"), + "cloud.api.skipVerify": common.GetOr(os.Getenv("TESTKUBE_PRO_SKIP_VERIFY"), os.Getenv("TESTKUBE_CLOUD_SKIP_VERIFY"), "false"), + "cloud.api.url": common.GetOr(os.Getenv("TESTKUBE_PRO_URL"), os.Getenv("TESTKUBE_CLOUD_URL")), + + "dashboard.url": os.Getenv("TESTKUBE_DASHBOARD_URI"), + "api.url": s.ApiUrl, + "namespace": s.Namespace, + }). + RegisterStringMap("workflow", map[string]string{ + "name": workflow.Name, + }). + RegisterStringMap("execution", map[string]string{ + "id": id, + }) // Preserve resolved TestWorkflow resolvedWorkflow := workflow.DeepCopy() diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go index 74ffecf8509..7455b5c519e 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go @@ -416,6 +416,9 @@ func watchEvents(clientSet kubernetes.Interface, namespace string, options ListO if !ok { return } + if event.Object == nil { + continue + } switch event.Type { case watch.Added, watch.Modified: w.SendValue(event.Object.(*corev1.Event)) diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go index d1522e80501..039da56be40 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go @@ -36,6 +36,7 @@ var ( var ( defaultInitImage = getInitImage() + defaultToolkitImage = getToolkitImage() defaultContainerConfig = testworkflowsv1.ContainerConfig{ Image: defaultImage, Env: []corev1.EnvVar{ @@ -55,3 +56,15 @@ func getInitImage() string { } return img } + +func getToolkitImage() string { + img := os.Getenv("TESTKUBE_TW_TOOLKIT_IMAGE") + if img == "" { + version := common.Version + if version == "" || version == "dev" { + version = "latest" + } + img = fmt.Sprintf("kubeshop/testkube-tw-toolkit:%s", version) + } + return img +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go index e41041b10f2..098c0fc863b 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go @@ -72,6 +72,7 @@ type ContainerMutations[T any] interface { ApplyCR(cr *testworkflowsv1.ContainerConfig) T ApplyImageData(image *imageinspector.Info) error + EnableToolkit(ref string) T } //go:generate mockgen -destination=./mock_container.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Container @@ -382,9 +383,12 @@ func (c *container) ApplyImageData(image *imageinspector.Info) error { return err } if len(c.Command()) == 0 { + args := c.Args() c.SetCommand(image.Entrypoint...) - if len(c.Args()) == 0 { + if len(args) == 0 { c.SetArgs(image.Cmd...) + } else { + c.SetArgs(args...) } } if image.WorkingDir != "" && c.WorkingDir() == "" { @@ -393,6 +397,30 @@ func (c *container) ApplyImageData(image *imageinspector.Info) error { return nil } +func (c *container) EnableToolkit(ref string) Container { + return c.AppendEnvMap(map[string]string{ + "TK_REF": ref, + "TK_NS": "{{internal.namespace}}", + "TK_WF": "{{workflow.name}}", + "TK_EX": "{{execution.id}}", + "TK_C_URL": "{{internal.cloud.api.url}}", + "TK_C_KEY": "{{internal.cloud.api.key}}", + "TK_C_TLS_INSECURE": "{{internal.cloud.api.tlsInsecure}}", + "TK_C_SKIP_VERIFY": "{{internal.cloud.api.skipVerify}}", + "TK_OS_ENDPOINT": "{{internal.storage.url}}", + "TK_OS_ACCESSKEY": "{{internal.storage.accessKey}}", + "TK_OS_SECRETKEY": "{{internal.storage.secretKey}}", + "TK_OS_REGION": "{{internal.storage.region}}", + "TK_OS_TOKEN": "{{internal.storage.token}}", + "TK_OS_BUCKET": "{{internal.storage.bucket}}", + "TK_OS_SSL": "{{internal.storage.ssl}}", + "TK_OS_SSL_SKIP_VERIFY": "{{internal.storage.skipVerify}}", + "TK_OS_CERT_FILE": "{{internal.storage.certFile}}", + "TK_OS_KEY_FILE": "{{internal.storage.keyFile}}", + "TK_OS_CA_FILE": "{{internal.storage.caFile}}", + }) +} + func (c *container) Resolve(m ...expressionstcl.Machine) error { base := expressionstcl.NewMachine(). RegisterAccessor(func(name string) (interface{}, bool) { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go index 3c44219775d..c03b7406915 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -9,13 +9,18 @@ package testworkflowprocessor import ( + "encoding/json" "fmt" + "path/filepath" + "strconv" + "strings" "time" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" + "github.com/kubeshop/testkube/pkg/tcl/mapperstcl/testworkflows" ) func ProcessDelay(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { @@ -66,6 +71,58 @@ func ProcessNestedSteps(p InternalProcessor, layer Intermediate, container Conta return group, nil } +func ProcessExecute(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Execute == nil { + return nil, nil + } + container = container.CreateChild() + stage := NewContainerStage(layer.NextRef(), container) + hasWorkflows := len(step.Execute.Workflows) > 0 + hasTests := len(step.Execute.Tests) > 0 + + // Fail if there is nothing to run + if !hasTests && !hasWorkflows { + return nil, errors.New("no test workflows and tests provided to the 'execute' step") + } + + container. + SetImage(defaultToolkitImage). + SetImagePullPolicy(corev1.PullIfNotPresent). + SetCommand("/toolkit", "execute"). + EnableToolkit(stage.Ref()) + args := make([]string, 0) + for _, t := range step.Execute.Tests { + args = append(args, "-t", t.Name) + } + for _, w := range step.Execute.Workflows { + if len(w.Config) == 0 { + args = append(args, "-w", w.Name) + } else { + v, _ := json.Marshal(testworkflows.MapConfigValueKubeToAPI(w.Config)) + args = append(args, "-w", fmt.Sprintf(`%s={"config":%s}`, w.Name, v)) + } + } + if step.Execute.Async { + args = append(args, "--async") + } + if step.Execute.Parallelism > 0 { + args = append(args, "-p", strconv.Itoa(int(step.Execute.Parallelism))) + } + container.SetArgs(args...) + + // Add default label + types := make([]string, 0) + if hasWorkflows { + types = append(types, "test workflows") + } + if hasTests { + types = append(types, "tests") + } + stage.SetCategory("Execute " + strings.Join(types, " & ")) + + return stage, nil +} + func ProcessContentFiles(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { if step.Content == nil { return nil, nil @@ -135,3 +192,99 @@ func ProcessContentFiles(_ InternalProcessor, layer Intermediate, container Cont } return nil, nil } + +func ProcessContentGit(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Content == nil || step.Content.Git == nil { + return nil, nil + } + + selfContainer := container.CreateChild() + stage := NewContainerStage(layer.NextRef(), selfContainer) + stage.SetCategory("Clone Git repository") + + // Compute mount path + mountPath := step.Content.Git.MountPath + if mountPath == "" { + mountPath = filepath.Join(defaultDataPath, "repo") + } + + // Build volume pair and share with all siblings + volumeMount := layer.AddEmptyDirVolume(nil, mountPath) + container.AppendVolumeMounts(volumeMount) + + selfContainer. + SetWorkingDir("/"). + SetImage(defaultToolkitImage). + SetImagePullPolicy(corev1.PullIfNotPresent). + SetCommand("/toolkit", "clone", step.Content.Git.Uri). + EnableToolkit(stage.Ref()) + + args := []string{mountPath} + + // Provide Git username + if step.Content.Git.UsernameFrom != nil { + container.AppendEnv(corev1.EnvVar{Name: "TK_GIT_USERNAME", ValueFrom: step.Content.Git.UsernameFrom}) + args = append(args, "-u", "{{env.TK_GIT_USERNAME}}") + } else if step.Content.Git.Username != "" { + args = append(args, "-u", step.Content.Git.Username) + } + + // Provide Git token + if step.Content.Git.TokenFrom != nil { + container.AppendEnv(corev1.EnvVar{Name: "TK_GIT_TOKEN", ValueFrom: step.Content.Git.TokenFrom}) + args = append(args, "-t", "{{env.TK_GIT_TOKEN}}") + } else if step.Content.Git.Token != "" { + args = append(args, "-t", step.Content.Git.Token) + } + + // Provide auth type + if step.Content.Git.AuthType != "" { + args = append(args, "-a", string(step.Content.Git.AuthType)) + } + + // Provide revision + if step.Content.Git.Revision != "" { + args = append(args, "-r", step.Content.Git.Revision) + } + + // Provide sparse paths + if len(step.Content.Git.Paths) > 0 { + for _, pattern := range step.Content.Git.Paths { + args = append(args, "-p", pattern) + } + } + + selfContainer.SetArgs(args...) + + return stage, nil +} + +func ProcessArtifacts(_ InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + if step.Artifacts == nil { + return nil, nil + } + + if len(step.Artifacts.Paths) == 0 { + return nil, errors.New("there needs to be at least one path to scrap for artifacts") + } + + selfContainer := container.CreateChild() + stage := NewContainerStage(layer.NextRef(), selfContainer) + stage.SetCondition("always") + stage.SetCategory("Upload artifacts") + + selfContainer. + SetImage(defaultToolkitImage). + SetImagePullPolicy(corev1.PullIfNotPresent). + SetCommand("/toolkit", "artifacts", "-m", defaultDataPath). + EnableToolkit(stage.Ref()) + + args := make([]string, 0) + if step.Artifacts.Compress != nil { + args = append(args, "--compress", step.Artifacts.Compress.Name) + } + args = append(args, step.Artifacts.Paths...) + selfContainer.SetArgs(args...) + + return stage, nil +} diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go index 8f2119b2a98..35494abd46c 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -52,9 +52,12 @@ func NewFullFeatured(inspector imageinspector.Inspector) Processor { return New(inspector). Register(ProcessDelay). Register(ProcessContentFiles). + Register(ProcessContentGit). Register(ProcessRunCommand). Register(ProcessShellCommand). - Register(ProcessNestedSteps) + Register(ProcessExecute). + Register(ProcessNestedSteps). + Register(ProcessArtifacts) } func (p *processor) Register(operation Operation) Processor { @@ -64,6 +67,9 @@ func (p *processor) Register(operation Operation) Processor { func (p *processor) process(layer Intermediate, container Container, step testworkflowsv1.Step, ref string) (Stage, error) { // Configure defaults + if step.WorkingDir != nil { + container.SetWorkingDir(*step.WorkingDir) + } container.ApplyCR(step.Container) // Build an initial group for the inner items From 4d2f8d392ce99829aa75a4448e974a45b8a17225 Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Fri, 8 Mar 2024 14:44:18 +0100 Subject: [PATCH 180/234] feat: Workflow tests - run script (#5074) * Workflow tests - run script * workflow tests - run script - duplicated cypress workflow removed --- .../executor-tests/crd-workflow/smoke.yaml | 2 +- test/scripts/executor-tests/run.sh | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/test/postman/executor-tests/crd-workflow/smoke.yaml b/test/postman/executor-tests/crd-workflow/smoke.yaml index 3815199ab30..5ee6d6f07d0 100644 --- a/test/postman/executor-tests/crd-workflow/smoke.yaml +++ b/test/postman/executor-tests/crd-workflow/smoke.yaml @@ -101,4 +101,4 @@ spec: trait: name: pre-official/postman config: - params: "ppostman-executor-smoke-without-envs.postman_collection.json" + params: "postman-executor-smoke-without-envs.postman_collection.json" diff --git a/test/scripts/executor-tests/run.sh b/test/scripts/executor-tests/run.sh index 1b3c7bc046c..989e41aa23c 100755 --- a/test/scripts/executor-tests/run.sh +++ b/test/scripts/executor-tests/run.sh @@ -111,6 +111,31 @@ common_run() { # name, test_crd_file, testsuite_name, testsuite_file, custom_exe fi } +common_workflow_run() { # name, workflow_crd_file, custom_workflow_template_crd_file + name=$1 + workflow_crd_file=$2 + custom_workflow_template_crd_file=$3 + + print_title "$name" + + if [ "$delete" = true ] ; then + if [ ! -z "$custom_executor_crd_file" ] ; then + kubectl --namespace $namespace delete -f $custom_workflow_template_crd_file --ignore-not-found=true + fi + kubectl --namespace $namespace delete -f $workflow_crd_file --ignore-not-found=true + fi + + if [ "$create" = true ] ; then + if [ ! -z "$custom_workflow_template_crd_file" ] ; then + # Workflow Template + kubectl --namespace $namespace apply -f $custom_workflow_template_crd_file + fi + + # Workflow + kubectl --namespace $namespace apply -f $workflow_crd_file + fi +} + artillery-smoke() { name="artillery" test_crd_file="test/artillery/executor-smoke/crd/crd.yaml" @@ -382,6 +407,64 @@ special-cases-jmeter() { common_run "$name" "$test_crd_file" "$testsuite_name" "$testsuite_file" } +workflow-cypress-smoke() { + name="Test Workflow - Cypress" + workflow_crd_file="test/cypress/executor-tests/crd-workflow/smoke.yaml" + custom_workflow_template_crd_file="test/test-workflow-templates/cypress.yaml" + + common_workflow_run "$name" "$workflow_crd_file" "$custom_workflow_template_crd_file" +} + +workflow-gradle-smoke() { + name="Test Workflow - Gradle" + workflow_crd_file="test/gradle/executor-smoke/crd-workflow/smoke.yaml" + + common_workflow_run "$name" "$workflow_crd_file" +} + +workflow-jmeter-smoke() { + name="Test Workflow - JMeter" + workflow_crd_file="test/jmeter/executor-tests/crd-workflow/smoke.yaml" + + common_workflow_run "$name" "$workflow_crd_file" +} + +workflow-k6-smoke() { + name="Test Workflow - k6" + workflow_crd_file="test/k6/executor-tests/crd-workflow/smoke.yaml" + custom_workflow_template_crd_file="test/test-workflow-templates/k6.yaml" + + common_workflow_run "$name" "$workflow_crd_file" "$custom_workflow_template_crd_file" +} + +workflow-maven-smoke() { + name="Test Workflow - Maven" + workflow_crd_file="test/maven/executor-smoke/crd-workflow/smoke.yaml" + + common_workflow_run "$name" "$workflow_crd_file" +} + +workflow-playwright-smoke() { + name="Test Workflow - Playwright" + workflow_crd_file="test/playwright/executor-tests/crd-workflow/smoke.yaml" + + common_workflow_run "$name" "$workflow_crd_file" +} + +workflow-postman-smoke() { + name="Test Workflow - Postman" + workflow_crd_file="test/postman/executor-tests/crd-workflow/smoke.yaml" + + common_workflow_run "$name" "$workflow_crd_file" +} + +workflow-soapui-smoke() { + name="Test Workflow - SoapUI" + workflow_crd_file="test/soapui/executor-smoke/crd-workflow/smoke.yaml" + + common_workflow_run "$name" "$workflow_crd_file" +} + main() { case $executor_type in all) @@ -438,6 +521,16 @@ main() { special-cases-large-artifacts special-cases-jmeter ;; + workflow) + workflow-cypress-smoke + workflow-gradle-smoke + workflow-jmeter-smoke + workflow-k6-smoke + workflow-maven-smoke + workflow-playwright-smoke + workflow-postman-smoke + workflow-soapui-smoke + ;; *) $executor_type ;; From 4e275af3f276b3da0a4634677e3100511b32d600 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Fri, 8 Mar 2024 14:54:52 +0100 Subject: [PATCH 181/234] fix: direct client uri fix (#5125) --- pkg/api/v1/client/uploads.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/api/v1/client/uploads.go b/pkg/api/v1/client/uploads.go index dabd6396924..b54893620cc 100644 --- a/pkg/api/v1/client/uploads.go +++ b/pkg/api/v1/client/uploads.go @@ -9,6 +9,7 @@ import ( "mime/multipart" "net/http" "path/filepath" + "strings" "time" "k8s.io/client-go/kubernetes" @@ -81,7 +82,7 @@ func (c CopyFileDirectClient) UploadFile(parentName string, parentType TestingTy } func (c CopyFileDirectClient) getUri() string { - return c.apiPathPrefix + uri + return strings.Join([]string{c.apiPathPrefix, c.apiURI, "/", Version, uri}, "") } // UploadFile uploads a copy file to the API server From 49ce9930c3aab5b4507bfd6cb48149c56d5c516a Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 8 Mar 2024 15:25:14 +0100 Subject: [PATCH 182/234] fix(TKC-1642): update TestWorkflow Toolkit to use libssl3 (#5124) * fix(TKC-1642): update Toolkit to use libssl3, as libssl1.1 is not available in newer alpine * fixup add missing "aborted" status for test workflow * fix: use map[string]interface{} for TestWorkflow output object * fixup warn --- Makefile | 1 + api/v1/testkube.yaml | 1 + build/testworkflow-toolkit/Dockerfile | 2 +- pkg/api/v1/testkube/model_test_workflow_output.go | 2 +- .../model_test_workflow_status_extended.go | 1 + .../testworkflowcontroller/logs.go | 14 +++++++++++--- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index cfe0b9c041a..492edf02f94 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,7 @@ openapi-generate-model-testkube: rm -rf tmp find ./pkg/api/v1/testkube -type f -exec sed -i '' -e "s/package swagger/package testkube/g" {} \; find ./pkg/api/v1/testkube -type f -exec sed -i '' -e "s/\*map\[string\]/map[string]/g" {} \; + find ./pkg/api/v1/testkube -name "*.go" -type f -exec sed -i '' -e "s/ map\[string\]Object / map\[string\]interface\{\} /g" {} \; # support map with empty additional properties find ./pkg/api/v1/testkube -name "*update*.go" -type f -exec sed -i '' -e "s/ map/ \*map/g" {} \; find ./pkg/api/v1/testkube -name "*update*.go" -type f -exec sed -i '' -e "s/ string/ \*string/g" {} \; find ./pkg/api/v1/testkube -name "*update*.go" -type f -exec sed -i '' -e "s/ \[\]/ \*\[\]/g" {} \; diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 95219cbe55f..c3279070164 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -7630,6 +7630,7 @@ components: description: output kind name value: type: object + additionalProperties: {} description: value returned TestWorkflowResult: diff --git a/build/testworkflow-toolkit/Dockerfile b/build/testworkflow-toolkit/Dockerfile index cbd4a996a91..6666347bd28 100644 --- a/build/testworkflow-toolkit/Dockerfile +++ b/build/testworkflow-toolkit/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 ARG ALPINE_IMAGE FROM ${ALPINE_IMAGE} -RUN apk --no-cache add ca-certificates libssl1.1 git +RUN apk --no-cache add ca-certificates libssl3 git COPY testworkflow-toolkit /toolkit USER 1001 ENTRYPOINT ["/toolkit"] diff --git a/pkg/api/v1/testkube/model_test_workflow_output.go b/pkg/api/v1/testkube/model_test_workflow_output.go index b464d7447df..503068db64d 100644 --- a/pkg/api/v1/testkube/model_test_workflow_output.go +++ b/pkg/api/v1/testkube/model_test_workflow_output.go @@ -15,5 +15,5 @@ type TestWorkflowOutput struct { // output kind name Name string `json:"name,omitempty"` // value returned - Value *interface{} `json:"value,omitempty"` + Value map[string]interface{} `json:"value,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_workflow_status_extended.go b/pkg/api/v1/testkube/model_test_workflow_status_extended.go index 6a17103d1ab..d56ba0d1e1c 100644 --- a/pkg/api/v1/testkube/model_test_workflow_status_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_status_extended.go @@ -21,6 +21,7 @@ func (statuses TestWorkflowStatuses) ToMap() map[TestWorkflowStatus]struct{} { // ParseTestWorkflowStatusList parse a list of workflow execution statuses from string func ParseTestWorkflowStatusList(source, separator string) (statusList TestWorkflowStatuses, err error) { statusMap := map[TestWorkflowStatus]struct{}{ + ABORTED_TestWorkflowStatus: {}, FAILED_TestWorkflowStatus: {}, PASSED_TestWorkflowStatus: {}, QUEUED_TestWorkflowStatus: {}, diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go index fc9c84b35b1..7f0a782c6c9 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go @@ -26,6 +26,7 @@ import ( "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/utils" ) @@ -39,9 +40,16 @@ func (i *Instruction) ToInternal() *testkube.TestWorkflowOutput { if i == nil { return nil } - value := &i.Value - if i.Value == nil { - value = nil + value := map[string]interface{}(nil) + if i.Value != nil { + v, _ := json.Marshal(i.Value) + e := json.Unmarshal(v, &value) + if e != nil { + log.DefaultLogger.Warnf("invalid output passed from TestWorfklow - %v", i.Value) + } + } + if v, ok := i.Value.(map[string]interface{}); ok { + value = v } return &testkube.TestWorkflowOutput{ Ref: i.Ref, From adc30b781d3770ee85426b2720b23e0916ed97cc Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 8 Mar 2024 15:26:01 +0100 Subject: [PATCH 183/234] feat(TKC-1716): integrate TestWorkflows with Testkube Pro/Enterprise (#5120) * feat(TKC-1716): add Cloud repositories for TestWorkflows * feat(TKC-1716): add Protobuf schema for TestWorkflow notifications stream via GRPC * feat(TKC-1716): add agent workers for sharing TestWorkflow notifications with Cloud * fix: make message required in gRPC's TestWorkflow notifications --- cmd/api-server/main.go | 50 +- internal/config/config.go | 191 +++--- internal/config/procontext.go | 23 +- pkg/agent/agent.go | 57 +- pkg/agent/agent_test.go | 12 +- pkg/agent/events_test.go | 12 +- pkg/agent/logs_test.go | 12 +- pkg/agent/testworkflows.go | 215 +++++++ pkg/cloud/service.pb.go | 550 ++++++++++++++---- pkg/cloud/service_grpc.pb.go | 73 ++- pkg/tcl/apitcl/v1/server.go | 2 + pkg/tcl/apitcl/v1/testworkflowexecutions.go | 26 + .../cloudtcl/data/testworkflow/commands.go | 79 +++ .../cloudtcl/data/testworkflow/execution.go | 142 +++++ .../data/testworkflow/execution_models.go | 138 +++++ pkg/tcl/cloudtcl/data/testworkflow/output.go | 107 ++++ .../data/testworkflow/output_models.go | 36 ++ pkg/tcl/cloudtcl/data/testworkflow/utils.go | 60 ++ proto/service.proto | 28 + 19 files changed, 1547 insertions(+), 266 deletions(-) create mode 100644 pkg/agent/testworkflows.go create mode 100644 pkg/tcl/cloudtcl/data/testworkflow/commands.go create mode 100644 pkg/tcl/cloudtcl/data/testworkflow/execution.go create mode 100644 pkg/tcl/cloudtcl/data/testworkflow/execution_models.go create mode 100644 pkg/tcl/cloudtcl/data/testworkflow/output.go create mode 100644 pkg/tcl/cloudtcl/data/testworkflow/output_models.go create mode 100644 pkg/tcl/cloudtcl/data/testworkflow/utils.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 62bf681d5e3..10728c3dd15 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -18,6 +18,7 @@ import ( "github.com/kubeshop/testkube/pkg/imageinspector" apitclv1 "github.com/kubeshop/testkube/pkg/tcl/apitcl/v1" "github.com/kubeshop/testkube/pkg/tcl/checktcl" + cloudtestworkflow "github.com/kubeshop/testkube/pkg/tcl/cloudtcl/data/testworkflow" "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" "github.com/kubeshop/testkube/pkg/tcl/schedulertcl" @@ -263,6 +264,8 @@ func main() { resultsRepository = cloudresult.NewCloudResultRepository(grpcClient, grpcConn, cfg.TestkubeProAPIKey) testResultsRepository = cloudtestresult.NewCloudRepository(grpcClient, grpcConn, cfg.TestkubeProAPIKey) configRepository = cloudconfig.NewCloudResultRepository(grpcClient, grpcConn, cfg.TestkubeProAPIKey) + testWorkflowResultsRepository = cloudtestworkflow.NewCloudRepository(grpcClient, grpcConn, cfg.TestkubeProAPIKey) + testWorkflowOutputRepository = cloudtestworkflow.NewCloudOutputRepository(grpcClient, grpcConn, cfg.TestkubeProAPIKey) triggerLeaseBackend = triggers.NewAcquireAlwaysLeaseBackend() artifactStorage = cloudartifacts.NewCloudArtifactsStorage(grpcClient, grpcConn, cfg.TestkubeProAPIKey) } else { @@ -405,17 +408,18 @@ func main() { } proContext := config.ProContext{ - APIKey: cfg.TestkubeProAPIKey, - URL: cfg.TestkubeProURL, - LogsPath: cfg.TestkubeProLogsPath, - TLSInsecure: cfg.TestkubeProTLSInsecure, - WorkerCount: cfg.TestkubeProWorkerCount, - LogStreamWorkerCount: cfg.TestkubeProLogStreamWorkerCount, - SkipVerify: cfg.TestkubeProSkipVerify, - EnvID: cfg.TestkubeProEnvID, - OrgID: cfg.TestkubeProOrgID, - Migrate: cfg.TestkubeProMigrate, - ConnectionTimeout: cfg.TestkubeProConnectionTimeout, + APIKey: cfg.TestkubeProAPIKey, + URL: cfg.TestkubeProURL, + LogsPath: cfg.TestkubeProLogsPath, + TLSInsecure: cfg.TestkubeProTLSInsecure, + WorkerCount: cfg.TestkubeProWorkerCount, + LogStreamWorkerCount: cfg.TestkubeProLogStreamWorkerCount, + WorkflowNotificationsWorkerCount: cfg.TestkubeProWorkflowNotificationsWorkerCount, + SkipVerify: cfg.TestkubeProSkipVerify, + EnvID: cfg.TestkubeProEnvID, + OrgID: cfg.TestkubeProOrgID, + Migrate: cfg.TestkubeProMigrate, + ConnectionTimeout: cfg.TestkubeProConnectionTimeout, } // Check Pro/Enterprise subscription @@ -574,6 +578,18 @@ func main() { subscriptionChecker, ) + // Apply Pro server enhancements + apiPro := apitclv1.NewApiTCL( + api, + &proContext, + kubeClient, + inspector, + testWorkflowResultsRepository, + testWorkflowOutputRepository, + "http://"+cfg.APIServerFullname+":"+cfg.APIServerPort, + ) + apiPro.AppendRoutes() + if mode == common.ModeAgent { log.DefaultLogger.Info("starting agent service") api.WithProContext(&proContext) @@ -582,6 +598,7 @@ func main() { api.Mux.Handler(), grpcClient, api.GetLogsStream, + apiPro.GetTestWorkflowNotificationsStream, clusterId, cfg.TestkubeClusterName, envs, @@ -601,17 +618,6 @@ func main() { eventsEmitter.Loader.Register(agentHandle) } - // Apply Pro server enhancements - apitclv1.NewApiTCL( - api, - &proContext, - kubeClient, - inspector, - testWorkflowResultsRepository, - testWorkflowOutputRepository, - "http://"+cfg.APIServerFullname+":"+cfg.APIServerPort, - ).AppendRoutes() - api.InitEvents() if !cfg.DisableTestTriggers { triggerService := triggers.NewService( diff --git a/internal/config/config.go b/internal/config/config.go index 82c4c7c6183..ac1378c7016 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,101 +7,102 @@ import ( ) type Config struct { - APIServerPort string `envconfig:"APISERVER_PORT" default:"8088"` - APIServerConfig string `envconfig:"APISERVER_CONFIG" default:""` - APIServerFullname string `envconfig:"APISERVER_FULLNAME" default:"testkube-api-server"` - APIMongoDSN string `envconfig:"API_MONGO_DSN" default:"mongodb://localhost:27017"` - APIMongoAllowTLS bool `envconfig:"API_MONGO_ALLOW_TLS" default:"false"` - APIMongoSSLCert string `envconfig:"API_MONGO_SSL_CERT" default:""` - APIMongoSSLCAFileKey string `envconfig:"API_MONGO_SSL_CA_FILE_KEY" default:"sslCertificateAuthorityFile"` - APIMongoSSLClientFileKey string `envconfig:"API_MONGO_SSL_CLIENT_FILE_KEY" default:"sslClientCertificateKeyFile"` - APIMongoSSLClientFilePass string `envconfig:"API_MONGO_SSL_CLIENT_FILE_PASS_KEY" default:"sslClientCertificateKeyFilePassword"` - APIMongoAllowDiskUse bool `envconfig:"API_MONGO_ALLOW_DISK_USE" default:"false"` - APIMongoDB string `envconfig:"API_MONGO_DB" default:"testkube"` - APIMongoDBType string `envconfig:"API_MONGO_DB_TYPE" default:"mongo"` - SlackToken string `envconfig:"SLACK_TOKEN" default:""` - SlackConfig string `envconfig:"SLACK_CONFIG" default:""` - SlackTemplate string `envconfig:"SLACK_TEMPLATE" default:""` - StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"localhost:9000"` - StorageBucket string `envconfig:"STORAGE_BUCKET" default:"testkube-logs"` - StorageExpiration int `envconfig:"STORAGE_EXPIRATION"` - StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:""` - StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:""` - StorageRegion string `envconfig:"STORAGE_REGION" default:""` - StorageToken string `envconfig:"STORAGE_TOKEN" default:""` - StorageSSL bool `envconfig:"STORAGE_SSL" default:"false"` - StorageSkipVerify bool `envconfig:"STORAGE_SKIP_VERIFY" default:"false"` - StorageCertFile string `envconfig:"STORAGE_CERT_FILE" default:""` - StorageKeyFile string `envconfig:"STORAGE_KEY_FILE" default:""` - StorageCAFile string `envconfig:"STORAGE_CA_FILE" default:""` - ScrapperEnabled bool `envconfig:"SCRAPPERENABLED" default:"false"` - LogsBucket string `envconfig:"LOGS_BUCKET" default:""` - LogsStorage string `envconfig:"LOGS_STORAGE" default:""` - NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` - NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` - NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` - NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` - NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` - NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` - NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` - JobServiceAccountName string `envconfig:"JOB_SERVICE_ACCOUNT_NAME" default:""` - JobTemplateFile string `envconfig:"JOB_TEMPLATE_FILE" default:""` - DisableTestTriggers bool `envconfig:"DISABLE_TEST_TRIGGERS" default:"false"` - TestkubeDefaultExecutors string `envconfig:"TESTKUBE_DEFAULT_EXECUTORS" default:""` - TestkubeEnabledExecutors string `envconfig:"TESTKUBE_ENABLED_EXECUTORS" default:""` - TestkubeTemplateJob string `envconfig:"TESTKUBE_TEMPLATE_JOB" default:""` - TestkubeContainerTemplateJob string `envconfig:"TESTKUBE_CONTAINER_TEMPLATE_JOB" default:""` - TestkubeContainerTemplateScraper string `envconfig:"TESTKUBE_CONTAINER_TEMPLATE_SCRAPER" default:""` - TestkubeContainerTemplatePVC string `envconfig:"TESTKUBE_CONTAINER_TEMPLATE_PVC" default:""` - TestkubeTemplateSlavePod string `envconfig:"TESTKUBE_TEMPLATE_SLAVE_POD" default:""` - TestkubeConfigDir string `envconfig:"TESTKUBE_CONFIG_DIR" default:"config"` - TestkubeAnalyticsEnabled bool `envconfig:"TESTKUBE_ANALYTICS_ENABLED" default:"false"` - TestkubeReadonlyExecutors bool `envconfig:"TESTKUBE_READONLY_EXECUTORS" default:"false"` - TestkubeNamespace string `envconfig:"TESTKUBE_NAMESPACE" default:"testkube"` - TestkubeOAuthClientID string `envconfig:"TESTKUBE_OAUTH_CLIENTID" default:""` - TestkubeOAuthClientSecret string `envconfig:"TESTKUBE_OAUTH_CLIENTSECRET" default:""` - TestkubeOAuthProvider string `envconfig:"TESTKUBE_OAUTH_PROVIDER" default:""` - TestkubeOAuthScopes string `envconfig:"TESTKUBE_OAUTH_SCOPES" default:""` - TestkubeProAPIKey string `envconfig:"TESTKUBE_PRO_API_KEY" default:""` - TestkubeProURL string `envconfig:"TESTKUBE_PRO_URL" default:""` - TestkubeProLogsPath string `envconfig:"TESTKUBE_PRO_LOGS_PATH" default:"/logs"` - TestkubeProTLSInsecure bool `envconfig:"TESTKUBE_PRO_TLS_INSECURE" default:"false"` - TestkubeProWorkerCount int `envconfig:"TESTKUBE_PRO_WORKER_COUNT" default:"50"` - TestkubeProLogStreamWorkerCount int `envconfig:"TESTKUBE_PRO_LOG_STREAM_WORKER_COUNT" default:"25"` - TestkubeProSkipVerify bool `envconfig:"TESTKUBE_PRO_SKIP_VERIFY" default:"false"` - TestkubeProEnvID string `envconfig:"TESTKUBE_PRO_ENV_ID" default:""` - TestkubeProOrgID string `envconfig:"TESTKUBE_PRO_ORG_ID" default:""` - TestkubeProMigrate string `envconfig:"TESTKUBE_PRO_MIGRATE" default:"false"` - TestkubeProConnectionTimeout int `envconfig:"TESTKUBE_PRO_CONNECTION_TIMEOUT" default:"10"` - TestkubeProCertFile string `envconfig:"TESTKUBE_PRO_CERT_FILE" default:""` - TestkubeProKeyFile string `envconfig:"TESTKUBE_PRO_KEY_FILE" default:""` - TestkubeProCAFile string `envconfig:"TESTKUBE_PRO_CA_FILE" default:""` - TestkubeProTLSSecret string `envconfig:"TESTKUBE_PRO_TLS_SECRET" default:""` - TestkubeWatcherNamespaces string `envconfig:"TESTKUBE_WATCHER_NAMESPACES" default:""` - GraphqlPort string `envconfig:"TESTKUBE_GRAPHQL_PORT" default:"8070"` - TestkubeRegistry string `envconfig:"TESTKUBE_REGISTRY" default:""` - TestkubePodStartTimeout time.Duration `envconfig:"TESTKUBE_POD_START_TIMEOUT" default:"30m"` - CDEventsTarget string `envconfig:"CDEVENTS_TARGET" default:""` - TestkubeDashboardURI string `envconfig:"TESTKUBE_DASHBOARD_URI" default:""` - DisableReconciler bool `envconfig:"DISABLE_RECONCILER" default:"false"` - TestkubeClusterName string `envconfig:"TESTKUBE_CLUSTER_NAME" default:""` - CompressArtifacts bool `envconfig:"COMPRESSARTIFACTS" default:"false"` - TestkubeHelmchartVersion string `envconfig:"TESTKUBE_HELMCHART_VERSION" default:""` - DebugListenAddr string `envconfig:"DEBUG_LISTEN_ADDR" default:"0.0.0.0:1337"` - EnableDebugServer bool `envconfig:"ENABLE_DEBUG_SERVER" default:"false"` - EnableSecretsEndpoint bool `envconfig:"ENABLE_SECRETS_ENDPOINT" default:"false"` - DisableMongoMigrations bool `envconfig:"DISABLE_MONGO_MIGRATIONS" default:"false"` - Debug bool `envconfig:"DEBUG" default:"false"` - EnableImageDataPersistentCache bool `envconfig:"TESTKUBE_ENABLE_IMAGE_DATA_PERSISTENT_CACHE" default:"false"` - ImageDataPersistentCacheKey string `envconfig:"TESTKUBE_IMAGE_DATA_PERSISTENT_CACHE_KEY" default:"testkube-image-cache"` - LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` - LogServerSecure bool `envconfig:"LOG_SERVER_SECURE" default:"false"` - LogServerSkipVerify bool `envconfig:"LOG_SERVER_SKIP_VERIFY" default:"false"` - LogServerCertFile string `envconfig:"LOG_SERVER_CERT_FILE" default:""` - LogServerKeyFile string `envconfig:"LOG_SERVER_KEY_FILE" default:""` - LogServerCAFile string `envconfig:"LOG_SERVER_CA_FILE" default:""` - DisableSecretCreation bool `envconfig:"DISABLE_SECRET_CREATION" default:"false"` - TestkubeExecutionNamespaces string `envconfig:"TESTKUBE_EXECUTION_NAMESPACES" default:""` + APIServerPort string `envconfig:"APISERVER_PORT" default:"8088"` + APIServerConfig string `envconfig:"APISERVER_CONFIG" default:""` + APIServerFullname string `envconfig:"APISERVER_FULLNAME" default:"testkube-api-server"` + APIMongoDSN string `envconfig:"API_MONGO_DSN" default:"mongodb://localhost:27017"` + APIMongoAllowTLS bool `envconfig:"API_MONGO_ALLOW_TLS" default:"false"` + APIMongoSSLCert string `envconfig:"API_MONGO_SSL_CERT" default:""` + APIMongoSSLCAFileKey string `envconfig:"API_MONGO_SSL_CA_FILE_KEY" default:"sslCertificateAuthorityFile"` + APIMongoSSLClientFileKey string `envconfig:"API_MONGO_SSL_CLIENT_FILE_KEY" default:"sslClientCertificateKeyFile"` + APIMongoSSLClientFilePass string `envconfig:"API_MONGO_SSL_CLIENT_FILE_PASS_KEY" default:"sslClientCertificateKeyFilePassword"` + APIMongoAllowDiskUse bool `envconfig:"API_MONGO_ALLOW_DISK_USE" default:"false"` + APIMongoDB string `envconfig:"API_MONGO_DB" default:"testkube"` + APIMongoDBType string `envconfig:"API_MONGO_DB_TYPE" default:"mongo"` + SlackToken string `envconfig:"SLACK_TOKEN" default:""` + SlackConfig string `envconfig:"SLACK_CONFIG" default:""` + SlackTemplate string `envconfig:"SLACK_TEMPLATE" default:""` + StorageEndpoint string `envconfig:"STORAGE_ENDPOINT" default:"localhost:9000"` + StorageBucket string `envconfig:"STORAGE_BUCKET" default:"testkube-logs"` + StorageExpiration int `envconfig:"STORAGE_EXPIRATION"` + StorageAccessKeyID string `envconfig:"STORAGE_ACCESSKEYID" default:""` + StorageSecretAccessKey string `envconfig:"STORAGE_SECRETACCESSKEY" default:""` + StorageRegion string `envconfig:"STORAGE_REGION" default:""` + StorageToken string `envconfig:"STORAGE_TOKEN" default:""` + StorageSSL bool `envconfig:"STORAGE_SSL" default:"false"` + StorageSkipVerify bool `envconfig:"STORAGE_SKIP_VERIFY" default:"false"` + StorageCertFile string `envconfig:"STORAGE_CERT_FILE" default:""` + StorageKeyFile string `envconfig:"STORAGE_KEY_FILE" default:""` + StorageCAFile string `envconfig:"STORAGE_CA_FILE" default:""` + ScrapperEnabled bool `envconfig:"SCRAPPERENABLED" default:"false"` + LogsBucket string `envconfig:"LOGS_BUCKET" default:""` + LogsStorage string `envconfig:"LOGS_STORAGE" default:""` + NatsURI string `envconfig:"NATS_URI" default:"nats://localhost:4222"` + NatsSecure bool `envconfig:"NATS_SECURE" default:"false"` + NatsSkipVerify bool `envconfig:"NATS_SKIP_VERIFY" default:"false"` + NatsCertFile string `envconfig:"NATS_CERT_FILE" default:""` + NatsKeyFile string `envconfig:"NATS_KEY_FILE" default:""` + NatsCAFile string `envconfig:"NATS_CA_FILE" default:""` + NatsConnectTimeout time.Duration `envconfig:"NATS_CONNECT_TIMEOUT" default:"5s"` + JobServiceAccountName string `envconfig:"JOB_SERVICE_ACCOUNT_NAME" default:""` + JobTemplateFile string `envconfig:"JOB_TEMPLATE_FILE" default:""` + DisableTestTriggers bool `envconfig:"DISABLE_TEST_TRIGGERS" default:"false"` + TestkubeDefaultExecutors string `envconfig:"TESTKUBE_DEFAULT_EXECUTORS" default:""` + TestkubeEnabledExecutors string `envconfig:"TESTKUBE_ENABLED_EXECUTORS" default:""` + TestkubeTemplateJob string `envconfig:"TESTKUBE_TEMPLATE_JOB" default:""` + TestkubeContainerTemplateJob string `envconfig:"TESTKUBE_CONTAINER_TEMPLATE_JOB" default:""` + TestkubeContainerTemplateScraper string `envconfig:"TESTKUBE_CONTAINER_TEMPLATE_SCRAPER" default:""` + TestkubeContainerTemplatePVC string `envconfig:"TESTKUBE_CONTAINER_TEMPLATE_PVC" default:""` + TestkubeTemplateSlavePod string `envconfig:"TESTKUBE_TEMPLATE_SLAVE_POD" default:""` + TestkubeConfigDir string `envconfig:"TESTKUBE_CONFIG_DIR" default:"config"` + TestkubeAnalyticsEnabled bool `envconfig:"TESTKUBE_ANALYTICS_ENABLED" default:"false"` + TestkubeReadonlyExecutors bool `envconfig:"TESTKUBE_READONLY_EXECUTORS" default:"false"` + TestkubeNamespace string `envconfig:"TESTKUBE_NAMESPACE" default:"testkube"` + TestkubeOAuthClientID string `envconfig:"TESTKUBE_OAUTH_CLIENTID" default:""` + TestkubeOAuthClientSecret string `envconfig:"TESTKUBE_OAUTH_CLIENTSECRET" default:""` + TestkubeOAuthProvider string `envconfig:"TESTKUBE_OAUTH_PROVIDER" default:""` + TestkubeOAuthScopes string `envconfig:"TESTKUBE_OAUTH_SCOPES" default:""` + TestkubeProAPIKey string `envconfig:"TESTKUBE_PRO_API_KEY" default:""` + TestkubeProURL string `envconfig:"TESTKUBE_PRO_URL" default:""` + TestkubeProLogsPath string `envconfig:"TESTKUBE_PRO_LOGS_PATH" default:"/logs"` + TestkubeProTLSInsecure bool `envconfig:"TESTKUBE_PRO_TLS_INSECURE" default:"false"` + TestkubeProWorkerCount int `envconfig:"TESTKUBE_PRO_WORKER_COUNT" default:"50"` + TestkubeProLogStreamWorkerCount int `envconfig:"TESTKUBE_PRO_LOG_STREAM_WORKER_COUNT" default:"25"` + TestkubeProWorkflowNotificationsWorkerCount int `envconfig:"TESTKUBE_PRO_WORKFLOW_NOTIFICATIONS_STREAM_WORKER_COUNT" default:"25"` + TestkubeProSkipVerify bool `envconfig:"TESTKUBE_PRO_SKIP_VERIFY" default:"false"` + TestkubeProEnvID string `envconfig:"TESTKUBE_PRO_ENV_ID" default:""` + TestkubeProOrgID string `envconfig:"TESTKUBE_PRO_ORG_ID" default:""` + TestkubeProMigrate string `envconfig:"TESTKUBE_PRO_MIGRATE" default:"false"` + TestkubeProConnectionTimeout int `envconfig:"TESTKUBE_PRO_CONNECTION_TIMEOUT" default:"10"` + TestkubeProCertFile string `envconfig:"TESTKUBE_PRO_CERT_FILE" default:""` + TestkubeProKeyFile string `envconfig:"TESTKUBE_PRO_KEY_FILE" default:""` + TestkubeProCAFile string `envconfig:"TESTKUBE_PRO_CA_FILE" default:""` + TestkubeProTLSSecret string `envconfig:"TESTKUBE_PRO_TLS_SECRET" default:""` + TestkubeWatcherNamespaces string `envconfig:"TESTKUBE_WATCHER_NAMESPACES" default:""` + GraphqlPort string `envconfig:"TESTKUBE_GRAPHQL_PORT" default:"8070"` + TestkubeRegistry string `envconfig:"TESTKUBE_REGISTRY" default:""` + TestkubePodStartTimeout time.Duration `envconfig:"TESTKUBE_POD_START_TIMEOUT" default:"30m"` + CDEventsTarget string `envconfig:"CDEVENTS_TARGET" default:""` + TestkubeDashboardURI string `envconfig:"TESTKUBE_DASHBOARD_URI" default:""` + DisableReconciler bool `envconfig:"DISABLE_RECONCILER" default:"false"` + TestkubeClusterName string `envconfig:"TESTKUBE_CLUSTER_NAME" default:""` + CompressArtifacts bool `envconfig:"COMPRESSARTIFACTS" default:"false"` + TestkubeHelmchartVersion string `envconfig:"TESTKUBE_HELMCHART_VERSION" default:""` + DebugListenAddr string `envconfig:"DEBUG_LISTEN_ADDR" default:"0.0.0.0:1337"` + EnableDebugServer bool `envconfig:"ENABLE_DEBUG_SERVER" default:"false"` + EnableSecretsEndpoint bool `envconfig:"ENABLE_SECRETS_ENDPOINT" default:"false"` + DisableMongoMigrations bool `envconfig:"DISABLE_MONGO_MIGRATIONS" default:"false"` + Debug bool `envconfig:"DEBUG" default:"false"` + EnableImageDataPersistentCache bool `envconfig:"TESTKUBE_ENABLE_IMAGE_DATA_PERSISTENT_CACHE" default:"false"` + ImageDataPersistentCacheKey string `envconfig:"TESTKUBE_IMAGE_DATA_PERSISTENT_CACHE_KEY" default:"testkube-image-cache"` + LogServerGrpcAddress string `envconfig:"LOG_SERVER_GRPC_ADDRESS" default:":9090"` + LogServerSecure bool `envconfig:"LOG_SERVER_SECURE" default:"false"` + LogServerSkipVerify bool `envconfig:"LOG_SERVER_SKIP_VERIFY" default:"false"` + LogServerCertFile string `envconfig:"LOG_SERVER_CERT_FILE" default:""` + LogServerKeyFile string `envconfig:"LOG_SERVER_KEY_FILE" default:""` + LogServerCAFile string `envconfig:"LOG_SERVER_CA_FILE" default:""` + DisableSecretCreation bool `envconfig:"DISABLE_SECRET_CREATION" default:"false"` + TestkubeExecutionNamespaces string `envconfig:"TESTKUBE_EXECUTION_NAMESPACES" default:""` // DEPRECATED: Use TestkubeProAPIKey instead TestkubeCloudAPIKey string `envconfig:"TESTKUBE_CLOUD_API_KEY" default:""` diff --git a/internal/config/procontext.go b/internal/config/procontext.go index 24831170d5f..2429a4a61b1 100644 --- a/internal/config/procontext.go +++ b/internal/config/procontext.go @@ -1,15 +1,16 @@ package config type ProContext struct { - APIKey string - URL string - LogsPath string - TLSInsecure bool - WorkerCount int - LogStreamWorkerCount int - SkipVerify bool - EnvID string - OrgID string - Migrate string - ConnectionTimeout int + APIKey string + URL string + LogsPath string + TLSInsecure bool + WorkerCount int + LogStreamWorkerCount int + WorkflowNotificationsWorkerCount int + SkipVerify bool + EnvID string + OrgID string + Migrate string + ConnectionTimeout int } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 7248774702e..b6d6c7466f6 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -141,6 +141,11 @@ type Agent struct { logStreamResponseBuffer chan *cloud.LogsStreamResponse logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) + testWorkflowNotificationsWorkerCount int + testWorkflowNotificationsRequestBuffer chan *cloud.TestWorkflowNotificationsRequest + testWorkflowNotificationsResponseBuffer chan *cloud.TestWorkflowNotificationsResponse + testWorkflowNotificationsFunc func(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error) + events chan testkube.Event sendTimeout time.Duration receiveTimeout time.Duration @@ -158,6 +163,7 @@ func NewAgent(logger *zap.SugaredLogger, handler fasthttp.RequestHandler, client cloud.TestKubeCloudAPIClient, logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error), + workflowNotificationsFunc func(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error), clusterID string, clusterName string, envs map[string]string, @@ -165,26 +171,30 @@ func NewAgent(logger *zap.SugaredLogger, proContext config.ProContext, ) (*Agent, error) { return &Agent{ - handler: handler, - logger: logger, - apiKey: proContext.APIKey, - client: client, - events: make(chan testkube.Event), - workerCount: proContext.WorkerCount, - requestBuffer: make(chan *cloud.ExecuteRequest, bufferSizePerWorker*proContext.WorkerCount), - responseBuffer: make(chan *cloud.ExecuteResponse, bufferSizePerWorker*proContext.WorkerCount), - receiveTimeout: 5 * time.Minute, - sendTimeout: 30 * time.Second, - healthcheckInterval: 30 * time.Second, - logStreamWorkerCount: proContext.LogStreamWorkerCount, - logStreamRequestBuffer: make(chan *cloud.LogsStreamRequest, bufferSizePerWorker*proContext.LogStreamWorkerCount), - logStreamResponseBuffer: make(chan *cloud.LogsStreamResponse, bufferSizePerWorker*proContext.LogStreamWorkerCount), - logStreamFunc: logStreamFunc, - clusterID: clusterID, - clusterName: clusterName, - envs: envs, - features: features, - proContext: proContext, + handler: handler, + logger: logger, + apiKey: proContext.APIKey, + client: client, + events: make(chan testkube.Event), + workerCount: proContext.WorkerCount, + requestBuffer: make(chan *cloud.ExecuteRequest, bufferSizePerWorker*proContext.WorkerCount), + responseBuffer: make(chan *cloud.ExecuteResponse, bufferSizePerWorker*proContext.WorkerCount), + receiveTimeout: 5 * time.Minute, + sendTimeout: 30 * time.Second, + healthcheckInterval: 30 * time.Second, + logStreamWorkerCount: proContext.LogStreamWorkerCount, + logStreamRequestBuffer: make(chan *cloud.LogsStreamRequest, bufferSizePerWorker*proContext.LogStreamWorkerCount), + logStreamResponseBuffer: make(chan *cloud.LogsStreamResponse, bufferSizePerWorker*proContext.LogStreamWorkerCount), + logStreamFunc: logStreamFunc, + testWorkflowNotificationsWorkerCount: proContext.WorkflowNotificationsWorkerCount, + testWorkflowNotificationsRequestBuffer: make(chan *cloud.TestWorkflowNotificationsRequest, bufferSizePerWorker*proContext.WorkflowNotificationsWorkerCount), + testWorkflowNotificationsResponseBuffer: make(chan *cloud.TestWorkflowNotificationsResponse, bufferSizePerWorker*proContext.WorkflowNotificationsWorkerCount), + testWorkflowNotificationsFunc: workflowNotificationsFunc, + clusterID: clusterID, + clusterName: clusterName, + envs: envs, + features: features, + proContext: proContext, }, nil } @@ -225,6 +235,13 @@ func (ag *Agent) run(ctx context.Context) (err error) { }) } + g.Go(func() error { + return ag.runTestWorkflowNotificationsLoop(groupCtx) + }) + g.Go(func() error { + return ag.runTestWorkflowNotificationsWorker(groupCtx, ag.testWorkflowNotificationsWorkerCount) + }) + err = g.Wait() return err diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 2192e16c012..f6c5cee62eb 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/log" "github.com/kubeshop/testkube/pkg/ui" @@ -56,10 +57,11 @@ func TestCommandExecution(t *testing.T) { grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) var logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) + var workflowNotificationsStreamFunc func(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error) logger, _ := zap.NewDevelopment() - proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5} - agent, err := agent.NewAgent(logger.Sugar(), m, grpcClient, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) + proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5, WorkflowNotificationsWorkerCount: 5} + agent, err := agent.NewAgent(logger.Sugar(), m, grpcClient, logStreamFunc, workflowNotificationsStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) if err != nil { t.Fatal(err) } @@ -88,6 +90,12 @@ func (cs *CloudServer) GetLogsStream(srv cloud.TestKubeCloudAPI_GetLogsStreamSer return nil } +func (cs *CloudServer) GetTestWorkflowNotificationsStream(srv cloud.TestKubeCloudAPI_GetTestWorkflowNotificationsStreamServer) error { + <-cs.ctx.Done() + + return nil +} + func (cs *CloudServer) ExecuteAsync(srv cloud.TestKubeCloudAPI_ExecuteAsyncServer) error { md, ok := metadata.FromIncomingContext(srv.Context()) if !ok { diff --git a/pkg/agent/events_test.go b/pkg/agent/events_test.go index 7d133ea4359..990c6664673 100644 --- a/pkg/agent/events_test.go +++ b/pkg/agent/events_test.go @@ -54,8 +54,10 @@ func TestEventLoop(t *testing.T) { grpcClient := cloud.NewTestKubeCloudAPIClient(grpcConn) var logStreamFunc func(ctx context.Context, executionID string) (chan output.Output, error) - proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5} - agent, err := agent.NewAgent(logger.Sugar(), nil, grpcClient, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) + var workflowNotificationsStreamFunc func(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error) + + proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5, WorkflowNotificationsWorkerCount: 5} + agent, err := agent.NewAgent(logger.Sugar(), nil, grpcClient, logStreamFunc, workflowNotificationsStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) assert.NoError(t, err) go func() { l, err := agent.Load() @@ -101,6 +103,12 @@ func (cws *CloudEventServer) GetLogsStream(srv cloud.TestKubeCloudAPI_GetLogsStr return nil } +func (cws *CloudEventServer) GetTestWorkflowNotificationsStream(srv cloud.TestKubeCloudAPI_GetTestWorkflowNotificationsStreamServer) error { + <-cws.ctx.Done() + + return nil +} + func (cws *CloudEventServer) Send(srv cloud.TestKubeCloudAPI_SendServer) error { md, ok := metadata.FromIncomingContext(srv.Context()) if !ok { diff --git a/pkg/agent/logs_test.go b/pkg/agent/logs_test.go index 3595912cbb4..4847c88fba9 100644 --- a/pkg/agent/logs_test.go +++ b/pkg/agent/logs_test.go @@ -9,6 +9,7 @@ import ( "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/pkg/agent" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/cloud" "github.com/kubeshop/testkube/pkg/executor/output" "github.com/kubeshop/testkube/pkg/featureflags" @@ -63,10 +64,11 @@ func TestLogStream(t *testing.T) { msgCnt++ return ch, nil } + var workflowNotificationsStreamFunc func(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error) logger, _ := zap.NewDevelopment() - proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5} - agent, err := agent.NewAgent(logger.Sugar(), m, grpcClient, logStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) + proContext := config.ProContext{APIKey: "api-key", WorkerCount: 5, LogStreamWorkerCount: 5, WorkflowNotificationsWorkerCount: 5} + agent, err := agent.NewAgent(logger.Sugar(), m, grpcClient, logStreamFunc, workflowNotificationsStreamFunc, "", "", nil, featureflags.FeatureFlags{}, proContext) if err != nil { t.Fatal(err) } @@ -93,6 +95,12 @@ func (cs *CloudLogsServer) ExecuteAsync(srv cloud.TestKubeCloudAPI_ExecuteAsyncS <-cs.ctx.Done() return nil } + +func (cs *CloudLogsServer) GetTestWorkflowNotificationsStream(srv cloud.TestKubeCloudAPI_GetTestWorkflowNotificationsStreamServer) error { + <-cs.ctx.Done() + return nil +} + func (cs *CloudLogsServer) GetLogsStream(srv cloud.TestKubeCloudAPI_GetLogsStreamServer) error { md, ok := metadata.FromIncomingContext(srv.Context()) if !ok { diff --git a/pkg/agent/testworkflows.go b/pkg/agent/testworkflows.go new file mode 100644 index 00000000000..e5439e675cf --- /dev/null +++ b/pkg/agent/testworkflows.go @@ -0,0 +1,215 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "math" + "time" + + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc" + "google.golang.org/grpc/encoding/gzip" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/cloud" +) + +const testWorkflowNotificationsRetryCount = 10 + +func getTestWorkflowNotificationType(n testkube.TestWorkflowExecutionNotification) cloud.TestWorkflowNotificationType { + if n.Result != nil { + return cloud.TestWorkflowNotificationType_WORKFLOW_STREAM_RESULT + } else if n.Output != nil { + return cloud.TestWorkflowNotificationType_WORKFLOW_STREAM_OUTPUT + } + return cloud.TestWorkflowNotificationType_WORKFLOW_STREAM_LOG +} + +func (ag *Agent) runTestWorkflowNotificationsLoop(ctx context.Context) error { + ctx = AddAPIKeyMeta(ctx, ag.apiKey) + + ag.logger.Infow("initiating workflow notifications streaming connection with Cloud API") + // creates a new Stream from the client side. ctx is used for the lifetime of the stream. + opts := []grpc.CallOption{grpc.UseCompressor(gzip.Name), grpc.MaxCallRecvMsgSize(math.MaxInt32)} + stream, err := ag.client.GetTestWorkflowNotificationsStream(ctx, opts...) + if err != nil { + ag.logger.Errorf("failed to execute: %w", err) + return errors.Wrap(err, "failed to setup stream") + } + + // GRPC stream have special requirements for concurrency on SendMsg, and RecvMsg calls. + // Please check https://github.com/grpc/grpc-go/blob/master/Documentation/concurrency.md + g, groupCtx := errgroup.WithContext(ctx) + g.Go(func() error { + for { + cmd, err := ag.receiveTestWorkflowNotificationsRequest(groupCtx, stream) + if err != nil { + return err + } + + ag.testWorkflowNotificationsRequestBuffer <- cmd + } + }) + + g.Go(func() error { + for { + select { + case resp := <-ag.testWorkflowNotificationsResponseBuffer: + err := ag.sendTestWorkflowNotificationsResponse(groupCtx, stream, resp) + if err != nil { + return err + } + case <-groupCtx.Done(): + return groupCtx.Err() + } + } + }) + + err = g.Wait() + + return err +} + +func (ag *Agent) runTestWorkflowNotificationsWorker(ctx context.Context, numWorkers int) error { + g, groupCtx := errgroup.WithContext(ctx) + for i := 0; i < numWorkers; i++ { + g.Go(func() error { + for { + select { + case req := <-ag.testWorkflowNotificationsRequestBuffer: + if req.RequestType == cloud.TestWorkflowNotificationsRequestType_WORKFLOW_STREAM_HEALTH_CHECK { + ag.testWorkflowNotificationsResponseBuffer <- &cloud.TestWorkflowNotificationsResponse{ + StreamId: req.StreamId, + SeqNo: 0, + } + break + } + + err := ag.executeWorkflowNotificationsRequest(groupCtx, req) + if err != nil { + ag.logger.Errorf("error executing workflow notifications request: %s", err.Error()) + } + case <-groupCtx.Done(): + return groupCtx.Err() + } + } + }) + } + return g.Wait() +} + +func (ag *Agent) executeWorkflowNotificationsRequest(ctx context.Context, req *cloud.TestWorkflowNotificationsRequest) error { + notificationsCh, err := ag.testWorkflowNotificationsFunc(ctx, req.ExecutionId) + for i := 0; i < testWorkflowNotificationsRetryCount; i++ { + if err != nil { + // We have a race condition here + // Cloud sometimes slow to insert execution or test + // while WorkflowNotifications request from websockets comes in faster + // so we retry up to testWorkflowNotificationsRetryCount times. + time.Sleep(100 * time.Millisecond) + notificationsCh, err = ag.testWorkflowNotificationsFunc(ctx, req.ExecutionId) + } + } + if err != nil { + message := fmt.Sprintf("cannot get pod logs: %s", err.Error()) + ag.testWorkflowNotificationsResponseBuffer <- &cloud.TestWorkflowNotificationsResponse{ + StreamId: req.StreamId, + SeqNo: 0, + Type: cloud.TestWorkflowNotificationType_WORKFLOW_STREAM_ERROR, + Message: message, + } + return nil + } + + for { + var i uint32 + select { + case n, ok := <-notificationsCh: + if !ok { + return nil + } + t := getTestWorkflowNotificationType(n) + msg := &cloud.TestWorkflowNotificationsResponse{ + StreamId: req.StreamId, + SeqNo: i, + Timestamp: n.Ts.Format(time.RFC3339Nano), + Ref: n.Ref, + Type: t, + } + if n.Result != nil { + m, _ := json.Marshal(n.Result) + msg.Message = string(m) + } else if n.Output != nil { + m, _ := json.Marshal(n.Output) + msg.Message = string(m) + } else { + msg.Message = n.Log + } + i++ + + select { + case ag.testWorkflowNotificationsResponseBuffer <- msg: + case <-ctx.Done(): + return ctx.Err() + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (ag *Agent) receiveTestWorkflowNotificationsRequest(ctx context.Context, stream cloud.TestKubeCloudAPI_GetTestWorkflowNotificationsStreamClient) (*cloud.TestWorkflowNotificationsRequest, error) { + respChan := make(chan testWorkflowNotificationsRequest, 1) + go func() { + cmd, err := stream.Recv() + respChan <- testWorkflowNotificationsRequest{resp: cmd, err: err} + }() + + var cmd *cloud.TestWorkflowNotificationsRequest + select { + case resp := <-respChan: + cmd = resp.resp + err := resp.err + + if err != nil { + ag.logger.Errorf("agent stream receive: %v", err) + return nil, err + } + case <-ctx.Done(): + return nil, ctx.Err() + } + + return cmd, nil +} + +type testWorkflowNotificationsRequest struct { + resp *cloud.TestWorkflowNotificationsRequest + err error +} + +func (ag *Agent) sendTestWorkflowNotificationsResponse(ctx context.Context, stream cloud.TestKubeCloudAPI_GetTestWorkflowNotificationsStreamClient, resp *cloud.TestWorkflowNotificationsResponse) error { + errChan := make(chan error, 1) + go func() { + errChan <- stream.Send(resp) + close(errChan) + }() + + t := time.NewTimer(ag.sendTimeout) + select { + case err := <-errChan: + if !t.Stop() { + <-t.C + } + return err + case <-ctx.Done(): + if !t.Stop() { + <-t.C + } + + return ctx.Err() + case <-t.C: + return errors.New("send response too slow") + } +} diff --git a/pkg/cloud/service.pb.go b/pkg/cloud/service.pb.go index 6e2ef74812d..0d8b4a7d29d 100644 --- a/pkg/cloud/service.pb.go +++ b/pkg/cloud/service.pb.go @@ -1,19 +1,18 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.27.1 +// protoc-gen-go v1.28.1 // protoc v3.19.4 // source: proto/service.proto package cloud import ( - reflect "reflect" - sync "sync" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" emptypb "google.golang.org/protobuf/types/known/emptypb" structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" ) const ( @@ -69,6 +68,104 @@ func (LogsStreamRequestType) EnumDescriptor() ([]byte, []int) { return file_proto_service_proto_rawDescGZIP(), []int{0} } +type TestWorkflowNotificationsRequestType int32 + +const ( + TestWorkflowNotificationsRequestType_WORKFLOW_STREAM_LOG_MESSAGE TestWorkflowNotificationsRequestType = 0 + TestWorkflowNotificationsRequestType_WORKFLOW_STREAM_HEALTH_CHECK TestWorkflowNotificationsRequestType = 1 +) + +// Enum value maps for TestWorkflowNotificationsRequestType. +var ( + TestWorkflowNotificationsRequestType_name = map[int32]string{ + 0: "WORKFLOW_STREAM_LOG_MESSAGE", + 1: "WORKFLOW_STREAM_HEALTH_CHECK", + } + TestWorkflowNotificationsRequestType_value = map[string]int32{ + "WORKFLOW_STREAM_LOG_MESSAGE": 0, + "WORKFLOW_STREAM_HEALTH_CHECK": 1, + } +) + +func (x TestWorkflowNotificationsRequestType) Enum() *TestWorkflowNotificationsRequestType { + p := new(TestWorkflowNotificationsRequestType) + *p = x + return p +} + +func (x TestWorkflowNotificationsRequestType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TestWorkflowNotificationsRequestType) Descriptor() protoreflect.EnumDescriptor { + return file_proto_service_proto_enumTypes[1].Descriptor() +} + +func (TestWorkflowNotificationsRequestType) Type() protoreflect.EnumType { + return &file_proto_service_proto_enumTypes[1] +} + +func (x TestWorkflowNotificationsRequestType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TestWorkflowNotificationsRequestType.Descriptor instead. +func (TestWorkflowNotificationsRequestType) EnumDescriptor() ([]byte, []int) { + return file_proto_service_proto_rawDescGZIP(), []int{1} +} + +type TestWorkflowNotificationType int32 + +const ( + TestWorkflowNotificationType_WORKFLOW_STREAM_ERROR TestWorkflowNotificationType = 0 + TestWorkflowNotificationType_WORKFLOW_STREAM_LOG TestWorkflowNotificationType = 1 + TestWorkflowNotificationType_WORKFLOW_STREAM_RESULT TestWorkflowNotificationType = 2 + TestWorkflowNotificationType_WORKFLOW_STREAM_OUTPUT TestWorkflowNotificationType = 3 +) + +// Enum value maps for TestWorkflowNotificationType. +var ( + TestWorkflowNotificationType_name = map[int32]string{ + 0: "WORKFLOW_STREAM_ERROR", + 1: "WORKFLOW_STREAM_LOG", + 2: "WORKFLOW_STREAM_RESULT", + 3: "WORKFLOW_STREAM_OUTPUT", + } + TestWorkflowNotificationType_value = map[string]int32{ + "WORKFLOW_STREAM_ERROR": 0, + "WORKFLOW_STREAM_LOG": 1, + "WORKFLOW_STREAM_RESULT": 2, + "WORKFLOW_STREAM_OUTPUT": 3, + } +) + +func (x TestWorkflowNotificationType) Enum() *TestWorkflowNotificationType { + p := new(TestWorkflowNotificationType) + *p = x + return p +} + +func (x TestWorkflowNotificationType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TestWorkflowNotificationType) Descriptor() protoreflect.EnumDescriptor { + return file_proto_service_proto_enumTypes[2].Descriptor() +} + +func (TestWorkflowNotificationType) Type() protoreflect.EnumType { + return &file_proto_service_proto_enumTypes[2] +} + +func (x TestWorkflowNotificationType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TestWorkflowNotificationType.Descriptor instead. +func (TestWorkflowNotificationType) EnumDescriptor() ([]byte, []int) { + return file_proto_service_proto_rawDescGZIP(), []int{2} +} + type Opcode int32 const ( @@ -105,11 +202,11 @@ func (x Opcode) String() string { } func (Opcode) Descriptor() protoreflect.EnumDescriptor { - return file_proto_service_proto_enumTypes[1].Descriptor() + return file_proto_service_proto_enumTypes[3].Descriptor() } func (Opcode) Type() protoreflect.EnumType { - return &file_proto_service_proto_enumTypes[1] + return &file_proto_service_proto_enumTypes[3] } func (x Opcode) Number() protoreflect.EnumNumber { @@ -118,7 +215,7 @@ func (x Opcode) Number() protoreflect.EnumNumber { // Deprecated: Use Opcode.Descriptor instead. func (Opcode) EnumDescriptor() ([]byte, []int) { - return file_proto_service_proto_rawDescGZIP(), []int{1} + return file_proto_service_proto_rawDescGZIP(), []int{3} } type LogsStreamRequest struct { @@ -436,6 +533,156 @@ func (x *ExecuteRequest) GetMessageId() string { return "" } +type TestWorkflowNotificationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + ExecutionId string `protobuf:"bytes,2,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` + RequestType TestWorkflowNotificationsRequestType `protobuf:"varint,3,opt,name=request_type,json=requestType,proto3,enum=cloud.TestWorkflowNotificationsRequestType" json:"request_type,omitempty"` +} + +func (x *TestWorkflowNotificationsRequest) Reset() { + *x = TestWorkflowNotificationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proto_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TestWorkflowNotificationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestWorkflowNotificationsRequest) ProtoMessage() {} + +func (x *TestWorkflowNotificationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_service_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestWorkflowNotificationsRequest.ProtoReflect.Descriptor instead. +func (*TestWorkflowNotificationsRequest) Descriptor() ([]byte, []int) { + return file_proto_service_proto_rawDescGZIP(), []int{5} +} + +func (x *TestWorkflowNotificationsRequest) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *TestWorkflowNotificationsRequest) GetExecutionId() string { + if x != nil { + return x.ExecutionId + } + return "" +} + +func (x *TestWorkflowNotificationsRequest) GetRequestType() TestWorkflowNotificationsRequestType { + if x != nil { + return x.RequestType + } + return TestWorkflowNotificationsRequestType_WORKFLOW_STREAM_LOG_MESSAGE +} + +type TestWorkflowNotificationsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + SeqNo uint32 `protobuf:"varint,2,opt,name=seq_no,json=seqNo,proto3" json:"seq_no,omitempty"` + Timestamp string `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + Ref string `protobuf:"bytes,4,opt,name=ref,proto3" json:"ref,omitempty"` + Type TestWorkflowNotificationType `protobuf:"varint,5,opt,name=type,proto3,enum=cloud.TestWorkflowNotificationType" json:"type,omitempty"` + Message string `protobuf:"bytes,6,opt,name=message,proto3" json:"message,omitempty"` // based on type: log/error = inline, others = serialized to JSON +} + +func (x *TestWorkflowNotificationsResponse) Reset() { + *x = TestWorkflowNotificationsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proto_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TestWorkflowNotificationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestWorkflowNotificationsResponse) ProtoMessage() {} + +func (x *TestWorkflowNotificationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_service_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestWorkflowNotificationsResponse.ProtoReflect.Descriptor instead. +func (*TestWorkflowNotificationsResponse) Descriptor() ([]byte, []int) { + return file_proto_service_proto_rawDescGZIP(), []int{6} +} + +func (x *TestWorkflowNotificationsResponse) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *TestWorkflowNotificationsResponse) GetSeqNo() uint32 { + if x != nil { + return x.SeqNo + } + return 0 +} + +func (x *TestWorkflowNotificationsResponse) GetTimestamp() string { + if x != nil { + return x.Timestamp + } + return "" +} + +func (x *TestWorkflowNotificationsResponse) GetRef() string { + if x != nil { + return x.Ref + } + return "" +} + +func (x *TestWorkflowNotificationsResponse) GetType() TestWorkflowNotificationType { + if x != nil { + return x.Type + } + return TestWorkflowNotificationType_WORKFLOW_STREAM_ERROR +} + +func (x *TestWorkflowNotificationsResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + type HeaderValue struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -447,7 +694,7 @@ type HeaderValue struct { func (x *HeaderValue) Reset() { *x = HeaderValue{} if protoimpl.UnsafeEnabled { - mi := &file_proto_service_proto_msgTypes[5] + mi := &file_proto_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -460,7 +707,7 @@ func (x *HeaderValue) String() string { func (*HeaderValue) ProtoMessage() {} func (x *HeaderValue) ProtoReflect() protoreflect.Message { - mi := &file_proto_service_proto_msgTypes[5] + mi := &file_proto_service_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -473,7 +720,7 @@ func (x *HeaderValue) ProtoReflect() protoreflect.Message { // Deprecated: Use HeaderValue.ProtoReflect.Descriptor instead. func (*HeaderValue) Descriptor() ([]byte, []int) { - return file_proto_service_proto_rawDescGZIP(), []int{5} + return file_proto_service_proto_rawDescGZIP(), []int{7} } func (x *HeaderValue) GetHeader() []string { @@ -497,7 +744,7 @@ type ExecuteResponse struct { func (x *ExecuteResponse) Reset() { *x = ExecuteResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proto_service_proto_msgTypes[6] + mi := &file_proto_service_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -510,7 +757,7 @@ func (x *ExecuteResponse) String() string { func (*ExecuteResponse) ProtoMessage() {} func (x *ExecuteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_service_proto_msgTypes[6] + mi := &file_proto_service_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -523,7 +770,7 @@ func (x *ExecuteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecuteResponse.ProtoReflect.Descriptor instead. func (*ExecuteResponse) Descriptor() ([]byte, []int) { - return file_proto_service_proto_rawDescGZIP(), []int{6} + return file_proto_service_proto_rawDescGZIP(), []int{8} } func (x *ExecuteResponse) GetStatus() int64 { @@ -566,7 +813,7 @@ type WebsocketData struct { func (x *WebsocketData) Reset() { *x = WebsocketData{} if protoimpl.UnsafeEnabled { - mi := &file_proto_service_proto_msgTypes[7] + mi := &file_proto_service_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -579,7 +826,7 @@ func (x *WebsocketData) String() string { func (*WebsocketData) ProtoMessage() {} func (x *WebsocketData) ProtoReflect() protoreflect.Message { - mi := &file_proto_service_proto_msgTypes[7] + mi := &file_proto_service_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -592,7 +839,7 @@ func (x *WebsocketData) ProtoReflect() protoreflect.Message { // Deprecated: Use WebsocketData.ProtoReflect.Descriptor instead. func (*WebsocketData) Descriptor() ([]byte, []int) { - return file_proto_service_proto_rawDescGZIP(), []int{7} + return file_proto_service_proto_rawDescGZIP(), []int{9} } func (x *WebsocketData) GetOpcode() Opcode { @@ -660,60 +907,109 @@ var file_proto_service_proto_rawDesc = []byte{ 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x25, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x22, 0xeb, 0x01, 0x0a, 0x0f, 0x45, 0x78, 0x65, - 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x3d, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, - 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x48, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x68, 0x65, 0x61, 0x64, - 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x49, 0x64, 0x1a, 0x4e, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x4a, 0x0a, 0x0d, 0x57, 0x65, 0x62, 0x73, 0x6f, 0x63, - 0x6b, 0x65, 0x74, 0x44, 0x61, 0x74, 0x61, 0x12, 0x25, 0x0a, 0x06, 0x6f, 0x70, 0x63, 0x6f, 0x64, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x4f, 0x70, 0x63, 0x6f, 0x64, 0x65, 0x52, 0x06, 0x6f, 0x70, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x12, - 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, - 0x64, 0x79, 0x2a, 0x48, 0x0a, 0x15, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x53, - 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4c, 0x4f, 0x47, 0x5f, 0x4d, 0x45, 0x53, 0x53, 0x41, 0x47, - 0x45, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x48, 0x45, - 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x10, 0x01, 0x2a, 0x4c, 0x0a, 0x06, - 0x4f, 0x70, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x0e, 0x0a, 0x0a, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x45, 0x58, 0x54, 0x5f, 0x46, - 0x52, 0x41, 0x4d, 0x45, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, - 0x5f, 0x46, 0x52, 0x41, 0x4d, 0x45, 0x10, 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x48, 0x45, 0x41, 0x4c, - 0x54, 0x48, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x10, 0x03, 0x32, 0xcc, 0x02, 0x0a, 0x10, 0x54, - 0x65, 0x73, 0x74, 0x4b, 0x75, 0x62, 0x65, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x41, 0x50, 0x49, 0x12, - 0x3c, 0x0a, 0x07, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x12, 0x16, 0x2e, 0x63, 0x6c, 0x6f, - 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x1a, 0x15, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, - 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, 0x36, 0x0a, - 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x14, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x57, 0x65, - 0x62, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x61, 0x74, 0x61, 0x1a, 0x16, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x28, 0x01, 0x12, 0x35, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x15, 0x2e, - 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x0c, - 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x41, 0x73, 0x79, 0x6e, 0x63, 0x12, 0x16, 0x2e, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x15, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, - 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, - 0x48, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x12, 0x19, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, - 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x18, 0x2e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, 0x42, 0x0b, 0x5a, 0x09, 0x70, 0x6b, 0x67, - 0x2f, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x01, 0x22, 0xb2, 0x01, 0x0a, 0x20, 0x54, 0x65, 0x73, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x65, 0x78, 0x65, 0x63, 0x75, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x4e, 0x0a, 0x0c, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, + 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0b, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x22, 0xda, 0x01, 0x0a, 0x21, 0x54, 0x65, 0x73, 0x74, 0x57, + 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, + 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x73, 0x65, 0x71, + 0x5f, 0x6e, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x65, 0x71, 0x4e, 0x6f, + 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x10, + 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x72, 0x65, 0x66, + 0x12, 0x37, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, + 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, + 0x6c, 0x6f, 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x25, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x22, 0xeb, 0x01, 0x0a, 0x0f, 0x45, + 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3d, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x68, 0x65, + 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x49, 0x64, 0x1a, 0x4e, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x4a, 0x0a, 0x0d, 0x57, 0x65, 0x62, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x44, 0x61, 0x74, 0x61, 0x12, 0x25, 0x0a, 0x06, 0x6f, 0x70, 0x63, + 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x4f, 0x70, 0x63, 0x6f, 0x64, 0x65, 0x52, 0x06, 0x6f, 0x70, 0x63, 0x6f, 0x64, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, + 0x62, 0x6f, 0x64, 0x79, 0x2a, 0x48, 0x0a, 0x15, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, + 0x61, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, + 0x12, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4c, 0x4f, 0x47, 0x5f, 0x4d, 0x45, 0x53, 0x53, + 0x41, 0x47, 0x45, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, + 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x10, 0x01, 0x2a, 0x69, + 0x0a, 0x24, 0x54, 0x65, 0x73, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4e, 0x6f, + 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1f, 0x0a, 0x1b, 0x57, 0x4f, 0x52, 0x4b, 0x46, 0x4c, + 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4c, 0x4f, 0x47, 0x5f, 0x4d, 0x45, + 0x53, 0x53, 0x41, 0x47, 0x45, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x57, 0x4f, 0x52, 0x4b, 0x46, + 0x4c, 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, + 0x48, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x10, 0x01, 0x2a, 0x8a, 0x01, 0x0a, 0x1c, 0x54, 0x65, + 0x73, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x15, 0x57, 0x4f, + 0x52, 0x4b, 0x46, 0x4c, 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x45, 0x52, + 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x57, 0x4f, 0x52, 0x4b, 0x46, 0x4c, 0x4f, + 0x57, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4c, 0x4f, 0x47, 0x10, 0x01, 0x12, 0x1a, + 0x0a, 0x16, 0x57, 0x4f, 0x52, 0x4b, 0x46, 0x4c, 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, + 0x4d, 0x5f, 0x52, 0x45, 0x53, 0x55, 0x4c, 0x54, 0x10, 0x02, 0x12, 0x1a, 0x0a, 0x16, 0x57, 0x4f, + 0x52, 0x4b, 0x46, 0x4c, 0x4f, 0x57, 0x5f, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4f, 0x55, + 0x54, 0x50, 0x55, 0x54, 0x10, 0x03, 0x2a, 0x4c, 0x0a, 0x06, 0x4f, 0x70, 0x63, 0x6f, 0x64, 0x65, + 0x12, 0x0e, 0x0a, 0x0a, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, + 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x45, 0x58, 0x54, 0x5f, 0x46, 0x52, 0x41, 0x4d, 0x45, 0x10, 0x01, + 0x12, 0x10, 0x0a, 0x0c, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x5f, 0x46, 0x52, 0x41, 0x4d, 0x45, + 0x10, 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x43, 0x48, 0x45, + 0x43, 0x4b, 0x10, 0x03, 0x32, 0xc9, 0x03, 0x0a, 0x10, 0x54, 0x65, 0x73, 0x74, 0x4b, 0x75, 0x62, + 0x65, 0x43, 0x6c, 0x6f, 0x75, 0x64, 0x41, 0x50, 0x49, 0x12, 0x3c, 0x0a, 0x07, 0x45, 0x78, 0x65, + 0x63, 0x75, 0x74, 0x65, 0x12, 0x16, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, + 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x15, 0x2e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, 0x36, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, + 0x14, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x57, 0x65, 0x62, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x44, 0x61, 0x74, 0x61, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x28, 0x01, 0x12, + 0x35, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x15, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, + 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x0c, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, + 0x65, 0x41, 0x73, 0x79, 0x6e, 0x63, 0x12, 0x16, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, + 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x15, + 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, 0x48, 0x0a, 0x0d, 0x47, 0x65, 0x74, + 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x19, 0x2e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x18, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x4c, 0x6f, + 0x67, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x28, + 0x01, 0x30, 0x01, 0x12, 0x7b, 0x0a, 0x22, 0x47, 0x65, 0x74, 0x54, 0x65, 0x73, 0x74, 0x57, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x28, 0x2e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4e, 0x6f, + 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x1a, 0x27, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x54, 0x65, 0x73, 0x74, + 0x57, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, + 0x42, 0x0b, 0x5a, 0x09, 0x70, 0x6b, 0x67, 0x2f, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -728,47 +1024,55 @@ func file_proto_service_proto_rawDescGZIP() []byte { return file_proto_service_proto_rawDescData } -var file_proto_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proto_service_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_proto_service_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_proto_service_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_proto_service_proto_goTypes = []interface{}{ - (LogsStreamRequestType)(0), // 0: cloud.LogsStreamRequestType - (Opcode)(0), // 1: cloud.Opcode - (*LogsStreamRequest)(nil), // 2: cloud.LogsStreamRequest - (*LogsStreamResponse)(nil), // 3: cloud.LogsStreamResponse - (*CommandRequest)(nil), // 4: cloud.CommandRequest - (*CommandResponse)(nil), // 5: cloud.CommandResponse - (*ExecuteRequest)(nil), // 6: cloud.ExecuteRequest - (*HeaderValue)(nil), // 7: cloud.HeaderValue - (*ExecuteResponse)(nil), // 8: cloud.ExecuteResponse - (*WebsocketData)(nil), // 9: cloud.WebsocketData - nil, // 10: cloud.ExecuteRequest.HeadersEntry - nil, // 11: cloud.ExecuteResponse.HeadersEntry - (*structpb.Struct)(nil), // 12: google.protobuf.Struct - (*emptypb.Empty)(nil), // 13: google.protobuf.Empty + (LogsStreamRequestType)(0), // 0: cloud.LogsStreamRequestType + (TestWorkflowNotificationsRequestType)(0), // 1: cloud.TestWorkflowNotificationsRequestType + (TestWorkflowNotificationType)(0), // 2: cloud.TestWorkflowNotificationType + (Opcode)(0), // 3: cloud.Opcode + (*LogsStreamRequest)(nil), // 4: cloud.LogsStreamRequest + (*LogsStreamResponse)(nil), // 5: cloud.LogsStreamResponse + (*CommandRequest)(nil), // 6: cloud.CommandRequest + (*CommandResponse)(nil), // 7: cloud.CommandResponse + (*ExecuteRequest)(nil), // 8: cloud.ExecuteRequest + (*TestWorkflowNotificationsRequest)(nil), // 9: cloud.TestWorkflowNotificationsRequest + (*TestWorkflowNotificationsResponse)(nil), // 10: cloud.TestWorkflowNotificationsResponse + (*HeaderValue)(nil), // 11: cloud.HeaderValue + (*ExecuteResponse)(nil), // 12: cloud.ExecuteResponse + (*WebsocketData)(nil), // 13: cloud.WebsocketData + nil, // 14: cloud.ExecuteRequest.HeadersEntry + nil, // 15: cloud.ExecuteResponse.HeadersEntry + (*structpb.Struct)(nil), // 16: google.protobuf.Struct + (*emptypb.Empty)(nil), // 17: google.protobuf.Empty } var file_proto_service_proto_depIdxs = []int32{ 0, // 0: cloud.LogsStreamRequest.request_type:type_name -> cloud.LogsStreamRequestType - 12, // 1: cloud.CommandRequest.payload:type_name -> google.protobuf.Struct - 10, // 2: cloud.ExecuteRequest.headers:type_name -> cloud.ExecuteRequest.HeadersEntry - 11, // 3: cloud.ExecuteResponse.headers:type_name -> cloud.ExecuteResponse.HeadersEntry - 1, // 4: cloud.WebsocketData.opcode:type_name -> cloud.Opcode - 7, // 5: cloud.ExecuteRequest.HeadersEntry.value:type_name -> cloud.HeaderValue - 7, // 6: cloud.ExecuteResponse.HeadersEntry.value:type_name -> cloud.HeaderValue - 8, // 7: cloud.TestKubeCloudAPI.Execute:input_type -> cloud.ExecuteResponse - 9, // 8: cloud.TestKubeCloudAPI.Send:input_type -> cloud.WebsocketData - 4, // 9: cloud.TestKubeCloudAPI.Call:input_type -> cloud.CommandRequest - 8, // 10: cloud.TestKubeCloudAPI.ExecuteAsync:input_type -> cloud.ExecuteResponse - 3, // 11: cloud.TestKubeCloudAPI.GetLogsStream:input_type -> cloud.LogsStreamResponse - 6, // 12: cloud.TestKubeCloudAPI.Execute:output_type -> cloud.ExecuteRequest - 13, // 13: cloud.TestKubeCloudAPI.Send:output_type -> google.protobuf.Empty - 5, // 14: cloud.TestKubeCloudAPI.Call:output_type -> cloud.CommandResponse - 6, // 15: cloud.TestKubeCloudAPI.ExecuteAsync:output_type -> cloud.ExecuteRequest - 2, // 16: cloud.TestKubeCloudAPI.GetLogsStream:output_type -> cloud.LogsStreamRequest - 12, // [12:17] is the sub-list for method output_type - 7, // [7:12] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 16, // 1: cloud.CommandRequest.payload:type_name -> google.protobuf.Struct + 14, // 2: cloud.ExecuteRequest.headers:type_name -> cloud.ExecuteRequest.HeadersEntry + 1, // 3: cloud.TestWorkflowNotificationsRequest.request_type:type_name -> cloud.TestWorkflowNotificationsRequestType + 2, // 4: cloud.TestWorkflowNotificationsResponse.type:type_name -> cloud.TestWorkflowNotificationType + 15, // 5: cloud.ExecuteResponse.headers:type_name -> cloud.ExecuteResponse.HeadersEntry + 3, // 6: cloud.WebsocketData.opcode:type_name -> cloud.Opcode + 11, // 7: cloud.ExecuteRequest.HeadersEntry.value:type_name -> cloud.HeaderValue + 11, // 8: cloud.ExecuteResponse.HeadersEntry.value:type_name -> cloud.HeaderValue + 12, // 9: cloud.TestKubeCloudAPI.Execute:input_type -> cloud.ExecuteResponse + 13, // 10: cloud.TestKubeCloudAPI.Send:input_type -> cloud.WebsocketData + 6, // 11: cloud.TestKubeCloudAPI.Call:input_type -> cloud.CommandRequest + 12, // 12: cloud.TestKubeCloudAPI.ExecuteAsync:input_type -> cloud.ExecuteResponse + 5, // 13: cloud.TestKubeCloudAPI.GetLogsStream:input_type -> cloud.LogsStreamResponse + 10, // 14: cloud.TestKubeCloudAPI.GetTestWorkflowNotificationsStream:input_type -> cloud.TestWorkflowNotificationsResponse + 8, // 15: cloud.TestKubeCloudAPI.Execute:output_type -> cloud.ExecuteRequest + 17, // 16: cloud.TestKubeCloudAPI.Send:output_type -> google.protobuf.Empty + 7, // 17: cloud.TestKubeCloudAPI.Call:output_type -> cloud.CommandResponse + 8, // 18: cloud.TestKubeCloudAPI.ExecuteAsync:output_type -> cloud.ExecuteRequest + 4, // 19: cloud.TestKubeCloudAPI.GetLogsStream:output_type -> cloud.LogsStreamRequest + 9, // 20: cloud.TestKubeCloudAPI.GetTestWorkflowNotificationsStream:output_type -> cloud.TestWorkflowNotificationsRequest + 15, // [15:21] is the sub-list for method output_type + 9, // [9:15] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_proto_service_proto_init() } @@ -838,7 +1142,7 @@ func file_proto_service_proto_init() { } } file_proto_service_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*HeaderValue); i { + switch v := v.(*TestWorkflowNotificationsRequest); i { case 0: return &v.state case 1: @@ -850,7 +1154,7 @@ func file_proto_service_proto_init() { } } file_proto_service_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExecuteResponse); i { + switch v := v.(*TestWorkflowNotificationsResponse); i { case 0: return &v.state case 1: @@ -862,6 +1166,30 @@ func file_proto_service_proto_init() { } } file_proto_service_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HeaderValue); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proto_service_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ExecuteResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proto_service_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*WebsocketData); i { case 0: return &v.state @@ -879,8 +1207,8 @@ func file_proto_service_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proto_service_proto_rawDesc, - NumEnums: 2, - NumMessages: 10, + NumEnums: 4, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/cloud/service_grpc.pb.go b/pkg/cloud/service_grpc.pb.go index 18f07f2e7f9..5ced9df6368 100644 --- a/pkg/cloud/service_grpc.pb.go +++ b/pkg/cloud/service_grpc.pb.go @@ -1,10 +1,13 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.19.4 +// source: proto/service.proto package cloud import ( context "context" - grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" @@ -27,6 +30,7 @@ type TestKubeCloudAPIClient interface { Call(ctx context.Context, in *CommandRequest, opts ...grpc.CallOption) (*CommandResponse, error) ExecuteAsync(ctx context.Context, opts ...grpc.CallOption) (TestKubeCloudAPI_ExecuteAsyncClient, error) GetLogsStream(ctx context.Context, opts ...grpc.CallOption) (TestKubeCloudAPI_GetLogsStreamClient, error) + GetTestWorkflowNotificationsStream(ctx context.Context, opts ...grpc.CallOption) (TestKubeCloudAPI_GetTestWorkflowNotificationsStreamClient, error) } type testKubeCloudAPIClient struct { @@ -173,6 +177,37 @@ func (x *testKubeCloudAPIGetLogsStreamClient) Recv() (*LogsStreamRequest, error) return m, nil } +func (c *testKubeCloudAPIClient) GetTestWorkflowNotificationsStream(ctx context.Context, opts ...grpc.CallOption) (TestKubeCloudAPI_GetTestWorkflowNotificationsStreamClient, error) { + stream, err := c.cc.NewStream(ctx, &TestKubeCloudAPI_ServiceDesc.Streams[4], "/cloud.TestKubeCloudAPI/GetTestWorkflowNotificationsStream", opts...) + if err != nil { + return nil, err + } + x := &testKubeCloudAPIGetTestWorkflowNotificationsStreamClient{stream} + return x, nil +} + +type TestKubeCloudAPI_GetTestWorkflowNotificationsStreamClient interface { + Send(*TestWorkflowNotificationsResponse) error + Recv() (*TestWorkflowNotificationsRequest, error) + grpc.ClientStream +} + +type testKubeCloudAPIGetTestWorkflowNotificationsStreamClient struct { + grpc.ClientStream +} + +func (x *testKubeCloudAPIGetTestWorkflowNotificationsStreamClient) Send(m *TestWorkflowNotificationsResponse) error { + return x.ClientStream.SendMsg(m) +} + +func (x *testKubeCloudAPIGetTestWorkflowNotificationsStreamClient) Recv() (*TestWorkflowNotificationsRequest, error) { + m := new(TestWorkflowNotificationsRequest) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // TestKubeCloudAPIServer is the server API for TestKubeCloudAPI service. // All implementations must embed UnimplementedTestKubeCloudAPIServer // for forward compatibility @@ -184,6 +219,7 @@ type TestKubeCloudAPIServer interface { Call(context.Context, *CommandRequest) (*CommandResponse, error) ExecuteAsync(TestKubeCloudAPI_ExecuteAsyncServer) error GetLogsStream(TestKubeCloudAPI_GetLogsStreamServer) error + GetTestWorkflowNotificationsStream(TestKubeCloudAPI_GetTestWorkflowNotificationsStreamServer) error mustEmbedUnimplementedTestKubeCloudAPIServer() } @@ -206,6 +242,9 @@ func (UnimplementedTestKubeCloudAPIServer) ExecuteAsync(TestKubeCloudAPI_Execute func (UnimplementedTestKubeCloudAPIServer) GetLogsStream(TestKubeCloudAPI_GetLogsStreamServer) error { return status.Errorf(codes.Unimplemented, "method GetLogsStream not implemented") } +func (UnimplementedTestKubeCloudAPIServer) GetTestWorkflowNotificationsStream(TestKubeCloudAPI_GetTestWorkflowNotificationsStreamServer) error { + return status.Errorf(codes.Unimplemented, "method GetTestWorkflowNotificationsStream not implemented") +} func (UnimplementedTestKubeCloudAPIServer) mustEmbedUnimplementedTestKubeCloudAPIServer() {} // UnsafeTestKubeCloudAPIServer may be embedded to opt out of forward compatibility for this service. @@ -341,6 +380,32 @@ func (x *testKubeCloudAPIGetLogsStreamServer) Recv() (*LogsStreamResponse, error return m, nil } +func _TestKubeCloudAPI_GetTestWorkflowNotificationsStream_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(TestKubeCloudAPIServer).GetTestWorkflowNotificationsStream(&testKubeCloudAPIGetTestWorkflowNotificationsStreamServer{stream}) +} + +type TestKubeCloudAPI_GetTestWorkflowNotificationsStreamServer interface { + Send(*TestWorkflowNotificationsRequest) error + Recv() (*TestWorkflowNotificationsResponse, error) + grpc.ServerStream +} + +type testKubeCloudAPIGetTestWorkflowNotificationsStreamServer struct { + grpc.ServerStream +} + +func (x *testKubeCloudAPIGetTestWorkflowNotificationsStreamServer) Send(m *TestWorkflowNotificationsRequest) error { + return x.ServerStream.SendMsg(m) +} + +func (x *testKubeCloudAPIGetTestWorkflowNotificationsStreamServer) Recv() (*TestWorkflowNotificationsResponse, error) { + m := new(TestWorkflowNotificationsResponse) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // TestKubeCloudAPI_ServiceDesc is the grpc.ServiceDesc for TestKubeCloudAPI service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -377,6 +442,12 @@ var TestKubeCloudAPI_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, ClientStreams: true, }, + { + StreamName: "GetTestWorkflowNotificationsStream", + Handler: _TestKubeCloudAPI_GetTestWorkflowNotificationsStream_Handler, + ServerStreams: true, + ClientStreams: true, + }, }, Metadata: "proto/service.proto", } diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index a9c88528b62..ba62f3888ad 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -20,6 +20,7 @@ import ( testworkflowsv1 "github.com/kubeshop/testkube-operator/pkg/client/testworkflows/v1" apiv1 "github.com/kubeshop/testkube/internal/app/api/v1" "github.com/kubeshop/testkube/internal/config" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/imageinspector" "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowexecutor" @@ -39,6 +40,7 @@ type apiTCL struct { type ApiTCL interface { AppendRoutes() + GetTestWorkflowNotificationsStream(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error) } func NewApiTCL( diff --git a/pkg/tcl/apitcl/v1/testworkflowexecutions.go b/pkg/tcl/apitcl/v1/testworkflowexecutions.go index a3a7515911f..837b8448ba8 100644 --- a/pkg/tcl/apitcl/v1/testworkflowexecutions.go +++ b/pkg/tcl/apitcl/v1/testworkflowexecutions.go @@ -358,6 +358,32 @@ func (s *apiTCL) GetTestWorkflowArtifactArchiveHandler() fiber.Handler { } } +func (s *apiTCL) GetTestWorkflowNotificationsStream(ctx context.Context, executionID string) (chan testkube.TestWorkflowExecutionNotification, error) { + // Load the execution + execution, err := s.TestWorkflowResults.Get(ctx, executionID) + if err != nil { + return nil, err + } + + // Check for the logs + ctrl, err := testworkflowcontroller.New(ctx, s.Clientset, s.Namespace, execution.Id, execution.ScheduledAt) + if err != nil { + return nil, err + } + + // Stream the notifications + ch := make(chan testkube.TestWorkflowExecutionNotification) + go func() { + for n := range ctrl.Watch(ctx).Stream(ctx).Channel() { + if n.Error == nil { + ch <- n.Value.ToInternal() + } + } + close(ch) + }() + return ch, nil +} + func getWorkflowExecutionsFilterFromRequest(c *fiber.Ctx) testworkflow.Filter { filter := testworkflow.NewExecutionsFilter() name := c.Params("id", "") diff --git a/pkg/tcl/cloudtcl/data/testworkflow/commands.go b/pkg/tcl/cloudtcl/data/testworkflow/commands.go new file mode 100644 index 00000000000..548c5d866cb --- /dev/null +++ b/pkg/tcl/cloudtcl/data/testworkflow/commands.go @@ -0,0 +1,79 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import "github.com/kubeshop/testkube/pkg/cloud/data/executor" + +const ( + CmdTestWorkflowExecutionGet executor.Command = "workflow_execution_get" + CmdTestWorkflowExecutionGetByNameAndWorkflow executor.Command = "workflow_execution_get_by_name_and_workflow" + CmdTestWorkflowExecutionGetLatestByWorkflow executor.Command = "workflow_execution_get_latest_by_workflow" + CmdTestWorkflowExecutionGetRunning executor.Command = "workflow_execution_get_running" + CmdTestWorkflowExecutionGetLatestByWorkflows executor.Command = "workflow_execution_get_latest_by_workflows" + CmdTestWorkflowExecutionGetExecutionTotals executor.Command = "workflow_execution_get_execution_totals" + CmdTestWorkflowExecutionGetExecutions executor.Command = "workflow_execution_get_executions" + CmdTestWorkflowExecutionGetExecutionsSummary executor.Command = "workflow_execution_get_executions_summary" + CmdTestWorkflowExecutionInsert executor.Command = "workflow_execution_insert" + CmdTestWorkflowExecutionUpdate executor.Command = "workflow_execution_update" + CmdTestWorkflowExecutionUpdateResult executor.Command = "workflow_execution_update_result" + CmdTestWorkflowExecutionUpdateOutput executor.Command = "workflow_execution_update_output" + CmdTestWorkflowExecutionDeleteByWorkflow executor.Command = "workflow_execution_delete_by_workflow" + CmdTestWorkflowExecutionDeleteAll executor.Command = "workflow_execution_delete_all" + CmdTestWorkflowExecutionDeleteByWorkflows executor.Command = "workflow_execution_delete_by_workflows" + CmdTestWorkflowExecutionGetWorkflowMetrics executor.Command = "workflow_execution_get_workflow_metrics" + + CmdTestWorkflowOutputPresignSaveLog executor.Command = "workflow_output_presign_save_log" + CmdTestWorkflowOutputPresignReadLog executor.Command = "workflow_output_presign_read_log" + CmdTestWorkflowOutputHasLog executor.Command = "workflow_output_has_log" +) + +func command(v interface{}) executor.Command { + switch v.(type) { + case ExecutionGetRequest: + return CmdTestWorkflowExecutionGet + case ExecutionGetByNameAndWorkflowRequest: + return CmdTestWorkflowExecutionGetByNameAndWorkflow + case ExecutionGetLatestByWorkflowRequest: + return CmdTestWorkflowExecutionGetLatestByWorkflow + case ExecutionGetRunningRequest: + return CmdTestWorkflowExecutionGetRunning + case ExecutionGetLatestByWorkflowsRequest: + return CmdTestWorkflowExecutionGetLatestByWorkflows + case ExecutionGetExecutionTotalsRequest: + return CmdTestWorkflowExecutionGetExecutionTotals + case ExecutionGetExecutionsRequest: + return CmdTestWorkflowExecutionGetExecutions + case ExecutionGetExecutionsSummaryRequest: + return CmdTestWorkflowExecutionGetExecutionsSummary + case ExecutionInsertRequest: + return CmdTestWorkflowExecutionInsert + case ExecutionUpdateRequest: + return CmdTestWorkflowExecutionUpdate + case ExecutionUpdateResultRequest: + return CmdTestWorkflowExecutionUpdateResult + case ExecutionUpdateOutputRequest: + return CmdTestWorkflowExecutionUpdateOutput + case ExecutionDeleteByWorkflowRequest: + return CmdTestWorkflowExecutionDeleteByWorkflow + case ExecutionDeleteAllRequest: + return CmdTestWorkflowExecutionDeleteAll + case ExecutionDeleteByWorkflowsRequest: + return CmdTestWorkflowExecutionDeleteByWorkflows + case ExecutionGetWorkflowMetricsRequest: + return CmdTestWorkflowExecutionGetWorkflowMetrics + + case OutputPresignSaveLogRequest: + return CmdTestWorkflowOutputPresignSaveLog + case OutputPresignReadLogRequest: + return CmdTestWorkflowOutputPresignReadLog + case OutputHasLogRequest: + return CmdTestWorkflowOutputHasLog + } + panic("unknown test workflows Cloud request") +} diff --git a/pkg/tcl/cloudtcl/data/testworkflow/execution.go b/pkg/tcl/cloudtcl/data/testworkflow/execution.go new file mode 100644 index 00000000000..a960a7d18f3 --- /dev/null +++ b/pkg/tcl/cloudtcl/data/testworkflow/execution.go @@ -0,0 +1,142 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "context" + + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" + + "google.golang.org/grpc" + + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/cloud" + "github.com/kubeshop/testkube/pkg/cloud/data/executor" +) + +var _ testworkflow.Repository = (*CloudRepository)(nil) + +type CloudRepository struct { + executor executor.Executor +} + +func NewCloudRepository(client cloud.TestKubeCloudAPIClient, grpcConn *grpc.ClientConn, apiKey string) *CloudRepository { + return &CloudRepository{executor: executor.NewCloudGRPCExecutor(client, grpcConn, apiKey)} +} + +func (r *CloudRepository) Get(ctx context.Context, id string) (testkube.TestWorkflowExecution, error) { + req := ExecutionGetRequest{ID: id} + process := func(v ExecutionGetResponse) testkube.TestWorkflowExecution { + return v.WorkflowExecution + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetByNameAndTestWorkflow(ctx context.Context, name, workflowName string) (result testkube.TestWorkflowExecution, err error) { + req := ExecutionGetByNameAndWorkflowRequest{Name: name, WorkflowName: workflowName} + process := func(v ExecutionGetResponse) testkube.TestWorkflowExecution { + return v.WorkflowExecution + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetLatestByTestWorkflow(ctx context.Context, workflowName string) (*testkube.TestWorkflowExecution, error) { + req := ExecutionGetLatestByWorkflowRequest{WorkflowName: workflowName} + process := func(v ExecutionGetLatestByWorkflowResponse) *testkube.TestWorkflowExecution { + return v.WorkflowExecution + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetLatestByTestWorkflows(ctx context.Context, workflowNames []string) (executions []testkube.TestWorkflowExecutionSummary, err error) { + req := ExecutionGetLatestByWorkflowsRequest{WorkflowNames: workflowNames} + process := func(v ExecutionGetLatestByWorkflowsResponse) []testkube.TestWorkflowExecutionSummary { + return v.WorkflowExecutions + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetRunning(ctx context.Context) (result []testkube.TestWorkflowExecution, err error) { + req := ExecutionGetRunningRequest{} + process := func(v ExecutionGetRunningResponse) []testkube.TestWorkflowExecution { + return v.WorkflowExecutions + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetExecutionsTotals(ctx context.Context, filter ...testworkflow.Filter) (totals testkube.ExecutionsTotals, err error) { + req := ExecutionGetExecutionTotalsRequest{Filter: mapFilters(filter)} + process := func(v ExecutionGetExecutionTotalsResponse) testkube.ExecutionsTotals { + return v.Totals + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetExecutions(ctx context.Context, filter testworkflow.Filter) (result []testkube.TestWorkflowExecution, err error) { + req := ExecutionGetExecutionsRequest{Filter: filter.(*testworkflow.FilterImpl)} + process := func(v ExecutionGetExecutionsResponse) []testkube.TestWorkflowExecution { + return v.WorkflowExecutions + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) GetExecutionsSummary(ctx context.Context, filter testworkflow.Filter) (result []testkube.TestWorkflowExecutionSummary, err error) { + req := ExecutionGetExecutionsSummaryRequest{Filter: filter.(*testworkflow.FilterImpl)} + process := func(v ExecutionGetExecutionsSummaryResponse) []testkube.TestWorkflowExecutionSummary { + return v.WorkflowExecutions + } + return pass(r.executor, ctx, req, process) +} + +func (r *CloudRepository) Insert(ctx context.Context, result testkube.TestWorkflowExecution) (err error) { + req := ExecutionInsertRequest{WorkflowExecution: result} + return passNoContent(r.executor, ctx, req) +} + +func (r *CloudRepository) Update(ctx context.Context, result testkube.TestWorkflowExecution) (err error) { + req := ExecutionUpdateRequest{WorkflowExecution: result} + return passNoContent(r.executor, ctx, req) +} + +func (r *CloudRepository) UpdateResult(ctx context.Context, id string, result *testkube.TestWorkflowResult) (err error) { + req := ExecutionUpdateResultRequest{ID: id, Result: result} + return passNoContent(r.executor, ctx, req) +} + +func (r *CloudRepository) UpdateOutput(ctx context.Context, id string, output []testkube.TestWorkflowOutput) (err error) { + req := ExecutionUpdateOutputRequest{ID: id, Output: output} + return passNoContent(r.executor, ctx, req) +} + +// DeleteByTestWorkflow deletes execution results by workflow +func (r *CloudRepository) DeleteByTestWorkflow(ctx context.Context, workflowName string) (err error) { + req := ExecutionDeleteByWorkflowRequest{WorkflowName: workflowName} + return passNoContent(r.executor, ctx, req) +} + +// DeleteAll deletes all execution results +func (r *CloudRepository) DeleteAll(ctx context.Context) (err error) { + req := ExecutionDeleteAllRequest{} + return passNoContent(r.executor, ctx, req) +} + +// DeleteByTestWorkflows deletes execution results by workflows +func (r *CloudRepository) DeleteByTestWorkflows(ctx context.Context, workflowNames []string) (err error) { + req := ExecutionDeleteByWorkflowsRequest{WorkflowNames: workflowNames} + return passNoContent(r.executor, ctx, req) +} + +// GetTestWorkflowMetrics returns test executions metrics +func (r *CloudRepository) GetTestWorkflowMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) { + req := ExecutionGetWorkflowMetricsRequest{Name: name, Limit: limit, Last: last} + process := func(v ExecutionGetWorkflowMetricsResponse) testkube.ExecutionsMetrics { + return v.Metrics + } + return pass(r.executor, ctx, req, process) +} diff --git a/pkg/tcl/cloudtcl/data/testworkflow/execution_models.go b/pkg/tcl/cloudtcl/data/testworkflow/execution_models.go new file mode 100644 index 00000000000..cbf41943f67 --- /dev/null +++ b/pkg/tcl/cloudtcl/data/testworkflow/execution_models.go @@ -0,0 +1,138 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "github.com/kubeshop/testkube/pkg/api/v1/testkube" + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" +) + +type ExecutionGetRequest struct { + ID string `json:"id"` +} + +type ExecutionGetResponse struct { + WorkflowExecution testkube.TestWorkflowExecution `json:"workflowExecution"` +} + +type ExecutionGetByNameAndWorkflowRequest struct { + Name string `json:"name"` + WorkflowName string `json:"workflowName"` +} + +type ExecutionGetByNameAndWorkflowResponse struct { + WorkflowExecution testkube.TestWorkflowExecution `json:"workflowExecution"` +} + +type ExecutionGetLatestByWorkflowRequest struct { + WorkflowName string `json:"workflowName"` +} + +type ExecutionGetLatestByWorkflowResponse struct { + WorkflowExecution *testkube.TestWorkflowExecution `json:"workflowExecution"` +} + +type ExecutionGetRunningRequest struct { +} + +type ExecutionGetRunningResponse struct { + WorkflowExecutions []testkube.TestWorkflowExecution `json:"workflowExecutions"` +} + +type ExecutionGetLatestByWorkflowsRequest struct { + WorkflowNames []string `json:"workflowNames"` +} + +type ExecutionGetLatestByWorkflowsResponse struct { + WorkflowExecutions []testkube.TestWorkflowExecutionSummary `json:"workflowExecutions"` +} + +type ExecutionGetExecutionTotalsRequest struct { + Filter []*testworkflow.FilterImpl `json:"filter"` +} + +type ExecutionGetExecutionTotalsResponse struct { + Totals testkube.ExecutionsTotals `json:"totals"` +} + +type ExecutionGetExecutionsRequest struct { + Filter *testworkflow.FilterImpl `json:"filter"` +} + +type ExecutionGetExecutionsResponse struct { + WorkflowExecutions []testkube.TestWorkflowExecution `json:"workflowExecutions"` +} + +type ExecutionGetExecutionsSummaryRequest struct { + Filter *testworkflow.FilterImpl `json:"filter"` +} + +type ExecutionGetExecutionsSummaryResponse struct { + WorkflowExecutions []testkube.TestWorkflowExecutionSummary `json:"workflowExecutions"` +} + +type ExecutionInsertRequest struct { + WorkflowExecution testkube.TestWorkflowExecution `json:"workflowExecution"` +} + +type ExecutionInsertResponse struct { +} + +type ExecutionUpdateRequest struct { + WorkflowExecution testkube.TestWorkflowExecution `json:"workflowExecution"` +} + +type ExecutionUpdateResponse struct { +} + +type ExecutionUpdateResultRequest struct { + ID string `json:"id"` + Result *testkube.TestWorkflowResult `json:"result"` +} + +type ExecutionUpdateResultResponse struct { +} + +type ExecutionUpdateOutputRequest struct { + ID string `json:"id"` + Output []testkube.TestWorkflowOutput `json:"output"` +} + +type ExecutionUpdateOutputResponse struct { +} + +type ExecutionDeleteByWorkflowRequest struct { + WorkflowName string `json:"workflowName"` +} + +type ExecutionDeleteByWorkflowResponse struct { +} + +type ExecutionDeleteAllRequest struct { +} + +type ExecutionDeleteAllResponse struct { +} + +type ExecutionDeleteByWorkflowsRequest struct { + WorkflowNames []string `json:"workflowNames"` +} + +type ExecutionDeleteByWorkflowsResponse struct { +} + +type ExecutionGetWorkflowMetricsRequest struct { + Name string `json:"name"` + Limit int `json:"limit"` + Last int `json:"last"` +} + +type ExecutionGetWorkflowMetricsResponse struct { + Metrics testkube.ExecutionsMetrics `json:"metrics"` +} diff --git a/pkg/tcl/cloudtcl/data/testworkflow/output.go b/pkg/tcl/cloudtcl/data/testworkflow/output.go new file mode 100644 index 00000000000..94ffdceff91 --- /dev/null +++ b/pkg/tcl/cloudtcl/data/testworkflow/output.go @@ -0,0 +1,107 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "bytes" + "context" + "io" + "net/http" + + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" + + "github.com/pkg/errors" + "google.golang.org/grpc" + + "github.com/kubeshop/testkube/pkg/cloud" + "github.com/kubeshop/testkube/pkg/cloud/data/executor" +) + +var _ testworkflow.OutputRepository = (*CloudOutputRepository)(nil) + +type CloudOutputRepository struct { + executor executor.Executor +} + +func NewCloudOutputRepository(client cloud.TestKubeCloudAPIClient, grpcConn *grpc.ClientConn, apiKey string) *CloudOutputRepository { + return &CloudOutputRepository{executor: executor.NewCloudGRPCExecutor(client, grpcConn, apiKey)} +} + +// PresignSaveLog builds presigned storage URL to save the output in Cloud +func (r *CloudOutputRepository) PresignSaveLog(ctx context.Context, id, workflowName string) (string, error) { + req := OutputPresignSaveLogRequest{ID: id, WorkflowName: workflowName} + process := func(v OutputPresignSaveLogResponse) string { + return v.URL + } + return pass(r.executor, ctx, req, process) +} + +// PresignReadLog builds presigned storage URL to read the output from Cloud +func (r *CloudOutputRepository) PresignReadLog(ctx context.Context, id, workflowName string) (string, error) { + req := OutputPresignReadLogRequest{ID: id, WorkflowName: workflowName} + process := func(v OutputPresignReadLogResponse) string { + return v.URL + } + return pass(r.executor, ctx, req, process) +} + +// SaveLog streams the output from the workflow to Cloud +func (r *CloudOutputRepository) SaveLog(ctx context.Context, id, workflowName string, reader io.Reader) error { + url, err := r.PresignSaveLog(ctx, id, workflowName) + if err != nil { + return err + } + // FIXME: It should stream instead + data, err := io.ReadAll(reader) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewBuffer(data)) + req.Header.Add("Content-Type", "application/octet-stream") + if err != nil { + return err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "failed to save file in cloud storage") + } + if res.StatusCode != http.StatusOK { + return errors.Errorf("error saving file with presigned url: expected 200 OK response code, got %d", res.StatusCode) + } + return nil +} + +// ReadLog streams the output from Cloud +func (r *CloudOutputRepository) ReadLog(ctx context.Context, id, workflowName string) (io.Reader, error) { + url, err := r.PresignReadLog(ctx, id, workflowName) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to get file from cloud storage") + } + if res.StatusCode != http.StatusOK { + return nil, errors.Errorf("error getting file from presigned url: expected 200 OK response code, got %d", res.StatusCode) + } + return res.Body, nil +} + +// HasLog checks if there is an output in Cloud +func (r *CloudOutputRepository) HasLog(ctx context.Context, id, workflowName string) (bool, error) { + req := OutputHasLogRequest{ID: id, WorkflowName: workflowName} + process := func(v OutputHasLogResponse) bool { + return v.Has + } + return pass(r.executor, ctx, req, process) +} diff --git a/pkg/tcl/cloudtcl/data/testworkflow/output_models.go b/pkg/tcl/cloudtcl/data/testworkflow/output_models.go new file mode 100644 index 00000000000..df941c9fe6b --- /dev/null +++ b/pkg/tcl/cloudtcl/data/testworkflow/output_models.go @@ -0,0 +1,36 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +type OutputPresignSaveLogRequest struct { + ID string `json:"id"` + WorkflowName string `json:"workflowName"` +} + +type OutputPresignSaveLogResponse struct { + URL string `json:"url"` +} + +type OutputPresignReadLogRequest struct { + ID string `json:"id"` + WorkflowName string `json:"workflowName"` +} + +type OutputPresignReadLogResponse struct { + URL string `json:"url"` +} + +type OutputHasLogRequest struct { + ID string `json:"id"` + WorkflowName string `json:"workflowName"` +} + +type OutputHasLogResponse struct { + Has bool `json:"has"` +} diff --git a/pkg/tcl/cloudtcl/data/testworkflow/utils.go b/pkg/tcl/cloudtcl/data/testworkflow/utils.go new file mode 100644 index 00000000000..ba9f3a80ce4 --- /dev/null +++ b/pkg/tcl/cloudtcl/data/testworkflow/utils.go @@ -0,0 +1,60 @@ +// Copyright 2024 Testkube. +// +// Licensed as a Testkube Pro file under the Testkube Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt + +package testworkflow + +import ( + "context" + "encoding/json" + + "github.com/kubeshop/testkube/pkg/cloud/data/executor" + "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" +) + +func passWithErr[T any, U any](e executor.Executor, ctx context.Context, req interface{}, fn func(u T) (U, error)) (v U, err error) { + response, err := e.Execute(ctx, command(req), req) + if err != nil { + return v, err + } + var commandResponse T + if err = json.Unmarshal(response, &commandResponse); err != nil { + return v, err + } + return fn(commandResponse) +} + +func pass[T any, U any](e executor.Executor, ctx context.Context, req interface{}, fn func(u T) U) (v U, err error) { + return passWithErr(e, ctx, req, func(u T) (U, error) { + return fn(u), nil + }) +} + +func passNoContentProcess[T any](e executor.Executor, ctx context.Context, req interface{}, fn func(u T) error) (err error) { + _, err = passWithErr(e, ctx, req, func(u T) (interface{}, error) { + return nil, fn(u) + }) + return err +} + +func passNoContent(e executor.Executor, ctx context.Context, req interface{}) (err error) { + return passNoContentProcess(e, ctx, req, func(u interface{}) error { + return nil + }) +} + +func mapFilters(s []testworkflow.Filter) []*testworkflow.FilterImpl { + v := make([]*testworkflow.FilterImpl, len(s)) + for i := range s { + if vv, ok := s[i].(testworkflow.FilterImpl); ok { + v[i] = &vv + } else { + v[i] = s[i].(*testworkflow.FilterImpl) + } + } + return v +} diff --git a/proto/service.proto b/proto/service.proto index d6c5a78c4c9..bcecb576763 100644 --- a/proto/service.proto +++ b/proto/service.proto @@ -15,6 +15,7 @@ service TestKubeCloudAPI { rpc Call(CommandRequest) returns (CommandResponse); rpc ExecuteAsync(stream ExecuteResponse) returns (stream ExecuteRequest); rpc GetLogsStream(stream LogsStreamResponse) returns (stream LogsStreamRequest); + rpc GetTestWorkflowNotificationsStream(stream TestWorkflowNotificationsResponse) returns (stream TestWorkflowNotificationsRequest); } enum LogsStreamRequestType { @@ -22,6 +23,18 @@ enum LogsStreamRequestType { STREAM_HEALTH_CHECK = 1; } +enum TestWorkflowNotificationsRequestType { + WORKFLOW_STREAM_LOG_MESSAGE = 0; + WORKFLOW_STREAM_HEALTH_CHECK = 1; +} + +enum TestWorkflowNotificationType { + WORKFLOW_STREAM_ERROR = 0; + WORKFLOW_STREAM_LOG = 1; + WORKFLOW_STREAM_RESULT = 2; + WORKFLOW_STREAM_OUTPUT = 3; +} + message LogsStreamRequest { string stream_id = 1; string execution_id = 2; @@ -52,6 +65,21 @@ message ExecuteRequest { string message_id = 5; } +message TestWorkflowNotificationsRequest { + string stream_id = 1; + string execution_id = 2; + TestWorkflowNotificationsRequestType request_type = 3; +} + +message TestWorkflowNotificationsResponse { + string stream_id = 1; + uint32 seq_no = 2; + string timestamp = 3; + string ref = 4; + TestWorkflowNotificationType type = 5; + string message = 6; // based on type: log/error = inline, others = serialized to JSON +} + message HeaderValue { repeated string header = 1; } From 79fd5191a03d6af8b36f7cb5bcf85c86219b5b0f Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Fri, 8 Mar 2024 16:25:49 +0100 Subject: [PATCH 184/234] feat: add durationMs to the TestWorkflow's result (#5127) --- api/v1/testkube.yaml | 6 ++++++ pkg/api/v1/testkube/model_test_workflow_result.go | 4 +++- pkg/api/v1/testkube/model_test_workflow_result_extended.go | 1 + pkg/api/v1/testkube/model_test_workflow_result_summary.go | 2 ++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index c3279070164..ef5bbe96f7c 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -7597,6 +7597,9 @@ components: duration: type: string description: Go-formatted (human-readable) duration + durationMs: + type: integer + description: Duration in milliseconds required: - status - predictedStatus @@ -7655,6 +7658,9 @@ components: duration: type: string description: Go-formatted (human-readable) duration + durationMs: + type: integer + description: Duration in milliseconds initialization: $ref: "#/components/schemas/TestWorkflowStepResult" steps: diff --git a/pkg/api/v1/testkube/model_test_workflow_result.go b/pkg/api/v1/testkube/model_test_workflow_result.go index bd1da212995..864645be9bc 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result.go +++ b/pkg/api/v1/testkube/model_test_workflow_result.go @@ -23,7 +23,9 @@ type TestWorkflowResult struct { // when the pod has been completed FinishedAt time.Time `json:"finishedAt,omitempty"` // Go-formatted (human-readable) duration - Duration string `json:"duration,omitempty"` + Duration string `json:"duration,omitempty"` + // Duration in milliseconds + DurationMs int32 `json:"durationMs,omitempty"` Initialization *TestWorkflowStepResult `json:"initialization,omitempty"` Steps map[string]TestWorkflowStepResult `json:"steps,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index 0e36eef1dfc..b4cfb6eaabd 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -97,6 +97,7 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { // Compute the duration if !r.FinishedAt.IsZero() { r.Duration = r.FinishedAt.Sub(r.QueuedAt).Round(time.Millisecond).String() + r.DurationMs = int32(r.FinishedAt.Sub(r.QueuedAt).Milliseconds()) } // Build status on the internal failure diff --git a/pkg/api/v1/testkube/model_test_workflow_result_summary.go b/pkg/api/v1/testkube/model_test_workflow_result_summary.go index 888c04fedcb..7311584fec8 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_summary.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_summary.go @@ -24,4 +24,6 @@ type TestWorkflowResultSummary struct { FinishedAt time.Time `json:"finishedAt,omitempty"` // Go-formatted (human-readable) duration Duration string `json:"duration,omitempty"` + // Duration in milliseconds + DurationMs int32 `json:"durationMs,omitempty"` } From c29618e07963dba115522f35d68c79e8011c69d6 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 11 Mar 2024 08:57:30 +0100 Subject: [PATCH 185/234] fix: add missing durationMs for TestWorkflowResult (#5128) --- pkg/api/v1/testkube/model_test_workflow_result_extended.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index b4cfb6eaabd..bc6a51a570d 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -68,6 +68,7 @@ func (r *TestWorkflowResult) Clone() *TestWorkflowResult { StartedAt: r.StartedAt, FinishedAt: r.FinishedAt, Duration: r.Duration, + DurationMs: r.DurationMs, Initialization: r.Initialization.Clone(), Steps: steps, } From 431a60f7c9034ec023e70b8a38811f56a0615cca Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 11 Mar 2024 08:57:36 +0100 Subject: [PATCH 186/234] feat: modify TestWorkflow preview to allow resolving without inlining the templates (#5129) --- api/v1/testkube.yaml | 10 ++++++++++ pkg/tcl/apitcl/v1/testworkflows.go | 31 +++++++++++++++++------------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index ef5bbe96f7c..d5174f0ea44 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -4062,6 +4062,8 @@ paths: - test-workflows - api - pro + parameters: + - $ref: "#/components/parameters/InlineTemplates" summary: Preview test workflow description: Preview test workflow after including templates inside operationId: previewTestWorkflow @@ -8617,6 +8619,14 @@ components: type: string required: true description: test type of the executor + InlineTemplates: + in: query + name: inline + schema: + type: boolean + default: false + description: should inline templates in the resolved workflow + required: false requestBodies: UploadsBody: description: "Upload files request body data" diff --git a/pkg/tcl/apitcl/v1/testworkflows.go b/pkg/tcl/apitcl/v1/testworkflows.go index f35704dcfcd..94098a52076 100644 --- a/pkg/tcl/apitcl/v1/testworkflows.go +++ b/pkg/tcl/apitcl/v1/testworkflows.go @@ -210,6 +210,9 @@ func (s *apiTCL) UpdateTestWorkflowHandler() fiber.Handler { func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { errPrefix := "failed to resolve test workflow" return func(c *fiber.Ctx) (err error) { + // Check if it should inline templates + inline, _ := strconv.ParseBool(c.Query("inline")) + // Deserialize resource obj := new(testworkflowsv1.TestWorkflow) if HasYAML(c) { @@ -232,21 +235,23 @@ func (s *apiTCL) PreviewTestWorkflowHandler() fiber.Handler { } obj.Namespace = s.Namespace - // Fetch the templates - tpls := testworkflowresolver.ListTemplates(obj) - tplsMap := make(map[string]testworkflowsv1.TestWorkflowTemplate, len(tpls)) - for name := range tpls { - tpl, err := s.TestWorkflowTemplatesClient.Get(name) - if err != nil { - return s.BadRequest(c, errPrefix, "fetching error", err) + if inline { + // Fetch the templates + tpls := testworkflowresolver.ListTemplates(obj) + tplsMap := make(map[string]testworkflowsv1.TestWorkflowTemplate, len(tpls)) + for name := range tpls { + tpl, err := s.TestWorkflowTemplatesClient.Get(name) + if err != nil { + return s.BadRequest(c, errPrefix, "fetching error", err) + } + tplsMap[name] = *tpl } - tplsMap[name] = *tpl - } - // Resolve the TestWorkflow - err = testworkflowresolver.ApplyTemplates(obj, tplsMap) - if err != nil { - return s.BadRequest(c, errPrefix, "resolving error", err) + // Resolve the TestWorkflow + err = testworkflowresolver.ApplyTemplates(obj, tplsMap) + if err != nil { + return s.BadRequest(c, errPrefix, "resolving error", err) + } } err = SendResource(c, "TestWorkflow", testworkflowsv1.GroupVersion, testworkflowmappers.MapKubeToAPI, obj) From 69a7bbc0a856b3df4b3a4afaceed8ca68f0e9875 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 11 Mar 2024 12:34:48 +0100 Subject: [PATCH 187/234] fix: added default times (#5102) * fix: added default times * fix: added default times --- pkg/logs/events/events.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 3e9a7bbc7d2..6176aacd701 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -51,6 +51,7 @@ type Log testkube.LogV2 func NewFinishLog() *Log { return &Log{ + Time: time.Now(), Content: "processing logs finished", Type_: "finish", Source: "log-server", @@ -67,6 +68,7 @@ func NewErrorLog(err error) *Log { msg = err.Error() } return &Log{ + Time: time.Now(), Error_: true, Content: msg, } From 24315a12889e0904d9f59a6da76e15c8b32cb885 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Mon, 11 Mar 2024 12:40:32 +0100 Subject: [PATCH 188/234] fix: v2 logs printing for cloud-enterprise (#5132) * fix: v2 logs printing for cloud-enterprise * fix: test name --- .../commands/common/client.go | 3 +- .../commands/common/render/common.go | 45 ++++++++++++++----- pkg/api/v1/client/direct_client.go | 4 ++ pkg/logs/events/events.go | 2 +- pkg/logs/events/events_test.go | 37 +++++++++++++++ 5 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 pkg/logs/events/events_test.go diff --git a/cmd/kubectl-testkube/commands/common/client.go b/cmd/kubectl-testkube/commands/common/client.go index 709841d9396..a7cdf7d1b3a 100644 --- a/cmd/kubectl-testkube/commands/common/client.go +++ b/cmd/kubectl-testkube/commands/common/client.go @@ -80,10 +80,9 @@ func GetClient(cmd *cobra.Command) (client.Client, string, error) { token := cfg.CloudContext.ApiKey - if cfg.CloudContext.ApiKey != "" && cfg.CloudContext.RefreshToken != "" { + if cfg.CloudContext.ApiKey != "" && cfg.CloudContext.RefreshToken != "" && cfg.OAuth2Data.Enabled { var refreshToken string authURI := fmt.Sprintf("%s/idp", cfg.CloudContext.ApiUri) - token, refreshToken, err = cloudlogin.CheckAndRefreshToken(context.Background(), authURI, cfg.CloudContext.ApiKey, cfg.CloudContext.RefreshToken) if err != nil { // Error: failed refreshing, go thru login flow diff --git a/cmd/kubectl-testkube/commands/common/render/common.go b/cmd/kubectl-testkube/commands/common/render/common.go index 21e3f194043..1494891342a 100644 --- a/cmd/kubectl-testkube/commands/common/render/common.go +++ b/cmd/kubectl-testkube/commands/common/render/common.go @@ -72,6 +72,9 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, return nil } + info, err := client.GetServerInfo() + ui.ExitOnError("getting server info", err) + ui.NL() switch true { case result.IsQueued(): @@ -82,16 +85,12 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, case result.IsPassed(): if showLogs { - ui.Info(result.Output) + PrintLogs(client, info, *execution) } if !logsOnly { duration := execution.EndTime.Sub(execution.StartTime) ui.Success("Test execution completed with success in " + duration.String()) - - info, err := client.GetServerInfo() - ui.ExitOnError("getting server info", err) - PrintExecutionURIs(execution, info.DashboardUri) } @@ -108,15 +107,11 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, ui.UseStderr() ui.Warn("Test execution failed:\n") ui.Errf(result.ErrorMessage) - - info, err := client.GetServerInfo() - ui.ExitOnError("getting server info", err) - PrintExecutionURIs(execution, info.DashboardUri) } if showLogs { - ui.Info(result.Output) + PrintLogs(client, info, *execution) } return errors.New(result.ErrorMessage) @@ -130,7 +125,7 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, } if showLogs { - ui.Info(result.Output) + PrintLogs(client, info, *execution) } return errors.New(result.ErrorMessage) } @@ -138,6 +133,34 @@ func RenderExecutionResult(client client.Client, execution *testkube.Execution, return nil } +func PrintLogs(client client.Client, info testkube.ServerInfo, execution testkube.Execution) { + if !info.Features.LogsV2 { + // fallback to default logs + ui.Info(execution.ExecutionResult.Output) + return + } + + logsCh, err := client.LogsV2(execution.Id) + ui.ExitOnError("getting logs", err) + + ui.H1("Logs:") + lastSource := "" + for log := range logsCh { + + if log.Source != lastSource { + ui.H2("source: " + log.Source) + ui.NL() + lastSource = log.Source + } + + if ui.Verbose { + ui.Print(log.Time.Format("2006-01-02 15:04:05") + " " + log.Content) + } else { + ui.Print(log.Content) + } + } +} + func PrintExecutionURIs(execution *testkube.Execution, dashboardURI string) { ui.NL() ui.Link("Test URI:", fmt.Sprintf("%s/tests/%s", dashboardURI, execution.TestName)) diff --git a/pkg/api/v1/client/direct_client.go b/pkg/api/v1/client/direct_client.go index 19523388717..13842c57d6d 100644 --- a/pkg/api/v1/client/direct_client.go +++ b/pkg/api/v1/client/direct_client.go @@ -197,6 +197,10 @@ func (t DirectClient[A]) GetLogsV2(uri string, logs chan events.Log) error { return err } + if resp.StatusCode != http.StatusOK { + return errors.New("error getting logs, invalid status code: " + resp.Status) + } + go func() { defer close(logs) defer resp.Body.Close() diff --git a/pkg/logs/events/events.go b/pkg/logs/events/events.go index 6176aacd701..eae4dfc453f 100644 --- a/pkg/logs/events/events.go +++ b/pkg/logs/events/events.go @@ -203,7 +203,7 @@ func NewLogFromBytes(b []byte) *Log { // new non-JSON format (just raw lines will be logged) return &Log{ Time: ts, - Content: string(b), + Content: string(content), Version: string(LogVersionV2), } } diff --git a/pkg/logs/events/events_test.go b/pkg/logs/events/events_test.go new file mode 100644 index 00000000000..671f16f0b41 --- /dev/null +++ b/pkg/logs/events/events_test.go @@ -0,0 +1,37 @@ +package events + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewLogFromBytes(t *testing.T) { + assert := require.New(t) + + t.Run("log line with timestamp passed from kube api", func(t *testing.T) { + b := []byte("2024-03-11T10:47:41.070097107Z Line") + + l := NewLogFromBytes(b) + + assert.Equal("2024-03-11 10:47:41.070097107 +0000 UTC", l.Time.String()) + assert.Equal("Line", l.Content) + }) + + t.Run("log line without timestamp", func(t *testing.T) { + b := []byte("Line") + + l := NewLogFromBytes(b) + + assert.Equal("Line", l.Content) + }) + + t.Run("old log line without timestamp", func(t *testing.T) { + b := []byte(`{"content":"Line"}`) + + l := NewLogFromBytes(b) + + assert.Equal("Line", l.Content) + }) + +} From d0531b56d26d485dcd7104ad047b11a3748c8390 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Mar 2024 12:42:39 +0100 Subject: [PATCH 189/234] build: bump github.com/gofiber/fiber/v2 from 2.51.0 to 2.52.1 (#5055) Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.51.0 to 2.52.1. - [Release notes](https://github.com/gofiber/fiber/releases) - [Commits](https://github.com/gofiber/fiber/compare/v2.51.0...v2.52.1) --- updated-dependencies: - dependency-name: github.com/gofiber/fiber/v2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 55110cfc0fb..b6e56d5876b 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.1 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 github.com/gofiber/adaptor/v2 v2.1.29 - github.com/gofiber/fiber/v2 v2.51.0 + github.com/gofiber/fiber/v2 v2.52.1 github.com/gofiber/websocket/v2 v2.1.1 github.com/golang/mock v1.6.0 github.com/gookit/color v1.5.3 @@ -46,7 +46,7 @@ require ( github.com/spf13/afero v1.10.0 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 - github.com/valyala/fasthttp v1.50.0 + github.com/valyala/fasthttp v1.51.0 github.com/vektah/gqlparser/v2 v2.5.2-0.20230422221642-25e09f9d292d go.mongodb.org/mongo-driver v1.11.0 go.uber.org/zap v1.26.0 @@ -152,7 +152,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/go-cmp v0.6.0 github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.4.0 + github.com/google/uuid v1.5.0 github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 9f39a31bd84..eac1a619495 100644 --- a/go.sum +++ b/go.sum @@ -202,8 +202,8 @@ github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a github.com/gofiber/adaptor/v2 v2.1.29 h1:JnYd6fbqVM9D4zPchk+kg89PfxyuKqZKhBWGQDHfKH4= github.com/gofiber/adaptor/v2 v2.1.29/go.mod h1:z4mAV9mMsUgIEVGGS5Ii6ZMTJq4VdV1KWL1JAbsZdUA= github.com/gofiber/fiber/v2 v2.39.0/go.mod h1:Cmuu+elPYGqlvQvdKyjtYsjGMi69PDp8a1AY2I5B2gM= -github.com/gofiber/fiber/v2 v2.51.0 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ= -github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U= +github.com/gofiber/fiber/v2 v2.52.1 h1:1RoU2NS+b98o1L77sdl5mboGPiW+0Ypsi5oLmcYlgHI= +github.com/gofiber/fiber/v2 v2.52.1/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/gofiber/websocket/v2 v2.1.1 h1:Q88s88UL8B+elZTT/QB+ocDb1REhdMEmnysI0C9zzqs= github.com/gofiber/websocket/v2 v2.1.1/go.mod h1:F0ES7DhlFrNyHtC2UGey2KYI+zdqIURRMbSF0C4qdGQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -285,8 +285,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= @@ -543,8 +543,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4= github.com/valyala/fasthttp v1.40.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= -github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M= -github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/vektah/gqlparser/v2 v2.5.2-0.20230422221642-25e09f9d292d h1:ibuD+jp4yLoOY4w8+5+2fDq0ufJ/noPn/cPntJMWB1E= From 1fa2354774906d9bce6b4fdd3cacb134139b33bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Mar 2024 12:43:00 +0100 Subject: [PATCH 190/234] chore(deps): bump github.com/cloudevents/sdk-go/v2 from 2.14.0 to 2.15.2 (#5114) Bumps [github.com/cloudevents/sdk-go/v2](https://github.com/cloudevents/sdk-go) from 2.14.0 to 2.15.2. - [Release notes](https://github.com/cloudevents/sdk-go/releases) - [Commits](https://github.com/cloudevents/sdk-go/compare/v2.14.0...v2.15.2) --- updated-dependencies: - dependency-name: github.com/cloudevents/sdk-go/v2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b6e56d5876b..ca880aaf730 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/adhocore/gronx v1.6.3 github.com/cdevents/sdk-go v0.3.0 github.com/cli/cli/v2 v2.20.2 - github.com/cloudevents/sdk-go/v2 v2.14.0 + github.com/cloudevents/sdk-go/v2 v2.15.2 github.com/coreos/go-oidc v2.2.1+incompatible github.com/creasty/defaults v1.7.0 github.com/denisbrodbeck/machineid v1.0.1 diff --git a/go.sum b/go.sum index eac1a619495..722a4e12b7f 100644 --- a/go.sum +++ b/go.sum @@ -114,8 +114,8 @@ github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5 github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk= github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudevents/sdk-go/v2 v2.14.0 h1:Nrob4FwVgi5L4tV9lhjzZcjYqFVyJzsA56CwPaPfv6s= -github.com/cloudevents/sdk-go/v2 v2.14.0/go.mod h1:xDmKfzNjM8gBvjaF8ijFjM1VYOVUEeUfapHMUX1T5To= +github.com/cloudevents/sdk-go/v2 v2.15.2 h1:54+I5xQEnI73RBhWHxbI1XJcqOFOVJN85vb41+8mHUc= +github.com/cloudevents/sdk-go/v2 v2.15.2/go.mod h1:lL7kSWAE/V8VI4Wh0jbL2v/jvqsm6tjmaQBSvxcv4uE= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= From 757d61d762438cd479a53308edde1aec8f9712d6 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 11 Mar 2024 13:43:20 +0100 Subject: [PATCH 191/234] feat: run TestWorkflows artifacts step as root, to read FS without problems (#5136) --- .../testworkflowstcl/testworkflowprocessor/container.go | 8 ++++++++ .../testworkflowstcl/testworkflowprocessor/operations.go | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go index 098c0fc863b..88769760a91 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go @@ -73,6 +73,7 @@ type ContainerMutations[T any] interface { ApplyCR(cr *testworkflowsv1.ContainerConfig) T ApplyImageData(image *imageinspector.Info) error EnableToolkit(ref string) T + RunAsRoot() T } //go:generate mockgen -destination=./mock_container.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Container @@ -421,6 +422,13 @@ func (c *container) EnableToolkit(ref string) Container { }) } +func (c *container) RunAsRoot() Container { + return c.SetSecurityContext(&corev1.SecurityContext{ + AllowPrivilegeEscalation: common.Ptr(false), + RunAsUser: common.Ptr(int64(0)), + }) +} + func (c *container) Resolve(m ...expressionstcl.Machine) error { base := expressionstcl.NewMachine(). RegisterAccessor(func(name string) (interface{}, bool) { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go index c03b7406915..76a1b9b8b45 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -277,7 +277,8 @@ func ProcessArtifacts(_ InternalProcessor, layer Intermediate, container Contain SetImage(defaultToolkitImage). SetImagePullPolicy(corev1.PullIfNotPresent). SetCommand("/toolkit", "artifacts", "-m", defaultDataPath). - EnableToolkit(stage.Ref()) + EnableToolkit(stage.Ref()). + RunAsRoot() args := make([]string, 0) if step.Artifacts.Compress != nil { From 563b1ad04ad5cb896cc58727fef43ecf618d8346 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Mon, 11 Mar 2024 13:55:01 +0200 Subject: [PATCH 192/234] fix typo in curl docs --- docs/docs/test-types/executor-curl.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/test-types/executor-curl.mdx b/docs/docs/test-types/executor-curl.mdx index b47fbdf123a..c08c2e8fb56 100644 --- a/docs/docs/test-types/executor-curl.mdx +++ b/docs/docs/test-types/executor-curl.mdx @@ -83,7 +83,7 @@ For a File test source: - `--file` (path to your curl test - in this case `test/curl/executor-tests/curl-smoke-test.json`) ```sh -testkube create test --name curl-test --type curl/test --test-content-type file-uri --file test/curl/executor-tests/curl-smoke-test.json +testkube create test --name curl-test --type curl/test --file test/curl/executor-tests/curl-smoke-test.json ``` ```sh title="Expected output:" From ef77feb482b70932f96db8353cb44395778d1286 Mon Sep 17 00:00:00 2001 From: Dejan Zele Pejchev Date: Mon, 11 Mar 2024 14:43:01 +0100 Subject: [PATCH 193/234] fix: add field for custom CA in containerexecutor JobOptions (#5137) --- pkg/executor/containerexecutor/containerexecutor.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index 3b08b6ce222..e9c665a4512 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -168,6 +168,7 @@ type JobOptions struct { UsernameSecret *testkube.SecretRef TokenSecret *testkube.SecretRef CertificateSecret string + AgentAPITLSSecret string Variables map[string]testkube.Variable ActiveDeadlineSeconds int64 ArtifactRequest *testkube.ArtifactRequest @@ -694,6 +695,7 @@ func NewJobOptionsFromExecutionOptions(options client.ExecuteOptions) *JobOption UsernameSecret: options.UsernameSecret, TokenSecret: options.TokenSecret, CertificateSecret: options.CertificateSecret, + AgentAPITLSSecret: options.AgentAPITLSSecret, ActiveDeadlineSeconds: options.Request.ActiveDeadlineSeconds, ArtifactRequest: artifactRequest, DelaySeconds: jobDelaySeconds, From f74d4b280822c8eba2931c6d49f479dd7825ea40 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 11 Mar 2024 15:03:42 +0100 Subject: [PATCH 194/234] fix: clean up TestWorkflow jobs after finish (#5139) --- pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go index 47040cc9e5d..f88a4cb450e 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go @@ -119,6 +119,8 @@ func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowE wg := sync.WaitGroup{} wg.Add(1) go func() { + defer wg.Done() + for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { if v.Error != nil { continue From a6b809f3c275407f88f2b6e99945bf996a9eb673 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 11 Mar 2024 17:10:47 +0300 Subject: [PATCH 195/234] fix: mandatory -n --- contrib/executor/jmeterd/pkg/runner/runner.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contrib/executor/jmeterd/pkg/runner/runner.go b/contrib/executor/jmeterd/pkg/runner/runner.go index b7d20d305ef..9346bd0d70e 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner.go +++ b/contrib/executor/jmeterd/pkg/runner/runner.go @@ -298,6 +298,8 @@ func removeDuplicatedArgs(args []string) []string { } func mergeDuplicatedArgs(args []string) []string { + // -n is mandatory regardless of args mode + args = append(args, "-n") allowed := map[string]int{ "-e": 0, "-n": 0, From fffa4a49d969bb026d3bb3b348313bb1194d93a8 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 11 Mar 2024 17:23:10 +0300 Subject: [PATCH 196/234] fix: unit test --- contrib/executor/jmeterd/pkg/runner/runner_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/executor/jmeterd/pkg/runner/runner_test.go b/contrib/executor/jmeterd/pkg/runner/runner_test.go index 6522355b77a..c45e2dde1e6 100644 --- a/contrib/executor/jmeterd/pkg/runner/runner_test.go +++ b/contrib/executor/jmeterd/pkg/runner/runner_test.go @@ -206,17 +206,17 @@ func TestMergeDuplicatedArgs(t *testing.T) { { name: "Duplicated args", args: []string{"-e", "", "-e"}, - expectedArgs: []string{"", "-e"}, + expectedArgs: []string{"", "-e", "-n"}, }, { name: "Multiple duplicated args", args: []string{"", "-e", "-e", "-n", "-n", "-l"}, - expectedArgs: []string{"", "-e", "-n", "-l"}, + expectedArgs: []string{"", "-e", "-l", "-n"}, }, { name: "Non duplicated args", args: []string{"-e", "-n", "", "-l"}, - expectedArgs: []string{"-e", "-n", "", "-l"}, + expectedArgs: []string{"-e", "", "-l", "-n"}, }, } From 0efd12bf3b0c5611a4e3d0708708b451caa6d213 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 11 Mar 2024 14:35:11 +0300 Subject: [PATCH 197/234] fix: remove duplicated logic for container executor --- .../containerexecutor/containerexecutor.go | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index e9c665a4512..f2eaeb8531d 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -588,43 +588,7 @@ func (c *ContainerExecutor) stopExecution(ctx context.Context, execution *testku // NewJobOptionsFromExecutionOptions compose JobOptions based on ExecuteOptions func NewJobOptionsFromExecutionOptions(options client.ExecuteOptions) *JobOptions { - // for args, command and image, HTTP request takes priority, then test spec, then executor - var args []string - argsMode := options.Request.ArgsMode - if options.TestSpec.ExecutionRequest != nil && argsMode == "" { - argsMode = string(options.TestSpec.ExecutionRequest.ArgsMode) - } - - if argsMode == string(testkube.ArgsModeTypeAppend) || argsMode == "" { - args = options.Request.Args - if options.TestSpec.ExecutionRequest != nil && len(args) == 0 { - args = options.TestSpec.ExecutionRequest.Args - } - - args = append(options.ExecutorSpec.Args, args...) - } - - if argsMode == string(testkube.ArgsModeTypeOverride) || argsMode == string(testkube.ArgsModeTypeReplace) { - args = options.Request.Args - if options.TestSpec.ExecutionRequest != nil && len(args) == 0 { - args = options.TestSpec.ExecutionRequest.Args - } - } - - var command []string - if len(options.ExecutorSpec.Command) != 0 { - command = options.ExecutorSpec.Command - } - - if options.TestSpec.ExecutionRequest != nil && - len(options.TestSpec.ExecutionRequest.Command) != 0 { - command = options.TestSpec.ExecutionRequest.Command - } - - if len(options.Request.Command) != 0 { - command = options.Request.Command - } - + // for image, HTTP request takes priority, then test spec, then executor var image string if options.ExecutorSpec.Image != "" { image = options.ExecutorSpec.Image @@ -683,8 +647,8 @@ func NewJobOptionsFromExecutionOptions(options client.ExecuteOptions) *JobOption return &JobOptions{ Image: image, ImagePullSecrets: options.ImagePullSecretNames, - Args: args, - Command: command, + Args: options.Request.Args, + Command: options.Request.Command, WorkingDir: workingDir, TestName: options.TestName, Namespace: options.Namespace, From 2d3ff852ced3230ca8c2c96012b0808b102d68dc Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Mon, 11 Mar 2024 18:02:39 +0100 Subject: [PATCH 198/234] fix: aborting execution via endpoint without test workflow name (#5143) --- pkg/tcl/apitcl/v1/testworkflowexecutions.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/tcl/apitcl/v1/testworkflowexecutions.go b/pkg/tcl/apitcl/v1/testworkflowexecutions.go index 837b8448ba8..b8847c0d856 100644 --- a/pkg/tcl/apitcl/v1/testworkflowexecutions.go +++ b/pkg/tcl/apitcl/v1/testworkflowexecutions.go @@ -217,8 +217,13 @@ func (s *apiTCL) AbortTestWorkflowExecutionHandler() fiber.Handler { executionID := c.Params("executionID") errPrefix := fmt.Sprintf("failed to abort test workflow execution '%s'", executionID) - // TODO: Fetch execution from database - execution, err := s.TestWorkflowResults.GetByNameAndTestWorkflow(ctx, executionID, name) + var execution testkube.TestWorkflowExecution + var err error + if name == "" { + execution, err = s.TestWorkflowResults.Get(ctx, executionID) + } else { + execution, err = s.TestWorkflowResults.GetByNameAndTestWorkflow(ctx, executionID, name) + } if err != nil { return s.ClientError(c, errPrefix, err) } From 4daff68e60e985111e972e03430719e0aa5079fe Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Mon, 11 Mar 2024 23:24:41 +0100 Subject: [PATCH 199/234] feat: TestWorkflow tests updated after `testworkflows.testkube.io/v1` and extended (#5138) * workflows updated after testworkflows.testkube.io/v1 * run script - postman workflow fixed * workflow tests - cases with trait updated, trait sub-step, trait with checkout on step, trait with global checkout * workflow cases - playwright anc cypress artifacts, cypress 13 video recording enabled, cypress 12 video recording (fefault), playwright shell, artifacts double asterisk, * workflows - k6 --- .../executor-tests/crd-workflow/smoke.yaml | 207 +++++++++++++++--- .../executor-smoke/crd-workflow/smoke.yaml | 30 +-- .../executor-tests/crd-workflow/smoke.yaml | 15 +- .../k6/executor-tests/crd-workflow/smoke.yaml | 63 +++--- .../executor-smoke/crd-workflow/smoke.yaml | 15 +- .../executor-tests/crd-workflow/smoke.yaml | 170 ++++++++++++-- .../executor-tests/crd-workflow/smoke.yaml | 64 +++--- test/scripts/executor-tests/run.sh | 3 +- .../executor-smoke/crd-workflow/smoke.yaml | 13 +- test/test-workflow-templates/cypress.yaml | 14 +- test/test-workflow-templates/k6.yaml | 8 +- test/test-workflow-templates/postman.yaml | 8 +- 12 files changed, 449 insertions(+), 161 deletions(-) diff --git a/test/cypress/executor-tests/crd-workflow/smoke.yaml b/test/cypress/executor-tests/crd-workflow/smoke.yaml index de03bf7c2fb..37421260d3d 100644 --- a/test/cypress/executor-tests/crd-workflow/smoke.yaml +++ b/test/cypress/executor-tests/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: cypress-workflow-smoke-13 labels: @@ -11,11 +11,12 @@ spec: revision: main paths: - test/cypress/executor-tests/cypress-13 - resources: - requests: - cpu: 2 - memory: 2Gi - workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 steps: - name: Run tests run: @@ -28,14 +29,53 @@ spec: env: - name: CYPRESS_CUSTOM_ENV value: CYPRESS_CUSTOM_ENV_value - - name: Saving artifacts - workingDir: /data/artifacts - artifacts: + steps: + - name: Saving artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: cypress-workflow-smoke-13-video-recording-enabled + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main paths: - - '*' + - test/cypress/executor-tests/cypress-13 + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + steps: + - name: Run tests + run: + image: cypress/included:13.6.4 + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - video=true + env: + - name: CYPRESS_CUSTOM_ENV + value: CYPRESS_CUSTOM_ENV_value + steps: + - name: Saving artifacts + workingDir: /data/repo/test/cypress/executor-tests/cypress-13/cypress/videos + artifacts: + paths: + - '**/*' --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: cypress-workflow-smoke-13-negative labels: @@ -47,11 +87,12 @@ spec: revision: main paths: - test/cypress/executor-tests/cypress-13 - resources: - requests: - cpu: 2 - memory: 2Gi - workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 steps: - name: Run tests run: @@ -68,33 +109,135 @@ spec: paths: - '**/*' --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: cypress-workflow-smoke-13-preofficial-trait labels: core-tests: workflows spec: - resources: - requests: - cpu: 2 - memory: 2Gi - workingDir: /data/repo/test/cypress/executor-tests/cypress-13 - env: - - name: CYPRESS_CUSTOM_ENV # currently only possible on this level - value: "CYPRESS_CUSTOM_ENV_value" + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + env: + - name: CYPRESS_CUSTOM_ENV # currently only possible on this level + value: "CYPRESS_CUSTOM_ENV_value" + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-13 steps: - - name: Checkout - checkout: + - name: Run from trait + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + template: + name: pre-official/cypress + config: + version: 13.5.0 + params: "--env NON_CYPRESS_ENV=NON_CYPRESS_ENV_value --config '{\"screenshotsFolder\":\"/data/artifacts/screenshots\",\"videosFolder\":\"/data/artifacts/videos\"}'" +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: cypress-workflow-smoke-13-preofficial-trait-checkout-on-step + labels: + core-tests: workflows +spec: + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + env: + - name: CYPRESS_CUSTOM_ENV # currently only possible on this level + value: "CYPRESS_CUSTOM_ENV_value" + steps: + - name: Run from trait + content: git: uri: https://github.com/kubeshop/testkube revision: main paths: - test/cypress/executor-tests/cypress-13 - - name: Run from trait workingDir: /data/repo/test/cypress/executor-tests/cypress-13 - trait: + template: name: pre-official/cypress config: version: 13.5.0 params: "--env NON_CYPRESS_ENV=NON_CYPRESS_ENV_value --config '{\"screenshotsFolder\":\"/data/artifacts/screenshots\",\"videosFolder\":\"/data/artifacts/videos\"}'" +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: cypress-workflow-smoke-13-preofficial-trait-sub-step + labels: + core-tests: workflows +spec: + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + env: + - name: CYPRESS_CUSTOM_ENV # currently only possible on this level + value: "CYPRESS_CUSTOM_ENV_value" + steps: + - name: Run cypress test + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-13 + steps: + - name: Run from trait + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + template: + name: pre-official/cypress + config: + version: 13.5.0 + params: "--env NON_CYPRESS_ENV=NON_CYPRESS_ENV_value --config '{\"screenshotsFolder\":\"/data/artifacts/screenshots\",\"videosFolder\":\"/data/artifacts/videos\"}'" +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: cypress-workflow-smoke-12.7.0 + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-12 + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-12 + steps: + - name: Run tests + run: + image: cypress/included:12.7.0 + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - '{"screenshotsFolder":"/data/artifacts/screenshots","videosFolder":"/data/artifacts/videos"}' + env: + - name: CYPRESS_CUSTOM_ENV + value: CYPRESS_CUSTOM_ENV_value + steps: + - name: Saving artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' diff --git a/test/gradle/executor-smoke/crd-workflow/smoke.yaml b/test/gradle/executor-smoke/crd-workflow/smoke.yaml index ba098d217f3..51f9ae2f481 100644 --- a/test/gradle/executor-smoke/crd-workflow/smoke.yaml +++ b/test/gradle/executor-smoke/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: gradle-workflow-smoke-jdk11 labels: @@ -11,11 +11,12 @@ spec: revision: main paths: - contrib/executor/gradle/examples/hello-gradle - resources: - requests: - cpu: 512m - memory: 512Mi - workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle + container: + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle steps: - name: Run tests run: @@ -28,8 +29,8 @@ spec: - name: TESTKUBE_GRADLE value: "true" --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: gradle-workflow-smoke-jdk11-default-command # TODO: recheck if it's fixed - the step passes without being executed labels: @@ -41,11 +42,12 @@ spec: revision: main paths: - contrib/executor/gradle/examples/hello-gradle - resources: - requests: - cpu: 512m - memory: 512Mi - workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle + container: + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle steps: - name: Run tests run: diff --git a/test/jmeter/executor-tests/crd-workflow/smoke.yaml b/test/jmeter/executor-tests/crd-workflow/smoke.yaml index 260654194b6..f85dd5a5f71 100644 --- a/test/jmeter/executor-tests/crd-workflow/smoke.yaml +++ b/test/jmeter/executor-tests/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: jmeter-workflow-smoke labels: @@ -11,11 +11,12 @@ spec: revision: main paths: - test/jmeter/executor-tests/jmeter-executor-smoke.jmx - resources: - requests: - cpu: 512m - memory: 512Mi - workingDir: /data/repo/test/jmeter/executor-tests + container: + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/test/jmeter/executor-tests steps: - name: Run tests run: diff --git a/test/k6/executor-tests/crd-workflow/smoke.yaml b/test/k6/executor-tests/crd-workflow/smoke.yaml index dbbf129101b..db55ad4aba5 100644 --- a/test/k6/executor-tests/crd-workflow/smoke.yaml +++ b/test/k6/executor-tests/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: k6-workflow-smoke labels: @@ -11,11 +11,12 @@ spec: revision: main paths: - test/k6/executor-tests/k6-smoke-test.js - resources: - requests: - cpu: 128m - memory: 128Mi - workingDir: /data/repo/test/k6/executor-tests + container: + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests steps: - name: Run test run: @@ -29,24 +30,25 @@ spec: - name: K6_SYSTEM_ENV value: K6_SYSTEM_ENV_value --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: k6-workflow-smoke-preofficial-trait labels: core-tests: workflows spec: - resources: - requests: - cpu: 128m - memory: 128Mi - workingDir: /data/repo/test/k6/executor-tests - env: - - name: K6_SYSTEM_ENV # currently only possible on this level - value: K6_SYSTEM_ENV_value + container: + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + env: + - name: K6_SYSTEM_ENV # currently only possible on this level + value: K6_SYSTEM_ENV_value steps: - name: Checkout - checkout: + content: git: uri: https://github.com/kubeshop/testkube revision: main @@ -54,14 +56,14 @@ spec: - test/k6/executor-tests/k6-smoke-test.js - name: Run from trait workingDir: /data/repo/test/k6/executor-tests - trait: + template: name: pre-official/k6 config: version: 0.48.0 params: "k6-smoke-test.js -e K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value" --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: k6-workflow-smoke-preofficial-trait-without-checkout-step labels: @@ -73,18 +75,19 @@ spec: revision: main paths: - test/k6/executor-tests/k6-smoke-test.js - resources: - requests: - cpu: 128m - memory: 128Mi - workingDir: /data/repo/test/k6/executor-tests - env: - - name: K6_SYSTEM_ENV # currently only possible on this level - value: K6_SYSTEM_ENV_value + container: + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + env: + - name: K6_SYSTEM_ENV # currently only possible on this level + value: K6_SYSTEM_ENV_value steps: - name: Run from trait workingDir: /data/repo/test/k6/executor-tests - trait: + template: name: pre-official/k6 config: version: 0.48.0 diff --git a/test/maven/executor-smoke/crd-workflow/smoke.yaml b/test/maven/executor-smoke/crd-workflow/smoke.yaml index b2f7be34ae4..68defca22be 100644 --- a/test/maven/executor-smoke/crd-workflow/smoke.yaml +++ b/test/maven/executor-smoke/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: maven-workflow-smoke-jdk11 labels: @@ -11,11 +11,12 @@ spec: revision: main paths: - contrib/executor/maven/examples/hello-maven - resources: - requests: - cpu: 256m - memory: 256Mi - workingDir: /data/repo/contrib/executor/maven/examples/hello-maven + container: + resources: + requests: + cpu: 256m + memory: 256Mi + workingDir: /data/repo/contrib/executor/maven/examples/hello-maven steps: - name: Run tests run: diff --git a/test/playwright/executor-tests/crd-workflow/smoke.yaml b/test/playwright/executor-tests/crd-workflow/smoke.yaml index 55517a678a7..d1cdb686b83 100644 --- a/test/playwright/executor-tests/crd-workflow/smoke.yaml +++ b/test/playwright/executor-tests/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: playwright-workflow-smoke-v1.32.3 labels: @@ -11,11 +11,54 @@ spec: revision: main paths: - test/playwright/executor-tests/playwright-project - resources: - requests: - cpu: 2 - memory: 2Gi - workingDir: /data/repo/test/playwright/executor-tests/playwright-project + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + steps: + - name: Install dependencies + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - npm + args: + - ci + - name: Run tests + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - "npx" + args: + - "--yes" + - "playwright@1.32.3" + - "test" + - name: Save artifacts + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + artifacts: + paths: + - playwright-report/**/* +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: playwright-workflow-smoke-v1.32.3-custom-report-dir + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/playwright/executor-tests/playwright-project + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project steps: - name: Install dependencies run: @@ -35,35 +78,122 @@ spec: - "test" - "--output" - "/data/artifacts" + env: + - name: PLAYWRIGHT_HTML_REPORT + value: /data/artifacts/playwright-report + steps: + - name: Save artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: playwright-workflow-smoke-v1.32.3-shell + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/playwright/executor-tests/playwright-project + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + steps: + - name: Install dependencies + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - npm + args: + - ci + - name: Run tests + shell: "npx --yes playwright@1.32.3 test --output /data/artifacts && cd /data/artifacts && ls -lah" + container: + image: mcr.microsoft.com/playwright:v1.32.3-focal + env: + - name: PLAYWRIGHT_HTML_REPORT + value: /data/artifacts/playwright-report + steps: + - name: Save artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: playwright-workflow-smoke-artifacts-double-asterisk + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/playwright/executor-tests/playwright-project + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + steps: + - name: Install dependencies + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - npm + args: + - ci + - name: Run tests + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - "npx" + args: + - "--yes" + - "playwright@1.32.3" + - "test" - name: Save artifacts - workingDir: /data/artifacts artifacts: paths: - - '*' + - /data/repo/**/playwright-report/**/* +--- --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: playwright-workflow-smoke-official-trait labels: core-tests: workflows spec: - resources: - requests: - cpu: 2 - memory: 2Gi - workingDir: /data/repo/test/playwright/executor-tests/playwright-project + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project steps: - - name: Checkout - checkout: + - name: Run from trait + content: git: uri: https://github.com/kubeshop/testkube revision: main paths: - test/playwright/executor-tests/playwright-project - - name: Run from trait workingDir: /data/repo/test/playwright/executor-tests/playwright-project - trait: + template: name: official/playwright config: # params: --workers 4 diff --git a/test/postman/executor-tests/crd-workflow/smoke.yaml b/test/postman/executor-tests/crd-workflow/smoke.yaml index 5ee6d6f07d0..be7b2bfed78 100644 --- a/test/postman/executor-tests/crd-workflow/smoke.yaml +++ b/test/postman/executor-tests/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: postman-workflow-smoke labels: @@ -11,11 +11,12 @@ spec: revision: main paths: - test/postman/executor-tests/postman-executor-smoke.postman_collection.json - resources: - requests: - cpu: 256m - memory: 128Mi - workingDir: /data/repo/test/postman/executor-tests + container: + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests steps: - name: Run test run: @@ -26,8 +27,8 @@ spec: - "--env-var" - "TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value" --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: postman-workflow-smoke-without-envs labels: @@ -39,11 +40,12 @@ spec: revision: main paths: - test/postman/executor-tests/postman-executor-smoke-without-envs.postman_collection.json - resources: - requests: - cpu: 256m - memory: 128Mi - workingDir: /data/repo/test/postman/executor-tests + container: + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests steps: - name: Run test run: @@ -52,8 +54,8 @@ spec: - run - postman-executor-smoke-without-envs.postman_collection.json --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: postman-workflow-smoke-preofficial-trait labels: @@ -65,21 +67,22 @@ spec: revision: main paths: - test/postman/executor-tests/postman-executor-smoke.postman_collection.json - resources: - requests: - cpu: 256m - memory: 128Mi - workingDir: /data/repo/test/postman/executor-tests + container: + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests steps: - name: Run from trait workingDir: /data/repo/test/postman/executor-tests - trait: + template: name: pre-official/postman config: params: "postman-executor-smoke.postman_collection.json --env-var TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value" --- -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: postman-workflow-smoke-preofficial-trait-without-envs labels: @@ -91,14 +94,15 @@ spec: revision: main paths: - test/postman/executor-tests/postman-executor-smoke-without-envs.postman_collection.json - resources: - requests: - cpu: 256m - memory: 128Mi - workingDir: /data/repo/test/postman/executor-tests + container: + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests steps: - name: Run from trait - trait: + template: name: pre-official/postman config: params: "postman-executor-smoke-without-envs.postman_collection.json" diff --git a/test/scripts/executor-tests/run.sh b/test/scripts/executor-tests/run.sh index 989e41aa23c..c0c8904b4a0 100755 --- a/test/scripts/executor-tests/run.sh +++ b/test/scripts/executor-tests/run.sh @@ -454,8 +454,9 @@ workflow-playwright-smoke() { workflow-postman-smoke() { name="Test Workflow - Postman" workflow_crd_file="test/postman/executor-tests/crd-workflow/smoke.yaml" + custom_workflow_template_crd_file="test/test-workflow-templates/postman.yaml" - common_workflow_run "$name" "$workflow_crd_file" + common_workflow_run "$name" "$workflow_crd_file" "$custom_workflow_template_crd_file" } workflow-soapui-smoke() { diff --git a/test/soapui/executor-smoke/crd-workflow/smoke.yaml b/test/soapui/executor-smoke/crd-workflow/smoke.yaml index afd8b558e11..d4404ad25b5 100644 --- a/test/soapui/executor-smoke/crd-workflow/smoke.yaml +++ b/test/soapui/executor-smoke/crd-workflow/smoke.yaml @@ -1,5 +1,5 @@ -apiVersion: workflows.testkube.io/v1beta1 -kind: Workflow +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow metadata: name: soapui-workflow-smoke labels: @@ -11,10 +11,11 @@ spec: revision: main paths: - test/soapui/executor-smoke/soapui-smoke-test.xml - resources: - requests: - cpu: 512m - memory: 256Mi + container: + resources: + requests: + cpu: 512m + memory: 256Mi steps: - name: Run tests run: diff --git a/test/test-workflow-templates/cypress.yaml b/test/test-workflow-templates/cypress.yaml index 55dd705af4b..816d915e976 100644 --- a/test/test-workflow-templates/cypress.yaml +++ b/test/test-workflow-templates/cypress.yaml @@ -1,5 +1,5 @@ -kind: WorkflowTemplate -apiVersion: workflows.testkube.io/v1beta1 +kind: TestWorkflowTemplate +apiVersion: testworkflows.testkube.io/v1 metadata: name: pre-official--cypress spec: @@ -18,10 +18,12 @@ spec: default: "" steps: - name: Install dependencies - run: + container: image: cypress/included:{{ config.version }} - shell: '{{ config.dependencies_command }}' + shell: '{{ config.dependencies_command }}' + + - name: Run Cypress tests - run: + container: image: cypress/included:{{ config.version }} - shell: npx cypress run {{ config.params }} + shell: npx cypress run {{ config.params }} diff --git a/test/test-workflow-templates/k6.yaml b/test/test-workflow-templates/k6.yaml index 49f663846da..78e242dbbe2 100644 --- a/test/test-workflow-templates/k6.yaml +++ b/test/test-workflow-templates/k6.yaml @@ -1,5 +1,5 @@ -kind: Trait -apiVersion: workflows.testkube.io/v1beta1 +kind: TestWorkflowTemplate +apiVersion: testworkflows.testkube.io/v1 metadata: name: pre-official--k6 spec: @@ -14,6 +14,6 @@ spec: default: "" steps: - name: Run k6 tests - run: + container: image: grafana/k6:{{ config.version }} - shell: k6 run {{ config.params }} + shell: k6 run {{ config.params }} diff --git a/test/test-workflow-templates/postman.yaml b/test/test-workflow-templates/postman.yaml index f74b9307ca7..cd65e9d3dd7 100644 --- a/test/test-workflow-templates/postman.yaml +++ b/test/test-workflow-templates/postman.yaml @@ -1,5 +1,5 @@ -kind: Trait -apiVersion: workflows.testkube.io/v1beta1 +kind: TestWorkflowTemplate +apiVersion: testworkflows.testkube.io/v1 metadata: name: pre-official--postman spec: @@ -14,6 +14,6 @@ spec: default: "" steps: - name: Run Postman tests - run: + container: image: postman/newman:{{ config.version }} - shell: newman run {{ config.params }} + shell: newman run {{ config.params }} From 2a0ed87f13502564aa2eeeb34421f1df927ff914 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 12 Mar 2024 09:01:56 +0100 Subject: [PATCH 200/234] fix: send Content-Type of TestWorkflow artifacts for signing URL (#5146) --- .../artifacts/cloud_uploader.go | 21 +++++++++++++++++-- pkg/cloud/data/artifact/scraper_model.go | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go b/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go index 541af6fc929..657f3689d4d 100644 --- a/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go +++ b/cmd/tcl/testworkflow-toolkit/artifacts/cloud_uploader.go @@ -9,6 +9,7 @@ package artifacts import ( + "bytes" "context" "crypto/tls" "encoding/json" @@ -57,13 +58,14 @@ func (d *cloudUploader) Start() (err error) { return err } -func (d *cloudUploader) getSignedURL(name string) (string, error) { +func (d *cloudUploader) getSignedURL(name, contentType string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() response, err := d.client.Execute(ctx, artifact.CmdScraperPutObjectSignedURL, &artifact.PutObjectSignedURLRequest{ Object: name, ExecutionID: env.ExecutionId(), TestWorkflowName: env.WorkflowName(), + ContentType: contentType, }) if err != nil { return "", err @@ -75,6 +77,21 @@ func (d *cloudUploader) getSignedURL(name string) (string, error) { return commandResponse.URL, nil } +func (d *cloudUploader) getContentType(path string, size int64) string { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, "/", &bytes.Buffer{}) + if err != nil { + return "" + } + for _, r := range d.reqEnhancers { + r(req, path, size) + } + contentType := req.Header.Get("Content-Type") + if contentType == "" { + return "application/octet-stream" + } + return contentType +} + func (d *cloudUploader) putObject(url string, path string, file io.Reader, size int64) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() @@ -104,7 +121,7 @@ func (d *cloudUploader) putObject(url string, path string, file io.Reader, size } func (d *cloudUploader) upload(path string, file io.Reader, size int64) { - url, err := d.getSignedURL(path) + url, err := d.getSignedURL(path, d.getContentType(path, size)) if err != nil { d.error.Store(true) ui.Errf("%s: failed: get signed URL: %s", path, err.Error()) diff --git a/pkg/cloud/data/artifact/scraper_model.go b/pkg/cloud/data/artifact/scraper_model.go index 22bd7ef2d3e..f1b73c9a8f2 100644 --- a/pkg/cloud/data/artifact/scraper_model.go +++ b/pkg/cloud/data/artifact/scraper_model.go @@ -8,6 +8,7 @@ const ( type PutObjectSignedURLRequest struct { Object string `json:"object"` + ContentType string `json:"contentType,omitempty"` ExecutionID string `json:"executionId"` TestName string `json:"testName"` TestSuiteName string `json:"testSuiteName"` From d4264af3c949b65da674cfa8ced2f42d12c878cf Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Mon, 11 Mar 2024 22:36:56 +0300 Subject: [PATCH 201/234] fix: variable parsing --- cmd/kubectl-testkube/commands/common/flags.go | 23 ++++++++++++++----- cmd/kubectl-testkube/commands/tests/create.go | 8 +++---- cmd/kubectl-testkube/commands/tests/run.go | 8 +++---- .../commands/testsuites/create.go | 8 +++---- .../commands/testsuites/run.go | 8 +++---- .../commands/testsuites/update.go | 8 +++---- 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/cmd/kubectl-testkube/commands/common/flags.go b/cmd/kubectl-testkube/commands/common/flags.go index 0d710ce2e79..eecf5a73766 100644 --- a/cmd/kubectl-testkube/commands/common/flags.go +++ b/cmd/kubectl-testkube/commands/common/flags.go @@ -11,24 +11,35 @@ import ( ) func CreateVariables(cmd *cobra.Command, ignoreSecretVariable bool) (vars map[string]testkube.Variable, err error) { - basicParams, err := cmd.Flags().GetStringToString("variable") + basicParams, err := cmd.Flags().GetStringArray("variable") if err != nil { return vars, err } vars = map[string]testkube.Variable{} - for k, v := range basicParams { - vars[k] = testkube.NewBasicVariable(k, v) + for _, v := range basicParams { + values := strings.SplitN(v, "=", 2) + if len(values) != 2 { + return vars, errors.New("wrong number of variable params") + } + + vars[values[0]] = testkube.NewBasicVariable(values[0], values[1]) } if !ignoreSecretVariable { - secretParams, err := cmd.Flags().GetStringToString("secret-variable") + secretParams, err := cmd.Flags().GetStringArray("secret-variable") if err != nil { return vars, err } - for k, v := range secretParams { - vars[k] = testkube.NewSecretVariable(k, v) + + for _, v := range secretParams { + values := strings.SplitN(v, "=", 2) + if len(values) != 2 { + return vars, errors.New("wrong number of secret variable params") + } + + vars[values[0]] = testkube.NewSecretVariable(values[0], values[1]) } } diff --git a/cmd/kubectl-testkube/commands/tests/create.go b/cmd/kubectl-testkube/commands/tests/create.go index 66d78acb11b..73cf8b77fa0 100644 --- a/cmd/kubectl-testkube/commands/tests/create.go +++ b/cmd/kubectl-testkube/commands/tests/create.go @@ -20,8 +20,8 @@ import ( type CreateCommonFlags struct { ExecutorType string Labels map[string]string - Variables map[string]string - SecretVariables map[string]string + Variables []string + SecretVariables []string Schedule string ExecutorArgs []string ArgsMode string @@ -230,8 +230,8 @@ func AddCreateFlags(cmd *cobra.Command, flags *CreateCommonFlags) { cmd.Flags().StringVarP(&flags.ExecutorType, "type", "t", "", "test type") cmd.Flags().StringToStringVarP(&flags.Labels, "label", "l", nil, "label key value pair: --label key1=value1") - cmd.Flags().StringToStringVarP(&flags.Variables, "variable", "v", nil, "variable key value pair: --variable key1=value1") - cmd.Flags().StringToStringVarP(&flags.SecretVariables, "secret-variable", "s", nil, "secret variable key value pair: --secret-variable key1=value1") + cmd.Flags().StringArrayVarP(&flags.Variables, "variable", "v", nil, "variable key value pair: --variable key1=value1") + cmd.Flags().StringArrayVarP(&flags.SecretVariables, "secret-variable", "s", nil, "secret variable key value pair: --secret-variable key1=value1") cmd.Flags().StringVarP(&flags.Schedule, "schedule", "", "", "test schedule in a cron job form: * * * * *") cmd.Flags().StringArrayVar(&flags.Command, "command", []string{}, "command passed to image in executor") cmd.Flags().StringArrayVarP(&flags.ExecutorArgs, "executor-args", "", []string{}, "executor binary additional arguments") diff --git a/cmd/kubectl-testkube/commands/tests/run.go b/cmd/kubectl-testkube/commands/tests/run.go index 6f2b26c761a..1c759457ed1 100644 --- a/cmd/kubectl-testkube/commands/tests/run.go +++ b/cmd/kubectl-testkube/commands/tests/run.go @@ -25,8 +25,8 @@ func NewRunTestCmd() *cobra.Command { iterations int watchEnabled bool binaryArgs []string - variables map[string]string - secretVariables map[string]string + variables []string + secretVariables []string variablesFile string downloadArtifactsEnabled bool downloadDir string @@ -348,8 +348,8 @@ func NewRunTestCmd() *cobra.Command { cmd.Flags().StringVarP(&name, "name", "n", "", "execution name, if empty will be autogenerated") cmd.Flags().StringVarP(&image, "image", "", "", "override executor container image") cmd.Flags().StringVarP(&variablesFile, "variables-file", "", "", "variables file path, e.g. postman env file - will be passed to executor if supported") - cmd.Flags().StringToStringVarP(&variables, "variable", "v", map[string]string{}, "execution variable passed to executor") - cmd.Flags().StringToStringVarP(&secretVariables, "secret-variable", "s", map[string]string{}, "execution secret variable passed to executor") + cmd.Flags().StringArrayVarP(&variables, "variable", "v", []string{}, "execution variable passed to executor") + cmd.Flags().StringArrayVarP(&secretVariables, "secret-variable", "s", []string{}, "execution secret variable passed to executor") cmd.Flags().StringArrayVar(&command, "command", []string{}, "command passed to image in executor") cmd.Flags().StringArrayVarP(&binaryArgs, "args", "", []string{}, "executor binary additional arguments") cmd.Flags().StringVarP(&argsMode, "args-mode", "", "append", "usage mode for argumnets. one of append|override|replace") diff --git a/cmd/kubectl-testkube/commands/testsuites/create.go b/cmd/kubectl-testkube/commands/testsuites/create.go index 1ab55360574..08dc6745bc7 100644 --- a/cmd/kubectl-testkube/commands/testsuites/create.go +++ b/cmd/kubectl-testkube/commands/testsuites/create.go @@ -21,8 +21,8 @@ func NewCreateTestSuitesCmd() *cobra.Command { name string file string labels map[string]string - variables map[string]string - secretVariables map[string]string + variables []string + secretVariables []string schedule string executionName string httpProxy, httpsProxy string @@ -104,8 +104,8 @@ func NewCreateTestSuitesCmd() *cobra.Command { cmd.Flags().StringVarP(&file, "file", "f", "", "JSON test suite file - will be read from stdin if not specified, look at testkube.TestUpsertRequest") cmd.Flags().StringVar(&name, "name", "", "Set/Override test suite name") cmd.Flags().StringToStringVarP(&labels, "label", "l", nil, "label key value pair: --label key1=value1") - cmd.Flags().StringToStringVarP(&variables, "variable", "v", nil, "param key value pair: --variable key1=value1") - cmd.Flags().StringToStringVarP(&secretVariables, "secret-variable", "s", nil, "secret variable key value pair: --secret-variable key1=value1") + cmd.Flags().StringArrayVarP(&variables, "variable", "v", nil, "param key value pair: --variable key1=value1") + cmd.Flags().StringArrayVarP(&secretVariables, "secret-variable", "s", nil, "secret variable key value pair: --secret-variable key1=value1") cmd.Flags().StringVarP(&schedule, "schedule", "", "", "test suite schedule in a cron job form: * * * * *") cmd.Flags().StringVarP(&executionName, "execution-name", "", "", "execution name, if empty will be autogenerated") cmd.Flags().StringVar(&httpProxy, "http-proxy", "", "http proxy for executor containers") diff --git a/cmd/kubectl-testkube/commands/testsuites/run.go b/cmd/kubectl-testkube/commands/testsuites/run.go index ef85cb3d0b7..e0e435bdb24 100644 --- a/cmd/kubectl-testkube/commands/testsuites/run.go +++ b/cmd/kubectl-testkube/commands/testsuites/run.go @@ -24,8 +24,8 @@ func NewRunTestSuiteCmd() *cobra.Command { var ( name string watchEnabled bool - variables map[string]string - secretVariables map[string]string + variables []string + secretVariables []string executionLabels map[string]string selectors []string concurrencyLevel int @@ -190,8 +190,8 @@ func NewRunTestSuiteCmd() *cobra.Command { } cmd.Flags().StringVarP(&name, "name", "n", "", "execution name, if empty will be autogenerated") - cmd.Flags().StringToStringVarP(&variables, "variable", "v", map[string]string{}, "execution variables passed to executor") - cmd.Flags().StringToStringVarP(&secretVariables, "secret-variable", "s", map[string]string{}, "execution variables passed to executor") + cmd.Flags().StringArrayVarP(&variables, "variable", "v", []string{}, "execution variables passed to executor") + cmd.Flags().StringArrayVarP(&secretVariables, "secret-variable", "s", []string{}, "execution variables passed to executor") cmd.Flags().BoolVarP(&watchEnabled, "watch", "f", false, "watch for changes after start") cmd.Flags().StringSliceVarP(&selectors, "label", "l", nil, "label key value pair: --label key1=value1") cmd.Flags().IntVar(&concurrencyLevel, "concurrency", 10, "concurrency level for multiple test suite execution") diff --git a/cmd/kubectl-testkube/commands/testsuites/update.go b/cmd/kubectl-testkube/commands/testsuites/update.go index abd0772f831..238c391d815 100644 --- a/cmd/kubectl-testkube/commands/testsuites/update.go +++ b/cmd/kubectl-testkube/commands/testsuites/update.go @@ -15,8 +15,8 @@ func UpdateTestSuitesCmd() *cobra.Command { labels map[string]string schedule string executionName string - variables map[string]string - secretVariables map[string]string + variables []string + secretVariables []string httpProxy, httpsProxy string secretVariableReferences map[string]string timeout int32 @@ -64,8 +64,8 @@ func UpdateTestSuitesCmd() *cobra.Command { cmd.Flags().StringVarP(&file, "file", "f", "", "JSON test file - will be read from stdin if not specified, look at testkube.TestUpsertRequest") cmd.Flags().StringVar(&name, "name", "", "Set/Override test suite name") cmd.Flags().StringToStringVarP(&labels, "label", "l", nil, "label key value pair: --label key1=value1") - cmd.Flags().StringToStringVarP(&variables, "variable", "v", nil, "param key value pair: --variable key1=value1") - cmd.Flags().StringToStringVarP(&secretVariables, "secret-variable", "s", nil, "secret variable key value pair: --secret-variable key1=value1") + cmd.Flags().StringArrayVarP(&variables, "variable", "v", nil, "param key value pair: --variable key1=value1") + cmd.Flags().StringArrayVarP(&secretVariables, "secret-variable", "s", nil, "secret variable key value pair: --secret-variable key1=value1") cmd.Flags().StringVarP(&schedule, "schedule", "", "", "test suite schedule in a cron job form: * * * * *") cmd.Flags().StringVarP(&executionName, "execution-name", "", "", "execution name, if empty will be autogenerated") cmd.Flags().StringVar(&httpProxy, "http-proxy", "", "http proxy for executor containers") From 4ef1911426441ccba1cb3ad012294302dc39146e Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 12 Mar 2024 11:40:52 +0100 Subject: [PATCH 202/234] chore: add more logs regarding TestWorkflow controller (#5153) --- .../testworkflowstcl/testworkflowexecutor/executor.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go index f88a4cb450e..6201658e7f4 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go @@ -150,7 +150,7 @@ func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowE err := writer.Close() if err != nil { - log.DefaultLogger.Error(errors.Wrap(err, "saving log output - closing stream")) + log.DefaultLogger.Errorw("failed to close TestWorkflow log output stream", "id", execution.Id, "error", err) } // TODO: Consider AppendOutput ($push) instead @@ -169,9 +169,13 @@ func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowE // Stream the log into Minio err = e.output.SaveLog(context.Background(), execution.Id, execution.Workflow.Name, reader) if err != nil { - log.DefaultLogger.Error(errors.Wrap(err, "saving log output")) + log.DefaultLogger.Errorw("failed to save TestWorkflow log output", "id", execution.Id, "error", err) } wg.Wait() - testworkflowcontroller.Cleanup(ctx, e.clientSet, e.namespace, execution.Id) + + err = testworkflowcontroller.Cleanup(ctx, e.clientSet, e.namespace, execution.Id) + if err != nil { + log.DefaultLogger.Errorw("failed to cleanup TestWorkflow resources", "id", execution.Id, "error", err) + } } From 3e0f5e8ad43c4477c1a57ae83fc9ea3a7dc6c162 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 12 Mar 2024 11:54:15 +0100 Subject: [PATCH 203/234] fix: don't treat TestWorkflow pods as Test pods (#5154) --- pkg/triggers/watcher.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/triggers/watcher.go b/pkg/triggers/watcher.go index cb85ee7b37b..20ca1afa31b 100644 --- a/pkg/triggers/watcher.go +++ b/pkg/triggers/watcher.go @@ -18,6 +18,7 @@ import ( executorv1 "github.com/kubeshop/testkube-operator/api/executor/v1" testsourcev1 "github.com/kubeshop/testkube-operator/api/testsource/v1" + "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" @@ -300,6 +301,9 @@ func (s *Service) podEventHandler(ctx context.Context) cache.ResourceEventHandle } func (s *Service) checkExecutionPodStatus(ctx context.Context, executionID string, pods []*corev1.Pod) error { + if len(pods) > 0 && pods[0].Labels[testworkflowprocessor.ExecutionIdLabelName] != "" { + return nil + } execution, err := s.resultRepository.Get(ctx, executionID) if err != nil { s.logger.Errorf("get execution returned an error %v while looking for execution id: %s", err, executionID) From e63c05517f082f7199e3a722b8c084359924f0ec Mon Sep 17 00:00:00 2001 From: Michal Dygas Date: Tue, 12 Mar 2024 11:33:28 +0100 Subject: [PATCH 204/234] fix(docs:deploying-in-aws): correct api path from /results/v1 to /v1 --- docs/docs/articles/deploying-in-aws.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/articles/deploying-in-aws.md b/docs/docs/articles/deploying-in-aws.md index d6c6cb4c960..eceedeefa7e 100644 --- a/docs/docs/articles/deploying-in-aws.md +++ b/docs/docs/articles/deploying-in-aws.md @@ -49,7 +49,7 @@ uiIngress: alb.ingress.kubernetes.io/healthcheck-port: "8088" alb.ingress.kubernetes.io/ssl-redirect: "443" alb.ingress.kubernetes.io/certificate-arn: "arn:aws:acm:us-east-1:*******:certificate/*****" - path: /results/v1 + path: /v1 hosts: - test-api.aws.testkube.io ``` @@ -77,7 +77,7 @@ path: / :::caution -Do not forget to add `apiServerEndpoint` to the `values.yaml` for `testkube-dashboard`, e.g.: `apiServerEndpoint: "test-api.aws.testkube.io/results/v1"`. +Do not forget to add `apiServerEndpoint` to the `values.yaml` for `testkube-dashboard`, e.g.: `apiServerEndpoint: "test-api.aws.testkube.io/v1"`. ::: @@ -134,7 +134,7 @@ spec: - host: test-api.aws.testkube.io http: paths: - - path: /results/v1 + - path: /v1 pathType: Prefix backend: service: @@ -169,7 +169,7 @@ service: :::caution -Do not forget to add `apiServerEndpoint` to the values.yaml for `testkube-dashboard`, e.g.: `apiServerEndpoint: "test-api.aws.testkube.io/results/v1"`. +Do not forget to add `apiServerEndpoint` to the values.yaml for `testkube-dashboard`, e.g.: `apiServerEndpoint: "test-api.aws.testkube.io/v1"`. ::: From 490847329cc4d3dbca87d0f7634fa82a75e66a12 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 12 Mar 2024 16:10:28 +0300 Subject: [PATCH 205/234] fix: list testsuite artifacts --- internal/app/api/v1/testsuites.go | 74 +++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/internal/app/api/v1/testsuites.go b/internal/app/api/v1/testsuites.go index 7f8b2525134..686d74e34e3 100644 --- a/internal/app/api/v1/testsuites.go +++ b/internal/app/api/v1/testsuites.go @@ -692,38 +692,26 @@ func (s TestkubeAPI) ListTestSuiteArtifactsHandler() fiber.Handler { var artifacts []testkube.Artifact for _, stepResult := range execution.StepResults { - if stepResult.Execution.Id == "" { + if stepResult.Execution == nil || stepResult.Execution.Id == "" { continue } - var stepArtifacts []testkube.Artifact - var bucket string - artifactsStorage := s.ArtifactsStorage - folder := stepResult.Execution.Id - if stepResult.Execution.ArtifactRequest != nil { - bucket = stepResult.Execution.ArtifactRequest.StorageBucket - if stepResult.Execution.ArtifactRequest.OmitFolderPerExecution { - folder = "" - } + artifacts, err = s.getExecutionArtfacts(c.Context(), stepResult.Execution, artifacts) + if err != nil { + continue } + } - if bucket != "" { - artifactsStorage, err = s.getArtifactStorage(bucket) - if err != nil { - s.Log.Warnw("can't get artifact storage", "executionID", stepResult.Execution.Id, "error", err) + for _, stepResults := range execution.ExecuteStepResults { + for _, stepResult := range stepResults.Execute { + if stepResult.Execution == nil || stepResult.Execution.Id == "" { continue } - } - stepArtifacts, err = artifactsStorage.ListFiles(c.Context(), folder, stepResult.Execution.TestName, stepResult.Execution.TestSuiteName, "") - if err != nil { - s.Log.Warnw("can't list artifacts", "executionID", stepResult.Execution.Id, "error", err) - continue - } - s.Log.Debugw("listing artifacts for step", "executionID", stepResult.Execution.Id, "artifacts", stepArtifacts) - for i := range stepArtifacts { - stepArtifacts[i].ExecutionName = stepResult.Execution.Name - artifacts = append(artifacts, stepArtifacts[i]) + artifacts, err = s.getExecutionArtfacts(c.Context(), stepResult.Execution, artifacts) + if err != nil { + continue + } } } @@ -735,6 +723,44 @@ func (s TestkubeAPI) ListTestSuiteArtifactsHandler() fiber.Handler { } } +func (s TestkubeAPI) getExecutionArtfacts(ctx context.Context, execution *testkube.Execution, + artifacts []testkube.Artifact) ([]testkube.Artifact, error) { + var stepArtifacts []testkube.Artifact + var bucket string + + artifactsStorage := s.ArtifactsStorage + folder := execution.Id + if execution.ArtifactRequest != nil { + bucket = execution.ArtifactRequest.StorageBucket + if execution.ArtifactRequest.OmitFolderPerExecution { + folder = "" + } + } + + var err error + if bucket != "" { + artifactsStorage, err = s.getArtifactStorage(bucket) + if err != nil { + s.Log.Warnw("can't get artifact storage", "executionID", execution.Id, "error", err) + return artifacts, err + } + } + + stepArtifacts, err = artifactsStorage.ListFiles(ctx, folder, execution.TestName, execution.TestSuiteName, "") + if err != nil { + s.Log.Warnw("can't list artifacts", "executionID", execution.Id, "error", err) + return artifacts, err + } + + s.Log.Debugw("listing artifacts for step", "executionID", execution.Id, "artifacts", stepArtifacts) + for i := range stepArtifacts { + stepArtifacts[i].ExecutionName = execution.Name + artifacts = append(artifacts, stepArtifacts[i]) + } + + return artifacts, nil +} + // AbortTestSuiteHandler for aborting a TestSuite with id func (s TestkubeAPI) AbortTestSuiteHandler() fiber.Handler { return func(c *fiber.Ctx) error { From 13873646ef762d68a4b338a28782bab1b99d48cd Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Tue, 12 Mar 2024 14:44:10 +0100 Subject: [PATCH 206/234] fix: [TKC-1714] test suite results with step params (#5156) * fix: delete remainder of tcl licensed steps * fix: cleanups * dep: operator dependency --- api/v1/testkube.yaml | 3 - docs/docs/articles/creating-test-suites.md | 2 +- go.mod | 8 +- go.sum | 6 +- internal/app/api/v1/testsuites.go | 68 ----------- ...model_test_suite_step_execution_request.go | 2 - pkg/mapper/testsuites/kube_openapi.go | 1 - pkg/mapper/testsuites/openapi_kube.go | 1 - pkg/scheduler/testsuite_scheduler.go | 1 - pkg/tcl/testsuitestcl/steps.go | 109 ------------------ 10 files changed, 9 insertions(+), 192 deletions(-) delete mode 100644 pkg/tcl/testsuitestcl/steps.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index d5174f0ea44..eb40067b951 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -6302,9 +6302,6 @@ components: - append - override - replace - sync: - type: boolean - description: whether to start execution sync or async httpProxy: type: string description: http proxy for executor containers diff --git a/docs/docs/articles/creating-test-suites.md b/docs/docs/articles/creating-test-suites.md index 6aeac587f9a..0f127a65d24 100644 --- a/docs/docs/articles/creating-test-suites.md +++ b/docs/docs/articles/creating-test-suites.md @@ -137,7 +137,7 @@ For details on which parameters are available in the CRDs, please consult the ta | command | ✓ | | ✓ | | image | ✓ | | | | imagePullSecrets | ✓ | | | -| sync | ✓ | ✓ | ✓ | +| sync | ✓ | ✓ | | | httpProxy | ✓ | ✓ | ✓ | | httpsProxy | ✓ | ✓ | ✓ | | negativeTest | ✓ | | | diff --git a/go.mod b/go.mod index ca880aaf730..0c6089e9fa0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/99designs/gqlgen v0.17.27 github.com/Masterminds/semver v1.5.0 github.com/adhocore/gronx v1.6.3 + github.com/bmatcuk/doublestar/v4 v4.6.1 github.com/cdevents/sdk-go v0.3.0 github.com/cli/cli/v2 v2.20.2 github.com/cloudevents/sdk-go/v2 v2.15.2 @@ -23,11 +24,12 @@ require ( github.com/golang/mock v1.6.0 github.com/gookit/color v1.5.3 github.com/gorilla/websocket v1.5.0 + github.com/h2non/filetype v1.1.3 github.com/joshdk/go-junit v1.0.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240307074605-059fddf0f7d9 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312130250-6625e24b02b3 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 @@ -68,7 +70,6 @@ require ( github.com/alecthomas/chroma v0.10.0 // indirect github.com/aymanbagabas/go-osc52 v1.2.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect github.com/briandowns/spinner v1.19.0 // indirect github.com/charmbracelet/glamour v0.6.0 // indirect @@ -92,7 +93,6 @@ require ( github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.1 // indirect github.com/henvic/httpretty v0.1.0 // indirect github.com/itchyny/gojq v0.12.14 // indirect @@ -142,7 +142,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 github.com/evanphx/json-patch v5.7.0+incompatible // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.3.0 // indirect diff --git a/go.sum b/go.sum index 722a4e12b7f..fc736e5eba4 100644 --- a/go.sum +++ b/go.sum @@ -360,8 +360,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240307074605-059fddf0f7d9 h1:4NPJCYprmVs47UDLOFQX3h7LnNN54af+6s1tV2Q1ZxE= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240307074605-059fddf0f7d9/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312123615-29580e66708a h1:JTuSWub/+r1Bvx1caz5oaXABSzCRJwLHJtL6LBN2e3Q= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312123615-29580e66708a/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312130250-6625e24b02b3 h1:ON23pyENTeGVUnDx8LT3IWK6T1eERC50qgSDjaCw7rw= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312130250-6625e24b02b3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/internal/app/api/v1/testsuites.go b/internal/app/api/v1/testsuites.go index 686d74e34e3..8e64af8cd03 100644 --- a/internal/app/api/v1/testsuites.go +++ b/internal/app/api/v1/testsuites.go @@ -898,71 +898,3 @@ func getExecutionsFilterFromRequest(c *fiber.Ctx) testresult.Filter { return filter } - -// MergeStepRequest inherits step request fields with execution request -func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, executionRequest testkube.ExecutionRequest) testkube.ExecutionRequest { - if stepRequest == nil { - return executionRequest - } - if stepRequest.ExecutionLabels != nil { - executionRequest.ExecutionLabels = stepRequest.ExecutionLabels - } - - if stepRequest.Variables != nil { - executionRequest.Variables = mergeVariables(executionRequest.Variables, stepRequest.Variables) - } - - if len(stepRequest.Args) != 0 { - if stepRequest.ArgsMode == string(testkube.ArgsModeTypeAppend) || stepRequest.ArgsMode == "" { - executionRequest.Args = append(executionRequest.Args, stepRequest.Args...) - } - - if stepRequest.ArgsMode == string(testkube.ArgsModeTypeOverride) || stepRequest.ArgsMode == string(testkube.ArgsModeTypeReplace) { - executionRequest.Args = stepRequest.Args - } - } - - if stepRequest.Command != nil { - executionRequest.Command = stepRequest.Command - } - executionRequest.Sync = stepRequest.Sync - executionRequest.HttpProxy = setStringField(executionRequest.HttpProxy, stepRequest.HttpProxy) - executionRequest.HttpsProxy = setStringField(executionRequest.HttpsProxy, stepRequest.HttpsProxy) - executionRequest.CronJobTemplate = setStringField(executionRequest.CronJobTemplate, stepRequest.CronJobTemplate) - executionRequest.CronJobTemplateReference = setStringField(executionRequest.CronJobTemplateReference, stepRequest.CronJobTemplateReference) - executionRequest.JobTemplate = setStringField(executionRequest.JobTemplate, stepRequest.JobTemplate) - executionRequest.JobTemplateReference = setStringField(executionRequest.JobTemplateReference, stepRequest.JobTemplateReference) - executionRequest.ScraperTemplate = setStringField(executionRequest.ScraperTemplate, stepRequest.ScraperTemplate) - executionRequest.ScraperTemplateReference = setStringField(executionRequest.ScraperTemplateReference, stepRequest.ScraperTemplateReference) - executionRequest.PvcTemplate = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplate) - executionRequest.PvcTemplateReference = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplateReference) - - if stepRequest.RunningContext != nil { - executionRequest.RunningContext = &testkube.RunningContext{ - Type_: string(stepRequest.RunningContext.Type_), - Context: stepRequest.RunningContext.Context, - } - } - - return executionRequest -} - -func setStringField(oldValue string, newValue string) string { - if newValue != "" { - return newValue - } - return oldValue -} - -func mergeVariables(vars1 map[string]testkube.Variable, vars2 map[string]testkube.Variable) map[string]testkube.Variable { - variables := map[string]testkube.Variable{} - for k, v := range vars1 { - variables[k] = v - } - - for k, v := range vars2 { - variables[k] = v - } - - return variables -} diff --git a/pkg/api/v1/testkube/model_test_suite_step_execution_request.go b/pkg/api/v1/testkube/model_test_suite_step_execution_request.go index 85d64b5e1b6..77093fb27eb 100644 --- a/pkg/api/v1/testkube/model_test_suite_step_execution_request.go +++ b/pkg/api/v1/testkube/model_test_suite_step_execution_request.go @@ -20,8 +20,6 @@ type TestSuiteStepExecutionRequest struct { Args []string `json:"args,omitempty"` // usage mode for arguments ArgsMode string `json:"args_mode,omitempty"` - // whether to start execution sync or async - Sync bool `json:"sync,omitempty"` // http proxy for executor containers HttpProxy string `json:"httpProxy,omitempty"` // https proxy for executor containers diff --git a/pkg/mapper/testsuites/kube_openapi.go b/pkg/mapper/testsuites/kube_openapi.go index 4f384847350..c39b1ef9609 100644 --- a/pkg/mapper/testsuites/kube_openapi.go +++ b/pkg/mapper/testsuites/kube_openapi.go @@ -391,7 +391,6 @@ func MapTestStepExecutionRequestCRDToAPI(request *testsuitesv3.TestSuiteStepExec Command: request.Command, Args: request.Args, ArgsMode: argsMode, - Sync: request.Sync, HttpProxy: request.HttpProxy, HttpsProxy: request.HttpsProxy, NegativeTest: request.NegativeTest, diff --git a/pkg/mapper/testsuites/openapi_kube.go b/pkg/mapper/testsuites/openapi_kube.go index 4132768594b..bd257b6f7f7 100644 --- a/pkg/mapper/testsuites/openapi_kube.go +++ b/pkg/mapper/testsuites/openapi_kube.go @@ -468,7 +468,6 @@ func MapTestStepExecutionRequestCRD(request *testkube.TestSuiteStepExecutionRequ Args: request.Args, ArgsMode: testsuitesv3.ArgsModeType(request.ArgsMode), Command: request.Command, - Sync: request.Sync, HttpProxy: request.HttpProxy, HttpsProxy: request.HttpsProxy, NegativeTest: request.NegativeTest, diff --git a/pkg/scheduler/testsuite_scheduler.go b/pkg/scheduler/testsuite_scheduler.go index 2699adc15da..57d9c6e9f8a 100644 --- a/pkg/scheduler/testsuite_scheduler.go +++ b/pkg/scheduler/testsuite_scheduler.go @@ -650,7 +650,6 @@ func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, execu if stepRequest.Command != nil { executionRequest.Command = stepRequest.Command } - executionRequest.Sync = stepRequest.Sync executionRequest.HttpProxy = setStringField(executionRequest.HttpProxy, stepRequest.HttpProxy) executionRequest.HttpsProxy = setStringField(executionRequest.HttpsProxy, stepRequest.HttpsProxy) executionRequest.CronJobTemplate = setStringField(executionRequest.CronJobTemplate, stepRequest.CronJobTemplate) diff --git a/pkg/tcl/testsuitestcl/steps.go b/pkg/tcl/testsuitestcl/steps.go deleted file mode 100644 index 5ae19760c5c..00000000000 --- a/pkg/tcl/testsuitestcl/steps.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2024 Kubeshop. -// -// Licensed as a Testkube Pro file under the Testkube Community -// License (the "License"); you may not use this file except in compliance with -// the License. You may obtain a copy of the License at -// -// https://github.com/kubeshop/testkube/blob/master/licenses/TCL.txt - -package testsuitestcl - -import ( - testsuitesv3 "github.com/kubeshop/testkube-operator/api/testsuite/v3" - - "github.com/kubeshop/testkube/pkg/api/v1/testkube" -) - -// MergeStepRequest inherits step request fields with execution request -func MergeStepRequest(stepRequest *testkube.TestSuiteStepExecutionRequest, executionRequest testkube.ExecutionRequest) testkube.ExecutionRequest { - if stepRequest == nil { - return executionRequest - } - if stepRequest.ExecutionLabels != nil { - executionRequest.ExecutionLabels = stepRequest.ExecutionLabels - } - - if stepRequest.Variables != nil { - executionRequest.Variables = mergeVariables(executionRequest.Variables, stepRequest.Variables) - } - - if len(stepRequest.Args) != 0 { - if stepRequest.ArgsMode == string(testkube.ArgsModeTypeAppend) || stepRequest.ArgsMode == "" { - executionRequest.Args = append(executionRequest.Args, stepRequest.Args...) - } - - if stepRequest.ArgsMode == string(testkube.ArgsModeTypeOverride) || stepRequest.ArgsMode == string(testkube.ArgsModeTypeReplace) { - executionRequest.Args = stepRequest.Args - } - } - - if stepRequest.Command != nil { - executionRequest.Command = stepRequest.Command - } - executionRequest.Sync = stepRequest.Sync - executionRequest.HttpProxy = setStringField(executionRequest.HttpProxy, stepRequest.HttpProxy) - executionRequest.HttpsProxy = setStringField(executionRequest.HttpsProxy, stepRequest.HttpsProxy) - executionRequest.CronJobTemplate = setStringField(executionRequest.CronJobTemplate, stepRequest.CronJobTemplate) - executionRequest.CronJobTemplateReference = setStringField(executionRequest.CronJobTemplateReference, stepRequest.CronJobTemplateReference) - executionRequest.JobTemplate = setStringField(executionRequest.JobTemplate, stepRequest.JobTemplate) - executionRequest.JobTemplateReference = setStringField(executionRequest.JobTemplateReference, stepRequest.JobTemplateReference) - executionRequest.ScraperTemplate = setStringField(executionRequest.ScraperTemplate, stepRequest.ScraperTemplate) - executionRequest.ScraperTemplateReference = setStringField(executionRequest.ScraperTemplateReference, stepRequest.ScraperTemplateReference) - executionRequest.PvcTemplate = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplate) - executionRequest.PvcTemplateReference = setStringField(executionRequest.PvcTemplate, stepRequest.PvcTemplateReference) - - if stepRequest.RunningContext != nil { - executionRequest.RunningContext = &testkube.RunningContext{ - Type_: string(stepRequest.RunningContext.Type_), - Context: stepRequest.RunningContext.Context, - } - } - - return executionRequest -} - -// HasStepsExecutionRequest checks if test suite has steps with execution requests -func HasStepsExecutionRequest(testSuite testsuitesv3.TestSuite) bool { - for _, batch := range testSuite.Spec.Before { - for _, step := range batch.Execute { - if step.ExecutionRequest != nil { - return true - } - } - } - for _, batch := range testSuite.Spec.Steps { - for _, step := range batch.Execute { - if step.ExecutionRequest != nil { - return true - } - } - } - for _, batch := range testSuite.Spec.After { - for _, step := range batch.Execute { - if step.ExecutionRequest != nil { - return true - } - } - } - return false -} - -func setStringField(oldValue string, newValue string) string { - if newValue != "" { - return newValue - } - return oldValue -} - -func mergeVariables(vars1 map[string]testkube.Variable, vars2 map[string]testkube.Variable) map[string]testkube.Variable { - variables := map[string]testkube.Variable{} - for k, v := range vars1 { - variables[k] = v - } - - for k, v := range vars2 { - variables[k] = v - } - - return variables -} From 573eebfe1e83983847dab4652f8b0bad64f6431d Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 12 Mar 2024 15:05:38 +0100 Subject: [PATCH 207/234] feat: add custom volumes and step setup phase for TestWorkflows (#5158) * feat: add volumes/volumeMounts/setup to TestWorkflow schema * feat: add proper merging of new volumes/volumeMounts/setup fields * feat: support native volumeMounts in Container * feat: support "setup" steps in TestWorkflow processor * feat: inject custom volumes to TestWorkflow * chore: update testkube-operator for support of new features --- api/v1/testkube.yaml | 415 ++++++++++++++++++ go.mod | 2 +- go.sum | 6 +- ...l_aws_elastic_block_store_volume_source.go | 22 + .../model_azure_disk_volume_source.go | 23 + .../model_azure_file_volume_source.go | 20 + .../testkube/model_ceph_fs_volume_source.go | 25 ++ .../model_config_map_volume_source.go | 21 + .../model_config_map_volume_source_items.go | 19 + .../testkube/model_empty_dir_volume_source.go | 17 + ...model_gce_persistent_disk_volume_source.go | 22 + .../testkube/model_host_path_volume_source.go | 17 + .../v1/testkube/model_nfs_volume_source.go | 20 + ...l_persistent_volume_claim_volume_source.go | 18 + .../v1/testkube/model_secret_volume_source.go | 21 + .../model_secret_volume_source_items.go | 19 + .../model_test_workflow_container_config.go | 2 + .../model_test_workflow_independent_step.go | 2 + .../model_test_workflow_pod_config.go | 2 + .../v1/testkube/model_test_workflow_step.go | 2 + pkg/api/v1/testkube/model_volume.go | 26 ++ pkg/api/v1/testkube/model_volume_mount.go | 25 ++ pkg/api/v1/testkube/model_volume_source.go | 13 + .../mapperstcl/testworkflows/kube_openapi.go | 158 ++++++- .../mapperstcl/testworkflows/openapi_kube.go | 151 +++++++ .../testworkflowprocessor/container.go | 27 +- .../testworkflowprocessor/intermediate.go | 7 +- .../testworkflowprocessor/operations.go | 12 + .../testworkflowprocessor/processor.go | 1 + .../testworkflowresolver/analyze.go | 3 + .../testworkflowresolver/apply.go | 21 +- .../testworkflowresolver/apply_test.go | 40 +- .../testworkflowresolver/merge.go | 3 + 33 files changed, 1135 insertions(+), 47 deletions(-) create mode 100644 pkg/api/v1/testkube/model_aws_elastic_block_store_volume_source.go create mode 100644 pkg/api/v1/testkube/model_azure_disk_volume_source.go create mode 100644 pkg/api/v1/testkube/model_azure_file_volume_source.go create mode 100644 pkg/api/v1/testkube/model_ceph_fs_volume_source.go create mode 100644 pkg/api/v1/testkube/model_config_map_volume_source.go create mode 100644 pkg/api/v1/testkube/model_config_map_volume_source_items.go create mode 100644 pkg/api/v1/testkube/model_empty_dir_volume_source.go create mode 100644 pkg/api/v1/testkube/model_gce_persistent_disk_volume_source.go create mode 100644 pkg/api/v1/testkube/model_host_path_volume_source.go create mode 100644 pkg/api/v1/testkube/model_nfs_volume_source.go create mode 100644 pkg/api/v1/testkube/model_persistent_volume_claim_volume_source.go create mode 100644 pkg/api/v1/testkube/model_secret_volume_source.go create mode 100644 pkg/api/v1/testkube/model_secret_volume_source_items.go create mode 100644 pkg/api/v1/testkube/model_volume.go create mode 100644 pkg/api/v1/testkube/model_volume_mount.go create mode 100644 pkg/api/v1/testkube/model_volume_source.go diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index eb40067b951..30ded87120a 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -7863,6 +7863,11 @@ components: $ref: "#/components/schemas/TestWorkflowStepExecute" artifacts: $ref: "#/components/schemas/TestWorkflowStepArtifacts" + setup: + type: array + description: nested setup steps to run + items: + $ref: "#/components/schemas/TestWorkflowIndependentStep" steps: type: array description: nested steps to run @@ -7916,6 +7921,11 @@ components: $ref: "#/components/schemas/TestWorkflowStepExecute" artifacts: $ref: "#/components/schemas/TestWorkflowStepArtifacts" + setup: + type: array + description: nested setup steps to run + items: + $ref: "#/components/schemas/TestWorkflowStep" steps: type: array description: nested steps to run @@ -8103,6 +8113,11 @@ components: description: label selector for node that the pod should land on additionalProperties: type: string + volumes: + type: array + description: volumes to append to the pod + items: + $ref: "#/components/schemas/Volume" TestWorkflowContainerConfig: type: object @@ -8132,6 +8147,11 @@ components: $ref: "#/components/schemas/TestWorkflowResources" securityContext: $ref: "#/components/schemas/SecurityContext" + volumeMounts: + type: array + description: volumes to mount to the container + items: + $ref: "#/components/schemas/VolumeMount" TestWorkflowConfigValue: type: object @@ -8329,6 +8349,401 @@ components: allowPrivilegeEscalation: $ref: "#/components/schemas/BoxedBoolean" + VolumeMount: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: Path within the container at which the + volume should be mounted. Must not contain ':'. + type: string + mountPropagation: + $ref: "#/components/schemas/BoxedString" + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: Mounted read-only if true, read-write + otherwise (false or unspecified). Defaults to false. + type: boolean + subPath: + description: Path within the volume from which the + container's volume should be mounted. Defaults to + "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable + references $(VAR_NAME) are expanded using the container's + environment. Defaults to "" (volume's root). SubPathExpr + and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + + HostPathVolumeSource: + description: 'hostPath represents a pre-existing file or + directory on the host machine that is directly exposed + to the container. This is generally used for system agents + or other privileged things that are allowed to see the + host machine. Most containers will NOT need this. More + info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + --- TODO(jonesdl) We need to restrict who can use host + directory mounts and who can/can not mount host directories + as read/write.' + properties: + path: + description: 'path of the directory on the host. If + the path is a symlink, it will follow the link to + the real path. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' + type: string + type: + $ref: "#/components/schemas/BoxedString" + required: + - path + type: object + + EmptyDirVolumeSource: + description: 'emptyDir represents a temporary directory + that shares a pod''s lifetime. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + properties: + medium: + description: 'medium represents what type of storage + medium should back this directory. The default is + "" which means to use the node''s default medium. + Must be an empty string (default) or Memory. More + info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + type: string + sizeLimit: + $ref: "#/components/schemas/BoxedString" + type: object + + GCEPersistentDiskVolumeSource: + description: 'gcePersistentDisk represents a GCE Disk resource + that is attached to a kubelet''s host machine and then + exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + properties: + fsType: + description: 'fsType is filesystem type of the volume + that you want to mount. Tip: Ensure that the filesystem + type is supported by the host operating system. Examples: + "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" + if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + TODO: how do we prevent errors in the filesystem from + compromising the machine' + type: string + partition: + description: 'partition is the partition in the volume + that you want to mount. If omitted, the default is + to mount by volume name. Examples: For volume /dev/sda1, + you specify the partition as "1". Similarly, the volume + partition for /dev/sda is "0" (or you can leave the + property empty). More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + format: int32 + type: integer + pdName: + description: 'pdName is unique name of the PD resource + in GCE. Used to identify the disk in GCE. More info: + https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: string + readOnly: + description: 'readOnly here will force the ReadOnly + setting in VolumeMounts. Defaults to false. More info: + https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: boolean + required: + - pdName + type: object + + AWSElasticBlockStoreVolumeSource: + description: 'awsElasticBlockStore represents an AWS Disk + resource that is attached to a kubelet''s host machine + and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + properties: + fsType: + description: 'fsType is the filesystem type of the volume + that you want to mount. Tip: Ensure that the filesystem + type is supported by the host operating system. Examples: + "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" + if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + TODO: how do we prevent errors in the filesystem from + compromising the machine' + type: string + partition: + description: 'partition is the partition in the volume + that you want to mount. If omitted, the default is + to mount by volume name. Examples: For volume /dev/sda1, + you specify the partition as "1". Similarly, the volume + partition for /dev/sda is "0" (or you can leave the + property empty).' + format: int32 + type: integer + readOnly: + description: 'readOnly value true will force the readOnly + setting in VolumeMounts. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: boolean + volumeID: + description: 'volumeID is unique ID of the persistent + disk resource in AWS (Amazon EBS volume). More info: + https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: string + required: + - volumeID + type: object + + SecretVolumeSource: + description: 'secret represents a secret that should populate + this volume. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' + properties: + defaultMode: + $ref: "#/components/schemas/BoxedInteger" + items: + description: items If unspecified, each key-value pair + in the Data field of the referenced Secret will be + projected into the volume as a file whose name is + the key and content is the value. If specified, the + listed keys will be projected into the specified paths, + and unlisted keys will not be present. If a key is + specified which is not present in the Secret, the + volume setup will error unless it is marked optional. + Paths must be relative and may not contain the '..' + path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + $ref: "#/components/schemas/BoxedInteger" + path: + description: path is the relative path of the + file to map the key to. May not be an absolute + path. May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + optional: + description: optional field specify whether the Secret + or its keys must be defined + type: boolean + secretName: + description: 'secretName is the name of the secret in + the pod''s namespace to use. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' + type: string + type: object + + NFSVolumeSource: + description: 'nfs represents an NFS mount on the host that + shares a pod''s lifetime More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + properties: + path: + description: 'path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: string + readOnly: + description: 'readOnly here will force the NFS export + to be mounted with read-only permissions. Defaults + to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: boolean + server: + description: 'server is the hostname or IP address of + the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: string + required: + - path + - server + type: object + + PersistentVolumeClaimVolumeSource: + description: 'persistentVolumeClaimVolumeSource represents + a reference to a PersistentVolumeClaim in the same namespace. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' + properties: + claimName: + description: 'claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' + type: string + readOnly: + description: readOnly Will force the ReadOnly setting + in VolumeMounts. Default false. + type: boolean + required: + - claimName + type: object + + CephFSVolumeSource: + description: cephFS represents a Ceph FS mount on the host + that shares a pod's lifetime + properties: + monitors: + description: 'monitors is Required: Monitors is a collection + of Ceph monitors More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + items: + type: string + type: array + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default is /' + type: string + readOnly: + description: 'readOnly is Optional: Defaults to false + (read/write). ReadOnly here will force the ReadOnly + setting in VolumeMounts. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile is + the path to key ring for User, default is /etc/ceph/user.secret + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: string + secretRef: + $ref: "#/components/schemas/LocalObjectReference" + user: + description: 'user is optional: User is the rados user + name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: string + required: + - monitors + type: object + + AzureFileVolumeSource: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: readOnly defaults to false (read/write). + ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + + ConfigMapVolumeSource: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + $ref: "#/components/schemas/BoxedInteger" + items: + description: items if unspecified, each key-value pair + in the Data field of the referenced ConfigMap will + be projected into the volume as a file whose name + is the key and content is the value. If specified, + the listed keys will be projected into the specified + paths, and unlisted keys will not be present. If a + key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. + Paths must be relative and may not contain the '..' + path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + $ref: "#/components/schemas/BoxedInteger" + path: + description: path is the relative path of the + file to map the key to. May not be an absolute + path. May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + + AzureDiskVolumeSource: + description: azureDisk represents an Azure Data Disk mount + on the host and bind mount to the pod. + properties: + cachingMode: + $ref: "#/components/schemas/BoxedString" + diskName: + description: diskName is the Name of the data disk in + the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in the + blob storage + type: string + fsType: + $ref: "#/components/schemas/BoxedString" + kind: + $ref: "#/components/schemas/BoxedString" + readOnly: + description: readOnly Defaults to false (read/write). + ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + + Volume: + type: object + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + name: + type: string + hostPath: + $ref: "#/components/schemas/HostPathVolumeSource" + emptyDir: + $ref: "#/components/schemas/EmptyDirVolumeSource" + gcePersistentDisk: + $ref: "#/components/schemas/GCEPersistentDiskVolumeSource" + awsElasticBlockStore: + $ref: "#/components/schemas/AWSElasticBlockStoreVolumeSource" + secret: + $ref: "#/components/schemas/SecretVolumeSource" + nfs: + $ref: "#/components/schemas/NFSVolumeSource" + persistentVolumeClaim: + $ref: "#/components/schemas/PersistentVolumeClaimVolumeSource" + cephfs: + $ref: "#/components/schemas/CephFSVolumeSource" + azureFile: + $ref: "#/components/schemas/AzureFileVolumeSource" + azureDisk: + $ref: "#/components/schemas/AzureDiskVolumeSource" + configMap: + $ref: "#/components/schemas/ConfigMapVolumeSource" + required: + - name + + VolumeSource: + type: object + EnvVarSource: type: object description: EnvVarSource represents a source for the value diff --git a/go.mod b/go.mod index 0c6089e9fa0..ed2669c85e4 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312130250-6625e24b02b3 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312131535-c5c795881853 github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index fc736e5eba4..54b5ad997fb 100644 --- a/go.sum +++ b/go.sum @@ -360,10 +360,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312123615-29580e66708a h1:JTuSWub/+r1Bvx1caz5oaXABSzCRJwLHJtL6LBN2e3Q= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312123615-29580e66708a/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312130250-6625e24b02b3 h1:ON23pyENTeGVUnDx8LT3IWK6T1eERC50qgSDjaCw7rw= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312130250-6625e24b02b3/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312131535-c5c795881853 h1:K91UOZFWIHNQuxsulpQ3aTTtjZmCuC3b84B+Vr5m8vA= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312131535-c5c795881853/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/pkg/api/v1/testkube/model_aws_elastic_block_store_volume_source.go b/pkg/api/v1/testkube/model_aws_elastic_block_store_volume_source.go new file mode 100644 index 00000000000..f0599c5197b --- /dev/null +++ b/pkg/api/v1/testkube/model_aws_elastic_block_store_volume_source.go @@ -0,0 +1,22 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// awsElasticBlockStore represents an AWS Disk resource that is attached to a kubelet's host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore +type AwsElasticBlockStoreVolumeSource struct { + // fsType is the filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: \"ext4\", \"xfs\", \"ntfs\". Implicitly inferred to be \"ext4\" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore TODO: how do we prevent errors in the filesystem from compromising the machine + FsType string `json:"fsType,omitempty"` + // partition is the partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as \"1\". Similarly, the volume partition for /dev/sda is \"0\" (or you can leave the property empty). + Partition int32 `json:"partition,omitempty"` + // readOnly value true will force the readOnly setting in VolumeMounts. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + ReadOnly bool `json:"readOnly,omitempty"` + // volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + VolumeID string `json:"volumeID"` +} diff --git a/pkg/api/v1/testkube/model_azure_disk_volume_source.go b/pkg/api/v1/testkube/model_azure_disk_volume_source.go new file mode 100644 index 00000000000..2501927724c --- /dev/null +++ b/pkg/api/v1/testkube/model_azure_disk_volume_source.go @@ -0,0 +1,23 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// azureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. +type AzureDiskVolumeSource struct { + CachingMode *BoxedString `json:"cachingMode,omitempty"` + // diskName is the Name of the data disk in the blob storage + DiskName string `json:"diskName"` + // diskURI is the URI of data disk in the blob storage + DiskURI string `json:"diskURI"` + FsType *BoxedString `json:"fsType,omitempty"` + Kind *BoxedString `json:"kind,omitempty"` + // readOnly Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + ReadOnly bool `json:"readOnly,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_azure_file_volume_source.go b/pkg/api/v1/testkube/model_azure_file_volume_source.go new file mode 100644 index 00000000000..4b712c8f3e2 --- /dev/null +++ b/pkg/api/v1/testkube/model_azure_file_volume_source.go @@ -0,0 +1,20 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// azureFile represents an Azure File Service mount on the host and bind mount to the pod. +type AzureFileVolumeSource struct { + // readOnly defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + ReadOnly bool `json:"readOnly,omitempty"` + // secretName is the name of secret that contains Azure Storage Account Name and Key + SecretName string `json:"secretName"` + // shareName is the azure share Name + ShareName string `json:"shareName"` +} diff --git a/pkg/api/v1/testkube/model_ceph_fs_volume_source.go b/pkg/api/v1/testkube/model_ceph_fs_volume_source.go new file mode 100644 index 00000000000..e5de0e33de1 --- /dev/null +++ b/pkg/api/v1/testkube/model_ceph_fs_volume_source.go @@ -0,0 +1,25 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// cephFS represents a Ceph FS mount on the host that shares a pod's lifetime +type CephFsVolumeSource struct { + // monitors is Required: Monitors is a collection of Ceph monitors More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + Monitors []string `json:"monitors"` + // path is Optional: Used as the mounted root, rather than the full Ceph tree, default is / + Path string `json:"path,omitempty"` + // readOnly is Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + ReadOnly bool `json:"readOnly,omitempty"` + // secretFile is Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + SecretFile string `json:"secretFile,omitempty"` + SecretRef *LocalObjectReference `json:"secretRef,omitempty"` + // user is optional: User is the rados user name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + User string `json:"user,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_config_map_volume_source.go b/pkg/api/v1/testkube/model_config_map_volume_source.go new file mode 100644 index 00000000000..a9934bcf8e9 --- /dev/null +++ b/pkg/api/v1/testkube/model_config_map_volume_source.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// configMap represents a configMap that should populate this volume +type ConfigMapVolumeSource struct { + DefaultMode *BoxedInteger `json:"defaultMode,omitempty"` + // items if unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + Items []SecretVolumeSourceItems `json:"items,omitempty"` + // Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid? + Name string `json:"name,omitempty"` + // optional specify whether the ConfigMap or its keys must be defined + Optional bool `json:"optional,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_config_map_volume_source_items.go b/pkg/api/v1/testkube/model_config_map_volume_source_items.go new file mode 100644 index 00000000000..383c2748fa0 --- /dev/null +++ b/pkg/api/v1/testkube/model_config_map_volume_source_items.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Maps a string key to a path within a volume. +type ConfigMapVolumeSourceItems struct { + // key is the key to project. + Key string `json:"key"` + Mode *BoxedInteger `json:"mode,omitempty"` + // path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + Path string `json:"path"` +} diff --git a/pkg/api/v1/testkube/model_empty_dir_volume_source.go b/pkg/api/v1/testkube/model_empty_dir_volume_source.go new file mode 100644 index 00000000000..dd549c57020 --- /dev/null +++ b/pkg/api/v1/testkube/model_empty_dir_volume_source.go @@ -0,0 +1,17 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// emptyDir represents a temporary directory that shares a pod's lifetime. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir +type EmptyDirVolumeSource struct { + // medium represents what type of storage medium should back this directory. The default is \"\" which means to use the node's default medium. Must be an empty string (default) or Memory. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + Medium string `json:"medium,omitempty"` + SizeLimit *BoxedString `json:"sizeLimit,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_gce_persistent_disk_volume_source.go b/pkg/api/v1/testkube/model_gce_persistent_disk_volume_source.go new file mode 100644 index 00000000000..6369325274c --- /dev/null +++ b/pkg/api/v1/testkube/model_gce_persistent_disk_volume_source.go @@ -0,0 +1,22 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// gcePersistentDisk represents a GCE Disk resource that is attached to a kubelet's host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk +type GcePersistentDiskVolumeSource struct { + // fsType is filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: \"ext4\", \"xfs\", \"ntfs\". Implicitly inferred to be \"ext4\" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk TODO: how do we prevent errors in the filesystem from compromising the machine + FsType string `json:"fsType,omitempty"` + // partition is the partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as \"1\". Similarly, the volume partition for /dev/sda is \"0\" (or you can leave the property empty). More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + Partition int32 `json:"partition,omitempty"` + // pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + PdName string `json:"pdName"` + // readOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + ReadOnly bool `json:"readOnly,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_host_path_volume_source.go b/pkg/api/v1/testkube/model_host_path_volume_source.go new file mode 100644 index 00000000000..edefe82fd89 --- /dev/null +++ b/pkg/api/v1/testkube/model_host_path_volume_source.go @@ -0,0 +1,17 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// hostPath represents a pre-existing file or directory on the host machine that is directly exposed to the container. This is generally used for system agents or other privileged things that are allowed to see the host machine. Most containers will NOT need this. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath --- TODO(jonesdl) We need to restrict who can use host directory mounts and who can/can not mount host directories as read/write. +type HostPathVolumeSource struct { + // path of the directory on the host. If the path is a symlink, it will follow the link to the real path. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + Path string `json:"path"` + Type_ *BoxedString `json:"type,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_nfs_volume_source.go b/pkg/api/v1/testkube/model_nfs_volume_source.go new file mode 100644 index 00000000000..1e8b200cb74 --- /dev/null +++ b/pkg/api/v1/testkube/model_nfs_volume_source.go @@ -0,0 +1,20 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// nfs represents an NFS mount on the host that shares a pod's lifetime More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs +type NfsVolumeSource struct { + // path that is exported by the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + Path string `json:"path"` + // readOnly here will force the NFS export to be mounted with read-only permissions. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + ReadOnly bool `json:"readOnly,omitempty"` + // server is the hostname or IP address of the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + Server string `json:"server"` +} diff --git a/pkg/api/v1/testkube/model_persistent_volume_claim_volume_source.go b/pkg/api/v1/testkube/model_persistent_volume_claim_volume_source.go new file mode 100644 index 00000000000..bfedb173bd7 --- /dev/null +++ b/pkg/api/v1/testkube/model_persistent_volume_claim_volume_source.go @@ -0,0 +1,18 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// persistentVolumeClaimVolumeSource represents a reference to a PersistentVolumeClaim in the same namespace. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims +type PersistentVolumeClaimVolumeSource struct { + // claimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims + ClaimName string `json:"claimName"` + // readOnly Will force the ReadOnly setting in VolumeMounts. Default false. + ReadOnly bool `json:"readOnly,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_secret_volume_source.go b/pkg/api/v1/testkube/model_secret_volume_source.go new file mode 100644 index 00000000000..5deede9f2f1 --- /dev/null +++ b/pkg/api/v1/testkube/model_secret_volume_source.go @@ -0,0 +1,21 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// secret represents a secret that should populate this volume. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret +type SecretVolumeSource struct { + DefaultMode *BoxedInteger `json:"defaultMode,omitempty"` + // items If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + Items []SecretVolumeSourceItems `json:"items,omitempty"` + // optional field specify whether the Secret or its keys must be defined + Optional bool `json:"optional,omitempty"` + // secretName is the name of the secret in the pod's namespace to use. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret + SecretName string `json:"secretName,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_secret_volume_source_items.go b/pkg/api/v1/testkube/model_secret_volume_source_items.go new file mode 100644 index 00000000000..8afdaacf46e --- /dev/null +++ b/pkg/api/v1/testkube/model_secret_volume_source_items.go @@ -0,0 +1,19 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Maps a string key to a path within a volume. +type SecretVolumeSourceItems struct { + // key is the key to project. + Key string `json:"key"` + Mode *BoxedInteger `json:"mode,omitempty"` + // path is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + Path string `json:"path"` +} diff --git a/pkg/api/v1/testkube/model_test_workflow_container_config.go b/pkg/api/v1/testkube/model_test_workflow_container_config.go index 1c04baf02aa..e070501ed9b 100644 --- a/pkg/api/v1/testkube/model_test_workflow_container_config.go +++ b/pkg/api/v1/testkube/model_test_workflow_container_config.go @@ -22,4 +22,6 @@ type TestWorkflowContainerConfig struct { Args *BoxedStringList `json:"args,omitempty"` Resources *TestWorkflowResources `json:"resources,omitempty"` SecurityContext *SecurityContext `json:"securityContext,omitempty"` + // volumes to mount to the container + VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_workflow_independent_step.go b/pkg/api/v1/testkube/model_test_workflow_independent_step.go index 9b7c4e1711c..566b33cb92d 100644 --- a/pkg/api/v1/testkube/model_test_workflow_independent_step.go +++ b/pkg/api/v1/testkube/model_test_workflow_independent_step.go @@ -31,6 +31,8 @@ type TestWorkflowIndependentStep struct { Container *TestWorkflowContainerConfig `json:"container,omitempty"` Execute *TestWorkflowStepExecute `json:"execute,omitempty"` Artifacts *TestWorkflowStepArtifacts `json:"artifacts,omitempty"` + // nested setup steps to run + Setup []TestWorkflowIndependentStep `json:"setup,omitempty"` // nested steps to run Steps []TestWorkflowIndependentStep `json:"steps,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_workflow_pod_config.go b/pkg/api/v1/testkube/model_test_workflow_pod_config.go index 396c716376d..0fd8e70ea89 100644 --- a/pkg/api/v1/testkube/model_test_workflow_pod_config.go +++ b/pkg/api/v1/testkube/model_test_workflow_pod_config.go @@ -20,4 +20,6 @@ type TestWorkflowPodConfig struct { ServiceAccountName string `json:"serviceAccountName,omitempty"` // label selector for node that the pod should land on NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // volumes to append to the pod + Volumes []Volume `json:"volumes,omitempty"` } diff --git a/pkg/api/v1/testkube/model_test_workflow_step.go b/pkg/api/v1/testkube/model_test_workflow_step.go index bac8cfd95eb..31a404c9f33 100644 --- a/pkg/api/v1/testkube/model_test_workflow_step.go +++ b/pkg/api/v1/testkube/model_test_workflow_step.go @@ -34,6 +34,8 @@ type TestWorkflowStep struct { Container *TestWorkflowContainerConfig `json:"container,omitempty"` Execute *TestWorkflowStepExecute `json:"execute,omitempty"` Artifacts *TestWorkflowStepArtifacts `json:"artifacts,omitempty"` + // nested setup steps to run + Setup []TestWorkflowStep `json:"setup,omitempty"` // nested steps to run Steps []TestWorkflowStep `json:"steps,omitempty"` } diff --git a/pkg/api/v1/testkube/model_volume.go b/pkg/api/v1/testkube/model_volume.go new file mode 100644 index 00000000000..bf6fb1bae89 --- /dev/null +++ b/pkg/api/v1/testkube/model_volume.go @@ -0,0 +1,26 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// Volume represents a named volume in a pod that may be accessed by any container in the pod. +type Volume struct { + Name string `json:"name"` + HostPath *HostPathVolumeSource `json:"hostPath,omitempty"` + EmptyDir *EmptyDirVolumeSource `json:"emptyDir,omitempty"` + GcePersistentDisk *GcePersistentDiskVolumeSource `json:"gcePersistentDisk,omitempty"` + AwsElasticBlockStore *AwsElasticBlockStoreVolumeSource `json:"awsElasticBlockStore,omitempty"` + Secret *SecretVolumeSource `json:"secret,omitempty"` + Nfs *NfsVolumeSource `json:"nfs,omitempty"` + PersistentVolumeClaim *PersistentVolumeClaimVolumeSource `json:"persistentVolumeClaim,omitempty"` + Cephfs *CephFsVolumeSource `json:"cephfs,omitempty"` + AzureFile *AzureFileVolumeSource `json:"azureFile,omitempty"` + AzureDisk *AzureDiskVolumeSource `json:"azureDisk,omitempty"` + ConfigMap *ConfigMapVolumeSource `json:"configMap,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_volume_mount.go b/pkg/api/v1/testkube/model_volume_mount.go new file mode 100644 index 00000000000..2aa74c5913e --- /dev/null +++ b/pkg/api/v1/testkube/model_volume_mount.go @@ -0,0 +1,25 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +// VolumeMount describes a mounting of a Volume within a container. +type VolumeMount struct { + // Path within the container at which the volume should be mounted. Must not contain ':'. + MountPath string `json:"mountPath"` + MountPropagation *BoxedString `json:"mountPropagation,omitempty"` + // This must match the Name of a Volume. + Name string `json:"name"` + // Mounted read-only if true, read-write otherwise (false or unspecified). Defaults to false. + ReadOnly bool `json:"readOnly,omitempty"` + // Path within the volume from which the container's volume should be mounted. Defaults to \"\" (volume's root). + SubPath string `json:"subPath,omitempty"` + // Expanded path within the volume from which the container's volume should be mounted. Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. Defaults to \"\" (volume's root). SubPathExpr and SubPath are mutually exclusive. + SubPathExpr string `json:"subPathExpr,omitempty"` +} diff --git a/pkg/api/v1/testkube/model_volume_source.go b/pkg/api/v1/testkube/model_volume_source.go new file mode 100644 index 00000000000..c7793075834 --- /dev/null +++ b/pkg/api/v1/testkube/model_volume_source.go @@ -0,0 +1,13 @@ +/* + * Testkube API + * + * Testkube provides a Kubernetes-native framework for test definition, execution and results + * + * API version: 1.0.0 + * Contact: testkube@kubeshop.io + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package testkube + +type VolumeSource struct { +} diff --git a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go index 2563e27f632..1e164db8993 100644 --- a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go +++ b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go @@ -10,6 +10,7 @@ package testworkflows import ( corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/util/intstr" testsv3 "github.com/kubeshop/testkube-operator/api/tests/v3" @@ -37,6 +38,13 @@ func MapStringToBoxedString(v *string) *testkube.BoxedString { return &testkube.BoxedString{Value: *v} } +func MapStringTypeToBoxedString[T ~string](v *T) *testkube.BoxedString { + if v == nil { + return nil + } + return &testkube.BoxedString{Value: string(*v)} +} + func MapBoolToBoxedBoolean(v *bool) *testkube.BoxedBoolean { if v == nil { return nil @@ -65,6 +73,135 @@ func MapInt32ToBoxedInteger(v *int32) *testkube.BoxedInteger { return &testkube.BoxedInteger{Value: *v} } +func MapQuantityToBoxedString(v *resource.Quantity) *testkube.BoxedString { + if v == nil { + return nil + } + return &testkube.BoxedString{Value: v.String()} +} + +func MapHostPathVolumeSourceKubeToAPI(v corev1.HostPathVolumeSource) testkube.HostPathVolumeSource { + return testkube.HostPathVolumeSource{ + Path: v.Path, + Type_: MapStringTypeToBoxedString[corev1.HostPathType](v.Type), + } +} + +func MapEmptyDirVolumeSourceKubeToAPI(v corev1.EmptyDirVolumeSource) testkube.EmptyDirVolumeSource { + return testkube.EmptyDirVolumeSource{ + Medium: string(v.Medium), + SizeLimit: MapQuantityToBoxedString(v.SizeLimit), + } +} + +func MapGCEPersistentDiskVolumeSourceKubeToAPI(v corev1.GCEPersistentDiskVolumeSource) testkube.GcePersistentDiskVolumeSource { + return testkube.GcePersistentDiskVolumeSource{ + PdName: v.PDName, + FsType: v.FSType, + Partition: v.Partition, + ReadOnly: v.ReadOnly, + } +} + +func MapAWSElasticBlockStoreVolumeSourceKubeToAPI(v corev1.AWSElasticBlockStoreVolumeSource) testkube.AwsElasticBlockStoreVolumeSource { + return testkube.AwsElasticBlockStoreVolumeSource{ + VolumeID: v.VolumeID, + FsType: v.FSType, + Partition: v.Partition, + ReadOnly: v.ReadOnly, + } +} + +func MapKeyToPathKubeToAPI(v corev1.KeyToPath) testkube.SecretVolumeSourceItems { + return testkube.SecretVolumeSourceItems{ + Key: v.Key, + Path: v.Path, + Mode: MapInt32ToBoxedInteger(v.Mode), + } +} + +func MapSecretVolumeSourceKubeToAPI(v corev1.SecretVolumeSource) testkube.SecretVolumeSource { + return testkube.SecretVolumeSource{ + SecretName: v.SecretName, + Items: common.MapSlice(v.Items, MapKeyToPathKubeToAPI), + DefaultMode: MapInt32ToBoxedInteger(v.DefaultMode), + Optional: common.ResolvePtr(v.Optional, false), + } +} + +func MapNFSVolumeSourceKubeToAPI(v corev1.NFSVolumeSource) testkube.NfsVolumeSource { + return testkube.NfsVolumeSource{ + Server: v.Server, + Path: v.Path, + ReadOnly: v.ReadOnly, + } +} + +func MapPersistentVolumeClaimVolumeSourceKubeToAPI(v corev1.PersistentVolumeClaimVolumeSource) testkube.PersistentVolumeClaimVolumeSource { + return testkube.PersistentVolumeClaimVolumeSource{ + ClaimName: v.ClaimName, + ReadOnly: v.ReadOnly, + } +} + +func MapCephFSVolumeSourceKubeToAPI(v corev1.CephFSVolumeSource) testkube.CephFsVolumeSource { + return testkube.CephFsVolumeSource{ + Monitors: v.Monitors, + Path: v.Path, + User: v.User, + SecretFile: v.SecretFile, + SecretRef: common.MapPtr(v.SecretRef, MapLocalObjectReferenceKubeToAPI), + ReadOnly: v.ReadOnly, + } +} + +func MapAzureFileVolumeSourceKubeToAPI(v corev1.AzureFileVolumeSource) testkube.AzureFileVolumeSource { + return testkube.AzureFileVolumeSource{ + SecretName: v.SecretName, + ShareName: v.ShareName, + ReadOnly: v.ReadOnly, + } +} + +func MapConfigMapVolumeSourceKubeToAPI(v corev1.ConfigMapVolumeSource) testkube.ConfigMapVolumeSource { + return testkube.ConfigMapVolumeSource{ + Name: v.Name, + Items: common.MapSlice(v.Items, MapKeyToPathKubeToAPI), + DefaultMode: MapInt32ToBoxedInteger(v.DefaultMode), + Optional: common.ResolvePtr(v.Optional, false), + } +} + +func MapAzureDiskVolumeSourceKubeToAPI(v corev1.AzureDiskVolumeSource) testkube.AzureDiskVolumeSource { + return testkube.AzureDiskVolumeSource{ + DiskName: v.DiskName, + DiskURI: v.DataDiskURI, + CachingMode: MapStringTypeToBoxedString[corev1.AzureDataDiskCachingMode](v.CachingMode), + FsType: MapStringToBoxedString(v.FSType), + ReadOnly: common.ResolvePtr(v.ReadOnly, false), + Kind: MapStringTypeToBoxedString[corev1.AzureDataDiskKind](v.Kind), + } +} + +func MapVolumeKubeToAPI(v corev1.Volume) testkube.Volume { + // TODO: Add rest of VolumeSource types in future, + // so they will be recognized by JSON API and persisted with Execution. + return testkube.Volume{ + Name: v.Name, + HostPath: common.MapPtr(v.HostPath, MapHostPathVolumeSourceKubeToAPI), + EmptyDir: common.MapPtr(v.EmptyDir, MapEmptyDirVolumeSourceKubeToAPI), + GcePersistentDisk: common.MapPtr(v.GCEPersistentDisk, MapGCEPersistentDiskVolumeSourceKubeToAPI), + AwsElasticBlockStore: common.MapPtr(v.AWSElasticBlockStore, MapAWSElasticBlockStoreVolumeSourceKubeToAPI), + Secret: common.MapPtr(v.Secret, MapSecretVolumeSourceKubeToAPI), + Nfs: common.MapPtr(v.NFS, MapNFSVolumeSourceKubeToAPI), + PersistentVolumeClaim: common.MapPtr(v.PersistentVolumeClaim, MapPersistentVolumeClaimVolumeSourceKubeToAPI), + Cephfs: common.MapPtr(v.CephFS, MapCephFSVolumeSourceKubeToAPI), + AzureFile: common.MapPtr(v.AzureFile, MapAzureFileVolumeSourceKubeToAPI), + ConfigMap: common.MapPtr(v.ConfigMap, MapConfigMapVolumeSourceKubeToAPI), + AzureDisk: common.MapPtr(v.AzureDisk, MapAzureDiskVolumeSourceKubeToAPI), + } +} + func MapEnvVarKubeToAPI(v corev1.EnvVar) testkube.EnvVar { return testkube.EnvVar{ Name: v.Name, @@ -292,6 +429,18 @@ func MapPodConfigKubeToAPI(v testworkflowsv1.PodConfig) testkube.TestWorkflowPod NodeSelector: v.NodeSelector, Labels: v.Labels, Annotations: v.Annotations, + Volumes: common.MapSlice(v.Volumes, MapVolumeKubeToAPI), + } +} + +func MapVolumeMountKubeToAPI(v corev1.VolumeMount) testkube.VolumeMount { + return testkube.VolumeMount{ + Name: v.Name, + ReadOnly: v.ReadOnly, + MountPath: v.MountPath, + SubPath: v.SubPath, + MountPropagation: MapStringTypeToBoxedString[corev1.MountPropagationMode](v.MountPropagation), + SubPathExpr: v.SubPathExpr, } } @@ -306,6 +455,7 @@ func MapContainerConfigKubeToAPI(v testworkflowsv1.ContainerConfig) testkube.Tes Args: MapStringSliceToBoxedStringList(v.Args), Resources: common.MapPtr(v.Resources, MapResourcesKubeToAPI), SecurityContext: MapSecurityContextKubeToAPI(v.SecurityContext), + VolumeMounts: common.MapSlice(v.VolumeMounts, MapVolumeMountKubeToAPI), } } @@ -351,14 +501,14 @@ func MapStepArtifactsKubeToAPI(v testworkflowsv1.StepArtifacts) testkube.TestWor func MapRetryPolicyKubeToAPI(v testworkflowsv1.RetryPolicy) testkube.TestWorkflowRetryPolicy { return testkube.TestWorkflowRetryPolicy{ Count: v.Count, - Until: string(v.Until), + Until: v.Until, } } func MapStepKubeToAPI(v testworkflowsv1.Step) testkube.TestWorkflowStep { return testkube.TestWorkflowStep{ Name: v.Name, - Condition: string(v.Condition), + Condition: v.Condition, Negative: v.Negative, Optional: v.Optional, Use: common.MapSlice(v.Use, MapTemplateRefKubeToAPI), @@ -373,6 +523,7 @@ func MapStepKubeToAPI(v testworkflowsv1.Step) testkube.TestWorkflowStep { Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), + Setup: common.MapSlice(v.Setup, MapStepKubeToAPI), Steps: common.MapSlice(v.Steps, MapStepKubeToAPI), } } @@ -380,7 +531,7 @@ func MapStepKubeToAPI(v testworkflowsv1.Step) testkube.TestWorkflowStep { func MapIndependentStepKubeToAPI(v testworkflowsv1.IndependentStep) testkube.TestWorkflowIndependentStep { return testkube.TestWorkflowIndependentStep{ Name: v.Name, - Condition: string(v.Condition), + Condition: v.Condition, Negative: v.Negative, Optional: v.Optional, Retry: common.MapPtr(v.Retry, MapRetryPolicyKubeToAPI), @@ -393,6 +544,7 @@ func MapIndependentStepKubeToAPI(v testworkflowsv1.IndependentStep) testkube.Tes Container: common.MapPtr(v.Container, MapContainerConfigKubeToAPI), Execute: common.MapPtr(v.Execute, MapStepExecuteKubeToAPI), Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsKubeToAPI), + Setup: common.MapSlice(v.Setup, MapIndependentStepKubeToAPI), Steps: common.MapSlice(v.Steps, MapIndependentStepKubeToAPI), } } diff --git a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go index b77caaf805d..40876caf7ca 100644 --- a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go +++ b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go @@ -43,6 +43,18 @@ func MapBoxedStringToString(v *testkube.BoxedString) *string { return &v.Value } +func MapBoxedStringToType[T ~string](v *testkube.BoxedString) *T { + if v == nil { + return nil + } + return common.Ptr(T(v.Value)) +} + +func MapBoxedStringToQuantity(v testkube.BoxedString) resource.Quantity { + q, _ := resource.ParseQuantity(v.Value) + return q +} + func MapBoxedBooleanToBool(v *testkube.BoxedBoolean) *bool { if v == nil { return nil @@ -301,6 +313,130 @@ func MapJobConfigAPIToKube(v testkube.TestWorkflowJobConfig) testworkflowsv1.Job } } +func MapHostPathVolumeSourceAPIToKube(v testkube.HostPathVolumeSource) corev1.HostPathVolumeSource { + return corev1.HostPathVolumeSource{ + Path: v.Path, + Type: MapBoxedStringToType[corev1.HostPathType](v.Type_), + } +} + +func MapEmptyDirVolumeSourceAPIToKube(v testkube.EmptyDirVolumeSource) corev1.EmptyDirVolumeSource { + return corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMedium(v.Medium), + SizeLimit: common.MapPtr(v.SizeLimit, MapBoxedStringToQuantity), + } +} + +func MapGCEPersistentDiskVolumeSourceAPIToKube(v testkube.GcePersistentDiskVolumeSource) corev1.GCEPersistentDiskVolumeSource { + return corev1.GCEPersistentDiskVolumeSource{ + PDName: v.PdName, + FSType: v.FsType, + Partition: v.Partition, + ReadOnly: v.ReadOnly, + } +} + +func MapAWSElasticBlockStoreVolumeSourceAPIToKube(v testkube.AwsElasticBlockStoreVolumeSource) corev1.AWSElasticBlockStoreVolumeSource { + return corev1.AWSElasticBlockStoreVolumeSource{ + VolumeID: v.VolumeID, + FSType: v.FsType, + Partition: v.Partition, + ReadOnly: v.ReadOnly, + } +} + +func MapKeyToPathAPIToKube(v testkube.SecretVolumeSourceItems) corev1.KeyToPath { + return corev1.KeyToPath{ + Key: v.Key, + Path: v.Path, + Mode: MapBoxedIntegerToInt32(v.Mode), + } +} + +func MapSecretVolumeSourceAPIToKube(v testkube.SecretVolumeSource) corev1.SecretVolumeSource { + return corev1.SecretVolumeSource{ + SecretName: v.SecretName, + Items: common.MapSlice(v.Items, MapKeyToPathAPIToKube), + DefaultMode: MapBoxedIntegerToInt32(v.DefaultMode), + Optional: common.PtrOrNil(v.Optional), + } +} + +func MapNFSVolumeSourceAPIToKube(v testkube.NfsVolumeSource) corev1.NFSVolumeSource { + return corev1.NFSVolumeSource{ + Server: v.Server, + Path: v.Path, + ReadOnly: v.ReadOnly, + } +} + +func MapPersistentVolumeClaimVolumeSourceAPIToKube(v testkube.PersistentVolumeClaimVolumeSource) corev1.PersistentVolumeClaimVolumeSource { + return corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: v.ClaimName, + ReadOnly: v.ReadOnly, + } +} + +func MapCephFSVolumeSourceAPIToKube(v testkube.CephFsVolumeSource) corev1.CephFSVolumeSource { + return corev1.CephFSVolumeSource{ + Monitors: v.Monitors, + Path: v.Path, + User: v.User, + SecretFile: v.SecretFile, + SecretRef: common.MapPtr(v.SecretRef, MapLocalObjectReferenceAPIToKube), + ReadOnly: v.ReadOnly, + } +} + +func MapAzureFileVolumeSourceAPIToKube(v testkube.AzureFileVolumeSource) corev1.AzureFileVolumeSource { + return corev1.AzureFileVolumeSource{ + SecretName: v.SecretName, + ShareName: v.ShareName, + ReadOnly: v.ReadOnly, + } +} + +func MapConfigMapVolumeSourceAPIToKube(v testkube.ConfigMapVolumeSource) corev1.ConfigMapVolumeSource { + return corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: v.Name}, + Items: common.MapSlice(v.Items, MapKeyToPathAPIToKube), + DefaultMode: MapBoxedIntegerToInt32(v.DefaultMode), + Optional: common.PtrOrNil(v.Optional), + } +} + +func MapAzureDiskVolumeSourceAPIToKube(v testkube.AzureDiskVolumeSource) corev1.AzureDiskVolumeSource { + return corev1.AzureDiskVolumeSource{ + DiskName: v.DiskName, + DataDiskURI: v.DiskURI, + CachingMode: MapBoxedStringToType[corev1.AzureDataDiskCachingMode](v.CachingMode), + FSType: MapBoxedStringToString(v.FsType), + ReadOnly: common.PtrOrNil(v.ReadOnly), + Kind: MapBoxedStringToType[corev1.AzureDataDiskKind](v.Kind), + } +} + +func MapVolumeAPIToKube(v testkube.Volume) corev1.Volume { + // TODO: Add rest of VolumeSource types in future, + // so they will be recognized by JSON API and persisted with Execution. + return corev1.Volume{ + Name: v.Name, + VolumeSource: corev1.VolumeSource{ + HostPath: common.MapPtr(v.HostPath, MapHostPathVolumeSourceAPIToKube), + EmptyDir: common.MapPtr(v.EmptyDir, MapEmptyDirVolumeSourceAPIToKube), + GCEPersistentDisk: common.MapPtr(v.GcePersistentDisk, MapGCEPersistentDiskVolumeSourceAPIToKube), + AWSElasticBlockStore: common.MapPtr(v.AwsElasticBlockStore, MapAWSElasticBlockStoreVolumeSourceAPIToKube), + Secret: common.MapPtr(v.Secret, MapSecretVolumeSourceAPIToKube), + NFS: common.MapPtr(v.Nfs, MapNFSVolumeSourceAPIToKube), + PersistentVolumeClaim: common.MapPtr(v.PersistentVolumeClaim, MapPersistentVolumeClaimVolumeSourceAPIToKube), + CephFS: common.MapPtr(v.Cephfs, MapCephFSVolumeSourceAPIToKube), + AzureFile: common.MapPtr(v.AzureFile, MapAzureFileVolumeSourceAPIToKube), + ConfigMap: common.MapPtr(v.ConfigMap, MapConfigMapVolumeSourceAPIToKube), + AzureDisk: common.MapPtr(v.AzureDisk, MapAzureDiskVolumeSourceAPIToKube), + }, + } +} + func MapPodConfigAPIToKube(v testkube.TestWorkflowPodConfig) testworkflowsv1.PodConfig { return testworkflowsv1.PodConfig{ ServiceAccountName: v.ServiceAccountName, @@ -308,6 +444,18 @@ func MapPodConfigAPIToKube(v testkube.TestWorkflowPodConfig) testworkflowsv1.Pod NodeSelector: v.NodeSelector, Labels: v.Labels, Annotations: v.Annotations, + Volumes: common.MapSlice(v.Volumes, MapVolumeAPIToKube), + } +} + +func MapVolumeMountAPIToKube(v testkube.VolumeMount) corev1.VolumeMount { + return corev1.VolumeMount{ + Name: v.Name, + ReadOnly: v.ReadOnly, + MountPath: v.MountPath, + SubPath: v.SubPath, + MountPropagation: MapBoxedStringToType[corev1.MountPropagationMode](v.MountPropagation), + SubPathExpr: v.SubPathExpr, } } @@ -322,6 +470,7 @@ func MapContainerConfigAPIToKube(v testkube.TestWorkflowContainerConfig) testwor Args: MapBoxedStringListToStringSlice(v.Args), Resources: common.MapPtr(v.Resources, MapResourcesAPIToKube), SecurityContext: MapSecurityContextAPIToKube(v.SecurityContext), + VolumeMounts: common.MapSlice(v.VolumeMounts, MapVolumeMountAPIToKube), } } @@ -393,6 +542,7 @@ func MapStepAPIToKube(v testkube.TestWorkflowStep) testworkflowsv1.Step { }, Use: common.MapSlice(v.Use, MapTemplateRefAPIToKube), Template: common.MapPtr(v.Template, MapTemplateRefAPIToKube), + Setup: common.MapSlice(v.Setup, MapStepAPIToKube), Steps: common.MapSlice(v.Steps, MapStepAPIToKube), } } @@ -415,6 +565,7 @@ func MapIndependentStepAPIToKube(v testkube.TestWorkflowIndependentStep) testwor Execute: common.MapPtr(v.Execute, MapStepExecuteAPIToKube), Artifacts: common.MapPtr(v.Artifacts, MapStepArtifactsAPIToKube), }, + Setup: common.MapSlice(v.Setup, MapIndependentStepAPIToKube), Steps: common.MapSlice(v.Steps, MapIndependentStepAPIToKube), } } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go index 88769760a91..b3471cbf6fb 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go @@ -26,9 +26,8 @@ import ( ) type container struct { - parent *container - Cr testworkflowsv1.ContainerConfig `expr:"include"` - CrMounts []corev1.VolumeMount `expr:"force"` + parent *container + Cr testworkflowsv1.ContainerConfig `expr:"include"` } type ContainerComposition interface { @@ -132,9 +131,9 @@ func (c *container) EnvFrom() []corev1.EnvFromSource { func (c *container) VolumeMounts() []corev1.VolumeMount { if c.parent == nil { - return c.CrMounts + return c.Cr.VolumeMounts } - return sum(c.parent.VolumeMounts(), c.CrMounts) + return sum(c.parent.VolumeMounts(), c.Cr.VolumeMounts) } func (c *container) ImagePullPolicy() corev1.PullPolicy { @@ -237,7 +236,7 @@ func (c *container) AppendEnvFrom(envFrom ...corev1.EnvFromSource) Container { } func (c *container) AppendVolumeMounts(volumeMounts ...corev1.VolumeMount) Container { - c.CrMounts = append(c.CrMounts, volumeMounts...) + c.Cr.VolumeMounts = append(c.Cr.VolumeMounts, volumeMounts...) return c } @@ -290,6 +289,10 @@ func (c *container) ToContainerConfig() testworkflowsv1.ContainerConfig { for i := range envFrom { envFrom[i] = *envFrom[i].DeepCopy() } + volumeMounts := slices.Clone(c.VolumeMounts()) + for i := range volumeMounts { + volumeMounts[i] = *volumeMounts[i].DeepCopy() + } return testworkflowsv1.ContainerConfig{ WorkingDir: common.Ptr(c.WorkingDir()), Image: c.Image(), @@ -303,20 +306,12 @@ func (c *container) ToContainerConfig() testworkflowsv1.ContainerConfig { Limits: maps.Clone(c.Resources().Limits), }, SecurityContext: c.SecurityContext().DeepCopy(), + VolumeMounts: volumeMounts, } } -func (c *container) volumeMountsCopy() []corev1.VolumeMount { - volumeMounts := make([]corev1.VolumeMount, len(c.VolumeMounts())) - for i, v := range c.VolumeMounts() { - volumeMounts[i] = *v.DeepCopy() - } - return volumeMounts -} - func (c *container) Detach() Container { c.Cr = c.ToContainerConfig() - c.CrMounts = c.volumeMountsCopy() c.parent = nil return c } @@ -365,7 +360,7 @@ func (c *container) ToKubernetesTemplate() (corev1.Container, error) { Args: args, Env: cr.Env, EnvFrom: cr.EnvFrom, - VolumeMounts: c.volumeMountsCopy(), + VolumeMounts: cr.VolumeMounts, Resources: resources, WorkingDir: workingDir, SecurityContext: cr.SecurityContext, diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go index d8223d01961..a51e097a906 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/intermediate.go @@ -58,7 +58,6 @@ type intermediate struct { Job testworkflowsv1.JobConfig `expr:"include"` // Actual Kubernetes resources to use - Vols []corev1.Volume `expr:"force"` Secs []corev1.Secret `expr:"force"` Cfgs []corev1.ConfigMap `expr:"force"` @@ -95,7 +94,7 @@ func (s *intermediate) Secrets() []corev1.Secret { } func (s *intermediate) Volumes() []corev1.Volume { - return s.Vols + return s.Pod.Volumes } func (s *intermediate) AppendJobConfig(cfg *testworkflowsv1.JobConfig) Intermediate { @@ -109,7 +108,7 @@ func (s *intermediate) AppendPodConfig(cfg *testworkflowsv1.PodConfig) Intermedi } func (s *intermediate) AddVolume(volume corev1.Volume) Intermediate { - s.Vols = append(s.Vols, volume) + s.Pod.Volumes = append(s.Pod.Volumes, volume) return s } @@ -147,7 +146,7 @@ func (s *intermediate) getInternalConfigMapStorage(size int) *corev1.ConfigMap { BinaryData: map[string][]byte{}, }) s.currentConfigMapStorage = &s.Cfgs[len(s.Cfgs)-1] - s.Vols = append(s.Vols, corev1.Volume{ + s.Pod.Volumes = append(s.Pod.Volumes, corev1.Volume{ Name: s.currentConfigMapStorage.Name + "-vol", VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go index 76a1b9b8b45..af7168be5a0 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -59,6 +59,18 @@ func ProcessRunCommand(_ InternalProcessor, layer Intermediate, container Contai return stage, nil } +func ProcessNestedSetupSteps(p InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { + group := NewGroupStage(layer.NextRef(), true) + for _, n := range step.Setup { + stage, err := p.Process(layer, container.CreateChild(), n) + if err != nil { + return nil, err + } + group.Add(stage) + } + return group, nil +} + func ProcessNestedSteps(p InternalProcessor, layer Intermediate, container Container, step testworkflowsv1.Step) (Stage, error) { group := NewGroupStage(layer.NextRef(), true) for _, n := range step.Steps { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go index 35494abd46c..4ff2153ab1a 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -53,6 +53,7 @@ func NewFullFeatured(inspector imageinspector.Inspector) Processor { Register(ProcessDelay). Register(ProcessContentFiles). Register(ProcessContentGit). + Register(ProcessNestedSetupSteps). Register(ProcessRunCommand). Register(ProcessShellCommand). Register(ProcessExecute). diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go index 8fc5aa36347..e509a6831aa 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/analyze.go @@ -31,6 +31,9 @@ func listStepTemplates(cr testworkflowsv1.Step) map[string]struct{} { for i := range cr.Use { v[GetInternalTemplateName(cr.Use[i].Name)] = struct{}{} } + for i := range cr.Setup { + maps.Copy(v, listStepTemplates(cr.Setup[i])) + } for i := range cr.Steps { maps.Copy(v, listStepTemplates(cr.Steps[i])) } diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go index 59f6bc0b10c..83a8f345861 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply.go @@ -88,10 +88,12 @@ func InjectStepTemplate(step *testworkflowsv1.Step, template testworkflowsv1.Tes } // Decouple sub-steps from the template - setup := common.MapSlice(append(template.Spec.Setup, template.Spec.Steps...), ConvertIndependentStepToStep) + setup := common.MapSlice(template.Spec.Setup, ConvertIndependentStepToStep) + steps := common.MapSlice(template.Spec.Steps, ConvertIndependentStepToStep) after := common.MapSlice(template.Spec.After, ConvertIndependentStepToStep) - step.Steps = append(setup, append(step.Steps, after...)...) + step.Setup = append(setup, step.Setup...) + step.Steps = append(steps, append(step.Steps, after...)...) return nil } @@ -122,9 +124,9 @@ func applyTemplatesToStep(step testworkflowsv1.Step, templates map[string]testwo return step, errors.Wrap(err, ".template: injecting template") } - if len(isolate.Steps) > 0 { + if len(isolate.Setup) > 0 || len(isolate.Steps) > 0 { if isolate.Container == nil && isolate.Content == nil && isolate.WorkingDir == nil { - step.Steps = append(isolate.Steps, step.Steps...) + step.Steps = append(append(isolate.Setup, isolate.Steps...), step.Steps...) } else { step.Steps = append([]testworkflowsv1.Step{isolate}, step.Steps...) } @@ -135,6 +137,12 @@ func applyTemplatesToStep(step testworkflowsv1.Step, templates map[string]testwo // Resolve templates in the sub-steps var err error + for i := range step.Setup { + step.Setup[i], err = applyTemplatesToStep(step.Setup[i], templates) + if err != nil { + return step, errors.Wrap(err, fmt.Sprintf(".steps[%d]", i)) + } + } for i := range step.Steps { step.Steps[i], err = applyTemplatesToStep(step.Steps[i], templates) if err != nil { @@ -149,12 +157,15 @@ func FlattenStepList(steps []testworkflowsv1.Step) []testworkflowsv1.Step { changed := false result := make([]testworkflowsv1.Step, 0, len(steps)) for _, step := range steps { + setup := step.Setup sub := step.Steps + step.Setup = nil step.Steps = nil if reflect.ValueOf(step).IsZero() { changed = true - result = append(result, sub...) + result = append(result, append(setup, sub...)...) } else { + step.Setup = setup step.Steps = sub result = append(result, step) } diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go index 36155734d5e..67271f66aa6 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/apply_test.go @@ -401,8 +401,10 @@ func TestApplyTemplatesStepBasicIsolatedWrapped(t *testing.T) { StepBase: testworkflowsv1.StepBase{ Container: tplStepsEnv.Spec.Container, }, - Steps: []testworkflowsv1.Step{ + Setup: []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplStepsEnv.Spec.Setup[0]), + }, + Steps: []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplStepsEnv.Spec.Steps[0]), ConvertIndependentStepToStep(tplStepsEnv.Spec.After[0]), }, @@ -418,8 +420,10 @@ func TestApplyTemplatesStepBasicSteps(t *testing.T) { s, err := applyTemplatesToStep(s, templates) want := *basicStep.DeepCopy() - want.Steps = append([]testworkflowsv1.Step{ + want.Setup = []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + } + want.Steps = append([]testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), }, append(want.Steps, []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.After[0]), @@ -435,18 +439,20 @@ func TestApplyTemplatesStepBasicMultipleSteps(t *testing.T) { s, err := applyTemplatesToStep(s, templates) want := *basicStep.DeepCopy() - want.Steps = append([]testworkflowsv1.Step{ + want.Setup = []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), - ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + } + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), }, append(want.Steps, []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.After[0]), ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), }...)...) - want.Steps[0].Name = "setup-tpl-test-20" - want.Steps[1].Name = "steps-tpl-test-20" - want.Steps[5].Name = "after-tpl-test-20" + want.Setup[0].Name = "setup-tpl-test-20" + want.Steps[0].Name = "steps-tpl-test-20" + want.Steps[3].Name = "after-tpl-test-20" assert.NoError(t, err) assert.Equal(t, want, s) @@ -478,8 +484,10 @@ func TestApplyTemplatesStepAdvancedIsolatedWrapped(t *testing.T) { StepBase: testworkflowsv1.StepBase{ Container: tplStepsEnv.Spec.Container, }, - Steps: []testworkflowsv1.Step{ + Setup: []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplStepsEnv.Spec.Setup[0]), + }, + Steps: []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplStepsEnv.Spec.Steps[0]), ConvertIndependentStepToStep(tplStepsEnv.Spec.After[0]), }, @@ -495,8 +503,10 @@ func TestApplyTemplatesStepAdvancedSteps(t *testing.T) { s, err := applyTemplatesToStep(s, templates) want := *advancedStep.DeepCopy() - want.Steps = append([]testworkflowsv1.Step{ + want.Setup = []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + } + want.Steps = append([]testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), }, append(want.Steps, []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.After[0]), @@ -512,18 +522,20 @@ func TestApplyTemplatesStepAdvancedMultipleSteps(t *testing.T) { s, err := applyTemplatesToStep(s, templates) want := *advancedStep.DeepCopy() - want.Steps = append([]testworkflowsv1.Step{ + want.Setup = []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplStepsConfig.Spec.Setup[0]), - ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), ConvertIndependentStepToStep(tplSteps.Spec.Setup[0]), + } + want.Steps = append([]testworkflowsv1.Step{ + ConvertIndependentStepToStep(tplStepsConfig.Spec.Steps[0]), ConvertIndependentStepToStep(tplSteps.Spec.Steps[0]), }, append(want.Steps, []testworkflowsv1.Step{ ConvertIndependentStepToStep(tplSteps.Spec.After[0]), ConvertIndependentStepToStep(tplStepsConfig.Spec.After[0]), }...)...) - want.Steps[0].Name = "setup-tpl-test-20" - want.Steps[1].Name = "steps-tpl-test-20" - want.Steps[6].Name = "after-tpl-test-20" + want.Setup[0].Name = "setup-tpl-test-20" + want.Steps[0].Name = "steps-tpl-test-20" + want.Steps[4].Name = "after-tpl-test-20" assert.NoError(t, err) assert.Equal(t, want, s) diff --git a/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go b/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go index c51bbb04d95..04c053453e5 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go +++ b/pkg/tcl/testworkflowstcl/testworkflowresolver/merge.go @@ -36,6 +36,7 @@ func MergePodConfig(dst, include *testworkflowsv1.PodConfig) *testworkflowsv1.Po dst.NodeSelector = map[string]string{} } maps.Copy(dst.NodeSelector, include.NodeSelector) + dst.Volumes = append(dst.Volumes, include.Volumes...) dst.ImagePullSecrets = append(dst.ImagePullSecrets, include.ImagePullSecrets...) if include.ServiceAccountName != "" { dst.ServiceAccountName = include.ServiceAccountName @@ -120,6 +121,7 @@ func MergeContainerConfig(dst, include *testworkflowsv1.ContainerConfig) *testwo } dst.Env = append(dst.Env, include.Env...) dst.EnvFrom = append(dst.EnvFrom, include.EnvFrom...) + dst.VolumeMounts = append(dst.VolumeMounts, include.VolumeMounts...) if include.Image != "" { dst.Image = include.Image dst.Command = include.Command @@ -137,6 +139,7 @@ func MergeContainerConfig(dst, include *testworkflowsv1.ContainerConfig) *testwo func ConvertIndependentStepToStep(step testworkflowsv1.IndependentStep) (res testworkflowsv1.Step) { res.StepBase = step.StepBase + res.Setup = common.MapSlice(step.Setup, ConvertIndependentStepToStep) res.Steps = common.MapSlice(step.Steps, ConvertIndependentStepToStep) return res } From 0bbb6c6bb7a332cc68cc38125622db00113e7787 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 12 Mar 2024 17:02:05 +0300 Subject: [PATCH 208/234] fix: ignore non test pods --- pkg/triggers/watcher.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/triggers/watcher.go b/pkg/triggers/watcher.go index 20ca1afa31b..43719f8b8c5 100644 --- a/pkg/triggers/watcher.go +++ b/pkg/triggers/watcher.go @@ -275,8 +275,8 @@ func (s *Service) podEventHandler(ctx context.Context) cache.ResourceEventHandle ) return } - if oldPod.Namespace == s.testkubeNamespace && oldPod.Labels["job-name"] != "" && - newPod.Namespace == s.testkubeNamespace && newPod.Labels["job-name"] != "" && + if oldPod.Namespace == s.testkubeNamespace && oldPod.Labels["job-name"] != "" && oldPod.Labels[testkube.TestLabelTestName] != "" && + newPod.Namespace == s.testkubeNamespace && newPod.Labels["job-name"] != "" && newPod.Labels[testkube.TestLabelTestName] != "" && oldPod.Labels["job-name"] == newPod.Labels["job-name"] { s.checkExecutionPodStatus(ctx, oldPod.Labels["job-name"], []*corev1.Pod{oldPod, newPod}) } @@ -288,7 +288,7 @@ func (s *Service) podEventHandler(ctx context.Context) cache.ResourceEventHandle return } s.logger.Debugf("trigger service: watcher component: emiting event: pod %s/%s deleted", pod.Namespace, pod.Name) - if pod.Namespace == s.testkubeNamespace && pod.Labels["job-name"] != "" { + if pod.Namespace == s.testkubeNamespace && pod.Labels["job-name"] != "" && pod.Labels[testkube.TestLabelTestName] != "" { s.checkExecutionPodStatus(ctx, pod.Labels["job-name"], []*corev1.Pod{pod}) } event := newWatcherEvent(testtrigger.EventDeleted, pod, testtrigger.ResourcePod, @@ -304,6 +304,7 @@ func (s *Service) checkExecutionPodStatus(ctx context.Context, executionID strin if len(pods) > 0 && pods[0].Labels[testworkflowprocessor.ExecutionIdLabelName] != "" { return nil } + execution, err := s.resultRepository.Get(ctx, executionID) if err != nil { s.logger.Errorf("get execution returned an error %v while looking for execution id: %s", err, executionID) From 1110e9652ba007e60fe4c99f2b65efeba7de3018 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Tue, 12 Mar 2024 18:03:34 +0300 Subject: [PATCH 209/234] fix: typo for auth selection --- pkg/skopeo/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/skopeo/client.go b/pkg/skopeo/client.go index 1b8c8d476b4..4da12702c19 100644 --- a/pkg/skopeo/client.go +++ b/pkg/skopeo/client.go @@ -84,7 +84,7 @@ func (c *client) Inspect(image string) (*DockerImage, error) { } if len(c.dockerAuthConfigs) != 0 { - i := 1 + rand.Intn(len(c.dockerAuthConfigs)) + i := rand.Intn(len(c.dockerAuthConfigs)) args = append(args, "--creds", c.dockerAuthConfigs[i].Username+":"+c.dockerAuthConfigs[i].Password) } From eb95557ccae7c308f485d523c40d40ba8b9aa985 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Tue, 12 Mar 2024 18:07:20 +0100 Subject: [PATCH 210/234] fix: make sync backwards compatible (#5161) --- api/v1/testkube.yaml | 3 +++ go.mod | 2 +- go.sum | 4 ++-- .../v1/testkube/model_test_suite_step_execution_request.go | 2 ++ pkg/mapper/testsuites/kube_openapi.go | 1 + pkg/mapper/testsuites/openapi_kube.go | 1 + 6 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 30ded87120a..26aa3631c74 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -6302,6 +6302,9 @@ components: - append - override - replace + sync: + type: boolean + description: whether to start execution sync or async httpProxy: type: string description: http proxy for executor containers diff --git a/go.mod b/go.mod index ed2669c85e4..8f00712ac68 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312131535-c5c795881853 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312153624-07cd5e8ce0be github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 54b5ad997fb..157b1739248 100644 --- a/go.sum +++ b/go.sum @@ -360,8 +360,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw= github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312131535-c5c795881853 h1:K91UOZFWIHNQuxsulpQ3aTTtjZmCuC3b84B+Vr5m8vA= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312131535-c5c795881853/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312153624-07cd5e8ce0be h1:cs5m8bekmvEcyvFT37KgUEduv6XrdUfmX5WqZAbLUCY= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312153624-07cd5e8ce0be/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/pkg/api/v1/testkube/model_test_suite_step_execution_request.go b/pkg/api/v1/testkube/model_test_suite_step_execution_request.go index 77093fb27eb..85d64b5e1b6 100644 --- a/pkg/api/v1/testkube/model_test_suite_step_execution_request.go +++ b/pkg/api/v1/testkube/model_test_suite_step_execution_request.go @@ -20,6 +20,8 @@ type TestSuiteStepExecutionRequest struct { Args []string `json:"args,omitempty"` // usage mode for arguments ArgsMode string `json:"args_mode,omitempty"` + // whether to start execution sync or async + Sync bool `json:"sync,omitempty"` // http proxy for executor containers HttpProxy string `json:"httpProxy,omitempty"` // https proxy for executor containers diff --git a/pkg/mapper/testsuites/kube_openapi.go b/pkg/mapper/testsuites/kube_openapi.go index c39b1ef9609..4f384847350 100644 --- a/pkg/mapper/testsuites/kube_openapi.go +++ b/pkg/mapper/testsuites/kube_openapi.go @@ -391,6 +391,7 @@ func MapTestStepExecutionRequestCRDToAPI(request *testsuitesv3.TestSuiteStepExec Command: request.Command, Args: request.Args, ArgsMode: argsMode, + Sync: request.Sync, HttpProxy: request.HttpProxy, HttpsProxy: request.HttpsProxy, NegativeTest: request.NegativeTest, diff --git a/pkg/mapper/testsuites/openapi_kube.go b/pkg/mapper/testsuites/openapi_kube.go index bd257b6f7f7..4132768594b 100644 --- a/pkg/mapper/testsuites/openapi_kube.go +++ b/pkg/mapper/testsuites/openapi_kube.go @@ -468,6 +468,7 @@ func MapTestStepExecutionRequestCRD(request *testkube.TestSuiteStepExecutionRequ Args: request.Args, ArgsMode: testsuitesv3.ArgsModeType(request.ArgsMode), Command: request.Command, + Sync: request.Sync, HttpProxy: request.HttpProxy, HttpsProxy: request.HttpsProxy, NegativeTest: request.NegativeTest, From c1a162f85f97f5a45db08708f316e899c8f52f1c Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 12 Mar 2024 18:17:33 +0100 Subject: [PATCH 211/234] feat: handle aborting TestWorkflow executions gracefully (#5162) --- .../testworkflowexecutor/executor.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go index 6201658e7f4..c38d2fd70dc 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go @@ -148,6 +148,26 @@ func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowE } } + // Try to gracefully handle abort + if execution.Result.FinishedAt.IsZero() { + ctrl, err = testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) + if err == nil { + for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { + if v.Error != nil || v.Value.Output == nil { + continue + } + + execution.Result = v.Value.Result + if execution.Result.IsFinished() { + execution.StatusAt = execution.Result.FinishedAt + } + _ = e.repository.UpdateResult(ctx, execution.Id, execution.Result) + } + } else { + e.handleFatalError(execution, err) + } + } + err := writer.Close() if err != nil { log.DefaultLogger.Errorw("failed to close TestWorkflow log output stream", "id", execution.Id, "error", err) From 2f4e925b0680b44102ec1a3c42cc4b4372d32572 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Tue, 12 Mar 2024 18:25:29 +0100 Subject: [PATCH 212/234] feat: calibrate TestWorkflow clock for more precise step durations (#5163) --- cmd/tcl/testworkflow-init/data/state.go | 3 + .../model_test_workflow_result_extended.go | 129 ++++++++++++++++++ .../testworkflowcontroller/controller.go | 18 ++- .../testworkflowprocessor/processor.go | 2 +- .../testworkflowprocessor/processor_test.go | 20 +-- 5 files changed, 157 insertions(+), 15 deletions(-) diff --git a/cmd/tcl/testworkflow-init/data/state.go b/cmd/tcl/testworkflow-init/data/state.go index 34071861aa8..352053eca7c 100644 --- a/cmd/tcl/testworkflow-init/data/state.go +++ b/cmd/tcl/testworkflow-init/data/state.go @@ -161,6 +161,9 @@ func Finish() { _ = Step.Cmd.Process.Kill() } + // Emit end hint to allow exporting the timestamp + PrintHint(Step.Ref, "end") + // The init process needs to finish with zero exit code, // to continue with the next container. os.Exit(0) diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index bc6a51a570d..b19c34e3691 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -112,6 +112,77 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { return } + // Calibrate the execution time initially + r.StartedAt = adjustMinimumTime(r.StartedAt, r.QueuedAt) + r.FinishedAt = adjustMinimumTime(r.FinishedAt, r.StartedAt) + initialDate := r.StartedAt + if initialDate.IsZero() { + initialDate = r.QueuedAt + } + + // Calibrate the initialization step + if r.Initialization != nil { + r.Initialization.QueuedAt = adjustMinimumTime(r.Initialization.QueuedAt, initialDate) + r.Initialization.StartedAt = adjustMinimumTime(r.Initialization.StartedAt, r.Initialization.QueuedAt) + r.Initialization.FinishedAt = adjustMinimumTime(r.Initialization.FinishedAt, r.Initialization.StartedAt) + initialDate = getLastDate(*r.Initialization, initialDate) + } + + // Prepare sequential list of container steps + type step struct { + ref string + result TestWorkflowStepResult + } + seq := make([]step, 0) + walkSteps(sig, func(s TestWorkflowSignature) { + if len(s.Children) > 0 { + return + } + seq = append(seq, step{ref: s.Ref, result: r.Steps[s.Ref]}) + }) + + // Calibrate the clock for container steps + for i := 0; i < len(seq); i++ { + if i != 0 { + initialDate = getLastDate(seq[i-1].result, initialDate) + } + seq[i].result.QueuedAt = adjustMinimumTime(seq[i].result.QueuedAt, initialDate) + seq[i].result.StartedAt = adjustMinimumTime(seq[i].result.StartedAt, seq[i].result.QueuedAt) + seq[i].result.FinishedAt = adjustMinimumTime(seq[i].result.FinishedAt, seq[i].result.StartedAt) + } + for _, s := range seq { + r.Steps[s.ref] = s.result + } + + // Calibrate the clock for group steps + walkSteps(sig, func(s TestWorkflowSignature) { + if len(s.Children) == 0 { + return + } + first := getFirstContainer(s.Children) + last := getLastContainer(s.Children) + if first == nil || last == nil { + return + } + res := r.Steps[s.Ref] + res.QueuedAt = r.Steps[first.Ref].QueuedAt + res.StartedAt = r.Steps[first.Ref].StartedAt + res.FinishedAt = r.Steps[last.Ref].FinishedAt + r.Steps[s.Ref] = res + }) + + // Calibrate execution clock + if r.Initialization != nil { + if r.Initialization.QueuedAt.Before(r.QueuedAt) { + r.QueuedAt = r.Initialization.QueuedAt + } + if r.Initialization.StartedAt.Before(r.StartedAt) { + r.StartedAt = r.Initialization.StartedAt + } + } + last := r.Steps[sig[len(sig)-1].Ref] + r.FinishedAt = adjustMinimumTime(r.FinishedAt, last.FinishedAt) + // Recompute the TestWorkflow status totalSig := TestWorkflowSignature{Children: sig} result, _ := predictTestWorkflowStepStatus(TestWorkflowStepResult{}, totalSig, r) @@ -149,6 +220,64 @@ func (r *TestWorkflowResult) RecomputeStep(sig TestWorkflowSignature) { v = recomputeTestWorkflowStepResult(v, sig, r) } +func walkSteps(sig []TestWorkflowSignature, fn func(signature TestWorkflowSignature)) { + for _, s := range sig { + fn(s) + walkSteps(s.Children, fn) + } +} + +func getFirstContainer(sig []TestWorkflowSignature) *TestWorkflowSignature { + for i := 0; i < len(sig); i++ { + s := sig[i] + if len(s.Children) == 0 { + return &s + } + c := getFirstContainer(s.Children) + if c != nil { + return c + } + } + return nil +} + +func getLastContainer(sig []TestWorkflowSignature) *TestWorkflowSignature { + for i := len(sig) - 1; i >= 0; i-- { + s := sig[i] + if len(s.Children) == 0 { + return &s + } + c := getLastContainer(s.Children) + if c != nil { + return c + } + } + return nil +} + +func getLastDate(r TestWorkflowStepResult, def time.Time) time.Time { + if !r.FinishedAt.IsZero() { + return r.FinishedAt + } + if !r.StartedAt.IsZero() { + return r.StartedAt + } + if !r.QueuedAt.IsZero() { + return r.QueuedAt + } + return def +} + +func adjustMinimumTime(dst, min time.Time) time.Time { + if dst.IsZero() { + return dst + } + if min.After(dst) { + return min + } + return dst +} + func predictTestWorkflowStepStatus(v TestWorkflowStepResult, sig TestWorkflowSignature, r *TestWorkflowResult) (TestWorkflowStepStatus, bool) { children := sig.Children if len(children) == 0 { diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go index 2325ea7c629..1e3fcbe82c1 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -207,12 +207,16 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { w.SendValue(Notification{Result: result.Clone()}) // Watch the initialization container logs + lastTs := result.Initialization.StartedAt pod := (<-c.pod.Any(ctx)).Value for v := range WatchContainerLogs(ctx, c.clientSet, c.podEvents, c.namespace, pod.Name, "tktw-init").Stream(ctx).Channel() { if v.Error != nil { w.SendError(v.Error) continue } + if v.Value.Time.After(lastTs) { + lastTs = v.Value.Time + } // TODO: Calibrate clock with v.Value.Hint or just first/last timestamp here w.SendValue(Notification{ Timestamp: v.Value.Time, @@ -227,6 +231,9 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { return } result.Initialization.FinishedAt = status.FinishedAt + if lastTs.After(result.Initialization.FinishedAt) { + result.Initialization.FinishedAt = lastTs + } result.Initialization.Status = common.Ptr(status.Status) if status.Status != testkube.PASSED_TestWorkflowStepStatus { result.Status = common.Ptr(testkube.FAILED_TestWorkflowStatus) @@ -240,7 +247,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } // Watch each of the containers - lastTs := result.Initialization.FinishedAt + lastTs = result.Initialization.FinishedAt for _, container := range append(pod.Spec.InitContainers, pod.Spec.Containers...) { // Ignore not-standard TestWorkflow containers if _, ok := result.Steps[container.Name]; !ok { @@ -292,7 +299,6 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { w.SendValue(Notification{Result: result.Clone()}) // Watch for the container logs, outputs and statuses - // TODO: Calibrate clock with Hints for v := range WatchContainerLogs(ctx, c.clientSet, c.podEvents, c.namespace, pod.Name, container.Name).Stream(ctx).Channel() { if v.Error != nil { w.SendError(v.Error) @@ -337,15 +343,19 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { w.SendError(err) break } + finishedAt := status.FinishedAt.UTC() + if !finishedAt.IsZero() && lastTs.After(finishedAt) { + finishedAt = lastTs.UTC() + } stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ - FinishedAt: status.FinishedAt.UTC(), + FinishedAt: finishedAt, ExitCode: float64(status.ExitCode), Status: common.Ptr(status.Status), }) w.SendValue(Notification{Result: result.Clone()}) // Update the last timestamp - lastTs = status.FinishedAt + lastTs = finishedAt } // Read the pod finish time diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go index 4ff2153ab1a..240b9adc506 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -254,7 +254,7 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s && (echo -n ',0' > %s && exit 0) || (echo -n 'failed,1' > %s && exit 1)", defaultInitPath, defaultStatePath, defaultStatePath, "/dev/termination-log", "/dev/termination-log")}, + Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s && (echo -n ',0' > %s && echo 'Done' && exit 0) || (echo -n 'failed,1' > %s && exit 1)", defaultInitPath, defaultStatePath, defaultStatePath, "/dev/termination-log", "/dev/termination-log")}, VolumeMounts: layer.ContainerDefaults().VolumeMounts(), } err = expressionstcl.FinalizeForce(&initContainer, machines...) diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go index 67826dc670f..81c2b43685a 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go @@ -93,7 +93,7 @@ func TestProcessBasic(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, }, @@ -168,7 +168,7 @@ func TestProcessBasicEnvReference(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, }, @@ -232,7 +232,7 @@ func TestProcessMultipleSteps(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -314,7 +314,7 @@ func TestProcessNestedSteps(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -438,7 +438,7 @@ func TestProcessOptionalSteps(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -560,7 +560,7 @@ func TestProcessNegativeSteps(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -679,7 +679,7 @@ func TestProcessNegativeContainerStep(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -755,7 +755,7 @@ func TestProcessOptionalContainerStep(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -840,7 +840,7 @@ func TestProcessLocalContent(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { @@ -932,7 +932,7 @@ func TestProcessGlobalContent(t *testing.T) { Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, - Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, + Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, }, { From 5f003de2baefdbe06ca3deb1ad511f3196c59fdd Mon Sep 17 00:00:00 2001 From: Tomasz Konieczny Date: Tue, 12 Mar 2024 23:33:41 +0100 Subject: [PATCH 213/234] feat: testworkflow cases - k6 and jmeter with artifacts, examples for kubecon (#5166) * test workflows - k6 and jmeter tests with artifacts * example tests and workflows for kubecon * example test - name fixed * example testsuite - typo fixed --- .../kubecon/test-workflows/cypress.yaml | 37 ++++++++++++++ .../kubecon/test-workflows/gradle.yaml | 30 +++++++++++ .../kubecon/test-workflows/jmeter.yaml | 30 +++++++++++ test/examples/kubecon/test-workflows/k6.yaml | 44 ++++++++++++++++ .../kubecon/test-workflows/playwright.yaml | 41 +++++++++++++++ .../kubecon/test-workflows/postman.yaml | 28 ++++++++++ test/examples/kubecon/tests/cypress.yaml | 51 +++++++++++++++++++ test/examples/kubecon/tests/gradle.yaml | 23 +++++++++ test/examples/kubecon/tests/jmeter.yaml | 18 +++++++ test/examples/kubecon/tests/k6.yaml | 18 +++++++ test/examples/kubecon/tests/playwright.yaml | 45 ++++++++++++++++ test/examples/kubecon/tests/postman.yaml | 20 ++++++++ .../examples/kubecon/tests/testsuites/k6.yaml | 19 +++++++ .../executor-tests/crd-workflow/smoke.yaml | 31 +++++++++++ .../k6/executor-tests/crd-workflow/smoke.yaml | 45 ++++++++++++++++ 15 files changed, 480 insertions(+) create mode 100644 test/examples/kubecon/test-workflows/cypress.yaml create mode 100644 test/examples/kubecon/test-workflows/gradle.yaml create mode 100644 test/examples/kubecon/test-workflows/jmeter.yaml create mode 100644 test/examples/kubecon/test-workflows/k6.yaml create mode 100644 test/examples/kubecon/test-workflows/playwright.yaml create mode 100644 test/examples/kubecon/test-workflows/postman.yaml create mode 100644 test/examples/kubecon/tests/cypress.yaml create mode 100644 test/examples/kubecon/tests/gradle.yaml create mode 100644 test/examples/kubecon/tests/jmeter.yaml create mode 100644 test/examples/kubecon/tests/k6.yaml create mode 100644 test/examples/kubecon/tests/playwright.yaml create mode 100644 test/examples/kubecon/tests/postman.yaml create mode 100644 test/examples/kubecon/tests/testsuites/k6.yaml diff --git a/test/examples/kubecon/test-workflows/cypress.yaml b/test/examples/kubecon/test-workflows/cypress.yaml new file mode 100644 index 00000000000..31d044b4273 --- /dev/null +++ b/test/examples/kubecon/test-workflows/cypress.yaml @@ -0,0 +1,37 @@ +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: cypress-video + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/cypress/executor-tests/cypress-13 + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/cypress/executor-tests/cypress-13 + steps: + - name: Run tests + run: + image: cypress/included:13.6.4 + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - video=true + env: + - name: CYPRESS_CUSTOM_ENV + value: CYPRESS_CUSTOM_ENV_value + steps: + - name: Saving artifacts + workingDir: /data/repo/test/cypress/executor-tests/cypress-13/cypress/videos + artifacts: + paths: + - '**/*' diff --git a/test/examples/kubecon/test-workflows/gradle.yaml b/test/examples/kubecon/test-workflows/gradle.yaml new file mode 100644 index 00000000000..a24a8230612 --- /dev/null +++ b/test/examples/kubecon/test-workflows/gradle.yaml @@ -0,0 +1,30 @@ +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: gradle-java-test + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - contrib/executor/gradle/examples/hello-gradle + container: + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/contrib/executor/gradle/examples/hello-gradle + steps: + - name: Run tests + run: + image: gradle:8.5.0-jdk11 + command: + - gradle + - --no-daemon + - test + env: + - name: TESTKUBE_GRADLE + value: "true" diff --git a/test/examples/kubecon/test-workflows/jmeter.yaml b/test/examples/kubecon/test-workflows/jmeter.yaml new file mode 100644 index 00000000000..f69298c2685 --- /dev/null +++ b/test/examples/kubecon/test-workflows/jmeter.yaml @@ -0,0 +1,30 @@ +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: jmeter-report + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/jmeter/executor-tests/jmeter-executor-smoke.jmx + container: + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/test/jmeter/executor-tests + steps: + - name: Run tests + shell: jmeter -n -t jmeter-executor-smoke.jmx -j /data/artifacts/jmeter.log -o /data/artifacts/report -l /data/artifacts/jtl-report.jtl -e + container: + image: justb4/jmeter:5.5 + steps: + - name: Save artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' diff --git a/test/examples/kubecon/test-workflows/k6.yaml b/test/examples/kubecon/test-workflows/k6.yaml new file mode 100644 index 00000000000..004c0ff91af --- /dev/null +++ b/test/examples/kubecon/test-workflows/k6.yaml @@ -0,0 +1,44 @@ +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: k6-loadtest + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/k6/executor-tests/k6-smoke-test.js + container: + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + steps: + - name: Run test + container: + image: grafana/k6:0.49.0 + steps: + - shell: mkdir /data/artifacts + - run: + args: + - run + - k6-smoke-test.js + - -e + - K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value + env: + - name: K6_SYSTEM_ENV + value: K6_SYSTEM_ENV_value + - name: K6_WEB_DASHBOARD + value: "true" + - name: K6_WEB_DASHBOARD_EXPORT + value: "/data/artifacts/k6-test-report.html" + steps: + - name: Saving artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '*' diff --git a/test/examples/kubecon/test-workflows/playwright.yaml b/test/examples/kubecon/test-workflows/playwright.yaml new file mode 100644 index 00000000000..76af17dadf9 --- /dev/null +++ b/test/examples/kubecon/test-workflows/playwright.yaml @@ -0,0 +1,41 @@ +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: playwright + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/playwright/executor-tests/playwright-project + container: + resources: + requests: + cpu: 2 + memory: 2Gi + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + steps: + - name: Install dependencies + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - npm + args: + - ci + - name: Run tests + run: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: + - "npx" + args: + - "--yes" + - "playwright@1.32.3" + - "test" + - name: Save artifacts + workingDir: /data/repo/test/playwright/executor-tests/playwright-project + artifacts: + paths: + - playwright-report/**/* diff --git a/test/examples/kubecon/test-workflows/postman.yaml b/test/examples/kubecon/test-workflows/postman.yaml new file mode 100644 index 00000000000..74cabc70ccc --- /dev/null +++ b/test/examples/kubecon/test-workflows/postman.yaml @@ -0,0 +1,28 @@ +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: postman-test + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/postman/executor-tests/postman-executor-smoke.postman_collection.json + container: + resources: + requests: + cpu: 256m + memory: 128Mi + workingDir: /data/repo/test/postman/executor-tests + steps: + - name: Run test + run: + image: postman/newman:6-alpine + args: + - run + - postman-executor-smoke.postman_collection.json + - "--env-var" + - "TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value" diff --git a/test/examples/kubecon/tests/cypress.yaml b/test/examples/kubecon/tests/cypress.yaml new file mode 100644 index 00000000000..43aae645521 --- /dev/null +++ b/test/examples/kubecon/tests/cypress.yaml @@ -0,0 +1,51 @@ +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: cypress-v13-executor +spec: + image: kubeshop/testkube-cypress-executor:cypress13 + command: ["./node_modules/cypress/bin/cypress"] + args: [ + "run", + "--reporter", + "junit", + "--reporter-options", + "mochaFile=,toConsole=false", + "--project", + "", + "--env", + "" + ] + types: + - cypress:v13/test + features: + - artifacts +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: cypress-video + labels: + core-tests: executors +spec: + type: cypress:v13/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube + branch: main + path: test/cypress/executor-tests/cypress-13 + executionRequest: + variables: + CYPRESS_CUSTOM_ENV: + name: CYPRESS_CUSTOM_ENV + value: CYPRESS_CUSTOM_ENV_value + type: basic + args: + - --env + - NON_CYPRESS_ENV=NON_CYPRESS_ENV_value + - --config + - video=true + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 2Gi\n cpu: 2\n" + activeDeadlineSeconds: 600 diff --git a/test/examples/kubecon/tests/gradle.yaml b/test/examples/kubecon/tests/gradle.yaml new file mode 100644 index 00000000000..ca9296c6f49 --- /dev/null +++ b/test/examples/kubecon/tests/gradle.yaml @@ -0,0 +1,23 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: gradle-java-test + labels: + core-tests: executors +spec: + type: gradle/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: contrib/executor/gradle/examples/hello-gradle-jdk18 + executionRequest: + variables: + TESTKUBE_GRADLE: + name: TESTKUBE_GRADLE + value: "true" + type: basic + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 diff --git a/test/examples/kubecon/tests/jmeter.yaml b/test/examples/kubecon/tests/jmeter.yaml new file mode 100644 index 00000000000..ab477196736 --- /dev/null +++ b/test/examples/kubecon/tests/jmeter.yaml @@ -0,0 +1,18 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: jmeter-report + labels: + core-tests: executors +spec: + type: jmeter/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/jmeter/executor-tests/jmeter-executor-smoke.jmx + executionRequest: + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 512Mi\n cpu: 512m\n" + activeDeadlineSeconds: 180 diff --git a/test/examples/kubecon/tests/k6.yaml b/test/examples/kubecon/tests/k6.yaml new file mode 100644 index 00000000000..1312e8d7683 --- /dev/null +++ b/test/examples/kubecon/tests/k6.yaml @@ -0,0 +1,18 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: k6-loadtest + labels: + core-tests: executors +spec: + type: k6/script + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/k6/executor-tests/k6-smoke-test-without-envs.js + executionRequest: + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 256Mi\n cpu: 256m\n" + activeDeadlineSeconds: 180 diff --git a/test/examples/kubecon/tests/playwright.yaml b/test/examples/kubecon/tests/playwright.yaml new file mode 100644 index 00000000000..05893a3936b --- /dev/null +++ b/test/examples/kubecon/tests/playwright.yaml @@ -0,0 +1,45 @@ +apiVersion: executor.testkube.io/v1 +kind: Executor +metadata: + name: container-executor-playwright-v1.32.3-args +spec: + image: mcr.microsoft.com/playwright:v1.32.3-focal + command: ["npx", "--yes", "playwright@1.32.3", "test", "--output", "/data/artifacts/playwright-results"] + executor_type: container + types: + - container-executor-playwright-v1.32.3-args/test + features: + - artifacts +--- +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: playwright + labels: + core-tests: executors +spec: + type: container-executor-playwright-v1.32.3-args/test + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube + branch: develop + path: test/playwright/executor-tests/playwright-project + workingDir: test/playwright/executor-tests/playwright-project + executionRequest: + artifactRequest: + storageClassName: standard + volumeMountPath: /data/artifacts + dirs: + - ./ + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 2Gi\n cpu: 2\n" + activeDeadlineSeconds: 600 + preRunScript: "npm ci" + args: + - "tests/smoke2.spec.js" + variables: + PLAYWRIGHT_HTML_REPORT: + name: PLAYWRIGHT_HTML_REPORT + value: "/data/artifacts/playwright-report" + type: basic diff --git a/test/examples/kubecon/tests/postman.yaml b/test/examples/kubecon/tests/postman.yaml new file mode 100644 index 00000000000..230ca3fdbb2 --- /dev/null +++ b/test/examples/kubecon/tests/postman.yaml @@ -0,0 +1,20 @@ +apiVersion: tests.testkube.io/v3 +kind: Test +metadata: + name: postman-test + labels: + core-tests: executors +spec: + type: postman/collection + content: + type: git + repository: + type: git + uri: https://github.com/kubeshop/testkube.git + branch: main + path: test/postman/executor-tests/postman-executor-smoke.postman_collection.json + executionRequest: + args: + - --env-var + - TESTKUBE_POSTMAN_PARAM=TESTKUBE_POSTMAN_PARAM_value + jobTemplate: "apiVersion: batch/v1\nkind: Job\nspec:\n template:\n spec:\n containers:\n - name: \"{{ .Name }}\"\n image: {{ .Image }}\n resources:\n requests:\n memory: 128Mi\n cpu: 256m\n" diff --git a/test/examples/kubecon/tests/testsuites/k6.yaml b/test/examples/kubecon/tests/testsuites/k6.yaml new file mode 100644 index 00000000000..38d94c25784 --- /dev/null +++ b/test/examples/kubecon/tests/testsuites/k6.yaml @@ -0,0 +1,19 @@ +apiVersion: tests.testkube.io/v3 +kind: TestSuite +metadata: + name: k6-parallel +spec: + description: "k6 parallel testsuite" + steps: + - stopOnFailure: false + execute: + - test: k6-loadtest + executionRequest: + args: + - "-vu" + - "1" + - test: k6-loadtest + executionRequest: + args: + - "-vu" + - "2" diff --git a/test/jmeter/executor-tests/crd-workflow/smoke.yaml b/test/jmeter/executor-tests/crd-workflow/smoke.yaml index f85dd5a5f71..5aba34a1314 100644 --- a/test/jmeter/executor-tests/crd-workflow/smoke.yaml +++ b/test/jmeter/executor-tests/crd-workflow/smoke.yaml @@ -27,3 +27,34 @@ spec: - -n - -t - jmeter-executor-smoke.jmx +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: jmeter-workflow-smoke-shell-artifacts + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/jmeter/executor-tests/jmeter-executor-smoke.jmx + container: + resources: + requests: + cpu: 512m + memory: 512Mi + workingDir: /data/repo/test/jmeter/executor-tests + steps: + - name: Run tests + shell: jmeter -n -t jmeter-executor-smoke.jmx -j /data/artifacts/jmeter.log -o /data/artifacts/report -l /data/artifacts/jtl-report.jtl -e + container: + image: justb4/jmeter:5.5 + steps: + - name: Save artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '**/*' diff --git a/test/k6/executor-tests/crd-workflow/smoke.yaml b/test/k6/executor-tests/crd-workflow/smoke.yaml index db55ad4aba5..a6379eb87c4 100644 --- a/test/k6/executor-tests/crd-workflow/smoke.yaml +++ b/test/k6/executor-tests/crd-workflow/smoke.yaml @@ -92,3 +92,48 @@ spec: config: version: 0.48.0 params: "k6-smoke-test.js -e K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value" +--- +apiVersion: testworkflows.testkube.io/v1 +kind: TestWorkflow +metadata: + name: k6-workflow-smoke-artifacts + labels: + core-tests: workflows +spec: + content: + git: + uri: https://github.com/kubeshop/testkube + revision: main + paths: + - test/k6/executor-tests/k6-smoke-test.js + container: + resources: + requests: + cpu: 128m + memory: 128Mi + workingDir: /data/repo/test/k6/executor-tests + steps: + - name: Run test + container: + image: grafana/k6:0.49.0 + steps: + - shell: mkdir /data/artifacts + - run: + args: + - run + - k6-smoke-test.js + - -e + - K6_ENV_FROM_PARAM=K6_ENV_FROM_PARAM_value + env: + - name: K6_SYSTEM_ENV + value: K6_SYSTEM_ENV_value + - name: K6_WEB_DASHBOARD + value: "true" + - name: K6_WEB_DASHBOARD_EXPORT + value: "/data/artifacts/k6-test-report.html" + steps: + - name: Saving artifacts + workingDir: /data/artifacts + artifacts: + paths: + - '*' From 70612f0837004e2ceee14856ee56450b64c39848 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 13 Mar 2024 11:10:20 +0100 Subject: [PATCH 214/234] feat: fill time gaps in the TestWorkflow clock (#5167) * feat: fill time gaps in the TestWorkflow clock * feat: calibrate TestWorkflow's execution queuedAt based on scheduledAt --- .../model_test_workflow_result_extended.go | 11 ++++++----- .../testworkflowcontroller/controller.go | 14 +++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index b19c34e3691..66f669e25f5 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -81,15 +81,15 @@ func getTestWorkflowStepStatus(result TestWorkflowStepResult) TestWorkflowStepSt return *result.Status } -func (r *TestWorkflowResult) UpdateStepResult(sig []TestWorkflowSignature, ref string, result TestWorkflowStepResult) TestWorkflowStepResult { +func (r *TestWorkflowResult) UpdateStepResult(sig []TestWorkflowSignature, ref string, result TestWorkflowStepResult, scheduledAt time.Time) TestWorkflowStepResult { v := r.Steps[ref] v.Merge(result) r.Steps[ref] = v - r.Recompute(sig) + r.Recompute(sig, scheduledAt) return v } -func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { +func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature, scheduledAt time.Time) { // Recompute steps for _, ch := range sig { r.RecomputeStep(ch) @@ -113,6 +113,7 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { } // Calibrate the execution time initially + r.QueuedAt = adjustMinimumTime(r.QueuedAt, scheduledAt) r.StartedAt = adjustMinimumTime(r.StartedAt, r.QueuedAt) r.FinishedAt = adjustMinimumTime(r.FinishedAt, r.StartedAt) initialDate := r.StartedAt @@ -146,7 +147,7 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature) { if i != 0 { initialDate = getLastDate(seq[i-1].result, initialDate) } - seq[i].result.QueuedAt = adjustMinimumTime(seq[i].result.QueuedAt, initialDate) + seq[i].result.QueuedAt = initialDate seq[i].result.StartedAt = adjustMinimumTime(seq[i].result.StartedAt, seq[i].result.QueuedAt) seq[i].result.FinishedAt = adjustMinimumTime(seq[i].result.FinishedAt, seq[i].result.StartedAt) } @@ -222,8 +223,8 @@ func (r *TestWorkflowResult) RecomputeStep(sig TestWorkflowSignature) { func walkSteps(sig []TestWorkflowSignature, fn func(signature TestWorkflowSignature)) { for _, s := range sig { - fn(s) walkSteps(s.Children, fn) + fn(s) } } diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go index 1e3fcbe82c1..a0d03dbf0c5 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -258,7 +258,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { stepResult := result.Steps[container.Name] stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ QueuedAt: lastTs.UTC(), - }) + }, c.scheduledAt) w.SendValue(Notification{Result: result.Clone()}) // Watch for the container events @@ -274,12 +274,12 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { if v.Value.Reason == "Created" { stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ QueuedAt: v.Value.CreationTimestamp.Time.UTC(), - }) + }, c.scheduledAt) } else if v.Value.Reason == "Started" { stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ StartedAt: v.Value.CreationTimestamp.Time.UTC(), Status: common.Ptr(testkube.RUNNING_TestWorkflowStepStatus), - }) + }, c.scheduledAt) } if v.Value.Type == "Normal" { continue @@ -309,7 +309,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { if v.Value.Time.After(stepResult.StartedAt) { stepResult = result.UpdateStepResult(sig, container.Name, testkube.TestWorkflowStepResult{ StartedAt: v.Value.Time.UTC(), - }) + }, c.scheduledAt) } } else if v.Value.Hint.Name == "status" { status := testkube.TestWorkflowStepStatus(v.Value.Hint.Value.(string)) @@ -319,7 +319,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { if _, ok := result.Steps[v.Value.Hint.Ref]; ok { stepResult = result.UpdateStepResult(sig, v.Value.Hint.Ref, testkube.TestWorkflowStepResult{ Status: &status, - }) + }, c.scheduledAt) } } continue @@ -351,7 +351,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { FinishedAt: finishedAt, ExitCode: float64(status.ExitCode), Status: common.Ptr(status.Status), - }) + }, c.scheduledAt) w.SendValue(Notification{Result: result.Clone()}) // Update the last timestamp @@ -366,7 +366,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } // Compute the TestWorkflow status and dates - result.Recompute(sig) + result.Recompute(sig, c.scheduledAt) w.SendValue(Notification{Result: result.Clone()}) }() From 74346c7b6696d6e3fcf0d07b5a5c7d7f5b3f656b Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 13 Mar 2024 12:29:21 +0100 Subject: [PATCH 215/234] fix: improve Kubernetes error handling of TestWorkflow executions (#5168) --- .../model_test_workflow_result_extended.go | 34 +++++++++++++++---- .../testworkflowcontroller/controller.go | 34 ++++++++++++++----- .../testworkflowcontroller/logs.go | 8 ++--- .../testworkflowcontroller/utils.go | 8 ++++- .../testworkflowexecutor/executor.go | 14 +++++++- 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/pkg/api/v1/testkube/model_test_workflow_result_extended.go b/pkg/api/v1/testkube/model_test_workflow_result_extended.go index 66f669e25f5..05f79cc79aa 100644 --- a/pkg/api/v1/testkube/model_test_workflow_result_extended.go +++ b/pkg/api/v1/testkube/model_test_workflow_result_extended.go @@ -37,20 +37,38 @@ func (r *TestWorkflowResult) IsAnyError() bool { return r.IsFinished() && !r.IsStatus(PASSED_TestWorkflowStatus) } -func (r *TestWorkflowResult) Fatal(err error) { +func (r *TestWorkflowResult) Fatal(err error, aborted bool, ts time.Time) { r.Initialization.ErrorMessage = err.Error() r.Status = common.Ptr(FAILED_TestWorkflowStatus) r.PredictedStatus = r.Status + if aborted { + r.Status = common.Ptr(ABORTED_TestWorkflowStatus) + } + if r.FinishedAt.IsZero() { + r.FinishedAt = ts.UTC() + } if r.Initialization.Status == nil || (*r.Initialization.Status == QUEUED_TestWorkflowStepStatus) || (*r.Initialization.Status == RUNNING_TestWorkflowStepStatus) { r.Initialization.Status = common.Ptr(FAILED_TestWorkflowStepStatus) + if aborted { + r.Initialization.Status = common.Ptr(ABORTED_TestWorkflowStepStatus) + } + r.Initialization.FinishedAt = r.FinishedAt } for i := range r.Steps { - if r.Steps[i].Status == nil || (*r.Steps[i].Status == QUEUED_TestWorkflowStepStatus) || (*r.Steps[i].Status == RUNNING_TestWorkflowStepStatus) { + if r.Steps[i].Status == nil || (*r.Steps[i].Status == QUEUED_TestWorkflowStepStatus) { s := r.Steps[i] s.Status = common.Ptr(SKIPPED_TestWorkflowStepStatus) r.Steps[i] = s + } else if *r.Steps[i].Status == RUNNING_TestWorkflowStepStatus { + s := r.Steps[i] + s.Status = common.Ptr(FAILED_TestWorkflowStepStatus) + if aborted { + s.Status = common.Ptr(ABORTED_TestWorkflowStepStatus) + } + r.Steps[i] = s } } + r.RecomputeDuration() } func (r *TestWorkflowResult) Clone() *TestWorkflowResult { @@ -89,6 +107,13 @@ func (r *TestWorkflowResult) UpdateStepResult(sig []TestWorkflowSignature, ref s return v } +func (r *TestWorkflowResult) RecomputeDuration() { + if !r.FinishedAt.IsZero() { + r.Duration = r.FinishedAt.Sub(r.QueuedAt).Round(time.Millisecond).String() + r.DurationMs = int32(r.FinishedAt.Sub(r.QueuedAt).Milliseconds()) + } +} + func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature, scheduledAt time.Time) { // Recompute steps for _, ch := range sig { @@ -96,10 +121,7 @@ func (r *TestWorkflowResult) Recompute(sig []TestWorkflowSignature, scheduledAt } // Compute the duration - if !r.FinishedAt.IsZero() { - r.Duration = r.FinishedAt.Sub(r.QueuedAt).Round(time.Millisecond).String() - r.DurationMs = int32(r.FinishedAt.Sub(r.QueuedAt).Milliseconds()) - } + r.RecomputeDuration() // Build status on the internal failure if getTestWorkflowStepStatus(*r.Initialization) == ABORTED_TestWorkflowStepStatus { diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go index a0d03dbf0c5..a46e2c38208 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -24,7 +24,13 @@ import ( ) const ( - JobRetrievalTimeout = 3 * time.Second + JobRetrievalTimeout = 1 * time.Second + JobEventRetrievalTimeout = 1 * time.Second +) + +var ( + ErrJobAborted = errors.New("job was aborted") + ErrJobTimeout = errors.New("timeout retrieving job") ) type Controller interface { @@ -59,8 +65,18 @@ func New(parentCtx context.Context, clientSet kubernetes.Interface, namespace, i return nil, errors.Wrap(err, "invalid job signature") } case <-time.After(JobRetrievalTimeout): + select { + case ev := <-jobEvents.Any(context.Background()): + if ev.Value != nil { + // Job was there, so it was aborted + err = ErrJobAborted + } + case <-time.After(JobEventRetrievalTimeout): + // The job is actually not found + err = ErrJobTimeout + } ctxCancel() - return nil, errors.New("timeout retrieving job") + return nil, err } // Build accessible controller @@ -141,7 +157,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } w.SendValue(Notification{ Timestamp: v.Value.CreationTimestamp.Time, - Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(KubernetesLogTimeFormat), v.Value.Reason, v.Value.Message), }) } @@ -167,7 +183,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } w.SendValue(Notification{ Timestamp: v.Value.CreationTimestamp.Time, - Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(KubernetesLogTimeFormat), v.Value.Reason, v.Value.Message), }) } @@ -179,7 +195,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { w.SendValue(Notification{Result: result.Clone()}) // Wait for the initialization container - for v := range WatchContainerPreEvents(ctx, c.podEvents, "tktw-init", 0).Stream(ctx).Channel() { + for v := range WatchContainerPreEvents(ctx, c.podEvents, "tktw-init", 0, true).Stream(ctx).Channel() { if v.Error != nil { w.SendError(v.Error) continue @@ -195,7 +211,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { } w.SendValue(Notification{ Timestamp: v.Value.CreationTimestamp.Time, - Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(KubernetesLogTimeFormat), v.Value.Reason, v.Value.Message), }) } @@ -220,7 +236,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { // TODO: Calibrate clock with v.Value.Hint or just first/last timestamp here w.SendValue(Notification{ Timestamp: v.Value.Time, - Log: fmt.Sprintf("%s %s\n", v.Value.Time.Format(time.RFC3339Nano), string(v.Value.Log)), + Log: fmt.Sprintf("%s %s\n", v.Value.Time.Format(KubernetesLogTimeFormat), string(v.Value.Log)), }) } @@ -263,7 +279,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { // Watch for the container events lastEvTs := time.Time{} - for v := range WatchContainerPreEvents(ctx, c.podEvents, container.Name, 0).Stream(ctx).Channel() { + for v := range WatchContainerPreEvents(ctx, c.podEvents, container.Name, 0, false).Stream(ctx).Channel() { if v.Error != nil { w.SendError(v.Error) continue @@ -287,7 +303,7 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { w.SendValue(Notification{ Timestamp: v.Value.CreationTimestamp.Time, Ref: container.Name, - Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(time.RFC3339Nano), v.Value.Reason, v.Value.Message), + Log: fmt.Sprintf("%s (%s) %s\n", v.Value.CreationTimestamp.Time.Format(KubernetesLogTimeFormat), v.Value.Reason, v.Value.Message), }) } diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go index 7f0a782c6c9..6910555a65f 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go @@ -122,10 +122,10 @@ func GetFinalContainerResult(ctx context.Context, pod Watcher[*corev1.Pod], cont var ErrNoStartedEvent = errors.New("started event not received") -func WatchContainerPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string, cacheSize int) Watcher[*corev1.Event] { +func WatchContainerPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string, cacheSize int, includePodWarnings bool) Watcher[*corev1.Event] { w := newWatcher[*corev1.Event](ctx, cacheSize) go func() { - events := WatchContainerEvents(ctx, podEvents, containerName, 0) + events := WatchContainerEvents(ctx, podEvents, containerName, 0, includePodWarnings) defer events.Close() defer w.Close() @@ -163,7 +163,7 @@ func WatchPodPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], ca } func WaitUntilContainerIsStarted(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string) error { - events := WatchContainerPreEvents(ctx, podEvents, containerName, 0) + events := WatchContainerPreEvents(ctx, podEvents, containerName, 0, false) defer events.Close() for ev := range events.Stream(ctx).Channel() { @@ -315,7 +315,7 @@ func ReadTimestamp(reader *bufio.Reader) (time.Time, []byte, error) { if count < 31 { return time.Time{}, nil, io.EOF } - ts, err := time.Parse(time.RFC3339Nano, string(tsPrefix[0:30])) + ts, err := time.Parse(KubernetesLogTimeFormat, string(tsPrefix[0:30])) if err != nil { return time.Time{}, tsPrefix, errors2.Wrap(err, "parsing timestamp") } diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go index 7455b5c519e..80ea622d231 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/utils.go @@ -24,6 +24,10 @@ import ( "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" ) +const ( + KubernetesLogTimeFormat = "2006-01-02T15:04:05.000000000Z" +) + func IsPodDone(pod *corev1.Pod) bool { return pod.Status.Phase != corev1.PodPending && pod.Status.Phase != corev1.PodRunning } @@ -198,7 +202,7 @@ func GetEventContainerName(event *corev1.Event) string { return "" } -func WatchContainerEvents(ctx context.Context, podEvents Watcher[*corev1.Event], name string, cacheSize int) Watcher[*corev1.Event] { +func WatchContainerEvents(ctx context.Context, podEvents Watcher[*corev1.Event], name string, cacheSize int, includePodWarnings bool) Watcher[*corev1.Event] { w := newWatcher[*corev1.Event](ctx, cacheSize) go func() { stream := podEvents.Stream(ctx) @@ -214,6 +218,8 @@ func WatchContainerEvents(ctx context.Context, podEvents Watcher[*corev1.Event], w.SendError(v.Error) } else if GetEventContainerName(v.Value) == name { w.SendValue(v.Value) + } else if includePodWarnings && v.Value.Type == "Warning" { + w.SendValue(v.Value) } } else { return diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go index c38d2fd70dc..2ed440d0105 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go @@ -13,6 +13,7 @@ import ( "context" "io" "sync" + "time" "github.com/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -85,7 +86,18 @@ func (e *executor) Deploy(ctx context.Context, bundle *testworkflowprocessor.Bun } func (e *executor) handleFatalError(execution testkube.TestWorkflowExecution, err error) { - execution.Result.Fatal(err) + // Detect error type + isAborted := errors.Is(err, testworkflowcontroller.ErrJobAborted) + isTimeout := errors.Is(err, testworkflowcontroller.ErrJobTimeout) + + // Build error timestamp, adjusting it for aborting job + ts := time.Now() + if isAborted || isTimeout { + ts = ts.Truncate(testworkflowcontroller.JobRetrievalTimeout) + } + + // Apply the expected result + execution.Result.Fatal(err, isAborted, ts) err = e.repository.UpdateResult(context.Background(), execution.Id, execution.Result) if err != nil { log.DefaultLogger.Errorf("failed to save fatal error for execution %s: %v", execution.Id, err) From 105122c951c9423e2da3159dcb8b3eb097cc7b5b Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 13 Mar 2024 12:34:55 +0100 Subject: [PATCH 216/234] feat: avoid root privileges for TestWorkflow pods (#5169) * feat: load default user/group for Docker images * feat: use common FS Group for TestWorkflow's pod to avoid needed root permissions --- pkg/imageinspector/skopeofetcher.go | 18 + pkg/imageinspector/types.go | 2 + pkg/skopeo/client.go | 1 + .../testworkflowprocessor/constants.go | 1 + .../testworkflowprocessor/container.go | 8 - .../testworkflowprocessor/operations.go | 3 +- .../testworkflowprocessor/processor.go | 7 +- .../testworkflowprocessor/processor_test.go | 437 +++++++++++------- .../testworkflowprocessor/utils.go | 8 + 9 files changed, 310 insertions(+), 175 deletions(-) diff --git a/pkg/imageinspector/skopeofetcher.go b/pkg/imageinspector/skopeofetcher.go index 45ef2bcfd94..f043aafb319 100644 --- a/pkg/imageinspector/skopeofetcher.go +++ b/pkg/imageinspector/skopeofetcher.go @@ -2,6 +2,8 @@ package imageinspector import ( "context" + "strconv" + "strings" "time" corev1 "k8s.io/api/core/v1" @@ -25,11 +27,27 @@ func (s *skopeoFetcher) Fetch(ctx context.Context, registry, image string, pullS if err != nil { return nil, err } + user, group := determineUserGroupPair(info.Config.User) return &Info{ FetchedAt: time.Now(), Entrypoint: info.Config.Entrypoint, Cmd: info.Config.Cmd, Shell: info.Shell, WorkingDir: info.Config.WorkingDir, + User: user, + Group: group, }, nil } + +func determineUserGroupPair(userGroupStr string) (int64, int64) { + if userGroupStr == "" { + userGroupStr = "0" + } + userStr, groupStr, _ := strings.Cut(userGroupStr, ":") + if groupStr == "" { + groupStr = userStr + } + user, _ := strconv.Atoi(userStr) + group, _ := strconv.Atoi(groupStr) + return int64(user), int64(group) +} diff --git a/pkg/imageinspector/types.go b/pkg/imageinspector/types.go index 29c617f8ccb..99987ba2b2a 100644 --- a/pkg/imageinspector/types.go +++ b/pkg/imageinspector/types.go @@ -44,6 +44,8 @@ type Info struct { Cmd []string `json:"c,omitempty"` Shell string `json:"s,omitempty"` WorkingDir string `json:"w,omitempty"` + User int64 `json:"u,omitempty"` + Group int64 `json:"g,omitempty"` } type RequestBase struct { diff --git a/pkg/skopeo/client.go b/pkg/skopeo/client.go index 4da12702c19..6d027ed1857 100644 --- a/pkg/skopeo/client.go +++ b/pkg/skopeo/client.go @@ -38,6 +38,7 @@ type DockerAuthConfig struct { // DockerImage contains definition of docker image type DockerImage struct { Config struct { + User string `json:"User"` Entrypoint []string `json:"Entrypoint"` Cmd []string `json:"Cmd"` WorkingDir string `json:"WorkingDir"` diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go index 039da56be40..cc3f68061b6 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go @@ -24,6 +24,7 @@ const ( defaultShell = "/bin/sh" defaultInternalPath = "/.tktw" defaultDataPath = "/data" + defaultFsGroup = int64(1001) ExecutionIdLabelName = "testworkflowid" ExecutionIdMainPodLabelName = "testworkflowid-main" SignatureAnnotationName = "testworkflows.testkube.io/signature" diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go index b3471cbf6fb..5f216915a50 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/container.go @@ -72,7 +72,6 @@ type ContainerMutations[T any] interface { ApplyCR(cr *testworkflowsv1.ContainerConfig) T ApplyImageData(image *imageinspector.Info) error EnableToolkit(ref string) T - RunAsRoot() T } //go:generate mockgen -destination=./mock_container.go -package=testworkflowprocessor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" Container @@ -417,13 +416,6 @@ func (c *container) EnableToolkit(ref string) Container { }) } -func (c *container) RunAsRoot() Container { - return c.SetSecurityContext(&corev1.SecurityContext{ - AllowPrivilegeEscalation: common.Ptr(false), - RunAsUser: common.Ptr(int64(0)), - }) -} - func (c *container) Resolve(m ...expressionstcl.Machine) error { base := expressionstcl.NewMachine(). RegisterAccessor(func(name string) (interface{}, bool) { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go index af7168be5a0..4d353beed43 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -289,8 +289,7 @@ func ProcessArtifacts(_ InternalProcessor, layer Intermediate, container Contain SetImage(defaultToolkitImage). SetImagePullPolicy(corev1.PullIfNotPresent). SetCommand("/toolkit", "artifacts", "-m", defaultDataPath). - EnableToolkit(stage.Ref()). - RunAsRoot() + EnableToolkit(stage.Ref()) args := make([]string, 0) if step.Artifacts.Compress != nil { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go index 240b9adc506..442dffa9017 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor.go @@ -241,6 +241,9 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo ImagePullSecrets: podConfig.ImagePullSecrets, ServiceAccountName: podConfig.ServiceAccountName, NodeSelector: podConfig.NodeSelector, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, }, } AnnotateControlledBy(&podSpec, "{{execution.id}}") @@ -249,13 +252,15 @@ func (p *processor) Bundle(ctx context.Context, workflow *testworkflowsv1.TestWo return nil, errors.Wrap(err, "finalizing pod template spec") } initContainer := corev1.Container{ - // TODO: Resources, SecurityContext? Name: "tktw-init", Image: defaultInitImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, Args: []string{fmt.Sprintf("cp /init %s && touch %s && chmod 777 %s && (echo -n ',0' > %s && echo 'Done' && exit 0) || (echo -n 'failed,1' > %s && exit 1)", defaultInitPath, defaultStatePath, defaultStatePath, "/dev/termination-log", "/dev/termination-log")}, VolumeMounts: layer.ContainerDefaults().VolumeMounts(), + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, } err = expressionstcl.FinalizeForce(&initContainer, machines...) if err != nil { diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go index 81c2b43685a..0458da876ba 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/processor_test.go @@ -95,6 +95,9 @@ func TestProcessBasic(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -109,15 +112,20 @@ func TestProcessBasic(t *testing.T) { "-r", fmt.Sprintf("=%s", sig[0].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, }, }, }, @@ -170,6 +178,9 @@ func TestProcessBasicEnvReference(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -196,11 +207,16 @@ func TestProcessBasicEnvReference(t *testing.T) { {Name: "NEXT", Value: "foo{{env.UNDETERMINED}}foofoobarbar"}, {Name: "LAST", Value: "foofoobarbar"}, }, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -234,6 +250,9 @@ func TestProcessMultipleSteps(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -246,13 +265,15 @@ func TestProcessMultipleSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -267,15 +288,20 @@ func TestProcessMultipleSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -316,6 +342,9 @@ func TestProcessNestedSteps(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -328,13 +357,15 @@ func TestProcessNestedSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[1].Children()[0].Ref(), @@ -349,13 +380,15 @@ func TestProcessNestedSteps(t *testing.T) { "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[1].Children()[1].Ref(), @@ -370,13 +403,15 @@ func TestProcessNestedSteps(t *testing.T) { "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-3"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-3"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -391,15 +426,20 @@ func TestProcessNestedSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-4"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-4"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -440,6 +480,9 @@ func TestProcessOptionalSteps(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -452,13 +495,15 @@ func TestProcessOptionalSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[2].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[1].Children()[0].Ref(), @@ -472,13 +517,15 @@ func TestProcessOptionalSteps(t *testing.T) { "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[1].Children()[1].Ref(), @@ -492,13 +539,15 @@ func TestProcessOptionalSteps(t *testing.T) { "-r", fmt.Sprintf("%s=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-3"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-3"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -513,15 +562,20 @@ func TestProcessOptionalSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[2].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-4"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-4"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -562,6 +616,9 @@ func TestProcessNegativeSteps(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -574,13 +631,15 @@ func TestProcessNegativeSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[1].Children()[0].Ref(), @@ -596,13 +655,15 @@ func TestProcessNegativeSteps(t *testing.T) { "-r", fmt.Sprintf("%s.v=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[1].Children()[1].Ref(), @@ -618,13 +679,15 @@ func TestProcessNegativeSteps(t *testing.T) { "-r", fmt.Sprintf("%s.v=%s&&%s", sig[1].Ref(), sig[1].Children()[0].Ref(), sig[1].Children()[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-3"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-3"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -639,15 +702,20 @@ func TestProcessNegativeSteps(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s&&%s", sig[0].Ref(), sig[1].Ref(), sig[2].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-4"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-4"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -681,6 +749,9 @@ func TestProcessNegativeContainerStep(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -693,13 +764,15 @@ func TestProcessNegativeContainerStep(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -715,15 +788,21 @@ func TestProcessNegativeContainerStep(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -757,6 +836,9 @@ func TestProcessOptionalContainerStep(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -769,13 +851,15 @@ func TestProcessOptionalContainerStep(t *testing.T) { "-r", fmt.Sprintf("=%s", sig[0].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -789,15 +873,20 @@ func TestProcessOptionalContainerStep(t *testing.T) { "-c", fmt.Sprintf("%s=passed", sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.NoError(t, err) @@ -842,6 +931,9 @@ func TestProcessLocalContent(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -854,13 +946,15 @@ func TestProcessLocalContent(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMountsWithContent, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMountsWithContent, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -875,15 +969,20 @@ func TestProcessLocalContent(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.Equal(t, want, res.Job.Spec.Template.Spec) @@ -934,6 +1033,9 @@ func TestProcessGlobalContent(t *testing.T) { Command: []string{"/bin/sh", "-c"}, Args: []string{"cp /init /.tktw/init && touch /.tktw/state && chmod 777 /.tktw/state && (echo -n ',0' > /dev/termination-log && echo 'Done' && exit 0) || (echo -n 'failed,1' > /dev/termination-log && exit 1)"}, VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, { Name: sig[0].Ref(), @@ -946,13 +1048,15 @@ func TestProcessGlobalContent(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, Containers: []corev1.Container{ @@ -967,15 +1071,20 @@ func TestProcessGlobalContent(t *testing.T) { "-r", fmt.Sprintf("=%s&&%s", sig[0].Ref(), sig[1].Ref()), "--", }, - Args: []string{defaultShell, "-c", "shell-test-2"}, - WorkingDir: "", - EnvFrom: []corev1.EnvFromSource(nil), - Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, - Resources: corev1.ResourceRequirements{}, - VolumeMounts: volumeMounts, - SecurityContext: (*corev1.SecurityContext)(nil), + Args: []string{defaultShell, "-c", "shell-test-2"}, + WorkingDir: "", + EnvFrom: []corev1.EnvFromSource(nil), + Env: []corev1.EnvVar{{Name: "CI", Value: "1"}}, + Resources: corev1.ResourceRequirements{}, + VolumeMounts: volumeMounts, + SecurityContext: &corev1.SecurityContext{ + RunAsGroup: common.Ptr(defaultFsGroup), + }, }, }, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: common.Ptr(defaultFsGroup), + }, } assert.Equal(t, want, res.Job.Spec.Template.Spec) diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go index 19842c7a4b0..0f3de1bd74c 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/utils.go @@ -130,6 +130,14 @@ func buildKubernetesContainers(stage Stage, init *initProcess, machines ...expre cr.Command = init.Command() cr.Args = init.Args() + // Ensure the container will have proper access to FS + if cr.SecurityContext == nil { + cr.SecurityContext = &corev1.SecurityContext{} + } + if cr.SecurityContext.RunAsGroup == nil { + cr.SecurityContext.RunAsGroup = common.Ptr(defaultFsGroup) + } + containers = []corev1.Container{cr} return } From a55fa1f864afcf21d08bbd36dfb79bf7d8df9e79 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Wed, 13 Mar 2024 13:01:35 +0100 Subject: [PATCH 217/234] feat: expose Test Workflow name for scheduled tests (#5170) * feat: expose Test Workflow name for scheduled tests * feat: use running context instead for marking scheduled tests --- cmd/tcl/testworkflow-toolkit/commands/execute.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/tcl/testworkflow-toolkit/commands/execute.go b/cmd/tcl/testworkflow-toolkit/commands/execute.go index b4d381f3ede..4d4c648c22f 100644 --- a/cmd/tcl/testworkflow-toolkit/commands/execute.go +++ b/cmd/tcl/testworkflow-toolkit/commands/execute.go @@ -23,7 +23,6 @@ import ( "github.com/kubeshop/testkube/cmd/tcl/testworkflow-toolkit/env" "github.com/kubeshop/testkube/pkg/api/v1/client" "github.com/kubeshop/testkube/pkg/api/v1/testkube" - "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" "github.com/kubeshop/testkube/pkg/ui" ) @@ -56,12 +55,15 @@ func buildTestExecution(test string, async bool) (func() error, error) { if request.ExecutionLabels == nil { request.ExecutionLabels = map[string]string{} } - request.ExecutionLabels[testworkflowprocessor.ExecutionIdLabelName] = env.ExecutionId() return func() (err error) { c := env.Testkube() exec, err := c.ExecuteTest(name, request.Name, client.ExecuteTestOptions{ + RunningContext: &testkube.RunningContext{ + Type_: "testworkflow", + Context: fmt.Sprintf("%s/executions/%s", env.WorkflowName(), env.ExecutionId()), + }, IsVariablesFileUploaded: request.IsVariablesFileUploaded, ExecutionLabels: request.ExecutionLabels, Command: request.Command, @@ -90,7 +92,6 @@ func buildTestExecution(test string, async bool) (func() error, error) { IsNegativeTestChangedOnRun: request.IsNegativeTestChangedOnRun, EnvConfigMaps: request.EnvConfigMaps, EnvSecrets: request.EnvSecrets, - RunningContext: request.RunningContext, SlavePodRequest: request.SlavePodRequest, ExecutionNamespace: request.ExecutionNamespace, }) From 8eff341f1e8cdf07f2ccad3477d24254a3d72ab7 Mon Sep 17 00:00:00 2001 From: Jacek Wysocki Date: Wed, 13 Mar 2024 17:03:52 +0100 Subject: [PATCH 218/234] chore: removed duplicated cloud-pro code --- cmd/kubectl-testkube/commands/cloud.go | 14 +- .../commands/cloud/connect.go | 215 ------------------ .../commands/cloud/disconnect.go | 120 ---------- cmd/kubectl-testkube/commands/cloud/init.go | 119 ---------- cmd/kubectl-testkube/commands/cloud/login.go | 52 ----- 5 files changed, 7 insertions(+), 513 deletions(-) delete mode 100644 cmd/kubectl-testkube/commands/cloud/connect.go delete mode 100644 cmd/kubectl-testkube/commands/cloud/disconnect.go delete mode 100644 cmd/kubectl-testkube/commands/cloud/init.go delete mode 100644 cmd/kubectl-testkube/commands/cloud/login.go diff --git a/cmd/kubectl-testkube/commands/cloud.go b/cmd/kubectl-testkube/commands/cloud.go index e9902c9aa2d..3565134aac0 100644 --- a/cmd/kubectl-testkube/commands/cloud.go +++ b/cmd/kubectl-testkube/commands/cloud.go @@ -3,7 +3,7 @@ package commands import ( "github.com/spf13/cobra" - "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/cloud" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/pro" "github.com/kubeshop/testkube/pkg/ui" ) @@ -15,15 +15,15 @@ func NewCloudCmd() *cobra.Command { Hidden: true, Short: "[Deprecated] Testkube Cloud commands", Aliases: []string{"cl"}, - Run: func(cmd *cobra.Command, args []string) { - ui.Warn("You are using a deprecated command, please switch to `testkube pro`.") + PersistentPreRun: func(cmd *cobra.Command, args []string) { + ui.Errf("You are using a deprecated command, please switch to `testkube pro` prefix.\n\n") }, } - cmd.AddCommand(cloud.NewConnectCmd()) - cmd.AddCommand(cloud.NewDisconnectCmd()) - cmd.AddCommand(cloud.NewInitCmd()) - cmd.AddCommand(cloud.NewLoginCmd()) + cmd.AddCommand(pro.NewConnectCmd()) + cmd.AddCommand(pro.NewDisconnectCmd()) + cmd.AddCommand(pro.NewInitCmd()) + cmd.AddCommand(pro.NewLoginCmd()) return cmd } diff --git a/cmd/kubectl-testkube/commands/cloud/connect.go b/cmd/kubectl-testkube/commands/cloud/connect.go deleted file mode 100644 index 1ffb4b58d99..00000000000 --- a/cmd/kubectl-testkube/commands/cloud/connect.go +++ /dev/null @@ -1,215 +0,0 @@ -package cloud - -import ( - "fmt" - "os" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" - "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - cloudclient "github.com/kubeshop/testkube/pkg/cloud/client" - "github.com/kubeshop/testkube/pkg/ui" -) - -const ( - listenAddr = "127.0.0.1:8090" - docsUrl = "https://docs.testkube.io/testkube-pro/intro" - tokenQueryParam = "token" -) - -func NewConnectCmd() *cobra.Command { - var opts = common.HelmOptions{} - - cmd := &cobra.Command{ - Use: "connect", - Deprecated: "Use `testkube pro connect` instead", - Hidden: true, - Aliases: []string{"c"}, - Short: "[Deprecated] Testkube Cloud connect ", - Run: func(cmd *cobra.Command, args []string) { - ui.Warn("You are using a deprecated command, please switch to `testkube pro connect`.") - - os.Exit(1) - - client, _, err := common.GetClient(cmd) - ui.ExitOnError("getting client", err) - - info, err := client.GetServerInfo() - firstInstall := err != nil && strings.Contains(err.Error(), "not found") - if err != nil && !firstInstall { - ui.Failf("Can't get testkube cluster information: %s", err.Error()) - } - - var apiContext string - if actx, ok := contextDescription[info.Context]; ok { - apiContext = actx - } - - ui.H1("Connect your cloud environment:") - ui.Paragraph("You can learn more about connecting your Testkube instance to the Cloud here:\n" + docsUrl) - ui.H2("You can safely switch between connecting Cloud and disconnecting without losing your data.") - - cfg, err := config.Load() - ui.ExitOnError("loading config", err) - - common.ProcessMasterFlags(cmd, &opts, &cfg) - - var clusterContext string - if cfg.ContextType == config.ContextTypeKubeconfig { - clusterContext, err = common.GetCurrentKubernetesContext() - ui.ExitOnError("getting current kubernetes context", err) - } - - // TODO: implement context info - ui.H1("Current status of your Testkube instance") - ui.Properties([][]string{ - {"Context", apiContext}, - {"Kubectl context", clusterContext}, - {"Namespace", cfg.Namespace}, - }) - - newStatus := [][]string{ - {"Testkube mode"}, - {"Context", contextDescription["cloud"]}, - {"Kubectl context", clusterContext}, - {"Namespace", cfg.Namespace}, - {ui.Separator, ""}, - } - - var ( - token string - refreshToken string - ) - // if no agent is passed create new environment and get its token - if opts.Master.AgentToken == "" && opts.Master.OrgId == "" && opts.Master.EnvId == "" { - token, refreshToken, err = common.LoginUser(opts.Master.URIs.Auth) - ui.ExitOnError("login", err) - - orgId, orgName, err := common.UiGetOrganizationId(opts.Master.URIs.Api, token) - ui.ExitOnError("getting organization", err) - - envName, err := uiGetEnvName() - ui.ExitOnError("getting environment name", err) - - envClient := cloudclient.NewEnvironmentsClient(opts.Master.URIs.Api, token, orgId) - env, err := envClient.Create(cloudclient.Environment{Name: envName, Owner: orgId}) - ui.ExitOnError("creating environment", err) - - opts.Master.OrgId = orgId - opts.Master.EnvId = env.Id - opts.Master.AgentToken = env.AgentToken - - newStatus = append( - newStatus, - [][]string{ - {"Testkube will be connected to cloud org/env"}, - {"Organization Id", opts.Master.OrgId}, - {"Organization name", orgName}, - {"Environment Id", opts.Master.EnvId}, - {"Environment name", env.Name}, - {ui.Separator, ""}, - }...) - } - - // validate if user created env - or was passed from flags - if opts.Master.EnvId == "" { - ui.Failf("You need pass valid environment id to connect to cloud") - } - if opts.Master.OrgId == "" { - ui.Failf("You need pass valid organization id to connect to cloud") - } - - // update summary - newStatus = append(newStatus, []string{"Testkube support services not needed anymore"}) - newStatus = append(newStatus, []string{"MinIO ", "Stopped and scaled down, (not deleted)"}) - newStatus = append(newStatus, []string{"MongoDB ", "Stopped and scaled down, (not deleted)"}) - newStatus = append(newStatus, []string{"Dashboard", "Stopped and scaled down, (not deleted)"}) - - ui.NL(2) - - ui.H1("Summary of your setup after connecting to Testkube Cloud") - ui.Properties(newStatus) - - ui.NL() - ui.Warn("Remember: All your historical data and artifacts will be safe in case you want to rollback. OSS and cloud executions will be separated.") - ui.NL() - - if ok := ui.Confirm("Proceed with connecting Testkube Cloud?"); !ok { - return - } - - spinner := ui.NewSpinner("Connecting Testkube Cloud") - err = common.HelmUpgradeOrInstallTestkubeCloud(opts, cfg, true) - ui.ExitOnError("Installing Testkube Cloud", err) - spinner.Success() - - ui.NL() - - // let's scale down deployment of mongo - if opts.MongoReplicas == 0 { - spinner = ui.NewSpinner("Scaling down MongoDB") - common.KubectlScaleDeployment(opts.Namespace, "testkube-mongodb", opts.MongoReplicas) - spinner.Success() - } - if opts.MinioReplicas == 0 { - spinner = ui.NewSpinner("Scaling down MinIO") - common.KubectlScaleDeployment(opts.Namespace, "testkube-minio-testkube", opts.MinioReplicas) - spinner.Success() - } - if opts.DashboardReplicas == 0 { - spinner = ui.NewSpinner("Scaling down Dashbaord") - common.KubectlScaleDeployment(opts.Namespace, "testkube-dashboard", opts.DashboardReplicas) - spinner.Success() - } - - ui.H2("Testkube Cloud is connected to your Testkube instance, saving local configuration") - - ui.H2("Saving testkube cli cloud context") - if token == "" && !common.IsUserLoggedIn(cfg, opts) { - token, refreshToken, err = common.LoginUser(opts.Master.URIs.Auth) - ui.ExitOnError("user login", err) - } - err = common.PopulateLoginDataToContext(opts.Master.OrgId, opts.Master.EnvId, token, refreshToken, opts, cfg) - - ui.ExitOnError("Setting cloud environment context", err) - - ui.NL(2) - - ui.ShellCommand("In case you want to roll back you can simply run the following command in your CLI:", "testkube cloud disconnect") - - ui.Success("You can now login to Testkube Cloud and validate your connection:") - ui.NL() - ui.Link(opts.Master.URIs.Ui + "/organization/" + opts.Master.OrgId + "/environment/" + opts.Master.EnvId + "/dashboard/tests") - - ui.NL(2) - }, - } - - common.PopulateHelmFlags(cmd, &opts) - common.PopulateMasterFlags(cmd, &opts) - - cmd.Flags().IntVar(&opts.MinioReplicas, "minio-replicas", 0, "MinIO replicas") - cmd.Flags().IntVar(&opts.MongoReplicas, "mongo-replicas", 0, "MongoDB replicas") - cmd.Flags().IntVar(&opts.DashboardReplicas, "dashboard-replicas", 0, "Dashboard replicas") - return cmd -} - -var contextDescription = map[string]string{ - "": "Unknown context, try updating your testkube cluster installation", - "oss": "Open Source Testkube", - "cloud": "Testkube in Cloud mode", -} - -func uiGetEnvName() (string, error) { - for i := 0; i < 3; i++ { - if envName := ui.TextInput("Tell us the name of your environment"); envName != "" { - return envName, nil - } - pterm.Error.Println("Environment name cannot be empty") - } - - return "", fmt.Errorf("environment name cannot be empty") -} diff --git a/cmd/kubectl-testkube/commands/cloud/disconnect.go b/cmd/kubectl-testkube/commands/cloud/disconnect.go deleted file mode 100644 index ad5de8a30db..00000000000 --- a/cmd/kubectl-testkube/commands/cloud/disconnect.go +++ /dev/null @@ -1,120 +0,0 @@ -package cloud - -import ( - "strings" - - "github.com/pterm/pterm" - - "github.com/spf13/cobra" - - "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" - "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - "github.com/kubeshop/testkube/pkg/ui" -) - -func NewDisconnectCmd() *cobra.Command { - - var opts common.HelmOptions - - cmd := &cobra.Command{ - Use: "disconnect", - Deprecated: "Use `testkube pro disconnect` instead", - Hidden: true, - Aliases: []string{"d"}, - Short: "[Deprecated] Switch back to Testkube OSS mode, based on active .kube/config file", - Run: func(cmd *cobra.Command, args []string) { - ui.Warn("You are using a deprecated command, please switch to `testkube pro disconnect`.") - - ui.H1("Disconnecting your cloud environment:") - ui.Paragraph("Rolling back to your clusters testkube OSS installation") - ui.Paragraph("If you need more details click into following link: " + docsUrl) - ui.H2("You can safely switch between connecting Cloud and disconnecting without losing your data.") - - cfg, err := config.Load() - if err != nil { - pterm.Error.Printfln("Failed to load config file: %s", err.Error()) - return - } - - client, _, err := common.GetClient(cmd) - ui.ExitOnError("getting client", err) - - info, err := client.GetServerInfo() - firstInstall := err != nil && strings.Contains(err.Error(), "not found") - if err != nil && !firstInstall { - ui.Failf("Can't get testkube cluster information: %s", err.Error()) - } - var apiContext string - if actx, ok := contextDescription[info.Context]; ok { - apiContext = actx - } - var clusterContext string - if cfg.ContextType == config.ContextTypeKubeconfig { - clusterContext, err = common.GetCurrentKubernetesContext() - if err != nil { - pterm.Error.Printfln("Failed to get current kubernetes context: %s", err.Error()) - return - } - } - - // TODO: implement context info - ui.H1("Current status of your Testkube instance") - - summary := [][]string{ - {"Testkube mode"}, - {"Context", apiContext}, - {"Kubectl context", clusterContext}, - {"Namespace", cfg.Namespace}, - {ui.Separator, ""}, - - {"Testkube is connected to cloud organizations environment"}, - {"Organization Id", info.OrgId}, - {"Environment Id", info.EnvId}, - } - - ui.Properties(summary) - - if ok := ui.Confirm("Shall we disconnect your cloud environment now?"); !ok { - return - } - - ui.NL(2) - - spinner := ui.NewSpinner("Disonnecting from Testkube Cloud") - - err = common.HelmUpgradeOrInstalTestkube(opts) - ui.ExitOnError("Installing Testkube Cloud", err) - spinner.Success() - - // let's scale down deployment of mongo - if opts.MongoReplicas > 0 { - spinner = ui.NewSpinner("Scaling up MongoDB") - common.KubectlScaleDeployment(opts.Namespace, "testkube-mongodb", opts.MongoReplicas) - spinner.Success() - } - if opts.MinioReplicas > 0 { - spinner = ui.NewSpinner("Scaling up MinIO") - common.KubectlScaleDeployment(opts.Namespace, "testkube-minio-testkube", opts.MinioReplicas) - spinner.Success() - } - if opts.DashboardReplicas > 0 { - spinner = ui.NewSpinner("Scaling up Dashbaord") - common.KubectlScaleDeployment(opts.Namespace, "testkube-dashboard", opts.DashboardReplicas) - spinner.Success() - } - - ui.NL() - ui.Success("Disconnect finished successfully") - ui.NL() - ui.ShellCommand("You can now open your local Dashboard and validate the successfull disconnect", "testkube dashboard") - }, - } - - // populate options - common.PopulateHelmFlags(cmd, &opts) - cmd.Flags().IntVar(&opts.MinioReplicas, "minio-replicas", 1, "MinIO replicas") - cmd.Flags().IntVar(&opts.MongoReplicas, "mongo-replicas", 1, "MongoDB replicas") - cmd.Flags().IntVar(&opts.DashboardReplicas, "dashboard-replicas", 1, "Dashboard replicas") - - return cmd -} diff --git a/cmd/kubectl-testkube/commands/cloud/init.go b/cmd/kubectl-testkube/commands/cloud/init.go deleted file mode 100644 index c976585c25c..00000000000 --- a/cmd/kubectl-testkube/commands/cloud/init.go +++ /dev/null @@ -1,119 +0,0 @@ -package cloud - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" - "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - "github.com/kubeshop/testkube/pkg/telemetry" - "github.com/kubeshop/testkube/pkg/ui" -) - -func NewInitCmd() *cobra.Command { - options := common.HelmOptions{ - NoMinio: true, - NoMongo: true, - NoDashboard: true, - } - - cmd := &cobra.Command{ - Use: "init", - Deprecated: "Use `testkube pro init` instead", - Hidden: true, - Short: "[Deprecated] Install Testkube Cloud Agent and connect to Testkube Cloud environment", - Aliases: []string{"install"}, - Run: func(cmd *cobra.Command, args []string) { - ui.Warn("You are using a deprecated command, please switch to `testkube pro init`.") - - ui.Info("WELCOME TO") - ui.Logo() - - cfg, err := config.Load() - ui.ExitOnError("loading config file", err) - ui.NL() - - common.ProcessMasterFlags(cmd, &options, &cfg) - - sendAttemptTelemetry(cmd, cfg) - - // create new cloud uris - if !options.NoConfirm { - ui.Warn("This will install Testkube to the latest version. This may take a few minutes.") - ui.Warn("Please be sure you're on valid kubectl context before continuing!") - ui.NL() - - currentContext, err := common.GetCurrentKubernetesContext() - if err != nil { - sendErrTelemetry(cmd, cfg, "k8s_context", err) - ui.ExitOnError("getting current context", err) - } - ui.Alert("Current kubectl context:", currentContext) - ui.NL() - - ok := ui.Confirm("Do you want to continue?") - if !ok { - ui.Errf("Testkube installation cancelled") - sendErrTelemetry(cmd, cfg, "user_cancel", err) - return - } - } - - spinner := ui.NewSpinner("Installing Testkube") - err = common.HelmUpgradeOrInstallTestkubeCloud(options, cfg, false) - if err != nil { - sendErrTelemetry(cmd, cfg, "helm_install", err) - ui.ExitOnError("Installing Testkube", err) - } - spinner.Success() - - ui.NL() - - ui.H2("Saving testkube cli cloud context") - var token, refreshToken string - if !common.IsUserLoggedIn(cfg, options) { - token, refreshToken, err = common.LoginUser(options.Master.URIs.Auth) - sendErrTelemetry(cmd, cfg, "login", err) - ui.ExitOnError("user login", err) - } - err = common.PopulateLoginDataToContext(options.Master.OrgId, options.Master.EnvId, token, refreshToken, options, cfg) - sendErrTelemetry(cmd, cfg, "setting_context", err) - ui.ExitOnError("Setting cloud environment context", err) - - ui.Info(" Happy Testing! 🚀") - ui.NL() - }, - } - - common.PopulateHelmFlags(cmd, &options) - common.PopulateMasterFlags(cmd, &options) - - cmd.Flags().BoolVar(&options.MultiNamespace, "multi-namespace", false, "multi namespace mode") - cmd.Flags().BoolVar(&options.NoOperator, "no-operator", false, "should operator be installed (for more instances in multi namespace mode it should be set to true)") - return cmd -} - -func sendErrTelemetry(cmd *cobra.Command, clientCfg config.Data, errType string, errorLogs error) { - var errorStackTrace string - errorStackTrace = fmt.Sprintf("%+v", errorLogs) - if clientCfg.TelemetryEnabled { - ui.Debug("collecting anonymous telemetry data, you can disable it by calling `kubectl testkube disable telemetry`") - out, err := telemetry.SendCmdErrorEvent(cmd, common.Version, errType, errorStackTrace) - if ui.Verbose && err != nil { - ui.Err(err) - } - ui.Debug("telemetry send event response", out) - } -} - -func sendAttemptTelemetry(cmd *cobra.Command, clientCfg config.Data) { - if clientCfg.TelemetryEnabled { - ui.Debug("collecting anonymous telemetry data, you can disable it by calling `kubectl testkube disable telemetry`") - out, err := telemetry.SendCmdAttemptEvent(cmd, common.Version) - if ui.Verbose && err != nil { - ui.Err(err) - } - ui.Debug("telemetry send event response", out) - } -} diff --git a/cmd/kubectl-testkube/commands/cloud/login.go b/cmd/kubectl-testkube/commands/cloud/login.go deleted file mode 100644 index 44970cd7c20..00000000000 --- a/cmd/kubectl-testkube/commands/cloud/login.go +++ /dev/null @@ -1,52 +0,0 @@ -package cloud - -import ( - "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" - "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - "github.com/kubeshop/testkube/pkg/ui" - - "github.com/spf13/cobra" -) - -func NewLoginCmd() *cobra.Command { - var opts common.HelmOptions - - cmd := &cobra.Command{ - Use: "login", - Deprecated: "Use `testkube pro login` instead", - Hidden: true, - Aliases: []string{"l"}, - Short: "[Deprecated] Login to Testkube Pro", - Run: func(cmd *cobra.Command, args []string) { - token, refreshToken, err := common.LoginUser(opts.Master.URIs.Auth) - ui.ExitOnError("getting token", err) - - orgID := opts.Master.OrgId - envID := opts.Master.EnvId - - if orgID == "" { - orgID, _, err = common.UiGetOrganizationId(opts.Master.URIs.Api, token) - ui.ExitOnError("getting organization", err) - } - if envID == "" { - envID, _, err = common.UiGetEnvironmentID(opts.Master.URIs.Api, token, orgID) - ui.ExitOnError("getting environment", err) - } - cfg, err := config.Load() - ui.ExitOnError("loading config file", err) - - common.ProcessMasterFlags(cmd, &opts, &cfg) - - err = common.PopulateLoginDataToContext(orgID, envID, token, refreshToken, opts, cfg) - ui.ExitOnError("saving config file", err) - - ui.Success("Your config was updated with new values") - ui.NL() - common.UiPrintContext(cfg) - }, - } - - common.PopulateMasterFlags(cmd, &opts) - - return cmd -} From e1958792d6d6128b3474931392dc399b84566b87 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 13 Mar 2024 16:24:13 +0200 Subject: [PATCH 219/234] update docs --- .../articles/migrating-from-oss-to-pro.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/docs/articles/migrating-from-oss-to-pro.md diff --git a/docs/docs/articles/migrating-from-oss-to-pro.md b/docs/docs/articles/migrating-from-oss-to-pro.md new file mode 100644 index 00000000000..ceb2730861f --- /dev/null +++ b/docs/docs/articles/migrating-from-oss-to-pro.md @@ -0,0 +1,92 @@ +#How to Migrate from Testkube OSS to Testkube Pro + + +It is possible to deploy Testkube Pro within the same k8s cluster where Testkube OSS is already running. To achieve this, you should install Testkube Pro in a different namespace and connect Testkube OSS as an Agent. + +:::note +Please note that your test executions will not be migrated to Testkube Pro, only Test definitions. +::: + + +## License +To start with Testkube Pro you need to request a license. Depending on your environment requirements it can be either an offline or an online license. Read more about these types of licenses [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#license). If you require an online license, it can be acquired [here](https://testkube.io/download). If you need an offline license, please contact us using this [form](https://testkube.io/contact). +There are multiple ways to integrate Testkube OSS into your Testkube Pro setup. We highly recommend creating a k8s secret, as it provides a more secure way to store sensitive data. + +At this point there are two options to deploy Testkube Pro: + +**Multi-cluster Installation:** + +*Description:* This option enables the connection of multiple Agents from different Kubernetes clusters. It allows you to consolidate all tests in a unified Dashboard, organized by Environments. + +*Requirements:* A domain name and certificates are necessary as the installation exposes Testkube endpoints to the outside world. + +*Benefit:* Offers a comprehensive view across clusters and environments. + +**One-cluster Installation:** + +*Description:* With this option, you can connect only one Agent (e.g. your existing Testkube OSS) within the same Kubernetes cluster. Access to the Dashboard is achieved through port-forwarding to localhost. + +*Requirements:* No domain names or certificates are required for this approach. + +*Benefit:* Simplified setup suitable for a single-cluster environment without the need for external exposure. + + + +## Multi-cluster Installation + +If you decide to go with multiple-cluster installation, please ensure that you have the following prerequisites in place: + +- [cert-manager](https://cert-manager.io/docs/installation/) (version 1.14.2+ ) or have your own certificates in place; +- [NGINX Controller](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/) (version 4.8.3+) or any other service of your choice to configure ingress traffic; +- a domain for exposing Tetskube endpoints. + +### Ingress +To make a central Testkube Pro cluster reachable for multiple Agents we need to expose [endpoints](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#domain) and create certificates. +Testkube Pro requires the NGINX Controller and it is the only supported Ingress Controller for now. By default, Testkube Pro integrates with cert-manager. However, if you choose to use your own certificates, provide them as specified [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#tls). +Create a `values.yaml` with your domain and certificate configuration. Additionally include a secretRef to the secret with the license that was created earlier: + +`values.yaml` +```yaml +global: + domain: you-domain.it.com + enterpriseLicenseSecretRef: testkube-enterprise-license + + certificateProvider: "cert-manager" + certManager: + issuerRef: letsencrypt + +``` + + +###Auth +Testkube Pro utilizes [Dex](https://dexidp.io/) for authentication and authorization. For detailed instruction on configuring Dex, please refer to the [Identity Provider](https://docs.testkube.io/testkube-enterprise/articles/auth) document. You may start with creating static users if you do not have any Identity Provider. Here is an example of usage: + + +`values.yaml` +```yaml +dex: + configTemplate: + additionalConfig: | + enablePasswordDB: true + staticPasswords: + - email: "admin@example.com" + # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" + +``` + +### Deployment +Now, let’s deploy Testkube Pro. Please refer to the installation commands [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide/#installation). Do not forget to pass your customized `values.yaml` file. + +It may take a few minutes for the certificates to be issued and for the pods to reach `Ready` status. Once everything is up and running, you may go to dashboard.your-domain.it.com and log in. + +The only thing that is remaining is to connect Testkube OSS as an Agent. [Create a new environment](https://docs.testkube.io/testkube-pro/articles/environment-management/#creating-a-new-environment) and duplicate the installation command. Execute this command in the cluster where Testkube OSS is deployed to seamlessly upgrade the existing installation to Agent mode. Pay attention to the namespace name, ensuring it aligns with the namespace of Testkube OSS. + +After running the command, navigate to the Dashboard and you will see all your tests available. + + +## One-cluster Installation + +It is possible to deploy Testkube Pro and connect an Agent to it in the same k8s cluster without exposing endpoints to the outside world. By simply running `bash <(curl -sSLf https://download.testkube.io)` and entering the license key (for now it works with Online licenses only), you will have a working environment in just a few minutes. The script will ask you for the namespace where your Testkube OSS is running and automatically connect it as an Agent, preserving all created tests. Please check out the [official documentation](https://docs.testkube.io/testkube-enterprise/articles/usage-guide/#installation-of-testkube-enterprise-and-an-agent-in-the-same-cluster) for more detailed info. \ No newline at end of file From 4322becdf85a217c46c44a5f0a9e1d4c4f5dbee1 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 13 Mar 2024 16:32:36 +0200 Subject: [PATCH 220/234] update Enterprise --- .../articles/migrating-from-oss-to-pro.md | 92 ------------------- .../articles/migrating-from-oss-to-pro.md | 90 ++++++++++++++++++ 2 files changed, 90 insertions(+), 92 deletions(-) delete mode 100644 docs/docs/articles/migrating-from-oss-to-pro.md create mode 100644 docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md diff --git a/docs/docs/articles/migrating-from-oss-to-pro.md b/docs/docs/articles/migrating-from-oss-to-pro.md deleted file mode 100644 index ceb2730861f..00000000000 --- a/docs/docs/articles/migrating-from-oss-to-pro.md +++ /dev/null @@ -1,92 +0,0 @@ -#How to Migrate from Testkube OSS to Testkube Pro - - -It is possible to deploy Testkube Pro within the same k8s cluster where Testkube OSS is already running. To achieve this, you should install Testkube Pro in a different namespace and connect Testkube OSS as an Agent. - -:::note -Please note that your test executions will not be migrated to Testkube Pro, only Test definitions. -::: - - -## License -To start with Testkube Pro you need to request a license. Depending on your environment requirements it can be either an offline or an online license. Read more about these types of licenses [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#license). If you require an online license, it can be acquired [here](https://testkube.io/download). If you need an offline license, please contact us using this [form](https://testkube.io/contact). -There are multiple ways to integrate Testkube OSS into your Testkube Pro setup. We highly recommend creating a k8s secret, as it provides a more secure way to store sensitive data. - -At this point there are two options to deploy Testkube Pro: - -**Multi-cluster Installation:** - -*Description:* This option enables the connection of multiple Agents from different Kubernetes clusters. It allows you to consolidate all tests in a unified Dashboard, organized by Environments. - -*Requirements:* A domain name and certificates are necessary as the installation exposes Testkube endpoints to the outside world. - -*Benefit:* Offers a comprehensive view across clusters and environments. - -**One-cluster Installation:** - -*Description:* With this option, you can connect only one Agent (e.g. your existing Testkube OSS) within the same Kubernetes cluster. Access to the Dashboard is achieved through port-forwarding to localhost. - -*Requirements:* No domain names or certificates are required for this approach. - -*Benefit:* Simplified setup suitable for a single-cluster environment without the need for external exposure. - - - -## Multi-cluster Installation - -If you decide to go with multiple-cluster installation, please ensure that you have the following prerequisites in place: - -- [cert-manager](https://cert-manager.io/docs/installation/) (version 1.14.2+ ) or have your own certificates in place; -- [NGINX Controller](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/) (version 4.8.3+) or any other service of your choice to configure ingress traffic; -- a domain for exposing Tetskube endpoints. - -### Ingress -To make a central Testkube Pro cluster reachable for multiple Agents we need to expose [endpoints](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#domain) and create certificates. -Testkube Pro requires the NGINX Controller and it is the only supported Ingress Controller for now. By default, Testkube Pro integrates with cert-manager. However, if you choose to use your own certificates, provide them as specified [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#tls). -Create a `values.yaml` with your domain and certificate configuration. Additionally include a secretRef to the secret with the license that was created earlier: - -`values.yaml` -```yaml -global: - domain: you-domain.it.com - enterpriseLicenseSecretRef: testkube-enterprise-license - - certificateProvider: "cert-manager" - certManager: - issuerRef: letsencrypt - -``` - - -###Auth -Testkube Pro utilizes [Dex](https://dexidp.io/) for authentication and authorization. For detailed instruction on configuring Dex, please refer to the [Identity Provider](https://docs.testkube.io/testkube-enterprise/articles/auth) document. You may start with creating static users if you do not have any Identity Provider. Here is an example of usage: - - -`values.yaml` -```yaml -dex: - configTemplate: - additionalConfig: | - enablePasswordDB: true - staticPasswords: - - email: "admin@example.com" - # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) - hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" - username: "admin" - userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" - -``` - -### Deployment -Now, let’s deploy Testkube Pro. Please refer to the installation commands [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide/#installation). Do not forget to pass your customized `values.yaml` file. - -It may take a few minutes for the certificates to be issued and for the pods to reach `Ready` status. Once everything is up and running, you may go to dashboard.your-domain.it.com and log in. - -The only thing that is remaining is to connect Testkube OSS as an Agent. [Create a new environment](https://docs.testkube.io/testkube-pro/articles/environment-management/#creating-a-new-environment) and duplicate the installation command. Execute this command in the cluster where Testkube OSS is deployed to seamlessly upgrade the existing installation to Agent mode. Pay attention to the namespace name, ensuring it aligns with the namespace of Testkube OSS. - -After running the command, navigate to the Dashboard and you will see all your tests available. - - -## One-cluster Installation - -It is possible to deploy Testkube Pro and connect an Agent to it in the same k8s cluster without exposing endpoints to the outside world. By simply running `bash <(curl -sSLf https://download.testkube.io)` and entering the license key (for now it works with Online licenses only), you will have a working environment in just a few minutes. The script will ask you for the namespace where your Testkube OSS is running and automatically connect it as an Agent, preserving all created tests. Please check out the [official documentation](https://docs.testkube.io/testkube-enterprise/articles/usage-guide/#installation-of-testkube-enterprise-and-an-agent-in-the-same-cluster) for more detailed info. \ No newline at end of file diff --git a/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md b/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md new file mode 100644 index 00000000000..fb999702e6b --- /dev/null +++ b/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md @@ -0,0 +1,90 @@ +#How to Migrate from Testkube OSS to Testkube Enterprise + + +It is possible to deploy Testkube Enterprise within the same k8s cluster where Testkube OSS is already running. To achieve this, you should install Testkube Enterprise in a different namespace and connect Testkube OSS as an Agent. + +:::note +Please note that your test executions will not be migrated to Testkube Enterprise, only Test definitions. +::: + + +## License +To start with Testkube Enterprise you need to request a license. Depending on your environment requirements it can be either an offline or an online license. Read more about these types of licenses [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#license). If you require an online license, it can be acquired [here](https://testkube.io/download). If you need an offline license, please contact us using this [form](https://testkube.io/contact). +There are multiple ways to integrate Testkube OSS into your Testkube Enterprise setup. We highly recommend creating a k8s secret, as it Enterprisevides a more secure way to store sensitive data. + +At this point there are two options to deploy Testkube Enterprise: + +**Multi-cluster Installation:** + +- *Description:* This option enables the connection of multiple Agents from different Kubernetes clusters. It allows you to consolidate all tests in a unified Dashboard, organized by Environments. + +- *Requirements:* A domain name and certificates are necessary as the installation exposes Testkube endpoints to the outside world. + +- *Benefit:* Offers a comprehensive view across clusters and environments. + +**One-cluster Installation:** + +- *Description:* With this option, you can connect only one Agent (e.g. your existing Testkube OSS) within the same Kubernetes cluster. Access to the Dashboard is achieved through port-forwarding to localhost. + +- *Requirements:* No domain names or certificates are required for this apEnterpriseach. + +- *Benefit:* Simplified setup suitable for a single-cluster environment without the need for external exposure. + + +## Multi-cluster Installation + +If you decide to go with multiple-cluster installation, please ensure that you have the following prerequisites in place: + +- [cert-manager](https://cert-manager.io/docs/installation/) (version 1.14.2+ ) or have your own certificates in place; +- [NGINX Controller](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/) (version 4.8.3+) or any other service of your choice to configure ingress traffic; +- a domain for exposing Tetskube endpoints. + +### Ingress +To make a central Testkube Enterprise cluster reachable for multiple Agents we need to expose [endpoints](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#domain) and create certificates. +Testkube Enterprise requires the NGINX Controller and it is the only supported Ingress Controller for now. By default, Testkube Enterprise integrates with cert-manager. However, if you choose to use your own certificates, provide them as specified [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide#tls). +Create a `values.yaml` with your domain and certificate configuration. Additionally include a secretRef to the secret with the license that was created earlier: + +`values.yaml` +```yaml +global: + domain: you-domain.it.com + enterpriseLicenseSecretRef: testkube-enterprise-license + + certificateProvider: "cert-manager" + certManager: + issuerRef: letsencrypt + +``` + +###Auth +Testkube Enterprise utilizes [Dex](https://dexidp.io/) for authentication and authorization. For detailed instruction on configuring Dex, please refer to the [Identity Provider](https://docs.testkube.io/testkube-enterprise/articles/auth) document. You may start with creating static users if you do not have any Identity Provider. Here is an example of usage: + + +`values.yaml` +```yaml +dex: + configTemplate: + additionalConfig: | + enablePasswordDB: true + staticPasswords: + - email: "admin@example.com" + # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" + +``` + +### Deployment +Now, let’s deploy Testkube Enterprise. Please refer to the installation commands [here](https://docs.testkube.io/testkube-enterprise/articles/usage-guide/#installation). Do not forget to pass your customized `values.yaml` file. + +It may take a few minutes for the certificates to be issued and for the pods to reach `Ready` status. Once everything is up and running, you may go to dashboard.your-domain.it.com and log in. + +The only thing that is remaining is to connect Testkube OSS as an Agent. [Create a new environment](https://docs.testkube.io/testkube-pro/articles/environment-management/#creating-a-new-environment) and duplicate the installation command. Execute this command in the cluster where Testkube OSS is deployed to seamlessly upgrade the existing installation to Agent mode. Pay attention to the namespace name, ensuring it aligns with the namespace of Testkube OSS. + +After running the command, navigate to the Dashboard and you will see all your tests available. + + +## One-cluster Installation + +It is possible to deploy Testkube Enterprise and connect an Agent to it in the same k8s cluster without exposing endpoints to the outside world. By simply running `bash <(curl -sSLf https://download.testkube.io)` and entering the license key (for now it works with Online licenses only), you will have a working environment in just a few minutes. The script will ask you for the namespace where your Testkube OSS is running and automatically connect it as an Agent, preserving all created tests. Please check out the [official documentation](https://docs.testkube.io/testkube-enterprise/articles/usage-guide/#installation-of-testkube-enterprise-and-an-agent-in-the-same-cluster) for more detailed info. \ No newline at end of file From 95130a33ae5f53755f4b621843d94fd4f658b3d5 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 13 Mar 2024 18:00:49 +0200 Subject: [PATCH 221/234] updated content --- docs/sidebars.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sidebars.js b/docs/sidebars.js index e16bccd7a97..db1f77229c0 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -210,7 +210,8 @@ const sidebars = { items: [ "testkube-enterprise/articles/testkube-enterprise", "testkube-enterprise/articles/usage-guide", - "testkube-enterprise/articles/auth"], + "testkube-enterprise/articles/auth", + "testkube-enterprise/articles/migrating-from-oss-to-pro"], }, "articles/testkube-oss", { From 31e99f8df2fd44414d4b3daeca4e49ddf924fb06 Mon Sep 17 00:00:00 2001 From: ypoplavs Date: Wed, 13 Mar 2024 18:13:09 +0200 Subject: [PATCH 222/234] fix title for enterprise docs --- .../testkube-enterprise/articles/migrating-from-oss-to-pro.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md b/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md index fb999702e6b..b5cfe4c50c9 100644 --- a/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md +++ b/docs/docs/testkube-enterprise/articles/migrating-from-oss-to-pro.md @@ -1,5 +1,4 @@ -#How to Migrate from Testkube OSS to Testkube Enterprise - +# How to Migrate from Testkube OSS to Testkube Enterprise It is possible to deploy Testkube Enterprise within the same k8s cluster where Testkube OSS is already running. To achieve this, you should install Testkube Enterprise in a different namespace and connect Testkube OSS as an Agent. From 2de1614fbb043f90f2751ac03585cae8babcf5ce Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Wed, 13 Mar 2024 19:47:03 +0100 Subject: [PATCH 223/234] feat: remove dashboard oss command (#5174) --- cmd/kubectl-testkube/commands/config.go | 2 - .../commands/config/dashboard_name.go | 36 ---- .../commands/config/dashboard_port.go | 44 ----- cmd/kubectl-testkube/commands/dashboard.go | 168 +----------------- .../commands/pro/disconnect.go | 1 - docs/docs/cli/testkube.md | 2 +- docs/docs/cli/testkube_dashboard.md | 9 +- 7 files changed, 10 insertions(+), 252 deletions(-) delete mode 100644 cmd/kubectl-testkube/commands/config/dashboard_name.go delete mode 100644 cmd/kubectl-testkube/commands/config/dashboard_port.go diff --git a/cmd/kubectl-testkube/commands/config.go b/cmd/kubectl-testkube/commands/config.go index e6457ed6d54..699c5d32807 100644 --- a/cmd/kubectl-testkube/commands/config.go +++ b/cmd/kubectl-testkube/commands/config.go @@ -32,8 +32,6 @@ func NewConfigCmd() *cobra.Command { cmd.AddCommand(oauth.NewConfigureOAuthCmd()) cmd.AddCommand(commands.NewConfigureAPIServerNameCmd()) cmd.AddCommand(commands.NewConfigureAPIServerPortCmd()) - cmd.AddCommand(commands.NewConfigureDashboardNameCmd()) - cmd.AddCommand(commands.NewConfigureDashboardPortCmd()) return cmd } diff --git a/cmd/kubectl-testkube/commands/config/dashboard_name.go b/cmd/kubectl-testkube/commands/config/dashboard_name.go deleted file mode 100644 index 372cae2dff4..00000000000 --- a/cmd/kubectl-testkube/commands/config/dashboard_name.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - "github.com/kubeshop/testkube/pkg/ui" -) - -// NewConfigureDashboardNameCmd is dashboard name config command -func NewConfigureDashboardNameCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "dashboard-name ", - Short: "Set dashboard name for testkube client", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return fmt.Errorf("please pass valid dashboard name value") - } - - return nil - }, - Run: func(cmd *cobra.Command, args []string) { - cfg, err := config.Load() - ui.ExitOnError("loading config file", err) - - cfg.DashboardName = args[0] - err = config.Save(cfg) - ui.ExitOnError("saving config file", err) - ui.Success("New dashboard name set to", cfg.DashboardName) - }, - } - - return cmd -} diff --git a/cmd/kubectl-testkube/commands/config/dashboard_port.go b/cmd/kubectl-testkube/commands/config/dashboard_port.go deleted file mode 100644 index 90d4231e32e..00000000000 --- a/cmd/kubectl-testkube/commands/config/dashboard_port.go +++ /dev/null @@ -1,44 +0,0 @@ -package config - -import ( - "fmt" - "strconv" - - "github.com/spf13/cobra" - - "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - "github.com/kubeshop/testkube/pkg/ui" -) - -// NewConfigureDashboardPortCmd is dashboard port config command -func NewConfigureDashboardPortCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "dashboard-port ", - Short: "Set dashboard port for testkube client", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return fmt.Errorf("please pass valid dashboard port value") - } - - if _, err := strconv.Atoi(args[0]); err != nil { - return fmt.Errorf("please pass integer dashboard port value: %w", err) - } - - return nil - }, - Run: func(cmd *cobra.Command, args []string) { - cfg, err := config.Load() - ui.ExitOnError("loading config file", err) - - port, err := strconv.Atoi(args[0]) - ui.ExitOnError("converting port value", err) - - cfg.DashboardPort = port - err = config.Save(cfg) - ui.ExitOnError("saving config file", err) - ui.Success("New dashboard port set to", strconv.Itoa(cfg.DashboardPort)) - }, - } - - return cmd -} diff --git a/cmd/kubectl-testkube/commands/dashboard.go b/cmd/kubectl-testkube/commands/dashboard.go index 98adfe68bfd..93c8378bf14 100644 --- a/cmd/kubectl-testkube/commands/dashboard.go +++ b/cmd/kubectl-testkube/commands/dashboard.go @@ -1,70 +1,34 @@ package commands import ( - "errors" "fmt" - "net" - "os" - "os/exec" - "os/signal" - "strconv" - "time" "github.com/skratchdot/open-golang/open" "github.com/spf13/cobra" "github.com/kubeshop/testkube/cmd/kubectl-testkube/config" - "github.com/kubeshop/testkube/pkg/http" - "github.com/kubeshop/testkube/pkg/process" "github.com/kubeshop/testkube/pkg/ui" ) -const maxPortNumber = 65535 - // NewDashboardCmd is a method to create new dashboard command func NewDashboardCmd() *cobra.Command { - var ( - useGlobalDashboard bool - ) - cmd := &cobra.Command{ Use: "dashboard", Aliases: []string{"d", "open-dashboard"}, - Short: "Open testkube dashboard", - Long: `Open testkube dashboard`, + Short: "Open Testkube Pro/Enterprise dashboard", + Long: `Open Testkube Pro/Enterprise dashboard`, Run: func(cmd *cobra.Command, args []string) { cfg, err := config.Load() ui.ExitOnError("loading config file", err) - if cfg.APIServerName == "" { - cfg.APIServerName = config.APIServerName - } - - if cfg.APIServerPort == 0 { - cfg.APIServerPort = config.APIServerPort - } - - if cfg.DashboardName == "" { - cfg.DashboardName = config.DashboardName - } - - if cfg.DashboardPort == 0 { - cfg.DashboardPort = config.DashboardPort - } - - namespace := cmd.Flag("namespace").Value.String() - - if cfg.ContextType == config.ContextTypeCloud { - openCloudDashboard(cfg) - + if cfg.ContextType != config.ContextTypeCloud { + ui.Warn("As of 1.17 the dashboard is no longer included with Testkube OSS - please refer to https://bit.ly/tk-dashboard for more info") } else { - openLocalDashboard(cmd, cfg, useGlobalDashboard, namespace) + openCloudDashboard(cfg) } - }, } - cmd.Flags().BoolVar(&useGlobalDashboard, "use-global-dashboard", false, "use global dashboard for viewing testkube results") return cmd } @@ -74,125 +38,3 @@ func openCloudDashboard(cfg config.Data) { err := open.Run(uri) ui.PrintOnError("openning dashboard", err) } - -func openLocalDashboard(cmd *cobra.Command, cfg config.Data, useGlobalDashboard bool, namespace string) { - - dashboardLocalPort, err := getDashboardLocalPort(cfg.APIServerPort) - ui.PrintOnError("checking dashboard port", err) - - uri := fmt.Sprintf("http://localhost:%d", dashboardLocalPort) - if useGlobalDashboard { - uri = DashboardURI - } - - apiURI := fmt.Sprintf("localhost:%d/%s", cfg.APIServerPort, ApiVersion) - dashboardAddress := fmt.Sprintf("%s/apiEndpoint?apiEndpoint=%s", uri, apiURI) - apiAddress := fmt.Sprintf("http://%s", apiURI) - - var commandsToKill []*exec.Cmd - - // kill background port-forwarded processes - defer func() { - for _, command := range commandsToKill { - if command != nil { - err := command.Process.Kill() - ui.PrintOnError("killing command: "+command.String(), err) - } - } - }() - - // if not global dasboard - we'll try to port-forward current cluster API - if !useGlobalDashboard { - command, err := asyncPortForward(namespace, cfg.DashboardName, dashboardLocalPort, cfg.DashboardPort) - ui.PrintOnError("port forwarding dashboard endpoint", err) - commandsToKill = append(commandsToKill, command) - } - - command, err := asyncPortForward(namespace, cfg.APIServerName, cfg.APIServerPort, cfg.APIServerPort) - ui.PrintOnError("port forwarding api endpoint", err) - commandsToKill = append(commandsToKill, command) - - // check for api and dasboard to be ready - ready, err := readinessCheck(cmd, apiAddress, dashboardAddress) - ui.PrintOnError("checking readiness of services", err) - ui.Debug("Endpoints readiness", fmt.Sprintf("%v", ready)) - - // open browser - err = open.Run(dashboardAddress) - ui.PrintOnError("openning dashboard", err) - - // wait for Ctrl/Cmd + c signal to clear everything - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) - - ui.NL() - ui.Success("The dashboard is accessible here:", dashboardAddress) - ui.Success("The API is accessible here:", apiAddress+"/info") - ui.Success("Port forwarding is started for the test results endpoint, hit Ctrl+c (or Cmd+c) to stop") - - s := <-c - fmt.Println("Got signal:", s) -} - -func readinessCheck(cmd *cobra.Command, apiURI, dashboardURI string) (bool, error) { - const readinessCheckTimeout = 30 * time.Second - insecure, err := strconv.ParseBool(cmd.Flag("insecure").Value.String()) - if err != nil { - return false, fmt.Errorf("parsing flag value %w", err) - } - - client := http.NewClient(insecure) - - ticker := time.NewTicker(readinessCheckTimeout) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - return false, fmt.Errorf("timed-out waiting for dashboard and api") - default: - apiResp, err := client.Get(apiURI + "/info") - if err != nil { - continue - } - dashboardResp, err := client.Get(dashboardURI) - if err != nil { - continue - } - - if apiResp.StatusCode < 400 && dashboardResp.StatusCode < 400 { - return true, nil - } - } - } -} - -func asyncPortForward(namespace, deploymentName string, localPort, clusterPort int) (command *exec.Cmd, err error) { - fullDeploymentName := fmt.Sprintf("deployment/%s", deploymentName) - ports := fmt.Sprintf("%d:%d", localPort, clusterPort) - return process.ExecuteAsync("kubectl", "port-forward", "--namespace", namespace, fullDeploymentName, ports) -} - -func localPortCheck(port int) error { - ln, err := net.Listen("tcp", ":"+fmt.Sprint(port)) - if err != nil { - return err - } - - ln.Close() - return nil -} - -func getDashboardLocalPort(apiServerPort int) (int, error) { - for port := DashboardLocalPort; port <= maxPortNumber; port++ { - if port == apiServerPort { - continue - } - - if localPortCheck(port) == nil { - return port, nil - } - } - - return 0, errors.New("no available local port") -} diff --git a/cmd/kubectl-testkube/commands/pro/disconnect.go b/cmd/kubectl-testkube/commands/pro/disconnect.go index 8d0408af14e..18edf94e614 100644 --- a/cmd/kubectl-testkube/commands/pro/disconnect.go +++ b/cmd/kubectl-testkube/commands/pro/disconnect.go @@ -103,7 +103,6 @@ func NewDisconnectCmd() *cobra.Command { ui.NL() ui.Success("Disconnect finished successfully") ui.NL() - ui.ShellCommand("You can now open your local Dashboard and validate the successfull disconnect", "testkube dashboard") }, } diff --git a/docs/docs/cli/testkube.md b/docs/docs/cli/testkube.md index 6a8ae3ccc98..6383b0ae01f 100644 --- a/docs/docs/cli/testkube.md +++ b/docs/docs/cli/testkube.md @@ -26,7 +26,7 @@ testkube [flags] * [testkube config](testkube_config.md) - Set feature configuration value * [testkube create](testkube_create.md) - Create resource * [testkube create-ticket](testkube_create-ticket.md) - Create bug ticket -* [testkube dashboard](testkube_dashboard.md) - Open testkube dashboard +* [testkube dashboard](testkube_dashboard.md) - Open Testkube Pro/Enterprise dashboard * [testkube debug](testkube_debug.md) - Print environment information for debugging * [testkube delete](testkube_delete.md) - Delete resources * [testkube disable](testkube_disable.md) - Disable feature diff --git a/docs/docs/cli/testkube_dashboard.md b/docs/docs/cli/testkube_dashboard.md index 4104b1dca31..60800d2bae5 100644 --- a/docs/docs/cli/testkube_dashboard.md +++ b/docs/docs/cli/testkube_dashboard.md @@ -1,10 +1,10 @@ ## testkube dashboard -Open testkube dashboard +Open Testkube Pro/Enterprise dashboard ### Synopsis -Open testkube dashboard +Open Testkube Pro/Enterprise dashboard ``` testkube dashboard [flags] @@ -13,14 +13,13 @@ testkube dashboard [flags] ### Options ``` - -h, --help help for dashboard - --use-global-dashboard use global dashboard for viewing testkube results + -h, --help help for dashboard ``` ### Options inherited from parent commands ``` - -a, --api-uri string api uri, default value read from config if set (default "https://demo.testkube.io/results") + -a, --api-uri string api uri, default value read from config if set (default "http://localhost:8088") -c, --client string client used for connecting to Testkube API one of proxy|direct (default "proxy") --insecure insecure connection for direct client --namespace string Kubernetes namespace, default value read from config if set (default "testkube") From 1a7a52e2daa821f8ee0e3810058bc68d8790ea4b Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 14 Mar 2024 08:17:02 +0100 Subject: [PATCH 224/234] fix: include TestWorkflow labels in the /labels list (#5177) --- internal/app/api/v1/labels.go | 52 ++++++++++++++--------------------- internal/app/api/v1/server.go | 11 ++++++++ pkg/tcl/apitcl/v1/server.go | 3 ++ 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/internal/app/api/v1/labels.go b/internal/app/api/v1/labels.go index 10b16f21a9a..6e1d84d3269 100644 --- a/internal/app/api/v1/labels.go +++ b/internal/app/api/v1/labels.go @@ -5,50 +5,38 @@ import ( "net/http" "github.com/gofiber/fiber/v2" - - "github.com/kubeshop/testkube/pkg/data/set" ) func (s TestkubeAPI) ListLabelsHandler() fiber.Handler { return func(c *fiber.Ctx) error { - errPrefix := "failed to list labels" - testSuitesLabels, err := s.TestsSuitesClient.ListLabels() - if err != nil { - return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client failed to list labels for test suites: %w", errPrefix, err)) - } + labels := make(map[string][]string) + sources := append(*s.LabelSources, s.TestsClient, s.TestsSuitesClient) - labels, err := s.TestsClient.ListLabels() - if err != nil { - return s.Error(c, http.StatusBadGateway, fmt.Errorf("%s: client failed to list labels for tests: %w", errPrefix, err)) - } - - for key, testValues := range testSuitesLabels { - if values, ok := labels[key]; !ok { - labels[key] = testValues - } else { - valuesMap := map[string]struct{}{} - for _, v := range values { - valuesMap[v] = struct{}{} - } + for _, source := range sources { + nextLabels, err := source.ListLabels() + if err != nil { + return s.Error(c, http.StatusBadGateway, fmt.Errorf("failed to list labels: %w", err)) + } - testValuesMap := map[string]struct{}{} - for _, v := range testValues { - testValuesMap[v] = struct{}{} - } + for key, testValues := range nextLabels { + if values, ok := labels[key]; !ok { + labels[key] = testValues + } else { + valuesMap := map[string]struct{}{} + for _, v := range values { + valuesMap[v] = struct{}{} + } - for k := range testValuesMap { - if _, ok := valuesMap[k]; !ok { - labels[key] = append(labels[key], k) + for _, label := range testValues { + if _, ok := valuesMap[label]; !ok { + labels[key] = append(labels[key], label) + valuesMap[label] = struct{}{} + } } } } } - // make labels unique - for key, list := range labels { - labels[key] = set.Of(list...).ToArray() - } - return c.JSON(labels) } } diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index e0ffd8ad77c..a8e941b35dd 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -13,6 +13,7 @@ import ( "github.com/pkg/errors" + "github.com/kubeshop/testkube/internal/common" "github.com/kubeshop/testkube/internal/config" "github.com/kubeshop/testkube/pkg/api/v1/testkube" repoConfig "github.com/kubeshop/testkube/pkg/repository/config" @@ -145,6 +146,7 @@ func NewTestkubeAPI( logGrpcClient: logGrpcClient, disableSecretCreation: disableSecretCreation, SubscriptionChecker: subscriptionChecker, + LabelSources: common.Ptr(make([]LabelSource, 0)), } // will be reused in websockets handler @@ -207,6 +209,7 @@ type TestkubeAPI struct { proContext *config.ProContext disableSecretCreation bool SubscriptionChecker checktcl.SubscriptionChecker + LabelSources *[]LabelSource } type storageParams struct { @@ -235,6 +238,14 @@ func (s *TestkubeAPI) WithFeatureFlags(ff featureflags.FeatureFlags) *TestkubeAP return s } +type LabelSource interface { + ListLabels() (map[string][]string, error) +} + +func (s *TestkubeAPI) WithLabelSources(l ...LabelSource) { + *s.LabelSources = append(*s.LabelSources, l...) +} + // SendTelemetryStartEvent sends anonymous start event to telemetry trackers func (s TestkubeAPI) SendTelemetryStartEvent(ctx context.Context, ch chan struct{}) { go func() { diff --git a/pkg/tcl/apitcl/v1/server.go b/pkg/tcl/apitcl/v1/server.go index ba62f3888ad..d92de952c73 100644 --- a/pkg/tcl/apitcl/v1/server.go +++ b/pkg/tcl/apitcl/v1/server.go @@ -97,6 +97,9 @@ func (s *apiTCL) ClientError(c *fiber.Ctx, prefix string, err error) error { func (s *apiTCL) AppendRoutes() { root := s.Routes + // Register TestWorkflows as additional source for labels + s.WithLabelSources(s.TestWorkflowsClient, s.TestWorkflowTemplatesClient) + testWorkflows := root.Group("/test-workflows") testWorkflows.Get("/", s.pro(s.ListTestWorkflowsHandler())) testWorkflows.Post("/", s.pro(s.CreateTestWorkflowHandler())) From 49b629cd601dad98cec1ceaad85c671e405d70f3 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 14 Mar 2024 08:32:45 +0100 Subject: [PATCH 225/234] fix: gracefully handle critical pod errors (like OOM) in TestWorkflows (#5178) * fix: gracefully handle critical pod errors (like OOM) in TestWorkflows * chore: add error logs on error saving TestWorkflow result --- .../testworkflowexecutor/executor.go | 61 +++++++++++++------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go index 2ed440d0105..121d0b5d125 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go +++ b/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.go @@ -60,7 +60,7 @@ func (e *executor) Schedule(bundle *testworkflowprocessor.Bundle, execution test // Deploy required resources err := e.Deploy(context.Background(), bundle) if err != nil { - e.handleFatalError(execution, err) + e.handleFatalError(execution, err, time.Time{}) return } @@ -85,15 +85,17 @@ func (e *executor) Deploy(ctx context.Context, bundle *testworkflowprocessor.Bun return } -func (e *executor) handleFatalError(execution testkube.TestWorkflowExecution, err error) { +func (e *executor) handleFatalError(execution testkube.TestWorkflowExecution, err error, ts time.Time) { // Detect error type isAborted := errors.Is(err, testworkflowcontroller.ErrJobAborted) isTimeout := errors.Is(err, testworkflowcontroller.ErrJobTimeout) // Build error timestamp, adjusting it for aborting job - ts := time.Now() - if isAborted || isTimeout { - ts = ts.Truncate(testworkflowcontroller.JobRetrievalTimeout) + if ts.IsZero() { + ts = time.Now() + if isAborted || isTimeout { + ts = ts.Truncate(testworkflowcontroller.JobRetrievalTimeout) + } } // Apply the expected result @@ -119,7 +121,7 @@ func (e *executor) Recover(ctx context.Context) { func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowExecution) { ctrl, err := testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) if err != nil { - e.handleFatalError(execution, err) + e.handleFatalError(execution, err, time.Time{}) return } @@ -144,7 +146,10 @@ func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowE if execution.Result.IsFinished() { execution.StatusAt = execution.Result.FinishedAt } - _ = e.repository.UpdateResult(ctx, execution.Id, execution.Result) + err := e.repository.UpdateResult(ctx, execution.Id, execution.Result) + if err != nil { + log.DefaultLogger.Error(errors.Wrap(err, "error saving test workflow execution result")) + } } else { if ref != v.Value.Ref { ref = v.Value.Ref @@ -162,21 +167,37 @@ func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowE // Try to gracefully handle abort if execution.Result.FinishedAt.IsZero() { - ctrl, err = testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) - if err == nil { - for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { - if v.Error != nil || v.Value.Output == nil { - continue - } - - execution.Result = v.Value.Result - if execution.Result.IsFinished() { - execution.StatusAt = execution.Result.FinishedAt - } - _ = e.repository.UpdateResult(ctx, execution.Id, execution.Result) + // Handle container failure + abortedAt := time.Time{} + for _, v := range execution.Result.Steps { + if v.Status != nil && *v.Status == testkube.ABORTED_TestWorkflowStepStatus { + abortedAt = v.FinishedAt + break } + } + if !abortedAt.IsZero() { + e.handleFatalError(execution, testworkflowcontroller.ErrJobAborted, abortedAt) } else { - e.handleFatalError(execution, err) + // Handle unknown state + ctrl, err = testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) + if err == nil { + for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { + if v.Error != nil || v.Value.Output == nil { + continue + } + + execution.Result = v.Value.Result + if execution.Result.IsFinished() { + execution.StatusAt = execution.Result.FinishedAt + } + err := e.repository.UpdateResult(ctx, execution.Id, execution.Result) + if err != nil { + log.DefaultLogger.Error(errors.Wrap(err, "error saving test workflow execution result")) + } + } + } else { + e.handleFatalError(execution, err, time.Time{}) + } } } From a1e46f4e3a034915a6688c8a90c099ce19914971 Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 14 Mar 2024 09:01:48 +0100 Subject: [PATCH 226/234] fix: use proper application version constant (#5181) --- .../testworkflowprocessor/constants.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go index cc3f68061b6..d3cddeb8226 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/constants.go @@ -16,7 +16,7 @@ import ( corev1 "k8s.io/api/core/v1" testworkflowsv1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" - "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" + "github.com/kubeshop/testkube/pkg/version" ) const ( @@ -49,11 +49,11 @@ var ( func getInitImage() string { img := os.Getenv("TESTKUBE_TW_INIT_IMAGE") if img == "" { - version := common.Version - if version == "" || version == "dev" { - version = "latest" + ver := version.Version + if ver == "" || ver == "dev" { + ver = "latest" } - img = fmt.Sprintf("kubeshop/testkube-tw-init:%s", version) + img = fmt.Sprintf("kubeshop/testkube-tw-init:%s", ver) } return img } @@ -61,11 +61,11 @@ func getInitImage() string { func getToolkitImage() string { img := os.Getenv("TESTKUBE_TW_TOOLKIT_IMAGE") if img == "" { - version := common.Version - if version == "" || version == "dev" { - version = "latest" + ver := version.Version + if ver == "" || ver == "dev" { + ver = "latest" } - img = fmt.Sprintf("kubeshop/testkube-tw-toolkit:%s", version) + img = fmt.Sprintf("kubeshop/testkube-tw-toolkit:%s", ver) } return img } From 75727ced4a1566c7ce92096c5cf75af558bdcade Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 14 Mar 2024 09:02:22 +0100 Subject: [PATCH 227/234] fix: add `workingDir` support for TestWorkflow artifacts step (#5180) --- api/v1/testkube.yaml | 2 ++ go.mod | 2 +- go.sum | 2 ++ pkg/api/v1/testkube/model_test_workflow_step_artifacts.go | 3 ++- pkg/tcl/mapperstcl/testworkflows/kube_openapi.go | 5 +++-- pkg/tcl/mapperstcl/testworkflows/openapi_kube.go | 5 +++-- pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go | 3 ++- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 26aa3631c74..37fc6c51ebc 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -7965,6 +7965,8 @@ components: TestWorkflowStepArtifacts: type: object properties: + workingDir: + $ref: "#/components/schemas/BoxedString" compress: $ref: "#/components/schemas/TestWorkflowStepArtifactsCompression" paths: diff --git a/go.mod b/go.mod index 8f00712ac68..6d36ecc5114 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kelseyhightower/envconfig v1.4.0 github.com/kubepug/kubepug v1.7.1 - github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312153624-07cd5e8ce0be + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240314074148-03a6d2dd1f3b github.com/minio/minio-go/v7 v7.0.47 github.com/montanaflynn/stats v0.6.6 github.com/moogar0880/problems v0.1.1 diff --git a/go.sum b/go.sum index 157b1739248..af6a132170e 100644 --- a/go.sum +++ b/go.sum @@ -362,6 +362,8 @@ github.com/kubepug/kubepug v1.7.1 h1:LKhfSxS8Y5mXs50v+3Lpyec+cogErDLcV7CMUuiaisw github.com/kubepug/kubepug v1.7.1/go.mod h1:lv+HxD0oTFL7ZWjj0u6HKhMbbTIId3eG7aWIW0gyF8g= github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312153624-07cd5e8ce0be h1:cs5m8bekmvEcyvFT37KgUEduv6XrdUfmX5WqZAbLUCY= github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240312153624-07cd5e8ce0be/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240314074148-03a6d2dd1f3b h1:O41BWxgeUQJaBz4bcDlQhvHhiWLHPvsTxtvIKmwxjOs= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240314074148-03a6d2dd1f3b/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= diff --git a/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go b/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go index c4843d57516..4897d0cae0a 100644 --- a/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go +++ b/pkg/api/v1/testkube/model_test_workflow_step_artifacts.go @@ -10,7 +10,8 @@ package testkube type TestWorkflowStepArtifacts struct { - Compress *TestWorkflowStepArtifactsCompression `json:"compress,omitempty"` + WorkingDir *BoxedString `json:"workingDir,omitempty"` + Compress *TestWorkflowStepArtifactsCompression `json:"compress,omitempty"` // file paths to fetch from the container Paths []string `json:"paths"` } diff --git a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go index 1e164db8993..04b66b7e302 100644 --- a/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go +++ b/pkg/tcl/mapperstcl/testworkflows/kube_openapi.go @@ -493,8 +493,9 @@ func MapStepArtifactsCompressionKubeToAPI(v testworkflowsv1.ArtifactCompression) func MapStepArtifactsKubeToAPI(v testworkflowsv1.StepArtifacts) testkube.TestWorkflowStepArtifacts { return testkube.TestWorkflowStepArtifacts{ - Compress: common.MapPtr(v.Compress, MapStepArtifactsCompressionKubeToAPI), - Paths: v.Paths, + WorkingDir: MapStringToBoxedString(v.WorkingDir), + Compress: common.MapPtr(v.Compress, MapStepArtifactsCompressionKubeToAPI), + Paths: v.Paths, } } diff --git a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go index 40876caf7ca..ed9cdf07607 100644 --- a/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go +++ b/pkg/tcl/mapperstcl/testworkflows/openapi_kube.go @@ -510,8 +510,9 @@ func MapStepArtifactsCompressionAPIToKube(v testkube.TestWorkflowStepArtifactsCo func MapStepArtifactsAPIToKube(v testkube.TestWorkflowStepArtifacts) testworkflowsv1.StepArtifacts { return testworkflowsv1.StepArtifacts{ - Compress: common.MapPtr(v.Compress, MapStepArtifactsCompressionAPIToKube), - Paths: v.Paths, + WorkingDir: MapBoxedStringToString(v.WorkingDir), + Compress: common.MapPtr(v.Compress, MapStepArtifactsCompressionAPIToKube), + Paths: v.Paths, } } diff --git a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go index 4d353beed43..4724eb79959 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go +++ b/pkg/tcl/testworkflowstcl/testworkflowprocessor/operations.go @@ -280,7 +280,8 @@ func ProcessArtifacts(_ InternalProcessor, layer Intermediate, container Contain return nil, errors.New("there needs to be at least one path to scrap for artifacts") } - selfContainer := container.CreateChild() + selfContainer := container.CreateChild(). + ApplyCR(&testworkflowsv1.ContainerConfig{WorkingDir: step.Artifacts.WorkingDir}) stage := NewContainerStage(layer.NextRef(), selfContainer) stage.SetCondition("always") stage.SetCategory("Upload artifacts") From 3f3d14225e3e768c6978f30aeb6e2b512b6cc496 Mon Sep 17 00:00:00 2001 From: nicufk Date: Thu, 14 Mar 2024 05:52:36 -0300 Subject: [PATCH 228/234] fix: container executor negative test (#5175) * fix: container executor negative test * fix: minor improvement --- .../containerexecutor/containerexecutor.go | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/pkg/executor/containerexecutor/containerexecutor.go b/pkg/executor/containerexecutor/containerexecutor.go index f2eaeb8531d..f7d8b0185b7 100644 --- a/pkg/executor/containerexecutor/containerexecutor.go +++ b/pkg/executor/containerexecutor/containerexecutor.go @@ -270,12 +270,12 @@ func (c *ContainerExecutor) Execute(ctx context.Context, execution *testkube.Exe for _, pod := range pods.Items { if pod.Status.Phase != corev1.PodRunning && pod.Labels["job-name"] == execution.Id { if options.Sync { - return c.updateResultsFromPod(ctx, pod, l, execution, jobOptions) + return c.updateResultsFromPod(ctx, pod, l, execution, jobOptions, options.Request.NegativeTest) } // async wait for complete status or error go func(pod corev1.Pod) { - _, err := c.updateResultsFromPod(ctx, pod, l, execution, jobOptions) + _, err := c.updateResultsFromPod(ctx, pod, l, execution, jobOptions, options.Request.NegativeTest) if err != nil { l.Errorw("update results from jobs pod error", "error", err) } @@ -342,11 +342,12 @@ func (c *ContainerExecutor) updateResultsFromPod( l *zap.SugaredLogger, execution *testkube.Execution, jobOptions *JobOptions, + isNegativeTest bool, ) (*testkube.ExecutionResult, error) { var err error // save stop time and final state - defer c.stopExecution(ctx, execution, execution.ExecutionResult) + defer c.stopExecution(ctx, execution, execution.ExecutionResult, isNegativeTest) // wait for pod l.Debug("poll immediate waiting for executor pod") @@ -492,9 +493,33 @@ func (c *ContainerExecutor) updateResultsFromPod( return execution.ExecutionResult, nil } -func (c *ContainerExecutor) stopExecution(ctx context.Context, execution *testkube.Execution, result *testkube.ExecutionResult) { - c.log.Debug("stopping execution") +func (c *ContainerExecutor) stopExecution(ctx context.Context, + execution *testkube.Execution, + result *testkube.ExecutionResult, + isNegativeTest bool, +) { + c.log.Debugw("stopping execution", "isNegativeTest", isNegativeTest, "test", execution.TestName) execution.Stop() + + if isNegativeTest { + if result.IsFailed() { + c.log.Debugw("test run was expected to fail, and it failed as expected", "test", execution.TestName) + execution.ExecutionResult.Status = testkube.ExecutionStatusPassed + result.Status = testkube.ExecutionStatusPassed + result.Output = result.Output + "\nTest run was expected to fail, and it failed as expected" + } else { + c.log.Debugw("test run was expected to fail - the result will be reversed", "test", execution.TestName) + execution.ExecutionResult.Status = testkube.ExecutionStatusFailed + result.Status = testkube.ExecutionStatusFailed + result.Output = result.Output + "\nTest run was expected to fail, the result will be reversed" + } + + err := c.repository.UpdateResult(ctx, execution.Id, *execution) + if err != nil { + c.log.Errorw("Update execution result error", "error", err) + } + } + err := c.repository.EndExecution(ctx, *execution) if err != nil { c.log.Errorw("Update execution result error", "error", err) From b415cb6cb7deca582ac7b94e64352fb69d68d15f Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 14 Mar 2024 12:01:58 +0100 Subject: [PATCH 229/234] fix: handle gracefully OOMKilled errors on GKE (#5190) - for some reason (bug?) GKE sends the SIGKILL when the container is already done successfully, and the Pod becomes stuck --- .../testworkflowcontroller/controller.go | 10 ++++++++++ .../testworkflowstcl/testworkflowcontroller/logs.go | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go index a46e2c38208..2d33cfa0dcc 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -372,6 +372,16 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { // Update the last timestamp lastTs = finishedAt + + // Break the function if the step has been aborted. + // Breaking only to the loop is not enough, + // because due to GKE bug, the Job is still pending, + // so it will get stuck there. + if status.Status == testkube.ABORTED_TestWorkflowStepStatus { + result.Recompute(sig, c.scheduledAt) + w.SendValue(Notification{Result: result.Clone()}) + return + } } // Read the pod finish time diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go index 6910555a65f..317d20eabdd 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go @@ -90,6 +90,13 @@ func GetContainerResult(c corev1.ContainerStatus) ContainerResult { return ContainerResult{Status: testkube.RUNNING_TestWorkflowStepStatus, ExitCode: -1} } re := regexp.MustCompile(`^([^,]*),(0|[1-9]\d*)$`) + + // Workaround - GKE sends SIGKILL after the container is already terminated, + // and the pod gets stuck then. + if c.State.Terminated.Reason != "Completed" { + return ContainerResult{Status: testkube.ABORTED_TestWorkflowStepStatus, ExitCode: -1, FinishedAt: c.State.Terminated.FinishedAt.Time} + } + msg := c.State.Terminated.Message match := re.FindStringSubmatch(msg) if match == nil { From 9a6bdc2208a7e36bc3665a8f3c5aefa87c5bf4e0 Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 14 Mar 2024 13:36:38 +0300 Subject: [PATCH 230/234] fix: git fetcher tests --- pkg/executor/content/fetcher_test.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/pkg/executor/content/fetcher_test.go b/pkg/executor/content/fetcher_test.go index 2dbceb20439..6227e53d07f 100644 --- a/pkg/executor/content/fetcher_test.go +++ b/pkg/executor/content/fetcher_test.go @@ -47,8 +47,8 @@ func TestFetcher_Integration(t *testing.T) { content := &testkube.TestContent{ Type_: string(testkube.TestContentTypeGitFile), - Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-examples.git", "main"). - WithPath("example.json"), + Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-docker-action.git", "main"). + WithPath("action.yaml"), } path, err := f.Fetch(content) @@ -65,15 +65,15 @@ func TestFetcher_Integration(t *testing.T) { content := &testkube.TestContent{ Type_: string(testkube.TestContentTypeGitDir), - Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-examples.git", "main"). + Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-docker-action.git", "main"). WithPath(""), } path, err := f.Fetch(content) assert.NoError(t, err) - assert.FileExists(t, filepath.Join(path, "example.json")) + assert.FileExists(t, filepath.Join(path, "action.yaml")) assert.FileExists(t, filepath.Join(path, "README.md")) - assert.FileExists(t, filepath.Join(path, "subdir/example.json")) + assert.FileExists(t, filepath.Join(path, ".github/update-major-version.yml")) }) @@ -82,14 +82,13 @@ func TestFetcher_Integration(t *testing.T) { content := &testkube.TestContent{ Type_: string(testkube.TestContentTypeGitDir), - Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-examples.git", "main"). - WithPath("subdir"), + Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-docker-action.git", "main"). + WithPath(".github"), } path, err := f.Fetch(content) assert.NoError(t, err) - assert.FileExists(t, filepath.Join(path, "example.json")) - assert.FileExists(t, filepath.Join(path, "example2.json")) + assert.FileExists(t, filepath.Join(path, "update-major-version.yml")) }) t.Run("test fetch no content", func(t *testing.T) { @@ -110,7 +109,7 @@ func TestFetcher_Integration(t *testing.T) { t.Run("with file", func(t *testing.T) { t.Parallel() - repo := testkube.NewGitRepository("https://github.com/kubeshop/testkube-examples.git", "main").WithPath("example.json") + repo := testkube.NewGitRepository("https://github.com/kubeshop/testkube-docker-action.git", "main").WithPath("action.yaml") contentType, err := f.CalculateGitContentType(*repo) assert.NoError(t, err) @@ -120,7 +119,7 @@ func TestFetcher_Integration(t *testing.T) { t.Run("with dir", func(t *testing.T) { t.Parallel() - repo := testkube.NewGitRepository("https://github.com/kubeshop/testkube-examples.git", "main").WithPath("subdir") + repo := testkube.NewGitRepository("https://github.com/kubeshop/testkube-docker-action.git", "main").WithPath(".github") contentType, err := f.CalculateGitContentType(*repo) assert.NoError(t, err) From ae2b596fd8fa03a10c5debdf814f59c4d566dd3b Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 14 Mar 2024 13:51:45 +0300 Subject: [PATCH 231/234] fix: change test content --- pkg/executor/content/fetcher_test.go | 50 ++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/pkg/executor/content/fetcher_test.go b/pkg/executor/content/fetcher_test.go index 6227e53d07f..a177bf476ad 100644 --- a/pkg/executor/content/fetcher_test.go +++ b/pkg/executor/content/fetcher_test.go @@ -14,10 +14,54 @@ import ( ) // this content is also saved in test repo -// in https://github.com/kubeshop/testkube-examples/blob/main/example.json +// in https:///github.com/kubeshop/testkube-docker-action/blob/main/action.yaml // file with \n on end -const fileContent = `{"some":"json","file":"with content"} -` +const fileContent = `action.yml +name: 'Testkube CLI' +description: 'Execute Testkube command' +inputs: + command: + description: 'Command' + required: true + default: 'get' + resource: + description: 'Resource' + required: false + default: 'tests' + namespace: + description: 'Namespace' + required: false + default: 'testkube' + api-key: + description: 'API key' + required: false + default: '' + api-uri: + description: 'API uri' + required: false + default: '' + parameters: + description: 'Parameters' + required: false + default: '' + stdin: + description: 'Standard input' + required: false + default: '' +outputs: + result: + description: 'The result of the command' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.command }} + - ${{ inputs.resource }} + - ${{ inputs.namespace }} + - ${{ inputs.api-key }} + - ${{ inputs.api-uri }} + - ${{ inputs.parameters }} + - ${{ inputs.stdin }}` func TestFetcher_Integration(t *testing.T) { test.IntegrationTest(t) From 16614c57c286be4b703db815903ad07b1cce713e Mon Sep 17 00:00:00 2001 From: Vladislav Sukhin Date: Thu, 14 Mar 2024 14:04:27 +0300 Subject: [PATCH 232/234] fix: test file context --- pkg/executor/content/fetcher_test.go | 70 +++++++++------------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/pkg/executor/content/fetcher_test.go b/pkg/executor/content/fetcher_test.go index a177bf476ad..68b9bbaa28b 100644 --- a/pkg/executor/content/fetcher_test.go +++ b/pkg/executor/content/fetcher_test.go @@ -16,52 +16,28 @@ import ( // this content is also saved in test repo // in https:///github.com/kubeshop/testkube-docker-action/blob/main/action.yaml // file with \n on end -const fileContent = `action.yml -name: 'Testkube CLI' -description: 'Execute Testkube command' -inputs: - command: - description: 'Command' - required: true - default: 'get' - resource: - description: 'Resource' - required: false - default: 'tests' - namespace: - description: 'Namespace' - required: false - default: 'testkube' - api-key: - description: 'API key' - required: false - default: '' - api-uri: - description: 'API uri' - required: false - default: '' - parameters: - description: 'Parameters' - required: false - default: '' - stdin: - description: 'Standard input' - required: false - default: '' -outputs: - result: - description: 'The result of the command' -runs: - using: 'docker' - image: 'Dockerfile' - args: - - ${{ inputs.command }} - - ${{ inputs.resource }} - - ${{ inputs.namespace }} - - ${{ inputs.api-key }} - - ${{ inputs.api-uri }} - - ${{ inputs.parameters }} - - ${{ inputs.stdin }}` +const fileContent = `MIT License + +Copyright (c) 2022 kubeshop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +` func TestFetcher_Integration(t *testing.T) { test.IntegrationTest(t) @@ -92,7 +68,7 @@ func TestFetcher_Integration(t *testing.T) { content := &testkube.TestContent{ Type_: string(testkube.TestContentTypeGitFile), Repository: testkube.NewGitRepository("https://github.com/kubeshop/testkube-docker-action.git", "main"). - WithPath("action.yaml"), + WithPath("LICENSE"), } path, err := f.Fetch(content) From 7a1519c6fbfe96f817296e98d8fe97dcaa607ffc Mon Sep 17 00:00:00 2001 From: Dawid Rusnak Date: Thu, 14 Mar 2024 12:37:16 +0100 Subject: [PATCH 233/234] feat: display abort reason for TestWorkflow execution (#5191) --- .../testworkflowcontroller/controller.go | 10 ++++++++++ .../testworkflowstcl/testworkflowcontroller/logs.go | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go index 2d33cfa0dcc..84e0b3ed92d 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/controller.go @@ -379,6 +379,16 @@ func (c *controller) Watch(parentCtx context.Context) Watcher[Notification] { // so it will get stuck there. if status.Status == testkube.ABORTED_TestWorkflowStepStatus { result.Recompute(sig, c.scheduledAt) + abortTs := result.Steps[container.Name].FinishedAt + if status.Details == "" { + status.Details = "Manual" + } + + w.SendValue(Notification{ + Timestamp: abortTs, + Ref: container.Name, + Log: fmt.Sprintf("\n%s Aborted (%s)", abortTs.Format(KubernetesLogTimeFormat), status.Details), + }) w.SendValue(Notification{Result: result.Clone()}) return } diff --git a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go index 317d20eabdd..839441d80d5 100644 --- a/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go +++ b/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go @@ -73,6 +73,7 @@ type ContainerLog struct { type ContainerResult struct { Status testkube.TestWorkflowStepStatus + Details string ExitCode int FinishedAt time.Time } @@ -94,7 +95,7 @@ func GetContainerResult(c corev1.ContainerStatus) ContainerResult { // Workaround - GKE sends SIGKILL after the container is already terminated, // and the pod gets stuck then. if c.State.Terminated.Reason != "Completed" { - return ContainerResult{Status: testkube.ABORTED_TestWorkflowStepStatus, ExitCode: -1, FinishedAt: c.State.Terminated.FinishedAt.Time} + return ContainerResult{Status: testkube.ABORTED_TestWorkflowStepStatus, Details: c.State.Terminated.Reason, ExitCode: -1, FinishedAt: c.State.Terminated.FinishedAt.Time} } msg := c.State.Terminated.Message From 2c0da5921774be11bbaff2d853735fc55df95667 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Thu, 14 Mar 2024 12:44:15 +0100 Subject: [PATCH 234/234] feat: [TKC-1740] remove dashboard from connect and disconnect commands (#5188) * feat: remove dashboard from connect and disconnect commands * feat: pro disconnect fixes * feat: update docs; remove helm params from installation --- README.md | 1 - .../commands/common/helper.go | 10 +++----- .../commands/common/validator/cloudcontext.go | 4 +-- cmd/kubectl-testkube/commands/pro/connect.go | 6 ----- .../commands/pro/disconnect.go | 25 +++++++++++-------- cmd/kubectl-testkube/commands/pro/init.go | 5 ++-- docs/docs/articles/testkube-oss.md | 1 - docs/docs/cli/testkube_init.md | 1 - docs/docs/cli/testkube_install.md | 1 - docs/docs/cli/testkube_pro_connect.md | 1 - 10 files changed, 22 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0e4ae73d365..ade5b2914df 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,6 @@ Main Testkube components are: - [Ginkgo](https://docs.testkube.io/test-types/executor-ginkgo/) - Runs tests written in Go using Ginkgo ([@jdborneman-terminus](https://github.com/jdborneman-terminus)) - [Executor Template](https://github.com/kubeshop/testkube-executor-template) - for creating your own executors - Results DB - for centralized test results aggregation and analysis -- [Testkube Dashboard](https://github.com/kubeshop/testkube-dashboard) - standalone web application for viewing real-time Testkube test results ## Getting Started diff --git a/cmd/kubectl-testkube/commands/common/helper.go b/cmd/kubectl-testkube/commands/common/helper.go index f3540d3b4a6..04f5178f8cd 100644 --- a/cmd/kubectl-testkube/commands/common/helper.go +++ b/cmd/kubectl-testkube/commands/common/helper.go @@ -21,9 +21,9 @@ import ( ) type HelmOptions struct { - Name, Namespace, Chart, Values string - NoDashboard, NoMinio, NoMongo, NoConfirm bool - MinioReplicas, MongoReplicas, DashboardReplicas int + Name, Namespace, Chart, Values string + NoMinio, NoMongo, NoConfirm bool + MinioReplicas, MongoReplicas int Master config.Master // For debug @@ -101,13 +101,11 @@ func HelmUpgradeOrInstallTestkubeCloud(options HelmOptions, cfg config.Data, isM args = append(args, "--set", fmt.Sprintf("testkube-api.multinamespace.enabled=%t", options.MultiNamespace)) args = append(args, "--set", fmt.Sprintf("testkube-operator.enabled=%t", !options.NoOperator)) - args = append(args, "--set", fmt.Sprintf("testkube-dashboard.enabled=%t", !options.NoDashboard)) args = append(args, "--set", fmt.Sprintf("mongodb.enabled=%t", !options.NoMongo)) args = append(args, "--set", fmt.Sprintf("testkube-api.minio.enabled=%t", !options.NoMinio)) args = append(args, "--set", fmt.Sprintf("testkube-api.minio.replicas=%d", options.MinioReplicas)) args = append(args, "--set", fmt.Sprintf("mongodb.replicas=%d", options.MongoReplicas)) - args = append(args, "--set", fmt.Sprintf("testkube-dashboard.replicas=%d", options.DashboardReplicas)) args = append(args, options.Name, options.Chart) @@ -147,7 +145,6 @@ func HelmUpgradeOrInstalTestkube(options HelmOptions) error { args = []string{"upgrade", "--install", "--create-namespace", "--namespace", options.Namespace} args = append(args, "--set", fmt.Sprintf("testkube-api.multinamespace.enabled=%t", options.MultiNamespace)) args = append(args, "--set", fmt.Sprintf("testkube-operator.enabled=%t", !options.NoOperator)) - args = append(args, "--set", fmt.Sprintf("testkube-dashboard.enabled=%t", !options.NoDashboard)) args = append(args, "--set", fmt.Sprintf("mongodb.enabled=%t", !options.NoMongo)) args = append(args, "--set", fmt.Sprintf("testkube-api.minio.enabled=%t", !options.NoMinio)) if options.NoMinio { @@ -178,7 +175,6 @@ func PopulateHelmFlags(cmd *cobra.Command, options *HelmOptions) { cmd.Flags().StringVar(&options.Values, "values", "", "path to Helm values file") cmd.Flags().BoolVar(&options.NoMinio, "no-minio", false, "don't install MinIO") - cmd.Flags().BoolVar(&options.NoDashboard, "no-dashboard", false, "don't install dashboard") cmd.Flags().BoolVar(&options.NoMongo, "no-mongo", false, "don't install MongoDB") cmd.Flags().BoolVar(&options.NoConfirm, "no-confirm", false, "don't ask for confirmation - unatended installation mode") cmd.Flags().BoolVar(&options.DryRun, "dry-run", false, "dry run mode - only print commands that would be executed") diff --git a/cmd/kubectl-testkube/commands/common/validator/cloudcontext.go b/cmd/kubectl-testkube/commands/common/validator/cloudcontext.go index 558cba14230..e4c85cd3d0f 100644 --- a/cmd/kubectl-testkube/commands/common/validator/cloudcontext.go +++ b/cmd/kubectl-testkube/commands/common/validator/cloudcontext.go @@ -12,11 +12,11 @@ func ValidateCloudContext(cfg config.Data) error { } if cfg.CloudContext.ApiUri == "" { - return errors.New("please provide Testkube Cloud URI") + return errors.New("please provide Testkube Pro URI") } if cfg.CloudContext.ApiKey == "" { - return errors.New("please provide Testkube Cloud API token") + return errors.New("please provide Testkube Pro API token") } if cfg.CloudContext.EnvironmentId == "" { diff --git a/cmd/kubectl-testkube/commands/pro/connect.go b/cmd/kubectl-testkube/commands/pro/connect.go index dab60d7ff13..fb37a5d5eaf 100644 --- a/cmd/kubectl-testkube/commands/pro/connect.go +++ b/cmd/kubectl-testkube/commands/pro/connect.go @@ -153,11 +153,6 @@ func NewConnectCmd() *cobra.Command { common.KubectlScaleDeployment(opts.Namespace, "testkube-minio-testkube", opts.MinioReplicas) spinner.Success() } - if opts.DashboardReplicas == 0 { - spinner = ui.NewSpinner("Scaling down Dashbaord") - common.KubectlScaleDeployment(opts.Namespace, "testkube-dashboard", opts.DashboardReplicas) - spinner.Success() - } ui.H2("Testkube Pro is connected to your Testkube instance, saving local configuration") @@ -187,7 +182,6 @@ func NewConnectCmd() *cobra.Command { cmd.Flags().IntVar(&opts.MinioReplicas, "minio-replicas", 0, "MinIO replicas") cmd.Flags().IntVar(&opts.MongoReplicas, "mongo-replicas", 0, "MongoDB replicas") - cmd.Flags().IntVar(&opts.DashboardReplicas, "dashboard-replicas", 0, "Dashboard replicas") return cmd } diff --git a/cmd/kubectl-testkube/commands/pro/disconnect.go b/cmd/kubectl-testkube/commands/pro/disconnect.go index 18edf94e614..07510b21990 100644 --- a/cmd/kubectl-testkube/commands/pro/disconnect.go +++ b/cmd/kubectl-testkube/commands/pro/disconnect.go @@ -1,6 +1,7 @@ package pro import ( + "fmt" "strings" "github.com/pterm/pterm" @@ -23,7 +24,7 @@ func NewDisconnectCmd() *cobra.Command { Run: func(cmd *cobra.Command, args []string) { ui.H1("Disconnecting your Pro environment:") - ui.Paragraph("Rolling back to your clusters testkube OSS installation") + ui.Paragraph("Rolling back to your clusters Testkube OSS installation") ui.Paragraph("If you need more details click into following link: " + docsUrl) ui.H2("You can safely switch between connecting Pro and disconnecting without losing your data.") @@ -39,7 +40,7 @@ func NewDisconnectCmd() *cobra.Command { info, err := client.GetServerInfo() firstInstall := err != nil && strings.Contains(err.Error(), "not found") if err != nil && !firstInstall { - ui.Failf("Can't get testkube cluster information: %s", err.Error()) + ui.Failf("Can't get Testkube cluster information: %s", err.Error()) } var apiContext string if actx, ok := contextDescription[info.Context]; ok { @@ -49,7 +50,7 @@ func NewDisconnectCmd() *cobra.Command { if cfg.ContextType == config.ContextTypeKubeconfig { clusterContext, err = common.GetCurrentKubernetesContext() if err != nil { - pterm.Error.Printfln("Failed to get current kubernetes context: %s", err.Error()) + pterm.Error.Printfln("Failed to get current Kubernetes context: %s", err.Error()) return } } @@ -65,8 +66,8 @@ func NewDisconnectCmd() *cobra.Command { {ui.Separator, ""}, {"Testkube is connected to Pro organizations environment"}, - {"Organization Id", info.OrgId}, - {"Environment Id", info.EnvId}, + {"Organization Id", cfg.CloudContext.OrganizationId}, + {"Environment Id", cfg.CloudContext.EnvironmentId}, } ui.Properties(summary) @@ -77,7 +78,7 @@ func NewDisconnectCmd() *cobra.Command { ui.NL(2) - spinner := ui.NewSpinner("Disonnecting from Testkube Pro") + spinner := ui.NewSpinner("Disconnecting from Testkube Pro") err = common.HelmUpgradeOrInstalTestkube(opts) ui.ExitOnError("Installing Testkube Pro", err) @@ -94,9 +95,14 @@ func NewDisconnectCmd() *cobra.Command { common.KubectlScaleDeployment(opts.Namespace, "testkube-minio-testkube", opts.MinioReplicas) spinner.Success() } - if opts.DashboardReplicas > 0 { - spinner = ui.NewSpinner("Scaling up Dashbaord") - common.KubectlScaleDeployment(opts.Namespace, "testkube-dashboard", opts.DashboardReplicas) + + spinner = ui.NewSpinner("Resetting Testkube config.json") + cfg.ContextType = config.ContextTypeKubeconfig + cfg.CloudContext = config.CloudContext{} + if err = config.Save(cfg); err != nil { + spinner.Fail(fmt.Sprintf("Error updating local Testkube config file: %s", err)) + ui.Warn("Please manually remove the fields contextType and cloudContext from your config file.") + } else { spinner.Success() } @@ -110,6 +116,5 @@ func NewDisconnectCmd() *cobra.Command { common.PopulateHelmFlags(cmd, &opts) cmd.Flags().IntVar(&opts.MinioReplicas, "minio-replicas", 1, "MinIO replicas") cmd.Flags().IntVar(&opts.MongoReplicas, "mongo-replicas", 1, "MongoDB replicas") - cmd.Flags().IntVar(&opts.DashboardReplicas, "dashboard-replicas", 1, "Dashboard replicas") return cmd } diff --git a/cmd/kubectl-testkube/commands/pro/init.go b/cmd/kubectl-testkube/commands/pro/init.go index f4b5dfdf774..7328b6b4468 100644 --- a/cmd/kubectl-testkube/commands/pro/init.go +++ b/cmd/kubectl-testkube/commands/pro/init.go @@ -13,9 +13,8 @@ import ( func NewInitCmd() *cobra.Command { options := common.HelmOptions{ - NoMinio: true, - NoMongo: true, - NoDashboard: true, + NoMinio: true, + NoMongo: true, } cmd := &cobra.Command{ diff --git a/docs/docs/articles/testkube-oss.md b/docs/docs/articles/testkube-oss.md index 00d0d3d1ac9..2ea19ae3515 100644 --- a/docs/docs/articles/testkube-oss.md +++ b/docs/docs/articles/testkube-oss.md @@ -25,7 +25,6 @@ This command will set up the following components in your Kubernetes cluster: - Create a Testkube namespace. - Deploy the Testkube API. - Use MongoDB for test results and Minio for artifact storage (optional; disable with --no-minio). -- Testkube Dashboard to visually and manage all your tests (optional; disable with --no-dashboard flag). - Testkube will listen and manage all the CRDs for Tests, TestSuites, Executors, etc… inside the Testkube namespace. diff --git a/docs/docs/cli/testkube_init.md b/docs/docs/cli/testkube_init.md index f7a562d83cd..597ddbabcbf 100644 --- a/docs/docs/cli/testkube_init.md +++ b/docs/docs/cli/testkube_init.md @@ -21,7 +21,6 @@ testkube init [flags] --name string installation name (usually you don't need to change it) (default "testkube") --namespace string namespace where to install (default "testkube") --no-confirm don't ask for confirmation - unatended installation mode - --no-dashboard don't install dashboard --no-minio don't install MinIO --no-mongo don't install MongoDB --org-id string Testkube Cloud organization id [required for centralized mode] diff --git a/docs/docs/cli/testkube_install.md b/docs/docs/cli/testkube_install.md index b51e91b60bd..a7921468c9c 100644 --- a/docs/docs/cli/testkube_install.md +++ b/docs/docs/cli/testkube_install.md @@ -12,7 +12,6 @@ testkube install [flags] --chart string chart name (default "kubeshop/testkube") -h, --help help for install --name string installation name (default "testkube") - --no-dashboard don't install dashboard --no-minio don't install MinIO --no-mongo don't install MongoDB --values string path to Helm values file diff --git a/docs/docs/cli/testkube_pro_connect.md b/docs/docs/cli/testkube_pro_connect.md index 111d65b3d49..a2c3af447e3 100644 --- a/docs/docs/cli/testkube_pro_connect.md +++ b/docs/docs/cli/testkube_pro_connect.md @@ -24,7 +24,6 @@ testkube pro connect [flags] --name string installation name (usually you don't need to change it) (default "testkube") --namespace string namespace where to install (default "testkube") --no-confirm don't ask for confirmation - unatended installation mode - --no-dashboard don't install dashboard --no-minio don't install MinIO --no-mongo don't install MongoDB --org-id string Testkube Cloud organization id [required for centralized mode]

XBlR#L~i_n9K(Fb&fk>= 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