diff --git a/cmd/drift.go b/cmd/drift.go index 32ca160..3dfa4e8 100644 --- a/cmd/drift.go +++ b/cmd/drift.go @@ -7,7 +7,10 @@ import ( "github.com/spf13/cobra" ) -const configFlagUsage = "config file (default is .tusk/config.yaml)" +const ( + configFlagUsage = "config file (default is .tusk/config.yaml)" + configOverrideFlagUsage = "config override file (merges on top of the base config)" +) //go:embed short_docs/drift/drift_overview.md var driftOverviewContent string @@ -21,6 +24,7 @@ var driftCmd = &cobra.Command{ func init() { rootCmd.AddCommand(driftCmd) driftCmd.PersistentFlags().StringVar(&cfgFile, "config", "", configFlagUsage) + driftCmd.PersistentFlags().StringVar(&cfgOverrideFile, "config-override", "", configOverrideFlagUsage) } func bindLegacyDriftAliasConfigFlag(cmd *cobra.Command) { diff --git a/cmd/drift_query.go b/cmd/drift_query.go index cedd24d..5395504 100644 --- a/cmd/drift_query.go +++ b/cmd/drift_query.go @@ -28,6 +28,13 @@ func init() { // setupDriftQueryCloud sets up the API client and resolves the service ID. func setupDriftQueryCloud(serviceIDFlag string) (*api.TuskClient, api.AuthOptions, string, error) { + // Ensure config is loaded with override support before Get() is called by SetupCloud + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return nil, api.AuthOptions{}, "", fmt.Errorf("failed to load config: %w", err) + } + } + client, authOptions, cfg, err := api.SetupCloud(context.Background(), false) if err != nil { return nil, api.AuthOptions{}, "", err diff --git a/cmd/drift_query_services.go b/cmd/drift_query_services.go index 744180e..ec09064 100644 --- a/cmd/drift_query_services.go +++ b/cmd/drift_query_services.go @@ -2,8 +2,11 @@ package cmd import ( "context" + "fmt" + "os" "github.com/Use-Tusk/tusk-cli/internal/api" + "github.com/Use-Tusk/tusk-cli/internal/config" "github.com/spf13/cobra" ) @@ -12,6 +15,13 @@ var driftQueryServicesCmd = &cobra.Command{ Short: "List available Tusk Drift Cloud services", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + // Ensure config is loaded with override support before Get() is called by SetupCloud + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } + client, authOptions, _, err := api.SetupCloud(context.Background(), false) if err != nil { return formatApiError(err) diff --git a/cmd/list.go b/cmd/list.go index d54c0fd..2cc0d1e 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -69,7 +69,11 @@ func bindListFlags(cmd *cobra.Command) { func listTests(cmd *cobra.Command, args []string) error { setupSignalHandling() - _ = config.Load(cfgFile) + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } cfg, getConfigErr := config.Get() executor := runner.NewExecutor() @@ -124,7 +128,11 @@ func listTests(cmd *cobra.Command, args []string) error { } tests = runner.ConvertTraceTestsToRunnerTests(all) } else { - _ = config.Load("") + if err := config.Load("", cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } cfg, getConfigErr := config.Get() selected := traceDir diff --git a/cmd/root.go b/cmd/root.go index 7ce4166..4e825ba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,9 +19,10 @@ import ( ) var ( - cfgFile string - debug bool - showVersion bool + cfgFile string + cfgOverrideFile string + debug bool + showVersion bool // Cleanup infrastructure cleanupFuncs []func() diff --git a/cmd/run.go b/cmd/run.go index 4f724ca..39227b6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -157,7 +157,11 @@ func runTests(cmd *cobra.Command, args []string) error { executor := runner.NewExecutor() executor.SetDebug(debug) - _ = config.Load(cfgFile) + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } cfg, getConfigErr := config.Get() if getConfigErr == nil && cfg.TestExecution.Concurrency > 0 { executor.SetConcurrency(cfg.TestExecution.Concurrency) diff --git a/docs/drift/configuration.md b/docs/drift/configuration.md index 9e7dc8f..45115f2 100644 --- a/docs/drift/configuration.md +++ b/docs/drift/configuration.md @@ -2,14 +2,57 @@ This document lists all configuration options, defaults, environment overrides, and guidance. See [`docs/architecture.md`](architecture.md) for the end‑to‑end flow. -Where the CLI reads config from: +Where the CLI reads config from (highest precedence first): 1. CLI flags (e.g., `--concurrency`, `--results-dir`, `--enable-service-logs`). See `--help` for each command for more details. 2. Environment variables (prefix `TUSK_`) -3. Config file (auto-discovered): `.tusk/config.yaml`, `.tusk/config.yml`, `tusk.yaml`, `tusk.yml`, or `~/.tusk/config.yaml` +3. Config override file via `--config-override` flag or `TUSK_CONFIG_OVERRIDE` env var (flag takes precedence) +4. Base config file (auto-discovered): `.tusk/config.yaml`, `.tusk/config.yml`, `tusk.yaml`, `tusk.yml`, or `~/.tusk/config.yaml` **✨ Run `tusk drift setup` in your service root directory to start an agent automatically create a config file based on your service.** +## Config Overrides + +You can provide a config override file that is merged on top of the base config. Only the fields you specify in the override file are changed; everything else is preserved from the base config. + +This is useful for local-only settings like disabling span export or changing the sampling mode without modifying the shared config file. + +**Using the `--config-override` flag:** +```bash +tusk drift run --config-override .tusk/local-config.yaml +tusk drift list --config-override .tusk/local-config.yaml +``` + +**Using the `TUSK_CONFIG_OVERRIDE` env var:** +```bash +TUSK_CONFIG_OVERRIDE=.tusk/local-config.yaml tusk drift run +``` + +The `--config-override` flag takes precedence over the `TUSK_CONFIG_OVERRIDE` env var. Both are overridden by individual env vars (e.g., `TUSK_RECORDING_SAMPLING_MODE`). + +Example override file: + +```yaml +recording: + sampling: + mode: fixed + base_rate: 1.0 + export_spans: false + enable_env_var_recording: false +``` + +### Recording Environment Variable Overrides + +These env vars override the corresponding config keys regardless of what's in the config files: + +| Env var | Config key | +|---------|-----------| +| `TUSK_RECORDING_SAMPLING_MODE` | `recording.sampling.mode` | +| `TUSK_RECORDING_SAMPLING_RATE` | `recording.sampling.base_rate` (+ legacy `recording.sampling_rate`) | +| `TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS` | `recording.sampling.log_transitions` | +| `TUSK_RECORDING_EXPORT_SPANS` | `recording.export_spans` | +| `TUSK_ENABLE_ENV_VAR_RECORDING` | `recording.enable_env_var_recording` | + ## Service diff --git a/internal/config/config.go b/internal/config/config.go index 70d4880..fc5e7df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,9 +131,14 @@ type CoverageConfig struct { StripPathPrefix string `koanf:"strip_path_prefix"` } -// Load loads the config file and applies environment overrides. +// Load loads the config file, applies overrides, and applies environment overrides. // This function is idempotent - calling it multiple times will only load once. -func Load(configFile string) error { +// +// Config precedence (highest wins): +// 1. Environment variables (TUSK_*) +// 2. Override file (from --config-override flag or TUSK_CONFIG_OVERRIDE env var) +// 3. Base config file (.tusk/config.yaml) +func Load(configFile string, overrideFiles ...string) error { loadMutex.Lock() defer loadMutex.Unlock() @@ -158,6 +163,30 @@ func Load(configFile string) error { log.Debug("No config file found, using defaults and environment variables") } + // Determine override file: --config-override flag takes precedence over TUSK_CONFIG_OVERRIDE env var. + // The env var fallback is only checked when the caller explicitly passes the overrideFiles arg + // (even if empty). Callers like ValidateConfigFile that pass no variadic arg won't trigger env + // var lookup, preventing TUSK_CONFIG_OVERRIDE from interfering with validation. + var overridePath string + if len(overrideFiles) > 0 { + overridePath = overrideFiles[0] + if overridePath == "" { + overridePath = os.Getenv("TUSK_CONFIG_OVERRIDE") + } + } + + if overridePath != "" { + if _, err := os.Stat(overridePath); err == nil { // #nosec G703 -- path from trusted flag/env var + if err := k.Load(file.Provider(overridePath), yaml.Parser()); err != nil { + return fmt.Errorf("error loading config override file %s: %w", overridePath, err) + } + log.Debug("Config override file loaded", "file", overridePath) + configFileFound = true + } else { + return fmt.Errorf("config override file not found: %s", overridePath) + } + } + // Support environment variable overrides for specific config keys envOverrides := map[string]string{ "TUSK_TRACES_DIR": "traces.dir", @@ -167,6 +196,9 @@ func Load(configFile string) error { "TUSK_RESULTS_DIR": "results.dir", "TUSK_RECORDING_SAMPLING_RATE": "recording.sampling_rate", "TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS": "recording.sampling.log_transitions", + "TUSK_RECORDING_SAMPLING_MODE": "recording.sampling.mode", + "TUSK_RECORDING_EXPORT_SPANS": "recording.export_spans", + "TUSK_ENABLE_ENV_VAR_RECORDING": "recording.enable_env_var_recording", } for envKey, configKey := range envOverrides { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d1d4d5d..308129f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -99,6 +99,358 @@ recording: assert.False(t, *cfg.Recording.Sampling.LogTransitions) } +func TestConfigOverrideFileMergesOnTopOfBaseConfig(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + // Base config + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: my-service + port: 8080 + start: + command: npm start +recording: + sampling: + mode: adaptive + base_rate: 0.25 + export_spans: true + enable_env_var_recording: true +`), 0o600)) + + // Override file - only overrides recording settings + overrideFile := filepath.Join(tmpDir, "override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +recording: + sampling: + mode: fixed + base_rate: 1.0 + export_spans: false + enable_env_var_recording: false +`), 0o600)) + + require.NoError(t, Load(baseConfig, overrideFile)) + + cfg, err := Get() + require.NoError(t, err) + + // Recording settings should be overridden + assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.Sampling.BaseRate) + assert.Equal(t, 1.0, *cfg.Recording.Sampling.BaseRate) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) + require.NotNil(t, cfg.Recording.EnableEnvVarRecording) + assert.False(t, *cfg.Recording.EnableEnvVarRecording) + + // Service settings from base config should be preserved + assert.Equal(t, "my-service", cfg.Service.Name) + assert.Equal(t, 8080, cfg.Service.Port) + assert.Equal(t, "npm start", cfg.Service.Start.Command) +} + +func TestConfigOverrideFilePartialMerge(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base-service + port: 3000 + start: + command: npm start +`), 0o600)) + + // Override only changes one field + overrideFile := filepath.Join(tmpDir, "override.yml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +service: + name: override-service +`), 0o600)) + + require.NoError(t, Load(baseConfig, overrideFile)) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "override-service", cfg.Service.Name) + assert.Equal(t, 3000, cfg.Service.Port) +} + +func TestTuskConfigOverrideEnvVar(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base-service + port: 3000 + start: + command: npm start +recording: + sampling: + mode: adaptive +`), 0o600)) + + // External override file (not in .tusk/ directory) + overrideFile := filepath.Join(tmpDir, "my-override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +recording: + sampling: + mode: fixed + base_rate: 1.0 + export_spans: false +`), 0o600)) + + t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) + + // Pass explicit empty override to opt into env var fallback (simulates CLI invocation) + require.NoError(t, Load(baseConfig, "")) + + cfg, err := Get() + require.NoError(t, err) + + // Override file takes precedence over base config + assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.Sampling.BaseRate) + assert.Equal(t, 1.0, *cfg.Recording.Sampling.BaseRate) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) + + // Base config values preserved for non-overridden fields + assert.Equal(t, "base-service", cfg.Service.Name) +} + +func TestConfigOverrideFileNotFound(t *testing.T) { + defer Invalidate() + + err := Load("", "/nonexistent/override.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "config override file not found") +} + +func TestTuskConfigOverrideEnvVarFileNotFound(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_CONFIG_OVERRIDE", "/nonexistent/override.yaml") + + // Pass explicit empty override to opt into env var fallback (simulates CLI invocation) + err := Load("", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "config override file not found") +} + +func TestConfigOverrideFlagBeatsEnvVar(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base + port: 3000 + start: + command: npm start +recording: + sampling: + mode: adaptive +`), 0o600)) + + // TUSK_CONFIG_OVERRIDE env var sets mode to fixed + envOverrideFile := filepath.Join(tmpDir, "env-override.yaml") + require.NoError(t, os.WriteFile(envOverrideFile, []byte(` +recording: + sampling: + mode: fixed +`), 0o600)) + + // --config-override flag sets mode back to adaptive with different rate + flagOverrideFile := filepath.Join(tmpDir, "flag-override.yaml") + require.NoError(t, os.WriteFile(flagOverrideFile, []byte(` +recording: + sampling: + mode: adaptive + base_rate: 0.5 +`), 0o600)) + + t.Setenv("TUSK_CONFIG_OVERRIDE", envOverrideFile) + + // Flag override (passed as second arg) beats TUSK_CONFIG_OVERRIDE env var + require.NoError(t, Load(baseConfig, flagOverrideFile)) + + cfg, err := Get() + require.NoError(t, err) + + assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.Sampling.BaseRate) + assert.Equal(t, 0.5, *cfg.Recording.Sampling.BaseRate) +} + +func TestRecordingSamplingModeEnvOverride(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_RECORDING_SAMPLING_MODE", "fixed") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +recording: + sampling: + mode: adaptive + base_rate: 0.25 +`), 0o600)) + + require.NoError(t, Load(configPath)) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) +} + +func TestRecordingExportSpansEnvOverride(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_RECORDING_EXPORT_SPANS", "false") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +recording: + export_spans: true +`), 0o600)) + + require.NoError(t, Load(configPath)) + + cfg, err := Get() + require.NoError(t, err) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) +} + +func TestEnableEnvVarRecordingEnvOverride(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_ENABLE_ENV_VAR_RECORDING", "false") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +recording: + enable_env_var_recording: true +`), 0o600)) + + require.NoError(t, Load(configPath)) + + cfg, err := Get() + require.NoError(t, err) + require.NotNil(t, cfg.Recording.EnableEnvVarRecording) + assert.False(t, *cfg.Recording.EnableEnvVarRecording) +} + +func TestEnvVarOverrideBeatsAllConfigFiles(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: test + port: 3000 + start: + command: npm start +recording: + sampling: + mode: adaptive + export_spans: true +`), 0o600)) + + overrideFile := filepath.Join(tmpDir, "override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +recording: + sampling: + mode: fixed +`), 0o600)) + + // Env vars win over everything + t.Setenv("TUSK_RECORDING_SAMPLING_MODE", "adaptive") + t.Setenv("TUSK_RECORDING_EXPORT_SPANS", "false") + + require.NoError(t, Load(baseConfig, overrideFile)) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) +} + +func TestConfigOverrideViaEnvVar(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + baseConfig := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base + port: 3000 + start: + command: npm start +`), 0o600)) + + overrideFile := filepath.Join(tmpDir, "override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +service: + name: overridden +`), 0o600)) + + t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) + + // Pass explicit empty override to opt into env var fallback (simulates --config-override not set) + require.NoError(t, Load(baseConfig, "")) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "overridden", cfg.Service.Name) + assert.Equal(t, 3000, cfg.Service.Port) +} + +func TestValidateConfigFileIgnoresTuskConfigOverrideEnvVar(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + baseConfig := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: test + port: 3000 + start: + command: npm start +`), 0o600)) + + // Set TUSK_CONFIG_OVERRIDE to a nonexistent file — ValidateConfigFile should NOT fail + t.Setenv("TUSK_CONFIG_OVERRIDE", "/nonexistent/override.yaml") + + result := ValidateConfigFile(baseConfig) + assert.True(t, result.Valid, "ValidateConfigFile should not be affected by TUSK_CONFIG_OVERRIDE env var") +} + func TestValidateRejectsInvalidRecordingSamplingMode(t *testing.T) { cfg := &Config{ Service: ServiceConfig{