Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions cli/cmd/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/dreadnode/dreadgoad/internal/config"
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions cli/internal/ansible/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions cli/internal/config/trace.go
Original file line number Diff line number Diff line change
@@ -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
}
134 changes: 134 additions & 0 deletions cli/internal/config/trace_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading