summaryrefslogtreecommitdiffstats
path: root/internal/app/cmd/exec.go
blob: 9a6824c9d1cf3e3ac3e3946ba2f7fab6d13112a5 (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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2019 nektos
// SPDX-License-Identifier: MIT

package cmd

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/docker/docker/api/types/container"
	"github.com/joho/godotenv"
	"github.com/nektos/act/pkg/artifactcache"
	"github.com/nektos/act/pkg/artifacts"
	"github.com/nektos/act/pkg/common"
	"github.com/nektos/act/pkg/model"
	"github.com/nektos/act/pkg/runner"
	log "github.com/sirupsen/logrus"
	"github.com/spf13/cobra"
	"golang.org/x/term"
)

type executeArgs struct {
	runList               bool
	job                   string
	event                 string
	workdir               string
	workflowsPath         string
	noWorkflowRecurse     bool
	autodetectEvent       bool
	forcePull             bool
	forceRebuild          bool
	jsonLogger            bool
	envs                  []string
	envfile               string
	secrets               []string
	defaultActionsURL     string
	insecureSecrets       bool
	privileged            bool
	usernsMode            string
	containerArchitecture string
	containerDaemonSocket string
	useGitIgnore          bool
	containerCapAdd       []string
	containerCapDrop      []string
	containerOptions      string
	artifactServerPath    string
	artifactServerAddr    string
	artifactServerPort    string
	noSkipCheckout        bool
	debug                 bool
	dryrun                bool
	image                 string
	cacheHandler          *artifactcache.Handler
	network               string
	githubInstance        string
}

// WorkflowsPath returns path to workflow file(s)
func (i *executeArgs) WorkflowsPath() string {
	return i.resolve(i.workflowsPath)
}

// Envfile returns path to .env
func (i *executeArgs) Envfile() string {
	return i.resolve(i.envfile)
}

func (i *executeArgs) LoadSecrets() map[string]string {
	s := make(map[string]string)
	for _, secretPair := range i.secrets {
		secretPairParts := strings.SplitN(secretPair, "=", 2)
		secretPairParts[0] = strings.ToUpper(secretPairParts[0])
		if strings.ToUpper(s[secretPairParts[0]]) == secretPairParts[0] {
			log.Errorf("Secret %s is already defined (secrets are case insensitive)", secretPairParts[0])
		}
		if len(secretPairParts) == 2 {
			s[secretPairParts[0]] = secretPairParts[1]
		} else if env, ok := os.LookupEnv(secretPairParts[0]); ok && env != "" {
			s[secretPairParts[0]] = env
		} else {
			fmt.Printf("Provide value for '%s': ", secretPairParts[0])
			val, err := term.ReadPassword(int(os.Stdin.Fd()))
			fmt.Println()
			if err != nil {
				log.Errorf("failed to read input: %v", err)
				os.Exit(1)
			}
			s[secretPairParts[0]] = string(val)
		}
	}
	return s
}

func readEnvs(path string, envs map[string]string) bool {
	if _, err := os.Stat(path); err == nil {
		env, err := godotenv.Read(path)
		if err != nil {
			log.Fatalf("Error loading from %s: %v", path, err)
		}
		for k, v := range env {
			envs[k] = v
		}
		return true
	}
	return false
}

func (i *executeArgs) LoadEnvs() map[string]string {
	envs := make(map[string]string)
	if i.envs != nil {
		for _, envVar := range i.envs {
			e := strings.SplitN(envVar, `=`, 2)
			if len(e) == 2 {
				envs[e[0]] = e[1]
			} else {
				envs[e[0]] = ""
			}
		}
	}
	_ = readEnvs(i.Envfile(), envs)

	envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"

	return envs
}

// Workdir returns path to workdir
func (i *executeArgs) Workdir() string {
	return i.resolve(".")
}

func (i *executeArgs) resolve(path string) string {
	basedir, err := filepath.Abs(i.workdir)
	if err != nil {
		log.Fatal(err)
	}
	if path == "" {
		return path
	}
	if !filepath.IsAbs(path) {
		path = filepath.Join(basedir, path)
	}
	return path
}

func printList(plan *model.Plan) error {
	type lineInfoDef struct {
		jobID   string
		jobName string
		stage   string
		wfName  string
		wfFile  string
		events  string
	}
	lineInfos := []lineInfoDef{}

	header := lineInfoDef{
		jobID:   "Job ID",
		jobName: "Job name",
		stage:   "Stage",
		wfName:  "Workflow name",
		wfFile:  "Workflow file",
		events:  "Events",
	}

	jobs := map[string]bool{}
	duplicateJobIDs := false

	jobIDMaxWidth := len(header.jobID)
	jobNameMaxWidth := len(header.jobName)
	stageMaxWidth := len(header.stage)
	wfNameMaxWidth := len(header.wfName)
	wfFileMaxWidth := len(header.wfFile)
	eventsMaxWidth := len(header.events)

	for i, stage := range plan.Stages {
		for _, r := range stage.Runs {
			jobID := r.JobID
			line := lineInfoDef{
				jobID:   jobID,
				jobName: r.String(),
				stage:   strconv.Itoa(i),
				wfName:  r.Workflow.Name,
				wfFile:  r.Workflow.File,
				events:  strings.Join(r.Workflow.On(), `,`),
			}
			if _, ok := jobs[jobID]; ok {
				duplicateJobIDs = true
			} else {
				jobs[jobID] = true
			}
			lineInfos = append(lineInfos, line)
			if jobIDMaxWidth < len(line.jobID) {
				jobIDMaxWidth = len(line.jobID)
			}
			if jobNameMaxWidth < len(line.jobName) {
				jobNameMaxWidth = len(line.jobName)
			}
			if stageMaxWidth < len(line.stage) {
				stageMaxWidth = len(line.stage)
			}
			if wfNameMaxWidth < len(line.wfName) {
				wfNameMaxWidth = len(line.wfName)
			}
			if wfFileMaxWidth < len(line.wfFile) {
				wfFileMaxWidth = len(line.wfFile)
			}
			if eventsMaxWidth < len(line.events) {
				eventsMaxWidth = len(line.events)
			}
		}
	}

	jobIDMaxWidth += 2
	jobNameMaxWidth += 2
	stageMaxWidth += 2
	wfNameMaxWidth += 2
	wfFileMaxWidth += 2

	fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
		-stageMaxWidth, header.stage,
		-jobIDMaxWidth, header.jobID,
		-jobNameMaxWidth, header.jobName,
		-wfNameMaxWidth, header.wfName,
		-wfFileMaxWidth, header.wfFile,
		-eventsMaxWidth, header.events,
	)
	for _, line := range lineInfos {
		fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
			-stageMaxWidth, line.stage,
			-jobIDMaxWidth, line.jobID,
			-jobNameMaxWidth, line.jobName,
			-wfNameMaxWidth, line.wfName,
			-wfFileMaxWidth, line.wfFile,
			-eventsMaxWidth, line.events,
		)
	}
	if duplicateJobIDs {
		fmt.Print("\nDetected multiple jobs with the same job name, use `-W` to specify the path to the specific workflow.\n")
	}
	return nil
}

func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *executeArgs) error {
	// plan with filtered jobs - to be used for filtering only
	var filterPlan *model.Plan

	// Determine the event name to be filtered
	var filterEventName string

	if len(execArgs.event) > 0 {
		log.Infof("Using chosed event for filtering: %s", execArgs.event)
		filterEventName = execArgs.event
	} else if execArgs.autodetectEvent {
		// collect all events from loaded workflows
		events := planner.GetEvents()

		// set default event type to first event from many available
		// this way user dont have to specify the event.
		log.Infof("Using first detected workflow event for filtering: %s", events[0])

		filterEventName = events[0]
	}

	var err error
	if execArgs.job != "" {
		log.Infof("Preparing plan with a job: %s", execArgs.job)
		filterPlan, err = planner.PlanJob(execArgs.job)
		if err != nil {
			return err
		}
	} else if filterEventName != "" {
		log.Infof("Preparing plan for a event: %s", filterEventName)
		filterPlan, err = planner.PlanEvent(filterEventName)
		if err != nil {
			return err
		}
	} else {
		log.Infof("Preparing plan with all jobs")
		filterPlan, err = planner.PlanAll()
		if err != nil {
			return err
		}
	}

	_ = printList(filterPlan)

	return nil
}

func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
	return func(cmd *cobra.Command, args []string) error {
		planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
		if err != nil {
			return err
		}

		if execArgs.runList {
			return runExecList(ctx, planner, execArgs)
		}

		// plan with triggered jobs
		var plan *model.Plan

		// Determine the event name to be triggered
		var eventName string

		// collect all events from loaded workflows
		events := planner.GetEvents()

		if len(execArgs.event) > 0 {
			log.Infof("Using chosed event for filtering: %s", execArgs.event)
			eventName = execArgs.event
		} else if len(events) == 1 && len(events[0]) > 0 {
			log.Infof("Using the only detected workflow event: %s", events[0])
			eventName = events[0]
		} else if execArgs.autodetectEvent && len(events) > 0 && len(events[0]) > 0 {
			// set default event type to first event from many available
			// this way user dont have to specify the event.
			log.Infof("Using first detected workflow event: %s", events[0])
			eventName = events[0]
		} else {
			log.Infof("Using default workflow event: push")
			eventName = "push"
		}

		// build the plan for this run
		if execArgs.job != "" {
			log.Infof("Planning job: %s", execArgs.job)
			plan, err = planner.PlanJob(execArgs.job)
			if err != nil {
				return err
			}
		} else {
			log.Infof("Planning jobs for event: %s", eventName)
			plan, err = planner.PlanEvent(eventName)
			if err != nil {
				return err
			}
		}

		maxLifetime := 3 * time.Hour
		if deadline, ok := ctx.Deadline(); ok {
			maxLifetime = time.Until(deadline)
		}

		// init a cache server
		handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
		if err != nil {
			return err
		}
		log.Infof("cache handler listens on: %v", handler.ExternalURL())
		execArgs.cacheHandler = handler

		if len(execArgs.artifactServerAddr) == 0 {
			ip := common.GetOutboundIP()
			if ip == nil {
				return fmt.Errorf("unable to determine outbound IP address")
			}
			execArgs.artifactServerAddr = ip.String()
		}

		if len(execArgs.artifactServerPath) == 0 {
			tempDir, err := os.MkdirTemp("", "gitea-act-")
			if err != nil {
				fmt.Println(err)
			}
			defer os.RemoveAll(tempDir)

			execArgs.artifactServerPath = tempDir
		}

		// run the plan
		config := &runner.Config{
			Workdir:               execArgs.Workdir(),
			BindWorkdir:           false,
			ReuseContainers:       false,
			ForcePull:             execArgs.forcePull,
			ForceRebuild:          execArgs.forceRebuild,
			LogOutput:             true,
			JSONLogger:            execArgs.jsonLogger,
			Env:                   execArgs.LoadEnvs(),
			Secrets:               execArgs.LoadSecrets(),
			InsecureSecrets:       execArgs.insecureSecrets,
			Privileged:            execArgs.privileged,
			UsernsMode:            execArgs.usernsMode,
			ContainerArchitecture: execArgs.containerArchitecture,
			ContainerDaemonSocket: execArgs.containerDaemonSocket,
			UseGitIgnore:          execArgs.useGitIgnore,
			GitHubInstance:        execArgs.githubInstance,
			ContainerCapAdd:       execArgs.containerCapAdd,
			ContainerCapDrop:      execArgs.containerCapDrop,
			ContainerOptions:      execArgs.containerOptions,
			AutoRemove:            true,
			ArtifactServerPath:    execArgs.artifactServerPath,
			ArtifactServerPort:    execArgs.artifactServerPort,
			ArtifactServerAddr:    execArgs.artifactServerAddr,
			NoSkipCheckout:        execArgs.noSkipCheckout,
			// PresetGitHubContext:   preset,
			// EventJSON:             string(eventJSON),
			ContainerNamePrefix:   fmt.Sprintf("GITEA-ACTIONS-TASK-%s", eventName),
			ContainerMaxLifetime:  maxLifetime,
			ContainerNetworkMode:  container.NetworkMode(execArgs.network),
			DefaultActionInstance: execArgs.defaultActionsURL,
			PlatformPicker: func(_ []string) string {
				return execArgs.image
			},
			ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
		}

		config.Env["ACT_EXEC"] = "true"

		if t := config.Secrets["GITEA_TOKEN"]; t != "" {
			config.Token = t
		} else if t := config.Secrets["GITHUB_TOKEN"]; t != "" {
			config.Token = t
		}

		if !execArgs.debug {
			logLevel := log.InfoLevel
			config.JobLoggerLevel = &logLevel
		}

		r, err := runner.New(config)
		if err != nil {
			return err
		}

		artifactCancel := artifacts.Serve(ctx, execArgs.artifactServerPath, execArgs.artifactServerAddr, execArgs.artifactServerPort)
		log.Debugf("artifacts server started at %s:%s", execArgs.artifactServerPath, execArgs.artifactServerPort)

		ctx = common.WithDryrun(ctx, execArgs.dryrun)
		executor := r.NewPlanExecutor(plan).Finally(func(ctx context.Context) error {
			artifactCancel()
			return nil
		})

		return executor(ctx)
	}
}

func loadExecCmd(ctx context.Context) *cobra.Command {
	execArg := executeArgs{}

	execCmd := &cobra.Command{
		Use:   "exec",
		Short: "Run workflow locally.",
		Args:  cobra.MaximumNArgs(20),
		RunE:  runExec(ctx, &execArg),
	}

	execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
	execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
	execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
	execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.forgejo/workflows/", "path to workflow file(s)")
	execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
	execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")
	execCmd.Flags().BoolVarP(&execArg.autodetectEvent, "detect-event", "", false, "Use first event type from workflow as event that triggered the workflow")
	execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
	execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
	execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
	execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)")
	execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
	execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
	execCmd.PersistentFlags().BoolVarP(&execArg.insecureSecrets, "insecure-secrets", "", false, "NOT RECOMMENDED! Doesn't hide secrets while printing logs.")
	execCmd.Flags().BoolVar(&execArg.privileged, "privileged", false, "use privileged mode")
	execCmd.Flags().StringVar(&execArg.usernsMode, "userns", "", "user namespace to use")
	execCmd.PersistentFlags().StringVarP(&execArg.containerArchitecture, "container-architecture", "", "", "Architecture which should be used to run containers, e.g.: linux/amd64. If not specified, will use host default architecture. Requires Docker server API Version 1.41+. Ignored on earlier Docker server platforms.")
	execCmd.PersistentFlags().StringVarP(&execArg.containerDaemonSocket, "container-daemon-socket", "", "/var/run/docker.sock", "Path to Docker daemon socket which will be mounted to containers")
	execCmd.Flags().BoolVar(&execArg.useGitIgnore, "use-gitignore", true, "Controls whether paths specified in .gitignore should be copied into container")
	execCmd.Flags().StringArrayVarP(&execArg.containerCapAdd, "container-cap-add", "", []string{}, "kernel capabilities to add to the workflow containers (e.g. --container-cap-add SYS_PTRACE)")
	execCmd.Flags().StringArrayVarP(&execArg.containerCapDrop, "container-cap-drop", "", []string{}, "kernel capabilities to remove from the workflow containers (e.g. --container-cap-drop SYS_PTRACE)")
	execCmd.Flags().StringVarP(&execArg.containerOptions, "container-opts", "", "", "container options")
	execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPath, "artifact-server-path", "", ".", "Defines the path where the artifact server stores uploads and retrieves downloads from. If not specified the artifact server will not start.")
	execCmd.PersistentFlags().StringVarP(&execArg.artifactServerAddr, "artifact-server-addr", "", "", "Defines the address where the artifact server listens")
	execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPort, "artifact-server-port", "", "34567", "Defines the port where the artifact server listens (will only bind to localhost).")
	execCmd.PersistentFlags().StringVarP(&execArg.defaultActionsURL, "default-actions-url", "", "https://code.forgejo.org", "Defines the default base url of the action.")
	execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
	execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
	execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
	execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "node:16-bullseye", "docker image to use")
	execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
	execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")

	return execCmd
}