diff --git a/CLAUDE.md b/CLAUDE.md index 6affe04..a63d031 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/root.go b/cmd/root.go index 0efc875..807153f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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(), diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..ccc9069 --- /dev/null +++ b/cmd/status.go @@ -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)) + }, + } +} diff --git a/internal/auth/mock_token_storage.go b/internal/auth/mock_token_storage.go index 6653a5a..1f58e09 100644 --- a/internal/auth/mock_token_storage.go +++ b/internal/auth/mock_token_storage.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: internal/auth/token_storage.go +// Source: token_storage.go // // Generated by this command: // -// mockgen -source=internal/auth/token_storage.go -destination=internal/auth/mock_token_storage.go -package=auth +// mockgen -source=token_storage.go -destination=mock_token_storage.go -package=auth // // Package auth is a generated GoMock package. diff --git a/internal/container/status.go b/internal/container/status.go new file mode 100644 index 0000000..fee0e8d --- /dev/null +++ b/internal/container/status.go @@ -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) + + 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 +} diff --git a/internal/container/status_test.go b/internal/container/status_test.go new file mode 100644 index 0000000..43abd93 --- /dev/null +++ b/internal/container/status_test.go @@ -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)) +} diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go new file mode 100644 index 0000000..7a95732 --- /dev/null +++ b/internal/emulator/aws/client.go @@ -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) + + 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 +} diff --git a/internal/emulator/aws/client_test.go b/internal/emulator/aws/client_test.go new file mode 100644 index 0000000..d9fc014 --- /dev/null +++ b/internal/emulator/aws/client_test.go @@ -0,0 +1,109 @@ +package aws + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchVersion(t *testing.T) { + t.Parallel() + + t.Run("returns version from health endpoint", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_localstack/health", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1", "services": {}}`) + })) + defer server.Close() + + c := NewClient(&http.Client{}) + version, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + assert.Equal(t, "4.14.1", version) + }) + + t.Run("returns error on non-200", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient(&http.Client{}) + _, err := c.FetchVersion(context.Background(), server.Listener.Addr().String()) + require.Error(t, err) + }) +} + +func TestFetchResources(t *testing.T) { + t.Parallel() + + t.Run("returns flat rows sorted by service then resource", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-bucket"}]}`) + _, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-function"}]}`) + })) + defer server.Close() + + c := NewClient(&http.Client{}) + rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, "Lambda", rows[0].Service) + assert.Equal(t, "my-function", rows[0].Name) + assert.Equal(t, "us-east-1", rows[0].Region) + assert.Equal(t, "000000000000", rows[0].Account) + assert.Equal(t, "S3", rows[1].Service) + assert.Equal(t, "my-bucket", rows[1].Name) + }) + + t.Run("extracts name from ARN", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::SNS::Topic": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "arn:aws:sns:us-east-1:000000000000:my-topic"}]}`) + })) + defer server.Close() + + c := NewClient(&http.Client{}) + rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "my-topic", rows[0].Name) + }) + + t.Run("returns empty slice when no resources", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + })) + defer server.Close() + + c := NewClient(&http.Client{}) + rows, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.NoError(t, err) + assert.Empty(t, rows) + }) + + t.Run("returns error on non-200", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient(&http.Client{}) + _, err := c.FetchResources(context.Background(), server.Listener.Addr().String()) + require.Error(t, err) + }) +} + diff --git a/internal/emulator/aws/resource_name.go b/internal/emulator/aws/resource_name.go new file mode 100644 index 0000000..8b035cf --- /dev/null +++ b/internal/emulator/aws/resource_name.go @@ -0,0 +1,25 @@ +package aws + +import "strings" + +// extractResourceName extracts the name from a resource ID. +// For ARNs (arn:partition:service:region:account:resource), it returns the resource part. +// For plain names, it returns the ID as-is. +func extractResourceName(id string) string { + if strings.HasPrefix(id, "arn:") { + parts := strings.SplitN(id, ":", 6) + if len(parts) == 6 { + resource := parts[5] + // Handle resources like "role/my-role" + if idx := strings.LastIndex(resource, "/"); idx != -1 { + return resource[idx+1:] + } + // Handle resources like "function:my-func" + if idx := strings.LastIndex(resource, ":"); idx != -1 { + return resource[idx+1:] + } + return resource + } + } + return id +} diff --git a/internal/emulator/aws/resource_name_test.go b/internal/emulator/aws/resource_name_test.go new file mode 100644 index 0000000..95dcaea --- /dev/null +++ b/internal/emulator/aws/resource_name_test.go @@ -0,0 +1,23 @@ +package aws + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractResourceName(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want string + }{ + {"my-bucket", "my-bucket"}, + {"arn:aws:sns:us-east-1:000000000000:my-topic", "my-topic"}, + {"arn:aws:iam::000000000000:role/my-role", "my-role"}, + {"arn:aws:lambda:us-east-1:000000000000:function:my-func", "my-func"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, extractResourceName(tt.input), "extractResourceName(%q)", tt.input) + } +} diff --git a/internal/output/events.go b/internal/output/events.go index 615189a..69a0919 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -57,8 +57,21 @@ type AuthEvent struct { URL string } +type InstanceInfoEvent struct { + EmulatorName string + Version string + Host string + ContainerName string + Uptime time.Duration +} + +type TableEvent struct { + Headers []string + Rows [][]string +} + type Event interface { - MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | LogLineEvent + MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | LogLineEvent | InstanceInfoEvent | TableEvent } type Sink interface { diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 0593be0..fb3c8fa 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -3,6 +3,7 @@ package output import ( "fmt" "strings" + "time" ) // FormatEventLine converts an output event into a single display line. @@ -27,6 +28,10 @@ func FormatEventLine(event any) (string, bool) { return formatUserInputRequest(e), true case LogLineEvent: return e.Line, true + case InstanceInfoEvent: + return formatInstanceInfo(e), true + case TableEvent: + return formatTable(e) default: return "", false } @@ -53,7 +58,6 @@ func formatStatusLine(e ContainerStatusEvent) (string, bool) { } } - func formatUserInputRequest(e UserInputRequestEvent) string { return FormatPrompt(e.Prompt, e.Options) } @@ -67,7 +71,6 @@ func FormatPromptLabels(options []InputOption) string { labels = append(labels, opt.Label) } } - switch len(labels) { case 0: return "" @@ -83,7 +86,6 @@ func FormatPrompt(prompt string, options []InputOption) string { lines := strings.Split(prompt, "\n") firstLine := lines[0] + FormatPromptLabels(options) rest := lines[1:] - if len(rest) == 0 { return firstLine } @@ -142,3 +144,111 @@ func formatErrorEvent(e ErrorEvent) string { } return sb.String() } + +func formatInstanceInfo(e InstanceInfoEvent) string { + var sb strings.Builder + sb.WriteString("✓ " + e.EmulatorName + " is running (" + e.Host + ")") + var meta []string + if e.Uptime > 0 { + meta = append(meta, "UPTIME: "+formatUptime(e.Uptime)) + } + if e.ContainerName != "" { + meta = append(meta, "CONTAINER: "+e.ContainerName) + } + if e.Version != "" { + meta = append(meta, "VERSION: "+e.Version) + } + if len(meta) > 0 { + sb.WriteString("\n " + strings.Join(meta, " · ")) + } + return sb.String() +} + +func formatUptime(d time.Duration) string { + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm %ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func formatTable(e TableEvent) (string, bool) { + if len(e.Rows) == 0 { + return "", false + } + return formatTableWidth(e, terminalWidth()), true +} + +func formatTableWidth(e TableEvent, totalWidth int) string { + ncols := len(e.Headers) + if ncols == 0 { + return "" + } + + widths := make([]int, ncols) + for i, h := range e.Headers { + widths[i] = len(h) + } + for _, row := range e.Rows { + for i := range min(len(row), ncols) { + if len(row[i]) > widths[i] { + widths[i] = len(row[i]) + } + } + } + + // Fixed overhead: 2 (indent) + (ncols-1)*2 (gaps between columns). + overhead := 2 + (ncols-1)*2 + + // Find the widest column and let it absorb any overflow. + maxCol := 0 + for i := 1; i < ncols; i++ { + if widths[i] > widths[maxCol] { + maxCol = i + } + } + fixedWidth := overhead + for i, w := range widths { + if i != maxCol { + fixedWidth += w + } + } + maxFlexible := totalWidth - fixedWidth + if maxFlexible < 10 { + maxFlexible = 10 + } + if widths[maxCol] > maxFlexible { + widths[maxCol] = maxFlexible + } + + var sb strings.Builder + writeRow := func(cols []string) { + sb.WriteString(" ") + for i := range ncols { + cell := "" + if i < len(cols) { + cell = cols[i] + } + val := truncate(cell, widths[i]) + sb.WriteString(val) + if i < ncols-1 { + padding := widths[i] - displayWidth(val) + 2 + for range padding { + sb.WriteByte(' ') + } + } + } + } + writeRow(e.Headers) + for _, row := range e.Rows { + sb.WriteString("\n") + writeRow(row) + } + return sb.String() +} diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 11ea01c..b29ffbb 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -1,6 +1,10 @@ package output -import "testing" +import ( + "strings" + "testing" + "time" +) func TestFormatEventLine(t *testing.T) { t.Parallel() @@ -107,6 +111,45 @@ func TestFormatEventLine(t *testing.T) { want: "Error: Docker not running\n Cannot connect to Docker daemon\n ==> Start Docker: open -a Docker", wantOK: true, }, + { + name: "instance info full", + event: InstanceInfoEvent{ + EmulatorName: "LocalStack AWS Emulator", + Version: "4.14.1", + Host: "localhost.localstack.cloud:4566", + ContainerName: "localstack-aws", + Uptime: 4*time.Minute + 23*time.Second, + }, + want: "✓ LocalStack AWS Emulator is running (localhost.localstack.cloud:4566)\n UPTIME: 4m 23s · CONTAINER: localstack-aws · VERSION: 4.14.1", + wantOK: true, + }, + { + name: "instance info minimal", + event: InstanceInfoEvent{ + EmulatorName: "LocalStack AWS Emulator", + Host: "127.0.0.1:4566", + }, + want: "✓ LocalStack AWS Emulator is running (127.0.0.1:4566)", + wantOK: true, + }, + { + name: "table with entries", + event: TableEvent{ + Headers: []string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"}, + Rows: [][]string{ + {"Lambda", "handler", "us-east-1", "000000000000"}, + {"S3", "my-bucket", "us-east-1", "000000000000"}, + }, + }, + want: " SERVICE RESOURCE REGION ACCOUNT\n Lambda handler us-east-1 000000000000\n S3 my-bucket us-east-1 000000000000", + wantOK: true, + }, + { + name: "table empty", + event: TableEvent{Headers: []string{"A"}, Rows: [][]string{}}, + want: "", + wantOK: false, + }, { name: "unsupported event", event: struct{}{}, @@ -130,3 +173,56 @@ func TestFormatEventLine(t *testing.T) { }) } } + +func TestFormatTableWidth(t *testing.T) { + t.Parallel() + + e := TableEvent{ + Headers: []string{"SERVICE", "RESOURCE", "REGION", "ACCOUNT"}, + Rows: [][]string{ + {"CloudFormation", "8245db0d-5c05-4209-90f0-51ec48446a58", "us-east-1", "000000000000"}, + {"EC2", "subnet-816649cee2efc65ac", "eu-central-1", "000000000000"}, + {"Lambda", "HelloWorldFunctionJavaScript", "us-east-1", "000000000000"}, + }, + } + + t.Run("truncates widest column to fit terminal width", func(t *testing.T) { + t.Parallel() + got := formatTableWidth(e, 80) + for i, line := range strings.Split(got, "\n") { + w := displayWidth(line) + if w > 80 { + t.Errorf("line %d has display width %d (>80): %q", i, w, line) + } + } + if !strings.Contains(got, "8245db0d") { + t.Error("expected truncated UUID to still contain prefix") + } + if !strings.Contains(got, "…") { + t.Error("expected truncation marker") + } + }) + + t.Run("no truncation when terminal is wide enough", func(t *testing.T) { + t.Parallel() + got := formatTableWidth(e, 200) + if strings.Contains(got, "…") { + t.Error("expected no truncation at width 200") + } + if !strings.Contains(got, "8245db0d-5c05-4209-90f0-51ec48446a58") { + t.Error("expected full UUID") + } + }) + + t.Run("narrow terminal still renders without panic", func(t *testing.T) { + t.Parallel() + got := formatTableWidth(e, 40) + if got == "" { + t.Error("expected non-empty output") + } + if !strings.Contains(got, "…") { + t.Error("expected truncation at narrow width") + } + }) +} + diff --git a/internal/output/terminal.go b/internal/output/terminal.go new file mode 100644 index 0000000..4e2e672 --- /dev/null +++ b/internal/output/terminal.go @@ -0,0 +1,31 @@ +package output + +import ( + "os" + + "golang.org/x/term" +) + +func terminalWidth() int { + for _, fd := range []uintptr{os.Stdout.Fd(), os.Stderr.Fd()} { + if w, _, err := term.GetSize(int(fd)); err == nil && w > 0 { + return w + } + } + return 80 +} + +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + if max <= 1 { + return "…" + } + return string(r[:max-1]) + "…" +} + +func displayWidth(s string) int { + return len([]rune(s)) +} diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index dddb9af..8b782a6 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -9,6 +9,7 @@ import ( stdruntime "runtime" "strconv" "strings" + "time" "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" @@ -156,6 +157,18 @@ func (d *DockerRuntime) IsRunning(ctx context.Context, containerID string) (bool return inspect.State.Running, nil } +func (d *DockerRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { + inspect, err := d.client.ContainerInspect(ctx, containerName) + if err != nil { + return time.Time{}, fmt.Errorf("failed to inspect container: %w", err) + } + t, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse container start time: %w", err) + } + return t, nil +} + func (d *DockerRuntime) Logs(ctx context.Context, containerID string, tail int) (string, error) { options := container.LogsOptions{ ShowStdout: true, diff --git a/internal/runtime/mock_runtime.go b/internal/runtime/mock_runtime.go index 9848ca0..e620a41 100644 --- a/internal/runtime/mock_runtime.go +++ b/internal/runtime/mock_runtime.go @@ -13,6 +13,7 @@ import ( context "context" io "io" reflect "reflect" + time "time" output "github.com/localstack/lstk/internal/output" gomock "go.uber.org/mock/gomock" @@ -42,6 +43,21 @@ func (m *MockRuntime) EXPECT() *MockRuntimeMockRecorder { return m.recorder } +// ContainerStartedAt mocks base method. +func (m *MockRuntime) ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContainerStartedAt", ctx, containerName) + ret0, _ := ret[0].(time.Time) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ContainerStartedAt indicates an expected call of ContainerStartedAt. +func (mr *MockRuntimeMockRecorder) ContainerStartedAt(ctx, containerName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerStartedAt", reflect.TypeOf((*MockRuntime)(nil).ContainerStartedAt), ctx, containerName) +} + // EmitUnhealthyError mocks base method. func (m *MockRuntime) EmitUnhealthyError(sink output.Sink, err error) { m.ctrl.T.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7a54b5d..b0ec640 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -3,6 +3,7 @@ package runtime import ( "context" "io" + "time" "github.com/localstack/lstk/internal/output" ) @@ -33,6 +34,7 @@ type Runtime interface { Stop(ctx context.Context, containerName string) error Remove(ctx context.Context, containerName string) error IsRunning(ctx context.Context, containerID string) (bool, error) + ContainerStartedAt(ctx context.Context, containerName string) (time.Time, error) Logs(ctx context.Context, containerID string, tail int) (string, error) StreamLogs(ctx context.Context, containerID string, out io.Writer, follow bool) error GetImageVersion(ctx context.Context, imageName string) (string, error) diff --git a/internal/ui/app.go b/internal/ui/app.go index 3891735..180071b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -142,7 +142,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case output.MessageEvent: line := styledLine{text: components.RenderMessage(msg)} - if a.spinner.PendingStop() { + if msg.Severity == output.SeverityInfo { + blank := styledLine{text: ""} + if a.spinner.PendingStop() { + a.bufferedLines = append(a.bufferedLines, blank, line, blank) + } else { + a.lines = appendLine(a.lines, blank) + a.lines = appendLine(a.lines, line) + a.lines = appendLine(a.lines, blank) + } + } else if a.spinner.PendingStop() { a.bufferedLines = append(a.bufferedLines, line) } else { a.lines = appendLine(a.lines, line) @@ -208,9 +217,34 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.errorDisplay = a.errorDisplay.Show(output.ErrorEvent{Title: msg.err.Error()}) } return a, tea.Quit + case output.TableEvent: + if line, ok := output.FormatEventLine(msg); ok { + parts := strings.Split(line, "\n") + var lines []styledLine + if len(parts) > 0 { + lines = append(lines, styledLine{text: parts[0], secondary: true}) + } + for _, part := range parts[1:] { + lines = append(lines, styledLine{text: part}) + } + if a.spinner.PendingStop() { + a.bufferedLines = append(a.bufferedLines, lines...) + } else { + for _, l := range lines { + a.lines = appendLine(a.lines, l) + } + } + } default: if line, ok := output.FormatEventLine(msg); ok { - a.lines = appendLine(a.lines, styledLine{text: line}) + for _, part := range strings.Split(line, "\n") { + l := styledLine{text: part} + if a.spinner.PendingStop() { + a.bufferedLines = append(a.bufferedLines, l) + } else { + a.lines = appendLine(a.lines, l) + } + } } } diff --git a/internal/ui/components/input_prompt.go b/internal/ui/components/input_prompt.go index c57732a..68d4bbb 100644 --- a/internal/ui/components/input_prompt.go +++ b/internal/ui/components/input_prompt.go @@ -39,6 +39,7 @@ func (p InputPrompt) View() string { } lines := strings.Split(p.prompt, "\n") + firstLine := lines[0] var sb strings.Builder diff --git a/internal/ui/components/message.go b/internal/ui/components/message.go index 9ba2da6..f1b82f3 100644 --- a/internal/ui/components/message.go +++ b/internal/ui/components/message.go @@ -14,6 +14,8 @@ func RenderMessage(e output.MessageEvent) string { return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render(e.Text) case output.SeverityWarning: return prefix + styles.Warning.Render("Warning:") + " " + styles.Message.Render(e.Text) + case output.SeverityInfo: + return styles.Highlight.Render(e.Text) default: return styles.Message.Render(e.Text) } diff --git a/internal/ui/run_status.go b/internal/ui/run_status.go new file mode 100644 index 0000000..4ab7a02 --- /dev/null +++ b/internal/ui/run_status.go @@ -0,0 +1,48 @@ +package ui + +import ( + "context" + "errors" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +func RunStatus(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, localStackHost string) error { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + app := NewApp("", "", "", cancel, withoutHeader()) + p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout)) + runErrCh := make(chan error, 1) + + go func() { + err := container.Status(ctx, rt, containers, localStackHost, output.NewTUISink(programSender{p: p})) + if err != nil && !errors.Is(err, context.Canceled) { + p.Send(runErrMsg{err: err}) + } else { + p.Send(runDoneMsg{}) + } + runErrCh <- err + }() + + model, err := p.Run() + if err != nil { + return err + } + + if app, ok := model.(App); ok && app.Err() != nil { + return output.NewSilentError(app.Err()) + } + + runErr := <-runErrCh + if runErr != nil && !errors.Is(runErr, context.Canceled) { + return runErr + } + + return nil +} diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 5146fd7..2dbf67f 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -10,6 +10,7 @@ type Key string const ( AuthToken Key = "LOCALSTACK_AUTH_TOKEN" + LocalStackHost Key = "LOCALSTACK_HOST" APIEndpoint Key = "LSTK_API_ENDPOINT" Keyring Key = "LSTK_KEYRING" CI Key = "CI" diff --git a/test/integration/status_test.go b/test/integration/status_test.go new file mode 100644 index 0000000..42adee1 --- /dev/null +++ b/test/integration/status_test.go @@ -0,0 +1,93 @@ +package integration_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusCommandFailsWhenNotRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + stdout, _, err := runLstk(t, testContext(t), "", nil, "status") + require.Error(t, err, "expected lstk status to fail when emulator not running") + assert.Contains(t, stdout, "is not running") + assert.Contains(t, stdout, "Start LocalStack:") + assert.Contains(t, stdout, "See help:") +} + +func TestStatusCommandShowsResourcesWhenRunning(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + // Mock the LocalStack HTTP API so we can test resource display without a real LocalStack instance. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1", "services": {}}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"AWS::S3::Bucket": [{"region_name": "global", "account_id": "000000000000", "id": "my-bucket"}]}`) + _, _ = fmt.Fprintln(w, `{"AWS::Lambda::Function": [{"region_name": "us-east-1", "account_id": "000000000000", "id": "my-function"}]}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host), "status") + require.NoError(t, err, "lstk status failed: %s", stderr) + assert.Contains(t, stdout, "running") + assert.Contains(t, stdout, "4.14.1") + assert.Contains(t, stdout, "2 resources") + assert.Contains(t, stdout, "2 services") + assert.Contains(t, stdout, "SERVICE") + assert.Contains(t, stdout, "RESOURCE") + assert.Contains(t, stdout, "S3") + assert.Contains(t, stdout, "my-bucket") + assert.Contains(t, stdout, "Lambda") + assert.Contains(t, stdout, "my-function") +} + +func TestStatusCommandShowsNoResourcesWhenEmpty(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_localstack/health": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"version": "4.14.1", "services": {}}`) + case "/_localstack/resources": + w.Header().Set("Content-Type", "application/x-ndjson") + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "http://") + + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host), "status") + require.NoError(t, err, "lstk status failed: %s", stderr) + assert.Contains(t, stdout, "No resources deployed") +}