Skip to content
Merged
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Notes:
- `env/` - Process environment snapshot/injection helper (also used to isolate test envs)
- `extension/` - Git-style `lstk-<name>` 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
Expand All @@ -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

Expand Down Expand Up @@ -144,7 +145,7 @@ There is no `--offline` flag. Instead `container.Start` degrades gracefully when

# Targeting an External Emulator (`--endpoint-url`)

`--endpoint-url <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 <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.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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-<name>` executables extend the CLI with new commands; see [extension authoring](https://github.com/localstack/lstk/blob/main/docs/extensions-authoring.md)
Expand Down
104 changes: 104 additions & 0 deletions cmd/inspect.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
18 changes: 17 additions & 1 deletion docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions internal/emulator/aws/appinspector.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions internal/inspect/inspect.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions internal/output/envelope_sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() {}
Expand Down
16 changes: 16 additions & 0 deletions internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
14 changes: 14 additions & 0 deletions internal/ui/run_inspect.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading