Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions test/e2e/functional/steps-skipped-output-ref.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: steps-skipped-output-ref-
spec:
entrypoint: main
templates:
- name: main
steps:
- - name: job1
template: run-job
- - name: job2
template: run-job
when: "\"{{steps.job1.outputs.parameters.status}}\" == \"FAILED\""
- - name: job2-error-handler
template: run-job
when: "\"{{steps.job2.outputs.parameters.status}}\" != \"SUCCESS\" && \"{{steps.job1.outputs.parameters.status}}\" == \"SUCCESS\""
- name: run-job
outputs:
parameters:
- name: status
valueFrom:
path: /tmp/status.txt
container:
image: argoproj/argosay:v2
args: ["echo", "SUCCESS", "/tmp/status.txt"]
29 changes: 29 additions & 0 deletions test/e2e/functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,35 @@ func (s *FunctionalSuite) TestDAGSkippedOutputRef() {
})
}

// TestStepsSkippedOutputRef tests that output references to skipped steps resolve to empty strings
// rather than causing an unresolvable reference error. NodeOmitted is not exercised here because
// that phase arises in DAG templates, not steps; the fix covers both phases in the same code path.
func (s *FunctionalSuite) TestStepsSkippedOutputRef() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test exercises NodeSkipped but never NodeOmitted. Both phases are handled in the fix. Since Omitted is a distinct node phase that arises in DAG templates (not steps), it may require a separate workflow fixture, but at least a comment explaining why it isn't tested would clarify the intent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, sorry for that, but honestly its my first time I wrote golang and did something in argo -

why it isn't tested would clarify the intent.

honestly - I just fixed the case which blocks us, that's the reason, I can try to fix other cases as well but this can take some time.

If its enough to write comment, can you please sketch some comment I can paste ? I will probably not create anything meaningful :/

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@utafrali I pushed following comment on that test

// TestStepsSkippedOutputRef tests that output references to skipped steps resolve to empty strings
// rather than causing an unresolvable reference error. NodeOmitted is not exercised here because
// that phase arises in DAG templates, not steps; the fix covers both phases in the same code path.

s.Given().
Workflow("@functional/steps-skipped-output-ref.yaml").
When().
SubmitWorkflow().
WaitForWorkflow().
Then().
ExpectWorkflow(func(t *testing.T, _ *metav1.ObjectMeta, status *wfv1.WorkflowStatus) {
assert.Equal(t, wfv1.WorkflowSucceeded, status.Phase)
nodeJob1 := status.Nodes.FindByDisplayName("job1")
if assert.NotNil(t, nodeJob1) {
assert.Equal(t, wfv1.NodeSucceeded, nodeJob1.Phase)
}
// job2 is skipped because job1 succeeded (not failed)
nodeJob2 := status.Nodes.FindByDisplayName("job2")
if assert.NotNil(t, nodeJob2) {
assert.Equal(t, wfv1.NodeSkipped, nodeJob2.Phase)
}
// job2-error-handler runs: job2 output resolves to "" so "" != "SUCCESS" is true
nodeHandler := status.Nodes.FindByDisplayName("job2-error-handler")
if assert.NotNil(t, nodeHandler) {
assert.Equal(t, wfv1.NodeSucceeded, nodeHandler.Phase)
}
})
}

func (s *FunctionalSuite) TestStepsWhenExprFilter() {
s.Given().
Workflow("@functional/steps-when-expr-filter.yaml").
Expand Down
4 changes: 2 additions & 2 deletions workflow/controller/dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,10 +715,10 @@ func (woc *wfOperationCtx) buildLocalScopeFromTask(ctx context.Context, dagCtx *
if err == nil && tmpl != nil {
for _, param := range tmpl.Outputs.Parameters {
key := fmt.Sprintf("%s.outputs.parameters.%s", prefix, param.Name)
scope.addParamToScope(key, "")
scope.addSkippedParamToScope(key)
}
if tmpl.Outputs.Result != nil {
scope.addParamToScope(fmt.Sprintf("%s.outputs.result", prefix), "")
scope.addSkippedParamToScope(fmt.Sprintf("%s.outputs.result", prefix))
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions workflow/controller/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -3314,6 +3314,9 @@ func (woc *wfOperationCtx) getTemplateOutputsFromScope(ctx context.Context, tmpl
return nil, err
}
val = param.ValueFrom.Default.String()
} else if param.ValueFrom.Default != nil && scope.isSkippedOutput(param.ValueFrom.Parameter) {
// The referenced step was skipped/omitted and produced no output; use the declared default.
val = param.ValueFrom.Default.String()
}
param.Value = wfv1.AnyStringPtr(val)
param.ValueFrom = nil
Expand Down
27 changes: 23 additions & 4 deletions workflow/controller/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"maps"
"strings"

"github.com/expr-lang/expr"

Expand All @@ -17,14 +18,16 @@ import (

// wfScope contains the current scope of variables available when executing a template
type wfScope struct {
tmpl *wfv1.Template
scope map[string]any
tmpl *wfv1.Template
scope map[string]any
skippedOutputs map[string]bool
}

func createScope(tmpl *wfv1.Template) *wfScope {
scope := &wfScope{
tmpl: tmpl,
scope: make(map[string]any),
tmpl: tmpl,
scope: make(map[string]any),
skippedOutputs: make(map[string]bool),
}
if tmpl != nil {
for _, param := range scope.tmpl.Inputs.Parameters {
Expand Down Expand Up @@ -59,6 +62,22 @@ func (s *wfScope) addArtifactToScope(key string, artifact wfv1.Artifact) {
s.scope[key] = artifact
}

// addSkippedParamToScope registers a parameter key with an empty value to satisfy reference
// resolution for skipped/omitted steps, and marks it so callers can distinguish it from a
// legitimately empty output.
func (s *wfScope) addSkippedParamToScope(key string) {
s.scope[key] = ""
s.skippedOutputs[key] = true
}

// isSkippedOutput reports whether the given scope key was populated as a placeholder for a
// skipped/omitted step (as opposed to a step that genuinely produced an empty string output).
// The key may optionally be wrapped in "{{" / "}}" as it appears in ValueFrom.Parameter.
func (s *wfScope) isSkippedOutput(key string) bool {
key = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(key, "{{"), "}}"))
return s.skippedOutputs[key]
}

// resolveVar resolves a parameter or artifact
func (s *wfScope) resolveVar(v string) (any, error) {
m := make(map[string]any)
Expand Down
22 changes: 22 additions & 0 deletions workflow/controller/steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,28 @@ func (woc *wfOperationCtx) executeSteps(ctx context.Context, nodeName string, tm
woc.buildLocalScope(stepsCtx.scope, prefix, sgNode)
} else {
woc.buildLocalScope(stepsCtx.scope, prefix, childNode)

if (childNode.Phase == wfv1.NodeSkipped || childNode.Phase == wfv1.NodeOmitted) && childNode.Outputs == nil {
_, stepTmpl, _, resolveErr := stepsCtx.tmplCtx.ResolveTemplate(ctx, &step)

if resolveErr != nil {
woc.log.WithError(resolveErr).Debug(ctx, "failed to resolve template for skipped step, outputs will not be populated in scope")
}

if resolveErr == nil && stepTmpl != nil {
for _, param := range stepTmpl.Outputs.Parameters {
key := fmt.Sprintf("%s.outputs.parameters.%s", prefix, param.Name)
stepsCtx.scope.addSkippedParamToScope(key)
}
for _, artifact := range stepTmpl.Outputs.Artifacts {
key := fmt.Sprintf("%s.outputs.artifacts.%s", prefix, artifact.Name)
stepsCtx.scope.addArtifactToScope(key, wfv1.Artifact{})
}
if stepTmpl.Outputs.Result != nil {
stepsCtx.scope.addSkippedParamToScope(fmt.Sprintf("%s.outputs.result", prefix))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix handles Parameters and Result but skips Artifacts. If any downstream step (or when expression) references steps.<skipped>.outputs.artifacts.<name>, it will still fail with an unresolvable reference. addArtifactToScope accepts a wfv1.Artifact value, so you can register a zero-value placeholder:

for _, artifact := range tmpl.Outputs.Artifacts {
    key := fmt.Sprintf("%s.outputs.artifacts.%s", prefix, artifact.Name)
    stepsCtx.scope.addArtifactToScope(key, wfv1.Artifact{})
}

This mirrors how the parameter case is handled above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applied

}
}
}
}
}
Expand Down
Loading