act/pkg/runner/expression.go
Markus Wolf 679cac1677
Fix composite input handling (#1345)
* test: add test case for #1319

* fix: setup of composite inputs

This change fixes the composite action setup handling of inputs.

All inputs are taken from the env now. The env is composed of
the 'level above'.
For example:
- step env -> taken from run context
- action env -> taken from step env
- composite env -> taken from action env

Before this change the env setup for steps, actions and composite
run contexts was harder to understand as all parts looked into
one of these: parent run context, step, action, composite run context.

Now the 'data flow' is from higher levels to lower levels which should
make it more clean.

Fixes #1319

* test: add simple remote composite action test

Since we don't have a remote composite test at all
before this, we need at least the simplest case.
This does not check every feature, but ensures basic
availability of remote composite actions.

* refactor: move ActionRef and ActionRepository

Moving ActionRef and ActionRepository from RunContext into the
step, allows us to remove the - more or less - ugly copy operations
from the RunContext.

This is more clean, as each step does hold the data required anyway
and the RunContext shouldn't know about the action details.

* refactor: remove unused properties
2022-10-06 21:58:16 +00:00

321 lines
8.9 KiB
Go

package runner
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/container"
"github.com/nektos/act/pkg/exprparser"
"gopkg.in/yaml.v3"
)
// ExpressionEvaluator is the interface for evaluating expressions
type ExpressionEvaluator interface {
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (interface{}, error)
EvaluateYamlNode(context.Context, *yaml.Node) error
Interpolate(context.Context, string) string
}
// NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) 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,
}
}
inputs := make(map[string]interface{})
for k, v := range rc.GetEnv() {
if strings.HasPrefix(k, "INPUT_") {
inputs[strings.ToLower(strings.TrimPrefix(k, "INPUT_"))] = v
}
}
ee := &exprparser.EvaluationEnvironment{
Github: rc.getGithubContext(ctx),
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",
"arch": container.RunnerArch(ctx),
"temp": "/tmp",
"tool_cache": "/opt/hostedtoolcache",
},
Secrets: rc.Config.Secrets,
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
Inputs: inputs,
}
return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run,
WorkingDir: rc.Config.Workdir,
Context: "job",
}),
}
}
// NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) 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,
}
}
inputs := make(map[string]interface{})
for k, v := range *step.getEnv() {
if strings.HasPrefix(k, "INPUT_") {
inputs[strings.ToLower(strings.TrimPrefix(k, "INPUT_"))] = v
}
}
ee := &exprparser.EvaluationEnvironment{
Github: step.getGithubContext(ctx),
Env: *step.getEnv(),
Job: rc.getJobContext(),
Steps: rc.getStepsContext(),
Runner: map[string]interface{}{
"os": "Linux",
"arch": container.RunnerArch(ctx),
"temp": "/tmp",
"tool_cache": "/opt/hostedtoolcache",
},
Secrets: rc.Config.Secrets,
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
// todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite
Inputs: 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(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (interface{}, error) {
logger := common.Logger(ctx)
logger.Debugf("evaluating expression '%s'", in)
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)")
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
return evaluated, err
}
func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) error {
var in string
if err := node.Decode(&in); err != nil {
return err
}
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return nil
}
expr, _ := rewriteSubExpression(ctx, in, false)
res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
if err != nil {
return err
}
return node.Encode(res)
}
func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) error {
// GitHub has this undocumented feature to merge maps, called insert directive
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
for i := 0; i < len(node.Content)/2; {
k := node.Content[i*2]
v := node.Content[i*2+1]
if err := ee.EvaluateYamlNode(ctx, v); err != nil {
return err
}
var sk string
// Merge the nested map of the insert directive
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...)
i += len(v.Content) / 2
} else {
if err := ee.EvaluateYamlNode(ctx, k); err != nil {
return err
}
i++
}
}
return nil
}
func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) error {
for i := 0; i < len(node.Content); {
v := node.Content[i]
// Preserve nested sequences
wasseq := v.Kind == yaml.SequenceNode
if err := ee.EvaluateYamlNode(ctx, v); err != nil {
return err
}
// GitHub has this undocumented feature to merge sequences / arrays
// We have a nested sequence via evaluation, merge the arrays
if v.Kind == yaml.SequenceNode && !wasseq {
node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...)
i += len(v.Content)
} else {
i++
}
}
return nil
}
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
return ee.evaluateScalarYamlNode(ctx, node)
case yaml.MappingNode:
return ee.evaluateMappingYamlNode(ctx, node)
case yaml.SequenceNode:
return ee.evaluateSequenceYamlNode(ctx, node)
default:
return nil
}
}
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in
}
expr, _ := rewriteSubExpression(ctx, in, true)
evaluated, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
if err != nil {
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", expr, err)
return ""
}
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(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
nextExpr, _ := rewriteSubExpression(ctx, expr, false)
evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck)
if err != nil {
return false, err
}
return exprparser.IsTruthy(evaluated), nil
}
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
//nolint:gocyclo
func rewriteSubExpression(ctx context.Context, 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
}
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
if in != out {
common.Logger(ctx).Debugf("expression '%s' rewritten to '%s'", in, out)
}
return out, nil
}