From 9885a196302e8a7cbc3da280399b5b5bb02e3535 Mon Sep 17 00:00:00 2001 From: Feng Shao Date: Tue, 21 Apr 2026 17:55:56 +0800 Subject: [PATCH 1/4] support boolean expression in condition of dependencies if condition is embraced by parenthesises, evaluate it as boolean expression Signed-off-by: Feng Shao --- internal/chart/v3/util/dependencies.go | 10 + internal/chart/v3/util/dependencies_test.go | 71 ++++ pkg/chart/common/util/condition_expression.go | 304 ++++++++++++++++++ pkg/chart/v2/util/dependencies.go | 11 + pkg/chart/v2/util/dependencies_test.go | 71 ++++ 5 files changed, 467 insertions(+) create mode 100644 pkg/chart/common/util/condition_expression.go diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 9c4d8e80f..5bc604bf8 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -18,6 +18,7 @@ package util import ( "errors" "fmt" + "log" "log/slog" "strings" @@ -41,6 +42,15 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, return } for _, r := range reqs { + if util.IsConditionExpression(r.Condition) { + enabled, err := util.EvaluateConditionExpression(strings.TrimSpace(r.Condition), cvals, cpath, r.Name) + if err != nil { + log.Printf("Warning: Failed to parse condition expression '%s' for chart %s: %v", strings.TrimSpace(r.Condition), r.Name, err) + continue + } + r.Enabled = enabled + continue + } for c := range strings.SplitSeq(strings.TrimSpace(r.Condition), ",") { if len(c) > 0 { // retrieve value diff --git a/internal/chart/v3/util/dependencies_test.go b/internal/chart/v3/util/dependencies_test.go index c8a176725..4c5368625 100644 --- a/internal/chart/v3/util/dependencies_test.go +++ b/internal/chart/v3/util/dependencies_test.go @@ -134,6 +134,77 @@ func TestDependencyEnabled(t *testing.T) { } } +func TestProcessDependencyConditionsExpressions(t *testing.T) { + type M = map[string]any + + t.Run("expression with nesting", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "subchart1", + Condition: "(subchart1.enabled && (!subchart2.enabled || global.flag))", + Enabled: false, + }} + + vals := common.Values(M{ + "subchart1": M{"enabled": true}, + "subchart2": M{"enabled": false}, + "global": M{"flag": false}, + }) + + processDependencyConditions(reqs, vals, "") + if !reqs[0].Enabled { + t.Fatal("expected expression condition to enable dependency") + } + }) + + t.Run("expression respects chart path prefix", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "child", + Condition: "(child.enabled && !other.enabled)", + Enabled: false, + }} + + vals := common.Values(M{ + "parent": M{ + "child": M{"enabled": true}, + "other": M{"enabled": false}, + }, + }) + + processDependencyConditions(reqs, vals, "parent.") + if !reqs[0].Enabled { + t.Fatal("expected expression condition to evaluate against prefixed path") + } + }) + + t.Run("expression parse error keeps current state", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "subchart1", + Condition: "(subchart1.enabled &&)", + Enabled: true, + }} + + vals := common.Values(M{"subchart1": M{"enabled": false}}) + processDependencyConditions(reqs, vals, "") + if !reqs[0].Enabled { + t.Fatal("expected parse failure to leave enabled state unchanged") + } + }) + + t.Run("legacy comma syntax remains unchanged", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "subchart1", + Condition: "subchart1.missing,subchart1.enabled", + Enabled: false, + }} + + vals := common.Values(M{"subchart1": M{"enabled": true}}) + processDependencyConditions(reqs, vals, "") + if !reqs[0].Enabled { + t.Fatal("expected legacy fallback condition path to be used") + } + }) +} + // extractChartNames recursively searches chart dependencies returning all charts found func extractChartNames(c *chart.Chart) []string { var out []string diff --git a/pkg/chart/common/util/condition_expression.go b/pkg/chart/common/util/condition_expression.go new file mode 100644 index 000000000..630bd0cec --- /dev/null +++ b/pkg/chart/common/util/condition_expression.go @@ -0,0 +1,304 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "log" + "strings" + "unicode" + + "helm.sh/helm/v4/pkg/chart/common" +) + +type conditionExpression interface { + eval(conditionEvalContext) bool +} + +type conditionEvalContext struct { + values common.Values + condition string + chartName string + chartPath string +} + +type conditionPath struct { + path string +} + +func (n conditionPath) eval(ctx conditionEvalContext) bool { + v, err := ctx.values.PathValue(ctx.chartPath + n.path) + if err != nil { + if _, ok := err.(common.ErrNoValue); !ok { + log.Printf("Warning: PathValue returned error %v", err) + } + return false + } + + b, ok := v.(bool) + if !ok { + log.Printf("Warning: Condition path '%s' for chart %s returned non-bool value", n.path, ctx.chartName) + return false + } + return b +} + +type conditionNot struct { + expr conditionExpression +} + +func (n conditionNot) eval(ctx conditionEvalContext) bool { + return !n.expr.eval(ctx) +} + +type conditionAnd struct { + left conditionExpression + right conditionExpression +} + +func (n conditionAnd) eval(ctx conditionEvalContext) bool { + if !n.left.eval(ctx) { + return false + } + return n.right.eval(ctx) +} + +type conditionOr struct { + left conditionExpression + right conditionExpression +} + +func (n conditionOr) eval(ctx conditionEvalContext) bool { + if n.left.eval(ctx) { + return true + } + return n.right.eval(ctx) +} + +type conditionTokenType int + +const ( + conditionTokenEOF conditionTokenType = iota + conditionTokenLParen + conditionTokenRParen + conditionTokenAnd + conditionTokenOr + conditionTokenNot + conditionTokenPath +) + +type conditionToken struct { + typeID conditionTokenType + value string + pos int +} + +type conditionTokenizer struct { + input []rune + pos int +} + +func newConditionTokenizer(input string) *conditionTokenizer { + return &conditionTokenizer{input: []rune(input)} +} + +func (t *conditionTokenizer) next() (conditionToken, error) { + t.skipSpaces() + if t.pos >= len(t.input) { + return conditionToken{typeID: conditionTokenEOF, pos: t.pos}, nil + } + + switch ch := t.input[t.pos]; ch { + case '(': + t.pos++ + return conditionToken{typeID: conditionTokenLParen, pos: t.pos - 1}, nil + case ')': + t.pos++ + return conditionToken{typeID: conditionTokenRParen, pos: t.pos - 1}, nil + case '!': + t.pos++ + return conditionToken{typeID: conditionTokenNot, pos: t.pos - 1}, nil + case '&': + if t.peek('&') { + t.pos += 2 + return conditionToken{typeID: conditionTokenAnd, pos: t.pos - 2}, nil + } + return conditionToken{}, fmt.Errorf("unexpected token '&' at position %d", t.pos) + case '|': + if t.peek('|') { + t.pos += 2 + return conditionToken{typeID: conditionTokenOr, pos: t.pos - 2}, nil + } + return conditionToken{}, fmt.Errorf("unexpected token '|' at position %d", t.pos) + default: + if isConditionPathChar(ch) { + start := t.pos + for t.pos < len(t.input) && isConditionPathChar(t.input[t.pos]) { + t.pos++ + } + return conditionToken{typeID: conditionTokenPath, value: string(t.input[start:t.pos]), pos: start}, nil + } + return conditionToken{}, fmt.Errorf("unexpected token '%c' at position %d", ch, t.pos) + } +} + +func (t *conditionTokenizer) skipSpaces() { + for t.pos < len(t.input) && unicode.IsSpace(t.input[t.pos]) { + t.pos++ + } +} + +func (t *conditionTokenizer) peek(ch rune) bool { + return t.pos+1 < len(t.input) && t.input[t.pos+1] == ch +} + +func isConditionPathChar(ch rune) bool { + return unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '-' || ch == '.' +} + +type conditionParser struct { + tokenizer *conditionTokenizer + current conditionToken +} + +func parseConditionExpression(input string) (conditionExpression, error) { + p := &conditionParser{tokenizer: newConditionTokenizer(input)} + if err := p.advance(); err != nil { + return nil, err + } + + expr, err := p.parseOr() + if err != nil { + return nil, err + } + + if p.current.typeID != conditionTokenEOF { + return nil, fmt.Errorf("unexpected token '%s' at position %d", p.current.value, p.current.pos) + } + return expr, nil +} + +func (p *conditionParser) advance() error { + token, err := p.tokenizer.next() + if err != nil { + return err + } + p.current = token + return nil +} + +func (p *conditionParser) parseOr() (conditionExpression, error) { + left, err := p.parseAnd() + if err != nil { + return nil, err + } + + for p.current.typeID == conditionTokenOr { + if err := p.advance(); err != nil { + return nil, err + } + right, err := p.parseAnd() + if err != nil { + return nil, err + } + left = conditionOr{left: left, right: right} + } + + return left, nil +} + +func (p *conditionParser) parseAnd() (conditionExpression, error) { + left, err := p.parseUnary() + if err != nil { + return nil, err + } + + for p.current.typeID == conditionTokenAnd { + if err := p.advance(); err != nil { + return nil, err + } + right, err := p.parseUnary() + if err != nil { + return nil, err + } + left = conditionAnd{left: left, right: right} + } + + return left, nil +} + +func (p *conditionParser) parseUnary() (conditionExpression, error) { + if p.current.typeID == conditionTokenNot { + if err := p.advance(); err != nil { + return nil, err + } + nested, err := p.parseUnary() + if err != nil { + return nil, err + } + return conditionNot{expr: nested}, nil + } + + return p.parsePrimary() +} + +func (p *conditionParser) parsePrimary() (conditionExpression, error) { + switch p.current.typeID { + case conditionTokenPath: + path := p.current.value + if err := p.advance(); err != nil { + return nil, err + } + return conditionPath{path: path}, nil + case conditionTokenLParen: + if err := p.advance(); err != nil { + return nil, err + } + expr, err := p.parseOr() + if err != nil { + return nil, err + } + if p.current.typeID != conditionTokenRParen { + return nil, fmt.Errorf("missing closing ')' at position %d", p.current.pos) + } + if err := p.advance(); err != nil { + return nil, err + } + return expr, nil + default: + return nil, fmt.Errorf("unexpected token at position %d", p.current.pos) + } +} + +func IsConditionExpression(condition string) bool { + trimmed := strings.TrimSpace(condition) + return strings.HasPrefix(trimmed, "(") && strings.HasSuffix(trimmed, ")") +} + +func EvaluateConditionExpression(condition string, cvals common.Values, cpath, chartName string) (bool, error) { + expr, err := parseConditionExpression(condition) + if err != nil { + return false, err + } + + ctx := conditionEvalContext{ + values: cvals, + condition: condition, + chartName: chartName, + chartPath: cpath, + } + return expr.eval(ctx), nil +} diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index abd673f9d..a9087f17d 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -18,6 +18,7 @@ package util import ( "errors" "fmt" + "log" "log/slog" "strings" @@ -41,6 +42,16 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, return } for _, r := range reqs { + if util.IsConditionExpression(r.Condition) { + enabled, err := util.EvaluateConditionExpression(strings.TrimSpace(r.Condition), cvals, cpath, r.Name) + if err != nil { + log.Printf("Warning: Failed to parse condition expression '%s' for chart %s: %v", strings.TrimSpace(r.Condition), r.Name, err) + continue + } + r.Enabled = enabled + continue + } + for c := range strings.SplitSeq(strings.TrimSpace(r.Condition), ",") { if len(c) > 0 { // retrieve value diff --git a/pkg/chart/v2/util/dependencies_test.go b/pkg/chart/v2/util/dependencies_test.go index 0e4df8528..917141725 100644 --- a/pkg/chart/v2/util/dependencies_test.go +++ b/pkg/chart/v2/util/dependencies_test.go @@ -134,6 +134,77 @@ func TestDependencyEnabled(t *testing.T) { } } +func TestProcessDependencyConditionsExpressions(t *testing.T) { + type M = map[string]any + + t.Run("expression with nesting", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "subchart1", + Condition: "(subchart1.enabled && (!subchart2.enabled || global.flag))", + Enabled: false, + }} + + vals := common.Values(M{ + "subchart1": M{"enabled": true}, + "subchart2": M{"enabled": false}, + "global": M{"flag": false}, + }) + + processDependencyConditions(reqs, vals, "") + if !reqs[0].Enabled { + t.Fatal("expected expression condition to enable dependency") + } + }) + + t.Run("expression respects chart path prefix", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "child", + Condition: "(child.enabled && !other.enabled)", + Enabled: false, + }} + + vals := common.Values(M{ + "parent": M{ + "child": M{"enabled": true}, + "other": M{"enabled": false}, + }, + }) + + processDependencyConditions(reqs, vals, "parent.") + if !reqs[0].Enabled { + t.Fatal("expected expression condition to evaluate against prefixed path") + } + }) + + t.Run("expression parse error keeps current state", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "subchart1", + Condition: "(subchart1.enabled &&)", + Enabled: true, + }} + + vals := common.Values(M{"subchart1": M{"enabled": false}}) + processDependencyConditions(reqs, vals, "") + if !reqs[0].Enabled { + t.Fatal("expected parse failure to leave enabled state unchanged") + } + }) + + t.Run("legacy comma syntax remains unchanged", func(t *testing.T) { + reqs := []*chart.Dependency{{ + Name: "subchart1", + Condition: "subchart1.missing,subchart1.enabled", + Enabled: false, + }} + + vals := common.Values(M{"subchart1": M{"enabled": true}}) + processDependencyConditions(reqs, vals, "") + if !reqs[0].Enabled { + t.Fatal("expected legacy fallback condition path to be used") + } + }) +} + // extractChartNames recursively searches chart dependencies returning all charts found func extractChartNames(c *chart.Chart) []string { var out []string From cf980e5472e766c65114f2b176ca58e5733606d7 Mon Sep 17 00:00:00 2001 From: Feng Shao Date: Tue, 21 Apr 2026 18:51:00 +0800 Subject: [PATCH 2/4] per review Signed-off-by: Feng Shao --- internal/chart/v3/util/dependencies.go | 3 +- pkg/chart/common/util/condition_expression.go | 30 +++++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 5bc604bf8..815691167 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -18,7 +18,6 @@ package util import ( "errors" "fmt" - "log" "log/slog" "strings" @@ -45,7 +44,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, if util.IsConditionExpression(r.Condition) { enabled, err := util.EvaluateConditionExpression(strings.TrimSpace(r.Condition), cvals, cpath, r.Name) if err != nil { - log.Printf("Warning: Failed to parse condition expression '%s' for chart %s: %v", strings.TrimSpace(r.Condition), r.Name, err) + slog.Warn("failed to parse condition expression", "expression", strings.TrimSpace(r.Condition), "chart", r.Name, "error", err) continue } r.Enabled = enabled diff --git a/pkg/chart/common/util/condition_expression.go b/pkg/chart/common/util/condition_expression.go index 630bd0cec..fc238a284 100644 --- a/pkg/chart/common/util/condition_expression.go +++ b/pkg/chart/common/util/condition_expression.go @@ -30,7 +30,6 @@ type conditionExpression interface { type conditionEvalContext struct { values common.Values - condition string chartName string chartPath string } @@ -88,16 +87,16 @@ func (n conditionOr) eval(ctx conditionEvalContext) bool { return n.right.eval(ctx) } -type conditionTokenType int +type conditionTokenType string const ( - conditionTokenEOF conditionTokenType = iota - conditionTokenLParen - conditionTokenRParen - conditionTokenAnd - conditionTokenOr - conditionTokenNot - conditionTokenPath + conditionTokenEOF conditionTokenType = "EOF" + conditionTokenLParen conditionTokenType = "Left Parenthesis" + conditionTokenRParen conditionTokenType = "Right Parenthesis" + conditionTokenAnd conditionTokenType = "And" + conditionTokenOr conditionTokenType = "Or" + conditionTokenNot conditionTokenType = "Not" + conditionTokenPath conditionTokenType = "Path" ) type conditionToken struct { @@ -124,23 +123,23 @@ func (t *conditionTokenizer) next() (conditionToken, error) { switch ch := t.input[t.pos]; ch { case '(': t.pos++ - return conditionToken{typeID: conditionTokenLParen, pos: t.pos - 1}, nil + return conditionToken{typeID: conditionTokenLParen, value: "(", pos: t.pos - 1}, nil case ')': t.pos++ - return conditionToken{typeID: conditionTokenRParen, pos: t.pos - 1}, nil + return conditionToken{typeID: conditionTokenRParen, value: ")", pos: t.pos - 1}, nil case '!': t.pos++ - return conditionToken{typeID: conditionTokenNot, pos: t.pos - 1}, nil + return conditionToken{typeID: conditionTokenNot, value: "!", pos: t.pos - 1}, nil case '&': if t.peek('&') { t.pos += 2 - return conditionToken{typeID: conditionTokenAnd, pos: t.pos - 2}, nil + return conditionToken{typeID: conditionTokenAnd, value: "&&", pos: t.pos - 2}, nil } return conditionToken{}, fmt.Errorf("unexpected token '&' at position %d", t.pos) case '|': if t.peek('|') { t.pos += 2 - return conditionToken{typeID: conditionTokenOr, pos: t.pos - 2}, nil + return conditionToken{typeID: conditionTokenOr, value: "||", pos: t.pos - 2}, nil } return conditionToken{}, fmt.Errorf("unexpected token '|' at position %d", t.pos) default: @@ -279,7 +278,7 @@ func (p *conditionParser) parsePrimary() (conditionExpression, error) { } return expr, nil default: - return nil, fmt.Errorf("unexpected token at position %d", p.current.pos) + return nil, fmt.Errorf("unexpected token %q (type %v) at position %d", p.current.value, p.current.typeID, p.current.pos) } } @@ -296,7 +295,6 @@ func EvaluateConditionExpression(condition string, cvals common.Values, cpath, c ctx := conditionEvalContext{ values: cvals, - condition: condition, chartName: chartName, chartPath: cpath, } From c8fd943635da8e6b64d5a2251d320ea6f25a59a4 Mon Sep 17 00:00:00 2001 From: Feng Shao Date: Tue, 21 Apr 2026 18:53:56 +0800 Subject: [PATCH 3/4] per review Signed-off-by: Feng Shao --- pkg/chart/v2/util/dependencies.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index a9087f17d..f0bef8cb8 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -18,7 +18,6 @@ package util import ( "errors" "fmt" - "log" "log/slog" "strings" @@ -45,7 +44,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, if util.IsConditionExpression(r.Condition) { enabled, err := util.EvaluateConditionExpression(strings.TrimSpace(r.Condition), cvals, cpath, r.Name) if err != nil { - log.Printf("Warning: Failed to parse condition expression '%s' for chart %s: %v", strings.TrimSpace(r.Condition), r.Name, err) + slog.Warn("failed to parse condition expression", "expression", strings.TrimSpace(r.Condition), "chart", r.Name, "error", err) continue } r.Enabled = enabled From f3d205f526d547f45ec379c69897e85f67a831a6 Mon Sep 17 00:00:00 2001 From: Feng Shao Date: Tue, 21 Apr 2026 19:32:06 +0800 Subject: [PATCH 4/4] missing path and non bool as err Signed-off-by: Feng Shao --- pkg/chart/common/util/condition_expression.go | 63 ++++-- .../common/util/condition_expression_test.go | 211 ++++++++++++++++++ 2 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 pkg/chart/common/util/condition_expression_test.go diff --git a/pkg/chart/common/util/condition_expression.go b/pkg/chart/common/util/condition_expression.go index fc238a284..76e3cba47 100644 --- a/pkg/chart/common/util/condition_expression.go +++ b/pkg/chart/common/util/condition_expression.go @@ -17,7 +17,7 @@ package util import ( "fmt" - "log" + "log/slog" "strings" "unicode" @@ -25,7 +25,7 @@ import ( ) type conditionExpression interface { - eval(conditionEvalContext) bool + eval(conditionEvalContext) (bool, error) } type conditionEvalContext struct { @@ -38,29 +38,33 @@ type conditionPath struct { path string } -func (n conditionPath) eval(ctx conditionEvalContext) bool { +func (n conditionPath) eval(ctx conditionEvalContext) (bool, error) { v, err := ctx.values.PathValue(ctx.chartPath + n.path) if err != nil { if _, ok := err.(common.ErrNoValue); !ok { - log.Printf("Warning: PathValue returned error %v", err) + slog.Warn("PathValue returned error", "error", err) } - return false + return false, err } b, ok := v.(bool) if !ok { - log.Printf("Warning: Condition path '%s' for chart %s returned non-bool value", n.path, ctx.chartName) - return false + slog.Warn("condition path returned non-bool value", "path", n.path, "chart", ctx.chartName) + return false, fmt.Errorf("condition path returned non-bool value") } - return b + return b, nil } type conditionNot struct { expr conditionExpression } -func (n conditionNot) eval(ctx conditionEvalContext) bool { - return !n.expr.eval(ctx) +func (n conditionNot) eval(ctx conditionEvalContext) (bool, error) { + result, err := n.expr.eval(ctx) + if err != nil { + return false, err + } + return !result, nil } type conditionAnd struct { @@ -68,9 +72,13 @@ type conditionAnd struct { right conditionExpression } -func (n conditionAnd) eval(ctx conditionEvalContext) bool { - if !n.left.eval(ctx) { - return false +func (n conditionAnd) eval(ctx conditionEvalContext) (bool, error) { + left, err := n.left.eval(ctx) + if err != nil { + return false, err + } + if !left { + return false, nil } return n.right.eval(ctx) } @@ -80,9 +88,13 @@ type conditionOr struct { right conditionExpression } -func (n conditionOr) eval(ctx conditionEvalContext) bool { - if n.left.eval(ctx) { - return true +func (n conditionOr) eval(ctx conditionEvalContext) (bool, error) { + left, err := n.left.eval(ctx) + if err != nil { + return false, err + } + if left { + return true, nil } return n.right.eval(ctx) } @@ -282,11 +294,28 @@ func (p *conditionParser) parsePrimary() (conditionExpression, error) { } } +// IsConditionExpression reports whether condition uses the boolean-expression +// syntax understood by EvaluateConditionExpression. +// +// A condition expression must be wrapped in a single outer pair of +// parentheses after trimming whitespace, for example `(subchart.enabled && +// !global.disabled)`. Inside the outer parentheses, operands are value paths +// made of letters, digits, `_`, `-`, and `.`, and they may be combined with +// `!`, `&&`, `||`, and nested parentheses. func IsConditionExpression(condition string) bool { trimmed := strings.TrimSpace(condition) return strings.HasPrefix(trimmed, "(") && strings.HasSuffix(trimmed, ")") } +// EvaluateConditionExpression parses and evaluates a condition expression +// against the provided values. +// +// The condition must follow the syntax recognized by IsConditionExpression: +// after trimming whitespace, the full expression is expected to be enclosed in +// outer parentheses, operands are value paths containing only letters, +// digits, `_`, `-`, and `.`, and operators are limited to `!`, `&&`, `||`, +// and nested parentheses. Evaluation returns an error if any referenced path +// is missing or resolves to a non-bool value. func EvaluateConditionExpression(condition string, cvals common.Values, cpath, chartName string) (bool, error) { expr, err := parseConditionExpression(condition) if err != nil { @@ -298,5 +327,5 @@ func EvaluateConditionExpression(condition string, cvals common.Values, cpath, c chartName: chartName, chartPath: cpath, } - return expr.eval(ctx), nil + return expr.eval(ctx) } diff --git a/pkg/chart/common/util/condition_expression_test.go b/pkg/chart/common/util/condition_expression_test.go new file mode 100644 index 000000000..86790a8a0 --- /dev/null +++ b/pkg/chart/common/util/condition_expression_test.go @@ -0,0 +1,211 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "strings" + "testing" + + "helm.sh/helm/v4/pkg/chart/common" +) + +func TestIsConditionExpression(t *testing.T) { + tests := []struct { + name string + condition string + want bool + }{ + {name: "trimmed outer parentheses", condition: " (enabled && !disabled) ", want: true}, + {name: "missing leading parenthesis", condition: "enabled && !disabled)", want: false}, + {name: "missing trailing parenthesis", condition: "(enabled && !disabled", want: false}, + {name: "plain path", condition: "enabled", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsConditionExpression(test.condition); got != test.want { + t.Fatalf("IsConditionExpression(%q) = %t, want %t", test.condition, got, test.want) + } + }) + } +} + +func TestParseConditionExpression(t *testing.T) { + values := common.Values{ + "enabled": true, + "disabled": false, + "other": false, + "nested": map[string]any{ + "enabled": true, + "disabled": false, + }, + } + + tests := []struct { + name string + expression string + want bool + }{ + { + name: "and binds tighter than or", + expression: "enabled || other && disabled", + want: true, + }, + { + name: "parentheses override precedence", + expression: "(enabled || other) && disabled", + want: false, + }, + { + name: "whitespace and unary not", + expression: " !nested.disabled && nested.enabled ", + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + expr, err := parseConditionExpression(test.expression) + if err != nil { + t.Fatalf("parseConditionExpression(%q) returned error: %v", test.expression, err) + } + + got, err := expr.eval(conditionEvalContext{values: values, chartName: "chart"}) + if err != nil { + t.Fatalf("expr.eval() returned error: %v", err) + } + if got != test.want { + t.Fatalf("parseConditionExpression(%q) evaluated to %t, want %t", test.expression, got, test.want) + } + }) + } +} + +func TestEvaluateConditionExpression(t *testing.T) { + values := common.Values{ + "parent": map[string]any{ + "enabled": true, + "guard": false, + "name": "demo", + }, + "fallback": true, + } + + tests := []struct { + name string + condition string + chartPath string + want bool + wantErr string + }{ + { + name: "uses chart path prefix", + condition: "(enabled && !guard)", + chartPath: "parent.", + want: true, + }, + { + name: "short circuit or avoids missing path error", + condition: "(enabled || missing)", + chartPath: "parent.", + want: true, + }, + { + name: "short circuit and avoids missing path error", + condition: "(guard && missing)", + chartPath: "parent.", + want: false, + }, + { + name: "missing values return error", + condition: "(missing || enabled)", + chartPath: "parent.", + wantErr: "\"missing\" is not a value", + }, + { + name: "non boolean values return error", + condition: "(name || guard)", + chartPath: "parent.", + wantErr: "condition path returned non-bool value", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := EvaluateConditionExpression(test.condition, values, test.chartPath, "chart") + if test.wantErr != "" { + if err == nil { + t.Fatalf("EvaluateConditionExpression(%q) returned nil error, want %q", test.condition, test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("EvaluateConditionExpression(%q) error = %q, want substring %q", test.condition, err.Error(), test.wantErr) + } + return + } + + if err != nil { + t.Fatalf("EvaluateConditionExpression(%q) returned error: %v", test.condition, err) + } + + if got != test.want { + t.Fatalf("EvaluateConditionExpression(%q) = %t, want %t", test.condition, got, test.want) + } + }) + } +} + +func TestParseConditionExpressionErrors(t *testing.T) { + tests := []struct { + name string + expression string + wantErr string + }{ + { + name: "missing closing parenthesis", + expression: "(enabled && (other || disabled)", + wantErr: "missing closing ')'", + }, + { + name: "single ampersand", + expression: "enabled & other", + wantErr: "unexpected token '&'", + }, + { + name: "invalid character", + expression: "enabled || $other", + wantErr: "unexpected token '$'", + }, + { + name: "operator without right operand", + expression: "enabled &&", + wantErr: "unexpected token \"\"", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := parseConditionExpression(test.expression) + if err == nil { + t.Fatalf("parseConditionExpression(%q) returned nil error", test.expression) + } + + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("parseConditionExpression(%q) error = %q, want substring %q", test.expression, err.Error(), test.wantErr) + } + }) + } +}