go-ansible/executor_test.go
Virgil df8c055954
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
feat(ansible): honour max_fail_percentage
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:23:52 +00:00

645 lines
17 KiB
Go

package ansible
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- NewExecutor ---
func TestExecutor_NewExecutor_Good(t *testing.T) {
e := NewExecutor("/some/path")
assert.NotNil(t, e)
assert.NotNil(t, e.parser)
assert.NotNil(t, e.vars)
assert.NotNil(t, e.facts)
assert.NotNil(t, e.results)
assert.NotNil(t, e.handlers)
assert.NotNil(t, e.notified)
assert.NotNil(t, e.clients)
}
// --- SetVar ---
func TestExecutor_SetVar_Good(t *testing.T) {
e := NewExecutor("/tmp")
e.SetVar("foo", "bar")
e.SetVar("count", 42)
assert.Equal(t, "bar", e.vars["foo"])
assert.Equal(t, 42, e.vars["count"])
}
// --- SetInventoryDirect ---
func TestExecutor_SetInventoryDirect_Good(t *testing.T) {
e := NewExecutor("/tmp")
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"web1": {AnsibleHost: "10.0.0.1"},
},
},
}
e.SetInventoryDirect(inv)
assert.Equal(t, inv, e.inventory)
}
// --- getHosts ---
func TestExecutor_GetHosts_Good_WithInventory(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
},
},
})
hosts := e.getHosts("all")
assert.Len(t, hosts, 2)
}
func TestExecutor_GetHosts_Good_Localhost(t *testing.T) {
e := NewExecutor("/tmp")
// No inventory set
hosts := e.getHosts("localhost")
assert.Equal(t, []string{"localhost"}, hosts)
}
func TestExecutor_GetHosts_Good_NoInventory(t *testing.T) {
e := NewExecutor("/tmp")
hosts := e.getHosts("webservers")
assert.Nil(t, hosts)
}
func TestExecutor_GetHosts_Good_WithLimit(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
"host3": {},
},
},
})
e.Limit = "host2"
hosts := e.getHosts("all")
assert.Len(t, hosts, 1)
assert.Contains(t, hosts, "host2")
}
func TestExecutor_RunPlay_Good_SerialBatchesHosts(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
},
},
})
var gathered []string
e.OnTaskStart = func(host string, task *Task) {
gathered = append(gathered, host+":"+task.Name)
}
gatherFacts := false
play := &Play{
Name: "serial",
Hosts: "all",
GatherFacts: &gatherFacts,
Serial: "1",
Tasks: []Task{
{Name: "first", Module: "debug", Args: map[string]any{"msg": "one"}},
{Name: "second", Module: "debug", Args: map[string]any{"msg": "two"}},
},
}
require.NoError(t, e.runPlay(context.Background(), play))
assert.Equal(t, []string{
"host1:first",
"host1:second",
"host2:first",
"host2:second",
}, gathered)
}
func TestExecutor_RunPlay_Good_MaxFailPercentStopsAfterThreshold(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {Vars: map[string]any{"fail_first": true, "fail_second": false}},
"host2": {Vars: map[string]any{"fail_first": false, "fail_second": true}},
"host3": {Vars: map[string]any{"fail_first": false, "fail_second": false}},
},
},
})
var executed []string
e.OnTaskStart = func(host string, task *Task) {
executed = append(executed, host+":"+task.Name)
}
gatherFacts := false
play := &Play{
Name: "max fail",
Hosts: "all",
GatherFacts: &gatherFacts,
MaxFailPercent: 50,
Tasks: []Task{
{
Name: "first failure",
Module: "fail",
Args: map[string]any{"msg": "first"},
When: "{{ fail_first }}",
},
{
Name: "second failure",
Module: "fail",
Args: map[string]any{"msg": "second"},
When: "{{ fail_second }}",
},
{
Name: "final task",
Module: "debug",
Args: map[string]any{"msg": "ok"},
},
},
}
err := e.runPlay(context.Background(), play)
require.Error(t, err)
assert.Equal(t, []string{
"host1:first failure",
"host2:first failure",
"host3:first failure",
"host2:second failure",
}, executed)
assert.NotContains(t, executed, "host3:second failure")
assert.NotContains(t, executed, "host1:final task")
assert.NotContains(t, executed, "host2:final task")
assert.NotContains(t, executed, "host3:final task")
}
func TestExecutor_RunPlay_Good_RunOnceTaskOnlyRunsOnFirstHost(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
},
},
})
var executed []string
e.OnTaskStart = func(host string, task *Task) {
executed = append(executed, host)
}
gatherFacts := false
play := &Play{
Name: "run once",
Hosts: "all",
GatherFacts: &gatherFacts,
Tasks: []Task{
{
Name: "single host",
Module: "debug",
Args: map[string]any{"msg": "ok"},
Register: "result",
RunOnce: true,
},
},
}
require.NoError(t, e.runPlay(context.Background(), play))
assert.Equal(t, []string{"host1"}, executed)
assert.NotNil(t, e.results["host1"]["result"])
_, ok := e.results["host2"]
assert.False(t, ok)
}
func TestExecutor_RunPlay_Good_PlayTagsApplyToUntaggedTasks(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
},
},
})
e.Tags = []string{"deploy"}
var executed []string
e.OnTaskStart = func(host string, task *Task) {
executed = append(executed, host+":"+task.Name)
}
gatherFacts := false
play := &Play{
Name: "tagged play",
Hosts: "all",
GatherFacts: &gatherFacts,
Tags: []string{"deploy"},
Tasks: []Task{
{Name: "untagged task", Module: "debug", Args: map[string]any{"msg": "ok"}},
},
}
require.NoError(t, e.runPlay(context.Background(), play))
assert.Equal(t, []string{"host1:untagged task"}, executed)
}
func TestExecutor_RunTaskOnHost_Good_LoopControlPause(t *testing.T) {
e := NewExecutor("/tmp")
task := &Task{
Name: "pause loop",
Module: "debug",
Args: map[string]any{"msg": "{{ item }}"},
Loop: []any{"one", "two"},
LoopControl: &LoopControl{
Pause: 1,
},
}
play := &Play{}
start := time.Now()
require.NoError(t, e.runTaskOnHost(context.Background(), "localhost", task, play))
assert.GreaterOrEqual(t, time.Since(start), 900*time.Millisecond)
}
func TestExecutor_SetTempResult_Good_ResultAliasSupportsUntil(t *testing.T) {
e := NewExecutor("/tmp")
e.setTempResult("host1", "", &TaskResult{Failed: true, RC: 1})
assert.False(t, e.evaluateWhen("result is success", "host1", &Task{}))
e.setTempResult("host1", "", &TaskResult{Failed: false, RC: 0})
assert.True(t, e.evaluateWhen("result is success", "host1", &Task{}))
}
func TestRetryTask_Good_RetriesAndWaits(t *testing.T) {
attempts := 0
start := time.Now()
result, err := retryTask(context.Background(), 1, 1, func() (*TaskResult, error) {
attempts++
if attempts == 1 {
return &TaskResult{Failed: true, RC: 1}, nil
}
return &TaskResult{Failed: false, RC: 0}, nil
}, func(result *TaskResult) bool {
return result.RC == 0
})
require.NoError(t, err)
assert.Equal(t, 2, attempts)
assert.NotNil(t, result)
assert.False(t, result.Failed)
assert.GreaterOrEqual(t, time.Since(start), 900*time.Millisecond)
}
// --- matchesTags ---
func TestExecutor_MatchesTags_Good_NoTagsFilter(t *testing.T) {
e := NewExecutor("/tmp")
assert.True(t, e.matchesTags(nil))
assert.True(t, e.matchesTags([]string{"any", "tags"}))
}
func TestExecutor_MatchesTags_Good_IncludeTag(t *testing.T) {
e := NewExecutor("/tmp")
e.Tags = []string{"deploy"}
assert.True(t, e.matchesTags([]string{"deploy"}))
assert.True(t, e.matchesTags([]string{"setup", "deploy"}))
assert.False(t, e.matchesTags([]string{"other"}))
}
func TestExecutor_MatchesTags_Good_SkipTag(t *testing.T) {
e := NewExecutor("/tmp")
e.SkipTags = []string{"slow"}
assert.True(t, e.matchesTags([]string{"fast"}))
assert.False(t, e.matchesTags([]string{"slow"}))
assert.False(t, e.matchesTags([]string{"fast", "slow"}))
}
func TestExecutor_MatchesTags_Good_AllTag(t *testing.T) {
e := NewExecutor("/tmp")
e.Tags = []string{"all"}
assert.True(t, e.matchesTags([]string{"anything"}))
}
func TestExecutor_MatchesTags_Good_NoTaskTags(t *testing.T) {
e := NewExecutor("/tmp")
e.Tags = []string{"deploy"}
// Tasks with no tags should not match when include tags are set
assert.False(t, e.matchesTags(nil))
assert.False(t, e.matchesTags([]string{}))
}
// --- handleNotify ---
func TestExecutor_HandleNotify_Good_String(t *testing.T) {
e := NewExecutor("/tmp")
e.handleNotify("restart nginx")
assert.True(t, e.notified["restart nginx"])
}
func TestExecutor_HandleNotify_Good_StringList(t *testing.T) {
e := NewExecutor("/tmp")
e.handleNotify([]string{"restart nginx", "reload config"})
assert.True(t, e.notified["restart nginx"])
assert.True(t, e.notified["reload config"])
}
func TestExecutor_HandleNotify_Good_AnyList(t *testing.T) {
e := NewExecutor("/tmp")
e.handleNotify([]any{"restart nginx", "reload config"})
assert.True(t, e.notified["restart nginx"])
assert.True(t, e.notified["reload config"])
}
// --- normalizeConditions ---
func TestExecutor_NormalizeConditions_Good_String(t *testing.T) {
result := normalizeConditions("my_var is defined")
assert.Equal(t, []string{"my_var is defined"}, result)
}
func TestExecutor_NormalizeConditions_Good_StringSlice(t *testing.T) {
result := normalizeConditions([]string{"cond1", "cond2"})
assert.Equal(t, []string{"cond1", "cond2"}, result)
}
func TestExecutor_NormalizeConditions_Good_AnySlice(t *testing.T) {
result := normalizeConditions([]any{"cond1", "cond2"})
assert.Equal(t, []string{"cond1", "cond2"}, result)
}
func TestExecutor_NormalizeConditions_Good_Nil(t *testing.T) {
result := normalizeConditions(nil)
assert.Nil(t, result)
}
// --- evaluateWhen ---
func TestExecutor_EvaluateWhen_Good_TrueLiteral(t *testing.T) {
e := NewExecutor("/tmp")
assert.True(t, e.evaluateWhen("true", "host1", nil))
assert.True(t, e.evaluateWhen("True", "host1", nil))
}
func TestExecutor_EvaluateWhen_Good_FalseLiteral(t *testing.T) {
e := NewExecutor("/tmp")
assert.False(t, e.evaluateWhen("false", "host1", nil))
assert.False(t, e.evaluateWhen("False", "host1", nil))
}
func TestExecutor_EvaluateWhen_Good_Negation(t *testing.T) {
e := NewExecutor("/tmp")
assert.False(t, e.evaluateWhen("not true", "host1", nil))
assert.True(t, e.evaluateWhen("not false", "host1", nil))
}
func TestExecutor_EvaluateWhen_Good_RegisteredVarDefined(t *testing.T) {
e := NewExecutor("/tmp")
e.results["host1"] = map[string]*TaskResult{
"myresult": {Changed: true, Failed: false},
}
assert.True(t, e.evaluateWhen("myresult is defined", "host1", nil))
assert.False(t, e.evaluateWhen("myresult is not defined", "host1", nil))
assert.False(t, e.evaluateWhen("nonexistent is defined", "host1", nil))
assert.True(t, e.evaluateWhen("nonexistent is not defined", "host1", nil))
}
func TestExecutor_EvaluateWhen_Good_RegisteredVarStatus(t *testing.T) {
e := NewExecutor("/tmp")
e.results["host1"] = map[string]*TaskResult{
"success_result": {Changed: true, Failed: false},
"failed_result": {Failed: true},
"skipped_result": {Skipped: true},
}
assert.True(t, e.evaluateWhen("success_result is success", "host1", nil))
assert.True(t, e.evaluateWhen("success_result is succeeded", "host1", nil))
assert.True(t, e.evaluateWhen("success_result is changed", "host1", nil))
assert.True(t, e.evaluateWhen("failed_result is failed", "host1", nil))
assert.True(t, e.evaluateWhen("skipped_result is skipped", "host1", nil))
}
func TestExecutor_EvaluateWhen_Good_VarTruthy(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["enabled"] = true
e.vars["disabled"] = false
e.vars["name"] = "hello"
e.vars["empty"] = ""
e.vars["count"] = 5
e.vars["zero"] = 0
assert.True(t, e.evalCondition("enabled", "host1"))
assert.False(t, e.evalCondition("disabled", "host1"))
assert.True(t, e.evalCondition("name", "host1"))
assert.False(t, e.evalCondition("empty", "host1"))
assert.True(t, e.evalCondition("count", "host1"))
assert.False(t, e.evalCondition("zero", "host1"))
}
func TestExecutor_EvaluateWhen_Good_MultipleConditions(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["enabled"] = true
// All conditions must be true (AND)
assert.True(t, e.evaluateWhen([]any{"true", "True"}, "host1", nil))
assert.False(t, e.evaluateWhen([]any{"true", "false"}, "host1", nil))
}
// --- templateString ---
func TestExecutor_TemplateString_Good_SimpleVar(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["name"] = "world"
result := e.templateString("hello {{ name }}", "", nil)
assert.Equal(t, "hello world", result)
}
func TestExecutor_TemplateString_Good_MultVars(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["host"] = "example.com"
e.vars["port"] = 8080
result := e.templateString("http://{{ host }}:{{ port }}", "", nil)
assert.Equal(t, "http://example.com:8080", result)
}
func TestExecutor_TemplateString_Good_Unresolved(t *testing.T) {
e := NewExecutor("/tmp")
result := e.templateString("{{ undefined_var }}", "", nil)
assert.Equal(t, "{{ undefined_var }}", result)
}
func TestExecutor_TemplateString_Good_NoTemplate(t *testing.T) {
e := NewExecutor("/tmp")
result := e.templateString("plain string", "", nil)
assert.Equal(t, "plain string", result)
}
// --- applyFilter ---
func TestExecutor_ApplyFilter_Good_Default(t *testing.T) {
e := NewExecutor("/tmp")
assert.Equal(t, "hello", e.applyFilter("hello", "default('fallback')"))
assert.Equal(t, "fallback", e.applyFilter("", "default('fallback')"))
}
func TestExecutor_ApplyFilter_Good_Bool(t *testing.T) {
e := NewExecutor("/tmp")
assert.Equal(t, "true", e.applyFilter("true", "bool"))
assert.Equal(t, "true", e.applyFilter("yes", "bool"))
assert.Equal(t, "true", e.applyFilter("1", "bool"))
assert.Equal(t, "false", e.applyFilter("false", "bool"))
assert.Equal(t, "false", e.applyFilter("no", "bool"))
assert.Equal(t, "false", e.applyFilter("anything", "bool"))
}
func TestExecutor_ApplyFilter_Good_Trim(t *testing.T) {
e := NewExecutor("/tmp")
assert.Equal(t, "hello", e.applyFilter(" hello ", "trim"))
}
// --- resolveLoop ---
func TestExecutor_ResolveLoop_Good_SliceAny(t *testing.T) {
e := NewExecutor("/tmp")
items := e.resolveLoop([]any{"a", "b", "c"}, "host1")
assert.Len(t, items, 3)
}
func TestExecutor_ResolveLoop_Good_SliceString(t *testing.T) {
e := NewExecutor("/tmp")
items := e.resolveLoop([]string{"a", "b", "c"}, "host1")
assert.Len(t, items, 3)
}
func TestExecutor_ResolveLoop_Good_Nil(t *testing.T) {
e := NewExecutor("/tmp")
items := e.resolveLoop(nil, "host1")
assert.Nil(t, items)
}
// --- templateArgs ---
func TestExecutor_TemplateArgs_Good(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["myvar"] = "resolved"
args := map[string]any{
"plain": "no template",
"templated": "{{ myvar }}",
"number": 42,
}
result := e.templateArgs(args, "host1", nil)
assert.Equal(t, "no template", result["plain"])
assert.Equal(t, "resolved", result["templated"])
assert.Equal(t, 42, result["number"])
}
func TestExecutor_TemplateArgs_Good_NestedMap(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["port"] = "8080"
args := map[string]any{
"nested": map[string]any{
"port": "{{ port }}",
},
}
result := e.templateArgs(args, "host1", nil)
nested := result["nested"].(map[string]any)
assert.Equal(t, "8080", nested["port"])
}
func TestExecutor_TemplateArgs_Good_ArrayValues(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["pkg"] = "nginx"
args := map[string]any{
"packages": []any{"{{ pkg }}", "curl"},
}
result := e.templateArgs(args, "host1", nil)
pkgs := result["packages"].([]any)
assert.Equal(t, "nginx", pkgs[0])
assert.Equal(t, "curl", pkgs[1])
}
// --- Helper functions ---
func TestExecutor_GetStringArg_Good(t *testing.T) {
args := map[string]any{
"name": "value",
"number": 42,
}
assert.Equal(t, "value", getStringArg(args, "name", ""))
assert.Equal(t, "42", getStringArg(args, "number", ""))
assert.Equal(t, "default", getStringArg(args, "missing", "default"))
}
func TestExecutor_GetBoolArg_Good(t *testing.T) {
args := map[string]any{
"enabled": true,
"disabled": false,
"yes_str": "yes",
"true_str": "true",
"one_str": "1",
"no_str": "no",
}
assert.True(t, getBoolArg(args, "enabled", false))
assert.False(t, getBoolArg(args, "disabled", true))
assert.True(t, getBoolArg(args, "yes_str", false))
assert.True(t, getBoolArg(args, "true_str", false))
assert.True(t, getBoolArg(args, "one_str", false))
assert.False(t, getBoolArg(args, "no_str", true))
assert.True(t, getBoolArg(args, "missing", true))
assert.False(t, getBoolArg(args, "missing", false))
}
// --- Close ---
func TestExecutor_Close_Good_EmptyClients(t *testing.T) {
e := NewExecutor("/tmp")
// Should not panic with no clients
e.Close()
assert.Empty(t, e.clients)
}