diff --git a/CLAUDE.md b/CLAUDE.md index 698cc66f..b1569867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ Notes: - `env/` - Process environment snapshot/injection helper (also used to isolate test envs) - `extension/` - Git-style `lstk-` extension resolution and exec - `iac/` - Wrappers for third-party infrastructure as code tools (`terraform/`, `cdk/`, `sam/`) + - `inspect/` - `lstk inspect` domain logic (App Inspector on/off; `--persist` saves it to config) - `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that) - `output/` - Generic event and sink abstractions for CLI/TUI/non-interactive rendering - `ports/` - Port availability checks @@ -79,7 +80,7 @@ Notes: - `version/` - Version info - `volume/` - `lstk volume` domain logic -Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch). +Commands are registered in `cmd/root.go` in two Cobra groups: the `commands` group (start, stop, restart, login, logout, status, logs, inspect, setup, config, volume, update, docs, snapshot, reset, save, load) and the `tools` group of proxy commands (aws, terraform/tf, cdk, sam, az). Shared helpers: `cmd/root.go` (wiring, groups, `requireSubcommand`, `initConfig`), `cmd/help.go` (help template), `cmd/iac.go` (IaC command boundary), `cmd/extension.go` (extension dispatch). ## Container runtime discovery @@ -144,7 +145,7 @@ There is no `--offline` flag. Instead `container.Start` degrades gracefully when # Targeting an External Emulator (`--endpoint-url`) -`--endpoint-url ` (a root persistent flag) lets `aws`, `az`, `terraform`/`tf`, `cdk`, `sam`, `snapshot save`/`load` (incl. the `lstk save`/`lstk load` aliases), `snapshot remove`, `snapshot list s3://...`, `reset`, and `status` target an emulator lstk did not start — docker compose, host-network mode, CI, a different machine, or a LocalStack cloud-hosted **ephemeral instance** — instead of discovering one via local Docker. `LSTK_ENDPOINT_URL` is the equivalent environment variable. Both `http://` and `https://` are accepted (any other scheme is rejected); the resolved scheme is preserved end-to-end all the way to whatever ultimately makes the request — the wrapped `aws`/`terraform`/`cdk`/`sam` tools, and the emulator API calls behind `snapshot`/`reset`/`status` — never normalized to `http`, which is what makes `https://` ephemeral instances work. +`--endpoint-url ` (a root persistent flag) lets `aws`, `az`, `terraform`/`tf`, `cdk`, `sam`, `snapshot save`/`load` (incl. the `lstk save`/`lstk load` aliases), `snapshot remove`, `snapshot list s3://...`, `reset`, `inspect` (not `--persist`), and `status` target an emulator lstk did not start — docker compose, host-network mode, CI, a different machine, or a LocalStack cloud-hosted **ephemeral instance** — instead of discovering one via local Docker. `LSTK_ENDPOINT_URL` is the equivalent environment variable. Both `http://` and `https://` are accepted (any other scheme is rejected); the resolved scheme is preserved end-to-end all the way to whatever ultimately makes the request — the wrapped `aws`/`terraform`/`cdk`/`sam` tools, and the emulator API calls behind `snapshot`/`reset`/`status` — never normalized to `http`, which is what makes `https://` ephemeral instances work. `lstk logs`, `stop`, `restart`, `volume` (`path`/`clear`), `start`, and the bare `lstk` root command (which starts the emulator the same way `start` does) have no remote equivalent and reject *any* resolved endpoint source. diff --git a/README.md b/README.md index ec731643..a2491121 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Running `lstk` will automatically handle authentication, configuration, and cont - **Interactive TUI** — a Bubble Tea-powered terminal UI in interactive terminals, plain output for CI/CD and scripting - **Browser-based login** — authenticate via browser and store credentials securely in the system keyring, or use `LOCALSTACK_AUTH_TOKEN` for CI (it takes precedence over stored credentials) - **Snapshots** — save, load, and manage emulator state as local files, cloud snapshots, or in your own S3 bucket +- **App Inspector**: `lstk inspect enable` turns on App Inspector for the AWS emulator; add `--persist` to keep it on after restarts - **Cloud CLI proxies** — run `aws`, `az`, `terraform`, `cdk`, and `sam` commands against LocalStack with the endpoint, credentials, and region pre-configured - **Target an external emulator** — pass `--endpoint-url ` (or set `LSTK_ENDPOINT_URL`) to point most commands at an already-running LocalStack instance — docker compose, host-network mode, CI, a different machine, or a cloud-hosted ephemeral instance (`https://` is supported) — instead of one lstk manages locally - **Extensions** — Git-style `lstk-` executables extend the CLI with new commands; see [extension authoring](https://github.com/localstack/lstk/blob/main/docs/extensions-authoring.md) diff --git a/cmd/inspect.go b/cmd/inspect.go new file mode 100644 index 00000000..bb326069 --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/emulator/aws" + "github.com/localstack/lstk/internal/endpoint" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/inspect" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/ui" + "github.com/spf13/cobra" +) + +func newInspectCmd(cfg *env.Env) *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect", + Short: "Turn App Inspector on or off", + Long: "Turn App Inspector on or off for the AWS emulator. App Inspector records the operations your application sends to the emulator, with their payloads, the services they connect, and IAM permission results. View them in the LocalStack Web Application or the LocalStack Toolkit for VS Code.", + } + requireSubcommand(cmd) + cmd.AddCommand(newInspectSetCmd(cfg, true), newInspectSetCmd(cfg, false), newInspectStatusCmd(cfg)) + return cmd +} + +func newInspectSetCmd(cfg *env.Env, enabled bool) *cobra.Command { + var persist bool + use, title, state := "enable", "Enable", "enabled" + if !enabled { + use, title, state = "disable", "Disable", "disabled" + } + + cmd := &cobra.Command{ + Use: use, + Short: title + " App Inspector", + Long: title + " App Inspector on the running AWS emulator.\n\n" + + "The change lasts until the emulator restarts. With --persist, lstk also saves it to the config file (as APP_INSPECTOR in an [env.app-inspector] profile), so App Inspector is " + state + " on every start. --persist works while the emulator is stopped too.", + Args: cobra.NoArgs, + PreRunE: initConfigDeferCreate(nil), + Annotations: map[string]string{jsonSupportedAnnotation: "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + sink := jsonAwareSink(cmd, cfg, os.Stdout) + t, err := resolveInspectTarget(cmd, cfg, sink, persist) + if err != nil { + return err + } + if isInteractiveMode(cfg) { + return ui.RunInspectSet(cmd.Context(), t, enabled, persist) + } + return inspect.Set(cmd.Context(), sink, t, enabled, persist) + }, + } + cmd.Flags().BoolVar(&persist, "persist", false, "Also save the setting to the config file so it applies on every start") + return cmd +} + +func newInspectStatusCmd(cfg *env.Env) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether App Inspector is enabled", + Args: cobra.NoArgs, + PreRunE: initConfigDeferCreate(nil), + Annotations: map[string]string{jsonSupportedAnnotation: "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + sink := jsonAwareSink(cmd, cfg, os.Stdout) + t, err := resolveInspectTarget(cmd, cfg, sink, false) + if err != nil { + return err + } + if isInteractiveMode(cfg) { + return ui.RunInspectStatus(cmd.Context(), t) + } + return inspect.Status(cmd.Context(), sink, t) + }, + } +} + +func resolveInspectTarget(cmd *cobra.Command, cfg *env.Env, sink output.Sink, persist bool) (inspect.Target, error) { + fail := func(e output.ErrorEvent) (inspect.Target, error) { + sink.Emit(e) + return inspect.Target{}, output.NewSilentError(errors.New(e.Title)) + } + + remote, err := endpoint.Resolve(cmd.Context(), cmd) + if err != nil { + return fail(output.ErrorEvent{Title: err.Error(), Code: output.ErrEmulatorNotRunning}) + } + if remote != nil { + if persist { + source, _, _ := endpoint.ResolvedSource(cmd) + return fail(output.ErrorEvent{ + Title: "--persist cannot be used with " + source, + Summary: "--persist saves to the lstk config, which does not start the emulator at " + remote.URL + ".", + Code: output.ErrValidationError, + }) + } + if remote.Type != config.EmulatorAWS { + return fail(output.ErrorEvent{ + Title: fmt.Sprintf("App Inspector is only available for the AWS emulator, but the endpoint at %s is a %s", remote.URL, remote.Type.DisplayName()), + Code: output.ErrEmulatorWrongType, + }) + } + c := config.ContainerConfig{Type: config.EmulatorAWS, Port: config.DefaultPort} + return inspect.Target{Runtime: runtime.NewExternalRuntime(c.Name()), Containers: []config.ContainerConfig{c}, Client: aws.NewClient(), Host: remote.URL}, nil + } + + appConfig, err := config.Get() + if err != nil { + return inspect.Target{}, failGetConfig(sink, cfg, err) + } + var awsContainer *config.ContainerConfig + for i := range appConfig.Containers { + if appConfig.Containers[i].Type == config.EmulatorAWS { + awsContainer = &appConfig.Containers[i] + break + } + } + if awsContainer == nil { + return fail(output.ErrorEvent{Title: "App Inspector is only available for the AWS emulator", Code: output.ErrEmulatorNotConfigured}) + } + if persist && !config.HasFile() { + return fail(output.ErrorEvent{ + Title: "No config file to save the setting to yet", + Actions: []output.ErrorAction{{Label: "Create it by starting the emulator once:", Value: "lstk start"}}, + Code: output.ErrConfigNotFound, + }) + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return inspect.Target{}, err + } + host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) + path, err := config.FriendlyConfigPath() + if err != nil { + path = "the config file" + } + return inspect.Target{ + Runtime: rt, + Containers: []config.ContainerConfig{*awsContainer}, + Client: aws.NewClient(), + Host: "http://" + host, + ConfigPath: path, + }, nil +} diff --git a/cmd/root.go b/cmd/root.go index ea453039..fb0be151 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -200,6 +200,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newLogoutCmd(cfg, logger), newStatusCmd(cfg), newLogsCmd(cfg), + newInspectCmd(cfg), newSetupCmd(cfg), newConfigCmd(), newVolumeCmd(cfg), diff --git a/docs/structured-output.md b/docs/structured-output.md index 4b8ae659..d7e8d5ef 100644 --- a/docs/structured-output.md +++ b/docs/structured-output.md @@ -8,7 +8,7 @@ The contract described here (a shared envelope shape, a shared error-code vocabu `--json` support is being rolled out per command, not all at once. The [Command Catalog](#command-catalog) below is split into two parts: -- **[Implemented](#implemented)** — `stop`, `reset`, `update`, `start`, `status`. These accept `--json` today and produce exactly the shapes documented below. +- **[Implemented](#implemented)** — `stop`, `reset`, `update`, `start`, `status`, `inspect enable`/`disable`/`status`. These accept `--json` today and produce exactly the shapes documented below. - **[Proposed for future work](#proposed-for-future-work-draft)** — every other built-in command. Attempting `--json` on any of these today is rejected with `NOT_JSON_CAPABLE`. This part is a **first-draft proposal only** — see the warning at the top of that section before relying on any of it. ## The envelope @@ -318,6 +318,23 @@ Codes: `RUNTIME_UNAVAILABLE`, `AUTH_REQUIRED`, `LICENSE_INVALID` (bad/expired/re ``` Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `CONFIG_INVALID`, `CONFIG_NOT_FOUND` (bad or missing `--config` path). +**`lstk inspect enable`** / **`disable`** / **`status`**: App Inspector's state on the running emulator. `changed` is whether this command changed it (always `false` for `status`). With `--persist`, `saved` reports the setting in the config file. When `--persist` runs while the emulator is stopped, `enabled` and `changed` are omitted. +```json +{ + "schemaVersion": 1, + "command": "inspect enable", + "status": "ok", + "data": { + "enabled": true, + "changed": true, + "saved": {"enabled": true, "changed": true} + }, + "warnings": [], + "error": null +} +``` +Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `EMULATOR_NOT_CONFIGURED`, `EMULATOR_WRONG_TYPE`, `VALIDATION_ERROR` (`--persist` with an endpoint URL), `CONFIG_NOT_FOUND` (`--persist` with no config file yet), `CONFIG_INVALID`. + ### Proposed for future work (draft) > **This section is a first-draft proposal only, not a committed contract.** None of the commands below accept `--json` yet — every one of them is rejected with `NOT_JSON_CAPABLE` today. The shapes shown are a starting point for design discussion, included here in full so the whole intended surface can be reviewed at once rather than piecemeal across many small follow-up PRs. Expect fields, error codes, and possibly the overall approach for any of these to change based on human feedback before implementation — treat everything below as a proposal to critique, not a spec to build against. diff --git a/internal/config/env_profile.go b/internal/config/env_profile.go new file mode 100644 index 00000000..e5544b4f --- /dev/null +++ b/internal/config/env_profile.go @@ -0,0 +1,130 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "os" + "regexp" + "strings" +) + +var ( + envListRe = regexp.MustCompile(`(?m)^[ \t]*env[ \t]*=[ \t]*\[`) + quotedRe = regexp.MustCompile(`"[^"\n]*"|'[^'\n]*'`) +) + +// SetEnvProfileVar sets key in [env.] and lists that profile last in +// the first [[containers]] block, so it wins. The file is restored if the value +// does not take effect. +func SetEnvProfileVar(profile, key, value string) (bool, error) { + path := resolvedConfigPath() + if path == "" { + return false, errors.New("no config file to write to yet") + } + original, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("failed to read config file: %w", err) + } + updated, err := addEnvReference(original, profile) + if err != nil { + return false, err + } + updated = setProfileVar(updated, profile, key, value) + if bytes.Equal(updated, original) { + return false, nil + } + if err := os.WriteFile(path, updated, 0644); err != nil { + return false, fmt.Errorf("failed to write config file: %w", err) + } + if err := checkResolvedEnv(path, key, value); err != nil { + if restoreErr := os.WriteFile(path, original, 0644); restoreErr != nil { + return false, errors.Join(err, restoreErr) + } + return false, errors.Join(err, loadConfig(path)) + } + return true, nil +} + +func checkResolvedEnv(path, key, value string) error { + if err := loadConfig(path); err != nil { + return err + } + cfg, err := Get() + if err != nil { + return err + } + env, err := cfg.Containers[0].ResolvedEnv(cfg.Env) + if err != nil { + return err + } + got := "" + for _, kv := range env { + if k, v, _ := strings.Cut(kv, "="); k == strings.ToUpper(key) { + got = v + } + } + if got != value { + return fmt.Errorf("another env profile also sets %s and overrides this change", key) + } + return nil +} + +func addEnvReference(data []byte, profile string) ([]byte, error) { + header := containersHeaderRe.FindIndex(data) + if header == nil { + return nil, errors.New("no [[containers]] block found in config") + } + start, end := header[1], len(data) + if next := tableHeaderRe.FindIndex(data[start:]); next != nil { + end = start + next[0] + } + quoted := `"` + profile + `"` + loc := envListRe.FindIndex(data[start:end]) + if loc == nil { + return replace(data, start, start, "\nenv = ["+quoted+"]"), nil + } + open := start + loc[1] + closeAt := bytes.IndexByte(data[open:end], ']') + if closeAt < 0 { + return nil, errors.New("could not parse the env list in the [[containers]] block") + } + names := quotedRe.FindAllIndex(data[open:open+closeAt], -1) + for _, n := range names { + if string(data[open+n[0]+1:open+n[1]-1]) == profile { + return data, nil + } + } + if len(names) == 0 { + return replace(data, open, open, quoted), nil + } + at := open + names[len(names)-1][1] + return replace(data, at, at, ", "+quoted), nil +} + +func setProfileVar(data []byte, profile, key, value string) []byte { + assignment := fmt.Sprintf("%s = %q", key, value) + headerRe := regexp.MustCompile(`(?m)^[ \t]*\[env\.` + regexp.QuoteMeta(profile) + `\][ \t]*$`) + header := headerRe.FindIndex(data) + if header == nil { + trimmed := len(bytes.TrimRight(data, "\n")) + return replace(data, trimmed, len(data), "\n\n[env."+profile+"]\n"+assignment+"\n") + } + start, end := header[1], len(data) + if next := tableHeaderRe.FindIndex(data[start:]); next != nil { + end = start + next[0] + } + // viper lowercases keys. + keyRe := regexp.MustCompile(`(?mi)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*=[ \t]*("[^"\n]*"|'[^'\n]*'|[^ \t\n#]+)`) + if loc := keyRe.FindSubmatchIndex(data[start:end]); loc != nil { + return replace(data, start+loc[2], start+loc[3], fmt.Sprintf("%q", value)) + } + return replace(data, start, start, "\n"+assignment) +} + +func replace(data []byte, from, to int, s string) []byte { + out := make([]byte, 0, len(data)-(to-from)+len(s)) + out = append(out, data[:from]...) + out = append(out, s...) + return append(out, data[to:]...) +} diff --git a/internal/config/env_profile_test.go b/internal/config/env_profile_test.go new file mode 100644 index 00000000..fa34b3e1 --- /dev/null +++ b/internal/config/env_profile_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeAndLoad(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + require.NoError(t, loadConfig(path)) + t.Cleanup(viper.Reset) + return path +} + +func readConfig(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +func TestSetEnvProfileVar_MultiLineEnvList(t *testing.T) { + path := writeAndLoad(t, "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\n \"mine\",\n]\n\n[env.mine]\nDEBUG = \"1\"\n") + + changed, err := SetEnvProfileVar("p", "KEY", "1") + + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\n \"mine\", \"p\",\n]\n\n[env.mine]\nDEBUG = \"1\"\n\n[env.p]\nKEY = \"1\"\n", readConfig(t, path)) +} + +func TestSetEnvProfileVar_RestoresFileWhenALaterProfileOverrides(t *testing.T) { + original := "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\"p\", \"later\"]\n\n[env.p]\nKEY = \"0\"\n\n[env.later]\nKEY = \"0\"\n" + path := writeAndLoad(t, original) + + _, err := SetEnvProfileVar("p", "KEY", "1") + + require.Error(t, err) + assert.Equal(t, original, readConfig(t, path)) +} + +func TestSetEnvProfileVar_RestoresFileWhenResultDoesNotParse(t *testing.T) { + // The dotted key already defines [env.p], so a new header is a duplicate. + original := "env.p.OTHER = \"x\"\n\n[[containers]]\ntype = \"aws\"\nport = \"4566\"\n" + path := writeAndLoad(t, original) + + _, err := SetEnvProfileVar("p", "KEY", "1") + + require.Error(t, err) + assert.Equal(t, original, readConfig(t, path)) +} diff --git a/internal/emulator/aws/appinspector.go b/internal/emulator/aws/appinspector.go new file mode 100644 index 00000000..af17507b --- /dev/null +++ b/internal/emulator/aws/appinspector.go @@ -0,0 +1,62 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/localstack/lstk/internal/inspect" +) + +const appInspectorStatusPath = "/_localstack/appinspector/status" + +type appInspectorStatus struct { + Status string `json:"status"` + Changed bool `json:"changed"` +} + +func (c *Client) AppInspectorEnabled(ctx context.Context, baseURL string) (bool, error) { + s, err := c.appInspectorStatus(ctx, http.MethodGet, baseURL, "") + return strings.EqualFold(s.Status, "enabled"), err +} + +func (c *Client) SetAppInspectorEnabled(ctx context.Context, baseURL string, enabled bool) (bool, error) { + body := `{"status": "disabled"}` + if enabled { + body = `{"status": "enabled"}` + } + s, err := c.appInspectorStatus(ctx, http.MethodPut, baseURL, body) + return s.Changed, err +} + +func (c *Client) appInspectorStatus(ctx context.Context, method, baseURL, body string) (appInspectorStatus, error) { + url := strings.TrimRight(baseURL, "/") + appInspectorStatusPath + req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(body)) + if err != nil { + return appInspectorStatus{}, fmt.Errorf("failed to create App Inspector request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return appInspectorStatus{}, fmt.Errorf("failed to reach App Inspector: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // The route is missing on older images and the community image. + if resp.StatusCode == http.StatusNotFound { + return appInspectorStatus{}, inspect.ErrUnavailable + } + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return appInspectorStatus{}, emulatorStatusError(fmt.Sprintf("LocalStack returned status %d", resp.StatusCode), data) + } + var s appInspectorStatus + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + return appInspectorStatus{}, fmt.Errorf("failed to decode App Inspector response: %w", err) + } + return s, nil +} diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go new file mode 100644 index 00000000..0ca9a989 --- /dev/null +++ b/internal/inspect/inspect.go @@ -0,0 +1,124 @@ +package inspect + +import ( + "context" + "errors" + "fmt" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +var ErrUnavailable = errors.New("this emulator has no App Inspector") + +type Client interface { + AppInspectorEnabled(ctx context.Context, host string) (bool, error) + SetAppInspectorEnabled(ctx context.Context, host string, enabled bool) (changed bool, err error) +} + +type Target struct { + Runtime runtime.Runtime + Containers []config.ContainerConfig + Client Client + Host string + ConfigPath string // empty for an --endpoint-url target +} + +func Set(ctx context.Context, sink output.Sink, t Target, enabled, persist bool) error { + running, err := isRunning(ctx, sink, t, persist) + if err != nil { + return err + } + if !running && !persist { + return emitNotRunning(sink, output.ErrorAction{Label: "Or apply it on the next start:", Value: "lstk inspect " + verb(enabled) + " --persist"}) + } + + if running { + changed, err := t.Client.SetAppInspectorEnabled(ctx, t.Host, enabled) + if err != nil { + return emitRequestError(sink, err) + } + sink.Emit(output.AppInspectorEvent{Enabled: enabled, Changed: changed}) + } + + if !persist { + if enabled && t.ConfigPath != "" { + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "To keep it on after restarts, run: lstk inspect enable --persist"}) + } + return nil + } + + value := "0" + if enabled { + value = "1" + } + changed, err := config.SetEnvProfileVar("app-inspector", "APP_INSPECTOR", value) + if err != nil { + sink.Emit(output.ErrorEvent{Title: "Could not save the App Inspector setting to " + t.ConfigPath, Summary: err.Error(), Code: output.ErrConfigInvalid}) + return output.NewSilentError(err) + } + sink.Emit(output.AppInspectorSavedEvent{Enabled: enabled, Changed: changed, ConfigPath: t.ConfigPath}) + if !running { + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "The emulator is not running. The setting applies on the next start."}) + } + return nil +} + +func Status(ctx context.Context, sink output.Sink, t Target) error { + running, err := isRunning(ctx, sink, t, false) + if err != nil { + return err + } + if !running { + return emitNotRunning(sink) + } + enabled, err := t.Client.AppInspectorEnabled(ctx, t.Host) + if err != nil { + return emitRequestError(sink, err) + } + sink.Emit(output.AppInspectorEvent{Enabled: enabled}) + return nil +} + +// With persist, an unreachable Docker counts as "not running": saving needs no emulator. +func isRunning(ctx context.Context, sink output.Sink, t Target, persist bool) (bool, error) { + if err := t.Runtime.IsHealthy(ctx); err != nil { + if persist { + return false, nil + } + t.Runtime.EmitUnhealthyError(sink, err) + return false, output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + } + running, err := container.RunningEmulators(ctx, t.Runtime, t.Containers) + if err != nil { + return false, fmt.Errorf("checking emulator status: %w", err) + } + return len(running) > 0, nil +} + +func emitNotRunning(sink output.Sink, extra ...output.ErrorAction) error { + sink.Emit(output.ErrorEvent{ + Title: "LocalStack is not running", + Actions: append([]output.ErrorAction{{Label: "Start LocalStack:", Value: "lstk"}}, extra...), + Code: output.ErrEmulatorNotRunning, + }) + return output.NewSilentError(errors.New("LocalStack is not running")) +} + +func emitRequestError(sink output.Sink, err error) error { + if errors.Is(err, ErrUnavailable) { + sink.Emit(output.ErrorEvent{Title: "App Inspector is not available in this emulator", Summary: "Use a recent LocalStack image."}) + } else { + sink.Emit(output.ErrorEvent{Title: "App Inspector request failed", Summary: err.Error()}) + } + return output.NewSilentError(err) +} + +func verb(enabled bool) string { + if enabled { + return "enable" + } + return "disable" +} diff --git a/internal/output/envelope_data.go b/internal/output/envelope_data.go index ec3cc756..58da2107 100644 --- a/internal/output/envelope_data.go +++ b/internal/output/envelope_data.go @@ -57,3 +57,9 @@ type JsonStatusResource struct { Region string `json:"region"` Account string `json:"account"` } + +// JsonAppInspectorSaved is `inspect`'s data.saved. +type JsonAppInspectorSaved struct { + Enabled bool `json:"enabled"` + Changed bool `json:"changed"` +} diff --git a/internal/output/envelope_sink.go b/internal/output/envelope_sink.go index b669a22d..d1177742 100644 --- a/internal/output/envelope_sink.go +++ b/internal/output/envelope_sink.go @@ -43,6 +43,11 @@ func (s *EnvelopeSink) Emit(event Event) { case EmulatorResetEvent: s.data["emulator"] = JsonEmulatorRef(e) s.data["reset"] = true + case AppInspectorEvent: + s.data["enabled"] = e.Enabled + s.data["changed"] = e.Changed + case AppInspectorSavedEvent: + s.data["saved"] = JsonAppInspectorSaved{Enabled: e.Enabled, Changed: e.Changed} case EmulatorStartedEvent: s.data["emulator"] = e.Type s.data["container"] = e.Container diff --git a/internal/output/events.go b/internal/output/events.go index 1ca11365..e2348fe2 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -168,6 +168,19 @@ type EmulatorResetEvent struct { Name string } +// AppInspectorEvent reports App Inspector's state on the running emulator. +type AppInspectorEvent struct { + Enabled bool + Changed bool +} + +// AppInspectorSavedEvent reports the App Inspector setting saved in the config file. +type AppInspectorSavedEvent struct { + Enabled bool + Changed bool + ConfigPath string +} + // EmulatorStartedEvent reports the emulator is up and reachable, whether // freshly started or already running. JSON-only: no plain-text line, since // start's existing messages already cover that. @@ -262,6 +275,8 @@ func (PodSnapshotRemovedEvent) sealedEvent() {} func (SnapshotShownEvent) sealedEvent() {} func (EmulatorStoppedEvent) sealedEvent() {} func (EmulatorResetEvent) sealedEvent() {} +func (AppInspectorEvent) sealedEvent() {} +func (AppInspectorSavedEvent) sealedEvent() {} func (EmulatorStartedEvent) sealedEvent() {} func (EmulatorStatusEvent) sealedEvent() {} func (UpdateCheckedEvent) sealedEvent() {} diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 9119d783..347c30ce 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -71,6 +71,10 @@ func FormatEventLine(event Event) (string, bool) { return formatEmulatorStopped(e), true case EmulatorResetEvent: return formatEmulatorReset(e), true + case AppInspectorEvent: + return formatAppInspector(e), true + case AppInspectorSavedEvent: + return formatAppInspectorSaved(e), true case EmulatorStatusEvent: return formatEmulatorStatus(e), true case UpdateCheckedEvent: @@ -209,6 +213,27 @@ func formatEmulatorReset(e EmulatorResetEvent) string { return SuccessMarker() + " Emulator state reset" } +func formatAppInspector(e AppInspectorEvent) string { + if e.Changed { + return SuccessMarker() + " App Inspector " + enabledWord(e.Enabled) + } + return "App Inspector is " + enabledWord(e.Enabled) +} + +func formatAppInspectorSaved(e AppInspectorSavedEvent) string { + if e.Changed { + return fmt.Sprintf("%s App Inspector will be %s on every start (saved to %s)", SuccessMarker(), enabledWord(e.Enabled), e.ConfigPath) + } + return fmt.Sprintf("App Inspector is %s on every start (%s)", enabledWord(e.Enabled), e.ConfigPath) +} + +func enabledWord(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} + func formatEmulatorStatus(e EmulatorStatusEvent) string { if !e.Running { return e.Type + ": not running" diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 79851c03..5ce3aff7 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -202,6 +202,30 @@ func TestFormatEventLine(t *testing.T) { want: SuccessMarker() + " LocalStack AWS Emulator stopped", wantOK: true, }, + { + name: "app inspector changed", + event: AppInspectorEvent{Enabled: true, Changed: true}, + want: SuccessMarker() + " App Inspector enabled", + wantOK: true, + }, + { + name: "app inspector unchanged", + event: AppInspectorEvent{Enabled: false}, + want: "App Inspector is disabled", + wantOK: true, + }, + { + name: "app inspector saved", + event: AppInspectorSavedEvent{Enabled: true, Changed: true, ConfigPath: "~/.config/lstk/config.toml"}, + want: SuccessMarker() + " App Inspector will be enabled on every start (saved to ~/.config/lstk/config.toml)", + wantOK: true, + }, + { + name: "app inspector already saved", + event: AppInspectorSavedEvent{Enabled: false, ConfigPath: "~/.config/lstk/config.toml"}, + want: "App Inspector is disabled on every start (~/.config/lstk/config.toml)", + wantOK: true, + }, { name: "emulator reset event", event: EmulatorResetEvent{Type: "aws", Name: "localstack-aws"}, diff --git a/internal/ui/run_inspect.go b/internal/ui/run_inspect.go new file mode 100644 index 00000000..2b1200e3 --- /dev/null +++ b/internal/ui/run_inspect.go @@ -0,0 +1,20 @@ +package ui + +import ( + "context" + + "github.com/localstack/lstk/internal/inspect" + "github.com/localstack/lstk/internal/output" +) + +func RunInspectSet(parentCtx context.Context, t inspect.Target, enabled, persist bool) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return inspect.Set(ctx, sink, t, enabled, persist) + }) +} + +func RunInspectStatus(parentCtx context.Context, t inspect.Target) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return inspect.Status(ctx, sink, t) + }) +} diff --git a/openspec/changes/json-output-schema/tasks.md b/openspec/changes/json-output-schema/tasks.md index 23749448..a154dd0d 100644 --- a/openspec/changes/json-output-schema/tasks.md +++ b/openspec/changes/json-output-schema/tasks.md @@ -35,6 +35,7 @@ Implemented first, ahead of every other command: each depends only on the shared - [ ] 4.3 `status`: add annotation; change the domain loop (`internal/container/status.go`) to report every configured emulator (running or not) instead of failing fast on the first non-running one, gated so plain-text behavior is unchanged; `{"emulators": [...]}` data shape. - [ ] 4.4 `logs` (bounded): add annotation, `{"lines": [...]}` data shape. - [ ] 4.5 `logs --follow`: wire the NDJSON stream writer from 1.5. +- [x] 4.5b `inspect enable`/`disable`/`status`: annotation, `{"enabled", "changed", "saved"}` data shape, with integration tests. - [ ] 4.6 Integration tests for each command in this wave, covering both the success payload and its documented error codes. ## 5. Remaining emulator lifecycle diff --git a/test/integration/__snapshots__/exit_code_test.snap b/test/integration/__snapshots__/exit_code_test.snap index 85bb0e62..e6aa556e 100644 --- a/test/integration/__snapshots__/exit_code_test.snap +++ b/test/integration/__snapshots__/exit_code_test.snap @@ -80,6 +80,29 @@ Global Options: --non-interactive Disable interactive mode --- +[TestBareParentCommandExitsZero_inspect_1] +Turn App Inspector on or off for the AWS emulator. App Inspector records the +operations your application sends to the emulator, with their payloads, the +services they connect, and IAM permission results. View them in the LocalStack +Web Application or the LocalStack Toolkit for VS Code. + +Usage: lstk inspect [flags] + +Commands: + disable Disable App Inspector + enable Enable App Inspector + status Show whether App Inspector is enabled + +Options: + -h, --help help for inspect + +Global Options: + --config string Path to config file + --endpoint-url string Target an existing, externally-managed emulator at this URL + --json Output in JSON format (only supported by some commands) + --non-interactive Disable interactive mode +--- + [TestBareParentCommandExitsZero_setup_1] Set up emulator CLI integration for AWS or Azure. @@ -166,6 +189,10 @@ Error: unknown flag: --bogus-flag-xyz Error: unknown flag: --bogus-flag-xyz --- +[TestInvalidUsageExitsNonZero_unknown_inspect_subcommand_1] +Error: unknown command "bogus" for "lstk inspect" +--- + [TestInvalidUsageExitsNonZero_unknown_setup_subcommand_1] Error: unknown command "bogus" for "lstk setup" --- diff --git a/test/integration/__snapshots__/extension_test.snap b/test/integration/__snapshots__/extension_test.snap index 04fbeb78..29857973 100644 --- a/test/integration/__snapshots__/extension_test.snap +++ b/test/integration/__snapshots__/extension_test.snap @@ -10,6 +10,7 @@ Commands: completion Set up tab completion for your shell config Manage configuration help Help about any command + inspect Turn App Inspector on or off load Load a snapshot into the running emulator login Manage login logout Remove stored authentication credentials @@ -71,6 +72,7 @@ Commands: completion Set up tab completion for your shell config Manage configuration help Help about any command + inspect Turn App Inspector on or off load Load a snapshot into the running emulator login Manage login logout Remove stored authentication credentials @@ -121,6 +123,7 @@ Commands: completion Set up tab completion for your shell config Manage configuration help Help about any command + inspect Turn App Inspector on or off load Load a snapshot into the running emulator login Manage login logout Remove stored authentication credentials diff --git a/test/integration/endpoint_url_test.go b/test/integration/endpoint_url_test.go index 29dd4a6e..2646f454 100644 --- a/test/integration/endpoint_url_test.go +++ b/test/integration/endpoint_url_test.go @@ -429,6 +429,7 @@ func TestEndpointURLWrongTypeErrorNamesEmulatorOnce(t *testing.T) { want string }{ {"reset", snowflake.URL, []string{"reset", "--force"}, "reset is only supported for the AWS emulator, but the endpoint at %s is a LocalStack Snowflake Emulator"}, + {"inspect", snowflake.URL, []string{"inspect", "enable"}, "App Inspector is only available for the AWS emulator, but the endpoint at %s is a LocalStack Snowflake Emulator"}, {"aws", snowflake.URL, []string{"aws", "s3", "ls"}, "lstk aws requires the AWS emulator, but the endpoint at %s is a LocalStack Snowflake Emulator"}, {"terraform", snowflake.URL, []string{"terraform", "plan"}, "lstk terraform requires the AWS emulator, but the endpoint at %s is a LocalStack Snowflake Emulator"}, {"sam", snowflake.URL, []string{"sam", "deploy"}, "lstk sam requires the AWS emulator, but the endpoint at %s is a LocalStack Snowflake Emulator"}, diff --git a/test/integration/exit_code_test.go b/test/integration/exit_code_test.go index 8e17e018..109bb067 100644 --- a/test/integration/exit_code_test.go +++ b/test/integration/exit_code_test.go @@ -29,6 +29,7 @@ func TestInvalidUsageExitsNonZero(t *testing.T) { {"unknown setup subcommand", []string{"setup", "bogus"}}, {"unknown volume subcommand", []string{"volume", "bogus"}}, {"unknown snapshot subcommand", []string{"snapshot", "bogus"}}, + {"unknown inspect subcommand", []string{"inspect", "bogus"}}, {"unknown completion shell", []string{"completion", "bogus"}}, } @@ -51,7 +52,7 @@ func TestInvalidUsageExitsNonZero(t *testing.T) { func TestBareParentCommandExitsZero(t *testing.T) { t.Parallel() - for _, parent := range []string{"config", "setup", "volume", "snapshot", "completion"} { + for _, parent := range []string{"config", "setup", "volume", "snapshot", "inspect", "completion"} { parent := parent t.Run(parent, func(t *testing.T) { t.Parallel() diff --git a/test/integration/inspect_test.go b/test/integration/inspect_test.go new file mode 100644 index 00000000..af326ba7 --- /dev/null +++ b/test/integration/inspect_test.go @@ -0,0 +1,357 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func appInspectorServer(t *testing.T, enabled bool) (*httptest.Server, func() []string) { + t.Helper() + var mu sync.Mutex + var puts []string + health := awsHealthHandler() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_localstack/appinspector/status" { + health.ServeHTTP(w, r) + return + } + mu.Lock() + defer mu.Unlock() + changed := false + if r.Method == http.MethodPut { + var req struct { + Status string `json:"status"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + puts = append(puts, req.Status) + changed = enabled != (req.Status == "enabled") + enabled = req.Status == "enabled" + } + status := "DISABLED" + if enabled { + status = "ENABLED" + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": status, "changed": changed}) + })) + t.Cleanup(srv.Close) + return srv, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), puts...) + } +} + +func inspectEnv(t *testing.T) env.Environ { + t.Helper() + return env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.DisableEvents, "1"). + With(dockerHostKey, "tcp://localhost:1") +} + +func writeInspectConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + return path +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return string(data) +} + +const inspectBaseConfig = `# my settings +[[containers]] +type = "aws" # emulator type +tag = "latest" +port = "4566" +# env = [] # Named environment profiles + +[cli] +check_for_update_on_startup = false +` + +func TestInspectEndpointURL(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + enabled bool + cmd string + want string + wantPuts []string + }{ + {"enable", false, "enable", "App Inspector enabled", []string{"enabled"}}, + {"enable when already on", true, "enable", "App Inspector is enabled", []string{"enabled"}}, + {"disable", true, "disable", "App Inspector disabled", []string{"disabled"}}, + {"status on", true, "status", "App Inspector is enabled", nil}, + {"status off", false, "status", "App Inspector is disabled", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, puts := appInspectorServer(t, tc.enabled) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + "--endpoint-url", srv.URL, "--non-interactive", "inspect", tc.cmd) + + require.NoError(t, err, stderr) + assert.Contains(t, stdout, tc.want) + assert.Equal(t, tc.wantPuts, puts()) + }) + } +} + +func TestInspectFailsWhenEmulatorLacksAppInspector(t *testing.T) { + t.Parallel() + srv := awsHealthServer(t) + t.Cleanup(srv.Close) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + "--endpoint-url", srv.URL, "--non-interactive", "inspect", "enable") + + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "App Inspector is not available in this emulator") +} + +func TestInspectPersistRejectsEndpointURL(t *testing.T) { + t.Parallel() + srv, puts := appInspectorServer(t, false) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + "--endpoint-url", srv.URL, "--non-interactive", "inspect", "enable", "--persist") + + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "--persist") + assert.Empty(t, puts()) +} + +func TestInspectPersist(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + cmd string + in string + want string + }{ + { + name: "enable adds a profile and keeps comments", + cmd: "enable", + in: inspectBaseConfig, + want: `# my settings +[[containers]] +env = ["app-inspector"] +type = "aws" # emulator type +tag = "latest" +port = "4566" +# env = [] # Named environment profiles + +[cli] +check_for_update_on_startup = false + +[env.app-inspector] +APP_INSPECTOR = "1" +`, + }, + { + name: "enable keeps existing profiles", + cmd: "enable", + in: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\"debug\"]\n\n[env.debug]\nDEBUG = \"1\"\n", + want: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\"debug\", \"app-inspector\"]\n\n[env.debug]\nDEBUG = \"1\"\n\n[env.app-inspector]\nAPP_INSPECTOR = \"1\"\n", + }, + { + name: "disable flips the saved value", + cmd: "disable", + in: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\"app-inspector\"]\n\n[env.app-inspector]\nAPP_INSPECTOR = \"1\"\n", + want: "[[containers]]\ntype = \"aws\"\nport = \"4566\"\nenv = [\"app-inspector\"]\n\n[env.app-inspector]\nAPP_INSPECTOR = \"0\"\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + configPath := writeInspectConfig(t, tc.in) + e := inspectEnv(t) + args := []string{"--config", configPath, "--non-interactive", "inspect", tc.cmd, "--persist"} + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), e, args...) + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "next start") + assert.Equal(t, tc.want, readFile(t, configPath)) + + stdout, stderr, err = runLstk(t, testContext(t), t.TempDir(), e, args...) + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "on every start ("+configPath+")") + assert.Equal(t, tc.want, readFile(t, configPath)) + }) + } +} + +func TestInspectPersistWithoutConfigFile(t *testing.T) { + t.Parallel() + e := inspectEnv(t) + configPath := resolvedConfigPath(t, e) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), e, + "--non-interactive", "inspect", "enable", "--persist") + + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "lstk start") + assert.NoFileExists(t, configPath) +} + +func TestInspectRejectsNonAWSConfig(t *testing.T) { + t.Parallel() + original := "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n" + configPath := writeInspectConfig(t, original) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + "--config", configPath, "--non-interactive", "inspect", "enable", "--persist") + + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "App Inspector is only available for the AWS emulator") + assert.Equal(t, original, readFile(t, configPath)) +} + +func TestInspectJSON(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + enabled bool + args []string + wantData string + }{ + {"status", true, []string{"inspect", "status"}, `{"enabled": true, "changed": false}`}, + {"enable", false, []string{"inspect", "enable"}, `{"enabled": true, "changed": true}`}, + {"disable", true, []string{"inspect", "disable"}, `{"enabled": false, "changed": true}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, _ := appInspectorServer(t, tc.enabled) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + append([]string{"--endpoint-url", srv.URL, "--json"}, tc.args...)...) + + require.NoError(t, err, stderr) + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "ok", envelope.Status) + assert.Equal(t, strings.Join(tc.args, " "), envelope.Command) + assert.JSONEq(t, tc.wantData, string(envelope.Data)) + }) + } +} + +func TestInspectPersistJSON(t *testing.T) { + t.Parallel() + configPath := writeInspectConfig(t, inspectBaseConfig) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + "--config", configPath, "--json", "inspect", "enable", "--persist") + + require.NoError(t, err, stderr) + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "ok", envelope.Status) + assert.JSONEq(t, `{"saved": {"enabled": true, "changed": true}}`, string(envelope.Data)) +} + +func TestInspectJSONErrors(t *testing.T) { + t.Parallel() + aws, _ := appInspectorServer(t, false) + snowflake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"version": "1.0.0", "services": map[string]string{"snowflake": "running"}}) + })) + t.Cleanup(snowflake.Close) + snowflakeConfig := writeInspectConfig(t, "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n") + + for _, tc := range []struct { + name string + args []string + wantCode string + }{ + {"persist with endpoint", []string{"--endpoint-url", aws.URL, "inspect", "enable", "--persist"}, "VALIDATION_ERROR"}, + {"non-AWS endpoint", []string{"--endpoint-url", snowflake.URL, "inspect", "status"}, "EMULATOR_WRONG_TYPE"}, + {"non-AWS config", []string{"--config", snowflakeConfig, "inspect", "status"}, "EMULATOR_NOT_CONFIGURED"}, + {"persist without config file", []string{"inspect", "enable", "--persist"}, "CONFIG_NOT_FOUND"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), append([]string{"--json"}, tc.args...)...) + + requireExitCode(t, 1, err) + envelope := decodeEnvelope(t, stdout) + require.NotNil(t, envelope.Error) + assert.Equal(t, tc.wantCode, envelope.Error.Code) + }) + } +} + +func TestInspectEnableNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), + "--non-interactive", "inspect", "enable") + + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "not running") + assert.Contains(t, stdout, "--persist") +} + +func TestInspectEnableLocalEmulator(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, puts := appInspectorServer(t, false) + analyticsSrv, events := mockAnalyticsServer(t) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.LocalStackHost, lsHost(srv)). + With(env.AnalyticsEndpoint, analyticsSrv.URL), + "--non-interactive", "inspect", "enable") + + require.NoError(t, err, stderr) + assert.Contains(t, stdout, "App Inspector enabled") + assert.Contains(t, stdout, "lstk inspect enable --persist") + assert.Equal(t, []string{"enabled"}, puts()) + assertCommandTelemetry(t, events, "inspect enable", 0) +} + +// The emulator turns App Inspector on shortly after it reports ready, so the check waits. +func TestInspectPersistAppliesOnStart(t *testing.T) { + requireDocker(t) + token := env.Require(t, env.AuthToken) + cleanup() + home := t.TempDir() + scheduleVolumeCleanup(t, home) + // Runs first (LIFO), so the emulator stops before its volume is removed. + t.Cleanup(cleanup) + + ctx := testContext(t) + configPath := writeInspectConfig(t, inspectBaseConfig) + e := env.Environ(testEnvWithHome(home, "")).With(env.AuthToken, token) + + _, stderr, err := runLstk(t, ctx, t.TempDir(), e, "--config", configPath, "--non-interactive", "inspect", "enable", "--persist") + require.NoError(t, err, stderr) + + _, stderr, err = runLstk(t, ctx, t.TempDir(), e, "--config", configPath, "--non-interactive", "start") + require.NoError(t, err, stderr) + + require.Eventually(t, func() bool { + stdout, _, err := runLstk(t, ctx, t.TempDir(), e, "--config", configPath, "--non-interactive", "inspect", "status") + return err == nil && strings.Contains(stdout, "App Inspector is enabled") + }, 15*time.Second, 500*time.Millisecond) +}