feat(ansible): support include_role apply defaults

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 00:17:04 +00:00
parent 57bc50002e
commit 807751ebe7
5 changed files with 157 additions and 1 deletions

View file

@ -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 {

View file

@ -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{

View file

@ -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 }}",
},

View file

@ -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:

View file

@ -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) {