Files
act_runner/pkg/runner/action.go
silverwind b0ec3fa4fc fmt
2026-02-24 08:17:17 +01:00

521 lines
16 KiB
Go

package runner
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"regexp"
"runtime"
"strings"
"github.com/kballard/go-shellquote"
"gitea.com/gitea/act_runner/pkg/common"
"gitea.com/gitea/act_runner/pkg/container"
"gitea.com/gitea/act_runner/pkg/model"
)
type actionStep interface {
step
getActionModel() *model.Action
getCompositeRunContext(context.Context) *RunContext
getCompositeSteps() *compositeSteps
getContainerActionPaths() (actionName string, containerActionDir string)
getTarArchive(ctx context.Context, src string) (io.ReadCloser, error)
getActionPath() string
maybeCopyToActionDir(ctx context.Context) error
}
type readAction func(ctx context.Context, step *model.Step, readFile actionYamlReader, config model.ActionConfig) (*model.Action, error)
type actionYamlReader func(filename string) (io.Reader, io.Closer, error)
type runAction func(step actionStep) common.Executor
func readActionImpl(ctx context.Context, step *model.Step, readFile actionYamlReader, config model.ActionConfig) (*model.Action, error) {
logger := common.Logger(ctx)
allErrors := []error{}
addError := func(fileName string, err error) {
if err != nil {
allErrors = append(allErrors, fmt.Errorf("failed to read '%s' from action '%s': %w", fileName, step.String(), err))
} else {
// One successful read, clear error state
allErrors = nil
}
}
reader, closer, err := readFile("action.yml")
addError("action.yml", err)
if os.IsNotExist(err) {
reader, closer, err = readFile("action.yaml")
addError("action.yaml", err)
if os.IsNotExist(err) {
_, closer, err := readFile("Dockerfile")
addError("Dockerfile", err)
if err == nil {
closer.Close()
action := &model.Action{
Name: "(Synthetic)",
Runs: model.ActionRuns{
Using: "docker",
Image: "Dockerfile",
},
}
logger.Debugf("Using synthetic action %v for Dockerfile", action)
return action, nil
}
}
}
if allErrors != nil {
return nil, errors.Join(allErrors...)
}
defer closer.Close()
action, err := model.ReadAction(reader, config)
logger.Debugf("Read action %v", action)
return action, err
}
func runActionImpl(step actionStep) common.Executor {
rc := step.getRunContext()
stepModel := step.getStepModel()
return func(ctx context.Context) error {
logger := common.Logger(ctx)
actionPath := step.getActionPath()
action := step.getActionModel()
logger.Debugf("About to run action %v", action)
err := setupActionEnv(ctx, step)
if err != nil {
return err
}
actionName, containerActionDir := step.getContainerActionPaths()
logger.Debugf("type=%v actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
x := action.Runs.Using
switch {
case x.IsNode():
if err := step.maybeCopyToActionDir(ctx); err != nil {
return err
}
containerArgs := []string{rc.GetNodeToolFullPath(ctx), path.Join(containerActionDir, action.Runs.Main)}
logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv())
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
return execAsDocker(ctx, step, actionName, actionPath, "entrypoint")
case x.IsComposite():
if err := step.maybeCopyToActionDir(ctx); err != nil {
return err
}
return execAsComposite(step)(ctx)
default:
return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{
model.ActionRunsUsingDocker,
model.ActionRunsUsingNode,
model.ActionRunsUsingComposite,
}, action.Runs.Using)
}
}
}
func setupActionEnv(ctx context.Context, step actionStep) error {
rc := step.getRunContext()
// A few fields in the environment (e.g. GITHUB_ACTION_REPOSITORY)
// are dependent on the action. That means we can complete the
// setup only after resolving the whole action model and cloning
// the action
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
populateEnvsFromSavedState(step.getEnv(), step, rc)
populateEnvsFromInput(ctx, step.getEnv(), step.getActionModel(), rc)
return nil
}
// TODO: break out parts of function to reduce complexicity
//
//nolint:gocyclo
func execAsDocker(ctx context.Context, step actionStep, actionName, subpath string, entrypointType string) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
action := step.getActionModel()
var prepImage common.Executor
var image string
forcePull := false
if after, ok := strings.CutPrefix(action.Runs.Image, "docker://"); ok {
image = after
// Apply forcePull only for prebuild docker images
forcePull = rc.Config.ForcePull
} else {
// "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
image = "act-" + strings.TrimLeft(image, "-")
image = strings.ToLower(image)
contextDir, fileName := path.Split(path.Join(subpath, action.Runs.Image))
anyArchExists, err := container.ImageExistsLocally(ctx, image, "any")
if err != nil {
return err
}
correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
if err != nil {
return err
}
if anyArchExists && !correctArchExists {
wasRemoved, err := container.RemoveImage(ctx, image, true, true)
if err != nil {
return err
}
if !wasRemoved {
return fmt.Errorf("failed to remove image '%s'", image)
}
}
if !correctArchExists || rc.Config.ForceRebuild {
logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir)
buildContext, err := step.getTarArchive(ctx, contextDir+".")
if err != nil {
return err
}
defer buildContext.Close()
prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
Dockerfile: fileName,
ImageTag: image,
BuildContext: buildContext,
Platform: rc.Config.ContainerArchitecture,
})
} else {
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
}
}
eval := rc.NewStepExpressionEvaluator(ctx, step)
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
if err != nil {
return err
}
if len(cmd) == 0 {
cmd = action.Runs.Args
evalDockerArgs(ctx, step, action, &cmd)
}
entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With[entrypointType]))
if len(entrypoint) == 0 {
if entrypointType == "pre-entrypoint" && action.Runs.PreEntrypoint != "" {
entrypoint, err = shellquote.Split(action.Runs.PreEntrypoint)
if err != nil {
return err
}
} else if entrypointType == "entrypoint" && action.Runs.Entrypoint != "" {
entrypoint, err = shellquote.Split(action.Runs.Entrypoint)
if err != nil {
return err
}
} else if entrypointType == "post-entrypoint" && action.Runs.PostEntrypoint != "" {
entrypoint, err = shellquote.Split(action.Runs.PostEntrypoint)
if err != nil {
return err
}
} else {
entrypoint = nil
}
}
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
return common.NewPipelineExecutor(
prepImage,
stepContainer.Pull(forcePull),
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
stepContainer.Start(true),
).Finally(
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
).Finally(stepContainer.Close())(ctx)
}
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
rc := step.getRunContext()
stepModel := step.getStepModel()
inputs := make(map[string]string)
eval := rc.NewExpressionEvaluator(ctx)
// Set Defaults
for k, input := range action.Inputs {
inputs[k] = eval.Interpolate(ctx, input.Default)
}
if stepModel.With != nil {
for k, v := range stepModel.With {
inputs[k] = eval.Interpolate(ctx, v)
}
}
mergeIntoMap(step, step.getEnv(), inputs)
stepEE := rc.NewStepExpressionEvaluator(ctx, step)
for i, v := range *cmd {
(*cmd)[i] = stepEE.Interpolate(ctx, v)
}
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
ee := rc.NewStepExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() {
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
}
}
func newStepContainer(ctx context.Context, step step, image string, cmd []string, entrypoint []string) container.Container {
rc := step.getRunContext()
stepModel := step.getStepModel()
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
} else {
rawLogger.Debugf("%s", s)
}
return true
})
envList := make([]string, 0)
for k, v := range *step.getEnv() {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
binds, mounts := rc.GetBindsAndMounts()
networkMode := "container:" + rc.jobContainerName()
var workdir string
if rc.IsHostEnv(ctx) {
networkMode = "default"
ext := container.LinuxContainerEnvironmentExtensions{}
workdir = ext.ToContainerPath(rc.Config.Workdir)
} else {
workdir = rc.JobContainer.ToContainerPath(rc.Config.Workdir)
}
stepContainer := container.NewContainer(&container.NewContainerInput{
Cmd: cmd,
Entrypoint: entrypoint,
WorkingDir: workdir,
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), stepModel.ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
Options: rc.Config.ContainerOptions,
})
return stepContainer
}
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
state, ok := rc.IntraActionState[step.getStepModel().ID]
if ok {
for name, value := range state {
envName := "STATE_" + name
(*env)[envName] = value
}
}
}
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
eval := rc.NewExpressionEvaluator(ctx)
for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = "INPUT_" + envKey
if _, ok := (*env)[envKey]; !ok {
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
}
}
}
func normalizePath(s string) string {
if runtime.GOOS == "windows" {
return strings.ReplaceAll(s, "\\", "/")
}
return s
}
func getOsSafeRelativePath(s, prefix string) string {
actionName := strings.TrimPrefix(s, prefix)
actionName = normalizePath(actionName)
actionName = strings.TrimPrefix(actionName, "/")
return actionName
}
func shouldRunPreStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool {
log := common.Logger(ctx)
if step.getActionModel() == nil {
log.Debugf("skip pre step for '%s': no action model available", step.getStepModel())
return false
}
return true
}
}
func hasPreStep(step actionStep) common.Conditional {
return func(_ context.Context) bool {
action := step.getActionModel()
return action.Runs.Using.IsComposite() ||
(action.Runs.Using.IsNode() &&
action.Runs.Pre != "") ||
(action.Runs.Using.IsDocker() &&
action.Runs.PreEntrypoint != "")
}
}
func runPreStep(step actionStep) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("run pre step for '%s'", step.getStepModel())
rc := step.getRunContext()
action := step.getActionModel()
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
actionPath := step.getActionPath()
actionName, containerActionDir := step.getContainerActionPaths()
x := action.Runs.Using
switch {
case x.IsNode():
if err := step.maybeCopyToActionDir(ctx); err != nil {
return err
}
containerArgs := []string{rc.GetNodeToolFullPath(ctx), path.Join(containerActionDir, action.Runs.Pre)}
logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv())
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
return execAsDocker(ctx, step, actionName, actionPath, "pre-entrypoint")
case x.IsComposite():
if step.getCompositeSteps() == nil {
step.getCompositeRunContext(ctx)
}
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
return steps.pre(ctx)
}
return errors.New("missing steps in composite action")
default:
return nil
}
}
}
func shouldRunPostStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool {
log := common.Logger(ctx)
stepResults := step.getRunContext().getStepsContext()
stepResult := stepResults[step.getStepModel().ID]
if stepResult == nil {
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; step was not executed", step.getStepModel())
return false
}
if stepResult.Conclusion == model.StepStatusSkipped {
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; main step was skipped", step.getStepModel())
return false
}
if step.getActionModel() == nil {
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s': no action model available", step.getStepModel())
return false
}
return true
}
}
func hasPostStep(step actionStep) common.Conditional {
return func(_ context.Context) bool {
action := step.getActionModel()
return action.Runs.Using.IsComposite() ||
(action.Runs.Using.IsNode() &&
action.Runs.Post != "") ||
(action.Runs.Using.IsDocker() &&
action.Runs.PostEntrypoint != "")
}
}
func runPostStep(step actionStep) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("run post step for '%s'", step.getStepModel())
rc := step.getRunContext()
action := step.getActionModel()
actionPath := step.getActionPath()
actionName, containerActionDir := step.getContainerActionPaths()
x := action.Runs.Using
switch {
case x.IsNode():
if err := step.maybeCopyToActionDir(ctx); err != nil {
return err
}
populateEnvsFromSavedState(step.getEnv(), step, rc)
populateEnvsFromInput(ctx, step.getEnv(), step.getActionModel(), rc)
containerArgs := []string{rc.GetNodeToolFullPath(ctx), path.Join(containerActionDir, action.Runs.Post)}
logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv())
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
return execAsDocker(ctx, step, actionName, actionPath, "post-entrypoint")
case x.IsComposite():
if err := step.maybeCopyToActionDir(ctx); err != nil {
return err
}
if steps := step.getCompositeSteps(); steps != nil && steps.post != nil {
return steps.post(ctx)
}
return errors.New("missing steps in composite action")
default:
return nil
}
}
}