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{