Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Environment variables:
- Errors returned by functions should always be checked unless in test files.
- Terminology: in user-facing CLI/help/docs, prefer `emulator` over `container`/`runtime`; use `container`/`runtime` only for internal implementation details.
- Avoid package-level global variables. Use constructor functions that return fresh instances and inject dependencies explicitly. This keeps packages testable in isolation and prevents shared mutable state between tests.
- Do not call `config.Get()` from domain/business-logic packages. Instead, extract the values you need at the command boundary (`cmd/`) and pass them as explicit function arguments. This keeps domain functions testable without requiring Viper/config initialization.

# Testing

Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
root.AddCommand(
newStartCmd(cfg, tel),
newStopCmd(cfg),
newStatusCmd(cfg),
newLoginCmd(cfg),
newLogoutCmd(cfg),
newLogsCmd(),
Expand Down
39 changes: 39 additions & 0 deletions cmd/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/ui"
"github.com/spf13/cobra"
)

func newStatusCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show emulator status and deployed resources",
Long: "Show the status of a running emulator and its deployed resources",
PreRunE: initConfig,
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime()
if err != nil {
return err
}

appCfg, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}

if isInteractiveMode(cfg) {
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost)
}
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, output.NewPlainSink(os.Stdout))
},
}
}
4 changes: 2 additions & 2 deletions internal/auth/mock_token_storage.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions internal/container/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package container

import (
"context"
"fmt"
"net/http"
"time"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/emulator/aws"
"github.com/localstack/lstk/internal/endpoint"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
)

const statusTimeout = 10 * time.Second

func Status(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string, sink output.Sink) error {
ctx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()

output.EmitSpinnerStart(sink, "Fetching LocalStack status")

for _, c := range containers {
name := c.Name()
running, err := rt.IsRunning(ctx, name)
if err != nil {
output.EmitSpinnerStop(sink)
return fmt.Errorf("checking %s running: %w", name, err)
}
if !running {
output.EmitSpinnerStop(sink)
output.EmitError(sink, output.ErrorEvent{
Title: fmt.Sprintf("%s is not running", c.DisplayName()),
Actions: []output.ErrorAction{
{Label: "Start LocalStack:", Value: "lstk"},
{Label: "See help:", Value: "lstk -h"},
},
})
return output.NewSilentError(fmt.Errorf("%s is not running", name))
}

host, _ := endpoint.ResolveHost(c.Port, localStackHost)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle ResolveHost failures explicitly.

Ignoring the error here can leave host empty and turns a local endpoint/config problem into a later fetch failure or is running () output. Fail fast before calling the emulator endpoints.

Suggested fix
-		host, _ := endpoint.ResolveHost(c.Port, localStackHost)
+		host, err := endpoint.ResolveHost(c.Port, localStackHost)
+		if err != nil {
+			output.EmitSpinnerStop(sink)
+			return fmt.Errorf("resolve host for %s: %w", name, err)
+		}
As per coding guidelines "Errors returned by functions should always be checked unless in test files"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
host, _ := endpoint.ResolveHost(c.Port, localStackHost)
host, err := endpoint.ResolveHost(c.Port, localStackHost)
if err != nil {
output.EmitSpinnerStop(sink)
return fmt.Errorf("resolve host for %s: %w", name, err)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/container/status.go` at line 43, The call to
endpoint.ResolveHost(c.Port, localStackHost) ignores its error, which can leave
host empty and cause misleading later failures; update the status-checking code
(around where ResolveHost is called and the host variable is used) to capture
the returned error, check it immediately, and return or propagate a clear error
when ResolveHost fails so we fail fast before attempting to contact emulator
endpoints or printing status like "is running ()". Ensure you reference
ResolveHost and the host variable in your change and handle the error path
consistently with surrounding error handling.


var uptime time.Duration
if startedAt, err := rt.ContainerStartedAt(ctx, name); err == nil {
uptime = time.Since(startedAt)
}

var version string
var rows []aws.Resource
switch c.Type {
case config.EmulatorAWS:
emulatorClient := aws.NewClient(&http.Client{})
if v, err := emulatorClient.FetchVersion(ctx, host); err != nil {
output.EmitSpinnerStop(sink)
output.EmitWarning(sink, fmt.Sprintf("Could not fetch version: %v", err))
} else {
version = v
}

var fetchErr error
rows, fetchErr = emulatorClient.FetchResources(ctx, host)
if fetchErr != nil {
output.EmitSpinnerStop(sink)
return fetchErr
}
}

output.EmitSpinnerStop(sink)

output.Emit(sink, output.InstanceInfoEvent{
EmulatorName: c.DisplayName(),
Version: version,
Host: host,
ContainerName: name,
Uptime: uptime,
})

if c.Type == config.EmulatorAWS {
if len(rows) == 0 {
output.EmitNote(sink, "No resources deployed")
continue
}

tableRows := make([][]string, len(rows))
services := map[string]struct{}{}
for i, r := range rows {
tableRows[i] = []string{r.Service, r.Name, r.Region, r.Account}
services[r.Service] = struct{}{}
}

output.EmitInfo(sink, fmt.Sprintf("~ %d resources · %d services", len(rows), len(services)))
output.Emit(sink, output.TableEvent{
Headers: []string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"},
Rows: tableRows,
})
}
}

return nil
}
46 changes: 46 additions & 0 deletions internal/container/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package container

import (
"context"
"fmt"
"io"
"testing"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)

func TestStatus_IsRunningError(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, fmt.Errorf("docker unavailable"))

containers := []config.ContainerConfig{{Type: config.EmulatorAWS}}
sink := output.NewPlainSink(io.Discard)

err := Status(context.Background(), mockRT, containers, "", sink)

require.Error(t, err)
assert.Contains(t, err.Error(), "docker unavailable")
}

func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil)

containers := []config.ContainerConfig{
{Type: config.EmulatorAWS},
{Type: config.EmulatorSnowflake},
}
sink := output.NewPlainSink(io.Discard)

err := Status(context.Background(), mockRT, containers, "", sink)

require.Error(t, err)
assert.True(t, output.IsSilent(err))
}
127 changes: 127 additions & 0 deletions internal/emulator/aws/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package aws

import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
)

type Resource struct {
Service string
Name string
Region string
Account string
}

type Client struct {
http *http.Client
}

func NewClient(httpClient *http.Client) *Client {
return &Client{http: httpClient}
}

type healthResponse struct {
Version string `json:"version"`
}

type instanceResource struct {
RegionName string `json:"region_name"`
AccountID string `json:"account_id"`
ID string `json:"id"`
}

func (c *Client) FetchVersion(ctx context.Context, host string) (string, error) {
url := fmt.Sprintf("http://%s/_localstack/health", host)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("failed to create health request: %w", err)
}

resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch health: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("health endpoint returned status %d", resp.StatusCode)
}

var h healthResponse
if err := json.NewDecoder(resp.Body).Decode(&h); err != nil {
return "", fmt.Errorf("failed to decode health response: %w", err)
}
return h.Version, nil
}

func (c *Client) FetchResources(ctx context.Context, host string) ([]Resource, error) {
url := fmt.Sprintf("http://%s/_localstack/resources", host)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create resources request: %w", err)
}

resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch resources: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch resources: status %d", resp.StatusCode)
}

// Each line of the NDJSON stream is a JSON object mapping an AWS resource type
// (e.g. "AWS::S3::Bucket") to a list of resource entries.
var rows []Resource
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
Comment on lines +82 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the file in question
find . -name "client.go" -path "*/emulator/aws/*" -type f

Repository: localstack/lstk

Length of output: 92


🏁 Script executed:

# Read the client.go file around lines 71-73
cat -n internal/emulator/aws/client.go | head -120 | tail -80

Repository: localstack/lstk

Length of output: 2791


🏁 Script executed:

# Get the full context of the FetchResources function
cat -n internal/emulator/aws/client.go

Repository: localstack/lstk

Length of output: 3800


Increase or remove the 1 MiB buffer ceiling on NDJSON resource lines.

scanner.Buffer(buf, 1024*1024) caps each scanned line at 1 MiB. Since each line contains all resources of a single AWS type, larger deployments can exceed this limit and cause FetchResources to fail. Use bufio.Reader directly or increase the buffer ceiling substantially to handle production deployments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/emulator/aws/client.go` around lines 71 - 73, The code in
FetchResources uses bufio.NewScanner(resp.Body) and then caps line size with
scanner.Buffer(buf, 1024*1024), which limits NDJSON lines to 1 MiB and can fail
on large deployments; replace this by either using a bufio.Reader to stream
lines (e.g., read with ReadString('\n') or ReadBytes('\n') from resp.Body) or
raise/remove the ceiling on the scanner (e.g., call scanner.Buffer(nil, <much
larger size>) or allocate a larger buffer) so that the scanner/reader can handle
multi-megabyte NDJSON lines; update the logic around the scanner variable and
resp.Body handling in FetchResources to use the chosen approach.


for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}

var chunk map[string][]instanceResource
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse resource line: %w", err)
}

for resourceType, entries := range chunk {
parts := strings.SplitN(resourceType, "::", 3)
service := resourceType
if len(parts) == 3 {
service = parts[1]
}

for _, e := range entries {
rows = append(rows, Resource{
Service: service,
Name: extractResourceName(e.ID),
Region: e.RegionName,
Account: e.AccountID,
})
}
}
}

if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read resources stream: %w", err)
}

sort.Slice(rows, func(i, j int) bool {
if rows[i].Service != rows[j].Service {
return rows[i].Service < rows[j].Service
}
return rows[i].Name < rows[j].Name
})

return rows, nil
}
Loading
Loading