-
Notifications
You must be signed in to change notification settings - Fork 0
New status command #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
New status command #100
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)) | ||
| }, | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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) | ||
|
|
||
| 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 | ||
| } | ||
| 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)) | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find and examine the file in question
find . -name "client.go" -path "*/emulator/aws/*" -type fRepository: 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 -80Repository: localstack/lstk Length of output: 2791 🏁 Script executed: # Get the full context of the FetchResources function
cat -n internal/emulator/aws/client.goRepository: localstack/lstk Length of output: 3800 Increase or remove the 1 MiB buffer ceiling on NDJSON resource lines.
🤖 Prompt for AI Agents |
||
|
|
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle
ResolveHostfailures explicitly.Ignoring the error here can leave
hostempty and turns a local endpoint/config problem into a later fetch failure oris running ()output. Fail fast before calling the emulator endpoints.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents