Skip to content

Commit 1eb8898

Browse files
status command
1 parent 02fadd2 commit 1eb8898

21 files changed

Lines changed: 1030 additions & 18 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Environment variables:
5858
- Errors returned by functions should always be checked unless in test files.
5959
- Terminology: in user-facing CLI/help/docs, prefer `emulator` over `container`/`runtime`; use `container`/`runtime` only for internal implementation details.
6060
- 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.
61+
- 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.
6162

6263
# Testing
6364

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
4848
root.AddCommand(
4949
newStartCmd(cfg, tel),
5050
newStopCmd(cfg),
51+
newStatusCmd(cfg),
5152
newLoginCmd(cfg),
5253
newLogoutCmd(cfg),
5354
newLogsCmd(),

cmd/status.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/localstack/lstk/internal/config"
8+
"github.com/localstack/lstk/internal/container"
9+
"github.com/localstack/lstk/internal/env"
10+
"github.com/localstack/lstk/internal/output"
11+
"github.com/localstack/lstk/internal/runtime"
12+
"github.com/localstack/lstk/internal/ui"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func newStatusCmd(cfg *env.Env) *cobra.Command {
17+
return &cobra.Command{
18+
Use: "status",
19+
Short: "Show emulator status and deployed resources",
20+
Long: "Show the status of a running emulator and its deployed resources",
21+
PreRunE: initConfig,
22+
RunE: func(cmd *cobra.Command, args []string) error {
23+
rt, err := runtime.NewDockerRuntime()
24+
if err != nil {
25+
return err
26+
}
27+
28+
appCfg, err := config.Get()
29+
if err != nil {
30+
return fmt.Errorf("failed to get config: %w", err)
31+
}
32+
33+
if isInteractiveMode(cfg) {
34+
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost)
35+
}
36+
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, output.NewPlainSink(os.Stdout))
37+
},
38+
}
39+
}

internal/auth/mock_token_storage.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/container/status.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package container
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/localstack/lstk/internal/config"
9+
"github.com/localstack/lstk/internal/emulator/aws"
10+
"github.com/localstack/lstk/internal/endpoint"
11+
"github.com/localstack/lstk/internal/output"
12+
"github.com/localstack/lstk/internal/runtime"
13+
)
14+
15+
const statusTimeout = 10 * time.Second
16+
17+
func Status(ctx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string, sink output.Sink) error {
18+
ctx, cancel := context.WithTimeout(ctx, statusTimeout)
19+
defer cancel()
20+
21+
for _, c := range containers {
22+
name := c.Name()
23+
running, err := rt.IsRunning(ctx, name)
24+
if err != nil {
25+
return fmt.Errorf("checking %s running: %w", name, err)
26+
}
27+
if !running {
28+
output.EmitError(sink, output.ErrorEvent{
29+
Title: fmt.Sprintf("%s is not running", c.DisplayName()),
30+
Actions: []output.ErrorAction{
31+
{Label: "Start LocalStack:", Value: "lstk"},
32+
{Label: "See help:", Value: "lstk -h"},
33+
},
34+
})
35+
return output.NewSilentError(fmt.Errorf("%s is not running", name))
36+
}
37+
38+
host, _ := endpoint.ResolveHost(c.Port, localStackHost)
39+
40+
var uptime time.Duration
41+
if startedAt, err := rt.ContainerStartedAt(ctx, name); err == nil {
42+
uptime = time.Since(startedAt)
43+
}
44+
45+
var version string
46+
switch c.Type {
47+
case config.EmulatorAWS:
48+
emulatorClient := aws.NewClient(nil)
49+
version, _ = emulatorClient.FetchVersion(ctx, host)
50+
51+
output.Emit(sink, output.InstanceInfoEvent{
52+
EmulatorName: c.DisplayName(),
53+
Version: version,
54+
Host: host,
55+
ContainerName: name,
56+
Uptime: uptime,
57+
})
58+
59+
rows, err := emulatorClient.FetchResources(ctx, host)
60+
if err != nil {
61+
return err
62+
}
63+
64+
if len(rows) == 0 {
65+
output.EmitNote(sink, "No resources deployed")
66+
continue
67+
}
68+
69+
services := map[string]struct{}{}
70+
for _, r := range rows {
71+
services[r.Service] = struct{}{}
72+
}
73+
output.Emit(sink, output.ResourceSummaryEvent{
74+
ResourceCount: len(rows),
75+
ServiceCount: len(services),
76+
})
77+
output.Emit(sink, output.ResourceTableEvent{Rows: rows})
78+
default:
79+
output.Emit(sink, output.InstanceInfoEvent{
80+
EmulatorName: c.DisplayName(),
81+
Version: version,
82+
Host: host,
83+
ContainerName: name,
84+
Uptime: uptime,
85+
})
86+
}
87+
}
88+
89+
return nil
90+
}

internal/container/status_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package container
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/localstack/lstk/internal/config"
14+
"github.com/localstack/lstk/internal/output"
15+
"github.com/localstack/lstk/internal/runtime"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
"go.uber.org/mock/gomock"
19+
)
20+
21+
func TestStatus_NotRunning(t *testing.T) {
22+
ctrl := gomock.NewController(t)
23+
mockRT := runtime.NewMockRuntime(ctrl)
24+
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil)
25+
26+
containers := []config.ContainerConfig{{Type: config.EmulatorAWS}}
27+
sink := output.NewPlainSink(io.Discard)
28+
29+
err := Status(context.Background(), mockRT, containers, "", sink)
30+
31+
require.Error(t, err)
32+
assert.True(t, output.IsSilent(err))
33+
assert.Contains(t, err.Error(), "is not running")
34+
}
35+
36+
func TestStatus_IsRunningError(t *testing.T) {
37+
ctrl := gomock.NewController(t)
38+
mockRT := runtime.NewMockRuntime(ctrl)
39+
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, fmt.Errorf("docker unavailable"))
40+
41+
containers := []config.ContainerConfig{{Type: config.EmulatorAWS}}
42+
sink := output.NewPlainSink(io.Discard)
43+
44+
err := Status(context.Background(), mockRT, containers, "", sink)
45+
46+
require.Error(t, err)
47+
assert.Contains(t, err.Error(), "docker unavailable")
48+
}
49+
50+
func TestStatus_RunningWithResources(t *testing.T) {
51+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+
switch r.URL.Path {
53+
case "/_localstack/health":
54+
w.Header().Set("Content-Type", "application/json")
55+
_, _ = fmt.Fprintln(w, `{"version": "4.14.1"}`)
56+
case "/_localstack/resources":
57+
w.Header().Set("Content-Type", "application/x-ndjson")
58+
_, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-bucket"}]}`)
59+
_, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-func"}]}`)
60+
}
61+
}))
62+
defer server.Close()
63+
64+
host := strings.TrimPrefix(server.URL, "http://")
65+
66+
ctrl := gomock.NewController(t)
67+
mockRT := runtime.NewMockRuntime(ctrl)
68+
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil)
69+
mockRT.EXPECT().ContainerStartedAt(gomock.Any(), "localstack-aws").Return(time.Now().Add(-5*time.Minute), nil)
70+
71+
containers := []config.ContainerConfig{{Type: config.EmulatorAWS}}
72+
var buf strings.Builder
73+
sink := output.NewPlainSink(&buf)
74+
75+
err := Status(context.Background(), mockRT, containers, host, sink)
76+
77+
require.NoError(t, err)
78+
out := buf.String()
79+
assert.Contains(t, out, "is running")
80+
assert.Contains(t, out, "4.14.1")
81+
assert.Contains(t, out, "S3")
82+
assert.Contains(t, out, "my-bucket")
83+
assert.Contains(t, out, "Lambda")
84+
assert.Contains(t, out, "my-func")
85+
assert.Contains(t, out, "2 resources")
86+
assert.Contains(t, out, "2 services")
87+
}
88+
89+
func TestStatus_RunningNoResources(t *testing.T) {
90+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91+
switch r.URL.Path {
92+
case "/_localstack/health":
93+
w.Header().Set("Content-Type", "application/json")
94+
_, _ = fmt.Fprintln(w, `{"version": "4.14.1"}`)
95+
case "/_localstack/resources":
96+
w.Header().Set("Content-Type", "application/x-ndjson")
97+
}
98+
}))
99+
defer server.Close()
100+
101+
host := strings.TrimPrefix(server.URL, "http://")
102+
103+
ctrl := gomock.NewController(t)
104+
mockRT := runtime.NewMockRuntime(ctrl)
105+
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil)
106+
mockRT.EXPECT().ContainerStartedAt(gomock.Any(), "localstack-aws").Return(time.Time{}, fmt.Errorf("not found"))
107+
108+
containers := []config.ContainerConfig{{Type: config.EmulatorAWS}}
109+
var buf strings.Builder
110+
sink := output.NewPlainSink(&buf)
111+
112+
err := Status(context.Background(), mockRT, containers, host, sink)
113+
114+
require.NoError(t, err)
115+
out := buf.String()
116+
assert.Contains(t, out, "is running")
117+
assert.Contains(t, out, "No resources deployed")
118+
}
119+
120+
func TestStatus_MultipleContainers_StopsAtFirstNotRunning(t *testing.T) {
121+
ctrl := gomock.NewController(t)
122+
mockRT := runtime.NewMockRuntime(ctrl)
123+
mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(false, nil)
124+
125+
containers := []config.ContainerConfig{
126+
{Type: config.EmulatorAWS},
127+
{Type: config.EmulatorSnowflake},
128+
}
129+
sink := output.NewPlainSink(io.Discard)
130+
131+
err := Status(context.Background(), mockRT, containers, "", sink)
132+
133+
require.Error(t, err)
134+
assert.True(t, output.IsSilent(err))
135+
}

internal/emulator/aws/aws.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package aws
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/localstack/lstk/internal/output"
8+
)
9+
10+
// Client defines the interface for communicating with a running AWS emulator instance.
11+
type Client interface {
12+
FetchVersion(ctx context.Context, host string) (string, error)
13+
FetchResources(ctx context.Context, host string) ([]output.ResourceRow, error)
14+
}
15+
16+
func NewClient(httpClient *http.Client) Client {
17+
if httpClient == nil {
18+
httpClient = &http.Client{}
19+
}
20+
return &client{http: httpClient}
21+
}

0 commit comments

Comments
 (0)