summaryrefslogtreecommitdiffstats
path: root/pkg/runner/runner.go
blob: b0890979e5b3dc921c0e5d10b3ae40e0b66f4b3b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
package runner

import (
	"context"
	"fmt"
	"io/ioutil"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"
	"time"

	log "github.com/sirupsen/logrus"

	"github.com/nektos/act/pkg/common"
	"github.com/nektos/act/pkg/container"
	"github.com/nektos/act/pkg/exprparser"
	"github.com/nektos/act/pkg/model"
)

// Runner provides capabilities to run GitHub actions
type Runner interface {
	NewPlanExecutor(plan *model.Plan) common.Executor
}

// Config contains the config for a new runner
type Config struct {
	Actor                 string            // the user that triggered the event
	Workdir               string            // path to working directory
	BindWorkdir           bool              // bind the workdir to the job container
	EventName             string            // name of event to run
	EventPath             string            // path to JSON file to use for event.json in containers
	DefaultBranch         string            // name of the main branch for this repository
	ReuseContainers       bool              // reuse containers to maintain state
	ForcePull             bool              // force pulling of the image, even if already present
	ForceRebuild          bool              // force rebuilding local docker image action
	LogOutput             bool              // log the output from docker run
	JSONLogger            bool              // use json or text logger
	Env                   map[string]string // env for containers
	Secrets               map[string]string // list of secrets
	Token                 string            // GitHub token
	InsecureSecrets       bool              // switch hiding output when printing to terminal
	Platforms             map[string]string // list of platforms
	Privileged            bool              // use privileged mode
	UsernsMode            string            // user namespace to use
	ContainerArchitecture string            // Desired OS/architecture platform for running containers
	ContainerDaemonSocket string            // Path to Docker daemon socket
	UseGitIgnore          bool              // controls if paths in .gitignore should not be copied into container, default true
	GitHubInstance        string            // GitHub instance to use, default "github.com"
	ContainerCapAdd       []string          // list of kernel capabilities to add to the containers
	ContainerCapDrop      []string          // list of kernel capabilities to remove from the containers
	AutoRemove            bool              // controls if the container is automatically removed upon workflow completion
	ArtifactServerPath    string            // the path where the artifact server stores uploads
	ArtifactServerPort    string            // the port the artifact server binds to
	NoSkipCheckout        bool              // do not skip actions/checkout
	RemoteName            string            // remote name in local git repo config
}

// Resolves the equivalent host path inside the container
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
// For use in docker volumes and binds
func (config *Config) containerPath(path string) string {
	if runtime.GOOS == "windows" && strings.Contains(path, "/") {
		log.Error("You cannot specify linux style local paths (/mnt/etc) on Windows as it does not understand them.")
		return ""
	}

	abspath, err := filepath.Abs(path)
	if err != nil {
		log.Error(err)
		return ""
	}

	// Test if the path is a windows path
	windowsPathRegex := regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`)
	windowsPathComponents := windowsPathRegex.FindStringSubmatch(abspath)

	// Return as-is if no match
	if windowsPathComponents == nil {
		return abspath
	}

	// Convert to WSL2-compatible path if it is a windows path
	// NOTE: Cannot use filepath because it will use the wrong path separators assuming we want the path to be windows
	// based if running on Windows, and because we are feeding this to Docker, GoLang auto-path-translate doesn't work.
	driveLetter := strings.ToLower(windowsPathComponents[1])
	translatedPath := strings.ReplaceAll(windowsPathComponents[2], `\`, `/`)
	// Should make something like /mnt/c/Users/person/My Folder/MyActProject
	result := strings.Join([]string{"/mnt", driveLetter, translatedPath}, `/`)
	return result
}

// Resolves the equivalent host path inside the container
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
func (config *Config) ContainerWorkdir() string {
	return config.containerPath(config.Workdir)
}

type runnerImpl struct {
	config    *Config
	eventJSON string
}

// New Creates a new Runner
func New(runnerConfig *Config) (Runner, error) {
	runner := &runnerImpl{
		config: runnerConfig,
	}

	runner.eventJSON = "{}"
	if runnerConfig.EventPath != "" {
		log.Debugf("Reading event.json from %s", runner.config.EventPath)
		eventJSONBytes, err := ioutil.ReadFile(runner.config.EventPath)
		if err != nil {
			return nil, err
		}
		runner.eventJSON = string(eventJSONBytes)
	}
	return runner, nil
}

// NewPlanExecutor ...
//nolint:gocyclo
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
	maxJobNameLen := 0

	stagePipeline := make([]common.Executor, 0)
	for i := range plan.Stages {
		s := i
		stage := plan.Stages[i]
		stagePipeline = append(stagePipeline, func(ctx context.Context) error {
			pipeline := make([]common.Executor, 0)
			for r, run := range stage.Runs {
				stageExecutor := make([]common.Executor, 0)
				job := run.Job()

				if job.Uses != "" {
					return fmt.Errorf("reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)")
				}

				if job.Strategy != nil {
					strategyRc := runner.newRunContext(run, nil)
					if err := strategyRc.NewExpressionEvaluator().EvaluateYamlNode(&job.Strategy.RawMatrix); err != nil {
						log.Errorf("Error while evaluating matrix: %v", err)
					}
				}
				matrixes := job.GetMatrixes()
				maxParallel := 4
				if job.Strategy != nil {
					maxParallel = job.Strategy.MaxParallel
				}

				if len(matrixes) < maxParallel {
					maxParallel = len(matrixes)
				}

				for i, matrix := range matrixes {
					rc := runner.newRunContext(run, matrix)
					rc.JobName = rc.Name
					if len(matrixes) > 1 {
						rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
					}
					// evaluate environment variables since they can contain
					// GitHub's special environment variables.
					for k, v := range rc.GetEnv() {
						valueEval, err := rc.ExprEval.evaluate(v, exprparser.DefaultStatusCheckNone)
						if err == nil {
							rc.Env[k] = fmt.Sprintf("%v", valueEval)
						}
					}
					if len(rc.String()) > maxJobNameLen {
						maxJobNameLen = len(rc.String())
					}
					stageExecutor = append(stageExecutor, func(ctx context.Context) error {
						jobName := fmt.Sprintf("%-*s", maxJobNameLen, rc.String())
						return rc.Executor().Finally(func(ctx context.Context) error {
							isLastRunningContainer := func(currentStage int, currentRun int) bool {
								return currentStage == len(plan.Stages)-1 && currentRun == len(stage.Runs)-1
							}

							if runner.config.AutoRemove && isLastRunningContainer(s, r) {
								var cancel context.CancelFunc
								if ctx.Err() == context.Canceled {
									ctx, cancel = context.WithTimeout(context.Background(), 5*time.Minute)
									defer cancel()
								}

								log.Infof("Cleaning up container for job %s", rc.JobName)

								if err := rc.stopJobContainer()(ctx); err != nil {
									log.Errorf("Error while cleaning container: %v", err)
								}
							}

							return nil
						})(common.WithJobErrorContainer(WithJobLogger(ctx, jobName, rc.Config, &rc.Masks)))
					})
				}
				pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
			}
			var ncpu int
			info, err := container.GetHostInfo(ctx)
			if err != nil {
				log.Errorf("failed to obtain container engine info: %s", err)
				ncpu = 1 // sane default?
			} else {
				ncpu = info.NCPU
			}
			return common.NewParallelExecutor(ncpu, pipeline...)(ctx)
		})
	}

	return common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
}

func handleFailure(plan *model.Plan) common.Executor {
	return func(ctx context.Context) error {
		for _, stage := range plan.Stages {
			for _, run := range stage.Runs {
				if run.Job().Result == "failure" {
					return fmt.Errorf("Job '%s' failed", run.String())
				}
			}
		}
		return nil
	}
}

func (runner *runnerImpl) newRunContext(run *model.Run, matrix map[string]interface{}) *RunContext {
	rc := &RunContext{
		Config:      runner.config,
		Run:         run,
		EventJSON:   runner.eventJSON,
		StepResults: make(map[string]*model.StepResult),
		Matrix:      matrix,
	}
	rc.ExprEval = rc.NewExpressionEvaluator()
	rc.Name = rc.ExprEval.Interpolate(run.String())
	return rc
}