From ab6d0a45e638803adbd06ada0810fcb15a287016 Mon Sep 17 00:00:00 2001 From: Daniel Baptista Dias Date: Wed, 31 May 2023 12:12:18 -0300 Subject: [PATCH] feat(cli): Allow user to reference protobuf file path on Test yaml from CLI (#2620) * Adding grpc e2e test * Updating tests * Adding feature of read protobuf file from path and injecting it on gRPC trigger * Adding validation to the test * Adding PR suggestion --- cli/actions/run_test_action.go | 55 +++++++++ cli/utils/common.go | 9 ++ .../server-setup/docker-compose-no-api.yaml | 68 +++++++++++ .../server-setup/docker-compose-pokeshop.yaml | 112 ++++++++++++++++++ .../jaeger/server-setup/docker-compose.yaml | 65 ---------- testing/cli-e2etest/environment/manager.go | 105 ++++++++++++---- .../testscenarios/test/resources/api.proto | 35 ++++++ .../grpc-trigger-embedded-protobuf.yaml | 58 +++++++++ .../grpc-trigger-reference-protobuf.yaml | 22 ++++ .../test/run_test_with_grpc_trigger_test.go | 55 +++++++++ 10 files changed, 499 insertions(+), 85 deletions(-) create mode 100644 testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-no-api.yaml create mode 100644 testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-pokeshop.yaml delete mode 100644 testing/cli-e2etest/environment/jaeger/server-setup/docker-compose.yaml create mode 100644 testing/cli-e2etest/testscenarios/test/resources/api.proto create mode 100644 testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-embedded-protobuf.yaml create mode 100644 testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-reference-protobuf.yaml create mode 100644 testing/cli-e2etest/testscenarios/test/run_test_with_grpc_trigger_test.go diff --git a/cli/actions/run_test_action.go b/cli/actions/run_test_action.go index 791d63bb87..e39df9432f 100644 --- a/cli/actions/run_test_action.go +++ b/cli/actions/run_test_action.go @@ -112,6 +112,17 @@ func (a runTestAction) testFileToID(ctx context.Context, originalPath, filePath return "", err } + if t, err := f.Definition().Test(); err == nil && t.Trigger.Type == "grpc" { + newFile, err := getUpdatedTestWithGrpcTrigger(f, t) + if err != nil { + return "", err + } + + if newFile != nil { // has new file, update it + f = *newFile + } + } + body, _, err := a.client.ApiApi. UpsertDefinition(ctx). TextDefinition(openapi.TextDefinition{ @@ -182,6 +193,17 @@ func (a runTestAction) runDefinitionFile(ctx context.Context, f file.File, param } } + if t, err := f.Definition().Test(); err == nil && t.Trigger.Type == "grpc" { + newFile, err := getUpdatedTestWithGrpcTrigger(f, t) + if err != nil { + return err + } + + if newFile != nil { // has new file, update it + f = *newFile + } + } + variables := make([]openapi.EnvironmentValue, 0) for name, value := range params.EnvironmentVariables { variables = append(variables, openapi.EnvironmentValue{Key: openapi.PtrString(name), Value: openapi.PtrString(value)}) @@ -578,3 +600,36 @@ func getTestRunIDFromString(testRunIDAsString string) int32 { func getTestRunID(testRun openapi.TestRun) int32 { return getTestRunIDFromString(testRun.GetId()) } + +func getUpdatedTestWithGrpcTrigger(f file.File, t yaml.Test) (*file.File, error) { + protobufFile := filepath.Join(f.AbsDir(), t.Trigger.GRPC.ProtobufFile) + + if !utils.StringReferencesFile(protobufFile) { + return nil, nil + } + + // referencing a file, keep the value + fileContent, err := utils.ReadFileContent(protobufFile) + if err != nil { + return nil, fmt.Errorf(`cannot read protobuf file: %w`, err) + } + + t.Trigger.GRPC.ProtobufFile = fileContent + + def := yaml.File{ + Type: yaml.FileTypeTest, + Spec: t, + } + + updated, err := def.Encode() + if err != nil { + return nil, fmt.Errorf(`cannot encode updated test: %w`, err) + } + + f, err = file.New(f.Path(), updated) + if err != nil { + return nil, fmt.Errorf(`cannot recreate updated file: %w`, err) + } + + return &f, nil +} diff --git a/cli/utils/common.go b/cli/utils/common.go index 5579db0b59..7f31202049 100644 --- a/cli/utils/common.go +++ b/cli/utils/common.go @@ -21,6 +21,15 @@ func IOReadCloserToString(r io.ReadCloser) string { return string(b) } +func ReadFileContent(filePath string) (string, error) { + fileContent, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + return string(fileContent), nil +} + func StringReferencesFile(path string) bool { // for the current working dir, check if the file exists // by finding its absolute path and executing a stat command diff --git a/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-no-api.yaml b/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-no-api.yaml new file mode 100644 index 0000000000..4b068a1e44 --- /dev/null +++ b/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-no-api.yaml @@ -0,0 +1,68 @@ +version: '3' +services: + tracetest: + image: kubeshop/tracetest:${TAG:-latest} + volumes: + - type: bind + source: ./tracetest-config.yaml + target: /app/tracetest.yaml + - type: bind + source: ./tracetest-provision.yaml + target: /app/provision.yaml + command: --provisioning-file /app/provision.yaml + ports: + - 11633:11633 + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + otel-collector: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--spider", "localhost:11633"] + interval: 1s + timeout: 3s + retries: 60 + environment: + TRACETEST_DEV: ${TRACETEST_DEV} + + postgres: + image: postgres:14 + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + healthcheck: + test: pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" + interval: 1s + timeout: 5s + retries: 60 + ports: + - 5432 + + otel-collector: + image: otel/opentelemetry-collector:0.54.0 + command: + - "--config" + - "/otel-local-config.yaml" + volumes: + - ./collector.config.yaml:/otel-local-config.yaml + depends_on: + - jaeger + ports: + - 4317 + + jaeger: + image: jaegertracing/all-in-one:latest + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "localhost:16686"] + interval: 1s + timeout: 3s + retries: 60 + ports: + - 16685 + +networks: + default: + name: _default diff --git a/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-pokeshop.yaml b/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-pokeshop.yaml new file mode 100644 index 0000000000..a9e72e3b78 --- /dev/null +++ b/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose-pokeshop.yaml @@ -0,0 +1,112 @@ +services: + cache: + healthcheck: + test: + - CMD + - redis-cli + - ping + timeout: 3s + interval: 1s + retries: 60 + image: redis:6 + restart: unless-stopped + + demo-api: + depends_on: + cache: + condition: service_healthy + postgres: + condition: service_healthy + queue: + condition: service_healthy + environment: + COLLECTOR_ENDPOINT: http://otel-collector:4317 + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + NPM_RUN_COMMAND: api + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + RABBITMQ_HOST: queue + REDIS_URL: cache + healthcheck: + test: + - CMD + - wget + - --spider + - localhost:8081 + timeout: 3s + interval: 1s + retries: 60 + image: kubeshop/demo-pokemon-api:latest + ports: + - mode: ingress + target: 8081 + published: 8081 + protocol: tcp + pull_policy: always + restart: unless-stopped + + demo-rpc: + depends_on: + cache: + condition: service_healthy + postgres: + condition: service_healthy + queue: + condition: service_healthy + environment: + COLLECTOR_ENDPOINT: http://otel-collector:4317 + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + NPM_RUN_COMMAND: rpc + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + RABBITMQ_HOST: queue + REDIS_URL: cache + healthcheck: + test: + - CMD + - lsof + - -i + - "8082" + timeout: 3s + interval: 1s + retries: 60 + image: kubeshop/demo-pokemon-api:latest + ports: + - mode: ingress + target: 8082 + published: 8082 + protocol: tcp + pull_policy: always + restart: unless-stopped + + demo-worker: + depends_on: + cache: + condition: service_healthy + postgres: + condition: service_healthy + queue: + condition: service_healthy + environment: + COLLECTOR_ENDPOINT: http://otel-collector:4317 + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + NPM_RUN_COMMAND: worker + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + RABBITMQ_HOST: queue + REDIS_URL: cache + image: kubeshop/demo-pokemon-api:latest + pull_policy: always + restart: unless-stopped + + queue: + healthcheck: + test: + - CMD-SHELL + - rabbitmq-diagnostics -q check_running + timeout: 5s + interval: 1s + retries: 60 + image: rabbitmq:3.8-management + restart: unless-stopped + +networks: + default: + name: _default diff --git a/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose.yaml b/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose.yaml deleted file mode 100644 index fec0f8aa32..0000000000 --- a/testing/cli-e2etest/environment/jaeger/server-setup/docker-compose.yaml +++ /dev/null @@ -1,65 +0,0 @@ -version: '3' -services: - - tracetest: - image: kubeshop/tracetest:${TAG:-latest} - volumes: - - type: bind - source: ./tracetest-config.yaml - target: /app/tracetest.yaml - - type: bind - source: ./tracetest-provision.yaml - target: /app/provision.yaml - command: --provisioning-file /app/provision.yaml - ports: - - 11633:11633 - extra_hosts: - - "host.docker.internal:host-gateway" - depends_on: - postgres: - condition: service_healthy - otel-collector: - condition: service_started - healthcheck: - test: ["CMD", "wget", "--spider", "localhost:11633"] - interval: 1s - timeout: 3s - retries: 60 - environment: - TRACETEST_DEV: ${TRACETEST_DEV} - - postgres: - image: postgres:14 - environment: - POSTGRES_PASSWORD: postgres - POSTGRES_USER: postgres - healthcheck: - test: pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" - interval: 1s - timeout: 5s - retries: 60 - ports: - - 5432 - - otel-collector: - image: otel/opentelemetry-collector:0.54.0 - command: - - "--config" - - "/otel-local-config.yaml" - volumes: - - ./collector.config.yaml:/otel-local-config.yaml - depends_on: - - jaeger - ports: - - 4317 - - jaeger: - image: jaegertracing/all-in-one:latest - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--spider", "localhost:16686"] - interval: 1s - timeout: 3s - retries: 60 - ports: - - 16685 diff --git a/testing/cli-e2etest/environment/manager.go b/testing/cli-e2etest/environment/manager.go index cdbf685edd..d6481043e2 100644 --- a/testing/cli-e2etest/environment/manager.go +++ b/testing/cli-e2etest/environment/manager.go @@ -12,6 +12,7 @@ import ( "github.com/kubeshop/tracetest/cli-e2etest/command" "github.com/kubeshop/tracetest/cli-e2etest/config" "github.com/kubeshop/tracetest/cli-e2etest/helpers" + "github.com/kubeshop/tracetest/cli-e2etest/tracetestcli" "github.com/stretchr/testify/require" "golang.org/x/exp/slices" ) @@ -31,13 +32,18 @@ type Manager interface { GetTestResourcePath(t *testing.T, resourceName string) string } +type option func(*internalManager) + type internalManager struct { - environmentType string - dockerComposeFilePath string - dockerProjectName string + environmentType string + dockerComposeNoApiFilePath string + dockerComposePokeshopFilePath string + dockerProjectName string + pokeshopEnabled bool + datastoreEnabled bool } -func CreateAndStart(t *testing.T) Manager { +func CreateAndStart(t *testing.T, options ...option) Manager { mutex.Lock() defer mutex.Unlock() @@ -47,12 +53,24 @@ func CreateAndStart(t *testing.T) Manager { t.Fatalf("environment %s not registered", environmentName) } - environment := GetManager(environmentName) + environment := GetManager(environmentName, options...) environment.Start(t) return environment } +func WithPokeshop() option { + return func(im *internalManager) { + im.pokeshopEnabled = true + } +} + +func WithDataStoreEnabled() option { + return func(im *internalManager) { + im.datastoreEnabled = true + } +} + func getExecutingDir() string { _, filename, _, _ := runtime.Caller(0) // get file of the getExecutingDir caller return path.Dir(filename) @@ -66,17 +84,25 @@ func getExecutingDir() string { // to use something like github.com/testcontainers/testcontainers-go // (github.com/testcontainers/testcontainers-go/modules/compose in specific) -func GetManager(environmentType string) Manager { +func GetManager(environmentType string, options ...option) Manager { currentDir := getExecutingDir() - dockerComposeFilepath := fmt.Sprintf("%s/%s/server-setup/docker-compose.yaml", currentDir, environmentType) + dockerComposeNoApiFilepath := fmt.Sprintf("%s/%s/server-setup/docker-compose-no-api.yaml", currentDir, environmentType) + dockerComposePokeshopFilepath := fmt.Sprintf("%s/%s/server-setup/docker-compose-pokeshop.yaml", currentDir, environmentType) atomic.AddInt64(&envCounter, 1) - return &internalManager{ - environmentType: environmentType, - dockerComposeFilePath: dockerComposeFilepath, - dockerProjectName: fmt.Sprintf("tracetest-env-%d", envCounter), + manager := &internalManager{ + environmentType: environmentType, + dockerComposeNoApiFilePath: dockerComposeNoApiFilepath, + dockerComposePokeshopFilePath: dockerComposePokeshopFilepath, + dockerProjectName: fmt.Sprintf("tracetest-env-%d", envCounter), + } + + for _, option := range options { + option(manager) } + + return manager } func (m *internalManager) Name() string { @@ -84,29 +110,68 @@ func (m *internalManager) Name() string { } func (m *internalManager) Start(t *testing.T) { - result, err := command.Exec( - "docker", "compose", - "--file", m.dockerComposeFilePath, // choose docker compose relative to the chosen environment + readiness := 1 * time.Second + args := []string{ + "compose", + "--file", m.dockerComposeNoApiFilePath, // choose docker compose relative to the chosen environment "--project-name", m.dockerProjectName, // create a project name to isolate this scenario - "up", "--detach") + "up", "--detach", + } + + if m.pokeshopEnabled { + readiness = 10 * time.Second + args = []string{ + "compose", + "--file", m.dockerComposeNoApiFilePath, // choose docker compose relative to the chosen environment + "--file", m.dockerComposePokeshopFilePath, // choose docker compose relative to the chosen environment + "--project-name", m.dockerProjectName, // create a project name to isolate this scenario + "up", "--detach", + } + } + + result, err := command.Exec("docker", args...) require.NoError(t, err) helpers.RequireExitCodeEqual(t, result, 0) // TODO: think in a better way to assure readiness for Tracetest - time.Sleep(1000 * time.Millisecond) + // like https://golang.testcontainers.org/quickstart/ "Wait for Log" method + time.Sleep(readiness) + + if m.datastoreEnabled { + cliConfig := m.GetCLIConfigPath(t) + dataStorePath := m.GetEnvironmentResourcePath(t, "data-store") + + result = tracetestcli.Exec(t, fmt.Sprintf("apply datastore --file %s", dataStorePath), tracetestcli.WithCLIConfig(cliConfig)) + helpers.RequireExitCodeEqual(t, result, 0) + } } func (m *internalManager) Close(t *testing.T) { - result, err := command.Exec( - "docker", "compose", - "--file", m.dockerComposeFilePath, // choose docker compose relative to the chosen environment + args := []string{ + "compose", + "--file", m.dockerComposeNoApiFilePath, // choose docker compose relative to the chosen environment "--project-name", m.dockerProjectName, // choose isolated project name "rm", "--force", // bypass removal question "--volumes", // remove volumes attached to this project "--stop", // force containers to stop - ) + } + + if m.pokeshopEnabled { + args = []string{ + "compose", + "--file", m.dockerComposeNoApiFilePath, // choose docker compose relative to the chosen environment + "--file", m.dockerComposePokeshopFilePath, // choose docker compose relative to the chosen environment + "--project-name", m.dockerProjectName, // choose isolated project name + "rm", + "--force", // bypass removal question + "--volumes", // remove volumes attached to this project + "--stop", // force containers to stop + } + } + + result, err := command.Exec("docker", args...) require.NoError(t, err) helpers.RequireExitCodeEqual(t, result, 0) } diff --git a/testing/cli-e2etest/testscenarios/test/resources/api.proto b/testing/cli-e2etest/testscenarios/test/resources/api.proto new file mode 100644 index 0000000000..3c2350ee07 --- /dev/null +++ b/testing/cli-e2etest/testscenarios/test/resources/api.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_outer_classname = "PokeshopProto"; +option objc_class_prefix = "PKS"; + +package pokeshop; + +service Pokeshop { + rpc getPokemonList (GetPokemonRequest) returns (GetPokemonListResponse) {} + rpc createPokemon (Pokemon) returns (Pokemon) {} + rpc importPokemon (ImportPokemonRequest) returns (ImportPokemonRequest) {} +} + +message ImportPokemonRequest { + int32 id = 1; +} + +message GetPokemonRequest { + optional int32 skip = 1; + optional int32 take = 2; +} + +message GetPokemonListResponse { + repeated Pokemon items = 1; + int32 totalCount = 2; +} + +message Pokemon { + optional int32 id = 1; + string name = 2; + string type = 3; + bool isFeatured = 4; + optional string imageUrl = 5; +} diff --git a/testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-embedded-protobuf.yaml b/testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-embedded-protobuf.yaml new file mode 100644 index 0000000000..d96869127f --- /dev/null +++ b/testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-embedded-protobuf.yaml @@ -0,0 +1,58 @@ +type: Test +spec: + id: create-pokemon + name: 'Create Pokemon' + description: Create a single pokemon on Pokeshop + trigger: + type: grpc + grpc: + protobufFile: | + syntax = "proto3"; + + option java_multiple_files = true; + option java_outer_classname = "PokeshopProto"; + option objc_class_prefix = "PKS"; + + package pokeshop; + + service Pokeshop { + rpc getPokemonList (GetPokemonRequest) returns (GetPokemonListResponse) {} + rpc createPokemon (Pokemon) returns (Pokemon) {} + rpc importPokemon (ImportPokemonRequest) returns (ImportPokemonRequest) {} + } + + message ImportPokemonRequest { + int32 id = 1; + } + + message GetPokemonRequest { + optional int32 skip = 1; + optional int32 take = 2; + } + + message GetPokemonListResponse { + repeated Pokemon items = 1; + int32 totalCount = 2; + } + + message Pokemon { + optional int32 id = 1; + string name = 2; + string type = 3; + bool isFeatured = 4; + optional string imageUrl = 5; + } + + address: demo-rpc:8082 + method: pokeshop.Pokeshop.createPokemon + request: |- + { + "name": "Pikachu", + "type": "eletric", + "isFeatured": true + } + specs: + - name: It calls Pokeshop correctly + selector: span[tracetest.span.type="rpc" name="createPokemon" rpc.system="grpc" rpc.method="createPokemon" rpc.service="Pokeshop.createPokemon"] + assertions: + - attr:rpc.grpc.status_code = 0 diff --git a/testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-reference-protobuf.yaml b/testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-reference-protobuf.yaml new file mode 100644 index 0000000000..a79039c08f --- /dev/null +++ b/testing/cli-e2etest/testscenarios/test/resources/grpc-trigger-reference-protobuf.yaml @@ -0,0 +1,22 @@ +type: Test +spec: + id: create-pokemon + name: 'Create Pokemon' + description: Create a single pokemon on Pokeshop + trigger: + type: grpc + grpc: + protobufFile: ./api.proto + address: demo-rpc:8082 + method: pokeshop.Pokeshop.createPokemon + request: |- + { + "name": "Pikachu", + "type": "eletric", + "isFeatured": true + } + specs: + - name: It calls Pokeshop correctly + selector: span[tracetest.span.type="rpc" name="createPokemon" rpc.system="grpc" rpc.method="createPokemon" rpc.service="Pokeshop.createPokemon"] + assertions: + - attr:rpc.grpc.status_code = 0 diff --git a/testing/cli-e2etest/testscenarios/test/run_test_with_grpc_trigger_test.go b/testing/cli-e2etest/testscenarios/test/run_test_with_grpc_trigger_test.go new file mode 100644 index 0000000000..e626b32154 --- /dev/null +++ b/testing/cli-e2etest/testscenarios/test/run_test_with_grpc_trigger_test.go @@ -0,0 +1,55 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/kubeshop/tracetest/cli-e2etest/environment" + "github.com/kubeshop/tracetest/cli-e2etest/helpers" + "github.com/kubeshop/tracetest/cli-e2etest/tracetestcli" + "github.com/stretchr/testify/require" +) + +func TestRunTestWithGrpcTrigger(t *testing.T) { + // setup isolated e2e environment + env := environment.CreateAndStart(t, environment.WithDataStoreEnabled(), environment.WithPokeshop()) + defer env.Close(t) + + cliConfig := env.GetCLIConfigPath(t) + + t.Run("should pass when using an embedded protobuf string in the test", func(t *testing.T) { + // instantiate require with testing helper + require := require.New(t) + + // Given I am a Tracetest CLI user + // And I have my server recently created + // And the datasource is already set + + // When I try to run a test with a gRPC trigger with embedded protobuf + // Then it should pass + testFile := env.GetTestResourcePath(t, "grpc-trigger-embedded-protobuf") + + command := fmt.Sprintf("test run -w -d %s", testFile) + result := tracetestcli.Exec(t, command, tracetestcli.WithCLIConfig(cliConfig)) + helpers.RequireExitCodeEqual(t, result, 0) + require.Contains(result.StdOut, "✔ It calls Pokeshop correctly") // checks if the assertion was succeeded + }) + + t.Run("should pass when referencing a protobuf file in the test", func(t *testing.T) { + // instantiate require with testing helper + require := require.New(t) + + // Given I am a Tracetest CLI user + // And I have my server recently created + // And the datasource is already set + + // When I try to run a test with a gRPC trigger with a reference to a protobuf file + // Then it should pass + testFile := env.GetTestResourcePath(t, "grpc-trigger-reference-protobuf") + + command := fmt.Sprintf("test run -w -d %s", testFile) + result := tracetestcli.Exec(t, command, tracetestcli.WithCLIConfig(cliConfig)) + helpers.RequireExitCodeEqual(t, result, 0) + require.Contains(result.StdOut, "✔ It calls Pokeshop correctly") // checks if the assertion was succeeded + }) +}