Skip to content

Commit fe0af11

Browse files
argo-cd-cherry-pick-bot[bot]Joibelclaude
authored
fix: tolerate expression template runtime failures when allowUnresolved is true. Fixes #15832, #15824 (cherry-pick #15839 for 4.0) (#15850)
Signed-off-by: Alan Clucas <alan@clucas.org> Co-authored-by: Alan Clucas <alan@clucas.org> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 107542b commit fe0af11

File tree

6 files changed

+281
-2
lines changed

6 files changed

+281
-2
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
apiVersion: argoproj.io/v1alpha1
2+
kind: Workflow
3+
metadata:
4+
generateName: dag-when-expr-skip-
5+
spec:
6+
entrypoint: main
7+
arguments:
8+
parameters:
9+
- name: data
10+
value: ''
11+
templates:
12+
- name: main
13+
dag:
14+
tasks:
15+
- name: A
16+
template: echo
17+
arguments:
18+
parameters:
19+
- name: message
20+
value: A
21+
- name: B
22+
dependencies: [A]
23+
when: "{{= workflow.parameters.data != '' }}"
24+
template: echo
25+
arguments:
26+
parameters:
27+
- name: message
28+
value: "{{= jsonpath(workflow.parameters.data, '$.id') }}"
29+
- name: echo
30+
inputs:
31+
parameters:
32+
- name: message
33+
container:
34+
image: argoproj/argosay:v2
35+
args: ["echo", "{{inputs.parameters.message}}"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
apiVersion: argoproj.io/v1alpha1
2+
kind: Workflow
3+
metadata:
4+
generateName: steps-when-expr-filter-
5+
spec:
6+
entrypoint: main
7+
templates:
8+
- name: main
9+
inputs:
10+
parameters:
11+
- name: test
12+
value: 'true'
13+
- name: list
14+
value: "{{= concat(['always'], inputs.parameters.test == 'true' ? ['test'] : []) | toJSON() }}"
15+
steps:
16+
- - name: fst
17+
template: run
18+
when: |
19+
"{{= get(item, 'type') ?? 'always' }}"
20+
in
21+
("{{= inputs.parameters.list | fromJSON() | join('","') }}","")
22+
withParam: |
23+
[
24+
{ "name": "first", "type": "" },
25+
{ "name": "second", "type": "always" },
26+
{ "name": "third", "type": "test" },
27+
{ "name": "fourth" }
28+
]
29+
arguments:
30+
parameters:
31+
- name: name
32+
value: "{{ item.name }}{{ inputs.parameters.list }}"
33+
- name: run
34+
inputs:
35+
parameters:
36+
- name: name
37+
container:
38+
image: argoproj/argosay:v2
39+
args: ["echo", "{{inputs.parameters.name}}"]

test/e2e/functional_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,54 @@ func (s *FunctionalSuite) TestDAGSkippedOutputRef() {
511511
})
512512
}
513513

514+
func (s *FunctionalSuite) TestStepsWhenExprFilter() {
515+
s.Given().
516+
Workflow("@functional/steps-when-expr-filter.yaml").
517+
When().
518+
SubmitWorkflow().
519+
WaitForWorkflow().
520+
Then().
521+
ExpectWorkflow(func(t *testing.T, _ *metav1.ObjectMeta, status *wfv1.WorkflowStatus) {
522+
assert.Equal(t, wfv1.WorkflowSucceeded, status.Phase)
523+
// "first" has type "" which matches the empty string in the list
524+
node0 := status.Nodes.FindByDisplayName("fst(0:name:first,type:)")
525+
if assert.NotNil(t, node0) {
526+
assert.Equal(t, wfv1.NodeSucceeded, node0.Phase)
527+
}
528+
// "second" has type "always" which is in the list
529+
node1 := status.Nodes.FindByDisplayName("fst(1:name:second,type:always)")
530+
if assert.NotNil(t, node1) {
531+
assert.Equal(t, wfv1.NodeSucceeded, node1.Phase)
532+
}
533+
// "third" has type "test" which is in the list (since test=true)
534+
node2 := status.Nodes.FindByDisplayName("fst(2:name:third,type:test)")
535+
if assert.NotNil(t, node2) {
536+
assert.Equal(t, wfv1.NodeSucceeded, node2.Phase)
537+
}
538+
// "fourth" has no type, defaults to "always" via ??
539+
node3 := status.Nodes.FindByDisplayName("fst(3:name:fourth)")
540+
if assert.NotNil(t, node3) {
541+
assert.Equal(t, wfv1.NodeSucceeded, node3.Phase)
542+
}
543+
})
544+
}
545+
546+
func (s *FunctionalSuite) TestDAGWhenExprSkip() {
547+
s.Given().
548+
Workflow("@functional/dag-when-expr-skip.yaml").
549+
When().
550+
SubmitWorkflow().
551+
WaitForWorkflow().
552+
Then().
553+
ExpectWorkflow(func(t *testing.T, _ *metav1.ObjectMeta, status *wfv1.WorkflowStatus) {
554+
assert.Equal(t, wfv1.WorkflowSucceeded, status.Phase)
555+
nodeB := status.Nodes.FindByDisplayName("B")
556+
if assert.NotNil(t, nodeB) {
557+
assert.Equal(t, wfv1.NodeSkipped, nodeB.Phase)
558+
}
559+
})
560+
}
561+
514562
// 128M is for argo executor
515563
func (s *FunctionalSuite) TestPendingRetryWorkflow() {
516564
s.Given().

util/template/expression_template.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,10 @@ func expressionReplaceStrict(ctx context.Context, w io.Writer, expression string
7878
}
7979

8080
// If we have missing identifiers but they are NOT strict, we allow unresolved.
81-
// If we have NO missing identifiers, we enforce resolution (to catch runtime errors).
82-
allowUnresolved := len(missingIdentifiers) > 0
81+
// If we have NO missing identifiers, we enforce resolution (to catch runtime errors),
82+
// unless the caller allows unresolved (strictRegex == nil), in which case runtime
83+
// failures are tolerated and the expression is left unresolved for later evaluation.
84+
allowUnresolved := len(missingIdentifiers) > 0 || strictRegex == nil
8385
return expressionReplaceCore(ctx, w, expression, env, allowUnresolved)
8486
}
8587

workflow/controller/dag_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4120,3 +4120,71 @@ func TestDAGSkippedOutputRef(t *testing.T) {
41204120
require.NotNil(t, nodeC, "stage-c should be created even though stage-b was skipped")
41214121
assert.Equal(t, wfv1.NodePending, nodeC.Phase)
41224122
}
4123+
4124+
var dagWhenExprSkipEval = `
4125+
apiVersion: argoproj.io/v1alpha1
4126+
kind: Workflow
4127+
metadata:
4128+
generateName: dag-when-expr-skip-eval-
4129+
spec:
4130+
entrypoint: main
4131+
arguments:
4132+
parameters:
4133+
- name: data
4134+
value: ''
4135+
templates:
4136+
- name: main
4137+
dag:
4138+
tasks:
4139+
- name: A
4140+
template: echo
4141+
when: "false"
4142+
arguments:
4143+
parameters:
4144+
- name: message
4145+
value: A
4146+
- name: B
4147+
dependencies: [A]
4148+
when: "{{= workflow.parameters.data != '' }}"
4149+
template: echo
4150+
arguments:
4151+
parameters:
4152+
- name: message
4153+
value: "{{= jsonpath(workflow.parameters.data, '$.id') }}"
4154+
- name: echo
4155+
inputs:
4156+
parameters:
4157+
- name: message
4158+
container:
4159+
image: alpine:3.23
4160+
command: [echo, "{{inputs.parameters.message}}"]
4161+
`
4162+
4163+
// TestDAGWhenExprSkipEval verifies that expression templates in a DAG task's arguments are not
4164+
// evaluated when the task's "when" clause evaluates to false.
4165+
// Scenario: workflow parameter "data" is empty. Task B has when: "{{= workflow.parameters.data != ” }}"
4166+
// which evaluates to false. B's argument uses jsonpath(workflow.parameters.data, '$.id') which would
4167+
// fail on an empty string. The expression should not be evaluated since the task will be skipped.
4168+
// Currently fails because SubstituteParams evaluates all expression templates in the entire DAG
4169+
// template before individual task "when" conditions are checked.
4170+
func TestDAGWhenExprSkipEval(t *testing.T) {
4171+
ctx := logging.TestContext(t.Context())
4172+
cancel, controller := newController(ctx)
4173+
defer cancel()
4174+
wfcset := controller.wfclientset.ArgoprojV1alpha1().Workflows("")
4175+
4176+
wf := wfv1.MustUnmarshalWorkflow(dagWhenExprSkipEval)
4177+
wf, err := wfcset.Create(ctx, wf, metav1.CreateOptions{})
4178+
require.NoError(t, err)
4179+
woc := newWorkflowOperationCtx(ctx, wf, controller)
4180+
4181+
woc.operate(ctx)
4182+
4183+
// Workflow should succeed: B's when clause evaluates to false so B should be skipped
4184+
// without evaluating B's argument expressions.
4185+
assert.Equal(t, wfv1.WorkflowSucceeded, woc.wf.Status.Phase)
4186+
4187+
nodeB := woc.wf.Status.Nodes.FindByDisplayName("B")
4188+
require.NotNil(t, nodeB)
4189+
assert.Equal(t, wfv1.NodeSkipped, nodeB.Phase)
4190+
}

workflow/controller/steps_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,3 +719,90 @@ func TestStepsWhenSkipNoRequeue(t *testing.T) {
719719
require.NotNil(t, nodeB)
720720
assert.Equal(t, wfv1.NodeSkipped, nodeB.Phase)
721721
}
722+
723+
var stepsWhenExprWithParamFilter = `
724+
apiVersion: argoproj.io/v1alpha1
725+
kind: Workflow
726+
metadata:
727+
generateName: steps-when-expr-filter-
728+
spec:
729+
entrypoint: main
730+
templates:
731+
- name: main
732+
inputs:
733+
parameters:
734+
- name: test
735+
value: 'true'
736+
- name: list
737+
value: "{{= concat(['always'], inputs.parameters.test == 'true' ? ['test'] : []) | toJSON() }}"
738+
steps:
739+
- - name: fst
740+
template: run
741+
when: |
742+
"{{= get(item, 'type') ?? 'always' }}"
743+
in
744+
("{{= inputs.parameters.list | fromJSON() | join('","') }}","")
745+
withParam: |
746+
[
747+
{ "name": "first", "type": "" },
748+
{ "name": "second", "type": "always" },
749+
{ "name": "third", "type": "test" },
750+
{ "name": "fourth" }
751+
]
752+
arguments:
753+
parameters:
754+
- name: name
755+
value: "{{ item.name }}{{ inputs.parameters.list }}"
756+
- name: run
757+
inputs:
758+
parameters:
759+
- name: name
760+
container:
761+
image: alpine:3.23
762+
command: [echo]
763+
args: ["{{inputs.parameters.name}}"]
764+
`
765+
766+
// TestStepsWhenExprWithParamFilter verifies that expression templates work correctly
767+
// in a steps workflow with withParam expansion and a when clause that filters items
768+
// using expression functions (concat, get, ??, toJSON, fromJSON, join).
769+
// This mirrors a real-world pattern where a dynamic list parameter controls which
770+
// withParam items execute.
771+
func TestStepsWhenExprWithParamFilter(t *testing.T) {
772+
ctx := logging.TestContext(t.Context())
773+
cancel, controller := newController(ctx)
774+
defer cancel()
775+
wfcset := controller.wfclientset.ArgoprojV1alpha1().Workflows("")
776+
777+
wf := wfv1.MustUnmarshalWorkflow(stepsWhenExprWithParamFilter)
778+
wf, err := wfcset.Create(ctx, wf, metav1.CreateOptions{})
779+
require.NoError(t, err)
780+
woc := newWorkflowOperationCtx(ctx, wf, controller)
781+
782+
woc.operate(ctx)
783+
784+
// Workflow is Running because pods haven't completed, but we can verify:
785+
// 1. No error occurred during expression evaluation
786+
// 2. All 4 items were expanded and scheduled (none were incorrectly skipped/errored)
787+
assert.Equal(t, wfv1.WorkflowRunning, woc.wf.Status.Phase)
788+
789+
// "first" has type "" which matches empty string in the filter list
790+
node0 := woc.wf.Status.Nodes.FindByDisplayName("fst(0:name:first,type:)")
791+
require.NotNil(t, node0)
792+
assert.Equal(t, wfv1.NodePending, node0.Phase)
793+
794+
// "second" has type "always" which is in the filter list
795+
node1 := woc.wf.Status.Nodes.FindByDisplayName("fst(1:name:second,type:always)")
796+
require.NotNil(t, node1)
797+
assert.Equal(t, wfv1.NodePending, node1.Phase)
798+
799+
// "third" has type "test" which is in the filter list (test=true)
800+
node2 := woc.wf.Status.Nodes.FindByDisplayName("fst(2:name:third,type:test)")
801+
require.NotNil(t, node2)
802+
assert.Equal(t, wfv1.NodePending, node2.Phase)
803+
804+
// "fourth" has no type, defaults to "always" via ?? operator
805+
node3 := woc.wf.Status.Nodes.FindByDisplayName("fst(3:name:fourth)")
806+
require.NotNil(t, node3)
807+
assert.Equal(t, wfv1.NodePending, node3.Phase)
808+
}

0 commit comments

Comments
 (0)