Files

492 lines
18 KiB
Go

// 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 // 特定作业 ID
event string // 事件名称
workdir string // 工作目录
workflowsPath string // 工作流文件路径
noWorkflowRecurse bool // 是否禁止递归运行子目录中的工作流
autodetectEvent bool // 自动检测事件
forcePull bool // 即使存在也拉取 Docker 镜像
forceRebuild bool // 即使存在也重建本地动作 Docker 镜像
jsonLogger bool // 以 JSON 格式输出日志
envs []string // 环境变量
envfile string // 环境文件
secrets []string // 机密
defaultActionsURL string // 默认动作 URL
insecureSecrets bool // 不建议!打印日志时不隐藏机密
privileged bool // 使用特权模式
usernsMode string // 用户命名空间
containerArchitecture string // 容器架构
containerDaemonSocket string // 容器守护进程套接字
useGitIgnore bool // 控制是否将 .gitignore 中指定的路径复制到容器中
containerCapAdd []string // 添加到工作流容器的内核能力
containerCapDrop []string // 从工作流容器中删除的内核能力
containerOptions string // 容器选项
artifactServerPath string // 存储上传和检索下载的路径
artifactServerAddr string // 监听地址
artifactServerPort string // 监听端口
noSkipCheckout bool // 不跳过 actions/checkout
debug bool // 启用调试日志
dryrun bool // dryrun 模式
image string // 使用的 Docker 镜像
cacheHandler *artifactcache.Handler // 缓存处理器
network string // 容器连接的网络
githubInstance string // 使用的 Gitea 实例
}
// WorkflowsPath 返回工作流文件的路径
func (i *executeArgs) WorkflowsPath() string {
return i.resolve(i.workflowsPath)
}
// Envfile 返回 .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("机密 %s 已经定义(机密不区分大小写)", 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("为 '%s' 提供值: ", secretPairParts[0])
val, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
log.Errorf("读取输入失败: %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("从 %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 返回工作目录的路径
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 // 作业 ID
jobName string // 作业名称
stage string // 阶段
wfName string // 工作流名称
wfFile string // 工作流文件
events string // 事件
}
lineInfos := []lineInfoDef{}
header := lineInfoDef{
jobID: "作业 ID",
jobName: "作业名称",
stage: "阶段",
wfName: "工作流名称",
wfFile: "工作流文件",
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
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("\n检测到多个作业具有相同的作业名称,请使用 `-W` 指定特定工作流的路径。\n")
}
return nil
}
func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *executeArgs) error {
// 计划带有过滤的作业 - 仅用于过滤
var filterPlan *model.Plan
// 确定要过滤的事件名称
var filterEventName string
if len(execArgs.event) > 0 {
log.Infof("使用选择的事件进行过滤: %s", execArgs.event)
filterEventName = execArgs.event
} else if execArgs.autodetectEvent {
// 收集所有加载的工作流中的事件
events := planner.GetEvents()
// 将默认事件类型设置为第一个可用的事件
// 这样用户就不必指定事件。
log.Infof("使用检测到的第一个工作流事件进行过滤: %s", events[0])
filterEventName = events[0]
}
var err error
if execArgs.job != "" {
log.Infof("准备带有作业的计划: %s", execArgs.job)
filterPlan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
} else if filterEventName != "" {
log.Infof("准备事件的计划: %s", filterEventName)
filterPlan, err = planner.PlanEvent(filterEventName)
if err != nil {
return err
}
} else {
log.Infof("准备所有作业的计划")
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)
}
// 计划触发作业
var plan *model.Plan
// 确定要触发的事件名称
var eventName string
// 收集所有加载的工作流中的事件
events := planner.GetEvents()
if len(execArgs.event) > 0 {
log.Infof("使用选择的事件进行过滤: %s", execArgs.event)
eventName = execArgs.event
} else if len(events) == 1 && len(events[0]) > 0 {
log.Infof("使用唯一检测到的工作流事件: %s", events[0])
eventName = events[0]
} else if execArgs.autodetectEvent && len(events) > 0 && len(events[0]) > 0 {
// 将默认事件类型设置为第一个可用的事件
// 这样用户就不必指定事件。
log.Infof("使用检测到的第一个工作流事件: %s", events[0])
eventName = events[0]
} else {
log.Infof("使用默认工作流事件: push")
eventName = "push"
}
// 为此运行构建计划
if execArgs.job != "" {
log.Infof("规划作业: %s", execArgs.job)
plan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
} else {
log.Infof("规划事件的作业: %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)
}
// 初始化缓存服务器
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
if err != nil {
return err
}
log.Infof("缓存处理器监听于: %v", handler.ExternalURL())
execArgs.cacheHandler = handler
if len(execArgs.artifactServerAddr) == 0 {
ip := common.GetOutboundIP()
if ip == nil {
return fmt.Errorf("无法确定出站 IP 地址")
}
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
}
// 运行计划
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{"​**​"}, // 所有挂载的卷(volumes)都允许被 exec 命令访问
}
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 服务器启动于 %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: "本地运行工作流",
Args: cobra.MaximumNArgs(20),
RunE: runExec(ctx, &execArg),
}
execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "本地运行工作流")
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "运行特定作业 ID")
execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "运行事件名称")
execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.gitea/workflows/", "工作流文件路径")
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "工作目录")
execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "禁用运行指定路径的子目录中的工作流")
execCmd.Flags().BoolVarP(&execArg.autodetectEvent, "detect-event", "", false, "使用工作流中的第一个事件类型作为触发工作流的事件")
execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "即使已经存在也拉取 Docker 镜像")
execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "即使已经存在也重建本地动作 Docker 镜像")
execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "以 JSON 格式输出日志")
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "使环境变量对动作可用,可选值(例如 --env myenv=foo 或 --env myenv)")
execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "读取并用作容器中的环境的环境文件")
execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "为 Action 提供密钥,可带可选值(例如 -s mysecret=foo 或 -s mysecret)")
execCmd.PersistentFlags().BoolVarP(&execArg.insecureSecrets, "insecure-secrets", "", false, "不推荐!打印日志时不会隐藏密钥信息")
execCmd.Flags().BoolVar(&execArg.privileged, "privileged", false, "使用特权模式")
execCmd.Flags().StringVar(&execArg.usernsMode, "userns", "", "要使用的用户命名空间")
execCmd.PersistentFlags().StringVarP(&execArg.containerArchitecture, "container-architecture", "", "", "运行容器使用的架构(如 linux/loong64)。未指定时使用宿主机默认架构。需要 Docker 服务端 API 版本 1.41+,更低版本的 Docker 平台会忽略此参数")
execCmd.PersistentFlags().StringVarP(&execArg.containerDaemonSocket, "container-daemon-socket", "", "/var/run/docker.sock", "挂载到容器的 Docker 守护进程 socket 路径")
execCmd.Flags().BoolVar(&execArg.useGitIgnore, "use-gitignore", true, "控制是否将 .gitignore 中指定的路径复制到容器中")
execCmd.Flags().StringArrayVarP(&execArg.containerCapAdd, "container-cap-add", "", []string{}, "为工作流容器添加的内核能力(例如 --container-cap-add SYS_PTRACE)")
execCmd.Flags().StringArrayVarP(&execArg.containerCapDrop, "container-cap-drop", "", []string{}, "从工作流容器移除的内核能力(例如 --container-cap-drop SYS_PTRACE)")
execCmd.Flags().StringVarP(&execArg.containerOptions, "container-opts", "", "", "容器选项")
execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPath, "artifact-server-path", "", ".", "定义构建物服务器存储上传和下载的路径。未指定时构建物服务器不会启动")
execCmd.PersistentFlags().StringVarP(&execArg.artifactServerAddr, "artifact-server-addr", "", "", "定义构建物服务器的监听地址")
execCmd.PersistentFlags().StringVarP(&execArg.artifactServerPort, "artifact-server-port", "", "34567", "定义构建物服务器的监听端口(例如 --container-cap-drop SYS_PTRACE)")
execCmd.PersistentFlags().StringVarP(&execArg.defaultActionsURL, "default-actions-url", "", "https://github.com", "定义 Action 实例的默认 URL")
execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "不跳过 actions/checkout")
execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "启用调试日志")
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun 模式")
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "lcr.loongnix.cn/library/debian:latest", "使用的 Docker 镜像。使用 \"-self-hosted\" 直接在主机上运行")
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "容器连接的网络")
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "使用的 Gitea 实例")
return execCmd
}