diff --git a/CLAUDE.md b/CLAUDE.md index 698cc66f..d049990c 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) - `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`, 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..ff0d6acd 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` and `lstk inspect disable` turn App Inspector on or off for the running AWS emulator - **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..96f304ca --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,104 @@ +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)) + return cmd +} + +func newInspectSetCmd(cfg *env.Env, enabled bool) *cobra.Command { + use, title := "enable", "Enable" + if !enabled { + use, title = "disable", "Disable" + } + + return &cobra.Command{ + Use: use, + Short: title + " App Inspector", + Long: title + " App Inspector on the running AWS emulator. The change lasts until the emulator restarts.", + 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) + if err != nil { + return err + } + if isInteractiveMode(cfg) { + return ui.RunInspectSet(cmd.Context(), t, enabled) + } + return inspect.Set(cmd.Context(), sink, t, enabled) + }, + } +} + +func resolveInspectTarget(cmd *cobra.Command, cfg *env.Env, sink output.Sink) (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 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}) + } + + rt, err := runtime.NewDockerRuntime(cfg.DockerHost) + if err != nil { + return inspect.Target{}, err + } + host, _ := endpoint.ResolveHost(cmd.Context(), awsContainer.Port, cfg.LocalStackHost) + return inspect.Target{ + Runtime: rt, + Containers: []config.ContainerConfig{*awsContainer}, + Client: aws.NewClient(), + Host: "http://" + host, + }, 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..112ced04 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`. 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,22 @@ 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`**: App Inspector's state on the running emulator after the command. `changed` is whether this command changed it. +```json +{ + "schemaVersion": 1, + "command": "inspect enable", + "status": "ok", + "data": { + "enabled": true, + "changed": true + }, + "warnings": [], + "error": null +} +``` +Codes: `RUNTIME_UNAVAILABLE`, `EMULATOR_NOT_RUNNING`, `EMULATOR_NOT_CONFIGURED`, `EMULATOR_WRONG_TYPE`, `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/emulator/aws/appinspector.go b/internal/emulator/aws/appinspector.go new file mode 100644 index 00000000..f35043e8 --- /dev/null +++ b/internal/emulator/aws/appinspector.go @@ -0,0 +1,49 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/localstack/lstk/internal/inspect" +) + +const appInspectorStatusPath = "/_localstack/appinspector/status" + +func (c *Client) SetAppInspectorEnabled(ctx context.Context, baseURL string, enabled bool) (bool, error) { + body := `{"status": "disabled"}` + if enabled { + body = `{"status": "enabled"}` + } + url := strings.TrimRight(baseURL, "/") + appInspectorStatusPath + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(body)) + if err != nil { + return false, 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 false, 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 false, inspect.ErrUnavailable + } + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return false, emulatorStatusError(fmt.Sprintf("LocalStack returned status %d", resp.StatusCode), data) + } + var s struct { + Changed bool `json:"changed"` + } + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + return false, fmt.Errorf("failed to decode App Inspector response: %w", err) + } + return s.Changed, nil +} diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go new file mode 100644 index 00000000..94331cd0 --- /dev/null +++ b/internal/inspect/inspect.go @@ -0,0 +1,71 @@ +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 { + 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 +} + +func Set(ctx context.Context, sink output.Sink, t Target, enabled bool) error { + running, err := isRunning(ctx, sink, t) + if err != nil { + return err + } + if !running { + return emitNotRunning(sink) + } + changed, err := t.Client.SetAppInspectorEnabled(ctx, t.Host, enabled) + if err != nil { + return emitRequestError(sink, err) + } + sink.Emit(output.AppInspectorEvent{Enabled: enabled, Changed: changed}) + return nil +} + +func isRunning(ctx context.Context, sink output.Sink, t Target) (bool, error) { + if err := t.Runtime.IsHealthy(ctx); err != 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) error { + sink.Emit(output.ErrorEvent{ + Title: "LocalStack is not running", + Actions: []output.ErrorAction{{Label: "Start LocalStack:", Value: "lstk"}}, + 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) +} diff --git a/internal/output/envelope_sink.go b/internal/output/envelope_sink.go index b669a22d..adcdb3ee 100644 --- a/internal/output/envelope_sink.go +++ b/internal/output/envelope_sink.go @@ -43,6 +43,9 @@ 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 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..586a06b8 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -168,6 +168,12 @@ type EmulatorResetEvent struct { Name string } +// AppInspectorEvent reports App Inspector's state on the running emulator. +type AppInspectorEvent struct { + Enabled bool + Changed bool +} + // 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 +268,7 @@ func (PodSnapshotRemovedEvent) sealedEvent() {} func (SnapshotShownEvent) sealedEvent() {} func (EmulatorStoppedEvent) sealedEvent() {} func (EmulatorResetEvent) sealedEvent() {} +func (AppInspectorEvent) 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..b2eb050a 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -71,6 +71,8 @@ func FormatEventLine(event Event) (string, bool) { return formatEmulatorStopped(e), true case EmulatorResetEvent: return formatEmulatorReset(e), true + case AppInspectorEvent: + return formatAppInspector(e), true case EmulatorStatusEvent: return formatEmulatorStatus(e), true case UpdateCheckedEvent: @@ -209,6 +211,20 @@ 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 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..0af64f60 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -202,6 +202,18 @@ 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: "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..c444d348 --- /dev/null +++ b/internal/ui/run_inspect.go @@ -0,0 +1,14 @@ +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 bool) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return inspect.Set(ctx, sink, t, enabled) + }) +} diff --git a/openspec/changes/json-output-schema/tasks.md b/openspec/changes/json-output-schema/tasks.md index 23749448..a9af22de 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`: annotation, `{"enabled", "changed"}` 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..3979ce3a 100644 --- a/test/integration/__snapshots__/exit_code_test.snap +++ b/test/integration/__snapshots__/exit_code_test.snap @@ -80,6 +80,28 @@ 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 + +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 +188,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..b45e85c6 --- /dev/null +++ b/test/integration/inspect_test.go @@ -0,0 +1,246 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "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 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"}}, + } { + 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 TestInspectRejectsNonAWSConfig(t *testing.T) { + t.Parallel() + configPath := writeInspectConfig(t, "[[containers]]\ntype = \"snowflake\"\nport = \"4566\"\n") + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), inspectEnv(t), + "--config", configPath, "--non-interactive", "inspect", "enable") + + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "App Inspector is only available for the AWS emulator") +} + +func TestInspectJSON(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + enabled bool + args []string + wantData string + }{ + {"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 TestInspectJSONErrors(t *testing.T) { + t.Parallel() + 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 + }{ + {"non-AWS endpoint", []string{"--endpoint-url", snowflake.URL, "inspect", "enable"}, "EMULATOR_WRONG_TYPE"}, + {"non-AWS config", []string{"--config", snowflakeConfig, "inspect", "enable"}, "EMULATOR_NOT_CONFIGURED"}, + {"runtime unavailable", []string{"inspect", "enable"}, "RUNTIME_UNAVAILABLE"}, + } { + 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.NotContains(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.NotContains(t, stdout, "--persist") + assert.Equal(t, []string{"enabled"}, puts()) + assertCommandTelemetry(t, events, "inspect enable", 0) +} + +func TestInspectEnableJSONLocalEmulator(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, puts := appInspectorServer(t, false) + + stdout, stderr, err := runLstk(t, ctx, t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.DisableEvents, "1"). + With(env.LocalStackHost, lsHost(srv)), + "--json", "inspect", "enable") + + require.NoError(t, err, stderr) + envelope := decodeEnvelope(t, stdout) + assert.Equal(t, "ok", envelope.Status) + assert.Equal(t, "inspect enable", envelope.Command) + assert.JSONEq(t, `{"enabled": true, "changed": true}`, string(envelope.Data)) + assert.Equal(t, []string{"enabled"}, puts()) +} + +func TestInspectEnableJSONNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.DisableEvents, "1"), + "--json", "inspect", "enable") + + requireExitCode(t, 1, err) + envelope := decodeEnvelope(t, stdout) + require.NotNil(t, envelope.Error) + assert.Equal(t, "EMULATOR_NOT_RUNNING", envelope.Error.Code) +}