diff options
Diffstat (limited to 'pkg/runner/expression.go')
-rw-r--r-- | pkg/runner/expression.go | 581 |
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 +} |