github.com/nektos/act@v0.2.63/pkg/runner/expression.go (about)

     1  package runner
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"path"
     8  	"reflect"
     9  	"regexp"
    10  	"strings"
    11  	"time"
    12  
    13  	_ "embed"
    14  
    15  	"github.com/nektos/act/pkg/common"
    16  	"github.com/nektos/act/pkg/container"
    17  	"github.com/nektos/act/pkg/exprparser"
    18  	"github.com/nektos/act/pkg/model"
    19  	"gopkg.in/yaml.v3"
    20  )
    21  
    22  // ExpressionEvaluator is the interface for evaluating expressions
    23  type ExpressionEvaluator interface {
    24  	evaluate(context.Context, string, exprparser.DefaultStatusCheck) (interface{}, error)
    25  	EvaluateYamlNode(context.Context, *yaml.Node) error
    26  	Interpolate(context.Context, string) string
    27  }
    28  
    29  // NewExpressionEvaluator creates a new evaluator
    30  func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) ExpressionEvaluator {
    31  	return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
    32  }
    33  
    34  func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) ExpressionEvaluator {
    35  	var workflowCallResult map[string]*model.WorkflowCallResult
    36  
    37  	// todo: cleanup EvaluationEnvironment creation
    38  	using := make(map[string]exprparser.Needs)
    39  	strategy := make(map[string]interface{})
    40  	if rc.Run != nil {
    41  		job := rc.Run.Job()
    42  		if job != nil && job.Strategy != nil {
    43  			strategy["fail-fast"] = job.Strategy.FailFast
    44  			strategy["max-parallel"] = job.Strategy.MaxParallel
    45  		}
    46  
    47  		jobs := rc.Run.Workflow.Jobs
    48  		jobNeeds := rc.Run.Job().Needs()
    49  
    50  		for _, needs := range jobNeeds {
    51  			using[needs] = exprparser.Needs{
    52  				Outputs: jobs[needs].Outputs,
    53  				Result:  jobs[needs].Result,
    54  			}
    55  		}
    56  
    57  		// only setup jobs context in case of workflow_call
    58  		// and existing expression evaluator (this means, jobs are at
    59  		// least ready to run)
    60  		if rc.caller != nil && rc.ExprEval != nil {
    61  			workflowCallResult = map[string]*model.WorkflowCallResult{}
    62  
    63  			for jobName, job := range jobs {
    64  				result := model.WorkflowCallResult{
    65  					Outputs: map[string]string{},
    66  				}
    67  				for k, v := range job.Outputs {
    68  					result.Outputs[k] = v
    69  				}
    70  				workflowCallResult[jobName] = &result
    71  			}
    72  		}
    73  	}
    74  
    75  	ghc := rc.getGithubContext(ctx)
    76  	inputs := getEvaluatorInputs(ctx, rc, nil, ghc)
    77  
    78  	ee := &exprparser.EvaluationEnvironment{
    79  		Github: ghc,
    80  		Env:    env,
    81  		Job:    rc.getJobContext(),
    82  		Jobs:   &workflowCallResult,
    83  		// todo: should be unavailable
    84  		// but required to interpolate/evaluate the step outputs on the job
    85  		Steps:     rc.getStepsContext(),
    86  		Secrets:   getWorkflowSecrets(ctx, rc),
    87  		Vars:      getWorkflowVars(ctx, rc),
    88  		Strategy:  strategy,
    89  		Matrix:    rc.Matrix,
    90  		Needs:     using,
    91  		Inputs:    inputs,
    92  		HashFiles: getHashFilesFunction(ctx, rc),
    93  	}
    94  	if rc.JobContainer != nil {
    95  		ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
    96  	}
    97  	return expressionEvaluator{
    98  		interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
    99  			Run:        rc.Run,
   100  			WorkingDir: rc.Config.Workdir,
   101  			Context:    "job",
   102  		}),
   103  	}
   104  }
   105  
   106  //go:embed hashfiles/index.js
   107  var hashfiles string
   108  
   109  // NewStepExpressionEvaluator creates a new evaluator
   110  func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) ExpressionEvaluator {
   111  	// todo: cleanup EvaluationEnvironment creation
   112  	job := rc.Run.Job()
   113  	strategy := make(map[string]interface{})
   114  	if job.Strategy != nil {
   115  		strategy["fail-fast"] = job.Strategy.FailFast
   116  		strategy["max-parallel"] = job.Strategy.MaxParallel
   117  	}
   118  
   119  	jobs := rc.Run.Workflow.Jobs
   120  	jobNeeds := rc.Run.Job().Needs()
   121  
   122  	using := make(map[string]exprparser.Needs)
   123  	for _, needs := range jobNeeds {
   124  		using[needs] = exprparser.Needs{
   125  			Outputs: jobs[needs].Outputs,
   126  			Result:  jobs[needs].Result,
   127  		}
   128  	}
   129  
   130  	ghc := rc.getGithubContext(ctx)
   131  	inputs := getEvaluatorInputs(ctx, rc, step, ghc)
   132  
   133  	ee := &exprparser.EvaluationEnvironment{
   134  		Github:   step.getGithubContext(ctx),
   135  		Env:      *step.getEnv(),
   136  		Job:      rc.getJobContext(),
   137  		Steps:    rc.getStepsContext(),
   138  		Secrets:  getWorkflowSecrets(ctx, rc),
   139  		Vars:     getWorkflowVars(ctx, rc),
   140  		Strategy: strategy,
   141  		Matrix:   rc.Matrix,
   142  		Needs:    using,
   143  		// todo: should be unavailable
   144  		// but required to interpolate/evaluate the inputs in actions/composite
   145  		Inputs:    inputs,
   146  		HashFiles: getHashFilesFunction(ctx, rc),
   147  	}
   148  	if rc.JobContainer != nil {
   149  		ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
   150  	}
   151  	return expressionEvaluator{
   152  		interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
   153  			Run:        rc.Run,
   154  			WorkingDir: rc.Config.Workdir,
   155  			Context:    "step",
   156  		}),
   157  	}
   158  }
   159  
   160  func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (interface{}, error) {
   161  	hashFiles := func(v []reflect.Value) (interface{}, error) {
   162  		if rc.JobContainer != nil {
   163  			timeed, cancel := context.WithTimeout(ctx, time.Minute)
   164  			defer cancel()
   165  			name := "workflow/hashfiles/index.js"
   166  			hout := &bytes.Buffer{}
   167  			herr := &bytes.Buffer{}
   168  			patterns := []string{}
   169  			followSymlink := false
   170  
   171  			for i, p := range v {
   172  				s := p.String()
   173  				if i == 0 {
   174  					if strings.HasPrefix(s, "--") {
   175  						if strings.EqualFold(s, "--follow-symbolic-links") {
   176  							followSymlink = true
   177  							continue
   178  						}
   179  						return "", fmt.Errorf("Invalid glob option %s, available option: '--follow-symbolic-links'", s)
   180  					}
   181  				}
   182  				patterns = append(patterns, s)
   183  			}
   184  			env := map[string]string{}
   185  			for k, v := range rc.Env {
   186  				env[k] = v
   187  			}
   188  			env["patterns"] = strings.Join(patterns, "\n")
   189  			if followSymlink {
   190  				env["followSymbolicLinks"] = "true"
   191  			}
   192  
   193  			stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr)
   194  			_ = rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
   195  				Name: name,
   196  				Mode: 0o644,
   197  				Body: hashfiles,
   198  			}).
   199  				Then(rc.execJobContainer([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
   200  					env, "", "")).
   201  				Finally(func(context.Context) error {
   202  					rc.JobContainer.ReplaceLogWriter(stdout, stderr)
   203  					return nil
   204  				})(timeed)
   205  			output := hout.String() + "\n" + herr.String()
   206  			guard := "__OUTPUT__"
   207  			outstart := strings.Index(output, guard)
   208  			if outstart != -1 {
   209  				outstart += len(guard)
   210  				outend := strings.Index(output[outstart:], guard)
   211  				if outend != -1 {
   212  					return output[outstart : outstart+outend], nil
   213  				}
   214  			}
   215  		}
   216  		return "", nil
   217  	}
   218  	return hashFiles
   219  }
   220  
   221  type expressionEvaluator struct {
   222  	interpreter exprparser.Interpreter
   223  }
   224  
   225  func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (interface{}, error) {
   226  	logger := common.Logger(ctx)
   227  	logger.Debugf("evaluating expression '%s'", in)
   228  	evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
   229  
   230  	printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)")
   231  	logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
   232  
   233  	return evaluated, err
   234  }
   235  
   236  func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
   237  	var in string
   238  	if err := node.Decode(&in); err != nil {
   239  		return nil, err
   240  	}
   241  	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
   242  		return nil, nil
   243  	}
   244  	expr, _ := rewriteSubExpression(ctx, in, false)
   245  	res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
   246  	if err != nil {
   247  		return nil, err
   248  	}
   249  	ret := &yaml.Node{}
   250  	if err := ret.Encode(res); err != nil {
   251  		return nil, err
   252  	}
   253  	return ret, err
   254  }
   255  
   256  func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
   257  	var ret *yaml.Node
   258  	// GitHub has this undocumented feature to merge maps, called insert directive
   259  	insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
   260  	for i := 0; i < len(node.Content)/2; i++ {
   261  		changed := func() error {
   262  			if ret == nil {
   263  				ret = &yaml.Node{}
   264  				if err := ret.Encode(node); err != nil {
   265  					return err
   266  				}
   267  				ret.Content = ret.Content[:i*2]
   268  			}
   269  			return nil
   270  		}
   271  		k := node.Content[i*2]
   272  		v := node.Content[i*2+1]
   273  		ev, err := ee.evaluateYamlNodeInternal(ctx, v)
   274  		if err != nil {
   275  			return nil, err
   276  		}
   277  		if ev != nil {
   278  			if err := changed(); err != nil {
   279  				return nil, err
   280  			}
   281  		} else {
   282  			ev = v
   283  		}
   284  		var sk string
   285  		// Merge the nested map of the insert directive
   286  		if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
   287  			if ev.Kind != yaml.MappingNode {
   288  				return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
   289  			}
   290  			if err := changed(); err != nil {
   291  				return nil, err
   292  			}
   293  			ret.Content = append(ret.Content, ev.Content...)
   294  		} else {
   295  			ek, err := ee.evaluateYamlNodeInternal(ctx, k)
   296  			if err != nil {
   297  				return nil, err
   298  			}
   299  			if ek != nil {
   300  				if err := changed(); err != nil {
   301  					return nil, err
   302  				}
   303  			} else {
   304  				ek = k
   305  			}
   306  			if ret != nil {
   307  				ret.Content = append(ret.Content, ek, ev)
   308  			}
   309  		}
   310  	}
   311  	return ret, nil
   312  }
   313  
   314  func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
   315  	var ret *yaml.Node
   316  	for i := 0; i < len(node.Content); i++ {
   317  		v := node.Content[i]
   318  		// Preserve nested sequences
   319  		wasseq := v.Kind == yaml.SequenceNode
   320  		ev, err := ee.evaluateYamlNodeInternal(ctx, v)
   321  		if err != nil {
   322  			return nil, err
   323  		}
   324  		if ev != nil {
   325  			if ret == nil {
   326  				ret = &yaml.Node{}
   327  				if err := ret.Encode(node); err != nil {
   328  					return nil, err
   329  				}
   330  				ret.Content = ret.Content[:i]
   331  			}
   332  			// GitHub has this undocumented feature to merge sequences / arrays
   333  			// We have a nested sequence via evaluation, merge the arrays
   334  			if ev.Kind == yaml.SequenceNode && !wasseq {
   335  				ret.Content = append(ret.Content, ev.Content...)
   336  			} else {
   337  				ret.Content = append(ret.Content, ev)
   338  			}
   339  		} else if ret != nil {
   340  			ret.Content = append(ret.Content, v)
   341  		}
   342  	}
   343  	return ret, nil
   344  }
   345  
   346  func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
   347  	switch node.Kind {
   348  	case yaml.ScalarNode:
   349  		return ee.evaluateScalarYamlNode(ctx, node)
   350  	case yaml.MappingNode:
   351  		return ee.evaluateMappingYamlNode(ctx, node)
   352  	case yaml.SequenceNode:
   353  		return ee.evaluateSequenceYamlNode(ctx, node)
   354  	default:
   355  		return nil, nil
   356  	}
   357  }
   358  
   359  func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
   360  	ret, err := ee.evaluateYamlNodeInternal(ctx, node)
   361  	if err != nil {
   362  		return err
   363  	}
   364  	if ret != nil {
   365  		return ret.Decode(node)
   366  	}
   367  	return nil
   368  }
   369  
   370  func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
   371  	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
   372  		return in
   373  	}
   374  
   375  	expr, _ := rewriteSubExpression(ctx, in, true)
   376  	evaluated, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
   377  	if err != nil {
   378  		common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", expr, err)
   379  		return ""
   380  	}
   381  
   382  	value, ok := evaluated.(string)
   383  	if !ok {
   384  		panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
   385  	}
   386  
   387  	return value
   388  }
   389  
   390  // EvalBool evaluates an expression against given evaluator
   391  func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
   392  	nextExpr, _ := rewriteSubExpression(ctx, expr, false)
   393  
   394  	evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck)
   395  	if err != nil {
   396  		return false, err
   397  	}
   398  
   399  	return exprparser.IsTruthy(evaluated), nil
   400  }
   401  
   402  func escapeFormatString(in string) string {
   403  	return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
   404  }
   405  
   406  //nolint:gocyclo
   407  func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) {
   408  	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
   409  		return in, nil
   410  	}
   411  
   412  	strPattern := regexp.MustCompile("(?:''|[^'])*'")
   413  	pos := 0
   414  	exprStart := -1
   415  	strStart := -1
   416  	var results []string
   417  	formatOut := ""
   418  	for pos < len(in) {
   419  		if strStart > -1 {
   420  			matches := strPattern.FindStringIndex(in[pos:])
   421  			if matches == nil {
   422  				panic("unclosed string.")
   423  			}
   424  
   425  			strStart = -1
   426  			pos += matches[1]
   427  		} else if exprStart > -1 {
   428  			exprEnd := strings.Index(in[pos:], "}}")
   429  			strStart = strings.Index(in[pos:], "'")
   430  
   431  			if exprEnd > -1 && strStart > -1 {
   432  				if exprEnd < strStart {
   433  					strStart = -1
   434  				} else {
   435  					exprEnd = -1
   436  				}
   437  			}
   438  
   439  			if exprEnd > -1 {
   440  				formatOut += fmt.Sprintf("{%d}", len(results))
   441  				results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
   442  				pos += exprEnd + 2
   443  				exprStart = -1
   444  			} else if strStart > -1 {
   445  				pos += strStart + 1
   446  			} else {
   447  				panic("unclosed expression.")
   448  			}
   449  		} else {
   450  			exprStart = strings.Index(in[pos:], "${{")
   451  			if exprStart != -1 {
   452  				formatOut += escapeFormatString(in[pos : pos+exprStart])
   453  				exprStart = pos + exprStart + 3
   454  				pos = exprStart
   455  			} else {
   456  				formatOut += escapeFormatString(in[pos:])
   457  				pos = len(in)
   458  			}
   459  		}
   460  	}
   461  
   462  	if len(results) == 1 && formatOut == "{0}" && !forceFormat {
   463  		return in, nil
   464  	}
   465  
   466  	out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
   467  	if in != out {
   468  		common.Logger(ctx).Debugf("expression '%s' rewritten to '%s'", in, out)
   469  	}
   470  	return out, nil
   471  }
   472  
   473  //nolint:gocyclo
   474  func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]interface{} {
   475  	inputs := map[string]interface{}{}
   476  
   477  	setupWorkflowInputs(ctx, &inputs, rc)
   478  
   479  	var env map[string]string
   480  	if step != nil {
   481  		env = *step.getEnv()
   482  	} else {
   483  		env = rc.GetEnv()
   484  	}
   485  
   486  	for k, v := range env {
   487  		if strings.HasPrefix(k, "INPUT_") {
   488  			inputs[strings.ToLower(strings.TrimPrefix(k, "INPUT_"))] = v
   489  		}
   490  	}
   491  
   492  	if ghc.EventName == "workflow_dispatch" {
   493  		config := rc.Run.Workflow.WorkflowDispatchConfig()
   494  		if config != nil && config.Inputs != nil {
   495  			for k, v := range config.Inputs {
   496  				value := nestedMapLookup(ghc.Event, "inputs", k)
   497  				if value == nil {
   498  					value = v.Default
   499  				}
   500  				if v.Type == "boolean" {
   501  					inputs[k] = value == "true"
   502  				} else {
   503  					inputs[k] = value
   504  				}
   505  			}
   506  		}
   507  	}
   508  
   509  	if ghc.EventName == "workflow_call" {
   510  		config := rc.Run.Workflow.WorkflowCallConfig()
   511  		if config != nil && config.Inputs != nil {
   512  			for k, v := range config.Inputs {
   513  				value := nestedMapLookup(ghc.Event, "inputs", k)
   514  				if value == nil {
   515  					value = v.Default
   516  				}
   517  				if v.Type == "boolean" {
   518  					inputs[k] = value == "true"
   519  				} else {
   520  					inputs[k] = value
   521  				}
   522  			}
   523  		}
   524  	}
   525  	return inputs
   526  }
   527  
   528  func setupWorkflowInputs(ctx context.Context, inputs *map[string]interface{}, rc *RunContext) {
   529  	if rc.caller != nil {
   530  		config := rc.Run.Workflow.WorkflowCallConfig()
   531  
   532  		for name, input := range config.Inputs {
   533  			value := rc.caller.runContext.Run.Job().With[name]
   534  			if value != nil {
   535  				if str, ok := value.(string); ok {
   536  					// evaluate using the calling RunContext (outside)
   537  					value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
   538  				}
   539  			}
   540  
   541  			if value == nil && config != nil && config.Inputs != nil {
   542  				value = input.Default
   543  				if rc.ExprEval != nil {
   544  					if str, ok := value.(string); ok {
   545  						// evaluate using the called RunContext (inside)
   546  						value = rc.ExprEval.Interpolate(ctx, str)
   547  					}
   548  				}
   549  			}
   550  
   551  			(*inputs)[name] = value
   552  		}
   553  	}
   554  }
   555  
   556  func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
   557  	if rc.caller != nil {
   558  		job := rc.caller.runContext.Run.Job()
   559  		secrets := job.Secrets()
   560  
   561  		if secrets == nil && job.InheritSecrets() {
   562  			secrets = rc.caller.runContext.Config.Secrets
   563  		}
   564  
   565  		if secrets == nil {
   566  			secrets = map[string]string{}
   567  		}
   568  
   569  		for k, v := range secrets {
   570  			secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
   571  		}
   572  
   573  		return secrets
   574  	}
   575  
   576  	return rc.Config.Secrets
   577  }
   578  
   579  func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string {
   580  	return rc.Config.Vars
   581  }