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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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
43 changes: 43 additions & 0 deletions cmd/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"fmt"
"net/http"
"os"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/emulator/aws"
"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)
}

awsClient := aws.NewClient(&http.Client{})

if isInteractiveMode(cfg) {
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient)
}
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient, 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.

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

import (
"context"
"fmt"
"time"

"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/emulator"
"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, emulatorClient emulator.Client, 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)

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

var version string
var rows []emulator.Resource
switch c.Type {
case config.EmulatorAWS:
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)
Comment on lines +21 to +68
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Balance spinner start/stop per container iteration.

EmitSpinnerStart runs once before the loop, but EmitSpinnerStop runs in several branch paths inside the loop (including twice on version-failure path). This can produce duplicate stop events and leaves later iterations without an active spinner.

Suggested adjustment
-	output.EmitSpinnerStart(sink, "Fetching LocalStack status")
-
 	for _, c := range containers {
+		output.EmitSpinnerStart(sink, "Fetching LocalStack status")
 		name := c.Name()
 		running, err := rt.IsRunning(ctx, name)
 		if err != nil {
 			output.EmitSpinnerStop(sink)
 			return fmt.Errorf("checking %s running: %w", name, err)
@@
 		case config.EmulatorAWS:
 			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
 			}

As per coding guidelines "Any feature/workflow package that produces user-visible progress should accept an output.Sink dependency and emit events through internal/output".

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

In `@internal/container/status.go` around lines 26 - 73, The spinner is started
once via output.EmitSpinnerStart(sink, ...) before iterating containers but
stopped multiple times inside the loop (output.EmitSpinnerStop), causing
duplicate stops and missing spinners on later iterations; change to balance
start/stop per-iteration by moving EmitSpinnerStart(sink, ...) inside the for _,
c := range containers loop (call it at the top of each iteration) and ensure a
single matching EmitSpinnerStop(sink) before each continue/return path in that
iteration (remove duplicate stops on the version-failure path and replace early
stops with the per-iteration stop), referencing EmitSpinnerStart,
EmitSpinnerStop, the loop over containers, and error/return branches like the
IsRunning error check, not-running path, version fetch warning path, and
fetchErr return so every start has exactly one stop.


output.EmitInstanceInfo(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.EmitResourceSummary(sink, len(rows), len(services))
output.EmitTable(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, "", nil, 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, "", nil, sink)

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

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

"github.com/localstack/lstk/internal/emulator"
)

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) ([]emulator.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 []emulator.Resource
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)

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, emulator.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