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 }