Skip to content
Draft
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <duration>` (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

Expand Down
26 changes: 24 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
47 changes: 47 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
55 changes: 55 additions & 0 deletions internal/config/update_check_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 2 additions & 0 deletions internal/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Env struct {
JSON bool
GitHubToken string
MergeStrategy string
UpdateCheck string
}

// Init initializes environment variable configuration and returns the result.
Expand All @@ -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"),
}

}
21 changes: 21 additions & 0 deletions internal/update/mode.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 17 additions & 0 deletions internal/update/mode_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions internal/update/notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/update/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions test/integration/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
43 changes: 43 additions & 0 deletions test/integration/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading