Add play module defaults support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run

This commit is contained in:
Virgil 2026-04-03 13:08:52 +00:00
parent bbe110c1c0
commit 031e41be19
4 changed files with 120 additions and 23 deletions

View file

@ -1403,6 +1403,49 @@ func TestExecutorExtra_RunIncludeRole_Good_PublicVarsPersist(t *testing.T) {
assert.Equal(t, "hello from public role", e.results["localhost"]["after_public_role"].Msg)
}
func TestExecutorExtra_RunPlay_Good_ModuleDefaultsApplyToTasks(t *testing.T) {
dir := t.TempDir()
e := NewExecutor(dir)
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"localhost": {},
},
},
})
mock := newTrackingMockClient()
e.clients["localhost"] = mock
mock.expectCommand(`cd "/tmp/module-defaults" && pwd`, "/tmp/module-defaults\n", "", 0)
gatherFacts := false
play := &Play{
Name: "Module defaults",
Hosts: "localhost",
Connection: "local",
GatherFacts: &gatherFacts,
ModuleDefaults: map[string]map[string]any{
"command": {
"chdir": "/tmp/module-defaults",
},
},
Tasks: []Task{
{
Name: "Run command with defaults",
Module: "command",
Args: map[string]any{"cmd": "pwd"},
Register: "command_result",
},
},
}
require.NoError(t, e.runPlay(context.Background(), play))
require.NotNil(t, e.results["localhost"]["command_result"])
assert.Equal(t, "/tmp/module-defaults\n", e.results["localhost"]["command_result"].Stdout)
assert.True(t, mock.hasExecuted(`cd "/tmp/module-defaults" && pwd`))
}
func TestExecutorExtra_GetHostsIter_Good(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{

View file

@ -67,8 +67,10 @@ func (e *Executor) executeModule(ctx context.Context, host string, client sshExe
}
}
// Template the args
args := e.templateArgs(task.Args, host, task)
// Merge play-level module defaults before templating so defaults and task
// arguments can both resolve host-scoped variables.
args := mergeModuleDefaults(task.Args, e.resolveModuleDefaults(play, module))
args = e.templateArgs(args, host, task)
switch module {
// Command execution
@ -199,6 +201,56 @@ func (e *Executor) executeModule(ctx context.Context, host string, client sshExe
}
}
func (e *Executor) resolveModuleDefaults(play *Play, module string) map[string]any {
if play == nil || len(play.ModuleDefaults) == 0 || module == "" {
return nil
}
canonical := NormalizeModule(module)
merged := make(map[string]any)
seen := false
keys := make([]string, 0, len(play.ModuleDefaults))
for key := range play.ModuleDefaults {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if NormalizeModule(key) != canonical {
continue
}
defaults := play.ModuleDefaults[key]
if len(defaults) == 0 {
continue
}
for k, v := range defaults {
merged[k] = v
}
seen = true
}
if !seen {
return nil
}
return merged
}
func mergeModuleDefaults(args, defaults map[string]any) map[string]any {
if len(args) == 0 && len(defaults) == 0 {
return nil
}
merged := make(map[string]any, len(args)+len(defaults))
for k, v := range defaults {
merged[k] = v
}
for k, v := range args {
merged[k] = v
}
return merged
}
func (e *Executor) resolveBecomePassword(host string) string {
if e == nil {
return ""

View file

@ -450,7 +450,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
"no_log": true, "become": true, "become_user": true,
"delegate_to": true, "delegate_facts": true, "run_once": true, "tags": true,
"block": true, "rescue": true, "always": true, "notify": true, "listen": true,
"retries": true, "delay": true, "until": true,
"module_defaults": true,
"retries": true, "delay": true, "until": true,
"action": true, "local_action": true,
"ansible.builtin.action": true, "ansible.legacy.action": true,
"ansible.builtin.local_action": true, "ansible.legacy.local_action": true,

View file

@ -22,26 +22,27 @@ type Playbook struct {
//
// play := Play{Name: "Configure web", Hosts: "webservers", Become: true}
type Play struct {
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
ImportPlaybook string `yaml:"import_playbook,omitempty"`
Connection string `yaml:"connection,omitempty"`
Become bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
GatherFacts *bool `yaml:"gather_facts,omitempty"`
ForceHandlers bool `yaml:"force_handlers,omitempty"`
AnyErrorsFatal bool `yaml:"any_errors_fatal,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
VarsFiles any `yaml:"vars_files,omitempty"` // string or []string
PreTasks []Task `yaml:"pre_tasks,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
PostTasks []Task `yaml:"post_tasks,omitempty"`
Roles []RoleRef `yaml:"roles,omitempty"`
Handlers []Task `yaml:"handlers,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
Serial any `yaml:"serial,omitempty"` // int or string
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
ImportPlaybook string `yaml:"import_playbook,omitempty"`
Connection string `yaml:"connection,omitempty"`
Become bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
GatherFacts *bool `yaml:"gather_facts,omitempty"`
ForceHandlers bool `yaml:"force_handlers,omitempty"`
AnyErrorsFatal bool `yaml:"any_errors_fatal,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
VarsFiles any `yaml:"vars_files,omitempty"` // string or []string
ModuleDefaults map[string]map[string]any `yaml:"module_defaults,omitempty"`
PreTasks []Task `yaml:"pre_tasks,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
PostTasks []Task `yaml:"post_tasks,omitempty"`
Roles []RoleRef `yaml:"roles,omitempty"`
Handlers []Task `yaml:"handlers,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
Serial any `yaml:"serial,omitempty"` // int or string
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
}
// UnmarshalYAML handles play-level aliases such as ansible.builtin.import_playbook.