diff --git a/cli/cmd/config_cmd.go b/cli/cmd/config_cmd.go index 5dc98aef..9523e524 100644 --- a/cli/cmd/config_cmd.go +++ b/cli/cmd/config_cmd.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/dreadnode/dreadgoad/internal/config" @@ -134,11 +135,116 @@ var configSetCmd = &cobra.Command{ }, } +var configTraceCmd = &cobra.Command{ + Use: "trace", + Short: "Show where each configuration value comes from", + Long: `Displays every configuration key with its effective value and the +source that provided it (cli flag, env var, config file, or default). + +Also shows the Ansible environment variables and extra-vars that Go +injects at runtime, highlighting overlaps with ansible.cfg.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + // Collect which persistent flags were explicitly set on the CLI. + changedFlags := make(map[string]bool) + for _, name := range []string{"env", "region", "debug", "config"} { + if f := cmd.Root().PersistentFlags().Lookup(name); f != nil && f.Changed { + changedFlags[name] = true + } + } + + // --- Section 1: Go/Viper config --- + entries := config.TraceConfig(cfg, changedFlags) + + fmt.Println("Go/Viper Configuration") + fmt.Println("======================") + if cfgFile := viper.ConfigFileUsed(); cfgFile != "" { + fmt.Printf("Config file: %s\n", cfgFile) + } else { + fmt.Println("Config file: (none found)") + } + fmt.Println("Precedence: cli flag > env var (DREADGOAD_*) > config file > default") + fmt.Println() + + maxKey, maxVal := 0, 0 + for _, e := range entries { + if len(e.Key) > maxKey { + maxKey = len(e.Key) + } + if len(e.Value) > maxVal { + maxVal = len(e.Value) + } + } + for _, e := range entries { + fmt.Printf(" %-*s = %-*s [%s]\n", maxKey, e.Key, maxVal, e.Value, e.Source) + } + + // --- Section 2: Ansible env vars --- + fmt.Println() + fmt.Println("Ansible Environment Variables (injected by Go)") + fmt.Println("===============================================") + ansibleEnv, err := cfg.AnsibleEnv() + if err != nil { + return err + } + envKeys := make([]string, 0, len(ansibleEnv)) + for k := range ansibleEnv { + envKeys = append(envKeys, k) + } + sort.Strings(envKeys) + for _, k := range envKeys { + origin := "hardcoded" + switch k { + case "ANSIBLE_CONFIG": + origin = "derived: project_root" + case "ANSIBLE_CACHE_PLUGIN_CONNECTION": + origin = "derived: env" + } + fmt.Printf(" %-38s = %-45s [%s]\n", k, ansibleEnv[k], origin) + } + + // --- Section 3: Ansible extra-vars --- + fmt.Println() + fmt.Println("Ansible Extra-Vars (injected by Go at runtime)") + fmt.Println("===============================================") + labConfig := cfg.LabConfigPath() + if _, statErr := os.Stat(labConfig); statErr == nil { + fmt.Printf(" @%s\n", labConfig) + fmt.Println(" [derived: env + project_root]") + } else { + fmt.Printf(" @%s (not found)\n", labConfig) + } + fmt.Println(" ansible_facts_gathering_timeout=60 [hardcoded]") + fmt.Println() + fmt.Println(" Note: Error-specific retry logic may add extra-vars at runtime.") + fmt.Println(" Run with --debug to see exact ansible-playbook invocations.") + + // --- Section 4: ansible.cfg overlaps --- + fmt.Println() + fmt.Println("ansible.cfg Overlaps") + fmt.Println("====================") + fmt.Printf(" Path: %s\n\n", cfg.AnsibleCfgPath()) + fmt.Println(" ansible.cfg setting overridden by") + fmt.Println(" ─────────────────────────────── ───────────────────────────────────────────") + fmt.Println(" fact_caching_connection ANSIBLE_CACHE_PLUGIN_CONNECTION (always)") + fmt.Println(" host_key_checking ANSIBLE_HOST_KEY_CHECKING (always)") + fmt.Println(" gathering ANSIBLE_GATHERING (retry: fact-gathering)") + fmt.Println(" timeout ANSIBLE_TIMEOUT (retry: SSM/reconnection)") + + return nil + }, +} + func init() { rootCmd.AddCommand(configCmd) configCmd.AddCommand(configShowCmd) configCmd.AddCommand(configInitCmd) configCmd.AddCommand(configSetCmd) + configCmd.AddCommand(configTraceCmd) } func valueOrDefault(v, def string) string { diff --git a/cli/internal/ansible/runner.go b/cli/internal/ansible/runner.go index 9218608e..a332f646 100644 --- a/cli/internal/ansible/runner.go +++ b/cli/internal/ansible/runner.go @@ -61,6 +61,7 @@ func RunPlaybook(ctx context.Context, opts RunOptions) *RunResult { } slog.Info("running playbook", "playbook", opts.Playbook, "args", strings.Join(args, " ")) + logRunOptions(opts) cmd := exec.CommandContext(ctx, "ansible-playbook", args...) cmd.Env = env @@ -245,6 +246,17 @@ func fileExists(path string) bool { return err == nil } +// logRunOptions emits debug-level logs for extra-vars and env vars being +// passed to ansible-playbook, making it easy to trace variable sources. +func logRunOptions(opts RunOptions) { + if len(opts.ExtraVars) > 0 { + slog.Debug("ansible extra-vars from Go", "vars", opts.ExtraVars) + } + if len(opts.ExtraEnv) > 0 { + slog.Debug("ansible extra env vars from Go", "vars", opts.ExtraEnv) + } +} + func killProcessGroup(pid int) { pgid, err := syscall.Getpgid(pid) if err == nil { diff --git a/cli/internal/config/trace.go b/cli/internal/config/trace.go new file mode 100644 index 00000000..25a3cab1 --- /dev/null +++ b/cli/internal/config/trace.go @@ -0,0 +1,99 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/viper" +) + +// TraceEntry represents a configuration key with its resolved value and source. +type TraceEntry struct { + Key string + Value string + Source string +} + +// TraceConfig returns trace entries for the main config keys, showing each +// key's effective value and which layer provided it. +func TraceConfig(cfg *Config, changedFlags map[string]bool) []TraceEntry { + fileKeys := configFileKeys() + cfgFile := viper.ConfigFileUsed() + + type item struct { + key string + value string + } + + items := []item{ + {"env", cfg.Env}, + {"region", cfg.Region}, + {"debug", fmt.Sprintf("%v", cfg.Debug)}, + {"max_retries", fmt.Sprintf("%d", cfg.MaxRetries)}, + {"retry_delay", fmt.Sprintf("%ds", cfg.RetryDelay)}, + {"idle_timeout", fmt.Sprintf("%ds", cfg.IdleTimeout)}, + {"log_dir", cfg.LogDir}, + {"project_root", cfg.ProjectRoot}, + {"infra.deployment", cfg.Infra.Deployment}, + {"infra.terragrunt_binary", cfg.Infra.TerragruntBinary}, + {"infra.terraform_binary", cfg.Infra.TerraformBinary}, + } + + var entries []TraceEntry + for _, it := range items { + value := it.value + if value == "" { + value = "(unset)" + } + source := resolveSource(it.key, changedFlags, fileKeys, cfgFile) + entries = append(entries, TraceEntry{Key: it.key, Value: value, Source: source}) + } + return entries +} + +// resolveSource determines the layer that provided a viper key's current value. +// Precedence: cli flag > env var > config file > auto-detected > default. +func resolveSource(key string, changedFlags map[string]bool, fileKeys map[string]bool, _ string) string { + if changedFlags[key] { + return "cli flag (--" + viperKeyToFlag(key) + ")" + } + envKey := "DREADGOAD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + if _, ok := os.LookupEnv(envKey); ok { + return "env var (" + envKey + ")" + } + if fileKeys[key] { + return "config file" + } + // Auto-detected values: the Config struct has a value but viper doesn't. + if key == "project_root" || key == "log_dir" { + if viper.GetString(key) == "" { + return "auto-detected" + } + } + return "default" +} + +// viperKeyToFlag converts a Viper key to its CLI flag form. +func viperKeyToFlag(key string) string { + return strings.ReplaceAll(key, "_", "-") +} + +// configFileKeys parses the config file in isolation (no defaults, no env +// binding) and returns the set of flattened keys actually present in the file. +func configFileKeys() map[string]bool { + cfgFile := viper.ConfigFileUsed() + if cfgFile == "" { + return nil + } + v := viper.New() + v.SetConfigFile(cfgFile) + if err := v.ReadInConfig(); err != nil { + return nil + } + keys := make(map[string]bool, len(v.AllKeys())) + for _, k := range v.AllKeys() { + keys[k] = true + } + return keys +} diff --git a/cli/internal/config/trace_test.go b/cli/internal/config/trace_test.go new file mode 100644 index 00000000..226ab622 --- /dev/null +++ b/cli/internal/config/trace_test.go @@ -0,0 +1,134 @@ +package config + +import ( + "os" + "testing" +) + +func TestResolveSource(t *testing.T) { + fileKeys := map[string]bool{"region": true, "env": true} + cfgFile := "/tmp/dreadgoad.yaml" + + tests := []struct { + name string + key string + changedFlags map[string]bool + envVar string + envVal string + wantContains string + }{ + { + name: "cli flag takes precedence", + key: "env", + changedFlags: map[string]bool{"env": true}, + wantContains: "cli flag", + }, + { + name: "env var takes precedence over config file", + key: "region", + changedFlags: map[string]bool{}, + envVar: "DREADGOAD_REGION", + envVal: "us-west-2", + wantContains: "env var", + }, + { + name: "config file when no flag or env", + key: "region", + changedFlags: map[string]bool{}, + wantContains: "config file", + }, + { + name: "default when not in any source", + key: "max_retries", + changedFlags: map[string]bool{}, + wantContains: "default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVar != "" { + t.Setenv(tt.envVar, tt.envVal) + } else { + // Ensure the env var is not set from a prior test + if err := os.Unsetenv("DREADGOAD_REGION"); err != nil { + t.Fatalf("failed to unset env var: %v", err) + } + } + got := resolveSource(tt.key, tt.changedFlags, fileKeys, cfgFile) + if got == "" { + t.Fatal("resolveSource returned empty string") + } + found := false + if len(tt.wantContains) > 0 { + for i := 0; i <= len(got)-len(tt.wantContains); i++ { + if got[i:i+len(tt.wantContains)] == tt.wantContains { + found = true + break + } + } + } + if !found { + t.Errorf("resolveSource(%q) = %q, want to contain %q", tt.key, got, tt.wantContains) + } + }) + } +} + +func TestTraceConfig(t *testing.T) { + cfg := &Config{ + Env: "staging", + Region: "us-east-1", + Debug: false, + MaxRetries: 3, + RetryDelay: 30, + IdleTimeout: 1200, + LogDir: "/tmp/logs", + ProjectRoot: "/opt/goad", + Infra: InfraConfig{ + Deployment: "goad-deployment", + TerragruntBinary: "terragrunt", + TerraformBinary: "tofu", + }, + } + + entries := TraceConfig(cfg, map[string]bool{}) + if len(entries) == 0 { + t.Fatal("TraceConfig returned no entries") + } + + // Verify all expected keys are present. + gotKeys := make(map[string]bool) + for _, e := range entries { + gotKeys[e.Key] = true + if e.Value == "" { + t.Errorf("entry %q has empty value", e.Key) + } + if e.Source == "" { + t.Errorf("entry %q has empty source", e.Key) + } + } + + for _, want := range []string{"env", "region", "debug", "max_retries", "project_root"} { + if !gotKeys[want] { + t.Errorf("TraceConfig missing key %q", want) + } + } +} + +func TestViperKeyToFlag(t *testing.T) { + tests := []struct { + key string + want string + }{ + {"env", "env"}, + {"max_retries", "max-retries"}, + {"infra.deployment", "infra.deployment"}, + } + for _, tt := range tests { + got := viperKeyToFlag(tt.key) + if got != tt.want { + t.Errorf("viperKeyToFlag(%q) = %q, want %q", tt.key, got, tt.want) + } + } +}