forgejo-runner

git clone git://git.lin.moe/forgejo-runner.git

  1// Copyright 2023 The Gitea Authors. All rights reserved.
  2// Copyright 2019 nektos
  3// SPDX-License-Identifier: MIT
  4
  5package cmd
  6
  7import (
  8	"context"
  9	"fmt"
 10	"os"
 11	"path/filepath"
 12	"strconv"
 13	"strings"
 14	"time"
 15
 16	"github.com/docker/docker/api/types/container"
 17	"github.com/joho/godotenv"
 18	"github.com/nektos/act/pkg/artifactcache"
 19	"github.com/nektos/act/pkg/artifacts"
 20	"github.com/nektos/act/pkg/common"
 21	"github.com/nektos/act/pkg/model"
 22	"github.com/nektos/act/pkg/runner"
 23	log "github.com/sirupsen/logrus"
 24	"github.com/spf13/cobra"
 25	"golang.org/x/term"
 26)
 27
 28type executeArgs struct {
 29	runList               bool
 30	job                   string
 31	event                 string
 32	workdir               string
 33	workflowsPath         string
 34	noWorkflowRecurse     bool
 35	autodetectEvent       bool
 36	forcePull             bool
 37	forceRebuild          bool
 38	jsonLogger            bool
 39	envs                  []string
 40	envfile               string
 41	secrets               []string
 42	defaultActionsURL     string
 43	insecureSecrets       bool
 44	privileged            bool
 45	usernsMode            string
 46	containerArchitecture string
 47	containerDaemonSocket string
 48	useGitIgnore          bool
 49	containerCapAdd       []string
 50	containerCapDrop      []string
 51	containerOptions      string
 52	artifactServerPath    string
 53	artifactServerAddr    string
 54	artifactServerPort    string
 55	noSkipCheckout        bool
 56	debug                 bool
 57	dryrun                bool
 58	image                 string
 59	cacheHandler          *artifactcache.Handler
 60	network               string
 61	enableIPv6            bool
 62	githubInstance        string
 63}
 64
 65// WorkflowsPath returns path to workflow file(s)
 66func (i *executeArgs) WorkflowsPath() string {
 67	return i.resolve(i.workflowsPath)
 68}
 69
 70// Envfile returns path to .env
 71func (i *executeArgs) Envfile() string {
 72	return i.resolve(i.envfile)
 73}
 74
 75func (i *executeArgs) LoadSecrets() map[string]string {
 76	s := make(map[string]string)
 77	for _, secretPair := range i.secrets {
 78		secretPairParts := strings.SplitN(secretPair, "=", 2)
 79		secretPairParts[0] = strings.ToUpper(secretPairParts[0])
 80		if strings.ToUpper(s[secretPairParts[0]]) == secretPairParts[0] {
 81			log.Errorf("Secret %s is already defined (secrets are case insensitive)", secretPairParts[0])
 82		}
 83		if len(secretPairParts) == 2 {
 84			s[secretPairParts[0]] = secretPairParts[1]
 85		} else if env, ok := os.LookupEnv(secretPairParts[0]); ok && env != "" {
 86			s[secretPairParts[0]] = env
 87		} else {
 88			fmt.Printf("Provide value for '%s': ", secretPairParts[0])
 89			val, err := term.ReadPassword(int(os.Stdin.Fd()))
 90			fmt.Println()
 91			if err != nil {
 92				log.Errorf("failed to read input: %v", err)
 93				os.Exit(1)
 94			}
 95			s[secretPairParts[0]] = string(val)
 96		}
 97	}
 98	return s
 99}
100
101func readEnvs(path string, envs map[string]string) bool {
102	if _, err := os.Stat(path); err == nil {
103		env, err := godotenv.Read(path)
104		if err != nil {
105			log.Fatalf("Error loading from %s: %v", path, err)
106		}
107		for k, v := range env {
108			envs[k] = v
109		}
110		return true
111	}
112	return false
113}
114
115func (i *executeArgs) LoadEnvs() map[string]string {
116	envs := make(map[string]string)
117	if i.envs != nil {
118		for _, envVar := range i.envs {
119			e := strings.SplitN(envVar, `=`, 2)
120			if len(e) == 2 {
121				envs[e[0]] = e[1]
122			} else {
123				envs[e[0]] = ""
124			}
125		}
126	}
127	_ = readEnvs(i.Envfile(), envs)
128
129	envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
130
131	return envs
132}
133
134// Workdir returns path to workdir
135func (i *executeArgs) Workdir() string {
136	return i.resolve(".")
137}
138
139func (i *executeArgs) resolve(path string) string {
140	basedir, err := filepath.Abs(i.workdir)
141	if err != nil {
142		log.Fatal(err)
143	}
144	if path == "" {
145		return path
146	}
147	if !filepath.IsAbs(path) {
148		path = filepath.Join(basedir, path)
149	}
150	return path
151}
152
153func printList(plan *model.Plan) error {
154	type lineInfoDef struct {
155		jobID   string
156		jobName string
157		stage   string
158		wfName  string
159		wfFile  string
160		events  string
161	}
162	lineInfos := []lineInfoDef{}
163
164	header := lineInfoDef{
165		jobID:   "Job ID",
166		jobName: "Job name",
167		stage:   "Stage",
168		wfName:  "Workflow name",
169		wfFile:  "Workflow file",
170		events:  "Events",
171	}
172
173	jobs := map[string]bool{}
174	duplicateJobIDs := false
175
176	jobIDMaxWidth := len(header.jobID)
177	jobNameMaxWidth := len(header.jobName)
178	stageMaxWidth := len(header.stage)
179	wfNameMaxWidth := len(header.wfName)
180	wfFileMaxWidth := len(header.wfFile)
181	eventsMaxWidth := len(header.events)
182
183	for i, stage := range plan.Stages {
184		for _, r := range stage.Runs {
185			jobID := r.JobID
186			line := lineInfoDef{
187				jobID:   jobID,
188				jobName: r.String(),
189				stage:   strconv.Itoa(i),
190				wfName:  r.Workflow.Name,
191				wfFile:  r.Workflow.File,
192				events:  strings.Join(r.Workflow.On(), `,`),
193			}
194			if _, ok := jobs[jobID]; ok {
195				duplicateJobIDs = true
196			} else {
197				jobs[jobID] = true
198			}
199			lineInfos = append(lineInfos, line)
200			if jobIDMaxWidth < len(line.jobID) {
201				jobIDMaxWidth = len(line.jobID)
202			}
203			if jobNameMaxWidth < len(line.jobName) {
204				jobNameMaxWidth = len(line.jobName)
205			}
206			if stageMaxWidth < len(line.stage) {
207				stageMaxWidth = len(line.stage)
208			}
209			if wfNameMaxWidth < len(line.wfName) {
210				wfNameMaxWidth = len(line.wfName)
211			}
212			if wfFileMaxWidth < len(line.wfFile) {
213				wfFileMaxWidth = len(line.wfFile)
214			}
215			if eventsMaxWidth < len(line.events) {
216				eventsMaxWidth = len(line.events)
217			}
218		}
219	}
220
221	jobIDMaxWidth += 2
222	jobNameMaxWidth += 2
223	stageMaxWidth += 2
224	wfNameMaxWidth += 2
225	wfFileMaxWidth += 2
226
227	fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
228		-stageMaxWidth, header.stage,
229		-jobIDMaxWidth, header.jobID,
230		-jobNameMaxWidth, header.jobName,
231		-wfNameMaxWidth, header.wfName,
232		-wfFileMaxWidth, header.wfFile,
233		-eventsMaxWidth, header.events,
234	)
235	for _, line := range lineInfos {
236		fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
237			-stageMaxWidth, line.stage,
238			-jobIDMaxWidth, line.jobID,
239			-jobNameMaxWidth, line.jobName,
240			-wfNameMaxWidth, line.wfName,
241			-wfFileMaxWidth, line.wfFile,
242			-eventsMaxWidth, line.events,
243		)
244	}
245	if duplicateJobIDs {
246		fmt.Print("\nDetected multiple jobs with the same job name, use `-W` to specify the path to the specific workflow.\n")
247	}
248	return nil
249}
250
251func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *executeArgs) error {
252	// plan with filtered jobs - to be used for filtering only
253	var filterPlan *model.Plan
254
255	// Determine the event name to be filtered
256	var filterEventName string
257
258	if len(execArgs.event) > 0 {
259		log.Infof("Using chosed event for filtering: %s", execArgs.event)
260		filterEventName = execArgs.event
261	} else if execArgs.autodetectEvent {
262		// collect all events from loaded workflows
263		events := planner.GetEvents()
264
265		// set default event type to first event from many available
266		// this way user dont have to specify the event.
267		log.Infof("Using first detected workflow event for filtering: %s", events[0])
268
269		filterEventName = events[0]
270	}
271
272	var err error
273	if execArgs.job != "" {
274		log.Infof("Preparing plan with a job: %s", execArgs.job)
275		filterPlan, err = planner.PlanJob(execArgs.job)
276		if err != nil {
277			return err
278		}
279	} else if filterEventName != "" {
280		log.Infof("Preparing plan for a event: %s", filterEventName)
281		filterPlan, err = planner.PlanEvent(filterEventName)
282		if err != nil {
283			return err
284		}
285	} else {
286		log.Infof("Preparing plan with all jobs")
287		filterPlan, err = planner.PlanAll()
288		if err != nil {
289			return err
290		}
291	}
292
293	_ = printList(filterPlan)
294
295	return nil
296}
297
298func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
299	return func(cmd *cobra.Command, args []string) error {
300		planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
301		if err != nil {
302			return err
303		}
304
305		if execArgs.runList {
306			return runExecList(ctx, planner, execArgs)
307		}
308
309		// plan with triggered jobs
310		var plan *model.Plan
311
312		// Determine the event name to be triggered
313		var eventName string
314
315		// collect all events from loaded workflows
316		events := planner.GetEvents()
317
318		if len(execArgs.event) > 0 {
319			log.Infof("Using chosed event for filtering: %s", execArgs.event)
320			eventName = execArgs.event
321		} else if len(events) == 1 && len(events[0]) > 0 {
322			log.Infof("Using the only detected workflow event: %s", events[0])
323			eventName = events[0]
324		} else if execArgs.autodetectEvent && len(events) > 0 && len(events[0]) > 0 {
325			// set default event type to first event from many available
326			// this way user dont have to specify the event.
327			log.Infof("Using first detected workflow event: %s", events[0])
328			eventName = events[0]
329		} else {
330			log.Infof("Using default workflow event: push")
331			eventName = "push"
332		}
333
334		// build the plan for this run
335		if execArgs.job != "" {
336			log.Infof("Planning job: %s", execArgs.job)
337			plan, err = planner.PlanJob(execArgs.job)
338			if err != nil {
339				return err
340			}
341		} else {
342			log.Infof("Planning jobs for event: %s", eventName)
343			plan, err = planner.PlanEvent(eventName)
344			if err != nil {
345				return err
346			}
347		}
348
349		maxLifetime := 3 * time.Hour
350		if deadline, ok := ctx.Deadline(); ok {
351			maxLifetime = time.Until(deadline)
352		}
353
354		// init a cache server
355		handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
356		if err != nil {
357			return err
358		}
359		log.Infof("cache handler listens on: %v", handler.ExternalURL())
360		execArgs.cacheHandler = handler
361
362		if len(execArgs.artifactServerAddr) == 0 {
363			ip := common.GetOutboundIP()
364			if ip == nil {
365				return fmt.Errorf("unable to determine outbound IP address")
366			}
367			execArgs.artifactServerAddr = ip.String()
368		}
369
370		if len(execArgs.artifactServerPath) == 0 {
371			tempDir, err := os.MkdirTemp("", "gitea-act-")
372			if err != nil {
373				fmt.Println(err)
374			}
375			defer os.RemoveAll(tempDir)
376
377			execArgs.artifactServerPath = tempDir
378		}
379
380		// run the plan
381		config := &runner.Config{
382			Workdir:               execArgs.Workdir(),
383			BindWorkdir:           false,
384			ReuseContainers:       false,
385			ForcePull:             execArgs.forcePull,
386			ForceRebuild:          execArgs.forceRebuild,
387			LogOutput:             true,
388			JSONLogger:            execArgs.jsonLogger,
389			Env:                   execArgs.LoadEnvs(),
390			Secrets:               execArgs.LoadSecrets(),
391			InsecureSecrets:       execArgs.insecureSecrets,
392			Privileged:            execArgs.privileged,
393			UsernsMode:            execArgs.usernsMode,
394			ContainerArchitecture: execArgs.containerArchitecture,
395			ContainerDaemonSocket: execArgs.containerDaemonSocket,
396			UseGitIgnore:          execArgs.useGitIgnore,
397			GitHubInstance:        execArgs.githubInstance,
398			ContainerCapAdd:       execArgs.containerCapAdd,
399			ContainerCapDrop:      execArgs.containerCapDrop,
400			ContainerOptions:      execArgs.containerOptions,
401			AutoRemove:            true,
402			ArtifactServerPath:    execArgs.artifactServerPath,
403			ArtifactServerPort:    execArgs.artifactServerPort,
404			ArtifactServerAddr:    execArgs.artifactServerAddr,
405			NoSkipCheckout:        execArgs.noSkipCheckout,
406			// PresetGitHubContext:        preset,
407			// EventJSON:                  string(eventJSON),
408			ContainerNamePrefix:        fmt.Sprintf("FORGEJO-ACTIONS-TASK-%s", eventName),
409			ContainerMaxLifetime:       maxLifetime,
410			ContainerNetworkMode:       container.NetworkMode(execArgs.network),
411			ContainerNetworkEnableIPv6: execArgs.enableIPv6,
412			DefaultActionInstance:      execArgs.defaultActionsURL,
413			PlatformPicker: func(_ []string) string {
414				return execArgs.image
415			},
416			ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
417		}
418
419		config.Env["ACT_EXEC"] = "true"
420
421		if t := config.Secrets["GITEA_TOKEN"]; t != "" {
422			config.Token = t
423		} else if t := config.Secrets["GITHUB_TOKEN"]; t != "" {
424			config.Token = t
425		}
426
427		if !execArgs.debug {
428			logLevel := log.InfoLevel
429			config.JobLoggerLevel = &logLevel
430		}
431
432		r, err := runner.New(config)
433		if err != nil {
434			return err
435		}
436
437		artifactCancel := artifacts.Serve(ctx, execArgs.artifactServerPath, execArgs.artifactServerAddr, execArgs.artifactServerPort)
438		log.Debugf("artifacts server started at %s:%s", execArgs.artifactServerPath, execArgs.artifactServerPort)
439
440		ctx = common.WithDryrun(ctx, execArgs.dryrun)
441		executor := r.NewPlanExecutor(plan).Finally(func(ctx context.Context) error {
442			artifactCancel()
443			return nil
444		})
445
446		return executor(ctx)
447	}
448}
449
450func loadExecCmd(ctx context.Context) *cobra.Command {
451	execArg := executeArgs{}
452
453	execCmd := &cobra.Command{
454		Use:   "exec",
455		Short: "Run workflow locally.",
456		Args:  cobra.MaximumNArgs(20),
457		RunE:  runExec(ctx, &execArg),
458	}
459
460	execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
461	execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
462	execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
463	execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.forgejo/workflows/", "path to workflow file(s)")
464	execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
465	execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")
466	execCmd.Flags().BoolVarP(&execArg.autodetectEvent, "detect-event", "", false, "Use first event type from workflow as event that triggered the workflow")
467	execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
468	execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
469	execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
470	execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)")
471	execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
472	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)")
473	execCmd.PersistentFlags().BoolVarP(&execArg.insecureSecrets, "insecure-secrets", "", false, "NOT RECOMMENDED! Doesn't hide secrets while printing logs.")
474	execCmd.Flags().BoolVar(&execArg.privileged, "privileged", false, "use privileged mode")
475	execCmd.Flags().StringVar(&execArg.usernsMode, "userns", "", "user namespace to use")
476	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.")
477	execCmd.PersistentFlags().StringVarP(&execArg.containerDaemonSocket, "container-daemon-socket", "", "/var/run/docker.sock", "Path to Docker daemon socket which will be mounted to containers")
478	execCmd.Flags().BoolVar(&execArg.useGitIgnore, "use-gitignore", true, "Controls whether paths specified in .gitignore should be copied into container")
479	execCmd.Flags().StringArrayVarP(&execArg.containerCapAdd, "container-cap-add", "", []string{}, "kernel capabilities to add to the workflow containers (e.g. --container-cap-add SYS_PTRACE)")
480	execCmd.Flags().StringArrayVarP(&execArg.containerCapDrop, "container-cap-drop", "", []string{}, "kernel capabilities to remove from the workflow containers (e.g. --container-cap-drop SYS_PTRACE)")
481	execCmd.Flags().StringVarP(&execArg.containerOptions, "container-opts", "", "", "container options")
482	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.")
483	execCmd.PersistentFlags().StringVarP(&execArg.artifactServerAddr, "artifact-server-addr", "", "", "Defines the address where the artifact server listens")
484	execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPort, "artifact-server-port", "", "34567", "Defines the port where the artifact server listens (will only bind to localhost).")
485	execCmd.PersistentFlags().StringVarP(&execArg.defaultActionsURL, "default-actions-url", "", "https://code.forgejo.org", "Defines the default base url of the action.")
486	execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
487	execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
488	execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
489	execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "node:20-bullseye", "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
490	execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
491	execCmd.PersistentFlags().BoolVarP(&execArg.enableIPv6, "enable-ipv6", "6", false, "Create network with IPv6 enabled.")
492	execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
493
494	return execCmd
495}