pull/32063/merge
Feng Shao 2 days ago committed by GitHub
commit d602b7e56f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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

@ -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)
}

@ -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)
}
})
}
}

@ -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

@ -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

Loading…
Cancel
Save