From ea6020552790fe9d86040256b6fa81f4b983a2ff Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 27 Aug 2026 14:27:27 +0200 Subject: [PATCH] Add LSTK_UPDATE_CHECK to control the self-update check --- CLAUDE.md | 1 + cmd/root.go | 26 ++++++++++++- cmd/root_test.go | 47 ++++++++++++++++++++++++ internal/config/config.go | 9 +++++ internal/config/update_check_test.go | 55 ++++++++++++++++++++++++++++ internal/env/env.go | 2 + internal/update/mode.go | 21 +++++++++++ internal/update/mode_test.go | 17 +++++++++ internal/update/notify.go | 6 +++ internal/update/notify_test.go | 16 ++++++++ test/integration/env/env.go | 1 + test/integration/update_test.go | 43 ++++++++++++++++++++++ 12 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 internal/config/update_check_test.go create mode 100644 internal/update/mode.go create mode 100644 internal/update/mode_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0d416630..d720483b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,7 @@ Environment variables: - `LSTK_STARTUP_TIMEOUT` - Startup readiness deadline for `lstk start` (Go duration). Zero/unset uses the per-mode default resolved in `resolveStartupTimeout` (`internal/container/start.go`): 20s interactive (deadline only shows a recoverable keep-waiting/stop prompt, re-armed by "keep waiting"), 60s non-interactive (fatal; the container is left running for inspection). Container exits are detected separately — and instantly, with the exit code — via the exit wait `runtime.Runtime.Start` registers between create and start. `lstk start --timeout ` (also on the bare root) overrides this for a single run; the flag wins over the env var when explicitly set, and `--timeout 0` falls back to the per-mode default (`addTimeoutFlag`/`applyTimeoutFlag` in `cmd/root.go`). `restart` and the snapshot auto-start path do not expose the flag. - `LSTK_OTEL=1` - Enables OpenTelemetry trace export (disabled by default); when enabled, standard `OTEL_EXPORTER_OTLP_*` env vars are respected by the SDK. Requires an OTLP-compatible backend to receive and visualize telemetry — for local development, `make otel` starts one (UI at http://localhost:16686). - `LSTK_MERGE_STRATEGY` - Default merge strategy for `snapshot load` / `load` (`account-region-merge`, `overwrite`, or `service-merge`) when `--merge` is not passed; an explicit `--merge` always wins. Resolved in `resolveMergeStrategy` (`cmd/snapshot.go`). +- `LSTK_UPDATE_CHECK` - Overrides the self-update check `lstk start` runs on startup: `prompt` (ask interactively and wait), `notify` (print a one-line notice, don't wait), or `off` (no check at all). Takes precedence over the `cli.update_check` config.toml setting; when neither is set, the existing default applies (prompt when interactive, notice-only otherwise). Resolved in `resolveUpdateCheckMode` (`cmd/root.go`) and validated by `update.ValidateMode` (`internal/update/mode.go`). # Infrastructure as Code Commands diff --git a/cmd/root.go b/cmd/root.go index 12dfbabd..3b719b5d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -373,11 +373,17 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t opts := buildStartOptions(cfg, appConfig, logger, tel, persist) + updateCheckMode, err := resolveUpdateCheckMode(cfg.UpdateCheck, appConfig.CLI.UpdateCheck) + if err != nil { + return err + } + notifyOpts := update.NotifyOptions{ GitHubToken: cfg.GitHubToken, - UpdatePrompt: true, + UpdatePrompt: updateCheckMode != update.ModeNotify, SkippedVersion: appConfig.CLI.UpdateSkippedVersion, PersistSkipVersion: config.SetUpdateSkippedVersion, + Skip: updateCheckMode == update.ModeOff, } if isInteractiveMode(cfg) { @@ -401,7 +407,8 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t Text: fmt.Sprintf("Configured with default emulator %s.", emName), }) } - update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) + // PlainSink can't answer a prompt, so this always uses the plain notice. + update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken, Skip: notifyOpts.Skip}) resolvedVersion, err := container.Start(ctx, rt, sink, opts, false) if err != nil { return err @@ -652,6 +659,21 @@ func isInteractiveMode(cfg *env.Env) bool { return !cfg.NonInteractive && !cfg.JSON && ui.IsInteractive() } +// resolveUpdateCheckMode picks LSTK_UPDATE_CHECK over cli.update_check. +// Empty return means neither was set; caller keeps its own default. +func resolveUpdateCheckMode(envValue, configValue string) (string, error) { + for _, v := range []string{envValue, configValue} { + if v == "" { + continue + } + if err := update.ValidateMode(v); err != nil { + return "", err + } + return v, nil + } + return "", nil +} + const maxLogSize = 1 << 20 // 1 MB func newLogger() (log.Logger, func(), error) { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..12d86c65 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "testing" + + "github.com/localstack/lstk/internal/update" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveUpdateCheckMode checks the LSTK_UPDATE_CHECK > cli.update_check +// precedence: env var wins, and an empty result means neither was set. +func TestResolveUpdateCheckMode(t *testing.T) { + t.Run("neither set: unset", func(t *testing.T) { + mode, err := resolveUpdateCheckMode("", "") + require.NoError(t, err) + assert.Empty(t, mode) + }) + + t.Run("config value alone", func(t *testing.T) { + mode, err := resolveUpdateCheckMode("", update.ModeOff) + require.NoError(t, err) + assert.Equal(t, update.ModeOff, mode) + }) + + t.Run("env var alone", func(t *testing.T) { + mode, err := resolveUpdateCheckMode(update.ModeNotify, "") + require.NoError(t, err) + assert.Equal(t, update.ModeNotify, mode) + }) + + t.Run("env var wins over config value", func(t *testing.T) { + mode, err := resolveUpdateCheckMode(update.ModeOff, update.ModeNotify) + require.NoError(t, err) + assert.Equal(t, update.ModeOff, mode) + }) + + t.Run("invalid env value is rejected", func(t *testing.T) { + _, err := resolveUpdateCheckMode("bogus", "") + assert.Error(t, err) + }) + + t.Run("invalid config value is rejected", func(t *testing.T) { + _, err := resolveUpdateCheckMode("", "bogus") + assert.Error(t, err) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index e95040b2..7f22892d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" + "github.com/localstack/lstk/internal/update" "github.com/localstack/lstk/internal/validate" "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" @@ -19,6 +20,9 @@ var defaultConfigTemplate string type CLIConfig struct { UpdateSkippedVersion string `mapstructure:"update_skipped_version"` + // UpdateCheck overrides the self-update check: "prompt", "notify", or + // "off" (skips the check entirely). LSTK_UPDATE_CHECK wins if both are set. + UpdateCheck string `mapstructure:"update_check"` } type Config struct { @@ -188,6 +192,11 @@ func Get() (*Config, error) { if err := validateNamedEnvs(cfg.Env); err != nil { return nil, err } + if cfg.CLI.UpdateCheck != "" { + if err := update.ValidateMode(cfg.CLI.UpdateCheck); err != nil { + return nil, fmt.Errorf("invalid cli.update_check: %w", err) + } + } return &cfg, nil } diff --git a/internal/config/update_check_test.go b/internal/config/update_check_test.go new file mode 100644 index 00000000..805c5fa5 --- /dev/null +++ b/internal/config/update_check_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGet_UpdateCheckAcceptsKnownModes checks that prompt, notify, and off +// are valid cli.update_check values; anything else is rejected at load. +func TestGet_UpdateCheckAcceptsKnownModes(t *testing.T) { + // Cannot run in parallel: mutates process-wide viper state. + for _, mode := range []string{"prompt", "notify", "off"} { + t.Run(mode, func(t *testing.T) { + configFile := filepath.Join(t.TempDir(), configFileName) + require.NoError(t, os.WriteFile(configFile, []byte(` +[[containers]] +type = "aws" +port = "4566" + +[cli] +update_check = "`+mode+`" +`), 0600)) + + viper.Reset() + t.Cleanup(viper.Reset) + viper.SetConfigFile(configFile) + require.NoError(t, viper.ReadInConfig()) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, mode, cfg.CLI.UpdateCheck) + }) + } +} + +func TestGet_UpdateCheckRejectsUnknownMode(t *testing.T) { + configFile := filepath.Join(t.TempDir(), configFileName) + require.NoError(t, os.WriteFile(configFile, []byte(` +[cli] +update_check = "bogus" +`), 0600)) + + viper.Reset() + t.Cleanup(viper.Reset) + viper.SetConfigFile(configFile) + require.NoError(t, viper.ReadInConfig()) + + _, err := Get() + assert.ErrorContains(t, err, "cli.update_check") +} diff --git a/internal/env/env.go b/internal/env/env.go index 71534ea1..e808f524 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -25,6 +25,7 @@ type Env struct { JSON bool GitHubToken string MergeStrategy string + UpdateCheck string } // Init initializes environment variable configuration and returns the result. @@ -51,6 +52,7 @@ func Init() *Env { AnalyticsEndpoint: viper.GetString("analytics_endpoint"), GitHubToken: viper.GetString("github_token"), MergeStrategy: viper.GetString("merge_strategy"), + UpdateCheck: viper.GetString("update_check"), } } diff --git a/internal/update/mode.go b/internal/update/mode.go new file mode 100644 index 00000000..f608f8d7 --- /dev/null +++ b/internal/update/mode.go @@ -0,0 +1,21 @@ +package update + +import "fmt" + +// Update-check modes for LSTK_UPDATE_CHECK / config.toml's cli.update_check. +const ( + ModePrompt = "prompt" // ask interactively and wait + ModeNotify = "notify" // print a one-line notice, don't wait + ModeOff = "off" // skips the check entirely +) + +// ValidateMode reports whether mode is one of the modes above. Empty string +// is not valid here; callers treat that as "unset" themselves. +func ValidateMode(mode string) error { + switch mode { + case ModePrompt, ModeNotify, ModeOff: + return nil + default: + return fmt.Errorf("unknown update check mode %q: use prompt, notify, or off", mode) + } +} diff --git a/internal/update/mode_test.go b/internal/update/mode_test.go new file mode 100644 index 00000000..34821e79 --- /dev/null +++ b/internal/update/mode_test.go @@ -0,0 +1,17 @@ +package update + +import "testing" + +func TestValidateMode(t *testing.T) { + for _, mode := range []string{ModePrompt, ModeNotify, ModeOff} { + if err := ValidateMode(mode); err != nil { + t.Errorf("ValidateMode(%q) = %v, want nil", mode, err) + } + } +} + +func TestValidateModeRejectsUnknownValue(t *testing.T) { + if err := ValidateMode("bogus"); err == nil { + t.Error("ValidateMode(\"bogus\") = nil, want error") + } +} diff --git a/internal/update/notify.go b/internal/update/notify.go index 244420df..21aa89e9 100644 --- a/internal/update/notify.go +++ b/internal/update/notify.go @@ -16,6 +16,8 @@ type NotifyOptions struct { UpdatePrompt bool SkippedVersion string PersistSkipVersion func(version string) error + // Skip disables the check entirely: no network call, no output. + Skip bool } const checkTimeout = 2 * time.Second @@ -51,6 +53,10 @@ func NotifyUpdate(ctx context.Context, sink output.Sink, opts NotifyOptions) (ex } func notifyUpdateWithVersion(ctx context.Context, sink output.Sink, opts NotifyOptions, currentVersion string, fetch versionFetcher) (exitAfter bool) { + if opts.Skip { + return false + } + current, latest, available := checkQuietlyWithVersion(ctx, opts.GitHubToken, currentVersion, fetch) if !available { return false diff --git a/internal/update/notify_test.go b/internal/update/notify_test.go index 499b0916..8df6d0eb 100644 --- a/internal/update/notify_test.go +++ b/internal/update/notify_test.go @@ -92,6 +92,22 @@ func TestNotifyUpdateNoUpdateAvailable(t *testing.T) { assert.Empty(t, events) } +func TestNotifyUpdateSkipped(t *testing.T) { + called := false + fetch := func(ctx context.Context, token string) (string, error) { + called = true + return "v2.0.0", nil + } + + var events []output.Event + sink := output.SinkFunc(func(event output.Event) { events = append(events, event) }) + + exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{Skip: true, UpdatePrompt: true}, "1.0.0", fetch) + assert.False(t, exit) + assert.Empty(t, events, "no output should be emitted when the check is skipped") + assert.False(t, called, "the version fetch itself should be skipped, not just its result") +} + func TestNotifyUpdatePromptDisabled(t *testing.T) { server := newTestGitHubServer(t, "v2.0.0") defer server.Close() diff --git a/test/integration/env/env.go b/test/integration/env/env.go index b5766988..794cb277 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -25,6 +25,7 @@ const ( Otel Key = "LSTK_OTEL" OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT" StartupTimeout Key = "LSTK_STARTUP_TIMEOUT" + UpdateCheck Key = "LSTK_UPDATE_CHECK" // UpdateGitHubAPIEndpoint and UpdateGitHubDownloadEndpoint point the // updater's release-metadata API (api.github.com) and asset downloads // (github.com) at mock servers (undocumented, test-only). diff --git a/test/integration/update_test.go b/test/integration/update_test.go index b85722aa..6a2574d9 100644 --- a/test/integration/update_test.go +++ b/test/integration/update_test.go @@ -369,6 +369,49 @@ port = "4566" # Host port assert.Contains(t, configStr, `port = "4566"`, "existing config values should be preserved") }) + // "off" and "notify" only assert on the update-check step itself, which + // always runs and prints (or doesn't) before the Docker health check — + // they deliberately never wait on anything past that point (e.g. the + // license re-login prompt), since that requires a reachable Docker daemon + // and would hang/timeout on runners without one (e.g. Windows CI). + t.Run("off", func(t *testing.T) { + t.Parallel() + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, tmpBinary, "--config", configFile) + cmd.Env = env.Without(env.AuthToken).With(env.AuthToken, "fake-token").With(env.APIEndpoint, mockServer.URL).With(env.UpdateCheck, "off") + + p := startCmdInPTY(t, ctx, cmd) + // Nothing to wait for positively (off means no output); the 30s + // context bounds how long we give the update-check step to have + // already run and printed nothing before we inspect the output. + out, _ := p.wait() + assert.NotContains(t, out, "New lstk version available", "LSTK_UPDATE_CHECK=off must suppress the update check entirely") + assert.NotContains(t, out, "Update available", "LSTK_UPDATE_CHECK=off must suppress the update check entirely") + }) + + t.Run("notify", func(t *testing.T) { + t.Parallel() + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(""), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, tmpBinary, "--config", configFile) + cmd.Env = env.Without(env.AuthToken).With(env.AuthToken, "fake-token").With(env.APIEndpoint, mockServer.URL).With(env.UpdateCheck, "notify") + + p := startCmdInPTY(t, ctx, cmd) + p.waitForOutput("Update available: 0.0.1", "LSTK_UPDATE_CHECK=notify must show a passive notice instead of prompting") + + out, _ := p.wait() + assert.NotContains(t, out, "New lstk version available", "notify mode must never show the interactive prompt") + }) + t.Run("update", func(t *testing.T) { t.Parallel() // Copy binary since it will be replaced during the update