diff --git a/cmd/ansible/ansible.go b/cmd/ansible/ansible.go index 1c23aac..2f57da8 100644 --- a/cmd/ansible/ansible.go +++ b/cmd/ansible/ansible.go @@ -2,6 +2,7 @@ package anscmd import ( "context" + "encoding/json" "os" "strconv" "strings" @@ -11,6 +12,7 @@ import ( "dappco.re/go/core/ansible" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + "gopkg.in/yaml.v3" ) // args extracts all positional arguments from Options. @@ -77,8 +79,8 @@ func verbosityLevel(opts core.Options, rawArgs []string) int { } // extraVars collects all repeated extra-vars values from Options. -func extraVars(opts core.Options) map[string]string { - vars := make(map[string]string) +func extraVars(opts core.Options) (map[string]any, error) { + vars := make(map[string]any) for _, o := range opts.Items() { if o.Key != "extra-vars" && o.Key != "e" { @@ -100,22 +102,83 @@ func extraVars(opts core.Options) map[string]string { } for _, value := range values { - for _, pair := range split(value, ",") { - parts := splitN(pair, "=", 2) - if len(parts) != 2 { - continue - } - - key := trimSpace(parts[0]) - if key == "" { - continue - } - - vars[key] = parts[1] + parsed, err := parseExtraVarsValue(value) + if err != nil { + return nil, err + } + for key, parsedValue := range parsed { + vars[key] = parsedValue } } } + return vars, nil +} + +func parseExtraVarsValue(value string) (map[string]any, error) { + trimmed := trimSpace(value) + if trimmed == "" { + return nil, nil + } + + if strings.HasPrefix(trimmed, "@") { + path := trimSpace(strings.TrimPrefix(trimmed, "@")) + if path == "" { + return nil, coreerr.E("parseExtraVarsValue", "extra vars file path required", nil) + } + + data, err := coreio.Local.Read(path) + if err != nil { + return nil, coreerr.E("parseExtraVarsValue", "read extra vars file", err) + } + + return parseExtraVarsValue(string(data)) + } + + if structured, ok := parseStructuredExtraVars(trimmed); ok { + return structured, nil + } + + if strings.Contains(trimmed, "=") { + return parseKeyValueExtraVars(trimmed), nil + } + + return nil, nil +} + +func parseStructuredExtraVars(value string) (map[string]any, bool) { + var parsed map[string]any + if json.Valid([]byte(value)) { + if err := yaml.Unmarshal([]byte(value), &parsed); err == nil && len(parsed) > 0 { + return parsed, true + } + } + if err := yaml.Unmarshal([]byte(value), &parsed); err != nil { + return nil, false + } + if len(parsed) == 0 { + return nil, false + } + return parsed, true +} + +func parseKeyValueExtraVars(value string) map[string]any { + vars := make(map[string]any) + + for _, pair := range split(value, ",") { + parts := splitN(pair, "=", 2) + if len(parts) != 2 { + continue + } + + key := trimSpace(parts[0]) + if key == "" { + continue + } + + vars[key] = parts[1] + } + return vars } @@ -162,7 +225,11 @@ func runAnsible(opts core.Options) core.Result { } // Parse extra vars - for key, value := range extraVars(opts) { + vars, err := extraVars(opts) + if err != nil { + return core.Result{Value: coreerr.E("runAnsible", "parse extra vars", err)} + } + for key, value := range vars { executor.SetVar(key, value) } diff --git a/cmd/ansible/ansible_test.go b/cmd/ansible/ansible_test.go index ceedd7e..4a4a194 100644 --- a/cmd/ansible/ansible_test.go +++ b/cmd/ansible/ansible_test.go @@ -1,6 +1,8 @@ package anscmd import ( + "os" + "path/filepath" "testing" "dappco.re/go/core" @@ -15,9 +17,10 @@ func TestExtraVars_Good_RepeatableAndCommaSeparated(t *testing.T) { core.Option{Key: "extra-vars", Value: []string{"build=42"}}, ) - vars := extraVars(opts) + vars, err := extraVars(opts) + require.NoError(t, err) - assert.Equal(t, map[string]string{ + assert.Equal(t, map[string]any{ "version": "1.2.3", "env": "prod", "region": "us-east-1", @@ -30,9 +33,10 @@ func TestExtraVars_Good_UsesShortAlias(t *testing.T) { core.Option{Key: "e", Value: "version=1.2.3,env=prod"}, ) - vars := extraVars(opts) + vars, err := extraVars(opts) + require.NoError(t, err) - assert.Equal(t, map[string]string{ + assert.Equal(t, map[string]any{ "version": "1.2.3", "env": "prod", }, vars) @@ -44,14 +48,62 @@ func TestExtraVars_Good_IgnoresMalformedPairs(t *testing.T) { core.Option{Key: "extra-vars", Value: "also_bad="}, ) - vars := extraVars(opts) + vars, err := extraVars(opts) + require.NoError(t, err) - assert.Equal(t, map[string]string{ + assert.Equal(t, map[string]any{ "keep": "this", "also_bad": "", }, vars) } +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") +} + func TestFirstString_Good_PrefersFirstNonEmptyKey(t *testing.T) { opts := core.NewOptions( core.Option{Key: "inventory", Value: ""},