diff --git a/executor_extra_test.go b/executor_extra_test.go index 4192f41..b646083 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -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{ diff --git a/modules.go b/modules.go index a24bd1a..423fc2b 100644 --- a/modules.go +++ b/modules.go @@ -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 "" diff --git a/parser.go b/parser.go index eeea154..3b553f8 100644 --- a/parser.go +++ b/parser.go @@ -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, diff --git a/types.go b/types.go index d0d6a61..6100d4b 100644 --- a/types.go +++ b/types.go @@ -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.