act/pkg/runner/expression.go
ChristopherHX 9868e13772
Feature: uses in composite (#793)
* Feature: uses in composite

* Negate logic

* Reduce complexity

* Update step_context.go

* Update step_context.go

* Update step_context.go

* Fix syntax error in test

* Bump

* Disable usage of actions/setup-node@v2

* Bump

* Fix step id collision

* Fix output command workaround

* Make secrets context inaccessible in composite

* Fix order after adding a workaround (needs tests)

Fixes https://github.com/nektos/act/pull/793#issuecomment-922329838

* Evaluate env before passing one step deeper

If env would contain any inputs, steps ctx or secrets there was undefined behaviour

* [no ci] prepare secret test

* Initial test pass inputs as env

* Fix syntax error

* extend test also for direct invoke

* Fix passing provided env as composite output

* Fix syntax error

* toUpper 'no such secret', act has a bug

* fix indent

* Fix env outputs in composite

* Test env outputs of composite

* Fix inputs not defined in docker actions

* Fix interpolate args input of docker actions

* Fix lint

* AllowCompositeIf now defaults to true

see https://github.com/actions/runner/releases/tag/v2.284.0

* Fix lint

* Fix env of docker action.yml

* Test calling a local docker action from composite

With input context hirachy

* local-action-dockerfile Test pass on action/runner

It seems action/runner ignores overrides of args,
if the target docker action has the args property set.

* Fix exec permissions of docker-local-noargs

* Revert getStepsContext change

* fix: handle composite action on error and continue

This change is a follow up of https://github.com/nektos/act/pull/840
and integrates with https://github.com/nektos/act/pull/793

There are two things included here:

- The default value for a step.if in an action need to be 'success()'
- We need to hand the error from a composite action back to the
  calling executor

Co-authored-by: Björn Brauer <bjoern.brauer@new-work.se>

* Patch inputs can be bool, float64 and string
for workflow_call
Also inputs is now always defined, but may be null

* Simplify cherry-picked commit

* Minor style adjustments

* Remove chmod +x from tests

now fails on windows like before

* Fix GITHUB_ACTION_PATH some action env vars

Fixes GITHUB_ACTION_REPOSITORY, GITHUB_ACTION_REF.

* Add comment to CompositeRestrictions

Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: Björn Brauer <bjoern.brauer@new-work.se>
Co-authored-by: Ryan <me@hackerc.at>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2021-12-22 19:19:50 +00:00

644 lines
15 KiB
Go

package runner
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/robertkrimen/otto"
log "github.com/sirupsen/logrus"
)
var expressionPattern, operatorPattern *regexp.Regexp
func init() {
expressionPattern = regexp.MustCompile(`\${{\s*(.+?)\s*}}`)
operatorPattern = regexp.MustCompile("^[!=><|&]+$")
}
// NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator {
vm := rc.newVM()
return &expressionEvaluator{
vm,
}
}
// NewExpressionEvaluator creates a new evaluator
func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator {
vm := sc.RunContext.newVM()
configers := []func(*otto.Otto){
sc.vmEnv(),
sc.vmNeeds(),
sc.vmSuccess(),
sc.vmFailure(),
}
for _, configer := range configers {
configer(vm)
}
return &expressionEvaluator{
vm,
}
}
// ExpressionEvaluator is the interface for evaluating expressions
type ExpressionEvaluator interface {
Evaluate(string) (string, bool, error)
Interpolate(string) string
InterpolateWithStringCheck(string) (string, bool)
Rewrite(string) string
}
type expressionEvaluator struct {
vm *otto.Otto
}
func (ee *expressionEvaluator) Evaluate(in string) (string, bool, error) {
if strings.HasPrefix(in, `secrets.`) {
in = `secrets.` + strings.ToUpper(strings.SplitN(in, `.`, 2)[1])
}
re := ee.Rewrite(in)
if re != in {
log.Debugf("Evaluating '%s' instead of '%s'", re, in)
}
val, err := ee.vm.Run(re)
if err != nil {
return "", false, err
}
if val.IsNull() || val.IsUndefined() {
return "", false, nil
}
valAsString, err := val.ToString()
if err != nil {
return "", false, err
}
return valAsString, val.IsString(), err
}
func (ee *expressionEvaluator) Interpolate(in string) string {
interpolated, _ := ee.InterpolateWithStringCheck(in)
return interpolated
}
func (ee *expressionEvaluator) InterpolateWithStringCheck(in string) (string, bool) {
errList := make([]error, 0)
out := in
isString := false
for {
out = expressionPattern.ReplaceAllStringFunc(in, func(match string) string {
// Extract and trim the actual expression inside ${{...}} delimiters
expression := expressionPattern.ReplaceAllString(match, "$1")
// Evaluate the expression and retrieve errors if any
evaluated, evaluatedIsString, err := ee.Evaluate(expression)
if err != nil {
errList = append(errList, err)
}
isString = evaluatedIsString
return evaluated
})
if len(errList) > 0 {
log.Errorf("Unable to interpolate string '%s' - %v", in, errList)
break
}
if out == in {
// No replacement occurred, we're done!
break
}
in = out
}
return out, isString
}
// Rewrite tries to transform any javascript property accessor into its bracket notation.
// For instance, "object.property" would become "object['property']".
func (ee *expressionEvaluator) Rewrite(in string) string {
var buf strings.Builder
r := strings.NewReader(in)
for {
c, _, err := r.ReadRune()
if err == io.EOF {
break
}
//nolint
switch {
default:
buf.WriteRune(c)
case c == '\'':
buf.WriteRune(c)
ee.advString(&buf, r)
case c == '.':
buf.WriteString("['")
ee.advPropertyName(&buf, r)
buf.WriteString("']")
}
}
return buf.String()
}
func (*expressionEvaluator) advString(w *strings.Builder, r *strings.Reader) error {
for {
c, _, err := r.ReadRune()
if err != nil {
return err
}
if c != '\'' {
w.WriteRune(c)
continue
}
// Handles a escaped string: ex. 'It''s ok'
c, _, err = r.ReadRune()
if err != nil {
w.WriteString("'")
return err
}
if c != '\'' {
w.WriteString("'")
if err := r.UnreadRune(); err != nil {
return err
}
break
}
w.WriteString(`\'`)
}
return nil
}
func (*expressionEvaluator) advPropertyName(w *strings.Builder, r *strings.Reader) error {
for {
c, _, err := r.ReadRune()
if err != nil {
return err
}
if !isLetter(c) {
if err := r.UnreadRune(); err != nil {
return err
}
break
}
w.WriteRune(c)
}
return nil
}
func isLetter(c rune) bool {
switch {
case c >= 'a' && c <= 'z':
return true
case c >= 'A' && c <= 'Z':
return true
case c >= '0' && c <= '9':
return true
case c == '_' || c == '-':
return true
default:
return false
}
}
func (rc *RunContext) newVM() *otto.Otto {
configers := []func(*otto.Otto){
vmContains,
vmStartsWith,
vmEndsWith,
vmFormat,
vmJoin,
vmToJSON,
vmFromJSON,
vmAlways,
rc.vmCancelled(),
rc.vmSuccess(),
rc.vmFailure(),
rc.vmHashFiles(),
rc.vmGithub(),
rc.vmJob(),
rc.vmSteps(),
rc.vmRunner(),
rc.vmSecrets(),
rc.vmStrategy(),
rc.vmMatrix(),
rc.vmEnv(),
rc.vmNeeds(),
rc.vmInputs(),
}
vm := otto.New()
for _, configer := range configers {
configer(vm)
}
return vm
}
func vmContains(vm *otto.Otto) {
_ = vm.Set("contains", func(searchString interface{}, searchValue string) bool {
if searchStringString, ok := searchString.(string); ok {
return strings.Contains(strings.ToLower(searchStringString), strings.ToLower(searchValue))
} else if searchStringArray, ok := searchString.([]string); ok {
for _, s := range searchStringArray {
if strings.EqualFold(s, searchValue) {
return true
}
}
}
return false
})
}
func vmStartsWith(vm *otto.Otto) {
_ = vm.Set("startsWith", func(searchString string, searchValue string) bool {
return strings.HasPrefix(strings.ToLower(searchString), strings.ToLower(searchValue))
})
}
func vmEndsWith(vm *otto.Otto) {
_ = vm.Set("endsWith", func(searchString string, searchValue string) bool {
return strings.HasSuffix(strings.ToLower(searchString), strings.ToLower(searchValue))
})
}
func vmFormat(vm *otto.Otto) {
_ = vm.Set("format", func(s string, vals ...otto.Value) string {
ex := regexp.MustCompile(`(\{[0-9]+\}|\{.?|\}.?)`)
return ex.ReplaceAllStringFunc(s, func(seg string) string {
switch seg {
case "{{":
return "{"
case "}}":
return "}"
default:
if len(seg) < 3 || !strings.HasPrefix(seg, "{") {
log.Errorf("The following format string is invalid: '%v'", s)
return ""
}
_i := seg[1 : len(seg)-1]
i, err := strconv.ParseInt(_i, 10, 32)
if err != nil {
log.Errorf("The following format string is invalid: '%v'. Error: %v", s, err)
return ""
}
if i >= int64(len(vals)) {
log.Errorf("The following format string references more arguments than were supplied: '%v'", s)
return ""
}
if vals[i].IsNull() || vals[i].IsUndefined() {
return ""
}
return vals[i].String()
}
})
})
}
func vmJoin(vm *otto.Otto) {
_ = vm.Set("join", func(element interface{}, optionalElem string) string {
slist := make([]string, 0)
if elementString, ok := element.(string); ok {
slist = append(slist, elementString)
} else if elementArray, ok := element.([]string); ok {
slist = append(slist, elementArray...)
}
if optionalElem != "" {
slist = append(slist, optionalElem)
}
return strings.Join(slist, " ")
})
}
func vmToJSON(vm *otto.Otto) {
toJSON := func(o interface{}) string {
rtn, err := json.MarshalIndent(o, "", " ")
if err != nil {
log.Errorf("Unable to marshal: %v", err)
return ""
}
return string(rtn)
}
_ = vm.Set("toJSON", toJSON)
_ = vm.Set("toJson", toJSON)
}
func vmFromJSON(vm *otto.Otto) {
fromJSON := func(str string) interface{} {
var dat interface{}
err := json.Unmarshal([]byte(str), &dat)
if err != nil {
log.Errorf("Unable to unmarshal: %v", err)
return dat
}
return dat
}
_ = vm.Set("fromJSON", fromJSON)
_ = vm.Set("fromJson", fromJSON)
}
func (rc *RunContext) vmHashFiles() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("hashFiles", func(paths ...string) string {
var files []string
for i := range paths {
newFiles, err := filepath.Glob(filepath.Join(rc.Config.Workdir, paths[i]))
if err != nil {
log.Errorf("Unable to glob.Glob: %v", err)
return ""
}
files = append(files, newFiles...)
}
hasher := sha256.New()
for _, file := range files {
f, err := os.Open(file)
if err != nil {
log.Errorf("Unable to os.Open: %v", err)
}
if _, err := io.Copy(hasher, f); err != nil {
log.Errorf("Unable to io.Copy: %v", err)
}
if err := f.Close(); err != nil {
log.Errorf("Unable to Close file: %v", err)
}
}
return hex.EncodeToString(hasher.Sum(nil))
})
}
}
func (rc *RunContext) vmSuccess() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("success", func() bool {
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.getNeedsTransitive(rc.Run.Job())
for _, needs := range jobNeeds {
if jobs[needs].Result != "success" {
return false
}
}
return true
})
}
}
func (rc *RunContext) vmFailure() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("failure", func() bool {
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.getNeedsTransitive(rc.Run.Job())
for _, needs := range jobNeeds {
if jobs[needs].Result == "failure" {
return true
}
}
return false
})
}
}
func vmAlways(vm *otto.Otto) {
_ = vm.Set("always", func() bool {
return true
})
}
func (rc *RunContext) vmCancelled() func(vm *otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("cancelled", func() bool {
return rc.getJobContext().Status == "cancelled"
})
}
}
func (rc *RunContext) vmGithub() func(*otto.Otto) {
github := rc.getGithubContext()
return func(vm *otto.Otto) {
_ = vm.Set("github", github)
}
}
func (rc *RunContext) vmEnv() func(*otto.Otto) {
return func(vm *otto.Otto) {
env := rc.GetEnv()
log.Debugf("context env => %v", env)
_ = vm.Set("env", env)
}
}
func (sc *StepContext) vmEnv() func(*otto.Otto) {
return func(vm *otto.Otto) {
log.Debugf("context env => %v", sc.Env)
_ = vm.Set("env", sc.Env)
}
}
func (rc *RunContext) vmInputs() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("inputs", rc.Inputs)
}
}
func (sc *StepContext) vmNeeds() func(*otto.Otto) {
jobs := sc.RunContext.Run.Workflow.Jobs
jobNeeds := sc.RunContext.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,
}
}
return func(vm *otto.Otto) {
log.Debugf("context needs => %v", using)
_ = vm.Set("needs", using)
}
}
func (sc *StepContext) vmSuccess() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("success", func() bool {
return sc.RunContext.getJobContext().Status == "success"
})
}
}
func (sc *StepContext) vmFailure() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("failure", func() bool {
return sc.RunContext.getJobContext().Status == "failure"
})
}
}
type vmNeedsStruct struct {
Outputs map[string]string `json:"outputs"`
Result string `json:"result"`
}
func (rc *RunContext) vmNeeds() func(*otto.Otto) {
return func(vm *otto.Otto) {
needsFunc := func() otto.Value {
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.Run.Job().Needs()
using := make(map[string]vmNeedsStruct)
for _, needs := range jobNeeds {
using[needs] = vmNeedsStruct{
Outputs: jobs[needs].Outputs,
Result: jobs[needs].Result,
}
}
log.Debugf("context needs => %+v", using)
value, err := vm.ToValue(using)
if err != nil {
return vm.MakeTypeError(err.Error())
}
return value
}
// Results might change after the Otto VM was created
// and initialized. To access the current state
// we can't just pass a copy to Otto - instead we
// created a 'live-binding'.
// Technical Note: We don't want to pollute the global
// js namespace (and add things github actions hasn't)
// we delete the helper function after installing it
// as a getter.
global, _ := vm.Run("this")
_ = global.Object().Set("__needs__", needsFunc)
_, _ = vm.Run(`
(function (global) {
Object.defineProperty(global, 'needs', { get: global.__needs__ });
delete global.__needs__;
})(this)
`)
}
}
func (rc *RunContext) vmJob() func(*otto.Otto) {
job := rc.getJobContext()
return func(vm *otto.Otto) {
_ = vm.Set("job", job)
}
}
func (rc *RunContext) vmSteps() func(*otto.Otto) {
ctxSteps := rc.getStepsContext()
steps := make(map[string]interface{})
for id, ctxStep := range ctxSteps {
steps[id] = map[string]interface{}{
"conclusion": ctxStep.Conclusion.String(),
"outcome": ctxStep.Outcome.String(),
"outputs": ctxStep.Outputs,
}
}
return func(vm *otto.Otto) {
log.Debugf("context steps => %v", steps)
_ = vm.Set("steps", steps)
}
}
func (rc *RunContext) vmRunner() func(*otto.Otto) {
runner := map[string]interface{}{
"os": "Linux",
"temp": "/tmp",
"tool_cache": "/opt/hostedtoolcache",
}
return func(vm *otto.Otto) {
_ = vm.Set("runner", runner)
}
}
func (rc *RunContext) vmSecrets() func(*otto.Otto) {
return func(vm *otto.Otto) {
// Hide secrets from composite actions
if rc.Composite == nil {
_ = vm.Set("secrets", rc.Config.Secrets)
}
}
}
func (rc *RunContext) vmStrategy() func(*otto.Otto) {
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
}
return func(vm *otto.Otto) {
_ = vm.Set("strategy", strategy)
}
}
func (rc *RunContext) vmMatrix() func(*otto.Otto) {
return func(vm *otto.Otto) {
_ = vm.Set("matrix", rc.Matrix)
}
}
// EvalBool evaluates an expression against given evaluator
func EvalBool(evaluator ExpressionEvaluator, expr string) (bool, error) {
if splitPattern == nil {
splitPattern = regexp.MustCompile(fmt.Sprintf(`%s|%s|\S+`, expressionPattern.String(), operatorPattern.String()))
}
if strings.HasPrefix(strings.TrimSpace(expr), "!") {
return false, errors.New("expressions starting with ! must be wrapped in ${{ }}")
}
if expr != "" {
parts := splitPattern.FindAllString(expr, -1)
var evaluatedParts []string
for i, part := range parts {
if operatorPattern.MatchString(part) {
evaluatedParts = append(evaluatedParts, part)
continue
}
interpolatedPart, isString := evaluator.InterpolateWithStringCheck(part)
// This peculiar transformation has to be done because the GitHub parser
// treats false returned from contexts as a string, not a boolean.
// Hence env.SOMETHING will be evaluated to true in an if: expression
// regardless if SOMETHING is set to false, true or any other string.
// It also handles some other weirdness that I found by trial and error.
if (expressionPattern.MatchString(part) && // it is an expression
!strings.Contains(part, "!")) && // but it's not negated
interpolatedPart == "false" && // and the interpolated string is false
(isString || previousOrNextPartIsAnOperator(i, parts)) { // and it's of type string or has an logical operator before or after
interpolatedPart = fmt.Sprintf("'%s'", interpolatedPart) // then we have to quote the false expression
}
evaluatedParts = append(evaluatedParts, interpolatedPart)
}
joined := strings.Join(evaluatedParts, " ")
v, _, err := evaluator.Evaluate(fmt.Sprintf("Boolean(%s)", joined))
if err != nil {
return false, err
}
log.Debugf("expression '%s' evaluated to '%s'", expr, v)
return v == "true", nil
}
return true, nil
}