go-devops/ansible/parser_test.go
Snider 6e346cb2fd test(devops): Phase 0 test coverage and hardening
- Fix go vet warnings across 4 files: update stale API calls in
  container/linuxkit_test.go, container/state_test.go, and
  devops/devops_test.go (removed io.Local arg from NewState/LoadState),
  rewrite container/templates_test.go for package-level function API
- Add ansible/parser_test.go: 17 tests covering ParsePlaybook,
  ParseInventory, ParseTasks, GetHosts, GetHostVars, isModule,
  NormalizeModule (plays, vars, handlers, blocks, loops, roles, FQCN)
- Add ansible/types_test.go: RoleRef/Task UnmarshalYAML, Inventory
  structure, Facts, TaskResult, KnownModules validation
- Add ansible/executor_test.go: executor logic (getHosts, matchesTags,
  evaluateWhen, templateString, applyFilter, resolveLoop, templateArgs,
  handleNotify, normalizeConditions, helper functions)
- Add infra/hetzner_test.go: HCloudClient/HRobotClient construction,
  do() round-trip via httptest, API error handling, JSON serialisation
  for HCloudServer, HCloudLoadBalancer, HRobotServer
- Add infra/cloudns_test.go: doRaw() round-trip via httptest, zone/record
  JSON parsing, CRUD response validation, ACME challenge logic,
  auth param verification, error handling
- Fix go.mod replace directive path (../go -> ../core)
- All tests pass, go vet clean, go test -race clean

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:36:03 +00:00

777 lines
18 KiB
Go

package ansible
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- ParsePlaybook ---
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Configure webserver
hosts: webservers
become: true
tasks:
- name: Install nginx
apt:
name: nginx
state: present
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, "Configure webserver", plays[0].Name)
assert.Equal(t, "webservers", plays[0].Hosts)
assert.True(t, plays[0].Become)
require.Len(t, plays[0].Tasks, 1)
assert.Equal(t, "Install nginx", plays[0].Tasks[0].Name)
assert.Equal(t, "apt", plays[0].Tasks[0].Module)
assert.Equal(t, "nginx", plays[0].Tasks[0].Args["name"])
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
}
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Play one
hosts: all
tasks:
- name: Say hello
debug:
msg: "Hello"
- name: Play two
hosts: localhost
connection: local
tasks:
- name: Say goodbye
debug:
msg: "Goodbye"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 2)
assert.Equal(t, "Play one", plays[0].Name)
assert.Equal(t, "all", plays[0].Hosts)
assert.Equal(t, "Play two", plays[1].Name)
assert.Equal(t, "localhost", plays[1].Hosts)
assert.Equal(t, "local", plays[1].Connection)
}
func TestParsePlaybook_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With vars
hosts: all
vars:
http_port: 8080
app_name: myapp
tasks:
- name: Print port
debug:
msg: "Port is {{ http_port }}"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, 8080, plays[0].Vars["http_port"])
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
}
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Full lifecycle
hosts: all
pre_tasks:
- name: Pre task
debug:
msg: "pre"
tasks:
- name: Main task
debug:
msg: "main"
post_tasks:
- name: Post task
debug:
msg: "post"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Len(t, plays[0].PreTasks, 1)
assert.Len(t, plays[0].Tasks, 1)
assert.Len(t, plays[0].PostTasks, 1)
assert.Equal(t, "Pre task", plays[0].PreTasks[0].Name)
assert.Equal(t, "Main task", plays[0].Tasks[0].Name)
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
}
func TestParsePlaybook_Good_Handlers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With handlers
hosts: all
tasks:
- name: Install package
apt:
name: nginx
notify: restart nginx
handlers:
- name: restart nginx
service:
name: nginx
state: restarted
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Len(t, plays[0].Handlers, 1)
assert.Equal(t, "restart nginx", plays[0].Handlers[0].Name)
assert.Equal(t, "service", plays[0].Handlers[0].Module)
}
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Shell tasks
hosts: all
tasks:
- name: Run a command
shell: echo hello world
- name: Run raw command
command: ls -la /tmp
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays[0].Tasks, 2)
assert.Equal(t, "shell", plays[0].Tasks[0].Module)
assert.Equal(t, "echo hello world", plays[0].Tasks[0].Args["_raw_params"])
assert.Equal(t, "command", plays[0].Tasks[1].Module)
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
}
func TestParsePlaybook_Good_WithTags(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Tagged play
hosts: all
tags:
- setup
tasks:
- name: Tagged task
debug:
msg: "tagged"
tags:
- debug
- always
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Equal(t, []string{"setup"}, plays[0].Tags)
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
}
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With blocks
hosts: all
tasks:
- name: Protected block
block:
- name: Try this
shell: echo try
rescue:
- name: Handle error
debug:
msg: "rescued"
always:
- name: Always runs
debug:
msg: "always"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
task := plays[0].Tasks[0]
assert.Len(t, task.Block, 1)
assert.Len(t, task.Rescue, 1)
assert.Len(t, task.Always, 1)
assert.Equal(t, "Try this", task.Block[0].Name)
assert.Equal(t, "Handle error", task.Rescue[0].Name)
assert.Equal(t, "Always runs", task.Always[0].Name)
}
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Loop test
hosts: all
tasks:
- name: Install packages
apt:
name: "{{ item }}"
state: present
loop:
- vim
- curl
- git
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
task := plays[0].Tasks[0]
assert.Equal(t, "apt", task.Module)
items, ok := task.Loop.([]any)
require.True(t, ok)
assert.Len(t, items, 3)
}
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With roles
hosts: all
roles:
- common
- role: webserver
vars:
http_port: 80
tags:
- web
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays[0].Roles, 2)
assert.Equal(t, "common", plays[0].Roles[0].Role)
assert.Equal(t, "webserver", plays[0].Roles[1].Role)
assert.Equal(t, 80, plays[0].Roles[1].Vars["http_port"])
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
}
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: FQCN modules
hosts: all
tasks:
- name: Copy file
ansible.builtin.copy:
src: /tmp/foo
dest: /tmp/bar
- name: Run shell
ansible.builtin.shell: echo hello
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Equal(t, "ansible.builtin.copy", plays[0].Tasks[0].Module)
assert.Equal(t, "/tmp/foo", plays[0].Tasks[0].Args["src"])
assert.Equal(t, "ansible.builtin.shell", plays[0].Tasks[1].Module)
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
}
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Conditional play
hosts: all
tasks:
- name: Check file
stat:
path: /etc/nginx/nginx.conf
register: nginx_conf
- name: Show result
debug:
msg: "File exists"
when: nginx_conf.stat.exists
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Equal(t, "nginx_conf", plays[0].Tasks[0].Register)
assert.NotNil(t, plays[0].Tasks[1].When)
}
func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
require.NoError(t, os.WriteFile(path, []byte("---\n[]"), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Empty(t, plays)
}
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
require.NoError(t, os.WriteFile(path, []byte("{{invalid yaml}}"), 0644))
p := NewParser(dir)
_, err := p.ParsePlaybook(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), "parse playbook")
}
func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
p := NewParser(t.TempDir())
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "read playbook")
}
func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: No facts
hosts: all
gather_facts: false
tasks: []
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.NotNil(t, plays[0].GatherFacts)
assert.False(t, *plays[0].GatherFacts)
}
// --- ParseInventory ---
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
hosts:
web1:
ansible_host: 192.168.1.10
web2:
ansible_host: 192.168.1.11
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
require.NotNil(t, inv.All)
assert.Len(t, inv.All.Hosts, 2)
assert.Equal(t, "192.168.1.10", inv.All.Hosts["web1"].AnsibleHost)
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
}
func TestParseInventory_Good_WithGroups(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
children:
webservers:
hosts:
web1:
ansible_host: 10.0.0.1
web2:
ansible_host: 10.0.0.2
databases:
hosts:
db1:
ansible_host: 10.0.1.1
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
require.NotNil(t, inv.All.Children["webservers"])
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
require.NotNil(t, inv.All.Children["databases"])
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
}
func TestParseInventory_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
vars:
ansible_user: admin
children:
production:
vars:
env: prod
hosts:
prod1:
ansible_host: 10.0.0.1
ansible_port: 2222
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
assert.Equal(t, "admin", inv.All.Vars["ansible_user"])
assert.Equal(t, "prod", inv.All.Children["production"].Vars["env"])
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
}
func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
require.NoError(t, os.WriteFile(path, []byte("{{{bad"), 0644))
p := NewParser(dir)
_, err := p.ParseInventory(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), "parse inventory")
}
func TestParseInventory_Bad_FileNotFound(t *testing.T) {
p := NewParser(t.TempDir())
_, err := p.ParseInventory("/nonexistent/inventory.yml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "read inventory")
}
// --- ParseTasks ---
func TestParseTasks_Good_TaskFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.yml")
yaml := `---
- name: First task
shell: echo first
- name: Second task
copy:
src: /tmp/a
dest: /tmp/b
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
tasks, err := p.ParseTasks(path)
require.NoError(t, err)
require.Len(t, tasks, 2)
assert.Equal(t, "shell", tasks[0].Module)
assert.Equal(t, "echo first", tasks[0].Args["_raw_params"])
assert.Equal(t, "copy", tasks[1].Module)
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
}
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
require.NoError(t, os.WriteFile(path, []byte("not: [valid: tasks"), 0644))
p := NewParser(dir)
_, err := p.ParseTasks(path)
assert.Error(t, err)
}
// --- GetHosts ---
func TestGetHosts_Good_AllPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
},
},
}
hosts := GetHosts(inv, "all")
assert.Len(t, hosts, 2)
assert.Contains(t, hosts, "host1")
assert.Contains(t, hosts, "host2")
}
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
inv := &Inventory{All: &InventoryGroup{}}
hosts := GetHosts(inv, "localhost")
assert.Equal(t, []string{"localhost"}, hosts)
}
func TestGetHosts_Good_GroupPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
"web": {
Hosts: map[string]*Host{
"web1": {},
"web2": {},
},
},
"db": {
Hosts: map[string]*Host{
"db1": {},
},
},
},
},
}
hosts := GetHosts(inv, "web")
assert.Len(t, hosts, 2)
assert.Contains(t, hosts, "web1")
assert.Contains(t, hosts, "web2")
}
func TestGetHosts_Good_SpecificHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
"servers": {
Hosts: map[string]*Host{
"myhost": {},
},
},
},
},
}
hosts := GetHosts(inv, "myhost")
assert.Equal(t, []string{"myhost"}, hosts)
}
func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"top": {}},
Children: map[string]*InventoryGroup{
"group1": {
Hosts: map[string]*Host{"child1": {}},
},
},
},
}
hosts := GetHosts(inv, "all")
assert.Len(t, hosts, 2)
assert.Contains(t, hosts, "top")
assert.Contains(t, hosts, "child1")
}
func TestGetHosts_Bad_NoMatch(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"host1": {}},
},
}
hosts := GetHosts(inv, "nonexistent")
assert.Empty(t, hosts)
}
func TestGetHosts_Bad_NilGroup(t *testing.T) {
inv := &Inventory{All: nil}
hosts := GetHosts(inv, "all")
assert.Empty(t, hosts)
}
// --- GetHostVars ---
func TestGetHostVars_Good_DirectHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"global_var": "global"},
Hosts: map[string]*Host{
"myhost": {
AnsibleHost: "10.0.0.1",
AnsiblePort: 2222,
AnsibleUser: "deploy",
},
},
},
}
vars := GetHostVars(inv, "myhost")
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
assert.Equal(t, 2222, vars["ansible_port"])
assert.Equal(t, "deploy", vars["ansible_user"])
assert.Equal(t, "global", vars["global_var"])
}
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"level": "all"},
Children: map[string]*InventoryGroup{
"production": {
Vars: map[string]any{"env": "prod", "level": "group"},
Hosts: map[string]*Host{
"prod1": {
AnsibleHost: "10.0.0.1",
},
},
},
},
},
}
vars := GetHostVars(inv, "prod1")
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
assert.Equal(t, "prod", vars["env"])
}
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"other": {}},
},
}
vars := GetHostVars(inv, "nonexistent")
assert.Empty(t, vars)
}
// --- isModule ---
func TestIsModule_Good_KnownModules(t *testing.T) {
assert.True(t, isModule("shell"))
assert.True(t, isModule("command"))
assert.True(t, isModule("copy"))
assert.True(t, isModule("file"))
assert.True(t, isModule("apt"))
assert.True(t, isModule("service"))
assert.True(t, isModule("systemd"))
assert.True(t, isModule("debug"))
assert.True(t, isModule("set_fact"))
}
func TestIsModule_Good_FQCN(t *testing.T) {
assert.True(t, isModule("ansible.builtin.shell"))
assert.True(t, isModule("ansible.builtin.copy"))
assert.True(t, isModule("ansible.builtin.apt"))
}
func TestIsModule_Good_DottedUnknown(t *testing.T) {
// Any key with dots is considered a module
assert.True(t, isModule("community.general.ufw"))
assert.True(t, isModule("ansible.posix.authorized_key"))
}
func TestIsModule_Bad_NotAModule(t *testing.T) {
assert.False(t, isModule("some_random_key"))
assert.False(t, isModule("foobar"))
}
// --- NormalizeModule ---
func TestNormalizeModule_Good(t *testing.T) {
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("shell"))
assert.Equal(t, "ansible.builtin.copy", NormalizeModule("copy"))
assert.Equal(t, "ansible.builtin.apt", NormalizeModule("apt"))
}
func TestNormalizeModule_Good_AlreadyFQCN(t *testing.T) {
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("ansible.builtin.shell"))
assert.Equal(t, "community.general.ufw", NormalizeModule("community.general.ufw"))
}
// --- NewParser ---
func TestNewParser_Good(t *testing.T) {
p := NewParser("/some/path")
assert.NotNil(t, p)
assert.Equal(t, "/some/path", p.basePath)
assert.NotNil(t, p.vars)
}