summaryrefslogtreecommitdiffstats
path: root/pkg/runner/expression.go
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-20 23:07:42 +0200
committerDaniel Baumann <daniel@debian.org>2024-11-09 15:38:42 +0100
commit714c83b2736d7e308bc33c49057952490eb98be2 (patch)
tree1d9ba7035798368569cd49056f4d596efc908cd8 /pkg/runner/expression.go
parentInitial commit. (diff)
downloadforgejo-act-debian.tar.xz
forgejo-act-debian.zip
Adding upstream version 1.21.4.HEADupstream/1.21.4upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'pkg/runner/expression.go')
-rw-r--r--pkg/runner/expression.go581
1 files changed, 581 insertions, 0 deletions
diff --git a/pkg/runner/expression.go b/pkg/runner/expression.go
new file mode 100644
index 0000000..412d1c0
--- /dev/null
+++ b/pkg/runner/expression.go
@@ -0,0 +1,581 @@
+package runner
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "path"
+ "reflect"
+ "regexp"
+ "strings"
+ "time"
+
+ _ "embed"
+
+ "github.com/nektos/act/pkg/common"
+ "github.com/nektos/act/pkg/container"
+ "github.com/nektos/act/pkg/exprparser"
+ "github.com/nektos/act/pkg/model"
+ "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 {
+ return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
+}
+
+func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) ExpressionEvaluator {
+ var workflowCallResult map[string]*model.WorkflowCallResult
+
+ // todo: cleanup EvaluationEnvironment creation
+ using := make(map[string]exprparser.Needs)
+ strategy := make(map[string]interface{})
+ if rc.Run != nil {
+ job := rc.Run.Job()
+ if job != nil && 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()
+
+ for _, needs := range jobNeeds {
+ using[needs] = exprparser.Needs{
+ Outputs: jobs[needs].Outputs,
+ Result: jobs[needs].Result,
+ }
+ }
+
+ // only setup jobs context in case of workflow_call
+ // and existing expression evaluator (this means, jobs are at
+ // least ready to run)
+ if rc.caller != nil && rc.ExprEval != nil {
+ workflowCallResult = map[string]*model.WorkflowCallResult{}
+
+ for jobName, job := range jobs {
+ result := model.WorkflowCallResult{
+ Outputs: map[string]string{},
+ }
+ for k, v := range job.Outputs {
+ result.Outputs[k] = v
+ }
+ workflowCallResult[jobName] = &result
+ }
+ }
+ }
+
+ ghc := rc.getGithubContext(ctx)
+ inputs := getEvaluatorInputs(ctx, rc, nil, ghc)
+
+ ee := &exprparser.EvaluationEnvironment{
+ Github: ghc,
+ Env: env,
+ Job: rc.getJobContext(),
+ Jobs: &workflowCallResult,
+ // todo: should be unavailable
+ // but required to interpolate/evaluate the step outputs on the job
+ Steps: rc.getStepsContext(),
+ Secrets: getWorkflowSecrets(ctx, rc),
+ Vars: getWorkflowVars(ctx, rc),
+ Strategy: strategy,
+ Matrix: rc.Matrix,
+ Needs: using,
+ Inputs: inputs,
+ HashFiles: getHashFilesFunction(ctx, rc),
+ }
+ if rc.JobContainer != nil {
+ ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
+ }
+ return expressionEvaluator{
+ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
+ Run: rc.Run,
+ WorkingDir: rc.Config.Workdir,
+ Context: "job",
+ }),
+ }
+}
+
+//go:embed hashfiles/index.js
+var hashfiles string
+
+// 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]exprparser.Needs)
+ for _, needs := range jobNeeds {
+ using[needs] = exprparser.Needs{
+ Outputs: jobs[needs].Outputs,
+ Result: jobs[needs].Result,
+ }
+ }
+
+ ghc := rc.getGithubContext(ctx)
+ inputs := getEvaluatorInputs(ctx, rc, step, ghc)
+
+ ee := &exprparser.EvaluationEnvironment{
+ Github: step.getGithubContext(ctx),
+ Env: *step.getEnv(),
+ Job: rc.getJobContext(),
+ Steps: rc.getStepsContext(),
+ Secrets: getWorkflowSecrets(ctx, rc),
+ Vars: getWorkflowVars(ctx, rc),
+ Strategy: strategy,
+ Matrix: rc.Matrix,
+ Needs: using,
+ // todo: should be unavailable
+ // but required to interpolate/evaluate the inputs in actions/composite
+ Inputs: inputs,
+ HashFiles: getHashFilesFunction(ctx, rc),
+ }
+ if rc.JobContainer != nil {
+ ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
+ }
+ return expressionEvaluator{
+ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
+ Run: rc.Run,
+ WorkingDir: rc.Config.Workdir,
+ Context: "step",
+ }),
+ }
+}
+
+func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (interface{}, error) {
+ hashFiles := func(v []reflect.Value) (interface{}, error) {
+ if rc.JobContainer != nil {
+ timeed, cancel := context.WithTimeout(ctx, time.Minute)
+ defer cancel()
+ name := "workflow/hashfiles/index.js"
+ hout := &bytes.Buffer{}
+ herr := &bytes.Buffer{}
+ patterns := []string{}
+ followSymlink := false
+
+ for i, p := range v {
+ s := p.String()
+ if i == 0 {
+ if strings.HasPrefix(s, "--") {
+ if strings.EqualFold(s, "--follow-symbolic-links") {
+ followSymlink = true
+ continue
+ }
+ return "", fmt.Errorf("Invalid glob option %s, available option: '--follow-symbolic-links'", s)
+ }
+ }
+ patterns = append(patterns, s)
+ }
+ env := map[string]string{}
+ for k, v := range rc.Env {
+ env[k] = v
+ }
+ env["patterns"] = strings.Join(patterns, "\n")
+ if followSymlink {
+ env["followSymbolicLinks"] = "true"
+ }
+
+ stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr)
+ _ = rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
+ Name: name,
+ Mode: 0o644,
+ Body: hashfiles,
+ }).
+ Then(rc.execJobContainer([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
+ env, "", "")).
+ Finally(func(context.Context) error {
+ rc.JobContainer.ReplaceLogWriter(stdout, stderr)
+ return nil
+ })(timeed)
+ output := hout.String() + "\n" + herr.String()
+ guard := "__OUTPUT__"
+ outstart := strings.Index(output, guard)
+ if outstart != -1 {
+ outstart += len(guard)
+ outend := strings.Index(output[outstart:], guard)
+ if outend != -1 {
+ return output[outstart : outstart+outend], nil
+ }
+ }
+ }
+ return "", nil
+ }
+ return hashFiles
+}
+
+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) (*yaml.Node, error) {
+ var in string
+ if err := node.Decode(&in); err != nil {
+ return nil, err
+ }
+ if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
+ return nil, nil
+ }
+ expr, _ := rewriteSubExpression(ctx, in, false)
+ res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
+ if err != nil {
+ return nil, err
+ }
+ ret := &yaml.Node{}
+ if err := ret.Encode(res); err != nil {
+ return nil, err
+ }
+ return ret, err
+}
+
+func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
+ var ret *yaml.Node
+ // 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; i++ {
+ changed := func() error {
+ if ret == nil {
+ ret = &yaml.Node{}
+ if err := ret.Encode(node); err != nil {
+ return err
+ }
+ ret.Content = ret.Content[:i*2]
+ }
+ return nil
+ }
+ k := node.Content[i*2]
+ v := node.Content[i*2+1]
+ ev, err := ee.evaluateYamlNodeInternal(ctx, v)
+ if err != nil {
+ return nil, err
+ }
+ if ev != nil {
+ if err := changed(); err != nil {
+ return nil, err
+ }
+ } else {
+ ev = v
+ }
+ var sk string
+ // Merge the nested map of the insert directive
+ if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
+ if ev.Kind != yaml.MappingNode {
+ return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
+ }
+ if err := changed(); err != nil {
+ return nil, err
+ }
+ ret.Content = append(ret.Content, ev.Content...)
+ } else {
+ ek, err := ee.evaluateYamlNodeInternal(ctx, k)
+ if err != nil {
+ return nil, err
+ }
+ if ek != nil {
+ if err := changed(); err != nil {
+ return nil, err
+ }
+ } else {
+ ek = k
+ }
+ if ret != nil {
+ ret.Content = append(ret.Content, ek, ev)
+ }
+ }
+ }
+ return ret, nil
+}
+
+func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
+ var ret *yaml.Node
+ for i := 0; i < len(node.Content); i++ {
+ v := node.Content[i]
+ // Preserve nested sequences
+ wasseq := v.Kind == yaml.SequenceNode
+ ev, err := ee.evaluateYamlNodeInternal(ctx, v)
+ if err != nil {
+ return nil, err
+ }
+ if ev != nil {
+ if ret == nil {
+ ret = &yaml.Node{}
+ if err := ret.Encode(node); err != nil {
+ return nil, err
+ }
+ ret.Content = ret.Content[:i]
+ }
+ // GitHub has this undocumented feature to merge sequences / arrays
+ // We have a nested sequence via evaluation, merge the arrays
+ if ev.Kind == yaml.SequenceNode && !wasseq {
+ ret.Content = append(ret.Content, ev.Content...)
+ } else {
+ ret.Content = append(ret.Content, ev)
+ }
+ } else if ret != nil {
+ ret.Content = append(ret.Content, v)
+ }
+ }
+ return ret, nil
+}
+
+func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.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, nil
+ }
+}
+
+func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
+ ret, err := ee.evaluateYamlNodeInternal(ctx, node)
+ if err != nil {
+ return err
+ }
+ if ret != nil {
+ return ret.Decode(node)
+ }
+ 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
+}
+
+//nolint:gocyclo
+func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]interface{} {
+ inputs := map[string]interface{}{}
+
+ setupWorkflowInputs(ctx, &inputs, rc)
+
+ var env map[string]string
+ if step != nil {
+ env = *step.getEnv()
+ } else {
+ env = rc.GetEnv()
+ }
+
+ for k, v := range env {
+ if strings.HasPrefix(k, "INPUT_") {
+ inputs[strings.ToLower(strings.TrimPrefix(k, "INPUT_"))] = v
+ }
+ }
+
+ if ghc.EventName == "workflow_dispatch" {
+ config := rc.Run.Workflow.WorkflowDispatchConfig()
+ if config != nil && config.Inputs != nil {
+ for k, v := range config.Inputs {
+ value := nestedMapLookup(ghc.Event, "inputs", k)
+ if value == nil {
+ value = v.Default
+ }
+ if v.Type == "boolean" {
+ inputs[k] = value == "true"
+ } else {
+ inputs[k] = value
+ }
+ }
+ }
+ }
+
+ if ghc.EventName == "workflow_call" {
+ config := rc.Run.Workflow.WorkflowCallConfig()
+ if config != nil && config.Inputs != nil {
+ for k, v := range config.Inputs {
+ value := nestedMapLookup(ghc.Event, "inputs", k)
+ if value == nil {
+ value = v.Default
+ }
+ if v.Type == "boolean" {
+ inputs[k] = value == "true"
+ } else {
+ inputs[k] = value
+ }
+ }
+ }
+ }
+ return inputs
+}
+
+func setupWorkflowInputs(ctx context.Context, inputs *map[string]interface{}, rc *RunContext) {
+ if rc.caller != nil {
+ config := rc.Run.Workflow.WorkflowCallConfig()
+
+ for name, input := range config.Inputs {
+ value := rc.caller.runContext.Run.Job().With[name]
+ if value != nil {
+ if str, ok := value.(string); ok {
+ // evaluate using the calling RunContext (outside)
+ value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
+ }
+ }
+
+ if value == nil && config != nil && config.Inputs != nil {
+ value = input.Default
+ if rc.ExprEval != nil {
+ if str, ok := value.(string); ok {
+ // evaluate using the called RunContext (inside)
+ value = rc.ExprEval.Interpolate(ctx, str)
+ }
+ }
+ }
+
+ (*inputs)[name] = value
+ }
+ }
+}
+
+func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
+ if rc.caller != nil {
+ job := rc.caller.runContext.Run.Job()
+ secrets := job.Secrets()
+
+ if secrets == nil && job.InheritSecrets() {
+ secrets = rc.caller.runContext.Config.Secrets
+ }
+
+ if secrets == nil {
+ secrets = map[string]string{}
+ }
+
+ for k, v := range secrets {
+ secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
+ }
+
+ return secrets
+ }
+
+ return rc.Config.Secrets
+}
+
+func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string {
+ return rc.Config.Vars
+}