mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-27 22:37:46 +00:00
feat: add input and input-file flags for exec command (#1173)
Add `input` and `input-file` flags for exec command and refactor common code from LoadVars and LoadEnvs. Closes (https://gitea.com/gitea/runner/issues/1022) --------- Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1173 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: Minjie Fang <wingsallen@gmail.com>
This commit is contained in:
+118
-61
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -45,6 +46,8 @@ type executeArgs struct {
|
||||
forcePull bool
|
||||
forceRebuild bool
|
||||
jsonLogger bool
|
||||
inputs []string
|
||||
inputfile string
|
||||
envs []string
|
||||
envfile string
|
||||
secrets []string
|
||||
@@ -90,6 +93,11 @@ func (i *executeArgs) Envfile() string {
|
||||
return i.resolve(i.envfile)
|
||||
}
|
||||
|
||||
// Inputfile returns path to .env-format inputfile
|
||||
func (i *executeArgs) Inputfile() string {
|
||||
return i.resolve(i.inputfile)
|
||||
}
|
||||
|
||||
func (i *executeArgs) LoadSecrets() map[string]string {
|
||||
s := make(map[string]string)
|
||||
for _, secretPair := range i.secrets {
|
||||
@@ -128,19 +136,55 @@ func readEnvs(path string, envs map[string]string) bool {
|
||||
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]] = ""
|
||||
}
|
||||
func (i *executeArgs) LoadVars() map[string]string {
|
||||
return parseKVAndFile(i.vars, "")
|
||||
}
|
||||
|
||||
func (i *executeArgs) LoadInputs() map[string]string {
|
||||
return parseKVAndFile(i.inputs, i.Inputfile())
|
||||
}
|
||||
|
||||
// eventJSON assembles the payload the run is triggered with, the `--input` values overriding
|
||||
// the inputs the `--eventpath` file carries.
|
||||
func (i *executeArgs) eventJSON() (string, error) {
|
||||
payload := []byte("{}")
|
||||
if path := i.resolve(i.eventpath); path != "" {
|
||||
var err error
|
||||
if payload, err = os.ReadFile(path); err != nil {
|
||||
return "", fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
_ = readEnvs(i.Envfile(), envs)
|
||||
|
||||
cliInputs := i.LoadInputs()
|
||||
if len(cliInputs) == 0 {
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return "", fmt.Errorf("failed to parse event payload: %w", err)
|
||||
}
|
||||
if event == nil { // a JSON `null` payload unmarshals into a nil map
|
||||
event = make(map[string]any)
|
||||
}
|
||||
inputs, ok := event["inputs"].(map[string]any)
|
||||
if !ok {
|
||||
inputs = make(map[string]any)
|
||||
}
|
||||
for name, value := range cliInputs {
|
||||
inputs[name] = value
|
||||
}
|
||||
event["inputs"] = inputs
|
||||
|
||||
merged, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal event payload: %w", err)
|
||||
}
|
||||
return string(merged), nil
|
||||
}
|
||||
|
||||
func (i *executeArgs) LoadEnvs() map[string]string {
|
||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||
|
||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||
// The same server answers the cache service v2 API, so let the actions reach it.
|
||||
@@ -149,20 +193,19 @@ func (i *executeArgs) LoadEnvs() map[string]string {
|
||||
return envs
|
||||
}
|
||||
|
||||
func (i *executeArgs) LoadVars() map[string]string {
|
||||
vars := make(map[string]string)
|
||||
if i.vars != nil {
|
||||
for _, runVar := range i.vars {
|
||||
e := strings.SplitN(runVar, `=`, 2)
|
||||
if len(e) == 2 {
|
||||
vars[e[0]] = e[1]
|
||||
} else {
|
||||
vars[e[0]] = ""
|
||||
}
|
||||
func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
_ = readEnvs(filePath, result)
|
||||
|
||||
for _, raw := range rawKVs {
|
||||
parts := strings.SplitN(raw, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
result[parts[0]] = parts[1]
|
||||
} else {
|
||||
result[parts[0]] = ""
|
||||
}
|
||||
}
|
||||
|
||||
return vars
|
||||
return result
|
||||
}
|
||||
|
||||
// Workdir returns path to workdir
|
||||
@@ -329,6 +372,52 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]string, maxLifetime time.Duration, sharedToolCache bool) (*runner.Config, error) {
|
||||
eventJSON, err := i.eventJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &runner.Config{
|
||||
Workdir: i.Workdir(),
|
||||
BindWorkdir: false,
|
||||
ForcePull: i.forcePull,
|
||||
ForceRebuild: i.forceRebuild,
|
||||
JSONLogger: i.jsonLogger,
|
||||
Env: env,
|
||||
ProxyEnv: proxyEnv,
|
||||
Vars: i.LoadVars(),
|
||||
Secrets: i.LoadSecrets(),
|
||||
InsecureSecrets: i.insecureSecrets,
|
||||
Privileged: i.privileged,
|
||||
UsernsMode: i.usernsMode,
|
||||
ContainerArchitecture: i.containerArchitecture,
|
||||
ContainerDaemonSocket: i.containerDaemonSocket,
|
||||
UseGitIgnore: i.useGitIgnore,
|
||||
GitHubInstance: i.githubInstance,
|
||||
ContainerCapAdd: i.containerCapAdd,
|
||||
ContainerCapDrop: i.containerCapDrop,
|
||||
ContainerOptions: i.containerOptions,
|
||||
ArtifactServerPath: i.artifactServerPath,
|
||||
ArtifactServerPort: i.artifactServerPort,
|
||||
ArtifactServerAddr: i.artifactServerAddr,
|
||||
NoSkipCheckout: i.noSkipCheckout,
|
||||
EventName: eventName,
|
||||
EventJSON: eventJSON,
|
||||
// PresetGitHubContext: preset,
|
||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
ContainerNetworkMode: container.NetworkMode(i.network),
|
||||
DefaultActionInstance: i.defaultActionsURL,
|
||||
DefaultActionInstanceIsSelfHosted: i.defaultActionsURL != "" && i.defaultActionsURL != "https://github.com",
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return i.image
|
||||
},
|
||||
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
|
||||
SharedToolCache: sharedToolCache,
|
||||
}, 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)
|
||||
@@ -445,43 +534,9 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
// run the plan
|
||||
config := &runner.Config{
|
||||
Workdir: execArgs.Workdir(),
|
||||
BindWorkdir: false,
|
||||
ForcePull: execArgs.forcePull,
|
||||
ForceRebuild: execArgs.forceRebuild,
|
||||
JSONLogger: execArgs.jsonLogger,
|
||||
Env: env,
|
||||
ProxyEnv: proxyEnv,
|
||||
Vars: execArgs.LoadVars(),
|
||||
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,
|
||||
ArtifactServerPath: execArgs.artifactServerPath,
|
||||
ArtifactServerPort: execArgs.artifactServerPort,
|
||||
ArtifactServerAddr: execArgs.artifactServerAddr,
|
||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
||||
EventPath: execArgs.resolve(execArgs.eventpath),
|
||||
// PresetGitHubContext: preset,
|
||||
// EventJSON: string(eventJSON),
|
||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
||||
DefaultActionInstance: execArgs.defaultActionsURL,
|
||||
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return execArgs.image
|
||||
},
|
||||
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
|
||||
SharedToolCache: shared,
|
||||
config, err := execArgs.runnerConfig(eventName, env, proxyEnv, maxLifetime, shared)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.Env["ACT_EXEC"] = "true"
|
||||
@@ -536,7 +591,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
||||
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.Flags().StringArrayVarP(&execArg.inputs, "input", "", []string{}, "set an input the workflow declares under its workflow_dispatch or workflow_call trigger, others stay invisible to the inputs context (e.g. --input name=bar; can be specified multiple times with highest precedence)")
|
||||
execCmd.Flags().StringVarP(&execArg.inputfile, "input-file", "", "", "path to an .env-format file containing key=value pairs as baseline workflow inputs (override event inputs)")
|
||||
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv; override env-file)")
|
||||
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.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
|
||||
|
||||
@@ -56,6 +56,81 @@ func TestExecuteArgsPaths(t *testing.T) {
|
||||
require.Equal(t, workdir, args.Workdir())
|
||||
}
|
||||
|
||||
func TestExecuteArgsLoadInputs(t *testing.T) {
|
||||
require.Empty(t, (&executeArgs{}).LoadInputs())
|
||||
|
||||
dir := t.TempDir()
|
||||
envFile := filepath.Join(dir, ".env")
|
||||
require.NoError(t, os.WriteFile(envFile, []byte("BAZ=qux\nOVERRIDE=from_file\n"), 0o600))
|
||||
|
||||
args := &executeArgs{inputs: []string{"FOO=bar", "EMPTY", "WITH=eq=sign", "OVERRIDE=from_cli"}, inputfile: envFile}
|
||||
require.Equal(t, map[string]string{
|
||||
"FOO": "bar",
|
||||
"EMPTY": "",
|
||||
"WITH": "eq=sign",
|
||||
"BAZ": "qux",
|
||||
"OVERRIDE": "from_cli",
|
||||
}, args.LoadInputs())
|
||||
}
|
||||
|
||||
func TestExecuteArgsEventJSON(t *testing.T) {
|
||||
payload := func(content string) string {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventpath string
|
||||
inputs []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "keeps a payload without inputs to merge",
|
||||
eventpath: payload(`{"inputs": {"kept": "from_event"}}`),
|
||||
expected: `{"inputs": {"kept": "from_event"}}`,
|
||||
},
|
||||
{
|
||||
name: "overrides the event inputs it names, keeping the rest",
|
||||
eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`),
|
||||
inputs: []string{"named=from_cli"},
|
||||
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
|
||||
},
|
||||
{
|
||||
name: "adds inputs to a payload carrying none",
|
||||
eventpath: payload(`{"repository": {"name": "test-repo"}}`),
|
||||
inputs: []string{"flag=val"},
|
||||
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
|
||||
},
|
||||
{
|
||||
name: "handles a null payload",
|
||||
eventpath: payload("null"),
|
||||
inputs: []string{"flag=val"},
|
||||
expected: `{"inputs": {"flag": "val"}}`,
|
||||
},
|
||||
{
|
||||
name: "builds a payload without an event file",
|
||||
inputs: []string{"flag=val"},
|
||||
expected: `{"inputs": {"flag": "val"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON()
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, tt.expected, eventJSON)
|
||||
})
|
||||
}
|
||||
|
||||
args := &executeArgs{inputs: []string{"flag=val"}}
|
||||
config, err := args.runnerConfig("workflow_dispatch", nil, nil, 0, false)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"inputs": {"flag": "val"}}`, config.EventJSON)
|
||||
require.Equal(t, "workflow_dispatch", config.EventName) // the inputs context stays empty without it
|
||||
}
|
||||
|
||||
func TestExecuteArgsLoadVars(t *testing.T) {
|
||||
require.Empty(t, (&executeArgs{}).LoadVars())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user