diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 9c4d8e80f..815691167 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -41,6 +41,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 { + slog.Warn("failed to parse condition expression", "expression", strings.TrimSpace(r.Condition), "chart", r.Name, "error", 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..76e3cba47 --- /dev/null +++ b/pkg/chart/common/util/condition_expression.go @@ -0,0 +1,331 @@ +/* +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/slog" + "strings" + "unicode" + + "helm.sh/helm/v4/pkg/chart/common" +) + +type conditionExpression interface { + eval(conditionEvalContext) (bool, error) +} + +type conditionEvalContext struct { + values common.Values + chartName string + chartPath string +} + +type conditionPath struct { + path string +} + +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 { + slog.Warn("PathValue returned error", "error", err) + } + return false, err + } + + b, ok := v.(bool) + if !ok { + 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, nil +} + +type conditionNot struct { + expr conditionExpression +} + +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 { + left conditionExpression + right conditionExpression +} + +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) +} + +type conditionOr struct { + left conditionExpression + right conditionExpression +} + +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) +} + +type conditionTokenType string + +const ( + 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 { + 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, value: "(", pos: t.pos - 1}, nil + case ')': + t.pos++ + return conditionToken{typeID: conditionTokenRParen, value: ")", pos: t.pos - 1}, nil + case '!': + t.pos++ + return conditionToken{typeID: conditionTokenNot, value: "!", pos: t.pos - 1}, nil + case '&': + if t.peek('&') { + t.pos += 2 + 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, value: "||", 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 %q (type %v) at position %d", p.current.value, p.current.typeID, p.current.pos) + } +} + +// 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 { + return false, err + } + + ctx := conditionEvalContext{ + values: cvals, + chartName: chartName, + chartPath: cpath, + } + 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) + } + }) + } +} diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index abd673f9d..f0bef8cb8 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -41,6 +41,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 { + slog.Warn("failed to parse condition expression", "expression", strings.TrimSpace(r.Condition), "chart", r.Name, "error", 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