mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-29 11:26:31 +08:00
`exec` forwarded the toolkit's ArtifactService calls to an instance it never registered, and its random runtime token held no backend IDs, so `actions/upload-artifact` and `actions/download-artifact` v4 and later failed. The local artifact server now serves the v4 API, including the single Put Blob uploads docker/build-push-action uses. `exec` gives each workflow its own unsigned JWT runtime token and artifact run, so artifact names conflict and carry over between runs as on GitHub. Artifacts default to a temporary directory instead of the workspace that jobs copy. Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1341 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni <bircni@icloud.com>
342 lines
12 KiB
Go
342 lines
12 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"path"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/container"
|
|
|
|
_ "embed"
|
|
|
|
"gitea.dev/actionslib/pkg/expreval"
|
|
"gitea.dev/actionslib/pkg/exprparser"
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
// 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
|
|
strategy := make(map[string]any)
|
|
if rc.Run != nil {
|
|
strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal)
|
|
|
|
// 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 rc.Run.Workflow.Jobs {
|
|
result := model.WorkflowCallResult{
|
|
Outputs: map[string]string{},
|
|
}
|
|
unlock := lockJob(job)
|
|
maps.Copy(result.Outputs, job.Outputs)
|
|
unlock()
|
|
workflowCallResult[jobName] = &result
|
|
}
|
|
}
|
|
}
|
|
|
|
ghc := rc.getGithubContext(ctx)
|
|
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.StepResults,
|
|
Secrets: getWorkflowSecrets(rc),
|
|
Vars: rc.Config.Vars,
|
|
Strategy: strategy,
|
|
Matrix: rc.Matrix,
|
|
Needs: exprparser.NeedsContext(rc.Run),
|
|
Inputs: getEvaluatorInputs(rc, rc.actionInputs, ghc),
|
|
HashFiles: getHashFilesFunction(ctx, rc),
|
|
}
|
|
ee.Runner = rc.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
|
|
|
|
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
|
|
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
|
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, rc.getJobContext())
|
|
}
|
|
|
|
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
|
|
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
|
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()), rc.getJobContext())
|
|
}
|
|
|
|
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator {
|
|
// todo: cleanup EvaluationEnvironment creation
|
|
ee := &exprparser.EvaluationEnvironment{
|
|
Github: step.getGithubContext(ctx),
|
|
Env: *step.getEnv(),
|
|
Job: jobContext,
|
|
Steps: rc.StepResults,
|
|
Secrets: getWorkflowSecrets(rc),
|
|
Vars: rc.Config.Vars,
|
|
Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
|
|
Matrix: rc.Matrix,
|
|
Needs: exprparser.NeedsContext(rc.Run),
|
|
// todo: should be unavailable
|
|
// but required to interpolate/evaluate the inputs in actions/composite
|
|
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
|
HashFiles: getHashFilesFunction(ctx, rc),
|
|
}
|
|
ee.Runner = rc.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) (any, error) {
|
|
return func(v []reflect.Value) (any, error) {
|
|
if rc.JobContainer != nil {
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
|
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 && 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{}
|
|
maps.Copy(env, rc.Env)
|
|
env["patterns"] = strings.Join(patterns, "\n")
|
|
if followSymlink {
|
|
env["followSymbolicLinks"] = "true"
|
|
}
|
|
|
|
stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr)
|
|
defer rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
|
err := rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
|
|
Name: name,
|
|
Mode: 0o644,
|
|
Body: hashfiles,
|
|
}).Then(rc.JobContainer.Exec([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)}, env, "", ""))(timeoutCtx)
|
|
quotedPatterns := expressionStringEscape(strings.Join(patterns, ", "))
|
|
switch {
|
|
case err == nil:
|
|
case ctx.Err() != nil:
|
|
return "", ctx.Err()
|
|
case timeoutCtx.Err() != nil || errors.Is(err, context.DeadlineExceeded):
|
|
return "", fmt.Errorf("hashFiles('%s') couldn't finish within 120 seconds", quotedPatterns)
|
|
case hashFilesNodeMissing(err):
|
|
common.Logger(ctx).With(rawOutputField, true).Warnf("##[warning]hashFiles could not run because node is unavailable in the job environment. Returning an empty string.")
|
|
return "", nil
|
|
default:
|
|
return "", fmt.Errorf("hashFiles('%s') failed. Fail to hash files under directory '%s'", quotedPatterns, rc.githubWorkspace())
|
|
}
|
|
for line := range strings.Lines(hout.String() + "\n" + herr.String()) {
|
|
if hash, ok := strings.CutPrefix(strings.TrimRight(line, "\r\n"), "__OUTPUT__"); ok {
|
|
if hash, ok = strings.CutSuffix(hash, "__OUTPUT__"); ok {
|
|
return hash, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func hashFilesNodeMissing(err error) bool {
|
|
exitErr, ok := errors.AsType[container.ExitCodeError](err)
|
|
return ok && (exitErr == 126 || exitErr == 127) || err.Error() == "Cannot find: node in PATH"
|
|
}
|
|
|
|
type expressionEvaluator struct {
|
|
interpreter exprparser.Interpreter
|
|
}
|
|
|
|
type ExpressionEvaluator = expressionEvaluator
|
|
|
|
var addMaskExpression = regexp.MustCompile(`::add-mask::.*`)
|
|
|
|
func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
|
logger := common.Logger(ctx)
|
|
logger.Debugf("evaluating expression '%s'", in)
|
|
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
|
|
|
// evaluated is an any: %t renders everything but a bool as "%!t(string=...)"
|
|
logger.Debugf("expression '%s' evaluated to '%s'", in, addMaskExpression.ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)"))
|
|
|
|
return evaluated, err
|
|
}
|
|
|
|
// shared returns the evaluation layer of the shared library, bound to this context so the
|
|
// evaluation of every single expression is still logged and masked here.
|
|
func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator {
|
|
return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
|
return ee.evaluate(ctx, in, defaultStatusCheck)
|
|
})
|
|
}
|
|
|
|
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
|
}
|
|
|
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
|
|
return ee.shared(ctx).Interpolate(in)
|
|
}
|
|
|
|
// InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
|
|
func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
|
|
out, err := ee.Interpolate(ctx, in)
|
|
if err != nil {
|
|
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
|
|
return in
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
|
// `${{ }}`, while literal text around one makes the whole value a string.
|
|
func EvalBool(ctx context.Context, evaluator *expressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
|
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
|
return evaluator.evaluate(ctx, in, dsc)
|
|
}).EvalBool(expr, defaultStatusCheck)
|
|
}
|
|
|
|
func inputsFromEnv(env map[string]string) map[string]any {
|
|
inputs := map[string]any{}
|
|
for k, v := range env {
|
|
if after, ok := strings.CutPrefix(k, "INPUT_"); ok {
|
|
inputs[strings.ToLower(after)] = v
|
|
}
|
|
}
|
|
return inputs
|
|
}
|
|
|
|
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
|
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
|
|
return maps.Clone(stepInputs)
|
|
}
|
|
// only a called workflow's job itself takes the inputs Gitea resolved,
|
|
// composite actions copy its Config but have their own `inputs`
|
|
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
|
|
return maps.Clone(rc.Config.WorkflowCallInputs)
|
|
}
|
|
|
|
inputs := map[string]any{}
|
|
maps.Copy(inputs, rc.workflowCallInputs)
|
|
|
|
switch ghc.EventName {
|
|
case "workflow_dispatch":
|
|
if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
|
|
for name, input := range config.Inputs {
|
|
if input.Type == "number" {
|
|
input.Type = "string" // boolean is the only typed dispatch input, as in Gitea
|
|
}
|
|
setEvaluatorInput(inputs, ghc, name, model.WorkflowCallInput{Type: input.Type, Default: input.Default})
|
|
}
|
|
}
|
|
case "workflow_call":
|
|
for name, input := range rc.Run.Workflow.WorkflowCallConfig().Inputs {
|
|
setEvaluatorInput(inputs, ghc, name, input)
|
|
}
|
|
}
|
|
return inputs
|
|
}
|
|
|
|
func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) {
|
|
value := model.NestedMapLookup(ghc.Event, "inputs", name)
|
|
if value == nil {
|
|
value, _ = input.DefaultValue()
|
|
}
|
|
inputs[name] = model.CoerceInputValue(value, input.Type)
|
|
}
|
|
|
|
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
|
func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
|
if rc.caller == nil {
|
|
return nil
|
|
}
|
|
logger := common.Logger(ctx)
|
|
callerJob := rc.caller.runContext.Run.Job()
|
|
callerEval := rc.caller.runContext.ExprEval.shared(ctx)
|
|
var callerWith map[string]any
|
|
if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil {
|
|
return err
|
|
}
|
|
if !rc.Run.Workflow.IsWorkflowCall() {
|
|
logger.Warn("workflow_call key is not defined in the referenced workflow")
|
|
}
|
|
config := rc.Run.Workflow.WorkflowCallConfig()
|
|
var err error
|
|
if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil {
|
|
logger.Warn(err)
|
|
}
|
|
|
|
if callerJob.InheritSecrets() {
|
|
rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext)
|
|
return nil
|
|
}
|
|
secrets := callerJob.Secrets()
|
|
if err := config.ValidateSecrets(secrets); err != nil {
|
|
logger.Warn(err)
|
|
}
|
|
if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil {
|
|
return fmt.Errorf("unable to interpolate %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getWorkflowSecrets(rc *RunContext) map[string]string {
|
|
if rc.caller != nil {
|
|
return rc.workflowCallSecrets
|
|
}
|
|
if rc.runtimeToken == "" {
|
|
return rc.Config.Secrets
|
|
}
|
|
secrets := map[string]string{"ACTIONS_RUNTIME_TOKEN": rc.runtimeToken} // as the daemon exposes its token
|
|
maps.Copy(secrets, rc.Config.Secrets)
|
|
return secrets
|
|
}
|