diff --git a/executor.go b/executor.go index 32f3bca..49251ec 100644 --- a/executor.go +++ b/executor.go @@ -469,8 +469,9 @@ func (e *Executor) runRole(ctx context.Context, hosts []string, roleRef *RoleRef // Execute tasks for _, task := range tasks { effectiveTask := task + e.applyRoleTaskDefaults(&effectiveTask, roleRef.Apply) if len(roleRef.Tags) > 0 { - effectiveTask.Tags = mergeStringSlices(roleRef.Tags, task.Tags) + effectiveTask.Tags = mergeStringSlices(roleRef.Tags, effectiveTask.Tags) } if err := e.runTaskOnHosts(ctx, eligibleHosts, &effectiveTask, play); err != nil { // Restore vars @@ -1532,6 +1533,7 @@ func (e *Executor) resolveIncludeRoleRef(host string, task *Task) *RoleRef { var roleName, tasksFrom, defaultsFrom, varsFrom string var roleVars map[string]any + var apply *TaskApply if task.IncludeRole != nil { roleName = task.IncludeRole.Name @@ -1539,12 +1541,14 @@ func (e *Executor) resolveIncludeRoleRef(host string, task *Task) *RoleRef { defaultsFrom = task.IncludeRole.DefaultsFrom varsFrom = task.IncludeRole.VarsFrom roleVars = task.IncludeRole.Vars + apply = task.IncludeRole.Apply } else if task.ImportRole != nil { roleName = task.ImportRole.Name tasksFrom = task.ImportRole.TasksFrom defaultsFrom = task.ImportRole.DefaultsFrom varsFrom = task.ImportRole.VarsFrom roleVars = task.ImportRole.Vars + apply = task.ImportRole.Apply } else { return nil } @@ -1560,6 +1564,7 @@ func (e *Executor) resolveIncludeRoleRef(host string, task *Task) *RoleRef { DefaultsFrom: e.templateString(defaultsFrom, host, task), VarsFrom: e.templateString(varsFrom, host, task), Vars: renderedVars, + Apply: apply, When: task.When, Tags: task.Tags, } @@ -1581,6 +1586,55 @@ func mergeTaskVars(parent, child map[string]any) map[string]any { return merged } +func mergeStringMap(parent, child map[string]string) map[string]string { + if len(parent) == 0 && len(child) == 0 { + return nil + } + + merged := make(map[string]string, len(parent)+len(child)) + for k, v := range parent { + merged[k] = v + } + for k, v := range child { + merged[k] = v + } + return merged +} + +func (e *Executor) applyRoleTaskDefaults(task *Task, apply *TaskApply) { + if task == nil || apply == nil { + return + } + + if len(apply.Tags) > 0 { + task.Tags = mergeStringSlices(apply.Tags, task.Tags) + } + if len(apply.Vars) > 0 { + task.Vars = mergeTaskVars(apply.Vars, task.Vars) + } + if len(apply.Environment) > 0 { + task.Environment = mergeStringMap(apply.Environment, task.Environment) + } + if apply.Become != nil && task.Become == nil { + task.Become = apply.Become + } + if apply.BecomeUser != "" && task.BecomeUser == "" { + task.BecomeUser = apply.BecomeUser + } + if apply.Delegate != "" && task.Delegate == "" { + task.Delegate = apply.Delegate + } + if apply.RunOnce && !task.RunOnce { + task.RunOnce = true + } + if apply.NoLog { + task.NoLog = true + } + if apply.IgnoreErrors { + task.IgnoreErrors = true + } +} + // getHosts returns hosts matching the pattern. func (e *Executor) getHosts(pattern string) []string { if e.inventory == nil { diff --git a/executor_extra_test.go b/executor_extra_test.go index 08b99e2..c496f2a 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -775,6 +775,7 @@ func TestExecutorExtra_RunIncludeRole_Good_InheritsTaskVars(t *testing.T) { DefaultsFrom string `yaml:"defaults_from,omitempty"` VarsFrom string `yaml:"vars_from,omitempty"` Vars map[string]any `yaml:"vars,omitempty"` + Apply *TaskApply `yaml:"apply,omitempty"` }{ Name: "demo", }, @@ -785,6 +786,73 @@ func TestExecutorExtra_RunIncludeRole_Good_InheritsTaskVars(t *testing.T) { assert.Equal(t, "hello from role", e.results["localhost"]["role_result"].Msg) } +func TestExecutorExtra_RunIncludeRole_Good_AppliesRoleDefaults(t *testing.T) { + dir := t.TempDir() + require.NoError(t, writeTestFile(joinPath(dir, "roles", "app", "tasks", "main.yml"), []byte(`--- +- name: Applied role task + vars: + role_message: from-task + shell: printf '%s|%s|%s' "$APP_ENV" "{{ apply_message }}" "{{ role_message }}" + register: role_result +`), 0644)) + + e := NewExecutor(dir) + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "localhost": {}, + }, + }, + }) + + gatherFacts := false + play := &Play{ + Hosts: "localhost", + Connection: "local", + GatherFacts: &gatherFacts, + } + + var started *Task + e.OnTaskStart = func(host string, task *Task) { + if task.Name == "Applied role task" { + started = task + } + } + + // Re-run with callback attached so we can inspect the merged task state. + require.NoError(t, e.runTaskOnHosts(context.Background(), []string{"localhost"}, &Task{ + Name: "Load role with apply", + IncludeRole: &struct { + Name string `yaml:"name"` + TasksFrom string `yaml:"tasks_from,omitempty"` + DefaultsFrom string `yaml:"defaults_from,omitempty"` + VarsFrom string `yaml:"vars_from,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + Apply *TaskApply `yaml:"apply,omitempty"` + }{ + Name: "app", + Apply: &TaskApply{ + Tags: []string{"role-apply"}, + Vars: map[string]any{ + "apply_message": "from-apply", + "role_message": "from-apply", + }, + Environment: map[string]string{ + "APP_ENV": "production", + }, + }, + }, + }, play)) + + require.NotNil(t, started) + assert.Contains(t, started.Tags, "role-apply") + assert.Equal(t, "production", started.Environment["APP_ENV"]) + assert.Equal(t, "from-apply", started.Vars["apply_message"]) + assert.Equal(t, "from-task", started.Vars["role_message"]) + require.NotNil(t, e.results["localhost"]["role_result"]) + assert.Equal(t, "production|from-apply|from-task", e.results["localhost"]["role_result"].Stdout) +} + func TestExecutorExtra_GetHostsIter_Good(t *testing.T) { inv := &Inventory{ All: &InventoryGroup{ diff --git a/executor_test.go b/executor_test.go index 9b028ae..de10d7a 100644 --- a/executor_test.go +++ b/executor_test.go @@ -662,6 +662,7 @@ func TestExecutor_RunIncludeRole_Good_TemplatesRoleName(t *testing.T) { DefaultsFrom string `yaml:"defaults_from,omitempty"` VarsFrom string `yaml:"vars_from,omitempty"` Vars map[string]any `yaml:"vars,omitempty"` + Apply *TaskApply `yaml:"apply,omitempty"` }{ Name: "{{ role_name }}", }, diff --git a/types.go b/types.go index 409aecc..466b635 100644 --- a/types.go +++ b/types.go @@ -51,6 +51,7 @@ type RoleRef struct { DefaultsFrom string `yaml:"defaults_from,omitempty"` VarsFrom string `yaml:"vars_from,omitempty"` Vars map[string]any `yaml:"vars,omitempty"` + Apply *TaskApply `yaml:"apply,omitempty"` When any `yaml:"when,omitempty"` Tags []string `yaml:"tags,omitempty"` } @@ -128,6 +129,7 @@ type Task struct { DefaultsFrom string `yaml:"defaults_from,omitempty"` VarsFrom string `yaml:"vars_from,omitempty"` Vars map[string]any `yaml:"vars,omitempty"` + Apply *TaskApply `yaml:"apply,omitempty"` } `yaml:"include_role,omitempty"` ImportRole *struct { Name string `yaml:"name"` @@ -135,6 +137,7 @@ type Task struct { DefaultsFrom string `yaml:"defaults_from,omitempty"` VarsFrom string `yaml:"vars_from,omitempty"` Vars map[string]any `yaml:"vars,omitempty"` + Apply *TaskApply `yaml:"apply,omitempty"` } `yaml:"import_role,omitempty"` // Raw YAML for module extraction @@ -154,6 +157,23 @@ type LoopControl struct { Extended bool `yaml:"extended,omitempty"` } +// TaskApply captures role-level task defaults from include_role/import_role. +// +// Example: +// +// apply := TaskApply{Tags: []string{"deploy"}} +type TaskApply struct { + Tags []string `yaml:"tags,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + Become *bool `yaml:"become,omitempty"` + BecomeUser string `yaml:"become_user,omitempty"` + Delegate string `yaml:"delegate_to,omitempty"` + RunOnce bool `yaml:"run_once,omitempty"` + NoLog bool `yaml:"no_log,omitempty"` + IgnoreErrors bool `yaml:"ignore_errors,omitempty"` +} + // TaskResult holds the result of executing a task. // // Example: diff --git a/types_test.go b/types_test.go index b9bce42..ecaa2a5 100644 --- a/types_test.go +++ b/types_test.go @@ -561,6 +561,13 @@ include_role: tasks_from: setup.yml defaults_from: defaults.yml vars_from: vars.yml + apply: + tags: + - deploy + become: true + become_user: root + environment: + APP_ENV: production ` var task Task err := yaml.Unmarshal([]byte(input), &task) @@ -571,6 +578,12 @@ include_role: assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom) assert.Equal(t, "defaults.yml", task.IncludeRole.DefaultsFrom) assert.Equal(t, "vars.yml", task.IncludeRole.VarsFrom) + require.NotNil(t, task.IncludeRole.Apply) + assert.Equal(t, []string{"deploy"}, task.IncludeRole.Apply.Tags) + require.NotNil(t, task.IncludeRole.Apply.Become) + assert.True(t, *task.IncludeRole.Apply.Become) + assert.Equal(t, "root", task.IncludeRole.Apply.BecomeUser) + assert.Equal(t, "production", task.IncludeRole.Apply.Environment["APP_ENV"]) } func TestTypes_Task_UnmarshalYAML_Good_BecomeFields(t *testing.T) {