From 751dda76a1cf8f882426196a9e822b35f25f7a9e Mon Sep 17 00:00:00 2001 From: Chitrang Patel Date: Fri, 10 Nov 2023 16:18:13 -0500 Subject: [PATCH] TEP-0142: Surface step results via termination message This PR surfaces step results (i.e results written to $(step.results..path)) via termination messages. A followup PR will handle surfacing the results via sidecar logs. --- cmd/entrypoint/main.go | 9 +++ .../v1/taskruns/alpha/stepaction-results.yaml | 56 ++++++++++++++++++ pkg/entrypoint/entrypointer.go | 27 ++++++++- pkg/pod/entrypoint.go | 58 +++++++++++++++++-- pkg/pod/entrypoint_test.go | 20 +++---- pkg/pod/pod.go | 9 ++- pkg/reconciler/taskrun/resources/apply.go | 19 +++++- pkg/reconciler/taskrun/resources/taskspec.go | 6 +- pkg/reconciler/taskrun/taskrun_test.go | 1 - 9 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 examples/v1/taskruns/alpha/stepaction-results.yaml diff --git a/cmd/entrypoint/main.go b/cmd/entrypoint/main.go index 54422d789e5..5046fea50cc 100644 --- a/cmd/entrypoint/main.go +++ b/cmd/entrypoint/main.go @@ -59,6 +59,8 @@ var ( enableSpire = flag.Bool("enable_spire", false, "If specified by configmap, this enables spire signing and verification") socketPath = flag.String("spire_socket_path", "unix:///spiffe-workload-api/spire-agent.sock", "Experimental: The SPIRE agent socket for SPIFFE workload API.") resultExtractionMethod = flag.String("result_from", featureFlags.ResultExtractionMethodTerminationMessage, "The method using which to extract results from tasks. Default is using the termination message.") + stepName = flag.String("step_name", "", "Name of the step") + stepResults = flag.String("step_results", "", "step results if specified") ) const ( @@ -145,6 +147,11 @@ func main() { } spireWorkloadAPI = spire.NewEntrypointerAPIClient(&spireConfig) } + stepRes := map[string]string{} + err := json.Unmarshal([]byte(*stepResults), &stepRes) + if err != nil { + log.Fatal(err) + } e := entrypoint.Entrypointer{ Command: append(cmd, commandArgs...), @@ -159,11 +166,13 @@ func main() { }, PostWriter: &realPostWriter{}, Results: strings.Split(*results, ","), + StepResults: stepRes, Timeout: timeout, BreakpointOnFailure: *breakpointOnFailure, OnError: *onError, StepMetadataDir: *stepMetadataDir, SpireWorkloadAPI: spireWorkloadAPI, + StepName: *stepName, ResultExtractionMethod: *resultExtractionMethod, } diff --git a/examples/v1/taskruns/alpha/stepaction-results.yaml b/examples/v1/taskruns/alpha/stepaction-results.yaml new file mode 100644 index 00000000000..75b889fd61d --- /dev/null +++ b/examples/v1/taskruns/alpha/stepaction-results.yaml @@ -0,0 +1,56 @@ +apiVersion: tekton.dev/v1alpha1 +kind: StepAction +metadata: + name: step-action-uri +spec: + results: + - name: uri + params: + - name: uri + image: alpine + script: | + echo $(params.uri) > $(step.results.uri.path) +--- +apiVersion: tekton.dev/v1alpha1 +kind: StepAction +metadata: + name: step-action-uri-digest +spec: + results: + - name: digest + - name: uri + params: + - name: uri + - name: digest + image: alpine + script: | + echo $(params.digest) > $(step.results.digest.path) + echo $(params.uri) > $(step.results.uri.path) +--- +apiVersion: tekton.dev/v1 +kind: TaskRun +metadata: + name: step-action-run +spec: + TaskSpec: + results: + - name: step-1-uri + value: $(steps.step1.results.uri) + - name: step-2-uri + value: $(steps.step2.results.uri) + - name: digest + steps: + - name: step1 + ref: + name: step-action-uri + params: + - name: uri + value: "https://github.com/tektoncd/pipeline" + name: step2 + - ref: + name: step-action-uri-digest + params: + - name: uri + value: "https://github.com/tektoncd/other" + - name: digest + value: "c8381846241cac4c93c30b6a5ac04cac51fa0a6e" diff --git a/pkg/entrypoint/entrypointer.go b/pkg/entrypoint/entrypointer.go index ac7baf16dfb..79fafe5a874 100644 --- a/pkg/entrypoint/entrypointer.go +++ b/pkg/entrypoint/entrypointer.go @@ -96,6 +96,8 @@ type Entrypointer struct { // PostWriter encapsulates writing files when complete. PostWriter PostWriter + // StepResults is the set of files that might contain step results + StepResults map[string]string // Results is the set of files that might contain task results Results []string // Timeout is an optional user-specified duration within which the Step must complete @@ -110,6 +112,8 @@ type Entrypointer struct { StepMetadataDir string // SpireWorkloadAPI connects to spire and does obtains SVID based on taskrun SpireWorkloadAPI spire.EntrypointerAPIClient + // StepName is the name of the step + StepName string // ResultsDirectory is the directory to find results, defaults to pipeline.DefaultResultPath ResultsDirectory string // ResultExtractionMethod is the method using which the controller extracts the results from the task pod. @@ -136,6 +140,7 @@ type PostWriter interface { // Go optionally waits for a file, runs the command, and writes a // post file. func (e Entrypointer) Go() error { + var err error prod, _ := zap.NewProduction() logger := prod.Sugar() @@ -147,6 +152,11 @@ func (e Entrypointer) Go() error { _ = logger.Sync() }() + if e.StepName != "" && e.StepResults != nil { + if err := os.MkdirAll(filepath.Join(pipeline.StepsDir, e.StepName, "results"), os.ModePerm); err != nil { + return err + } + } for _, f := range e.WaitFiles { if err := e.Waiter.Wait(context.Background(), f, e.WaitFileContent, e.BreakpointOnFailure); err != nil { // An error happened while waiting, so we bail @@ -170,7 +180,6 @@ func (e Entrypointer) Go() error { ResultType: result.InternalTektonResultType, }) - var err error if e.Timeout != nil && *e.Timeout < time.Duration(0) { err = fmt.Errorf("negative timeout specified") } @@ -232,7 +241,7 @@ func (e Entrypointer) Go() error { // strings.Split(..) with an empty string returns an array that contains one element, an empty string. // This creates an error when trying to open the result folder as a file. - if len(e.Results) >= 1 && e.Results[0] != "" { + if (len(e.Results) >= 1 && e.Results[0] != "") || len(e.StepResults) > 0 { resultPath := pipeline.DefaultResultPath if e.ResultsDirectory != "" { resultPath = e.ResultsDirectory @@ -264,6 +273,20 @@ func (e Entrypointer) readResultsFromDisk(ctx context.Context, resultDir string) ResultType: result.TaskRunResultType, }) } + for resultName, resultPath := range e.StepResults { + fileContents, err := os.ReadFile(resultPath) + if os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + // if the file doesn't exist, ignore it + output = append(output, result.RunResult{ + Key: resultName, + Value: string(fileContents), + ResultType: result.TaskRunResultType, + }) + } if e.SpireWorkloadAPI != nil { signed, err := e.SpireWorkloadAPI.Sign(ctx, output) if err != nil { diff --git a/pkg/pod/entrypoint.go b/pkg/pod/entrypoint.go index d7cd3506405..8dbf2c989b7 100644 --- a/pkg/pod/entrypoint.go +++ b/pkg/pod/entrypoint.go @@ -124,7 +124,7 @@ var ( // command, we must have fetched the image's ENTRYPOINT before calling this // method, using entrypoint_lookup.go. // Additionally, Step timeouts are added as entrypoint flag. -func orderContainers(commonExtraEntrypointArgs []string, steps []corev1.Container, taskSpec *v1.TaskSpec, breakpointConfig *v1.TaskRunDebug, waitForReadyAnnotation, enableKeepPodOnCancel bool) ([]corev1.Container, error) { +func orderContainers(ctx context.Context, commonExtraEntrypointArgs []string, steps []corev1.Container, taskSpec *v1.TaskSpec, breakpointConfig *v1.TaskRunDebug, waitForReadyAnnotation, enableKeepPodOnCancel bool) ([]corev1.Container, error) { if len(steps) == 0 { return nil, errors.New("No steps specified") } @@ -143,12 +143,17 @@ func orderContainers(commonExtraEntrypointArgs []string, steps []corev1.Containe } else { // Not the first step - wait for previous argsForEntrypoint = append(argsForEntrypoint, "-wait_file", filepath.Join(RunDir, strconv.Itoa(i-1), "out")) } + stepName := StepName(s.Name, i, false) argsForEntrypoint = append(argsForEntrypoint, // Start next step. "-post_file", filepath.Join(RunDir, idx, "out"), "-termination_path", terminationPath, "-step_metadata_dir", filepath.Join(RunDir, idx, "status"), ) + if config.FromContextOrDefaults(ctx).FeatureFlags.EnableStepActions { + argsForEntrypoint = append(argsForEntrypoint, "-step_name", stepName) + } + argsForEntrypoint = append(argsForEntrypoint, commonExtraEntrypointArgs...) if taskSpec != nil { if taskSpec.Steps != nil && len(taskSpec.Steps) >= i+1 { @@ -170,6 +175,13 @@ func orderContainers(commonExtraEntrypointArgs []string, steps []corev1.Containe } } argsForEntrypoint = append(argsForEntrypoint, resultArgument(steps, taskSpec.Results)...) + if config.FromContextOrDefaults(ctx).FeatureFlags.EnableStepActions { + stepResultArgs, err := stepResultArgument(taskSpec.Results, stepName) + if err != nil { + return nil, err + } + argsForEntrypoint = append(argsForEntrypoint, stepResultArgs...) + } } if breakpointConfig != nil && breakpointConfig.NeedsDebugOnFailure() { @@ -199,6 +211,36 @@ func orderContainers(commonExtraEntrypointArgs []string, steps []corev1.Containe return steps, nil } +func stepResultArgument(results []v1.TaskResult, stepName string) ([]string, error) { + if len(results) == 0 { + return nil, nil + } + if stepName == "" { + return nil, nil + } + res := map[string]string{} + for _, r := range results { + if r.Value != nil { + if r.Value.StringVal != "" { + sName, resultName, err := v1.ExtractStepResultName(r.Value.StringVal) + if err != nil { + return nil, err + } + if stepName == sName { + res[r.Name] = filepath.Join(pipeline.StepsDir, stepName, "results", resultName) + } + } + } else { + res[r.Name] = filepath.Join(pipeline.StepsDir, stepName, "results", r.Name) + } + } + resBytes, err := json.Marshal(res) + if err != nil { + return nil, err + } + return []string{"-step_results", string(resBytes)}, nil +} + func resultArgument(steps []corev1.Container, results []v1.TaskResult) []string { if len(results) == 0 { return nil @@ -209,7 +251,9 @@ func resultArgument(steps []corev1.Container, results []v1.TaskResult) []string func collectResultsName(results []v1.TaskResult) string { var resultNames []string for _, r := range results { - resultNames = append(resultNames, r.Name) + if r.Value == nil { + resultNames = append(resultNames, r.Name) + } } return strings.Join(resultNames, ",") } @@ -333,9 +377,13 @@ func TrimSidecarPrefix(name string) string { return strings.TrimPrefix(name, sid // StepName returns the step name after adding "step-" prefix to the actual step name or // returns "step-unnamed-" if not specified -func StepName(name string, i int) string { +func StepName(name string, i int, addPrefix bool) string { + prefix := "" + if addPrefix { + prefix = stepPrefix + } if name != "" { - return fmt.Sprintf("%s%s", stepPrefix, name) + return fmt.Sprintf("%s%s", prefix, name) } - return fmt.Sprintf("%sunnamed-%d", stepPrefix, i) + return fmt.Sprintf("%sunnamed-%d", prefix, i) } diff --git a/pkg/pod/entrypoint_test.go b/pkg/pod/entrypoint_test.go index 56d57c8a146..7ab6c3c308e 100644 --- a/pkg/pod/entrypoint_test.go +++ b/pkg/pod/entrypoint_test.go @@ -95,7 +95,7 @@ func TestOrderContainers(t *testing.T) { }, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, nil, nil, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, nil, nil, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -163,7 +163,7 @@ func TestOrderContainersWithResultsSidecarLogs(t *testing.T) { }, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{"-dont_send_results_to_termination_path"}, steps, nil, nil, true, false) + got, err := orderContainers(context.Background(), []string{"-dont_send_results_to_termination_path"}, steps, nil, nil, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -209,7 +209,7 @@ func TestOrderContainersWithNoWait(t *testing.T) { VolumeMounts: []corev1.VolumeMount{volumeMount}, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, nil, nil, false, false) + got, err := orderContainers(context.Background(), []string{}, steps, nil, nil, false, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -245,7 +245,7 @@ func TestOrderContainersWithDebugOnFailure(t *testing.T) { OnFailure: "enabled", }, } - got, err := orderContainers([]string{}, steps, nil, taskRunDebugConfig, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, nil, taskRunDebugConfig, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -273,7 +273,7 @@ func TestOrderContainersWithEnabelKeepPodOnCancel(t *testing.T) { VolumeMounts: []corev1.VolumeMount{downwardMount}, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, nil, nil, false, true) + got, err := orderContainers(context.Background(), []string{}, steps, nil, nil, false, true) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -351,7 +351,7 @@ func TestEntryPointResults(t *testing.T) { }, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, &taskSpec, nil, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, &taskSpec, nil, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -392,7 +392,7 @@ func TestEntryPointResultsSingleStep(t *testing.T) { VolumeMounts: []corev1.VolumeMount{downwardMount}, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, &taskSpec, nil, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, &taskSpec, nil, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -429,7 +429,7 @@ func TestEntryPointSingleResultsSingleStep(t *testing.T) { VolumeMounts: []corev1.VolumeMount{downwardMount}, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, &taskSpec, nil, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, &taskSpec, nil, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } @@ -500,7 +500,7 @@ func TestEntryPointOnError(t *testing.T) { err: errors.New("task step onError must be either \"continue\" or \"stopAndFail\" but it is set to an invalid value \"invalid-on-error\""), }} { t.Run(tc.desc, func(t *testing.T) { - got, err := orderContainers([]string{}, steps, &tc.taskSpec, nil, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, &tc.taskSpec, nil, true, false) if len(tc.wantContainers) == 0 { if err == nil { t.Fatalf("expected an error for an invalid value for onError but received none") @@ -599,7 +599,7 @@ func TestEntryPointStepOutputConfigs(t *testing.T) { }, TerminationMessagePath: "/tekton/termination", }} - got, err := orderContainers([]string{}, steps, &taskSpec, nil, true, false) + got, err := orderContainers(context.Background(), []string{}, steps, &taskSpec, nil, true, false) if err != nil { t.Fatalf("orderContainers: %v", err) } diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index cfc7d69d683..526f34f5f44 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -84,7 +84,6 @@ var ( }, { Name: "tekton-internal-steps", MountPath: pipeline.StepsDir, - ReadOnly: true, }} implicitVolumes = []corev1.Volume{{ Name: "tekton-internal-workspace", @@ -239,9 +238,9 @@ func (b *Builder) Build(ctx context.Context, taskRun *v1.TaskRun, taskSpec v1.Ta readyImmediately := isPodReadyImmediately(*featureFlags, taskSpec.Sidecars) if alphaAPIEnabled { - stepContainers, err = orderContainers(commonExtraEntrypointArgs, stepContainers, &taskSpec, taskRun.Spec.Debug, !readyImmediately, enableKeepPodOnCancel) + stepContainers, err = orderContainers(ctx, commonExtraEntrypointArgs, stepContainers, &taskSpec, taskRun.Spec.Debug, !readyImmediately, enableKeepPodOnCancel) } else { - stepContainers, err = orderContainers(commonExtraEntrypointArgs, stepContainers, &taskSpec, nil, !readyImmediately, enableKeepPodOnCancel) + stepContainers, err = orderContainers(ctx, commonExtraEntrypointArgs, stepContainers, &taskSpec, nil, !readyImmediately, enableKeepPodOnCancel) } if err != nil { return nil, err @@ -350,7 +349,7 @@ func (b *Builder) Build(ctx context.Context, taskRun *v1.TaskRun, taskSpec v1.Ta // TODO(#1605): Remove this loop and make each transformation in // isolation. for i, s := range stepContainers { - stepContainers[i].Name = names.SimpleNameGenerator.RestrictLength(StepName(s.Name, i)) + stepContainers[i].Name = names.SimpleNameGenerator.RestrictLength(StepName(s.Name, i, true)) } // Add podTemplate Volumes to the explicitly declared use volumes @@ -537,7 +536,7 @@ func entrypointInitContainer(image string, steps []v1.Step, setSecurityContext, // into the correct location for later steps and initialize steps folder command := []string{"/ko-app/entrypoint", "init", "/ko-app/entrypoint", entrypointBinary} for i, s := range steps { - command = append(command, StepName(s.Name, i)) + command = append(command, StepName(s.Name, i, true)) } volumeMounts := []corev1.VolumeMount{binMount, internalStepsMount} securityContext := linuxSecurityContext diff --git a/pkg/reconciler/taskrun/resources/apply.go b/pkg/reconciler/taskrun/resources/apply.go index 0af13115fda..f2e0b367c3a 100644 --- a/pkg/reconciler/taskrun/resources/apply.go +++ b/pkg/reconciler/taskrun/resources/apply.go @@ -24,6 +24,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" "github.com/tektoncd/pipeline/pkg/container" "github.com/tektoncd/pipeline/pkg/pod" "github.com/tektoncd/pipeline/pkg/substitution" @@ -44,14 +45,20 @@ var ( // FIXME(vdemeester) Remove that with deprecating v1beta1 "inputs.params.%s", } + stepActionResultPatterns = []string{ + "step.results.%s.path", + "step.results[%q].path", + "step.results['%s'].path", + } ) // applyStepActionParameters applies the params from the Task and the underlying Step to the referenced StepAction. -func applyStepActionParameters(step *v1.Step, spec *v1.TaskSpec, tr *v1.TaskRun, stepParams v1.Params, defaults []v1.ParamSpec) *v1.Step { +func applyStepActionParametersAndResults(step *v1.Step, spec *v1.TaskSpec, tr *v1.TaskRun, stepParams v1.Params, stepActionSpec v1alpha1.StepActionSpec, stepName string) *v1.Step { if stepParams != nil { stringR, arrayR, objectR := getTaskParameters(spec, tr, spec.Params...) stepParams = stepParams.ReplaceVariables(stringR, arrayR, objectR) } + defaults := stepActionSpec.Params // Set params from StepAction defaults stringReplacements, arrayReplacements, _ := replacementsFromDefaultParams(defaults) @@ -63,6 +70,12 @@ func applyStepActionParameters(step *v1.Step, spec *v1.TaskSpec, tr *v1.TaskRun, for k, v := range stepArrays { arrayReplacements[k] = v } + // Overwrite step result path reference + for _, result := range stepActionSpec.Results { + for _, pattern := range stepActionResultPatterns { + stringReplacements[fmt.Sprintf(pattern, result.Name)] = filepath.Join(pipeline.StepsDir, stepName, "results", result.Name) + } + } container.ApplyStepReplacements(step, stringReplacements, arrayReplacements) return step @@ -282,8 +295,8 @@ func ApplyStepExitCodePath(spec *v1.TaskSpec) *v1.TaskSpec { stringReplacements := map[string]string{} for i, step := range spec.Steps { - stringReplacements[fmt.Sprintf("steps.%s.exitCode.path", pod.StepName(step.Name, i))] = - filepath.Join(pipeline.StepsDir, pod.StepName(step.Name, i), "exitCode") + stringReplacements[fmt.Sprintf("steps.%s.exitCode.path", pod.StepName(step.Name, i, true))] = + filepath.Join(pipeline.StepsDir, pod.StepName(step.Name, i, true), "exitCode") } return ApplyReplacements(spec, stringReplacements, map[string][]string{}, map[string]map[string]string{}) } diff --git a/pkg/reconciler/taskrun/resources/taskspec.go b/pkg/reconciler/taskrun/resources/taskspec.go index cf24b19b977..05c5d272415 100644 --- a/pkg/reconciler/taskrun/resources/taskspec.go +++ b/pkg/reconciler/taskrun/resources/taskspec.go @@ -25,6 +25,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" clientset "github.com/tektoncd/pipeline/pkg/client/clientset/versioned" resolutionutil "github.com/tektoncd/pipeline/pkg/internal/resolution" + "github.com/tektoncd/pipeline/pkg/pod" remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" "github.com/tektoncd/pipeline/pkg/trustedresources" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -103,7 +104,7 @@ func GetTaskData(ctx context.Context, taskRun *v1.TaskRun, getTask GetTask) (*re // GetStepActionsData extracts the StepActions and merges them with the inlined Step specification. func GetStepActionsData(ctx context.Context, taskSpec v1.TaskSpec, taskRun *v1.TaskRun, tekton clientset.Interface, k8s kubernetes.Interface, requester remoteresource.Requester) ([]v1.Step, error) { steps := []v1.Step{} - for _, step := range taskSpec.Steps { + for i, step := range taskSpec.Steps { s := step.DeepCopy() if step.Ref != nil { getStepAction := GetStepActionFunc(tekton, k8s, requester, taskRun, s) @@ -134,7 +135,8 @@ func GetStepActionsData(ctx context.Context, taskSpec v1.TaskSpec, taskRun *v1.T if err := validateStepHasStepActionParameters(s.Params, stepActionSpec.Params); err != nil { return nil, err } - s = applyStepActionParameters(s, &taskSpec, taskRun, s.Params, stepActionSpec.Params) + stepName := pod.StepName(s.Name, i, false) + s = applyStepActionParametersAndResults(s, &taskSpec, taskRun, s.Params, stepActionSpec, stepName) s.Params = nil s.Ref = nil steps = append(steps, *s) diff --git a/pkg/reconciler/taskrun/taskrun_test.go b/pkg/reconciler/taskrun/taskrun_test.go index 18654d536c6..26badd1d43f 100644 --- a/pkg/reconciler/taskrun/taskrun_test.go +++ b/pkg/reconciler/taskrun/taskrun_test.go @@ -5127,7 +5127,6 @@ func podVolumeMounts(idx, totalSteps int) []corev1.VolumeMount { mnts = append(mnts, corev1.VolumeMount{ Name: "tekton-internal-steps", MountPath: "/tekton/steps", - ReadOnly: true, }) return mnts