From 857149ffb368301a9776abad53e9523834d7f983 Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Sun, 27 Sep 2026 20:47:42 +0100 Subject: [PATCH] feat: log only configured env vars environment.log now records only an allowlist of env var names, instead of the full process environment, resolved with precedence --log-env flag > LOADER_LOG_ENV_VARS CSV > ~/.config/loader/config.yaml (logging.env_vars) > none. If nothing is configured, environment.log is omitted entirely. --- README.md | 23 +++++++++- engine.go | 6 ++- go.mod | 1 + go.sum | 4 ++ logdir.go | 29 +++++++++---- logdir_test.go | 52 +++++++++++++++++++++++ main.go | 60 ++++++++++++++++++++++++++ main_test.go | 112 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 logdir_test.go create mode 100644 main_test.go diff --git a/README.md b/README.md index c329180..215433c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ loader [options] COMMAND [ARGS...] | `--no-tui` | | off | Force plain-text output instead of the fullscreen TUI | | `--log-dir` | | `.loader` | Directory to write per-run log files into (gets its own timestamped subfolder); also settable via `LOADER_LOG_DIR` | | `--no-log` | | off | Disable writing per-run log files | +| `--log-env` | | none | Env var name to record in `environment.log` (can be specified multiple times); also settable via `LOADER_LOG_ENV_VARS` or a config file | At least one of `--max-count` or `--duration` must be set, otherwise the tool runs until interrupted. @@ -85,7 +86,8 @@ Unless `--no-log` is set, each run writes its log files to a fresh timestamped subfolder (e.g. `2026-09-24T15-04-21Z`, UTC) under `--log-dir` (default `.loader`), so repeated runs never clobber each other: -- `environment.log` — the environment loader saw at startup, one `KEY=value` per line +- `environment.log` — only the env vars you've chosen to record (see below), one `KEY=value` + per line; omitted entirely if none are configured - `proc-N.log` — combined stdout/stderr for launched process `N` (matches `LOADER_RUN_ATTEMPT`) - `run.log` — one timestamped line per start/stop event, mirroring the TUI's recent-activity feed: @@ -96,6 +98,25 @@ timestamped subfolder (e.g. `2026-09-24T15-04-21Z`, UTC) under `--log-dir` - `summary.log` — the run's config followed by its final summary stats, written once the run finishes +### Choosing which env vars to record + +Which env vars end up in `environment.log` is resolved in this order (first match wins): + +1. `--log-env NAME` (can be specified multiple times) +2. `LOADER_LOG_ENV_VARS` — a comma-separated list, e.g. `LOADER_LOG_ENV_VARS=FOO,BAR` +3. `logging.env_vars` in `~/.config/loader/config.yaml`: + + ```yaml + logging: + env_vars: + - FOO + - BAR + ``` + +4. none — `environment.log` is omitted + +Named vars that aren't actually set are silently skipped. + ## Interactive mode In a terminal, `loader` runs as a fullscreen dashboard with: diff --git a/engine.go b/engine.go index 1a159cc..0b98f42 100644 --- a/engine.go +++ b/engine.go @@ -96,6 +96,10 @@ type Config struct { // LogDir is the resolved directory to write per-run log files into. // Empty means logging is disabled. LogDir string + + // LogEnvVars is the resolved list of environment variable names to + // record in environment.log. Empty means none are logged. + LogEnvVars []string } // LogLine is a single line of output emitted by a process, or a system @@ -412,7 +416,7 @@ type Engine struct { // up eagerly so StopLaunching/KillRunning are safe to call as soon as // NewEngine returns, even before Run's goroutine has started. func NewEngine(cfg Config) (*Engine, error) { - logger, err := newRunLogger(cfg.LogDir) + logger, err := newRunLogger(cfg.LogDir, cfg.LogEnvVars) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index 01754a6..317ae8d 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/urfave/cli/v3 v3.13.0 golang.org/x/term v0.46.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index 6545477..7b88f5a 100644 --- a/go.sum +++ b/go.sum @@ -53,3 +53,7 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logdir.go b/logdir.go index e48f331..c3574f0 100644 --- a/logdir.go +++ b/logdir.go @@ -27,9 +27,10 @@ type RunLogger struct { runLog *os.File } -// newRunLogger creates dir and writes environment.log into it. An empty dir -// means logging is disabled, and returns (nil, nil). -func newRunLogger(dir string) (*RunLogger, error) { +// newRunLogger creates dir and writes environment.log into it, containing +// only the names listed in envVars that are actually set. An empty dir means +// logging is disabled, and returns (nil, nil). +func newRunLogger(dir string, envVars []string) (*RunLogger, error) { if dir == "" { return nil, nil } @@ -41,7 +42,7 @@ func newRunLogger(dir string) (*RunLogger, error) { return nil, fmt.Errorf("create run log: %w", err) } l := &RunLogger{dir: dir, runLog: f} - if err := l.writeEnvironment(); err != nil { + if err := l.writeEnvironment(envVars); err != nil { return nil, err } return l, nil @@ -55,10 +56,22 @@ func (l *RunLogger) Dir() string { return l.dir } -func (l *RunLogger) writeEnvironment() error { - env := os.Environ() - sort.Strings(env) - return os.WriteFile(filepath.Join(l.dir, "environment.log"), []byte(strings.Join(env, "\n")+"\n"), 0o644) +// writeEnvironment writes environment.log containing "NAME=value" lines for +// each name in envVars that is set in the current process environment. Names +// that aren't set are silently omitted. If no lines result, the file is +// skipped entirely: no file means nothing was configured to be logged. +func (l *RunLogger) writeEnvironment(envVars []string) error { + var lines []string + for _, name := range envVars { + if v, ok := os.LookupEnv(name); ok { + lines = append(lines, name+"="+v) + } + } + if len(lines) == 0 { + return nil + } + sort.Strings(lines) + return os.WriteFile(filepath.Join(l.dir, "environment.log"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) } // processLogPath returns the path to the combined stdout/stderr file for diff --git a/logdir_test.go b/logdir_test.go new file mode 100644 index 0000000..eadebfa --- /dev/null +++ b/logdir_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteEnvironmentFiltersToNamedVars(t *testing.T) { + t.Setenv("LOADER_TEST_A", "1") + t.Setenv("LOADER_TEST_B", "2") + + dir := t.TempDir() + l := &RunLogger{dir: dir} + if err := l.writeEnvironment([]string{"LOADER_TEST_B", "LOADER_TEST_A", "LOADER_TEST_UNSET"}); err != nil { + t.Fatalf("writeEnvironment: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "environment.log")) + if err != nil { + t.Fatalf("read environment.log: %v", err) + } + want := "LOADER_TEST_A=1\nLOADER_TEST_B=2\n" + if string(got) != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestWriteEnvironmentSkipsFileWhenNoNames(t *testing.T) { + dir := t.TempDir() + l := &RunLogger{dir: dir} + if err := l.writeEnvironment(nil); err != nil { + t.Fatalf("writeEnvironment: %v", err) + } + assertNoEnvironmentLog(t, dir) +} + +func TestWriteEnvironmentSkipsFileWhenNamesAllUnset(t *testing.T) { + dir := t.TempDir() + l := &RunLogger{dir: dir} + if err := l.writeEnvironment([]string{"LOADER_TEST_DEFINITELY_UNSET"}); err != nil { + t.Fatalf("writeEnvironment: %v", err) + } + assertNoEnvironmentLog(t, dir) +} + +func assertNoEnvironmentLog(t *testing.T, dir string) { + t.Helper() + if _, err := os.Stat(filepath.Join(dir, "environment.log")); !os.IsNotExist(err) { + t.Fatalf("expected environment.log to be absent, stat error: %v", err) + } +} diff --git a/main.go b/main.go index 866913c..0e13ebc 100644 --- a/main.go +++ b/main.go @@ -5,10 +5,12 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/urfave/cli/v3" "golang.org/x/term" + "gopkg.in/yaml.v3" ) // outputMode decides how subprocess stdout/stderr should be handled: the TUI @@ -43,6 +45,52 @@ func resolveLogDir(override string) string { return filepath.Join(base, time.Now().UTC().Format(logDirTimeFormat)) } +// logEnvVarsConfig mirrors the "logging.env_vars" section of +// ~/.config/loader/config.yaml. +type logEnvVarsConfig struct { + Logging struct { + EnvVars []string `yaml:"env_vars"` + } `yaml:"logging"` +} + +// resolveLogEnvVars resolves the environment variable names to record in +// environment.log, in order of precedence: the --log-env flag (if given at +// all, it wins outright), else the LOADER_LOG_ENV_VARS CSV env var, else +// ~/.config/loader/config.yaml's logging.env_vars, else none. +func resolveLogEnvVars(flagValues []string) ([]string, error) { + if len(flagValues) > 0 { + return flagValues, nil + } + + if csv, ok := os.LookupEnv("LOADER_LOG_ENV_VARS"); ok { + var names []string + for _, name := range strings.Split(csv, ",") { + name = strings.TrimSpace(name) + if name != "" { + names = append(names, name) + } + } + return names, nil + } + + configDir, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("locate user config dir: %w", err) + } + data, err := os.ReadFile(filepath.Join(configDir, "loader", "config.yaml")) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read config.yaml: %w", err) + } + var cfg logEnvVarsConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config.yaml: %w", err) + } + return cfg.Logging.EnvVars, nil +} + func buildConfig(cmd *cli.Command, interactive bool) (Config, error) { args := cmd.Args().Slice() if len(args) == 0 { @@ -55,8 +103,15 @@ func buildConfig(cmd *cli.Command, interactive bool) (Config, error) { } var logDir string + var logEnvVars []string if !cmd.Bool("no-log") { logDir = resolveLogDir(cmd.String("log-dir")) + + var err error + logEnvVars, err = resolveLogEnvVars(cmd.StringSlice("log-env")) + if err != nil { + return Config{}, err + } } return Config{ @@ -67,6 +122,7 @@ func buildConfig(cmd *cli.Command, interactive bool) (Config, error) { TestDuration: cmd.Duration("duration"), OutputMode: outputMode(interactive, cmd.Bool("verbose")), LogDir: logDir, + LogEnvVars: logEnvVars, }, nil } @@ -140,6 +196,10 @@ func main() { Name: "no-log", Usage: "disable writing per-run log files", }, + &cli.StringSliceFlag{ + Name: "log-env", + Usage: "environment variable name to record in environment.log (repeatable); overrides LOADER_LOG_ENV_VARS and config.yaml", + }, }, Action: run, } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..7ff3d13 --- /dev/null +++ b/main_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// setUserConfigDir points os.UserConfigDir() at a subdirectory of dir and +// returns the resulting config dir, accounting for the env var UserConfigDir +// consults varying by OS. +func setUserConfigDir(t *testing.T, dir string) string { + t.Helper() + switch runtime.GOOS { + case "darwin": + t.Setenv("HOME", dir) + return filepath.Join(dir, "Library", "Application Support") + case "windows": + t.Setenv("AppData", dir) + return dir + default: + t.Setenv("XDG_CONFIG_HOME", dir) + return dir + } +} + +func TestResolveLogEnvVarsFlagWinsOverAll(t *testing.T) { + t.Setenv("LOADER_LOG_ENV_VARS", "FROM_ENV") + writeConfigYAML(t, "FROM_YAML") + + got, err := resolveLogEnvVars([]string{"FROM_FLAG"}) + if err != nil { + t.Fatalf("resolveLogEnvVars: %v", err) + } + if len(got) != 1 || got[0] != "FROM_FLAG" { + t.Fatalf("got %v, want [FROM_FLAG]", got) + } +} + +func TestResolveLogEnvVarsCSVEnvVarOverYAML(t *testing.T) { + t.Setenv("LOADER_LOG_ENV_VARS", " FOO , BAR ,,BAZ") + writeConfigYAML(t, "FROM_YAML") + + got, err := resolveLogEnvVars(nil) + if err != nil { + t.Fatalf("resolveLogEnvVars: %v", err) + } + want := []string{"FOO", "BAR", "BAZ"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestResolveLogEnvVarsFallsBackToYAML(t *testing.T) { + writeConfigYAML(t, "FROM_YAML") + + got, err := resolveLogEnvVars(nil) + if err != nil { + t.Fatalf("resolveLogEnvVars: %v", err) + } + if len(got) != 1 || got[0] != "FROM_YAML" { + t.Fatalf("got %v, want [FROM_YAML]", got) + } +} + +func TestResolveLogEnvVarsNoSourceReturnsEmpty(t *testing.T) { + setUserConfigDir(t, t.TempDir()) + + got, err := resolveLogEnvVars(nil) + if err != nil { + t.Fatalf("resolveLogEnvVars: %v", err) + } + if len(got) != 0 { + t.Fatalf("got %v, want empty", got) + } +} + +func TestResolveLogEnvVarsMalformedYAMLErrors(t *testing.T) { + configDir := setUserConfigDir(t, t.TempDir()) + configPath := filepath.Join(configDir, "loader") + if err := os.MkdirAll(configPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configPath, "config.yaml"), []byte("logging: [this is not a map"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := resolveLogEnvVars(nil); err == nil { + t.Fatal("expected an error for malformed config.yaml, got nil") + } +} + +// writeConfigYAML points os.UserConfigDir() at a fresh temp dir and writes a +// loader/config.yaml into it with the given env var name. +func writeConfigYAML(t *testing.T, envVar string) { + t.Helper() + configDir := setUserConfigDir(t, t.TempDir()) + configPath := filepath.Join(configDir, "loader") + if err := os.MkdirAll(configPath, 0o755); err != nil { + t.Fatal(err) + } + content := "logging:\n env_vars:\n - " + envVar + "\n" + if err := os.WriteFile(filepath.Join(configPath, "config.yaml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +}