2026-04-03 11:11:17 +00:00
|
|
|
package ansiblecmd
|
2026-04-01 21:12:05 +00:00
|
|
|
|
|
|
|
|
import (
|
2026-04-02 13:21:01 +00:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-04-01 21:12:05 +00:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"dappco.re/go/core"
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
2026-04-02 02:12:48 +00:00
|
|
|
"github.com/stretchr/testify/require"
|
2026-04-01 21:12:05 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestExtraVars_Good_RepeatableAndCommaSeparated(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "version=1.2.3,env=prod"},
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "region=us-east-1"},
|
|
|
|
|
core.Option{Key: "extra-vars", Value: []string{"build=42"}},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
2026-04-01 21:12:05 +00:00
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
assert.Equal(t, map[string]any{
|
2026-04-01 21:12:05 +00:00
|
|
|
"version": "1.2.3",
|
|
|
|
|
"env": "prod",
|
|
|
|
|
"region": "us-east-1",
|
2026-04-03 11:44:56 +00:00
|
|
|
"build": 42,
|
2026-04-01 21:12:05 +00:00
|
|
|
}, vars)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:27:36 +00:00
|
|
|
func TestExtraVars_Good_UsesShortAlias(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "e", Value: "version=1.2.3,env=prod"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
2026-04-02 00:27:36 +00:00
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
assert.Equal(t, map[string]any{
|
2026-04-02 00:27:36 +00:00
|
|
|
"version": "1.2.3",
|
|
|
|
|
"env": "prod",
|
|
|
|
|
}, vars)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:28:08 +00:00
|
|
|
func TestExtraVars_Good_TrimsWhitespaceAroundPairs(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: " version = 1.2.3 , env = prod , empty = "},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, map[string]any{
|
|
|
|
|
"version": "1.2.3",
|
|
|
|
|
"env": "prod",
|
|
|
|
|
"empty": "",
|
|
|
|
|
}, vars)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:12:05 +00:00
|
|
|
func TestExtraVars_Good_IgnoresMalformedPairs(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "missing_equals,keep=this"},
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "also_bad="},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
2026-04-01 21:12:05 +00:00
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
assert.Equal(t, map[string]any{
|
2026-04-01 21:12:05 +00:00
|
|
|
"keep": "this",
|
|
|
|
|
"also_bad": "",
|
|
|
|
|
}, vars)
|
|
|
|
|
}
|
2026-04-01 23:24:17 +00:00
|
|
|
|
2026-04-03 11:44:56 +00:00
|
|
|
func TestExtraVars_Good_ParsesYAMLScalarsInKeyValuePairs(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "enabled=true,count=42,threshold=3.5"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, map[string]any{
|
|
|
|
|
"enabled": true,
|
|
|
|
|
"count": 42,
|
|
|
|
|
"threshold": 3.5,
|
|
|
|
|
}, vars)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:28:08 +00:00
|
|
|
func TestSplitCommaSeparatedOption_Good_TrimsWhitespace(t *testing.T) {
|
|
|
|
|
assert.Equal(t, []string{"deploy", "setup", "smoke"}, splitCommaSeparatedOption(" deploy, setup ,smoke "))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 13:21:01 +00:00
|
|
|
func TestExtraVars_Good_SupportsStructuredYAMLAndJSON(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "app:\n port: 8080\n debug: true"},
|
|
|
|
|
core.Option{Key: "extra-vars", Value: `{"image":"nginx:latest","replicas":3}`},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, map[string]any{
|
|
|
|
|
"app": map[string]any{
|
|
|
|
|
"port": int(8080),
|
|
|
|
|
"debug": true,
|
|
|
|
|
},
|
|
|
|
|
"image": "nginx:latest",
|
|
|
|
|
"replicas": int(3),
|
|
|
|
|
}, vars)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtraVars_Good_LoadsFileReferences(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "vars.yml")
|
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte("deploy_env: prod\nrelease: 42\n"), 0644))
|
|
|
|
|
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "@" + path},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
vars, err := extraVars(opts)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, map[string]any{
|
|
|
|
|
"deploy_env": "prod",
|
|
|
|
|
"release": int(42),
|
|
|
|
|
}, vars)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtraVars_Bad_MissingFile(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "@/definitely/missing/vars.yml"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_, err := extraVars(opts)
|
|
|
|
|
require.Error(t, err)
|
|
|
|
|
assert.Contains(t, err.Error(), "read extra vars file")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:27:36 +00:00
|
|
|
func TestFirstString_Good_PrefersFirstNonEmptyKey(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "inventory", Value: ""},
|
|
|
|
|
core.Option{Key: "i", Value: "/tmp/inventory.yml"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-03 11:11:17 +00:00
|
|
|
assert.Equal(t, "/tmp/inventory.yml", firstStringOption(opts, "inventory", "i"))
|
2026-04-02 00:27:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFirstBool_Good_UsesAlias(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "v", Value: true},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-03 11:11:17 +00:00
|
|
|
assert.True(t, firstBoolOption(opts, "verbose", "v"))
|
2026-04-02 00:27:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:39:22 +00:00
|
|
|
func TestVerbosityLevel_Good_CountsStackedShortFlags(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions()
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, 3, verbosityLevel(opts, []string{"-vvv"}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVerbosityLevel_Good_CountsLongForm(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions()
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, 1, verbosityLevel(opts, []string{"--verbose"}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVerbosityLevel_Good_PreservesExplicitNumericLevel(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(core.Option{Key: "verbose", Value: 2})
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, 2, verbosityLevel(opts, nil))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:24:10 +00:00
|
|
|
func TestBuildPlaybookCommandSettings_Good_AppliesFlags(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
playbookPath := filepath.Join(dir, "site.yml")
|
|
|
|
|
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
|
|
|
|
|
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "_arg", Value: playbookPath},
|
|
|
|
|
core.Option{Key: "limit", Value: "web1"},
|
|
|
|
|
core.Option{Key: "tags", Value: "deploy,setup"},
|
|
|
|
|
core.Option{Key: "skip-tags", Value: "slow"},
|
|
|
|
|
core.Option{Key: "extra-vars", Value: "version=1.2.3"},
|
|
|
|
|
core.Option{Key: "check", Value: true},
|
|
|
|
|
core.Option{Key: "diff", Value: true},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
settings, err := buildPlaybookCommandSettings(opts, []string{"-vvv"})
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, playbookPath, settings.playbookPath)
|
|
|
|
|
assert.Equal(t, dir, settings.basePath)
|
|
|
|
|
assert.Equal(t, "web1", settings.limit)
|
|
|
|
|
assert.Equal(t, []string{"deploy", "setup"}, settings.tags)
|
|
|
|
|
assert.Equal(t, []string{"slow"}, settings.skipTags)
|
|
|
|
|
assert.Equal(t, 3, settings.verbose)
|
|
|
|
|
assert.True(t, settings.checkMode)
|
|
|
|
|
assert.True(t, settings.diff)
|
|
|
|
|
assert.Equal(t, map[string]any{"version": "1.2.3"}, settings.extraVars)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:52:36 +00:00
|
|
|
func TestBuildPlaybookCommandSettings_Good_MergesRepeatedListFlags(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
playbookPath := filepath.Join(dir, "site.yml")
|
|
|
|
|
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
|
|
|
|
|
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "_arg", Value: playbookPath},
|
|
|
|
|
core.Option{Key: "limit", Value: "web1"},
|
|
|
|
|
core.Option{Key: "limit", Value: []string{"web2"}},
|
|
|
|
|
core.Option{Key: "tags", Value: "deploy,setup"},
|
|
|
|
|
core.Option{Key: "tags", Value: []string{"smoke"}},
|
|
|
|
|
core.Option{Key: "skip-tags", Value: "slow"},
|
|
|
|
|
core.Option{Key: "skip-tags", Value: []string{"flaky,experimental"}},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
settings, err := buildPlaybookCommandSettings(opts, nil)
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, "web1,web2", settings.limit)
|
|
|
|
|
assert.Equal(t, []string{"deploy", "setup", "smoke"}, settings.tags)
|
|
|
|
|
assert.Equal(t, []string{"slow", "flaky", "experimental"}, settings.skipTags)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:24:10 +00:00
|
|
|
func TestBuildPlaybookCommandSettings_Bad_MissingPlaybook(t *testing.T) {
|
|
|
|
|
_, err := buildPlaybookCommandSettings(core.NewOptions(), nil)
|
|
|
|
|
|
|
|
|
|
require.Error(t, err)
|
|
|
|
|
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:30:48 +00:00
|
|
|
func TestDiffOutputLines_Good_IncludesPathAndBeforeAfter(t *testing.T) {
|
|
|
|
|
lines := diffOutputLines(map[string]any{
|
|
|
|
|
"path": "/etc/nginx/conf.d/app.conf",
|
|
|
|
|
"before": "server_name=old.example.com;",
|
|
|
|
|
"after": "server_name=web01.example.com;",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, []string{
|
|
|
|
|
"diff:",
|
|
|
|
|
"path: /etc/nginx/conf.d/app.conf",
|
|
|
|
|
"- server_name=old.example.com;",
|
|
|
|
|
"+ server_name=web01.example.com;",
|
|
|
|
|
}, lines)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:24:17 +00:00
|
|
|
func TestTestKeyFile_Good_PrefersExplicitKey(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "key", Value: "/tmp/id_ed25519"},
|
|
|
|
|
core.Option{Key: "i", Value: "/tmp/ignored"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-03 13:18:36 +00:00
|
|
|
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
|
2026-04-01 23:24:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTestKeyFile_Good_FallsBackToShortAlias(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "i", Value: "/tmp/id_ed25519"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-03 13:18:36 +00:00
|
|
|
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
|
2026-04-01 23:24:17 +00:00
|
|
|
}
|
2026-04-02 00:27:36 +00:00
|
|
|
|
|
|
|
|
func TestFirstString_Good_ResolvesShortUserAlias(t *testing.T) {
|
|
|
|
|
opts := core.NewOptions(
|
|
|
|
|
core.Option{Key: "u", Value: "deploy"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-03 11:11:17 +00:00
|
|
|
cfgUser := firstStringOption(opts, "user", "u")
|
2026-04-02 00:27:36 +00:00
|
|
|
|
|
|
|
|
assert.Equal(t, "deploy", cfgUser)
|
|
|
|
|
}
|
2026-04-02 02:12:48 +00:00
|
|
|
|
|
|
|
|
func TestRegister_Good_RegistersAnsibleCommands(t *testing.T) {
|
|
|
|
|
app := core.New()
|
|
|
|
|
|
|
|
|
|
Register(app)
|
|
|
|
|
|
|
|
|
|
ansible := app.Command("ansible")
|
|
|
|
|
require.True(t, ansible.OK)
|
|
|
|
|
ansibleCmd := ansible.Value.(*core.Command)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, "ansible", ansibleCmd.Path)
|
|
|
|
|
assert.Equal(t, "ansible", ansibleCmd.Name)
|
|
|
|
|
assert.Equal(t, "Run Ansible playbooks natively (no Python required)", ansibleCmd.Description)
|
|
|
|
|
require.NotNil(t, ansibleCmd.Action)
|
|
|
|
|
|
|
|
|
|
test := app.Command("ansible/test")
|
|
|
|
|
require.True(t, test.OK)
|
|
|
|
|
testCmd := test.Value.(*core.Command)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, "ansible/test", testCmd.Path)
|
|
|
|
|
assert.Equal(t, "test", testCmd.Name)
|
|
|
|
|
assert.Equal(t, "Test SSH connectivity to a host", testCmd.Description)
|
|
|
|
|
require.NotNil(t, testCmd.Action)
|
|
|
|
|
|
|
|
|
|
paths := app.Commands()
|
|
|
|
|
assert.Contains(t, paths, "ansible")
|
|
|
|
|
assert.Contains(t, paths, "ansible/test")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_Good_ExposesExpectedFlags(t *testing.T) {
|
|
|
|
|
app := core.New()
|
|
|
|
|
|
|
|
|
|
Register(app)
|
|
|
|
|
|
|
|
|
|
ansibleCmd := app.Command("ansible").Value.(*core.Command)
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("inventory"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("i"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("limit"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("l"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("tags"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("t"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("skip-tags"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("extra-vars"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("e"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("verbose"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("v"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("check"))
|
|
|
|
|
assert.True(t, ansibleCmd.Flags.Has("diff"))
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("inventory"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("i"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("limit"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("l"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("tags"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("t"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("skip-tags"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("extra-vars"))
|
|
|
|
|
assert.Equal(t, "", ansibleCmd.Flags.String("e"))
|
|
|
|
|
assert.Equal(t, 0, ansibleCmd.Flags.Int("verbose"))
|
|
|
|
|
assert.False(t, ansibleCmd.Flags.Bool("v"))
|
|
|
|
|
assert.False(t, ansibleCmd.Flags.Bool("check"))
|
|
|
|
|
assert.False(t, ansibleCmd.Flags.Bool("diff"))
|
|
|
|
|
|
|
|
|
|
testCmd := app.Command("ansible/test").Value.(*core.Command)
|
|
|
|
|
assert.True(t, testCmd.Flags.Has("user"))
|
|
|
|
|
assert.True(t, testCmd.Flags.Has("u"))
|
|
|
|
|
assert.True(t, testCmd.Flags.Has("password"))
|
|
|
|
|
assert.True(t, testCmd.Flags.Has("key"))
|
|
|
|
|
assert.True(t, testCmd.Flags.Has("i"))
|
|
|
|
|
assert.True(t, testCmd.Flags.Has("port"))
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, "root", testCmd.Flags.String("user"))
|
|
|
|
|
assert.Equal(t, "root", testCmd.Flags.String("u"))
|
|
|
|
|
assert.Equal(t, "", testCmd.Flags.String("password"))
|
|
|
|
|
assert.Equal(t, "", testCmd.Flags.String("key"))
|
|
|
|
|
assert.Equal(t, "", testCmd.Flags.String("i"))
|
|
|
|
|
assert.Equal(t, 22, testCmd.Flags.Int("port"))
|
|
|
|
|
}
|
2026-04-02 14:17:29 +00:00
|
|
|
|
|
|
|
|
func TestRunAnsible_Bad_MissingPlaybook(t *testing.T) {
|
2026-04-03 11:11:17 +00:00
|
|
|
result := runPlaybookCommand(core.NewOptions())
|
2026-04-02 14:17:29 +00:00
|
|
|
|
|
|
|
|
require.False(t, result.OK)
|
|
|
|
|
err, ok := result.Value.(error)
|
|
|
|
|
require.True(t, ok)
|
|
|
|
|
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunAnsibleTest_Bad_MissingHost(t *testing.T) {
|
2026-04-03 11:11:17 +00:00
|
|
|
result := runSSHTestCommand(core.NewOptions())
|
2026-04-02 14:17:29 +00:00
|
|
|
|
|
|
|
|
require.False(t, result.OK)
|
|
|
|
|
err, ok := result.Value.(error)
|
|
|
|
|
require.True(t, ok)
|
|
|
|
|
assert.Contains(t, err.Error(), "usage: ansible test <host>")
|
|
|
|
|
}
|