mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-30 15:57:44 +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:
+120
-63
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -45,6 +46,8 @@ type executeArgs struct {
|
|||||||
forcePull bool
|
forcePull bool
|
||||||
forceRebuild bool
|
forceRebuild bool
|
||||||
jsonLogger bool
|
jsonLogger bool
|
||||||
|
inputs []string
|
||||||
|
inputfile string
|
||||||
envs []string
|
envs []string
|
||||||
envfile string
|
envfile string
|
||||||
secrets []string
|
secrets []string
|
||||||
@@ -90,6 +93,11 @@ func (i *executeArgs) Envfile() string {
|
|||||||
return i.resolve(i.envfile)
|
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 {
|
func (i *executeArgs) LoadSecrets() map[string]string {
|
||||||
s := make(map[string]string)
|
s := make(map[string]string)
|
||||||
for _, secretPair := range i.secrets {
|
for _, secretPair := range i.secrets {
|
||||||
@@ -128,19 +136,55 @@ func readEnvs(path string, envs map[string]string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func (i *executeArgs) LoadEnvs() map[string]string {
|
||||||
envs := make(map[string]string)
|
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||||
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() + "/"
|
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||||
// The same server answers the cache service v2 API, so let the actions reach it.
|
// 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
|
return envs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *executeArgs) LoadVars() map[string]string {
|
func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
|
||||||
vars := make(map[string]string)
|
result := make(map[string]string)
|
||||||
if i.vars != nil {
|
_ = readEnvs(filePath, result)
|
||||||
for _, runVar := range i.vars {
|
|
||||||
e := strings.SplitN(runVar, `=`, 2)
|
|
||||||
if len(e) == 2 {
|
|
||||||
vars[e[0]] = e[1]
|
|
||||||
} else {
|
|
||||||
vars[e[0]] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return vars
|
for _, raw := range rawKVs {
|
||||||
|
parts := strings.SplitN(raw, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
result[parts[0]] = parts[1]
|
||||||
|
} else {
|
||||||
|
result[parts[0]] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workdir returns path to workdir
|
// Workdir returns path to workdir
|
||||||
@@ -329,6 +372,52 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
|
|||||||
return nil
|
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 {
|
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
|
||||||
return func(cmd *cobra.Command, args []string) error {
|
return func(cmd *cobra.Command, args []string) error {
|
||||||
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
|
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
|
// run the plan
|
||||||
config := &runner.Config{
|
config, err := execArgs.runnerConfig(eventName, env, proxyEnv, maxLifetime, shared)
|
||||||
Workdir: execArgs.Workdir(),
|
if err != nil {
|
||||||
BindWorkdir: false,
|
return err
|
||||||
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.Env["ACT_EXEC"] = "true"
|
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.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.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.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.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.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)")
|
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())
|
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) {
|
func TestExecuteArgsLoadVars(t *testing.T) {
|
||||||
require.Empty(t, (&executeArgs{}).LoadVars())
|
require.Empty(t, (&executeArgs{}).LoadVars())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user