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