Apply task and play environment vars

This commit is contained in:
Virgil 2026-04-01 20:51:57 +00:00
parent 4245e1e530
commit f71e8642e9
3 changed files with 133 additions and 0 deletions

View file

@ -34,6 +34,21 @@ type sshExecutorClient interface {
Close() error
}
// environmentSSHClient wraps another SSH client and prefixes commands with
// shell exports so play/task environment variables reach remote execution.
type environmentSSHClient struct {
sshExecutorClient
prefix string
}
func (c *environmentSSHClient) Run(ctx context.Context, cmd string) (string, string, int, error) {
return c.sshExecutorClient.Run(ctx, c.prefix+cmd)
}
func (c *environmentSSHClient) RunScript(ctx context.Context, script string) (string, string, int, error) {
return c.sshExecutorClient.RunScript(ctx, c.prefix+script)
}
// Executor runs Ansible playbooks.
//
// Example:
@ -1498,6 +1513,55 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
return "{{ " + expr + " }}" // Return as-is if unresolved
}
// buildEnvironmentPrefix renders merged play/task environment variables as
// shell export statements.
func (e *Executor) buildEnvironmentPrefix(host string, task *Task, play *Play) string {
env := make(map[string]string)
if play != nil {
for key, value := range play.Environment {
env[key] = value
}
}
if task != nil {
for key, value := range task.Environment {
env[key] = value
}
}
if len(env) == 0 {
return ""
}
keys := make([]string, 0, len(env))
for key := range env {
keys = append(keys, key)
}
slices.Sort(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
renderedKey := e.templateString(key, host, task)
if renderedKey == "" {
continue
}
renderedValue := e.templateString(env[key], host, task)
parts = append(parts, sprintf("export %s=%s", renderedKey, shellQuote(renderedValue)))
}
if len(parts) == 0 {
return ""
}
return join("; ", parts) + "; "
}
// shellQuote wraps a string in single quotes for shell use.
func shellQuote(value string) string {
return "'" + replaceAll(value, "'", "'\\''") + "'"
}
// lookupExprValue resolves the first segment of an expression against the
// executor, task, and inventory scopes.
func (e *Executor) lookupExprValue(name string, host string, task *Task) (any, bool) {

View file

@ -231,6 +231,68 @@ func TestExecutor_RunTaskOnHost_Good_DelegateToUsesDelegatedClient(t *testing.T)
assert.Equal(t, 1, mock.commandCount())
}
func TestExecutor_RunTaskOnHost_Good_EnvironmentMergesForCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
play := &Play{
Environment: map[string]string{
"APP_ENV": "play",
"PLAY_ONLY": "from-play",
},
}
task := &Task{
Name: "Environment command",
Module: "command",
Args: map[string]any{
"cmd": `echo "$APP_ENV:$PLAY_ONLY:$TASK_ONLY"`,
},
Environment: map[string]string{
"APP_ENV": "task",
"TASK_ONLY": "from-task",
},
Register: "env_result",
}
mock.expectCommand(`export APP_ENV='task'; export PLAY_ONLY='from-play'; export TASK_ONLY='from-task'; echo`, "task:from-play:from-task\n", "", 0)
err := e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play)
require.NoError(t, err)
require.NotNil(t, e.results["host1"]["env_result"])
assert.Equal(t, "task:from-play:from-task\n", e.results["host1"]["env_result"].Stdout)
assert.True(t, mock.hasExecuted(`export APP_ENV='task'; export PLAY_ONLY='from-play'; export TASK_ONLY='from-task'; echo`))
}
func TestExecutor_RunTaskOnHost_Good_EnvironmentAppliesToShellScript(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
play := &Play{
Environment: map[string]string{
"SHELL_ONLY": "from-play",
},
}
task := &Task{
Name: "Environment shell",
Module: "shell",
Args: map[string]any{
"_raw_params": `echo "$SHELL_ONLY"`,
},
Environment: map[string]string{
"SHELL_ONLY": "from-task",
},
Register: "shell_env_result",
}
mock.expectCommand(`export SHELL_ONLY='from-task'; echo`, "from-task\n", "", 0)
err := e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play)
require.NoError(t, err)
require.NotNil(t, e.results["host1"]["shell_env_result"])
assert.Equal(t, "from-task\n", e.results["host1"]["shell_env_result"].Stdout)
assert.True(t, mock.hasExecuted(`export SHELL_ONLY='from-task'; echo`))
}
func TestExecutor_RunRole_Good_AppliesRoleTagsToTasks(t *testing.T) {
dir := t.TempDir()
roleTasks := `---

View file

@ -34,6 +34,13 @@ func (e *Executor) executeModule(ctx context.Context, host string, client sshExe
defer client.SetBecome(oldBecome, oldUser, oldPass)
}
if prefix := e.buildEnvironmentPrefix(host, task, play); prefix != "" {
client = &environmentSSHClient{
sshExecutorClient: client,
prefix: prefix,
}
}
// Template the args
args := e.templateArgs(task.Args, host, task)