support boolean expression in condition of dependencies

if condition is embraced by parenthesises, evaluate it as boolean
expression

Signed-off-by: Feng Shao <shaof777@gmail.com>
pull/32063/head
Feng Shao 3 weeks ago
parent 29d309e56b
commit 9885a19630

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

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

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

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