act/pkg/runner/expression.go
Björn Brauer eba71f98fe
Refactor expression evaluator to use parser from actionlint package (#908)
* feat: implement expression evaluator

Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se>

* feat: integrate exprparser into act

Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se>

* Escape { and }, do not fail on missing properties

* Fix empty inputs context

* fix: contains() comparison for complex values

Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se>
Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
2022-01-21 08:07:20 -08:00

260 lines
6.2 KiB
Go

package runner
import (
"fmt"
"math"
"regexp"
"strings"
"github.com/nektos/act/pkg/exprparser"
log "github.com/sirupsen/logrus"
)
// ExpressionEvaluator is the interface for evaluating expressions
type ExpressionEvaluator interface {
evaluate(string, bool) (interface{}, error)
Interpolate(string) string
}
// NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator {
// todo: cleanup EvaluationEnvironment creation
job := rc.Run.Job()
strategy := make(map[string]interface{})
if job.Strategy != nil {
strategy["fail-fast"] = job.Strategy.FailFast
strategy["max-parallel"] = job.Strategy.MaxParallel
}
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.Run.Job().Needs()
using := make(map[string]map[string]map[string]string)
for _, needs := range jobNeeds {
using[needs] = map[string]map[string]string{
"outputs": jobs[needs].Outputs,
}
}
secrets := rc.Config.Secrets
if rc.Composite != nil {
secrets = nil
}
ee := &exprparser.EvaluationEnvironment{
Github: rc.getGithubContext(),
Env: rc.GetEnv(),
Job: rc.getJobContext(),
// todo: should be unavailable
// but required to interpolate/evaluate the step outputs on the job
Steps: rc.getStepsContext(),
Runner: map[string]interface{}{
"os": "Linux",
"temp": "/tmp",
"tool_cache": "/opt/hostedtoolcache",
},
Secrets: secrets,
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
Inputs: rc.Inputs,
}
return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run,
WorkingDir: rc.Config.Workdir,
Context: "job",
}),
}
}
// NewExpressionEvaluator creates a new evaluator
func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator {
rc := sc.RunContext
// todo: cleanup EvaluationEnvironment creation
job := rc.Run.Job()
strategy := make(map[string]interface{})
if job.Strategy != nil {
strategy["fail-fast"] = job.Strategy.FailFast
strategy["max-parallel"] = job.Strategy.MaxParallel
}
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.Run.Job().Needs()
using := make(map[string]map[string]map[string]string)
for _, needs := range jobNeeds {
using[needs] = map[string]map[string]string{
"outputs": jobs[needs].Outputs,
}
}
secrets := rc.Config.Secrets
if rc.Composite != nil {
secrets = nil
}
ee := &exprparser.EvaluationEnvironment{
Github: rc.getGithubContext(),
Env: rc.GetEnv(),
Job: rc.getJobContext(),
Steps: rc.getStepsContext(),
Runner: map[string]interface{}{
"os": "Linux",
"temp": "/tmp",
"tool_cache": "/opt/hostedtoolcache",
},
Secrets: secrets,
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
// todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite
Inputs: rc.Inputs,
}
return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run,
WorkingDir: rc.Config.Workdir,
Context: "step",
}),
}
}
type expressionEvaluator struct {
interpreter exprparser.Interpreter
}
func (ee expressionEvaluator) evaluate(in string, isIfExpression bool) (interface{}, error) {
evaluated, err := ee.interpreter.Evaluate(in, isIfExpression)
return evaluated, err
}
func (ee expressionEvaluator) Interpolate(in string) string {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in
}
expr, _ := rewriteSubExpression(in, true)
if in != expr {
log.Debugf("expression '%s' rewritten to '%s'", in, expr)
}
evaluated, err := ee.evaluate(expr, false)
if err != nil {
log.Errorf("Unable to interpolate expression '%s': %s", expr, err)
return ""
}
log.Debugf("expression '%s' evaluated to '%s'", expr, evaluated)
value, ok := evaluated.(string)
if !ok {
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
}
return value
}
// EvalBool evaluates an expression against given evaluator
func EvalBool(evaluator ExpressionEvaluator, expr string) (bool, error) {
nextExpr, _ := rewriteSubExpression(expr, false)
if expr != nextExpr {
log.Debugf("expression '%s' rewritten to '%s'", expr, nextExpr)
}
evaluated, err := evaluator.evaluate(nextExpr, true)
if err != nil {
return false, err
}
var result bool
switch t := evaluated.(type) {
case bool:
result = t
case string:
result = t != ""
case int:
result = t != 0
case float64:
if math.IsNaN(t) {
result = false
} else {
result = t != 0
}
default:
return false, fmt.Errorf("Unable to map return type to boolean for '%s'", expr)
}
log.Debugf("expression '%s' evaluated to '%t'", nextExpr, result)
return result, nil
}
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
//nolint:gocyclo
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in, nil
}
strPattern := regexp.MustCompile("(?:''|[^'])*'")
pos := 0
exprStart := -1
strStart := -1
var results []string
formatOut := ""
for pos < len(in) {
if strStart > -1 {
matches := strPattern.FindStringIndex(in[pos:])
if matches == nil {
panic("unclosed string.")
}
strStart = -1
pos += matches[1]
} else if exprStart > -1 {
exprEnd := strings.Index(in[pos:], "}}")
strStart = strings.Index(in[pos:], "'")
if exprEnd > -1 && strStart > -1 {
if exprEnd < strStart {
strStart = -1
} else {
exprEnd = -1
}
}
if exprEnd > -1 {
formatOut += fmt.Sprintf("{%d}", len(results))
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
pos += exprEnd + 2
exprStart = -1
} else if strStart > -1 {
pos += strStart + 1
} else {
panic("unclosed expression.")
}
} else {
exprStart = strings.Index(in[pos:], "${{")
if exprStart != -1 {
formatOut += escapeFormatString(in[pos : pos+exprStart])
exprStart = pos + exprStart + 3
pos = exprStart
} else {
formatOut += escapeFormatString(in[pos:])
pos = len(in)
}
}
}
if len(results) == 1 && formatOut == "{0}" && !forceFormat {
return in, nil
}
return fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")), nil
}