fix(ansible): accept FQCN include directives
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run

Co-authored-by: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 10:04:06 +00:00
parent 8699d00933
commit dd9ccc777c
4 changed files with 139 additions and 0 deletions

View file

@ -427,11 +427,36 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
"retries": true, "delay": true, "until": true,
"action": true, "local_action": true,
"include_tasks": true, "import_tasks": true,
"ansible.builtin.include_tasks": true, "ansible.legacy.include_tasks": true,
"ansible.builtin.import_tasks": true, "ansible.legacy.import_tasks": true,
"apply": true,
"include_role": true, "import_role": true, "public": true,
"ansible.builtin.include_role": true, "ansible.legacy.include_role": true,
"ansible.builtin.import_role": true, "ansible.legacy.import_role": true,
"with_items": true, "with_dict": true, "with_indexed_items": true, "with_nested": true, "with_together": true, "with_subelements": true, "with_file": true, "with_fileglob": true, "with_sequence": true,
}
if value, ok := directiveValue(m, "include_tasks"); ok && t.IncludeTasks == "" {
t.IncludeTasks = sprintf("%v", value)
}
if value, ok := directiveValue(m, "import_tasks"); ok && t.ImportTasks == "" {
t.ImportTasks = sprintf("%v", value)
}
if value, ok := directiveValue(m, "include_role"); ok && t.IncludeRole == nil {
var ref RoleRef
if err := decodeYAMLValue(value, &ref); err != nil {
return err
}
t.IncludeRole = &ref
}
if value, ok := directiveValue(m, "import_role"); ok && t.ImportRole == nil {
var ref RoleRef
if err := decodeYAMLValue(value, &ref); err != nil {
return err
}
t.ImportRole = &ref
}
for key, val := range m {
if knownKeys[key] {
continue
@ -561,6 +586,14 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
return nil
}
func decodeYAMLValue(value any, out any) error {
data, err := yaml.Marshal(value)
if err != nil {
return err
}
return yaml.Unmarshal(data, out)
}
// parseActionSpec converts action/local_action values into a module name and
// argument map.
func parseActionSpec(value any) (string, map[string]any) {

View file

@ -155,6 +155,36 @@ func TestParser_ParsePlaybook_Good_TemplatedImportPlaybook(t *testing.T) {
assert.Equal(t, "all", plays[0].Hosts)
}
func TestParser_ParsePlaybook_Good_FQCNImportPlaybook(t *testing.T) {
dir := t.TempDir()
mainPath := joinPath(dir, "site.yml")
importDir := joinPath(dir, "plays")
importPath := joinPath(importDir, "web.yml")
yamlMain := `---
- ansible.builtin.import_playbook: plays/web.yml
`
yamlImported := `---
- name: Imported play
hosts: all
tasks:
- name: Say imported
debug:
msg: "imported"
`
require.NoError(t, os.MkdirAll(importDir, 0755))
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(mainPath)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, "Imported play", plays[0].Name)
assert.Equal(t, "all", plays[0].Hosts)
}
func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")

View file

@ -44,6 +44,27 @@ type Play struct {
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
}
// UnmarshalYAML handles play-level aliases such as ansible.builtin.import_playbook.
func (p *Play) UnmarshalYAML(node *yaml.Node) error {
type rawPlay Play
var raw rawPlay
if err := node.Decode(&raw); err != nil {
return err
}
*p = Play(raw)
var fields map[string]any
if err := node.Decode(&fields); err != nil {
return err
}
if value, ok := directiveValue(fields, "import_playbook"); ok && p.ImportPlaybook == "" {
p.ImportPlaybook = sprintf("%v", value)
}
return nil
}
// RoleRef represents a role reference in a play.
//
// Example:
@ -90,6 +111,20 @@ func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}
func directiveValue(fields map[string]any, name string) (any, bool) {
if fields == nil {
return nil, false
}
for _, key := range []string{name, "ansible.builtin." + name, "ansible.legacy." + name} {
if value, ok := fields[key]; ok {
return value, true
}
}
return nil, false
}
// Task represents an Ansible task.
//
// Example:

View file

@ -605,6 +605,18 @@ include_tasks: other-tasks.yml
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksFQCN(t *testing.T) {
input := `
name: Include tasks
ansible.builtin.include_tasks: other-tasks.yml
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksApply(t *testing.T) {
input := `
name: Include tasks
@ -681,6 +693,22 @@ include_role: common
assert.Equal(t, "common", task.IncludeRole.Role)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleFQCN(t *testing.T) {
input := `
name: Include role
ansible.builtin.include_role:
name: common
tasks_from: setup.yml
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.IncludeRole)
assert.Equal(t, "common", task.IncludeRole.Role)
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
}
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleStringForm(t *testing.T) {
input := `
name: Import role
@ -694,6 +722,19 @@ import_role: common
assert.Equal(t, "common", task.ImportRole.Role)
}
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleFQCN(t *testing.T) {
input := `
name: Import role
ansible.builtin.import_role: common
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.ImportRole)
assert.Equal(t, "common", task.ImportRole.Role)
}
func TestTypes_Task_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
input := `
name: Privileged task