From 53b67f17f131398b1a736df100a01abea691eaf9 Mon Sep 17 00:00:00 2001 From: Lilla Vass Date: Thu, 20 Jun 2024 18:37:54 +0200 Subject: [PATCH] feat: onStateChange on webhooks (#5601) --- api/v1/testkube.yaml | 7 ++ .../commands/webhooks/common.go | 14 +++ .../commands/webhooks/create.go | 2 + .../commands/webhooks/update.go | 2 + docs/docs/articles/webhooks.mdx | 54 ++++++++ go.mod | 2 +- go.sum | 4 +- .../.goreleaser-docker-build-api.yml | 1 - internal/app/api/v1/executions_test.go | 4 + internal/app/api/v1/server.go | 6 +- internal/graphql/gen/generated.go | 5 +- internal/graphql/services/mock_executors.go | 1 - pkg/api/v1/testkube/model_webhook.go | 2 + .../testkube/model_webhook_create_request.go | 2 + pkg/api/v1/testkube/model_webhook_extended.go | 3 +- .../testkube/model_webhook_update_request.go | 2 + pkg/cloud/data/result/commands.go | 41 ++++--- pkg/cloud/data/result/result.go | 14 +++ pkg/cloud/data/result/result_models.go | 9 ++ pkg/cloud/data/result/result_test.go | 24 ++++ pkg/cloud/data/testresult/commands.go | 29 ++--- pkg/cloud/data/testresult/testresult.go | 14 +++ .../data/testresult/testresult_models.go | 9 ++ pkg/cloud/data/testresult/testresult_test.go | 24 ++++ pkg/cloud/data/testworkflow/commands.go | 37 +++--- pkg/cloud/data/testworkflow/execution.go | 16 +++ .../data/testworkflow/execution_models.go | 11 ++ pkg/cloud/service.pb.go | 5 +- pkg/cloud/service_grpc.pb.go | 1 + pkg/event/kind/webhook/listener.go | 116 ++++++++++++++---- pkg/event/kind/webhook/listener_test.go | 13 +- pkg/event/kind/webhook/loader.go | 29 +++-- pkg/event/kind/webhook/loader_test.go | 2 +- .../containerexecutor_test.go | 5 + pkg/expressions/mock_expression.go | 2 +- pkg/expressions/mock_machine.go | 2 +- pkg/expressions/mock_staticvalue.go | 2 +- pkg/logs/client/mock_client.go | 1 + pkg/logs/pb/logs.pb.go | 5 +- pkg/logs/pb/logs_grpc.pb.go | 1 + pkg/mapper/webhooks/mapper.go | 7 ++ pkg/repository/result/interface.go | 2 + pkg/repository/result/mock_repository.go | 15 +++ pkg/repository/result/mongo.go | 26 ++++ pkg/repository/testresult/interface.go | 2 + pkg/repository/testresult/mock_repository.go | 15 +++ pkg/repository/testresult/mongo.go | 25 ++++ pkg/repository/testworkflow/interface.go | 2 + .../testworkflow/mock_output_repository.go | 8 +- .../testworkflow/mock_repository.go | 25 +++- pkg/repository/testworkflow/mongo.go | 26 ++++ .../testworkflowprocessor/mock_container.go | 2 +- .../testworkflowprocessor/mock_stage.go | 2 +- 53 files changed, 563 insertions(+), 117 deletions(-) diff --git a/api/v1/testkube.yaml b/api/v1/testkube.yaml index 909997b0873..609f513dd1d 100644 --- a/api/v1/testkube.yaml +++ b/api/v1/testkube.yaml @@ -6895,6 +6895,13 @@ components: example: - true - false + onStateChange: + type: boolean + description: whether webhook is triggered on state change only + default: false + example: + - true + - false Event: description: Event data diff --git a/cmd/kubectl-testkube/commands/webhooks/common.go b/cmd/kubectl-testkube/commands/webhooks/common.go index b4eb92d4497..561355417d7 100644 --- a/cmd/kubectl-testkube/commands/webhooks/common.go +++ b/cmd/kubectl-testkube/commands/webhooks/common.go @@ -42,6 +42,11 @@ func NewCreateWebhookOptionsFromFlags(cmd *cobra.Command) (options apiv1.CreateW return options, err } + onStateChange, err := cmd.Flags().GetBool("on-state-change") + if err != nil { + return options, err + } + payloadTemplateReference := cmd.Flag("payload-template-reference").Value.String() options = apiv1.CreateWebhookOptions{ Name: name, @@ -54,6 +59,7 @@ func NewCreateWebhookOptionsFromFlags(cmd *cobra.Command) (options apiv1.CreateW PayloadTemplate: payloadTemplateContent, Headers: headers, PayloadTemplateReference: payloadTemplateReference, + OnStateChange: onStateChange, } if cmd.Flag("enable").Changed { @@ -153,6 +159,14 @@ func NewUpdateWebhookOptionsFromFlags(cmd *cobra.Command) (options apiv1.UpdateW *options.Disabled = true } + if cmd.Flag("on-state-change").Changed { + onStateChange, err := cmd.Flags().GetBool("on-state-change") + if err != nil { + return options, err + } + options.OnStateChange = &onStateChange + } + return options, nil } diff --git a/cmd/kubectl-testkube/commands/webhooks/create.go b/cmd/kubectl-testkube/commands/webhooks/create.go index 81a130d2675..5727c23cfdc 100644 --- a/cmd/kubectl-testkube/commands/webhooks/create.go +++ b/cmd/kubectl-testkube/commands/webhooks/create.go @@ -23,6 +23,7 @@ func NewCreateWebhookCmd() *cobra.Command { headers map[string]string payloadTemplateReference string update bool + onStateChange bool ) cmd := &cobra.Command{ @@ -112,6 +113,7 @@ func NewCreateWebhookCmd() *cobra.Command { cmd.Flags().BoolVar(&update, "update", false, "update, if webhook already exists") cmd.Flags().Bool("disable", false, "disable webhook") cmd.Flags().Bool("enable", false, "enable webhook") + cmd.Flags().BoolVar(&onStateChange, "on-state-change", false, "specify whether webhook should be triggered only on a state change") return cmd } diff --git a/cmd/kubectl-testkube/commands/webhooks/update.go b/cmd/kubectl-testkube/commands/webhooks/update.go index 4fda059cc1a..2f3f9405f5f 100644 --- a/cmd/kubectl-testkube/commands/webhooks/update.go +++ b/cmd/kubectl-testkube/commands/webhooks/update.go @@ -19,6 +19,7 @@ func UpdateWebhookCmd() *cobra.Command { payloadTemplateReference string enable bool disable bool + onStateChange bool ) cmd := &cobra.Command{ @@ -64,6 +65,7 @@ func UpdateWebhookCmd() *cobra.Command { cmd.Flags().StringVar(&payloadTemplateReference, "payload-template-reference", "", "reference to payload template to use for the webhook") cmd.Flags().BoolVar(&disable, "disable", false, "disable webhook") cmd.Flags().BoolVar(&enable, "enable", false, "enable webhook") + cmd.Flags().BoolVar(&onStateChange, "on-state-change", false, "specify whether webhook should be triggered only on a state change") return cmd } diff --git a/docs/docs/articles/webhooks.mdx b/docs/docs/articles/webhooks.mdx index af42fab3d0b..a089f9dd509 100644 --- a/docs/docs/articles/webhooks.mdx +++ b/docs/docs/articles/webhooks.mdx @@ -890,3 +890,57 @@ Example usage: The examples show only the `create` operations, however, these instructions work the same for `update` and `run` as well. + +## Enable Webhooks on State Changes + +It is possible to get notified by webhooks only when the latest execution's outcome differs from the previous one. This way you can see instantly when one of your scheduled tests was healed or when they got broken. It could also help prevent alert fatigue by deduplicating the alerts and therefore decreasing the load on your monitoring systems. Enable this by setting `onStateChange` to true directly on the webhook. + + + + + +```bash +testkube update webhook --name example-webhook --on-state-change +``` + + + + + +```json +{ + "name": "example-webhook", + "namespace": "testkube", + "uri": "https://webhook.url/", + "events": [ + "start-test", + "end-test-success", + "end-test-failed" + ], + "disabled": false, + "onStateChange": true, +} +``` + + + + + +```yaml +apiVersion: executor.testkube.io/v1 +kind: Webhook +metadata: + name: example-webhook-enabled + namespace: testkube +spec: + events: + - start-test + - end-test-success + - end-test-failed + onStateChange: true + uri: https://webhook.url/ +``` + + + + diff --git a/go.mod b/go.mod index 64760352d3e..917be223b71 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,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.20240607075650-f76a84665689 + github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240612160826-d6bd7a493885 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 93f67175e9d..ff911672a0b 100644 --- a/go.sum +++ b/go.sum @@ -358,8 +358,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.20240607075650-f76a84665689 h1:YVG675sarzcCDYcPH81tM5FpYReGgDoSbIShbpw7NNg= -github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240607075650-f76a84665689/go.mod h1:P47tw1nKQFufdsZndyq2HG2MSa0zK/lU0XpRfZtEmIk= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240612160826-d6bd7a493885 h1:nudyF4kv10vtvR/0QYGUg33agMWU60bb4R/+TZPTsjQ= +github.com/kubeshop/testkube-operator v1.15.2-beta1.0.20240612160826-d6bd7a493885/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/goreleaser_files/.goreleaser-docker-build-api.yml b/goreleaser_files/.goreleaser-docker-build-api.yml index 1399226acc0..6ae349a4019 100644 --- a/goreleaser_files/.goreleaser-docker-build-api.yml +++ b/goreleaser_files/.goreleaser-docker-build-api.yml @@ -1,4 +1,3 @@ -version: 2 project_name: testkube-api-server env: diff --git a/internal/app/api/v1/executions_test.go b/internal/app/api/v1/executions_test.go index 0d8be318ee7..f7e5646be75 100644 --- a/internal/app/api/v1/executions_test.go +++ b/internal/app/api/v1/executions_test.go @@ -236,6 +236,10 @@ func (r MockExecutionResultsRepository) GetNextExecutionNumber(ctx context.Conte panic("not implemented") } +func (r MockExecutionResultsRepository) GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (testkube.ExecutionStatus, error) { + panic("not implemented") +} + func (r MockExecutionResultsRepository) Insert(ctx context.Context, result testkube.Execution) error { panic("not implemented") } diff --git a/internal/app/api/v1/server.go b/internal/app/api/v1/server.go index 288065f466b..921af8e0536 100644 --- a/internal/app/api/v1/server.go +++ b/internal/app/api/v1/server.go @@ -69,7 +69,7 @@ const ( func NewTestkubeAPI( namespace string, testExecutionResults result.Repository, - testsuiteExecutionsResults testresult.Repository, + testSuiteExecutionsResults testresult.Repository, testWorkflowResults testworkflow.Repository, testWorkflowOutput testworkflow.OutputRepository, testsClient *testsclientv3.TestsClient, @@ -125,7 +125,7 @@ func NewTestkubeAPI( s := TestkubeAPI{ HTTPServer: server.NewServer(httpConfig), - TestExecutionResults: testsuiteExecutionsResults, + TestExecutionResults: testSuiteExecutionsResults, ExecutionResults: testExecutionResults, TestWorkflowResults: testWorkflowResults, TestWorkflowOutput: testWorkflowOutput, @@ -169,7 +169,7 @@ func NewTestkubeAPI( // will be reused in websockets handler s.WebsocketLoader = ws.NewWebsocketLoader() - s.Events.Loader.Register(webhook.NewWebhookLoader(s.Log, webhookClient, templatesClient)) + s.Events.Loader.Register(webhook.NewWebhookLoader(s.Log, webhookClient, templatesClient, testExecutionResults, testSuiteExecutionsResults, testWorkflowResults)) s.Events.Loader.Register(s.WebsocketLoader) s.Events.Loader.Register(s.slackLoader) diff --git a/internal/graphql/gen/generated.go b/internal/graphql/gen/generated.go index a918d3f4a25..06d1ec97d82 100644 --- a/internal/graphql/gen/generated.go +++ b/internal/graphql/gen/generated.go @@ -14,11 +14,10 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" - gqlparser "github.com/vektah/gqlparser/v2" - "github.com/vektah/gqlparser/v2/ast" - "github.com/kubeshop/testkube/internal/graphql/scalars" "github.com/kubeshop/testkube/pkg/api/v1/testkube" + gqlparser "github.com/vektah/gqlparser/v2" + "github.com/vektah/gqlparser/v2/ast" ) // region ************************** generated!.gotpl ************************** diff --git a/internal/graphql/services/mock_executors.go b/internal/graphql/services/mock_executors.go index b6f6de27262..598273c28d1 100644 --- a/internal/graphql/services/mock_executors.go +++ b/internal/graphql/services/mock_executors.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/api/v1/testkube/model_webhook.go b/pkg/api/v1/testkube/model_webhook.go index 1302ed9b2f0..34700b1b69f 100644 --- a/pkg/api/v1/testkube/model_webhook.go +++ b/pkg/api/v1/testkube/model_webhook.go @@ -29,4 +29,6 @@ type Webhook struct { Labels map[string]string `json:"labels,omitempty"` // whether webhook is disabled Disabled bool `json:"disabled,omitempty"` + // whether webhook is triggered on state change only + OnStateChange bool `json:"onStateChange,omitempty"` } diff --git a/pkg/api/v1/testkube/model_webhook_create_request.go b/pkg/api/v1/testkube/model_webhook_create_request.go index d98e35a0710..ebbcb8f0989 100644 --- a/pkg/api/v1/testkube/model_webhook_create_request.go +++ b/pkg/api/v1/testkube/model_webhook_create_request.go @@ -29,4 +29,6 @@ type WebhookCreateRequest struct { Labels map[string]string `json:"labels,omitempty"` // whether webhook is disabled Disabled bool `json:"disabled,omitempty"` + // whether webhook is triggered on state change only + OnStateChange bool `json:"onStateChange,omitempty"` } diff --git a/pkg/api/v1/testkube/model_webhook_extended.go b/pkg/api/v1/testkube/model_webhook_extended.go index c937aca7f5e..f723fd10b64 100644 --- a/pkg/api/v1/testkube/model_webhook_extended.go +++ b/pkg/api/v1/testkube/model_webhook_extended.go @@ -14,7 +14,7 @@ import "fmt" type Webhooks []Webhook func (list Webhooks) Table() (header []string, output [][]string) { - header = []string{"Name", "URI", "Events", "Selector", "Labels", "Disabled"} + header = []string{"Name", "URI", "Events", "Selector", "Labels", "Disabled", "On State Change"} for _, e := range list { output = append(output, []string{ @@ -24,6 +24,7 @@ func (list Webhooks) Table() (header []string, output [][]string) { e.Selector, MapToString(e.Labels), fmt.Sprint(e.Disabled), + fmt.Sprint(e.OnStateChange), }) } diff --git a/pkg/api/v1/testkube/model_webhook_update_request.go b/pkg/api/v1/testkube/model_webhook_update_request.go index 8ec6795be9e..111ed24d956 100644 --- a/pkg/api/v1/testkube/model_webhook_update_request.go +++ b/pkg/api/v1/testkube/model_webhook_update_request.go @@ -29,4 +29,6 @@ type WebhookUpdateRequest struct { Labels *map[string]string `json:"labels,omitempty"` // whether webhook is disabled Disabled *bool `json:"disabled,omitempty"` + // whether webhook is triggered on state change only + OnStateChange *bool `json:"onStateChange,omitempty"` } diff --git a/pkg/cloud/data/result/commands.go b/pkg/cloud/data/result/commands.go index 53d4c2adc39..98a8eed5609 100644 --- a/pkg/cloud/data/result/commands.go +++ b/pkg/cloud/data/result/commands.go @@ -3,24 +3,25 @@ package result import "github.com/kubeshop/testkube/pkg/cloud/data/executor" const ( - CmdResultGetNextExecutionNumber executor.Command = "result_get_next_execution_number" - CmdResultGet executor.Command = "result_get" - CmdResultGetByNameAndTest executor.Command = "result_get_by_name_and_test" - CmdResultGetLatestByTest executor.Command = "result_get_latest_by_test" - CmdResultGetLatestByTests executor.Command = "result_get_latest_by_tests" - CmdResultGetExecutions executor.Command = "result_get_executions" - CmdResultGetExecutionTotals executor.Command = "result_get_execution_totals" - CmdResultInsert executor.Command = "result_insert" - CmdResultUpdate executor.Command = "result_update" - CmdResultUpdateResult executor.Command = "result_update_result" - CmdResultStartExecution executor.Command = "result_start_execution" - CmdResultEndExecution executor.Command = "result_end_execution" - CmdResultGetLabels executor.Command = "result_get_labels" - CmdResultDeleteByTest executor.Command = "result_delete_by_test" - CmdResultDeleteByTestSuite executor.Command = "result_delete_by_test_suite" - CmdResultDeleteAll executor.Command = "result_delete_all" - CmdResultDeleteByTests executor.Command = "result_delete_by_tests" - CmdResultDeleteByTestSuites executor.Command = "result_delete_by_test_suites" - CmdResultDeleteForAllTestSuites executor.Command = "result_delete_for_all_test_suites" - CmdResultGetTestMetrics executor.Command = "result_get_test_metrics" + CmdResultGetNextExecutionNumber executor.Command = "result_get_next_execution_number" + CmdResultGet executor.Command = "result_get" + CmdResultGetByNameAndTest executor.Command = "result_get_by_name_and_test" + CmdResultGetLatestByTest executor.Command = "result_get_latest_by_test" + CmdResultGetLatestByTests executor.Command = "result_get_latest_by_tests" + CmdResultGetExecutions executor.Command = "result_get_executions" + CmdResultGetExecutionTotals executor.Command = "result_get_execution_totals" + CmdResultGetPreviousFinishedState executor.Command = "result_get_previous_finished_state" + CmdResultInsert executor.Command = "result_insert" + CmdResultUpdate executor.Command = "result_update" + CmdResultUpdateResult executor.Command = "result_update_result" + CmdResultStartExecution executor.Command = "result_start_execution" + CmdResultEndExecution executor.Command = "result_end_execution" + CmdResultGetLabels executor.Command = "result_get_labels" + CmdResultDeleteByTest executor.Command = "result_delete_by_test" + CmdResultDeleteByTestSuite executor.Command = "result_delete_by_test_suite" + CmdResultDeleteAll executor.Command = "result_delete_all" + CmdResultDeleteByTests executor.Command = "result_delete_by_tests" + CmdResultDeleteByTestSuites executor.Command = "result_delete_by_test_suites" + CmdResultDeleteForAllTestSuites executor.Command = "result_delete_for_all_test_suites" + CmdResultGetTestMetrics executor.Command = "result_get_test_metrics" ) diff --git a/pkg/cloud/data/result/result.go b/pkg/cloud/data/result/result.go index 5ee06ad7aaa..40ba4610376 100644 --- a/pkg/cloud/data/result/result.go +++ b/pkg/cloud/data/result/result.go @@ -333,3 +333,17 @@ func (r *CloudRepository) GetTestMetrics(ctx context.Context, name string, limit func (r *CloudRepository) Count(ctx context.Context, filter result.Filter) (int64, error) { return 0, nil } + +// GetPreviousFinishedState gets previous finished execution state by test +func (r *CloudRepository) GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (testkube.ExecutionStatus, error) { + req := GetPreviousFinishedStateRequest{TestName: testName, Date: date} + response, err := r.executor.Execute(ctx, CmdResultGetPreviousFinishedState, req) + if err != nil { + return "", err + } + var commandResponse GetPreviousFinishedStateResponse + if err := json.Unmarshal(response, &commandResponse); err != nil { + return "", err + } + return commandResponse.Result, nil +} diff --git a/pkg/cloud/data/result/result_models.go b/pkg/cloud/data/result/result_models.go index 7146da86652..2c523a9ef4f 100644 --- a/pkg/cloud/data/result/result_models.go +++ b/pkg/cloud/data/result/result_models.go @@ -67,6 +67,15 @@ type GetExecutionTotalsResponse struct { Result testkube.ExecutionsTotals `json:"result"` } +type GetPreviousFinishedStateRequest struct { + TestName string + Date time.Time +} + +type GetPreviousFinishedStateResponse struct { + Result testkube.ExecutionStatus +} + type InsertRequest struct { Result testkube.Execution `json:"result"` } diff --git a/pkg/cloud/data/result/result_test.go b/pkg/cloud/data/result/result_test.go index 32f1982e3af..1956a72eca1 100644 --- a/pkg/cloud/data/result/result_test.go +++ b/pkg/cloud/data/result/result_test.go @@ -120,6 +120,30 @@ func TestCloudResultRepository_GetLatestByTest(t *testing.T) { assert.Equal(t, &endExecution, result) } +func TestCloudResultRepository_GetPreviousFinishedState(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockExecutor := executor.NewMockExecutor(mockCtrl) + repo := &CloudRepository{executor: mockExecutor} + + testName := "test_name" + date := time.Date(2023, 5, 5, 0, 0, 0, 0, time.UTC) + expectedStatus := testkube.PASSED_ExecutionStatus + response, _ := json.Marshal(GetPreviousFinishedStateResponse{Result: expectedStatus}) + + mockExecutor. + EXPECT(). + Execute(ctx, CmdResultGetPreviousFinishedState, GetPreviousFinishedStateRequest{TestName: testName, Date: date}). + Return(response, nil) + + status, err := repo.GetPreviousFinishedState(ctx, testName, date) + assert.NoError(t, err) + assert.Equal(t, expectedStatus, status) +} + func TestCloudResultRepository_Insert(t *testing.T) { t.Parallel() diff --git a/pkg/cloud/data/testresult/commands.go b/pkg/cloud/data/testresult/commands.go index 378c9ceb972..1133385049a 100644 --- a/pkg/cloud/data/testresult/commands.go +++ b/pkg/cloud/data/testresult/commands.go @@ -3,18 +3,19 @@ package testresult import "github.com/kubeshop/testkube/pkg/cloud/data/executor" const ( - CmdTestResultGet executor.Command = "test_result_get" - CmdTestResultGetByNameAndTestSuite executor.Command = "test_result_get_by_name_and_test" - CmdTestResultGetLatestByTestSuite executor.Command = "test_result_get_latest_by_test_suite" - CmdTestResultGetLatestByTestSuites executor.Command = "test_result_get_latest_by_test_suites" - CmdTestResultGetExecutionsTotals executor.Command = "test_result_get_executions_totals" - CmdTestResultGetExecutions executor.Command = "test_result_get_executions" - CmdTestResultInsert executor.Command = "test_result_insert" - CmdTestResultUpdate executor.Command = "test_result_update" - CmdTestResultStartExecution executor.Command = "test_result_start_execution" - CmdTestResultEndExecution executor.Command = "test_result_end_execution" - CmdTestResultDeleteByTestSuite executor.Command = "test_result_delete_by_test_suite" - CmdTestResultDeleteAll executor.Command = "test_result_delete_all" - CmdTestResultDeleteByTestSuites executor.Command = "test_result_delete_by_test_suites" - CmdTestResultGetTestSuiteMetrics executor.Command = "test_result_get_test_suite_metrics" + CmdTestResultGet executor.Command = "test_result_get" + CmdTestResultGetByNameAndTestSuite executor.Command = "test_result_get_by_name_and_test" + CmdTestResultGetLatestByTestSuite executor.Command = "test_result_get_latest_by_test_suite" + CmdTestResultGetLatestByTestSuites executor.Command = "test_result_get_latest_by_test_suites" + CmdTestResultGetExecutionsTotals executor.Command = "test_result_get_executions_totals" + CmdTestResultGetExecutions executor.Command = "test_result_get_executions" + CmdTestResultGetPreviousFinishedState executor.Command = "test_result_get_previous_finished_state" + CmdTestResultInsert executor.Command = "test_result_insert" + CmdTestResultUpdate executor.Command = "test_result_update" + CmdTestResultStartExecution executor.Command = "test_result_start_execution" + CmdTestResultEndExecution executor.Command = "test_result_end_execution" + CmdTestResultDeleteByTestSuite executor.Command = "test_result_delete_by_test_suite" + CmdTestResultDeleteAll executor.Command = "test_result_delete_all" + CmdTestResultDeleteByTestSuites executor.Command = "test_result_delete_by_test_suites" + CmdTestResultGetTestSuiteMetrics executor.Command = "test_result_get_test_suite_metrics" ) diff --git a/pkg/cloud/data/testresult/testresult.go b/pkg/cloud/data/testresult/testresult.go index 37425ca1ba5..18f23dd080b 100644 --- a/pkg/cloud/data/testresult/testresult.go +++ b/pkg/cloud/data/testresult/testresult.go @@ -238,3 +238,17 @@ func (r *CloudRepository) GetTestSuiteMetrics(ctx context.Context, name string, func (r *CloudRepository) Count(ctx context.Context, filter testresult.Filter) (int64, error) { return 0, nil } + +// GetPreviousFinishedState gets previous finished execution state by test +func (r *CloudRepository) GetPreviousFinishedState(ctx context.Context, testSuiteName string, date time.Time) (testkube.TestSuiteExecutionStatus, error) { + req := GetPreviousFinishedStateRequest{TestSuiteName: testSuiteName, Date: date} + response, err := r.executor.Execute(ctx, CmdTestResultGetPreviousFinishedState, req) + if err != nil { + return "", err + } + var commandResponse GetPreviousFinishedStateResponse + if err := json.Unmarshal(response, &commandResponse); err != nil { + return "", err + } + return commandResponse.Result, nil +} diff --git a/pkg/cloud/data/testresult/testresult_models.go b/pkg/cloud/data/testresult/testresult_models.go index 7343221e80d..5f3379f52d9 100644 --- a/pkg/cloud/data/testresult/testresult_models.go +++ b/pkg/cloud/data/testresult/testresult_models.go @@ -58,6 +58,15 @@ type GetExecutionsResponse struct { TestSuiteExecutions []testkube.TestSuiteExecution `json:"testSuiteExecutions"` } +type GetPreviousFinishedStateRequest struct { + TestSuiteName string + Date time.Time +} + +type GetPreviousFinishedStateResponse struct { + Result testkube.TestSuiteExecutionStatus +} + type InsertRequest struct { TestSuiteExecution testkube.TestSuiteExecution `json:"testSuiteExecution"` } diff --git a/pkg/cloud/data/testresult/testresult_test.go b/pkg/cloud/data/testresult/testresult_test.go index 35197a3f72d..8eedd613dce 100644 --- a/pkg/cloud/data/testresult/testresult_test.go +++ b/pkg/cloud/data/testresult/testresult_test.go @@ -100,3 +100,27 @@ func TestCloudResultRepository_GetLatestByTestSuites(t *testing.T) { assert.Contains(t, results, expectedResults[0]) assert.Contains(t, results, expectedResults[1]) } + +func TestCloudResultRepository_GetPreviousFinishedState(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockExecutor := executor.NewMockExecutor(mockCtrl) + repo := &CloudRepository{executor: mockExecutor} + + testSuiteName := "test_suite_name" + date := time.Date(2023, 5, 5, 0, 0, 0, 0, time.UTC) + expectedStatus := testkube.TestSuiteExecutionStatusPassed + response, _ := json.Marshal(GetPreviousFinishedStateResponse{Result: *expectedStatus}) + + mockExecutor. + EXPECT(). + Execute(ctx, CmdTestResultGetPreviousFinishedState, GetPreviousFinishedStateRequest{TestSuiteName: testSuiteName, Date: date}). + Return(response, nil) + + status, err := repo.GetPreviousFinishedState(ctx, testSuiteName, date) + assert.NoError(t, err) + assert.Equal(t, *expectedStatus, status) +} diff --git a/pkg/cloud/data/testworkflow/commands.go b/pkg/cloud/data/testworkflow/commands.go index 2880057c90d..73d5c8c7beb 100644 --- a/pkg/cloud/data/testworkflow/commands.go +++ b/pkg/cloud/data/testworkflow/commands.go @@ -3,23 +3,24 @@ 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" - CmdTestWorkflowExecutionAddReport executor.Command = "workflow_execution_add_report" - 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" + 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" + CmdTestWorkflowExecutionGetPreviousFinishedState executor.Command = "workflow_execution_get_previous_finished_state" + CmdTestWorkflowExecutionInsert executor.Command = "workflow_execution_insert" + CmdTestWorkflowExecutionUpdate executor.Command = "workflow_execution_update" + CmdTestWorkflowExecutionUpdateResult executor.Command = "workflow_execution_update_result" + CmdTestWorkflowExecutionAddReport executor.Command = "workflow_execution_add_report" + 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" @@ -46,6 +47,8 @@ func command(v interface{}) executor.Command { return CmdTestWorkflowExecutionGetExecutions case ExecutionGetExecutionsSummaryRequest: return CmdTestWorkflowExecutionGetExecutionsSummary + case ExecutionGetPreviousFinishedStateRequest: + return CmdTestWorkflowExecutionGetPreviousFinishedState case ExecutionInsertRequest: return CmdTestWorkflowExecutionInsert case ExecutionUpdateRequest: diff --git a/pkg/cloud/data/testworkflow/execution.go b/pkg/cloud/data/testworkflow/execution.go index 2c10d8c7ce6..961e59b3f29 100644 --- a/pkg/cloud/data/testworkflow/execution.go +++ b/pkg/cloud/data/testworkflow/execution.go @@ -2,6 +2,8 @@ package testworkflow import ( "context" + "encoding/json" + "time" "google.golang.org/grpc" @@ -140,3 +142,17 @@ func (r *CloudRepository) GetTestWorkflowMetrics(ctx context.Context, name strin } return pass(r.executor, ctx, req, process) } + +// GetPreviousFinishedState gets previous finished execution state by test +func (r *CloudRepository) GetPreviousFinishedState(ctx context.Context, workflowName string, date time.Time) (testkube.TestWorkflowStatus, error) { + req := ExecutionGetPreviousFinishedStateRequest{WorkflowName: workflowName, Date: date} + response, err := r.executor.Execute(ctx, CmdTestWorkflowExecutionGetPreviousFinishedState, req) + if err != nil { + return "", err + } + var commandResponse ExecutionGetPreviousFinishedStateResponse + if err := json.Unmarshal(response, &commandResponse); err != nil { + return "", err + } + return commandResponse.Result, nil +} diff --git a/pkg/cloud/data/testworkflow/execution_models.go b/pkg/cloud/data/testworkflow/execution_models.go index d28c4099499..01eea60feb9 100644 --- a/pkg/cloud/data/testworkflow/execution_models.go +++ b/pkg/cloud/data/testworkflow/execution_models.go @@ -1,6 +1,8 @@ package testworkflow import ( + "time" + "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/repository/testworkflow" ) @@ -69,6 +71,15 @@ type ExecutionGetExecutionsSummaryResponse struct { WorkflowExecutions []testkube.TestWorkflowExecutionSummary `json:"workflowExecutions"` } +type ExecutionGetPreviousFinishedStateRequest struct { + WorkflowName string + Date time.Time +} + +type ExecutionGetPreviousFinishedStateResponse struct { + Result testkube.TestWorkflowStatus +} + type ExecutionInsertRequest struct { WorkflowExecution testkube.TestWorkflowExecution `json:"workflowExecution"` } diff --git a/pkg/cloud/service.pb.go b/pkg/cloud/service.pb.go index ba917395604..7a140af6a52 100644 --- a/pkg/cloud/service.pb.go +++ b/pkg/cloud/service.pb.go @@ -7,12 +7,13 @@ 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 ( diff --git a/pkg/cloud/service_grpc.pb.go b/pkg/cloud/service_grpc.pb.go index 40a960ee3c0..8aab7a21191 100644 --- a/pkg/cloud/service_grpc.pb.go +++ b/pkg/cloud/service_grpc.pb.go @@ -8,6 +8,7 @@ package cloud import ( context "context" + grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" diff --git a/pkg/event/kind/webhook/listener.go b/pkg/event/kind/webhook/listener.go index bfb37dd3b6f..ec63ef6108e 100644 --- a/pkg/event/kind/webhook/listener.go +++ b/pkg/event/kind/webhook/listener.go @@ -2,6 +2,7 @@ package webhook import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -15,6 +16,9 @@ import ( "github.com/kubeshop/testkube/pkg/event/kind/common" thttp "github.com/kubeshop/testkube/pkg/http" "github.com/kubeshop/testkube/pkg/log" + "github.com/kubeshop/testkube/pkg/repository/result" + "github.com/kubeshop/testkube/pkg/repository/testresult" + "github.com/kubeshop/testkube/pkg/repository/testworkflow" "github.com/kubeshop/testkube/pkg/utils" "github.com/kubeshop/testkube/pkg/utils/text" ) @@ -22,32 +26,44 @@ import ( var _ common.Listener = (*WebhookListener)(nil) func NewWebhookListener(name, uri, selector string, events []testkube.EventType, - payloadObjectField, payloadTemplate string, headers map[string]string, disabled bool) *WebhookListener { + payloadObjectField, payloadTemplate string, headers map[string]string, disabled bool, + onStateChange bool, + testExecutionResults result.Repository, + testSuiteExecutionResults testresult.Repository, + testWorkflowExecutionResults testworkflow.Repository) *WebhookListener { return &WebhookListener{ - name: name, - Uri: uri, - Log: log.DefaultLogger, - HttpClient: thttp.NewClient(), - selector: selector, - events: events, - payloadObjectField: payloadObjectField, - payloadTemplate: payloadTemplate, - headers: headers, - disabled: disabled, + name: name, + Uri: uri, + Log: log.DefaultLogger, + HttpClient: thttp.NewClient(), + selector: selector, + events: events, + payloadObjectField: payloadObjectField, + payloadTemplate: payloadTemplate, + headers: headers, + disabled: disabled, + onStateChange: onStateChange, + testExecutionResults: testExecutionResults, + testSuiteExecutionResults: testSuiteExecutionResults, + testWorkflowExecutionResults: testWorkflowExecutionResults, } } type WebhookListener struct { - name string - Uri string - Log *zap.SugaredLogger - HttpClient *http.Client - events []testkube.EventType - selector string - payloadObjectField string - payloadTemplate string - headers map[string]string - disabled bool + name string + Uri string + Log *zap.SugaredLogger + HttpClient *http.Client + events []testkube.EventType + selector string + payloadObjectField string + payloadTemplate string + headers map[string]string + disabled bool + onStateChange bool + testExecutionResults result.Repository + testSuiteExecutionResults testresult.Repository + testWorkflowExecutionResults testworkflow.Repository } func (l *WebhookListener) Name() string { @@ -71,6 +87,7 @@ func (l *WebhookListener) Metadata() map[string]string { "payloadTemplate": l.payloadTemplate, "headers": fmt.Sprintf("%v", l.headers), "disabled": fmt.Sprint(l.disabled), + "onStateChange": fmt.Sprint(l.onStateChange), } } @@ -106,6 +123,16 @@ func (l *WebhookListener) Notify(event testkube.Event) (result testkube.EventRes return testkube.NewSuccessEventResult(event.Id, "webhook listener is disabled for test workflow execution") } + if l.onStateChange { + changed, err := l.hasStateChanges(event) + if err != nil { + l.Log.With(event.Log()...).Errorw(fmt.Sprintf("could not get previous finished state for test %s", event.TestExecution.TestName), "error", err) + } + if !changed { + return testkube.NewSuccessEventResult(event.Id, "webhook set to state change only; state has not changed") + } + } + body := bytes.NewBuffer([]byte{}) log := l.Log.With(event.Log()...) @@ -211,3 +238,50 @@ func (l *WebhookListener) processTemplate(field, body string, event testkube.Eve return buffer.Bytes(), nil } + +func (l *WebhookListener) hasStateChanges(event testkube.Event) (bool, error) { + log := l.Log.With(event.Log()...) + + if event.TestExecution != nil && event.TestExecution.ExecutionResult != nil { + prevStatus, err := l.testExecutionResults.GetPreviousFinishedState(context.Background(), event.TestExecution.TestName, event.TestExecution.EndTime) + if err != nil { + return false, err + } + if prevStatus == "" { + log.Debugw(fmt.Sprintf("no previous finished state for test %s", event.TestExecution.TestName)) + return true, nil + } + + return *event.TestExecution.ExecutionResult.Status != prevStatus, nil + } + + if event.TestSuiteExecution != nil && event.TestSuiteExecution.Status != nil { + prevStatus, err := l.testSuiteExecutionResults.GetPreviousFinishedState(context.Background(), event.TestSuiteExecution.TestSuite.Name, event.TestSuiteExecution.EndTime) + if err != nil { + log.Errorw(fmt.Sprintf("could not get previous finished state for test suite %s", event.TestSuiteExecution.TestSuite.Name), "error", err) + return false, err + } + if prevStatus == "" { + log.Debugw(fmt.Sprintf("no previous finished state for test suite %s", event.TestSuiteExecution.TestSuite.Name)) + return true, nil + } + + return *event.TestSuiteExecution.Status != prevStatus, nil + } + + if event.TestWorkflowExecution != nil && event.TestWorkflowExecution.Result != nil { + prevStatus, err := l.testWorkflowExecutionResults.GetPreviousFinishedState(context.Background(), event.TestWorkflowExecution.Workflow.Name, event.TestWorkflowExecution.StatusAt) + + if err != nil { + log.Errorw(fmt.Sprintf("could not get previous finished state for test workflow %s", event.TestWorkflowExecution.Workflow.Name), "error", err) + return false, err + } + if prevStatus == "" { + log.Debugw(fmt.Sprintf("no previous finished state for test workflow %s", event.TestWorkflowExecution.Workflow.Name)) + return true, nil + } + return *event.TestWorkflowExecution.Result.Status != prevStatus, nil + } + + return false, nil +} diff --git a/pkg/event/kind/webhook/listener_test.go b/pkg/event/kind/webhook/listener_test.go index 5fb7a786fbd..9068f5ac468 100644 --- a/pkg/event/kind/webhook/listener_test.go +++ b/pkg/event/kind/webhook/listener_test.go @@ -33,7 +33,7 @@ func TestWebhookListener_Notify(t *testing.T) { svr := httptest.NewServer(testHandler) defer svr.Close() - l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "", "", nil, false) + l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "", "", nil, false, false, nil, nil, nil) // when r := l.Notify(testkube.Event{ @@ -55,7 +55,7 @@ func TestWebhookListener_Notify(t *testing.T) { svr := httptest.NewServer(testHandler) defer svr.Close() - l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "", "", nil, false) + l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "", "", nil, false, false, nil, nil, nil) // when r := l.Notify(testkube.Event{ @@ -72,7 +72,7 @@ func TestWebhookListener_Notify(t *testing.T) { t.Parallel() // given - s := NewWebhookListener("l1", "http://baduri.badbadbad", "", testEventTypes, "", "", nil, false) + s := NewWebhookListener("l1", "http://baduri.badbadbad", "", testEventTypes, "", "", nil, false, false, nil, nil, nil) // when r := s.Notify(testkube.Event{ @@ -105,7 +105,7 @@ func TestWebhookListener_Notify(t *testing.T) { svr := httptest.NewServer(testHandler) defer svr.Close() - l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "field", "", nil, false) + l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "field", "", nil, false, false, nil, nil, nil) // when r := l.Notify(testkube.Event{ @@ -131,7 +131,8 @@ func TestWebhookListener_Notify(t *testing.T) { svr := httptest.NewServer(testHandler) defer svr.Close() - l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "", "{\"id\": \"{{ .Id }}\"}", map[string]string{"Content-Type": "application/json"}, false) + l := NewWebhookListener("l1", svr.URL, "", testEventTypes, "", "{\"id\": \"{{ .Id }}\"}", + map[string]string{"Content-Type": "application/json"}, false, false, nil, nil, nil) // when r := l.Notify(testkube.Event{ @@ -148,7 +149,7 @@ func TestWebhookListener_Notify(t *testing.T) { t.Parallel() // given - s := NewWebhookListener("l1", "http://baduri.badbadbad", "", testEventTypes, "", "", nil, true) + s := NewWebhookListener("l1", "http://baduri.badbadbad", "", testEventTypes, "", "", nil, true, false, nil, nil, nil) // when r := s.Notify(testkube.Event{ diff --git a/pkg/event/kind/webhook/loader.go b/pkg/event/kind/webhook/loader.go index 9410eb140ae..b77bfd94d60 100644 --- a/pkg/event/kind/webhook/loader.go +++ b/pkg/event/kind/webhook/loader.go @@ -10,6 +10,9 @@ import ( "github.com/kubeshop/testkube/pkg/api/v1/testkube" "github.com/kubeshop/testkube/pkg/event/kind/common" "github.com/kubeshop/testkube/pkg/mapper/webhooks" + "github.com/kubeshop/testkube/pkg/repository/result" + "github.com/kubeshop/testkube/pkg/repository/testresult" + "github.com/kubeshop/testkube/pkg/repository/testworkflow" ) var _ common.ListenerLoader = (*WebhooksLoader)(nil) @@ -19,18 +22,26 @@ type WebhooksLister interface { List(selector string) (*executorsv1.WebhookList, error) } -func NewWebhookLoader(log *zap.SugaredLogger, webhooksClient WebhooksLister, templatesClient templatesclientv1.Interface) *WebhooksLoader { +func NewWebhookLoader(log *zap.SugaredLogger, webhooksClient WebhooksLister, templatesClient templatesclientv1.Interface, + testExecutionResults result.Repository, testSuiteExecutionResults testresult.Repository, testWorkflowExecutionResults testworkflow.Repository, +) *WebhooksLoader { return &WebhooksLoader{ - log: log, - WebhooksClient: webhooksClient, - templatesClient: templatesClient, + log: log, + WebhooksClient: webhooksClient, + templatesClient: templatesClient, + testExecutionResults: testExecutionResults, + testSuiteExecutionResults: testSuiteExecutionResults, + testWorkflowExecutionResults: testWorkflowExecutionResults, } } type WebhooksLoader struct { - log *zap.SugaredLogger - WebhooksClient WebhooksLister - templatesClient templatesclientv1.Interface + log *zap.SugaredLogger + WebhooksClient WebhooksLister + templatesClient templatesclientv1.Interface + testExecutionResults result.Repository + testSuiteExecutionResults testresult.Repository + testWorkflowExecutionResults testworkflow.Repository } func (r WebhooksLoader) Kind() string { @@ -66,7 +77,9 @@ func (r WebhooksLoader) Load() (listeners common.Listeners, err error) { types := webhooks.MapEventArrayToCRDEvents(webhook.Spec.Events) name := fmt.Sprintf("%s.%s", webhook.ObjectMeta.Namespace, webhook.ObjectMeta.Name) - listeners = append(listeners, NewWebhookListener(name, webhook.Spec.Uri, webhook.Spec.Selector, types, webhook.Spec.PayloadObjectField, payloadTemplate, webhook.Spec.Headers, webhook.Spec.Disabled)) + listeners = append(listeners, NewWebhookListener(name, webhook.Spec.Uri, webhook.Spec.Selector, types, + webhook.Spec.PayloadObjectField, payloadTemplate, webhook.Spec.Headers, webhook.Spec.Disabled, + webhook.Spec.OnStateChange, r.testExecutionResults, r.testSuiteExecutionResults, r.testWorkflowExecutionResults)) } return listeners, nil diff --git a/pkg/event/kind/webhook/loader_test.go b/pkg/event/kind/webhook/loader_test.go index b6aabeea05c..ec3d7b0bf2a 100644 --- a/pkg/event/kind/webhook/loader_test.go +++ b/pkg/event/kind/webhook/loader_test.go @@ -29,7 +29,7 @@ func TestWebhookLoader(t *testing.T) { defer mockCtrl.Finish() mockTemplatesClient := templatesclientv1.NewMockInterface(mockCtrl) - webhooksLoader := NewWebhookLoader(zap.NewNop().Sugar(), &DummyLoader{}, mockTemplatesClient) + webhooksLoader := NewWebhookLoader(zap.NewNop().Sugar(), &DummyLoader{}, mockTemplatesClient, nil, nil, nil) listeners, err := webhooksLoader.Load() assert.Equal(t, 1, len(listeners)) diff --git a/pkg/executor/containerexecutor/containerexecutor_test.go b/pkg/executor/containerexecutor/containerexecutor_test.go index e8088ca6b1c..57156d19a14 100644 --- a/pkg/executor/containerexecutor/containerexecutor_test.go +++ b/pkg/executor/containerexecutor/containerexecutor_test.go @@ -416,6 +416,11 @@ func (r FakeResultRepository) GetExecutionTotals(ctx context.Context, paging boo panic("implement me") } +func (r FakeResultRepository) GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (result testkube.ExecutionStatus, err error) { + //TODO implement me + panic("implement me") +} + func (r FakeResultRepository) Insert(ctx context.Context, result testkube.Execution) error { //TODO implement me panic("implement me") diff --git a/pkg/expressions/mock_expression.go b/pkg/expressions/mock_expression.go index 357a8587d2d..b6b9dada75f 100644 --- a/pkg/expressions/mock_expression.go +++ b/pkg/expressions/mock_expression.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/kubeshop/testkube/pkg/tcl/expressions (interfaces: Expression) +// Source: github.com/kubeshop/testkube/pkg/expressions (interfaces: Expression) // Package expressions is a generated GoMock package. package expressions diff --git a/pkg/expressions/mock_machine.go b/pkg/expressions/mock_machine.go index 726b20fee40..b4f6ed3eddd 100644 --- a/pkg/expressions/mock_machine.go +++ b/pkg/expressions/mock_machine.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/kubeshop/testkube/pkg/tcl/expressions (interfaces: Machine) +// Source: github.com/kubeshop/testkube/pkg/expressions (interfaces: Machine) // Package expressions is a generated GoMock package. package expressions diff --git a/pkg/expressions/mock_staticvalue.go b/pkg/expressions/mock_staticvalue.go index 9e158619924..ba541cc2a7f 100644 --- a/pkg/expressions/mock_staticvalue.go +++ b/pkg/expressions/mock_staticvalue.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/kubeshop/testkube/pkg/tcl/expressions (interfaces: StaticValue) +// Source: github.com/kubeshop/testkube/pkg/expressions (interfaces: StaticValue) // Package expressions is a generated GoMock package. package expressions diff --git a/pkg/logs/client/mock_client.go b/pkg/logs/client/mock_client.go index a87031ea1af..a89570c85ce 100644 --- a/pkg/logs/client/mock_client.go +++ b/pkg/logs/client/mock_client.go @@ -9,6 +9,7 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" + events "github.com/kubeshop/testkube/pkg/logs/events" ) diff --git a/pkg/logs/pb/logs.pb.go b/pkg/logs/pb/logs.pb.go index 9a6c90ffc02..c7359be3ddf 100644 --- a/pkg/logs/pb/logs.pb.go +++ b/pkg/logs/pb/logs.pb.go @@ -7,11 +7,12 @@ package pb import ( + reflect "reflect" + sync "sync" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" ) const ( diff --git a/pkg/logs/pb/logs_grpc.pb.go b/pkg/logs/pb/logs_grpc.pb.go index 13dac477e57..77bc3950ef9 100644 --- a/pkg/logs/pb/logs_grpc.pb.go +++ b/pkg/logs/pb/logs_grpc.pb.go @@ -8,6 +8,7 @@ package pb import ( context "context" + grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" diff --git a/pkg/mapper/webhooks/mapper.go b/pkg/mapper/webhooks/mapper.go index 36da188012d..5febf357095 100644 --- a/pkg/mapper/webhooks/mapper.go +++ b/pkg/mapper/webhooks/mapper.go @@ -21,6 +21,7 @@ func MapCRDToAPI(item executorv1.Webhook) testkube.Webhook { PayloadTemplateReference: item.Spec.PayloadTemplateReference, Headers: item.Spec.Headers, Disabled: item.Spec.Disabled, + OnStateChange: item.Spec.OnStateChange, } } @@ -57,6 +58,7 @@ func MapAPIToCRD(request testkube.WebhookCreateRequest) executorv1.Webhook { PayloadTemplateReference: request.PayloadTemplateReference, Headers: request.Headers, Disabled: request.Disabled, + OnStateChange: request.OnStateChange, }, } } @@ -127,6 +129,10 @@ func MapUpdateToSpec(request testkube.WebhookUpdateRequest, webhook *executorv1. webhook.Spec.Disabled = *request.Disabled } + if request.OnStateChange != nil { + webhook.Spec.OnStateChange = *request.OnStateChange + } + return webhook } @@ -176,6 +182,7 @@ func MapSpecToUpdate(webhook *executorv1.Webhook) (request testkube.WebhookUpdat request.Labels = &webhook.Labels request.Headers = &webhook.Spec.Headers request.Disabled = &webhook.Spec.Disabled + request.OnStateChange = &webhook.Spec.OnStateChange return request } diff --git a/pkg/repository/result/interface.go b/pkg/repository/result/interface.go index a10928e1917..11c8d157b57 100644 --- a/pkg/repository/result/interface.go +++ b/pkg/repository/result/interface.go @@ -39,6 +39,8 @@ type Repository interface { 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) + // GetPreviousFinishedState gets previous finished execution state by test + GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (testkube.ExecutionStatus, error) // GetLatestByTest gets latest execution result by test GetLatestByTest(ctx context.Context, testName string) (*testkube.Execution, error) // GetLatestByTests gets latest execution results by test names diff --git a/pkg/repository/result/mock_repository.go b/pkg/repository/result/mock_repository.go index a03399c72db..3c8b16aa749 100644 --- a/pkg/repository/result/mock_repository.go +++ b/pkg/repository/result/mock_repository.go @@ -289,6 +289,21 @@ func (mr *MockRepositoryMockRecorder) GetNextExecutionNumber(arg0, arg1 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNextExecutionNumber", reflect.TypeOf((*MockRepository)(nil).GetNextExecutionNumber), arg0, arg1) } +// GetPreviousFinishedState mocks base method. +func (m *MockRepository) GetPreviousFinishedState(arg0 context.Context, arg1 string, arg2 time.Time) (testkube.ExecutionStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPreviousFinishedState", arg0, arg1, arg2) + ret0, _ := ret[0].(testkube.ExecutionStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPreviousFinishedState indicates an expected call of GetPreviousFinishedState. +func (mr *MockRepositoryMockRecorder) GetPreviousFinishedState(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPreviousFinishedState", reflect.TypeOf((*MockRepository)(nil).GetPreviousFinishedState), arg0, arg1, arg2) +} + // GetTestMetrics mocks base method. func (m *MockRepository) GetTestMetrics(arg0 context.Context, arg1 string, arg2, arg3 int) (testkube.ExecutionsMetrics, error) { m.ctrl.T.Helper() diff --git a/pkg/repository/result/mongo.go b/pkg/repository/result/mongo.go index 7e058d73d5c..0f93a91fef3 100644 --- a/pkg/repository/result/mongo.go +++ b/pkg/repository/result/mongo.go @@ -878,3 +878,29 @@ func cleanSteps(steps []testkube.ExecutionStepResult) []testkube.ExecutionStepRe } return steps } + +// GetPreviousFinishedState gets previous finished execution state by test +func (r *MongoRepository) GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (testkube.ExecutionStatus, error) { + opts := options.FindOne().SetProjection(bson.M{"executionresult.status": 1}).SetSort(bson.D{{Key: "endtime", Value: -1}}) + + filter := bson.D{ + {Key: "testname", Value: testName}, + {Key: "endtime", Value: bson.M{"$lt": date}}, + {Key: "executionresult.status", Value: bson.M{"$in": []string{"passed", "failed", "skipped", "aborted", "timeout"}}}, + } + + var result testkube.Execution + err := r.ResultsColl.FindOne(ctx, filter, opts).Decode(&result) + if err != nil && err == mongo.ErrNoDocuments { + return "", nil + } + if err != nil { + return "", fmt.Errorf("error getting previous finished execution status: %w", err) + } + + if result.ExecutionResult == nil || result.ExecutionResult.Status == nil { + return "", nil + } + + return *result.ExecutionResult.Status, nil +} diff --git a/pkg/repository/testresult/interface.go b/pkg/repository/testresult/interface.go index 2fc76c2d49f..36dc7d6b470 100644 --- a/pkg/repository/testresult/interface.go +++ b/pkg/repository/testresult/interface.go @@ -41,6 +41,8 @@ type Repository interface { 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.TestSuiteExecution, error) + // GetPreviousFinishedState gets previous finished execution state by test + GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (testkube.TestSuiteExecutionStatus, error) // Insert inserts new execution result Insert(ctx context.Context, result testkube.TestSuiteExecution) error // Update updates execution result diff --git a/pkg/repository/testresult/mock_repository.go b/pkg/repository/testresult/mock_repository.go index d74f63f6cea..7e748c29566 100644 --- a/pkg/repository/testresult/mock_repository.go +++ b/pkg/repository/testresult/mock_repository.go @@ -202,6 +202,21 @@ func (mr *MockRepositoryMockRecorder) GetLatestByTestSuites(arg0, arg1 interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestByTestSuites", reflect.TypeOf((*MockRepository)(nil).GetLatestByTestSuites), arg0, arg1) } +// GetPreviousFinishedState mocks base method. +func (m *MockRepository) GetPreviousFinishedState(arg0 context.Context, arg1 string, arg2 time.Time) (testkube.TestSuiteExecutionStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPreviousFinishedState", arg0, arg1, arg2) + ret0, _ := ret[0].(testkube.TestSuiteExecutionStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPreviousFinishedState indicates an expected call of GetPreviousFinishedState. +func (mr *MockRepositoryMockRecorder) GetPreviousFinishedState(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPreviousFinishedState", reflect.TypeOf((*MockRepository)(nil).GetPreviousFinishedState), arg0, arg1, arg2) +} + // GetTestSuiteMetrics mocks base method. func (m *MockRepository) GetTestSuiteMetrics(arg0 context.Context, arg1 string, arg2, arg3 int) (testkube.ExecutionsMetrics, error) { m.ctrl.T.Helper() diff --git a/pkg/repository/testresult/mongo.go b/pkg/repository/testresult/mongo.go index b1f16a11f7e..e9b7ec14dd5 100644 --- a/pkg/repository/testresult/mongo.go +++ b/pkg/repository/testresult/mongo.go @@ -2,6 +2,7 @@ package testresult import ( "context" + "fmt" "strings" "time" @@ -541,3 +542,27 @@ func (r *MongoRepository) GetTestSuiteMetrics(ctx context.Context, name string, return metrics, nil } + +// GetPreviousFinishedState gets previous finished execution state by test +func (r *MongoRepository) GetPreviousFinishedState(ctx context.Context, testSuiteName string, date time.Time) (testkube.TestSuiteExecutionStatus, error) { + opts := options.FindOne().SetProjection(bson.M{"status": 1}).SetSort(bson.D{{Key: "endtime", Value: -1}}) + filter := bson.D{ + {Key: "testsuite.name", Value: testSuiteName}, + {Key: "endtime", Value: bson.M{"$lt": date}}, + {Key: "status", Value: bson.M{"$in": []string{"passed", "failed", "skipped", "aborted", "timeout"}}}, + } + + var result testkube.TestSuiteExecution + err := r.Coll.FindOne(ctx, filter, opts).Decode(&result) + if err != nil && err == mongo.ErrNoDocuments { + return "", nil + } + if err != nil { + return "", fmt.Errorf("error getting previous finished execution status: %w", err) + } + if result.Status == nil { + return "", nil + } + + return *result.Status, nil +} diff --git a/pkg/repository/testworkflow/interface.go b/pkg/repository/testworkflow/interface.go index 1c4596e59d9..4f0dbca1b8c 100644 --- a/pkg/repository/testworkflow/interface.go +++ b/pkg/repository/testworkflow/interface.go @@ -46,6 +46,8 @@ type Repository interface { GetExecutions(ctx context.Context, filter Filter) ([]testkube.TestWorkflowExecution, error) // GetExecutionsSummary gets executions summary using a filter, use filter with no data for all GetExecutionsSummary(ctx context.Context, filter Filter) ([]testkube.TestWorkflowExecutionSummary, error) + // GetPreviousFinishedState gets previous finished execution state by test + GetPreviousFinishedState(ctx context.Context, testName string, date time.Time) (testkube.TestWorkflowStatus, error) // Insert inserts new execution result Insert(ctx context.Context, result testkube.TestWorkflowExecution) error // Update updates execution diff --git a/pkg/repository/testworkflow/mock_output_repository.go b/pkg/repository/testworkflow/mock_output_repository.go index 502ac205e61..de7247db86e 100644 --- a/pkg/repository/testworkflow/mock_output_repository.go +++ b/pkg/repository/testworkflow/mock_output_repository.go @@ -5,11 +5,11 @@ package testworkflow import ( - "context" - "io" - "reflect" + context "context" + io "io" + reflect "reflect" - "github.com/golang/mock/gomock" + gomock "github.com/golang/mock/gomock" ) // MockOutputRepository is a mock of OutputRepository interface. diff --git a/pkg/repository/testworkflow/mock_repository.go b/pkg/repository/testworkflow/mock_repository.go index 227fbd2d28f..15d4d3cadfa 100644 --- a/pkg/repository/testworkflow/mock_repository.go +++ b/pkg/repository/testworkflow/mock_repository.go @@ -5,12 +5,12 @@ package testworkflow import ( - "context" - "reflect" + context "context" + reflect "reflect" + time "time" - "github.com/golang/mock/gomock" - - "github.com/kubeshop/testkube/pkg/api/v1/testkube" + gomock "github.com/golang/mock/gomock" + testkube "github.com/kubeshop/testkube/pkg/api/v1/testkube" ) // MockRepository is a mock of Repository interface. @@ -188,6 +188,21 @@ func (mr *MockRepositoryMockRecorder) GetLatestByTestWorkflows(arg0, arg1 interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestByTestWorkflows", reflect.TypeOf((*MockRepository)(nil).GetLatestByTestWorkflows), arg0, arg1) } +// GetPreviousFinishedState mocks base method. +func (m *MockRepository) GetPreviousFinishedState(arg0 context.Context, arg1 string, arg2 time.Time) (testkube.TestWorkflowStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPreviousFinishedState", arg0, arg1, arg2) + ret0, _ := ret[0].(testkube.TestWorkflowStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPreviousFinishedState indicates an expected call of GetPreviousFinishedState. +func (mr *MockRepositoryMockRecorder) GetPreviousFinishedState(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPreviousFinishedState", reflect.TypeOf((*MockRepository)(nil).GetPreviousFinishedState), arg0, arg1, arg2) +} + // GetRunning mocks base method. func (m *MockRepository) GetRunning(arg0 context.Context) ([]testkube.TestWorkflowExecution, error) { m.ctrl.T.Helper() diff --git a/pkg/repository/testworkflow/mongo.go b/pkg/repository/testworkflow/mongo.go index 9c938db5f4b..ef303a404bc 100644 --- a/pkg/repository/testworkflow/mongo.go +++ b/pkg/repository/testworkflow/mongo.go @@ -2,6 +2,7 @@ package testworkflow import ( "context" + "fmt" "strings" "time" @@ -430,3 +431,28 @@ func (r *MongoRepository) GetTestWorkflowMetrics(ctx context.Context, name strin return metrics, nil } + +// GetPreviousFinishedState gets previous finished execution state by test workflow +func (r *MongoRepository) GetPreviousFinishedState(ctx context.Context, testWorkflowName string, date time.Time) (testkube.TestWorkflowStatus, error) { + opts := options.FindOne().SetProjection(bson.M{"result.status": 1}).SetSort(bson.D{{Key: "result.finishedat", Value: -1}}) + filter := bson.D{ + {Key: "workflow.name", Value: testWorkflowName}, + {Key: "result.finishedat", Value: bson.M{"$lt": date}}, + {Key: "result.status", Value: bson.M{"$in": []string{"passed", "failed", "skipped", "aborted", "timeout"}}}, + } + + var result testkube.TestWorkflowExecution + err := r.Coll.FindOne(ctx, filter, opts).Decode(&result) + if err != nil && err == mongo.ErrNoDocuments { + return "", nil + } + if err != nil { + return "", fmt.Errorf("error decoding previous finished execution status: %w", err) + } + + if result.Result == nil || result.Result.Status == nil { + return "", nil + } + + return *result.Result.Status, nil +} diff --git a/pkg/testworkflows/testworkflowprocessor/mock_container.go b/pkg/testworkflows/testworkflowprocessor/mock_container.go index f652f34d073..add37ac9983 100644 --- a/pkg/testworkflows/testworkflowprocessor/mock_container.go +++ b/pkg/testworkflows/testworkflowprocessor/mock_container.go @@ -9,8 +9,8 @@ import ( gomock "github.com/golang/mock/gomock" v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" - imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" expressions "github.com/kubeshop/testkube/pkg/expressions" + imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" v10 "k8s.io/api/core/v1" ) diff --git a/pkg/testworkflows/testworkflowprocessor/mock_stage.go b/pkg/testworkflows/testworkflowprocessor/mock_stage.go index beb54a04bb9..bad244fc9e9 100644 --- a/pkg/testworkflows/testworkflowprocessor/mock_stage.go +++ b/pkg/testworkflows/testworkflowprocessor/mock_stage.go @@ -9,8 +9,8 @@ import ( gomock "github.com/golang/mock/gomock" v1 "github.com/kubeshop/testkube-operator/api/testworkflows/v1" - imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" expressions "github.com/kubeshop/testkube/pkg/expressions" + imageinspector "github.com/kubeshop/testkube/pkg/imageinspector" ) // MockStage is a mock of Stage interface.